From a737512847699aa5c3138c2368370faac52bf46b Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 16:54:41 -0700 Subject: [PATCH 1/7] query: pin ChainView at a buried best-chain hash As-of reads need a snapshot of an ancestor, not only the live tip. pin_chain_view_at uses height_of_hash; still_live stays true while that height's confirmed header_fk is unchanged (tip extension is fine). --- crates/rbitcoin-query/src/chain_view.rs | 15 +++++++++ crates/rbitcoin-query/src/lib.rs | 44 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/rbitcoin-query/src/chain_view.rs b/crates/rbitcoin-query/src/chain_view.rs index 33f290618..5cc4ddd07 100644 --- a/crates/rbitcoin-query/src/chain_view.rs +++ b/crates/rbitcoin-query/src/chain_view.rs @@ -29,10 +29,25 @@ impl Query { let Some(height) = self.tip_height() else { return Ok(None); }; + let Some((_, rec)) = self.header_at_height(height)? else { + return Ok(None); + }; + self.pin_chain_view_at(&rec.hash) + } + + /// Pin a **best-chain** header hash (tip or buried). `None` if it is not + /// `confirmed[height]` (unknown, archive-only orphan, or disconnected). + pub fn pin_chain_view_at(&self, hash: &[u8; 32]) -> Result, QueryError> { + let Some(height) = self.height_of_hash(hash)? else { + return Ok(None); + }; let Some(header_fk) = self.store.confirmed.get(height)? else { return Ok(None); }; let rec = self.store.get_header(header_fk)?; + if rec.hash != *hash { + return Ok(None); + } Ok(Some(ChainView { height, hash: rec.hash, diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index 4d794fffe..ebce9b5fe 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -2714,6 +2714,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn chain_view_at_buried_pin_survives_tip_extension_and_higher_replace() { + let (dir, q) = temp_query("chain-view-at"); + let (h0, t0) = coinbase_block(0, Fk::NULL, None); + let hash0 = h0.hash; + q.connect_block(Height(0), &h0, &[t0]).unwrap(); + assert!(q.pin_chain_view_at(&[0xee; 32]).unwrap().is_none()); + + let prev_fk = q.tip_header_fk().unwrap().unwrap(); + let (h1, t1) = coinbase_block(1, prev_fk, Some(hash0)); + q.connect_block(Height(1), &h1, &[t1]).unwrap(); + let prev1 = q.tip_header_fk().unwrap().unwrap(); + let (h2, t2) = coinbase_block(2, prev1, Some(h1.hash)); + q.connect_block(Height(2), &h2, &[t2]).unwrap(); + + let buried = q.pin_chain_view_at(&hash0).unwrap().expect("genesis hash"); + assert_eq!(buried.height, Height(0)); + assert_eq!(buried.hash, hash0); + assert!(buried.still_live(&q).unwrap()); + + q.disconnect_tip().unwrap(); + assert_eq!(q.tip_height(), Some(Height(1))); + assert!( + buried.still_live(&q).unwrap(), + "disconnect of height 2 must not kill a height-0 pin" + ); + assert_eq!( + q.pin_chain_view_at(&hash0).unwrap().unwrap().header_fk, + buried.header_fk + ); + + q.disconnect_tip().unwrap(); + assert_eq!(q.tip_height(), Some(Height(0))); + assert!(buried.still_live(&q).unwrap()); + + q.disconnect_tip().unwrap(); + assert!( + !buried.still_live(&q).unwrap(), + "disconnect of the pinned height kills the buried view" + ); + assert!(q.pin_chain_view_at(&hash0).unwrap().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn chain_view_sh_join_slot_miss_on_same_height_replace() { let (dir, q) = temp_query("chain-view-sh-slot"); From 44893d9074fec7ff3ae1dbefa6cf847640f70ea1 Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 16:56:18 -0700 Subject: [PATCH 2/7] query: as-of spentness and SH balance/utxo under a ChainView A spend at height N is invisible as of N-1 because spentness is annotation plus spender confirmed-strong, not a mutated UTXO set. has_confirmed_strong_spender_at and listunspent_in/balance_in take that height clamp. --- crates/rbitcoin-query/src/lib.rs | 90 ++++++++++++++++++++++++- crates/rbitcoin-query/src/scripthash.rs | 33 ++++++++- crates/rbitcoin-store/src/store.rs | 21 ++++++ 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index ebce9b5fe..2a2b561d1 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -1407,7 +1407,20 @@ impl Query { /// Does **not** treat archive-only point rows as spent: Class A may write /// edges before Class C; those spenders are not strong yet. pub fn is_outpoint_spent(&self, txid: &[u8; 32], vout: u32) -> Result { - Ok(self.store.has_confirmed_strong_spender(txid, vout)?) + let tip = self.tip_height().map(|h| h.0); + self.is_outpoint_spent_at(txid, vout, tip) + } + + /// Spentness as of a confirmed height (`None` = empty chain). + pub fn is_outpoint_spent_at( + &self, + txid: &[u8; 32], + vout: u32, + tip: Option, + ) -> Result { + Ok(self + .store + .has_confirmed_strong_spender_at(txid, vout, tip)?) } /// Spentness by known create fk (confirm pin path — no head probe). @@ -2758,6 +2771,81 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn chain_view_at_spend_asof_hides_later_spend() { + let (dir, q) = temp_query("chain-view-asof-spend"); + let (h0, mut ta0) = coinbase_block(0, Fk::NULL, None); + ta0.outputs = vec![OutputRecord::unspent(10_0000_0000, vec![0x51])]; + let create_txid = ta0.tx.txid; + let hash0 = h0.hash; + let hfk0 = q.connect_block(Height(0), &h0, &[ta0]).unwrap(); + let create_fk = q.block_tx_fks(Height(0)).unwrap()[0]; + let view0 = q.pin_chain_view_at(&hash0).unwrap().unwrap(); + + let mut spend_txid = [0u8; 32]; + spend_txid[0] = 0x11; + spend_txid[31] = 0xcd; + let hash1 = rbitcoin_store::block_header_hash(1, &hash0, &[0x11; 32], 2, 0x207fffff, 1); + let h1 = HeaderRecord { + prev_fk: hfk0, + version: 1, + timestamp: 2, + bits: 0x207fffff, + nonce: 1, + merkle_root: [0x11; 32], + hash: hash1, + }; + q.connect_block( + Height(1), + &h1, + &[TxApply { + tx: TxRecord { + txid: spend_txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord { + prev_txid: create_txid, + create_fk, + prev_index: 0, + sequence: u32::MAX, + script_sig: vec![], + witness: vec![], + }], + outputs: vec![OutputRecord::unspent(9_0000_0000, vec![0x00])], + }], + ) + .unwrap(); + let view1 = q.pin_chain_view_at(&hash1).unwrap().unwrap(); + let sh = script_hash(&[0x51]); + + assert!(!q.is_outpoint_spent_at(&create_txid, 0, Some(0)).unwrap()); + assert!(q.is_outpoint_spent_at(&create_txid, 0, Some(1)).unwrap()); + assert!(q.is_outpoint_spent(&create_txid, 0).unwrap()); + + let utxo0 = q.scripthash_listunspent_in(&sh, &view0).unwrap(); + assert_eq!(utxo0.len(), 1); + assert_eq!(utxo0[0].tx_hash, create_txid); + assert_eq!(utxo0[0].value, 10_0000_0000); + let bal0 = q.scripthash_balance_in(&sh, &view0).unwrap(); + assert_eq!(bal0.confirmed, 10_0000_0000); + let hist0 = q.scripthash_history_in(&sh, &view0).unwrap(); + assert_eq!(hist0.len(), 1); + assert_eq!(hist0[0].txid, create_txid); + + let utxo1 = q.scripthash_listunspent_in(&sh, &view1).unwrap(); + assert!(utxo1.is_empty(), "spend at height 1 is visible as of 1"); + let bal1 = q.scripthash_balance_in(&sh, &view1).unwrap(); + assert_eq!(bal1.confirmed, 0); + let hist1 = q.scripthash_history_in(&sh, &view1).unwrap(); + assert_eq!(hist1.len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn chain_view_sh_join_slot_miss_on_same_height_replace() { let (dir, q) = temp_query("chain-view-sh-slot"); diff --git a/crates/rbitcoin-query/src/scripthash.rs b/crates/rbitcoin-query/src/scripthash.rs index 3ff4c6512..83c029c93 100644 --- a/crates/rbitcoin-query/src/scripthash.rs +++ b/crates/rbitcoin-query/src/scripthash.rs @@ -877,7 +877,16 @@ impl Query { unconfirmed: 0, }); }; - let joined = self.join_creates_and_spends(scripthash, ShJoinNeed::BALANCE, None, &view)?; + self.scripthash_balance_in(scripthash, &view) + } + + /// Confirmed balance as of `view`. + pub fn scripthash_balance_in( + &self, + scripthash: &[u8; 32], + view: &ChainView, + ) -> Result { + let joined = self.join_creates_and_spends(scripthash, ShJoinNeed::BALANCE, None, view)?; self.balance_from_joined(&joined) } @@ -956,8 +965,17 @@ impl Query { let Some(view) = self.pin_chain_view()? else { return Ok(Vec::new()); }; + self.scripthash_listunspent_in(scripthash, &view) + } + + /// Confirmed UTXOs as of `view`. + pub fn scripthash_listunspent_in( + &self, + scripthash: &[u8; 32], + view: &ChainView, + ) -> Result, QueryError> { let mut joined = - self.join_creates_and_spends(scripthash, ShJoinNeed::LISTUNSPENT, None, &view)?; + self.join_creates_and_spends(scripthash, ShJoinNeed::LISTUNSPENT, None, view)?; self.fill_create_txids(&mut joined, true)?; self.listunspent_from_joined(&joined) } @@ -1064,8 +1082,17 @@ impl Query { spent_txo_sum: 0, }); }; + self.scripthash_chain_stats_in(scripthash, &view) + } + + /// Confirmed chain_stats as of `view`. + pub fn scripthash_chain_stats_in( + &self, + scripthash: &[u8; 32], + view: &ChainView, + ) -> Result { let joined = - self.join_creates_and_spends(scripthash, ShJoinNeed::CHAIN_STATS, None, &view)?; + self.join_creates_and_spends(scripthash, ShJoinNeed::CHAIN_STATS, None, view)?; self.chain_stats_from_joined(&joined) } diff --git a/crates/rbitcoin-store/src/store.rs b/crates/rbitcoin-store/src/store.rs index d36b6d596..8c946ecf8 100644 --- a/crates/rbitcoin-store/src/store.rs +++ b/crates/rbitcoin-store/src/store.rs @@ -924,6 +924,17 @@ impl Store { body_range: Option<(u64, u64)>, ) -> Result { let tip = self.confirmed.tip_height().map(|t| t.0); + self.has_confirmed_strong_spender_create_at(create_tx_fk, out_index, body_range, tip) + } + + /// Like [`Self::has_confirmed_strong_spender_create`] with a caller-cached tip. + pub fn has_confirmed_strong_spender_create_at( + &self, + create_tx_fk: Fk, + out_index: u32, + body_range: Option<(u64, u64)>, + tip: Option, + ) -> Result { let (multi, field) = match body_range { Some((off, len)) => self.txs.get_output_spender_meta_at(off, len, out_index)?, None => self.txs.get_output_spender_meta(create_tx_fk, out_index)?, @@ -1046,6 +1057,16 @@ impl Store { out_index: u32, ) -> Result { let tip = self.confirmed.tip_height().map(|t| t.0); + self.has_confirmed_strong_spender_at(out_txid, out_index, tip) + } + + /// Like [`Self::has_confirmed_strong_spender`] with a caller-cached tip. + pub fn has_confirmed_strong_spender_at( + &self, + out_txid: &[u8; 32], + out_index: u32, + tip: Option, + ) -> Result { let Some((create_fk, _)) = self.txs.get_by_txid(out_txid)? else { return Ok(false); }; From bafee077f910025015c131c1c6581ec916f6f7dc Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 16:59:33 -0700 Subject: [PATCH 3/7] electrum: trailing asof block hash on history/balance/listunspent Confirmed rows are joined at that still-live ancestor; mempool is off. Unknown hash is 'asof not on chain'. server.features.asof advertises it. The stamped chain_tip is the asof block, not the live tip. --- crates/rbitcoin-electrum/src/server.rs | 386 ++++++++++++++++++++++--- 1 file changed, 352 insertions(+), 34 deletions(-) diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index b283b4f2e..1ec3156c4 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -521,7 +521,7 @@ where let stamp = method_stamps_chain_tip(&method_owned); match tokio::task::spawn_blocking(move || { let (r, view) = if stamp { - electrum_at_chain_view(&q, |q| { + electrum_at_chain_view(&q, &method_owned, ¶ms_owned, |q| { dispatch_with_join( &method_owned, ¶ms_owned, @@ -849,30 +849,52 @@ fn rpc_result(id: &Value, result: &Value, view: Option<&ChainView>) -> Value { obj } -fn electrum_at_chain_view(query: &Query, mut f: F) -> (Result, Option) +fn electrum_at_chain_view( + query: &Query, + method: &str, + params: &Value, + mut f: F, +) -> (Result, Option) where F: FnMut(&Query) -> Result, { - const BOUND: u32 = 8; - for _ in 0..BOUND { - let view = match query.pin_chain_view() { - Ok(v) => v, - Err(e) => return (Err(e.to_string()), None), - }; - let Some(view) = view else { - return (f(query), None); - }; - let out = f(query); - if out.is_err() { - return (out, None); - } - match view.still_live(query) { - Ok(true) => return (out, Some(view)), - Ok(false) => continue, - Err(e) => return (Err(e.to_string()), None), + match take_trailing_asof(method, params) { + Ok((_, Some(hash))) => match query.pin_chain_view_at(&hash) { + Ok(Some(view)) => { + let out = f(query); + match view.still_live(query) { + Ok(true) => (out, Some(view)), + Ok(false) => (Err("asof not on chain".into()), None), + Err(e) => (Err(e.to_string()), None), + } + } + Ok(None) => (Err("asof not on chain".into()), None), + Err(e) => (Err(e.to_string()), None), + }, + Ok((_, None)) => { + const BOUND: u32 = 8; + for _ in 0..BOUND { + let view = match query.pin_chain_view() { + Ok(v) => v, + Err(e) => return (Err(e.to_string()), None), + }; + let Some(view) = view else { + return (f(query), None); + }; + let out = f(query); + if out.is_err() { + return (out, None); + } + match view.still_live(query) { + Ok(true) => return (out, Some(view)), + Ok(false) => continue, + Err(e) => return (Err(e.to_string()), None), + } + } + (Err("chain view moved".into()), None) } + Err(e) => (Err(e), None), } - (Err("chain view moved".into()), None) } #[cfg(test)] @@ -923,6 +945,7 @@ fn dispatch_with_join( "silent_payments": [0], "tweaks": true, "chain_tip": true, + "asof": true, })), "blockchain.headers.subscribe" => { *header_sub = true; @@ -952,11 +975,23 @@ fn dispatch_with_join( Ok(json!({"count": n, "hex": hexes, "max": 2016})) } "blockchain.scripthash.get_history" => { - let sh = param_scripthash(params, 0)?; - let (filter, include_mempool) = parse_get_history_window(params)?; - let mut hist = query - .scripthash_history_filtered_slot(&sh, &filter, sh_join) - .map_err(|e| e.to_string())?; + let (params, asof) = take_trailing_asof(method, params)?; + let sh = param_scripthash(¶ms, 0)?; + let (filter, mut include_mempool) = parse_get_history_window(¶ms)?; + let mut hist = if let Some(hash) = asof { + include_mempool = false; + let view = query + .pin_chain_view_at(&hash) + .map_err(|e| e.to_string())? + .ok_or_else(|| "asof not on chain".to_string())?; + query + .scripthash_history_filtered_in(&sh, &filter, &view) + .map_err(|e| e.to_string())? + } else { + query + .scripthash_history_filtered_slot(&sh, &filter, sh_join) + .map_err(|e| e.to_string())? + }; // Confirmed rows are height-asc from the filter. Mempool (if any) is // appended as a tail — Electrum Cash: only when to_height is -1/omitted. if include_mempool { @@ -982,20 +1017,43 @@ fn dispatch_with_join( Ok(Value::Array(arr)) } "blockchain.scripthash.get_balance" => { - let sh = param_scripthash(params, 0)?; - let mut b = query - .scripthash_balance_slot(&sh, sh_join) - .map_err(|e| e.to_string())?; - if let Some(mp) = mempool { - b.unconfirmed = mp.scripthash_unconfirmed_delta(&sh); + let (params, asof) = take_trailing_asof(method, params)?; + let sh = param_scripthash(¶ms, 0)?; + let mut b = if let Some(hash) = asof { + let view = query + .pin_chain_view_at(&hash) + .map_err(|e| e.to_string())? + .ok_or_else(|| "asof not on chain".to_string())?; + query + .scripthash_balance_in(&sh, &view) + .map_err(|e| e.to_string())? + } else { + query + .scripthash_balance_slot(&sh, sh_join) + .map_err(|e| e.to_string())? + }; + if asof.is_none() { + if let Some(mp) = mempool { + b.unconfirmed = mp.scripthash_unconfirmed_delta(&sh); + } } Ok(json!({"confirmed": b.confirmed, "unconfirmed": b.unconfirmed})) } "blockchain.scripthash.listunspent" => { - let sh = param_scripthash(params, 0)?; - let u = + let (params, asof) = take_trailing_asof(method, params)?; + let sh = param_scripthash(¶ms, 0)?; + let u = if let Some(hash) = asof { + let view = query + .pin_chain_view_at(&hash) + .map_err(|e| e.to_string())? + .ok_or_else(|| "asof not on chain".to_string())?; + query + .scripthash_listunspent_in(&sh, &view) + .map_err(|e| e.to_string())? + } else { crate::unspent::scripthash_utxos_with_mempool_slot(query, mempool, &sh, sh_join) - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())? + }; let arr: Vec = u .iter() .map(|x| { @@ -1196,6 +1254,52 @@ fn param_i64(params: &Value, idx: usize) -> Result { .ok_or_else(|| format!("param {idx} expected integer")) } +fn method_accepts_asof(method: &str) -> bool { + matches!( + method, + "blockchain.scripthash.get_history" + | "blockchain.scripthash.get_balance" + | "blockchain.scripthash.listunspent" + ) +} + +fn parse_blockhash32(s: &str) -> Option<[u8; 32]> { + let mut bytes = rbitcoin_primitives::hex_decode(s).ok()?; + if bytes.len() != 32 { + return None; + } + bytes.reverse(); + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Some(out) +} + +/// Trailing 64-hex is an asof block hash (display order). Never the sole param +/// (that is the scripthash). +fn take_trailing_asof(method: &str, params: &Value) -> Result<(Value, Option<[u8; 32]>), String> { + if !method_accepts_asof(method) { + return Ok((params.clone(), None)); + } + let Some(arr) = params.as_array() else { + return Ok((params.clone(), None)); + }; + if arr.len() < 2 { + return Ok((params.clone(), None)); + } + let Some(last) = arr.last().and_then(|v| v.as_str()) else { + return Ok((params.clone(), None)); + }; + if last.len() != 64 { + return Ok((params.clone(), None)); + } + let Some(hash) = parse_blockhash32(last) else { + return Err("asof must be 32 bytes hex".into()); + }; + let mut rest = arr.clone(); + rest.pop(); + Ok((Value::Array(rest), Some(hash))) +} + /// Electrum Cash optional height window after scripthash for `get_history`. /// /// Returns `(confirmed HistoryFilter, include_mempool)`. @@ -1396,6 +1500,18 @@ mod tests { assert_eq!(f.from_height, 1); assert_eq!(f.to_height, Some(10)); assert!(!mp); + let asof_hex = "ab".repeat(32); + let (rest, h) = take_trailing_asof( + "blockchain.scripthash.get_balance", + &json!([sh_hex, asof_hex]), + ) + .unwrap(); + assert!(h.is_some()); + assert_eq!(rest, json!([sh_hex])); + let (_, none) = + take_trailing_asof("blockchain.scripthash.get_balance", &json!([sh_hex])).unwrap(); + assert!(none.is_none()); + assert!(parse_get_history_window(&json!([sh_hex, 10, 5])) .unwrap_err() .contains("from_height")); @@ -1510,6 +1626,7 @@ mod tests { assert_eq!(features["silent_payments"], json!([0])); assert_eq!(features["tweaks"], json!(true)); assert_eq!(features["chain_tip"], json!(true)); + assert_eq!(features["asof"], json!(true)); let probe = dispatch( "blockchain.tweaks.subscribe", @@ -2332,6 +2449,207 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn asof_scripthash_reads_hide_later_spend() { + use rbitcoin_primitives::{Fk, Height}; + use rbitcoin_query::TxApply; + use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; + + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + let cfg = ElectrumConfig::for_params("127.0.0.1:0".parse().unwrap(), ¶ms); + let merkle = [0xab; 32]; + let h0 = HeaderRecord { + prev_fk: Fk::NULL, + version: 1, + timestamp: 1, + bits: 0x207fffff, + nonce: 0, + merkle_root: merkle, + hash: merkle, + }; + let mut create_txid = [0xcb; 32]; + create_txid[31] = 0; + let ta0 = TxApply { + tx: TxRecord { + txid: create_txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord { + prev_txid: [0u8; 32], + create_fk: Fk::NULL, + prev_index: u32::MAX, + sequence: u32::MAX, + script_sig: vec![0], + witness: vec![], + }], + outputs: vec![OutputRecord::unspent(10_0000_0000, vec![0x51])], + }; + let hfk0 = q.connect_block(Height(0), &h0, &[ta0]).unwrap(); + let create_fk = q.block_tx_fks(Height(0)).unwrap()[0]; + let hash1 = rbitcoin_store::block_header_hash(1, &merkle, &[0x11; 32], 2, 0x207fffff, 1); + let h1 = HeaderRecord { + prev_fk: hfk0, + version: 1, + timestamp: 2, + bits: 0x207fffff, + nonce: 1, + merkle_root: [0x11; 32], + hash: hash1, + }; + let mut spend_txid = [0u8; 32]; + spend_txid[0] = 0x11; + spend_txid[31] = 0xcd; + q.connect_block( + Height(1), + &h1, + &[TxApply { + tx: TxRecord { + txid: spend_txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord { + prev_txid: create_txid, + create_fk, + prev_index: 0, + sequence: u32::MAX, + script_sig: vec![], + witness: vec![], + }], + outputs: vec![OutputRecord::unspent(9_0000_0000, vec![0x00])], + }], + ) + .unwrap(); + + let sh = electrum_scripthash_hex(&[0x51]); + let asof0 = hash_hex_rev(&merkle); + let asof1 = hash_hex_rev(&hash1); + let mut header_sub = false; + let mut sh_subs = HashSet::new(); + + let bal0 = dispatch( + "blockchain.scripthash.get_balance", + &json!([sh, asof0]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!(bal0["confirmed"], 10_0000_0000); + assert_eq!(bal0["unconfirmed"], 0); + let utxo0 = dispatch( + "blockchain.scripthash.listunspent", + &json!([sh, asof0]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!(utxo0.as_array().unwrap().len(), 1); + let hist0 = dispatch( + "blockchain.scripthash.get_history", + &json!([sh, asof0]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!(hist0.as_array().unwrap().len(), 1); + + let bal1 = dispatch( + "blockchain.scripthash.get_balance", + &json!([sh, asof1]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!(bal1["confirmed"], 0); + let utxo1 = dispatch( + "blockchain.scripthash.listunspent", + &json!([sh, asof1]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert!(utxo1.as_array().unwrap().is_empty()); + let hist1 = dispatch( + "blockchain.scripthash.get_history", + &json!([sh, asof1]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!(hist1.as_array().unwrap().len(), 2); + + let err = dispatch( + "blockchain.scripthash.get_balance", + &json!([sh, "ee".repeat(32)]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap_err(); + assert!(err.contains("asof not on chain"), "unknown asof: {err}"); + let (out, view) = electrum_at_chain_view( + &q, + "blockchain.scripthash.get_balance", + &json!([sh, asof0]), + |q| { + let mut hs = false; + let mut subs = HashSet::new(); + let mut slot = None; + dispatch_with_join( + "blockchain.scripthash.get_balance", + &json!([sh, asof0]), + q, + &cfg, + ¶ms, + None, + &mut hs, + &mut subs, + &mut slot, + ) + }, + ); + assert_eq!(out.unwrap()["confirmed"], 10_0000_0000); + assert_eq!(view.unwrap().hash, merkle); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn dispatch_casa_sequence_reuses_sh_join_slot() { use rbitcoin_primitives::{Fk, Height}; From 9b069a3ebbf06db868aab1f035832e89addb09c5 Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 17:04:46 -0700 Subject: [PATCH 4/7] esplora: ?asof= block hash on utxo/stats/txs/status/outspend Wallet reads join at that still-live ancestor. Mempool is off. Response headers are the asof hash, not the live tip. Unknown or disconnected asof is 404 (no retry onto another block at the same height). --- crates/rbitcoin-esplora/src/handlers.rs | 359 +++++++++++++++++------- crates/rbitcoin-esplora/src/lib.rs | 4 +- crates/rbitcoin-esplora/src/server.rs | 182 +++++++++++- crates/rbitcoin-esplora/src/tx_json.rs | 17 +- crates/rbitcoin-query/src/lib.rs | 9 + crates/rbitcoin-store/src/store.rs | 10 + 6 files changed, 470 insertions(+), 111 deletions(-) diff --git a/crates/rbitcoin-esplora/src/handlers.rs b/crates/rbitcoin-esplora/src/handlers.rs index 7dc21b9ff..6ce141a61 100644 --- a/crates/rbitcoin-esplora/src/handlers.rs +++ b/crates/rbitcoin-esplora/src/handlers.rs @@ -1,9 +1,12 @@ //! Esplora route handlers beyond tip/header/basic tx. -use crate::server::{block_hash_hex, not_found, parse_hash32, plain_ok, store_err, AppState}; -use crate::tx_json::{build_tx_json, history_items_to_tx_json, tx_status_json, utxo_list_json}; +use crate::server::{ + block_hash_hex, not_found, parse_asof_param, parse_hash32, plain_ok, store_err, AppState, + AsOfQuery, +}; +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::extract::{Path, Query as AxumQuery, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; @@ -13,7 +16,7 @@ use bitcoin::hashes::Hash; use bitcoin::pow::{CompactTarget, Target}; use bitcoin::{MerkleBlock, Network}; use rbitcoin_primitives::{median_time_past_times, Fk, Height}; -use rbitcoin_query::{HistoryFilter, Query}; +use rbitcoin_query::{ChainView, HistoryFilter, Query}; use rbitcoin_store::script_hash; use serde_json::{json, Value}; use std::str::FromStr; @@ -431,7 +434,12 @@ fn tx_merkleblock_proof_sync(st: AppState, txid_hex: String) -> Response { pub async fn tx_outspend( State(st): State, Path((txid_hex, vout)): Path<(String, u32)>, + AxumQuery(asof): AxumQuery, ) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; spawn_join(move || { let Ok(txid) = parse_hash32(&txid_hex) else { return not_found(); @@ -439,7 +447,7 @@ pub async fn tx_outspend( if st.query.tx_fk_by_txid(&txid).ok().flatten().is_none() { return not_found(); } - match outspend_json(&st.query, &txid, vout) { + match outspend_json(&st.query, &txid, vout, asof) { Ok(v) => Json(v).into_response(), Err(e) => store_err(e), } @@ -447,7 +455,15 @@ pub async fn tx_outspend( .await } -pub async fn tx_outspends(State(st): State, Path(txid_hex): Path) -> Response { +pub async fn tx_outspends( + State(st): State, + Path(txid_hex): Path, + AxumQuery(asof): AxumQuery, +) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; spawn_join(move || { let Ok(txid) = parse_hash32(&txid_hex) else { return not_found(); @@ -464,7 +480,7 @@ pub async fn tx_outspends(State(st): State, Path(txid_hex): Path arr.push(v), Err(e) => return store_err(e), } @@ -478,14 +494,24 @@ fn outspend_json( query: &Query, txid: &[u8; 32], vout: u32, + asof: Option<[u8; 32]>, ) -> Result { - let spenders = query.spenders(txid, vout)?; + let view = match asof { + Some(hash) => query + .pin_chain_view_at(&hash)? + .ok_or(rbitcoin_store::StoreError::NotFound)?, + None => match query.pin_chain_view()? { + Some(v) => v, + None => return Ok(json!({ "spent": false })), + }, + }; + let spenders = query.spenders_at(txid, vout, Some(view.height.0))?; if spenders.is_empty() { return Ok(json!({ "spent": false })); } let p = &spenders[0]; let spend_txid = query.store().txs.body_txid(p.spending_tx_fk)?; - let status = tx_status_json(query, p.spending_tx_fk)?; + let status = tx_status_json_in(query, p.spending_tx_fk, &view)?; Ok(json!({ "spent": true, "txid": block_hash_hex(&spend_txid), @@ -501,11 +527,30 @@ pub(crate) async fn spawn_join(f: impl FnOnce() -> Response + Send + 'static) -> } } -pub async fn address_info(State(st): State, Path(addr_s): Path) -> Response { +fn asof_view(query: &Query, asof: Option<[u8; 32]>) -> Result, Response> { + let Some(hash) = asof else { + return Ok(None); + }; + match query.pin_chain_view_at(&hash) { + Ok(Some(v)) => Ok(Some(v)), + Ok(None) => Err(not_found()), + Err(e) => Err(store_err(e)), + } +} + +pub async fn address_info( + State(st): State, + Path(addr_s): Path, + AxumQuery(asof): AxumQuery, +) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; match resolve_address_sh(&addr_s, st.network) { Ok(sh) => { spawn_join( - move || match sh_stats_json(&st, &sh, Some(addr_s.as_str()), None) { + move || match sh_stats_json(&st, &sh, Some(addr_s.as_str()), None, asof) { Ok(v) => Json(v).into_response(), Err(e) => store_err(e), }, @@ -516,12 +561,20 @@ pub async fn address_info(State(st): State, Path(addr_s): Path } } -pub async fn scripthash_info(State(st): State, Path(sh_hex): Path) -> Response { +pub async fn scripthash_info( + State(st): State, + Path(sh_hex): Path, + AxumQuery(asof): AxumQuery, +) -> Response { let Ok(sh) = parse_hash32(&sh_hex) else { return not_found(); }; + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; spawn_join( - move || match sh_stats_json(&st, &sh, None, Some(sh_hex.as_str())) { + move || match sh_stats_json(&st, &sh, None, Some(sh_hex.as_str()), asof) { Ok(v) => Json(v).into_response(), Err(e) => store_err(e), }, @@ -529,35 +582,61 @@ pub async fn scripthash_info(State(st): State, Path(sh_hex): Path, Path(addr_s): Path) -> Response { +pub async fn address_utxo( + State(st): State, + Path(addr_s): Path, + AxumQuery(asof): AxumQuery, +) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; match resolve_address_sh(&addr_s, st.network) { - Ok(sh) => spawn_join(move || utxo_response(&st, &sh)).await, + Ok(sh) => spawn_join(move || utxo_response(&st, &sh, asof)).await, Err(_) => not_found(), } } -pub async fn scripthash_utxo(State(st): State, Path(sh_hex): Path) -> Response { +pub async fn scripthash_utxo( + State(st): State, + Path(sh_hex): Path, + AxumQuery(asof): AxumQuery, +) -> Response { let Ok(sh) = parse_hash32(&sh_hex) else { return not_found(); }; - spawn_join(move || utxo_response(&st, &sh)).await + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; + spawn_join(move || utxo_response(&st, &sh, asof)).await } -fn utxo_response(st: &AppState, sh: &[u8; 32]) -> Response { - st.with_sh_join(|slot| { - match rbitcoin_electrum::scripthash_utxos_with_mempool_slot( - &st.query, - st.mempool.as_deref(), - sh, - slot, - ) { +fn utxo_response(st: &AppState, sh: &[u8; 32], asof: Option<[u8; 32]>) -> Response { + match asof_view(&st.query, asof) { + Ok(Some(view)) => match st.query.scripthash_listunspent_in(sh, &view) { Ok(list) => match utxo_list_json(&st.query, &list) { Ok(v) => Json(v).into_response(), Err(e) => store_err(e), }, Err(e) => store_err(e), - } - }) + }, + Ok(None) => st.with_sh_join(|slot| { + match rbitcoin_electrum::scripthash_utxos_with_mempool_slot( + &st.query, + st.mempool.as_deref(), + sh, + slot, + ) { + Ok(list) => match utxo_list_json(&st.query, &list) { + Ok(v) => Json(v).into_response(), + Err(e) => store_err(e), + }, + Err(e) => store_err(e), + } + }), + Err(resp) => resp, + } } pub(crate) fn resolve_address_sh(addr_s: &str, network: Network) -> Result<[u8; 32], ()> { @@ -571,71 +650,99 @@ fn sh_stats_json( sh: &[u8; 32], address: Option<&str>, scripthash_hex: Option<&str>, + asof: Option<[u8; 32]>, ) -> Result { - st.with_sh_join(|slot| { - let chain = st.query.scripthash_chain_stats_slot(sh, slot)?; - let chain_stats = json!({ - "tx_count": chain.tx_count, - "funded_txo_count": chain.funded_txo_count, - "funded_txo_sum": chain.funded_txo_sum, - "spent_txo_count": chain.spent_txo_count, - "spent_txo_sum": chain.spent_txo_sum, - }); - let mempool_stats = json!({ - "tx_count": 0, - "funded_txo_count": 0, - "funded_txo_sum": 0, - "spent_txo_count": 0, - "spent_txo_sum": 0, - }); - let mempool_stats = if let Some(mp) = st.mempool.as_ref() { - match rbitcoin_electrum::scripthash_mempool_stats_slot(&st.query, mp, sh, slot) { - Ok(s) => json!({ + let chain = if let Some(hash) = asof { + let view = st + .query + .pin_chain_view_at(&hash)? + .ok_or(rbitcoin_store::StoreError::NotFound)?; + st.query.scripthash_chain_stats_in(sh, &view)? + } else { + st.with_sh_join(|slot| st.query.scripthash_chain_stats_slot(sh, slot))? + }; + let chain_stats = json!({ + "tx_count": chain.tx_count, + "funded_txo_count": chain.funded_txo_count, + "funded_txo_sum": chain.funded_txo_sum, + "spent_txo_count": chain.spent_txo_count, + "spent_txo_sum": chain.spent_txo_sum, + }); + let zeros = json!({ + "tx_count": 0, + "funded_txo_count": 0, + "funded_txo_sum": 0, + "spent_txo_count": 0, + "spent_txo_sum": 0, + }); + let mempool_stats = if asof.is_some() { + zeros + } else if let Some(mp) = st.mempool.as_ref() { + st.with_sh_join(|slot| { + rbitcoin_electrum::scripthash_mempool_stats_slot(&st.query, mp, sh, slot).map(|s| { + json!({ "tx_count": s.tx_count, "funded_txo_count": s.funded_txo_count, "funded_txo_sum": s.funded_txo_sum, "spent_txo_count": s.spent_txo_count, "spent_txo_sum": s.spent_txo_sum, - }), - Err(e) => return Err(e), - } - } else { - mempool_stats - }; - let mut obj = json!({ - "chain_stats": chain_stats, - "mempool_stats": mempool_stats, - }); - if let Some(a) = address { - obj["address"] = Value::String(a.to_string()); - } - if let Some(h) = scripthash_hex { - obj["scripthash"] = Value::String(h.to_string()); - } - Ok(obj) - }) + }) + }) + })? + } else { + zeros + }; + let mut obj = json!({ + "chain_stats": chain_stats, + "mempool_stats": mempool_stats, + }); + if let Some(a) = address { + obj["address"] = Value::String(a.to_string()); + } + if let Some(h) = scripthash_hex { + obj["scripthash"] = Value::String(h.to_string()); + } + Ok(obj) } pub async fn scripthash_txs_chain( State(st): State, Path(sh_hex): Path, + AxumQuery(asof): AxumQuery, ) -> Response { - spawn_join(move || chain_page(&st, &sh_hex, None)).await + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; + spawn_join(move || chain_page(&st, &sh_hex, None, asof)).await } pub async fn scripthash_txs_chain_cursor( State(st): State, Path((sh_hex, last)): Path<(String, String)>, + AxumQuery(asof): AxumQuery, ) -> Response { let Ok(after) = parse_hash32(&last) else { return not_found(); }; - spawn_join(move || chain_page(&st, &sh_hex, Some(after))).await + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; + spawn_join(move || chain_page(&st, &sh_hex, Some(after), asof)).await } -pub async fn address_txs_chain(State(st): State, Path(addr_s): Path) -> Response { +pub async fn address_txs_chain( + State(st): State, + Path(addr_s): Path, + AxumQuery(asof): AxumQuery, +) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; match resolve_address_sh(&addr_s, st.network) { - Ok(sh) => spawn_join(move || chain_page_sh(&st, &sh, None)).await, + Ok(sh) => spawn_join(move || chain_page_sh(&st, &sh, None, asof)).await, Err(_) => not_found(), } } @@ -643,73 +750,114 @@ pub async fn address_txs_chain(State(st): State, Path(addr_s): Path, Path((addr_s, last)): Path<(String, String)>, + AxumQuery(asof): AxumQuery, ) -> Response { let Ok(after) = parse_hash32(&last) else { return not_found(); }; + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; match resolve_address_sh(&addr_s, st.network) { - Ok(sh) => spawn_join(move || chain_page_sh(&st, &sh, Some(after))).await, + Ok(sh) => spawn_join(move || chain_page_sh(&st, &sh, Some(after), asof)).await, Err(_) => not_found(), } } /// Combined `/scripthash/:h/txs` = mempool (cap 50) + first chain page. -pub async fn scripthash_txs(State(st): State, Path(sh_hex): Path) -> Response { +pub async fn scripthash_txs( + State(st): State, + Path(sh_hex): Path, + AxumQuery(asof): AxumQuery, +) -> Response { let Ok(sh) = parse_hash32(&sh_hex) else { return not_found(); }; - spawn_join(move || combined_txs(&st, &sh)).await + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; + spawn_join(move || combined_txs(&st, &sh, asof)).await } -pub async fn address_txs(State(st): State, Path(addr_s): Path) -> Response { +pub async fn address_txs( + State(st): State, + Path(addr_s): Path, + AxumQuery(asof): AxumQuery, +) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; match resolve_address_sh(&addr_s, st.network) { - Ok(sh) => spawn_join(move || combined_txs(&st, &sh)).await, + Ok(sh) => spawn_join(move || combined_txs(&st, &sh, asof)).await, Err(_) => not_found(), } } -fn chain_page(st: &AppState, sh_hex: &str, after: Option<[u8; 32]>) -> Response { +fn chain_page( + st: &AppState, + sh_hex: &str, + after: Option<[u8; 32]>, + asof: Option<[u8; 32]>, +) -> Response { let Ok(sh) = parse_hash32(sh_hex) else { return not_found(); }; - chain_page_sh(st, &sh, after) + chain_page_sh(st, &sh, after, asof) } -fn chain_page_sh(st: &AppState, sh: &[u8; 32], after: Option<[u8; 32]>) -> Response { +fn chain_page_sh( + st: &AppState, + sh: &[u8; 32], + after: Option<[u8; 32]>, + asof: Option<[u8; 32]>, +) -> Response { let filter = HistoryFilter::esplora_chain_page(after); - st.with_sh_join( - |slot| match st.query.scripthash_history_filtered_slot(sh, &filter, slot) { + match asof_view(&st.query, asof) { + Ok(Some(view)) => match st.query.scripthash_history_filtered_in(sh, &filter, &view) { Ok(items) => match history_items_to_tx_json(&st.query, &items, st.network) { Ok(v) => Json(v).into_response(), Err(e) => store_err(e), }, Err(e) => store_err(e), }, - ) + Ok(None) => st.with_sh_join(|slot| { + match st.query.scripthash_history_filtered_slot(sh, &filter, slot) { + Ok(items) => match history_items_to_tx_json(&st.query, &items, st.network) { + Ok(v) => Json(v).into_response(), + Err(e) => store_err(e), + }, + Err(e) => store_err(e), + } + }), + Err(resp) => resp, + } } -fn combined_txs(st: &AppState, sh: &[u8; 32]) -> Response { +fn combined_txs(st: &AppState, sh: &[u8; 32], asof: Option<[u8; 32]>) -> Response { 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) { - // Confirmed path shouldn't hit; mempool txs may not be in store. - if let Ok(v) = build_tx_json(&st.query, fk, st.network) { - out.push(v); - continue; + 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, + })); } - // Minimal mempool row if not in Class A store. - out.push(json!({ - "txid": block_hash_hex(&item.txid), - "status": { "confirmed": false }, - "fee": item.fee, - })); } } let filter = HistoryFilter::esplora_chain_page(None); - st.with_sh_join( - |slot| match st.query.scripthash_history_filtered_slot(sh, &filter, slot) { + match asof_view(&st.query, asof) { + Ok(Some(view)) => match st.query.scripthash_history_filtered_in(sh, &filter, &view) { Ok(items) => match history_items_to_tx_json(&st.query, &items, st.network) { Ok(chain) => { out.extend(chain); @@ -719,7 +867,20 @@ fn combined_txs(st: &AppState, sh: &[u8; 32]) -> Response { }, Err(e) => store_err(e), }, - ) + Ok(None) => st.with_sh_join(|slot| { + match st.query.scripthash_history_filtered_slot(sh, &filter, slot) { + Ok(items) => match history_items_to_tx_json(&st.query, &items, st.network) { + Ok(chain) => { + out.extend(chain); + Json(out).into_response() + } + Err(e) => store_err(e), + }, + Err(e) => store_err(e), + } + }), + Err(resp) => resp, + } } pub async fn mempool_info(State(st): State) -> Response { @@ -1125,18 +1286,18 @@ mod pure_helper_tests { }; let sh = script_hash(&[0x51]); reset_body_ok_reads(); - let info = super::sh_stats_json(&st, &sh, None, None).unwrap(); + let info = super::sh_stats_json(&st, &sh, None, None, None).unwrap(); assert_eq!(info["chain_stats"]["tx_count"], 3); let after_info = body_ok_reads(); assert_eq!(after_info, 3); - let _ = super::utxo_response(&st, &sh); + let _ = super::utxo_response(&st, &sh, None); assert_eq!( body_ok_reads(), after_info, "/utxo must reuse the last SH join" ); - let _ = super::chain_page_sh(&st, &sh, None); + let _ = super::chain_page_sh(&st, &sh, None, None); assert_eq!( body_ok_reads(), after_info, diff --git a/crates/rbitcoin-esplora/src/lib.rs b/crates/rbitcoin-esplora/src/lib.rs index 6ed02aa60..eb81deede 100644 --- a/crates/rbitcoin-esplora/src/lib.rs +++ b/crates/rbitcoin-esplora/src/lib.rs @@ -16,4 +16,6 @@ pub use server::{ run_esplora, sample_reset_perf, EsploraConfig, EsploraHandle, DEFAULT_MAX_TRACK_ADDRESSES, DEFAULT_MAX_TRACK_TXS, DEFAULT_MAX_WS_CONNECTIONS, DEFAULT_MAX_WS_MESSAGE_BYTES, }; -pub use tx_json::{build_tx_json, history_items_to_tx_json, tx_status_json, utxo_list_json}; +pub use tx_json::{ + build_tx_json, history_items_to_tx_json, tx_status_json, tx_status_json_in, utxo_list_json, +}; diff --git a/crates/rbitcoin-esplora/src/server.rs b/crates/rbitcoin-esplora/src/server.rs index c0caaeee4..6fc8378c4 100644 --- a/crates/rbitcoin-esplora/src/server.rs +++ b/crates/rbitcoin-esplora/src/server.rs @@ -1,9 +1,9 @@ //! Esplora HTTP listener (axum + tower limits) and wallet WebSocket live path. use crate::handlers; -use crate::tx_json::{build_tx_json, tx_status_json}; +use crate::tx_json::{build_tx_json, tx_status_json, tx_status_json_in}; use crate::ws; -use axum::extract::{Path, Request, State}; +use axum::extract::{Path, Query as AxumQuery, Request, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; @@ -17,6 +17,7 @@ use rbitcoin_net::{MempoolHub, TipEvent}; use rbitcoin_primitives::Height; use rbitcoin_query::{ChainView, Query, ShJoinSlot}; use rbitcoin_store::StoreError; +use serde::Deserialize; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -99,10 +100,49 @@ fn stamp_chain_view_headers(resp: &mut Response, view: &ChainView) { ); } +#[derive(Clone, Debug, Default, Deserialize)] +pub(crate) struct AsOfQuery { + pub asof: Option, +} + +pub(crate) fn parse_asof_param(q: &AsOfQuery) -> Result, ()> { + match q.asof.as_deref() { + None => Ok(None), + Some(s) => parse_hash32(s).map(Some), + } +} + +fn asof_hash_from_uri(uri: &axum::http::Uri) -> Result, ()> { + let Some(query) = uri.query() else { + return Ok(None); + }; + for pair in query.split('&') { + if let Some(v) = pair.strip_prefix("asof=") { + if v.is_empty() { + return Err(()); + } + return parse_hash32(v).map(Some); + } + } + Ok(None) +} + async fn stamp_chain_view_mw(State(st): State, req: Request, next: Next) -> Response { - let view = match st.query.pin_chain_view() { + let asof = match asof_hash_from_uri(req.uri()) { Ok(v) => v, - Err(e) => return store_err(e), + Err(()) => return not_found(), + }; + let view = if let Some(hash) = asof { + match st.query.pin_chain_view_at(&hash) { + Ok(Some(v)) => Some(v), + Ok(None) => return not_found(), + Err(e) => return store_err(e), + } + } else { + match st.query.pin_chain_view() { + Ok(v) => v, + Err(e) => return store_err(e), + } }; let mut resp = next.run(req).await; let Some(view) = view else { @@ -113,6 +153,7 @@ async fn stamp_chain_view_mw(State(st): State, req: Request, next: Nex stamp_chain_view_headers(&mut resp, &view); resp } + Ok(false) if asof.is_some() => not_found(), Ok(false) => (StatusCode::SERVICE_UNAVAILABLE, "chain view moved").into_response(), Err(e) => store_err(e), } @@ -432,16 +473,35 @@ async fn tx_hex(State(st): State, Path(txid_hex): Path) -> Res } /// `GET /tx/:txid/status` → Esplora confirmation status JSON. -async fn tx_status(State(st): State, Path(txid_hex): Path) -> Response { +async fn tx_status( + State(st): State, + Path(txid_hex): Path, + AxumQuery(asof): AxumQuery, +) -> Response { + let asof = match parse_asof_param(&asof) { + Ok(v) => v, + Err(()) => return not_found(), + }; handlers::spawn_join(move || { let Ok(txid) = parse_hash32(&txid_hex) else { return not_found(); }; match st.query.tx_fk_by_txid(&txid) { - Ok(Some(fk)) => match tx_status_json(&st.query, fk) { - Ok(v) => Json(v).into_response(), - Err(e) => store_err(e), - }, + Ok(Some(fk)) => { + let status = if let Some(hash) = asof { + match st.query.pin_chain_view_at(&hash) { + Ok(Some(view)) => tx_status_json_in(&st.query, fk, &view), + Ok(None) => return not_found(), + Err(e) => return store_err(e), + } + } else { + tx_status_json(&st.query, fk) + }; + match status { + Ok(v) => Json(v).into_response(), + Err(e) => store_err(e), + } + } Ok(None) => not_found(), Err(e) => store_err(e), } @@ -726,6 +786,110 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[tokio::test] + async fn asof_utxo_hides_later_spend() { + use rbitcoin_primitives::Fk; + use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; + + let (dir, q) = temp_query("asof-utxo"); + let (h0, mut t0) = coinbase(0, Fk::NULL, None); + t0.outputs = vec![OutputRecord::unspent(10_0000_0000, vec![0x51])]; + let create_txid = t0.tx.txid; + let hash0 = h0.hash; + let hfk0 = q.connect_block(Height(0), &h0, &[t0]).unwrap(); + let create_fk = q.block_tx_fks(Height(0)).unwrap()[0]; + let hash1 = rbitcoin_store::block_header_hash(1, &hash0, &[0x11; 32], 2, 0x207fffff, 1); + let h1 = HeaderRecord { + prev_fk: hfk0, + version: 1, + timestamp: 2, + bits: 0x207fffff, + nonce: 1, + merkle_root: [0x11; 32], + hash: hash1, + }; + let mut spend_txid = [0u8; 32]; + spend_txid[0] = 0x11; + spend_txid[31] = 0xcd; + q.connect_block( + Height(1), + &h1, + &[TxApply { + tx: TxRecord { + txid: spend_txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord { + prev_txid: create_txid, + create_fk, + prev_index: 0, + sequence: u32::MAX, + script_sig: vec![], + witness: vec![], + }], + outputs: vec![OutputRecord::unspent(9_0000_0000, vec![0x00])], + }], + ) + .unwrap(); + + let q = Arc::new(q); + let cfg = EsploraConfig::new("127.0.0.1:0".parse().unwrap()); + let handle = run_esplora(cfg, Arc::clone(&q), None, None) + .await + .expect("listen"); + let addr = handle.local_addr; + let sh = rbitcoin_store::script_hash(&[0x51]); + let sh_hex = block_hash_hex(&sh); + let asof0 = block_hash_hex(&hash0); + let asof1 = block_hash_hex(&hash1); + + let (st, raw, body) = + http_get_raw(addr, &format!("/scripthash/{sh_hex}/utxo?asof={asof0}")).await; + assert_eq!(st, 200, "asof0 body={body}"); + let utxos: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(utxos.as_array().unwrap().len(), 1); + assert_eq!( + header_value(&raw, HDR_CHAIN_TIP).as_deref(), + Some(asof0.as_str()) + ); + assert_eq!( + header_value(&raw, HDR_CHAIN_TIP_HEIGHT).as_deref(), + Some("0") + ); + + let (st, raw, body) = + http_get_raw(addr, &format!("/scripthash/{sh_hex}/utxo?asof={asof1}")).await; + assert_eq!(st, 200, "asof1 body={body}"); + let utxos: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(utxos.as_array().unwrap().is_empty()); + assert_eq!( + header_value(&raw, HDR_CHAIN_TIP).as_deref(), + Some(asof1.as_str()) + ); + + let create_hex = block_hash_hex(&create_txid); + let (st, _, body) = + http_get_raw(addr, &format!("/tx/{create_hex}/status?asof={asof0}")).await; + assert_eq!(st, 200, "status0={body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["confirmed"], true); + + let (st, _, _) = http_get_raw( + addr, + &format!("/scripthash/{sh_hex}/utxo?asof={}", "ee".repeat(32)), + ) + .await; + assert_eq!(st, 404); + + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn empty_chain_tip_is_unavailable() { let (dir, q) = temp_query("empty"); diff --git a/crates/rbitcoin-esplora/src/tx_json.rs b/crates/rbitcoin-esplora/src/tx_json.rs index 1751fad34..7e3ce881e 100644 --- a/crates/rbitcoin-esplora/src/tx_json.rs +++ b/crates/rbitcoin-esplora/src/tx_json.rs @@ -13,8 +13,21 @@ use std::collections::HashMap; /// Esplora `status` object for a Class A tx fk (confirmed or not). pub fn tx_status_json(query: &Query, tx_fk: Fk) -> Result { - let tip = query.pin_chain_view()?.map(|v| v.height.0); - let confirmed = query.store().is_confirmed_strong_at(tx_fk, tip)?; + match query.pin_chain_view()? { + Some(view) => tx_status_json_in(query, tx_fk, &view), + None => Ok(json!({ "confirmed": false })), + } +} + +/// Confirmation status as of `view`. +pub fn tx_status_json_in( + query: &Query, + tx_fk: Fk, + view: &rbitcoin_query::ChainView, +) -> Result { + let confirmed = query + .store() + .is_confirmed_strong_at(tx_fk, Some(view.height.0))?; if !confirmed { return Ok(json!({ "confirmed": false })); } diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index 2a2b561d1..fe0a3aad1 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -2129,6 +2129,15 @@ impl Query { self.store.spenders(out_txid, out_index) } + pub fn spenders_at( + &self, + out_txid: &[u8; 32], + out_index: u32, + tip: Option, + ) -> Result, QueryError> { + self.store.spenders_at(out_txid, out_index, tip) + } + pub fn spenders_raw( &self, out_txid: &[u8; 32], diff --git a/crates/rbitcoin-store/src/store.rs b/crates/rbitcoin-store/src/store.rs index 8c946ecf8..69ca95279 100644 --- a/crates/rbitcoin-store/src/store.rs +++ b/crates/rbitcoin-store/src/store.rs @@ -1094,6 +1094,16 @@ impl Store { out_index: u32, ) -> Result, StoreError> { let tip = self.confirmed.tip_height().map(|t| t.0); + self.spenders_at(out_txid, out_index, tip) + } + + /// Spenders confirmed-strong as of `tip` (`None` = none). + pub fn spenders_at( + &self, + out_txid: &[u8; 32], + out_index: u32, + tip: Option, + ) -> Result, StoreError> { let mut out = Vec::new(); for rec in self.spenders_raw(out_txid, out_index)? { if self.is_confirmed_strong_at(rec.spending_tx_fk, tip)? { From 5ebdb236f66239edba8ffd723e834a9df8699288 Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 17:05:22 -0700 Subject: [PATCH 5/7] docs: as-of ancestor snapshot on Electrum and Esplora Thank Yuval again: as-of-hash is the buried-height half of binding confirmations to a chain (mempool#6584 / electrum-protocol#2). Stamp is the asof block; a disconnected asof is 404, not a different hash. --- CHANGELOG.md | 7 +++++++ COMPAT.md | 17 ++++++++++++----- docs/concurrency.md | 3 ++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca9a9910..5ca131e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ before 1.0). and [electrum-protocol#2](https://github.com/spesmilo/electrum-protocol/pull/2). [`COMPAT.md`](COMPAT.md), [`docs/concurrency.md`](docs/concurrency.md). +- **As-of ancestor snapshot:** `?asof=` (Esplora) and a trailing + asof hash (Electrum `get_balance` / `listunspent` / `get_history`) return + confirmed UTXOs/balance/history as of a still-live best-chain block. + Thanks again to Yuval — this is the buried-height half of binding + confirmations to a chain. Stamp is the asof hash; unknown/disconnected + → 404 / `asof not on chain`. + - **Road to 1.0:** [`docs/road-to-1.0.md`](docs/road-to-1.0.md) owns 1.0 product gates (claimed Core functional, Core-parity fuzz, selected crates.io libraries, SH/RSS, eclipse/DoS, fee validation, schema freeze). diff --git a/COMPAT.md b/COMPAT.md index 5c6fd896e..e083b9340 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -77,7 +77,7 @@ Full method list, auth, and shindex matrix: **[`docs/rpc.md`](./docs/rpc.md)**. | server.version / banner / features | done | Banner: libre-relay-class. `server.version[0]` is `rbitcoin-electrs ` — **not electrs**; see below | | blockchain.tweaks.subscribe | done | Cake stream (first height as result, then notifies + `done`). Naive walk, or `--sptweaks` thin index (`len:tweak` only; one `txout` span per wave). Pre-taproot: empty maps in ≤1024-height writes. Isolate may still hardcode `electrs.cakewallet.com` | | headers / block headers | done | Tip push on subscribe | -| scripthash history / balance / listunspent | done | Unconf when mempool attached; `get_history` optional BCH-style `from_height` / exclusive `to_height` (`-1` = tip + mempool); 1-arg = full history; **subscribe status always full**; `listunspent` loads `txid.body` only for unspent creates; one TCP connection reuses the last SH outs+spent join until tip **hash** changes. Confirmed methods stamp `chain_tip` / `chain_tip_height` on the JSON-RPC object (not inside `result`). `server.features.chain_tip = true`. | +| scripthash history / balance / listunspent | done | Unconf when mempool attached; `get_history` optional BCH-style `from_height` / exclusive `to_height` (`-1` = tip + mempool); 1-arg = full history; **subscribe status always full**; `listunspent` loads `txid.body` only for unspent creates; one TCP connection reuses the last SH outs+spent join until tip **hash** changes. Confirmed methods stamp `chain_tip` / `chain_tip_height` on the JSON-RPC object (not inside `result`). `server.features.chain_tip = true`. Trailing 64-hex **asof** hash (`server.features.asof`): confirmed rows as of that still-live ancestor, **no** mempool; stamp is the asof block; unknown hash → `asof not on chain`. | | scripthash.get_mempool / subscribe | done | Status on mempool announce **and** on confirming tip when that block creates or spends the hash (posting-list probe; no Class A expand on a miss). Reorg (`TipNotify.reorg_from_height`) restatuses every watch even if the new block misses the script. Status preimage is `txid:height:blockhash:` for confirmed rows (mempool rows stay `txid:height:`). | | transaction.get / get_merkle | done | get falls back to mempool; confirmed responses stamp `chain_tip` | | transaction.broadcast | done | Mempool accept + P2P inv | @@ -116,8 +116,15 @@ the published tip and retry if it disconnects | Electrum TCP | JSON-RPC extra members `chain_tip` / `chain_tip_height` next to `result` (ping/version omit). `server.features.chain_tip`. | `result` shape unchanged. Status preimage includes confirming `blockhash` so subscribe clients refetch on same-height replace. Notification `params` stay `[scripthash, status]`. | We stamp **tip**, not only the last relevant history tx hash (empty history -and list envelopes still need a token). We do **not** serve “as of hash H” -after H is disconnected. +and list envelopes still need a token). + +**As-of (buried ancestor):** thanks again to Yuval — the same A-B-A / +bind-confirmations-to-a-chain work implies “wallet as of this block” +while that block is still on the best chain. Esplora `?asof=` and +Electrum trailing asof hash join under `pin_chain_view_at`. Stamp is +that hash. If the asof block leaves the tip chain: **404** / +`asof not on chain` (no retry onto another block at the same height). +We still do **not** serve a disconnected fork hash. ## Esplora REST surface @@ -129,8 +136,8 @@ via reverse proxy; app `ServeLimits` always on (same model as Electrum). | Tip | done | `/blocks/tip/height`, `/blocks/tip/hash`. Every REST response with a published tip also stamps `X-Bitcoin-Chain-Tip` (display-order hex, same as `/blocks/tip/hash`) and `X-Bitcoin-Chain-Tip-Height`, CORS-exposed. 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]` | -| Tx | done | `/tx/:txid` full JSON, `/hex`, `/raw`, `/status`, Electrum `/merkle-proof`, BIP37 `/merkleblock-proof`, `/outspend(s)` | -| 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 tip **hash** changes; needs SH finalize | +| 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 tip **hash** changes; needs SH finalize. `?asof=` on `/`, `/utxo`, `/txs`, `/txs/chain`: confirmed join at that ancestor, **no** mempool; headers are the asof hash; 404 if not on chain. | | 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/concurrency.md b/docs/concurrency.md index 0d4d0fd71..3b032010b 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -96,10 +96,11 @@ cannot pin bitcoind RPC — we can). | Rule | Detail | |------|--------| | Pin | `Query::pin_chain_view` captures `{height, hash, header_fk}` of published tip | +| Buried / as-of | `pin_chain_view_at(hash)` for a still-live ancestor. As-of APIs stamp that hash. If it leaves the tip chain: 404 / `asof not on chain` — **do not** retry onto another block at the same height | | Filter | SH join uses `is_confirmed_strong_at(fk, view.height)`; slot keys on **hash** | | Live-check | `ChainView::still_live` ⇔ `confirmed[height] == header_fk` | | Extension | Prefix pin stays live; creates above the pin are filtered | -| Disconnect / same-height replace | Pin dies; `run_at_chain_view` retries (bound 8) then `StoreError::Stale` | +| Disconnect / same-height replace | Live pin dies; `run_at_chain_view` retries (bound 8) then `StoreError::Stale` | | Not OK | Pause queries during write, MVCC Class C, serving a disconnected hash | API tokens: [`COMPAT.md`](../COMPAT.md) (Esplora headers, Electrum JSON-RPC extra members, status preimage). From d4b4625c75cd87cf4e5a282e0905b37698973e5a Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 17:27:10 -0700 Subject: [PATCH 6/7] electrum: asof: dialect via protocol 1.4.2-asof Trailing bare 64-hex collides with a later official string/hash arg. Tag the extra positional as asof: and honor it only after server.version negotiates 1.4.2-asof. protocol_max stays dotted-int 1.4.2 so Electrum parsers do not choke; features.asof_protocol advertises the dialect. First version call wins. --- CHANGELOG.md | 13 +- COMPAT.md | 20 +- crates/rbitcoin-electrum/src/server.rs | 339 +++++++++++++++--- .../rbitcoin-test/tests/electrum_protocol.rs | 1 + docs/concurrency.md | 2 +- 5 files changed, 317 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca131e36..155be7ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,14 @@ before 1.0). [`COMPAT.md`](COMPAT.md), [`docs/concurrency.md`](docs/concurrency.md). - **As-of ancestor snapshot:** `?asof=` (Esplora) and a trailing - asof hash (Electrum `get_balance` / `listunspent` / `get_history`) return - confirmed UTXOs/balance/history as of a still-live best-chain block. - Thanks again to Yuval — this is the buried-height half of binding - confirmations to a chain. Stamp is the asof hash; unknown/disconnected - → 404 / `asof not on chain`. + Electrum `asof:` string on `get_balance` / `listunspent` / + `get_history` return confirmed UTXOs/balance/history as of a still-live + best-chain block. Thanks again to Yuval — this is the buried-height half + of binding confirmations to a chain. Stamp is the asof hash; + unknown/disconnected → 404 / `asof not on chain`. The `asof:` prefix + cannot be a later official hash/string arg. Clients negotiate protocol + `1.4.2-asof` (`server.features.asof_protocol`); Electrum `protocol_max` + stays dotted-int `1.4.2`. - **Road to 1.0:** [`docs/road-to-1.0.md`](docs/road-to-1.0.md) owns 1.0 product gates (claimed Core functional, Core-parity fuzz, selected diff --git a/COMPAT.md b/COMPAT.md index e083b9340..39aec3c64 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -74,10 +74,10 @@ Full method list, auth, and shindex matrix: **[`docs/rpc.md`](./docs/rpc.md)**. | Method | Status | Notes | |--------|--------|-------| -| server.version / banner / features | done | Banner: libre-relay-class. `server.version[0]` is `rbitcoin-electrs ` — **not electrs**; see below | +| server.version / banner / features | done | Banner: libre-relay-class. `server.version[0]` is `rbitcoin-electrs ` — **not electrs**; see below. `server.version` negotiates: omitted → `1.4.2`; `"1.4"` → `1.4`; `["1.4","1.4.2"]` → `1.4.2`; `"1.4.2-asof"` (or a range containing it) → as-of dialect. First call wins. `features.protocol_max` is `1.4.2`; `features.asof_protocol` is `1.4.2-asof`. | | blockchain.tweaks.subscribe | done | Cake stream (first height as result, then notifies + `done`). Naive walk, or `--sptweaks` thin index (`len:tweak` only; one `txout` span per wave). Pre-taproot: empty maps in ≤1024-height writes. Isolate may still hardcode `electrs.cakewallet.com` | | headers / block headers | done | Tip push on subscribe | -| scripthash history / balance / listunspent | done | Unconf when mempool attached; `get_history` optional BCH-style `from_height` / exclusive `to_height` (`-1` = tip + mempool); 1-arg = full history; **subscribe status always full**; `listunspent` loads `txid.body` only for unspent creates; one TCP connection reuses the last SH outs+spent join until tip **hash** changes. Confirmed methods stamp `chain_tip` / `chain_tip_height` on the JSON-RPC object (not inside `result`). `server.features.chain_tip = true`. Trailing 64-hex **asof** hash (`server.features.asof`): confirmed rows as of that still-live ancestor, **no** mempool; stamp is the asof block; unknown hash → `asof not on chain`. | +| scripthash history / balance / listunspent | done | Unconf when mempool attached; `get_history` optional BCH-style `from_height` / exclusive `to_height` (`-1` = tip + mempool); 1-arg = full history; **subscribe status always full**; `listunspent` loads `txid.body` only for unspent creates; one TCP connection reuses the last SH outs+spent join until tip **hash** changes. Confirmed methods stamp `chain_tip` / `chain_tip_height` on the JSON-RPC object (not inside `result`). `server.features.chain_tip = true`. Trailing **`asof:`** after the official args (`server.features.asof` / `asof_protocol = 1.4.2-asof`): confirmed rows as of that still-live ancestor, **no** mempool; stamp is the asof block; unknown hash → `asof not on chain`. Prefix keeps it off the future positional-string landmine. Requires negotiated `1.4.2-asof` (first `server.version` only). Electrum `protocol_max` stays `1.4.2`. | | scripthash.get_mempool / subscribe | done | Status on mempool announce **and** on confirming tip when that block creates or spends the hash (posting-list probe; no Class A expand on a miss). Reorg (`TipNotify.reorg_from_height`) restatuses every watch even if the new block misses the script. Status preimage is `txid:height:blockhash:` for confirmed rows (mempool rows stay `txid:height:`). | | transaction.get / get_merkle | done | get falls back to mempool; confirmed responses stamp `chain_tip` | | transaction.broadcast | done | Mempool accept + P2P inv | @@ -121,10 +121,18 @@ and list envelopes still need a token). **As-of (buried ancestor):** thanks again to Yuval — the same A-B-A / bind-confirmations-to-a-chain work implies “wallet as of this block” while that block is still on the best chain. Esplora `?asof=` and -Electrum trailing asof hash join under `pin_chain_view_at`. Stamp is -that hash. If the asof block leaves the tip chain: **404** / -`asof not on chain` (no retry onto another block at the same height). -We still do **not** serve a disconnected fork hash. +Electrum trailing `asof:` (after official positional args) join +under `pin_chain_view_at`. Stamp is that hash. If the asof block leaves +the tip chain: **404** / `asof not on chain` (no retry onto another +block at the same height). We still do **not** serve a disconnected fork +hash. + +Electrum clients that want as-of send `server.version(name, "1.4.2-asof")` +(or a `[min, max]` range whose max is that string). Standard Electrum +`"1.4"` / `["1.4", "1.4.2"]` stays on dotted-int 1.4.x; an `asof:` tag +without the dialect is an error. `server.features.protocol_max` remains +`"1.4.2"` so Electrum dotted-int parsers do not choke; discovery is +`asof` + `asof_protocol`. ## Esplora REST surface diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index 1ec3156c4..4659dcf23 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -23,6 +23,8 @@ use tokio::task::JoinHandle; const PROTOCOL_MIN: &str = "1.4"; const PROTOCOL_MAX: &str = "1.4.2"; +// Dialect version: trailing `asof:`. Not a dotted-int; `protocol_max` stays 1.4.2. +const PROTOCOL_ASOF: &str = "1.4.2-asof"; /// First `server.version` element. Cake Wallet `getNodeIsElectrs()` requires /// this string (lowercased) to contain `electrs` before it will probe /// `blockchain.tweaks.subscribe [0, 1, false]`. @@ -341,6 +343,7 @@ where let mut header_sub = false; let mut sh_subs: HashSet<[u8; 32]> = HashSet::new(); let mut sh_join: Option = None; + let mut protocol = String::new(); let notify = Arc::new(Notify::new()); let mut mempool_rx = mempool.as_ref().map(|m| m.subscribe_announces()); let idle = config.idle_timeout(); @@ -507,6 +510,7 @@ where &mut header_sub, &mut sh_subs, &mut sh_join, + &mut protocol, ) } else { let q = Arc::clone(&query); @@ -518,22 +522,30 @@ where let mut hs = header_sub; let mut shs = sh_subs.clone(); let mut slot = sh_join.take(); + let mut proto = protocol.clone(); let stamp = method_stamps_chain_tip(&method_owned); match tokio::task::spawn_blocking(move || { let (r, view) = if stamp { - electrum_at_chain_view(&q, &method_owned, ¶ms_owned, |q| { - dispatch_with_join( - &method_owned, - ¶ms_owned, - q, - &cfg, - &p, - mp.as_deref(), - &mut hs, - &mut shs, - &mut slot, - ) - }) + electrum_at_chain_view( + &q, + &method_owned, + ¶ms_owned, + proto.as_str() == PROTOCOL_ASOF, + |q| { + dispatch_with_join( + &method_owned, + ¶ms_owned, + q, + &cfg, + &p, + mp.as_deref(), + &mut hs, + &mut shs, + &mut slot, + &mut proto, + ) + }, + ) } else { ( dispatch_with_join( @@ -546,6 +558,7 @@ where &mut hs, &mut shs, &mut slot, + &mut proto, ), None, ) @@ -853,12 +866,13 @@ fn electrum_at_chain_view( query: &Query, method: &str, params: &Value, + asof_ok: bool, mut f: F, ) -> (Result, Option) where F: FnMut(&Query) -> Result, { - match take_trailing_asof(method, params) { + match take_trailing_asof(method, params, asof_ok) { Ok((_, Some(hash))) => match query.pin_chain_view_at(&hash) { Ok(Some(view)) => { let out = f(query); @@ -909,8 +923,18 @@ fn dispatch( sh_subs: &mut HashSet<[u8; 32]>, ) -> Result { let mut slot = None; + let mut protocol = String::new(); dispatch_with_join( - method, params, query, config, chain, mempool, header_sub, sh_subs, &mut slot, + method, + params, + query, + config, + chain, + mempool, + header_sub, + sh_subs, + &mut slot, + &mut protocol, ) } @@ -924,9 +948,15 @@ fn dispatch_with_join( header_sub: &mut bool, sh_subs: &mut HashSet<[u8; 32]>, sh_join: &mut Option, + protocol: &mut String, ) -> Result { match method { - "server.version" => Ok(json!([SERVER_VERSION, PROTOCOL_MAX])), + "server.version" => { + if protocol.is_empty() { + *protocol = negotiate_protocol(params)?; + } + Ok(json!([SERVER_VERSION, protocol.as_str()])) + } "server.ping" => Ok(Value::Null), "server.banner" => Ok(json!(config.banner)), "server.donation_address" => Ok(json!(config.donation_address)), @@ -946,6 +976,7 @@ fn dispatch_with_join( "tweaks": true, "chain_tip": true, "asof": true, + "asof_protocol": PROTOCOL_ASOF, })), "blockchain.headers.subscribe" => { *header_sub = true; @@ -975,7 +1006,8 @@ fn dispatch_with_join( Ok(json!({"count": n, "hex": hexes, "max": 2016})) } "blockchain.scripthash.get_history" => { - let (params, asof) = take_trailing_asof(method, params)?; + let (params, asof) = + take_trailing_asof(method, params, protocol.as_str() == PROTOCOL_ASOF)?; let sh = param_scripthash(¶ms, 0)?; let (filter, mut include_mempool) = parse_get_history_window(¶ms)?; let mut hist = if let Some(hash) = asof { @@ -1017,7 +1049,8 @@ fn dispatch_with_join( Ok(Value::Array(arr)) } "blockchain.scripthash.get_balance" => { - let (params, asof) = take_trailing_asof(method, params)?; + let (params, asof) = + take_trailing_asof(method, params, protocol.as_str() == PROTOCOL_ASOF)?; let sh = param_scripthash(¶ms, 0)?; let mut b = if let Some(hash) = asof { let view = query @@ -1040,7 +1073,8 @@ fn dispatch_with_join( Ok(json!({"confirmed": b.confirmed, "unconfirmed": b.unconfirmed})) } "blockchain.scripthash.listunspent" => { - let (params, asof) = take_trailing_asof(method, params)?; + let (params, asof) = + take_trailing_asof(method, params, protocol.as_str() == PROTOCOL_ASOF)?; let sh = param_scripthash(¶ms, 0)?; let u = if let Some(hash) = asof { let view = query @@ -1274,26 +1308,90 @@ fn parse_blockhash32(s: &str) -> Option<[u8; 32]> { Some(out) } -/// Trailing 64-hex is an asof block hash (display order). Never the sole param -/// (that is the scripthash). -fn take_trailing_asof(method: &str, params: &Value) -> Result<(Value, Option<[u8; 32]>), String> { +fn protocol_tuple(s: &str) -> Option> { + if s.is_empty() { + return None; + } + s.split('.').map(|p| p.parse().ok()).collect() +} + +fn protocol_string(parts: &[u32]) -> String { + parts + .iter() + .map(ToString::to_string) + .collect::>() + .join(".") +} + +fn pick_dotted(cmin_s: &str, cmax_s: &str) -> Result { + let cmin = + protocol_tuple(cmin_s).ok_or_else(|| format!("unsupported protocol version {cmin_s}"))?; + let cmax = + protocol_tuple(cmax_s).ok_or_else(|| format!("unsupported protocol version {cmax_s}"))?; + let smin = protocol_tuple(PROTOCOL_MIN).expect("PROTOCOL_MIN"); + let smax = protocol_tuple(PROTOCOL_MAX).expect("PROTOCOL_MAX"); + let lo = if cmin < smin { smin } else { cmin }; + let hi = if cmax < smax { cmax } else { smax }; + if hi < lo { + return Err("unsupported protocol version".into()); + } + Ok(protocol_string(&hi)) +} + +fn negotiate_protocol(params: &Value) -> Result { + let pv = params + .as_array() + .and_then(|a| a.get(1)) + .unwrap_or(&Value::Null); + if pv.is_null() { + return Ok(PROTOCOL_MAX.to_string()); + } + if let Some(s) = pv.as_str() { + if s == PROTOCOL_ASOF { + return Ok(PROTOCOL_ASOF.to_string()); + } + return pick_dotted(s, s); + } + let Some(range) = pv.as_array() else { + return Err("protocol_version expected string or [min, max]".into()); + }; + if range.len() != 2 { + return Err("protocol_version range must be [min, max]".into()); + } + let a = range[0] + .as_str() + .ok_or("protocol_version range expected strings")?; + let b = range[1] + .as_str() + .ok_or("protocol_version range expected strings")?; + if a == PROTOCOL_ASOF || b == PROTOCOL_ASOF { + return Ok(PROTOCOL_ASOF.to_string()); + } + pick_dotted(a, b) +} + +fn take_trailing_asof( + method: &str, + params: &Value, + asof_ok: bool, +) -> Result<(Value, Option<[u8; 32]>), String> { if !method_accepts_asof(method) { return Ok((params.clone(), None)); } let Some(arr) = params.as_array() else { return Ok((params.clone(), None)); }; - if arr.len() < 2 { - return Ok((params.clone(), None)); - } let Some(last) = arr.last().and_then(|v| v.as_str()) else { return Ok((params.clone(), None)); }; - if last.len() != 64 { + let Some(hex) = last.strip_prefix("asof:") else { return Ok((params.clone(), None)); + }; + if !asof_ok { + return Err("asof requires protocol 1.4.2-asof".into()); } - let Some(hash) = parse_blockhash32(last) else { - return Err("asof must be 32 bytes hex".into()); + let Some(hash) = parse_blockhash32(hex) else { + return Err("asof must be asof:<32-byte hex>".into()); }; let mut rest = arr.clone(); rest.pop(); @@ -1501,16 +1599,61 @@ mod tests { assert_eq!(f.to_height, Some(10)); assert!(!mp); let asof_hex = "ab".repeat(32); + let tagged = format!("asof:{asof_hex}"); let (rest, h) = take_trailing_asof( "blockchain.scripthash.get_balance", - &json!([sh_hex, asof_hex]), + &json!([sh_hex, tagged]), + true, ) .unwrap(); assert!(h.is_some()); assert_eq!(rest, json!([sh_hex])); + let (rest_win, h_win) = take_trailing_asof( + "blockchain.scripthash.get_history", + &json!([sh_hex, 1, 10, tagged]), + true, + ) + .unwrap(); + assert!(h_win.is_some()); + assert_eq!(rest_win, json!([sh_hex, 1, 10])); let (_, none) = - take_trailing_asof("blockchain.scripthash.get_balance", &json!([sh_hex])).unwrap(); + take_trailing_asof("blockchain.scripthash.get_balance", &json!([sh_hex]), true) + .unwrap(); assert!(none.is_none()); + let (_, not_hex) = take_trailing_asof( + "blockchain.scripthash.get_balance", + &json!([sh_hex, asof_hex]), + true, + ) + .unwrap(); + assert!( + not_hex.is_none(), + "bare trailing hex must not be asof (future positional hash args)" + ); + let (_, leftover_obj) = take_trailing_asof( + "blockchain.scripthash.get_balance", + &json!([sh_hex, { "other": true }]), + true, + ) + .unwrap(); + assert!(leftover_obj.is_none()); + let denied = take_trailing_asof( + "blockchain.scripthash.get_balance", + &json!([sh_hex, tagged]), + false, + ) + .unwrap_err(); + assert!( + denied.contains("1.4.2-asof"), + "asof tag without dialect: {denied}" + ); + assert!(take_trailing_asof( + "blockchain.scripthash.get_balance", + &json!([sh_hex, "asof:zz"]), + true, + ) + .unwrap_err() + .contains("asof:<32-byte hex>")); assert!(parse_get_history_window(&json!([sh_hex, 10, 5])) .unwrap_err() @@ -1544,6 +1687,35 @@ mod tests { assert_eq!(hex.len(), 160); } + #[test] + fn negotiate_protocol_intersection_and_asof_dialect() { + assert_eq!(negotiate_protocol(&json!([])).unwrap(), PROTOCOL_MAX); + assert_eq!(negotiate_protocol(&json!(["c"])).unwrap(), PROTOCOL_MAX); + assert_eq!(negotiate_protocol(&json!(["c", "1.4"])).unwrap(), "1.4"); + assert_eq!( + negotiate_protocol(&json!(["c", "1.4.2"])).unwrap(), + PROTOCOL_MAX + ); + assert_eq!( + negotiate_protocol(&json!(["c", ["1.4", "1.4.2"]])).unwrap(), + PROTOCOL_MAX + ); + assert_eq!( + negotiate_protocol(&json!(["c", PROTOCOL_ASOF])).unwrap(), + PROTOCOL_ASOF + ); + assert_eq!( + negotiate_protocol(&json!(["c", ["1.4", PROTOCOL_ASOF]])).unwrap(), + PROTOCOL_ASOF + ); + assert!(negotiate_protocol(&json!(["c", "1.5"])) + .unwrap_err() + .contains("unsupported")); + assert!(negotiate_protocol(&json!(["c", ["1.4.3", "1.5"]])) + .unwrap_err() + .contains("unsupported")); + } + #[test] fn dispatch_static_methods_and_errors() { let (dir, q) = tmp_store(); @@ -1621,12 +1793,15 @@ mod tests { &mut sh_subs, ) .unwrap(); + assert_eq!(v[1], PROTOCOL_MAX); assert_eq!(features["protocol_min"], PROTOCOL_MIN); + assert_eq!(features["protocol_max"], PROTOCOL_MAX); assert_eq!(features["server_version"], v[0]); assert_eq!(features["silent_payments"], json!([0])); assert_eq!(features["tweaks"], json!(true)); assert_eq!(features["chain_tip"], json!(true)); assert_eq!(features["asof"], json!(true)); + assert_eq!(features["asof_protocol"], PROTOCOL_ASOF); let probe = dispatch( "blockchain.tweaks.subscribe", @@ -2534,107 +2709,174 @@ mod tests { let sh = electrum_scripthash_hex(&[0x51]); let asof0 = hash_hex_rev(&merkle); let asof1 = hash_hex_rev(&hash1); + let tag0 = format!("asof:{asof0}"); + let tag1 = format!("asof:{asof1}"); let mut header_sub = false; let mut sh_subs = HashSet::new(); + let mut sh_join = None; + let mut protocol = String::new(); + + let denied = dispatch_with_join( + "blockchain.scripthash.get_balance", + &json!([sh, tag0]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + &mut sh_join, + &mut protocol, + ) + .unwrap_err(); + assert!( + denied.contains("1.4.2-asof"), + "asof tag before handshake: {denied}" + ); - let bal0 = dispatch( + let ver = dispatch_with_join( + "server.version", + &json!(["test", PROTOCOL_ASOF]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + &mut sh_join, + &mut protocol, + ) + .unwrap(); + assert_eq!(ver[1], PROTOCOL_ASOF); + let locked = dispatch_with_join( + "server.version", + &json!(["test", "1.4"]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + &mut sh_join, + &mut protocol, + ) + .unwrap(); + assert_eq!(locked[1], PROTOCOL_ASOF); + + let bal0 = dispatch_with_join( "blockchain.scripthash.get_balance", - &json!([sh, asof0]), + &json!([sh, tag0]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(bal0["confirmed"], 10_0000_0000); assert_eq!(bal0["unconfirmed"], 0); - let utxo0 = dispatch( + let utxo0 = dispatch_with_join( "blockchain.scripthash.listunspent", - &json!([sh, asof0]), + &json!([sh, tag0]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(utxo0.as_array().unwrap().len(), 1); - let hist0 = dispatch( + let hist0 = dispatch_with_join( "blockchain.scripthash.get_history", - &json!([sh, asof0]), + &json!([sh, tag0]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(hist0.as_array().unwrap().len(), 1); - let bal1 = dispatch( + let bal1 = dispatch_with_join( "blockchain.scripthash.get_balance", - &json!([sh, asof1]), + &json!([sh, tag1]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(bal1["confirmed"], 0); - let utxo1 = dispatch( + let utxo1 = dispatch_with_join( "blockchain.scripthash.listunspent", - &json!([sh, asof1]), + &json!([sh, tag1]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap(); assert!(utxo1.as_array().unwrap().is_empty()); - let hist1 = dispatch( + let hist1 = dispatch_with_join( "blockchain.scripthash.get_history", - &json!([sh, asof1]), + &json!([sh, tag1]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(hist1.as_array().unwrap().len(), 2); - let err = dispatch( + let err = dispatch_with_join( "blockchain.scripthash.get_balance", - &json!([sh, "ee".repeat(32)]), + &json!([sh, format!("asof:{}", "ee".repeat(32))]), &q, &cfg, ¶ms, None, &mut header_sub, &mut sh_subs, + &mut sh_join, + &mut protocol, ) .unwrap_err(); assert!(err.contains("asof not on chain"), "unknown asof: {err}"); let (out, view) = electrum_at_chain_view( &q, "blockchain.scripthash.get_balance", - &json!([sh, asof0]), + &json!([sh, tag0]), + true, |q| { let mut hs = false; let mut subs = HashSet::new(); let mut slot = None; + let mut proto = PROTOCOL_ASOF.to_string(); dispatch_with_join( "blockchain.scripthash.get_balance", - &json!([sh, asof0]), + &json!([sh, tag0]), q, &cfg, ¶ms, @@ -2642,6 +2884,7 @@ mod tests { &mut hs, &mut subs, &mut slot, + &mut proto, ) }, ); @@ -2714,6 +2957,7 @@ mod tests { let mut header_sub = false; let mut sh_subs = HashSet::new(); let mut sh_join = None; + let mut protocol = String::new(); let sh = electrum_scripthash_hex(&[0x51]); reset_body_ok_reads(); let bal = dispatch_with_join( @@ -2726,6 +2970,7 @@ mod tests { &mut header_sub, &mut sh_subs, &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(bal["confirmed"].as_i64().unwrap(), 150_0000_0000); @@ -2742,6 +2987,7 @@ mod tests { &mut header_sub, &mut sh_subs, &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(hist.as_array().unwrap().len(), 3); @@ -2761,6 +3007,7 @@ mod tests { &mut header_sub, &mut sh_subs, &mut sh_join, + &mut protocol, ) .unwrap(); assert_eq!(unspent.as_array().unwrap().len(), 3); diff --git a/crates/rbitcoin-test/tests/electrum_protocol.rs b/crates/rbitcoin-test/tests/electrum_protocol.rs index 353691a1f..3e27b2f27 100644 --- a/crates/rbitcoin-test/tests/electrum_protocol.rs +++ b/crates/rbitcoin-test/tests/electrum_protocol.rs @@ -54,6 +54,7 @@ async fn electrum_server_version_history_balance() { assert!(v.get("result").is_some(), "{v}"); let ver = v["result"].as_array().unwrap(); assert_eq!(ver.len(), 2); + assert_eq!(ver[1].as_str(), Some("1.4")); // OP_TRUE scripthash let sh_hex = electrum_scripthash_hex(&[0x51]); diff --git a/docs/concurrency.md b/docs/concurrency.md index 3b032010b..cbfe87438 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -96,7 +96,7 @@ cannot pin bitcoind RPC — we can). | Rule | Detail | |------|--------| | Pin | `Query::pin_chain_view` captures `{height, hash, header_fk}` of published tip | -| Buried / as-of | `pin_chain_view_at(hash)` for a still-live ancestor. As-of APIs stamp that hash. If it leaves the tip chain: 404 / `asof not on chain` — **do not** retry onto another block at the same height | +| Buried / as-of | `pin_chain_view_at(hash)` for a still-live ancestor. Esplora `?asof=`; Electrum trailing `asof:` after `server.version` dialect `1.4.2-asof`. Stamp is that hash. If it leaves the tip chain: 404 / `asof not on chain` — **do not** retry onto another block at the same height | | Filter | SH join uses `is_confirmed_strong_at(fk, view.height)`; slot keys on **hash** | | Live-check | `ChainView::still_live` ⇔ `confirmed[height] == header_fk` | | Extension | Prefix pin stays live; creates above the pin are filtered | From 17bb5de98e02c1713ee4edf11f3baf3867194120 Mon Sep 17 00:00:00 2001 From: rbitcoin-grok Date: Sat, 22 Aug 2026 18:25:08 -0700 Subject: [PATCH 7/7] docs: Electrum 1.6/1.7 after P2P package relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol_max stays 1.4.2 until BIP331 (Q-48). Then implement 1.6 (broadcast_package) and 1.7 and raise the advertised max in the same work — do not advertise a version we do not speak. --- COMPAT.md | 16 +++++++++++++++- docs/quality.md | 2 +- docs/road-to-1.0.md | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index 39aec3c64..b86d2aab7 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -80,10 +80,24 @@ Full method list, auth, and shindex matrix: **[`docs/rpc.md`](./docs/rpc.md)**. | scripthash history / balance / listunspent | done | Unconf when mempool attached; `get_history` optional BCH-style `from_height` / exclusive `to_height` (`-1` = tip + mempool); 1-arg = full history; **subscribe status always full**; `listunspent` loads `txid.body` only for unspent creates; one TCP connection reuses the last SH outs+spent join until tip **hash** changes. Confirmed methods stamp `chain_tip` / `chain_tip_height` on the JSON-RPC object (not inside `result`). `server.features.chain_tip = true`. Trailing **`asof:`** after the official args (`server.features.asof` / `asof_protocol = 1.4.2-asof`): confirmed rows as of that still-live ancestor, **no** mempool; stamp is the asof block; unknown hash → `asof not on chain`. Prefix keeps it off the future positional-string landmine. Requires negotiated `1.4.2-asof` (first `server.version` only). Electrum `protocol_max` stays `1.4.2`. | | scripthash.get_mempool / subscribe | done | Status on mempool announce **and** on confirming tip when that block creates or spends the hash (posting-list probe; no Class A expand on a miss). Reorg (`TipNotify.reorg_from_height`) restatuses every watch even if the new block misses the script. Status preimage is `txid:height:blockhash:` for confirmed rows (mempool rows stay `txid:height:`). | | transaction.get / get_merkle | done | get falls back to mempool; confirmed responses stamp `chain_tip` | -| transaction.broadcast | done | Mempool accept + P2P inv | +| transaction.broadcast | done | Mempool accept + P2P inv. `broadcast_package` is Electrum **1.6** — wait for P2P package relay, then bump (below). | | relayfee / estimatefee / histogram | done | Libre min + live median | | TLS | external | terminate at reverse proxy; node is plain TCP | +### Protocol versions + +`features.protocol_max` is **1.4.2** on purpose (plus dialect `1.4.2-asof`). +Electrum 4.8 wallets speak 1.4–1.6; ElectrumX advertises 1.7. Do **not** +raise the number ahead of the methods. + +**After P2P package relay** ([`docs/quality.md`](./docs/quality.md) **Q-48** / +BIP331), implement Electrum **1.6** (`blockchain.transaction.broadcast_package`, +`mempool.get_info`, `block.headers` as a list, `server.version` first) then +**1.7** (`scriptpubkey.*`, outpoint subscribe) and raise `protocol_max` in +the same work. Dual-serve `scripthash.*` until 1.7 clients exist. RPC +`submitpackage` / Esplora `POST /txs/package` already accept packages; the +Electrum bump waits on the P2P command so 1.6 is not a lie. + ### Why `server.version` says electrs We are **not** electrs. Cake Wallet `getNodeIsElectrs()` lowercases diff --git a/docs/quality.md b/docs/quality.md index 52dd9c037..dbfc5d3bf 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -84,7 +84,7 @@ evidence (failed Core corpus, new dual path, red required CI, MSRV drift). |-----:|----|------|-----|-----------------| | 1 | **Q-30** | Continuous differential fuzz | reliability | A nightly/weekly job that feeds BIP324 + header/block (and script) wire. Crashes → `docs/external_findings/` + named regression. **Today: `fuzz/` `block_wire` + nightly `fuzz.yml` (not a required PR check).** Grow corpus / more targets. Findings 001–021 came from an external fuzzamoto campaign — that is not a substitute for the in-tree job. | | 2 | **Q-41** | Grow Core functional `run` set | test | Inventory `run` covers the wallet-client / P2P / mempool / buried-activation scripts we **claim**. **Today: 44 / 267.** COMPAT-done leftovers are `rpc-dialect` (not `rpc-missing`). Next `run` candidates: `mempool_accept` type-check, `mining_basic` weight, `rpc_getblockfrompeer`. Product-never skips stay skip. Unlabeled PRs stay cargo-only; nightly green | -| 3 | **Q-48** | BIP331 rust-bitcoin package types | interop | Native BIP331 `NetworkMessage` when rust-bitcoin exposes it (**RB-007**). Packages today are RPC `submitpackage` / Esplora `POST /txs/package` only — no private P2P command. Blocked upstream — ranked below unblocked ops work | +| 3 | **Q-48** | BIP331 rust-bitcoin package types | interop | Native BIP331 `NetworkMessage` when rust-bitcoin exposes it (**RB-007**). Packages today are RPC `submitpackage` / Esplora `POST /txs/package` only — no private P2P command. Blocked upstream — ranked below unblocked ops work. **After this:** Electrum 1.6 then 1.7 (`protocol_max` bump in the same work) — [`COMPAT.md`](../COMPAT.md) § Protocol versions | | 4 | **Q-31** | Hermetic tip fixtures | ops | Frozen signet/mainnet tip packs for offline consensus/Electrum regression (no live API). Unblocks Q-30 corpora | | 5 | **R-10** | Residual god-files | code | Peel **only** when a higher row needs a seam. After extracting `peer_tests` / `methods_tests` / `scripthash_tests`: production `query/lib` **4.2k**, `electrum/server` **3.7k**, `scripthash` **3.4k**, `sorted_run` **3.4k**, `methods` **3.3k**, `chain` **3.3k**, `store` **3.2k**. Further production peels wait for a real seam | diff --git a/docs/road-to-1.0.md b/docs/road-to-1.0.md index 79ffd3611..5853f38c2 100644 --- a/docs/road-to-1.0.md +++ b/docs/road-to-1.0.md @@ -124,6 +124,6 @@ Older-than-1.0 or corrupt files can still refuse with a one-line message. ## After 1.0 (unless it falls out earlier) -- BIP331 package relay, if rust-bitcoin still has no types (**Q-48**) +- BIP331 package relay, if rust-bitcoin still has no types (**Q-48**); then Electrum protocol 1.6/1.7 ([`COMPAT.md`](../COMPAT.md) § Protocol versions) - Tor - Publishing the store as a crate