From 73ec7714fc93daf89e41ac5fce2289a240aded9e Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:54:27 -0400 Subject: [PATCH 1/3] perf(sp): eliminate per-tx DB iterator reads in tweaks.subscribe The scan hot loop resolved the tweak spend-cache height with a fresh RocksDB iterator scan once per transaction row, which dominates scan latency on dense blocks (thousands of iterator creations per block). - Resolve cache state once per block height with a memoized HashMap entry. - Switch the cache-height lookup from iter_scan to a direct point read. - Extract P2TR xonly keys straight from the script bytes (OP_1 <32 bytes>) instead of building + splitting the full script-to-asm string per vout. - Iterate stored vout data by reference; only clone when a stale spend cache requires a lookup_spend refresh (rare, self-healing path). Output wire format is byte-for-byte unchanged. --- src/electrum/server.rs | 58 ++++++++++++++++++++++++++++++----------- src/new_index/schema.rs | 5 ++-- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/electrum/server.rs b/src/electrum/server.rs index d1928876d..30342ea8e 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -55,6 +55,16 @@ fn usize_from_value(val: Option<&Value>, name: &str) -> Result { Ok(val as usize) } +/// Fast P2TR key extraction: OP_1 <32-byte xonly key> = 0x51 0x20 + 32 bytes. +fn p2tr_pubkey_hex(script: &bitcoin::Script) -> Option { + let bytes = script.as_bytes(); + if bytes.len() == 34 && bytes[0] == 0x51 && bytes[1] == 0x20 { + Some(bytes[2..].as_hex().to_string()) + } else { + None + } +} + fn usize_from_value_or(val: Option<&Value>, name: &str, default: usize) -> Result { if val.is_none() { return Ok(default); @@ -371,6 +381,13 @@ impl Connection { let mut tweak_map = HashMap::new(); let mut prev_height = scan_height; + // Per-block memoization of whether the tweak spend-cache is current for a + // height. Previously the cache height was read from RocksDB (creating a + // fresh iterator) once per TRANSACTION, which dominated scan latency. + // Resolving it once per block removes thousands of DB iterator creations + // per dense block while keeping behavior identical. + let mut block_cache_current: HashMap = HashMap::new(); + let rows: Vec<_> = self .query .tweaks_iter_scan(scan_height, final_scanned_height) @@ -388,20 +405,24 @@ impl Connection { tweak_map = HashMap::new(); } - if row_height < last_blockchain_height - 5 { - let cached_height_for_tweak = self - .query - .chain() - .get_tweak_cached_height(row_height) - .unwrap_or(0); - query_for_height_cached = Some(last_blockchain_height == cached_height_for_tweak); + if row_height + 5 < last_blockchain_height { + query_for_height_cached = Some( + *block_cache_current.entry(row_height).or_insert_with(|| { + let cached_height_for_tweak = self + .query + .chain() + .get_tweak_cached_height(row_height) + .unwrap_or(0); + last_blockchain_height == cached_height_for_tweak + }), + ); } let txid = tweak_row.key.txid; let tweak = tweak_row.get_tweak_data(); let mut vout_map = HashMap::new(); - for vout in tweak.vout_data.clone().into_iter() { + for vout in tweak.vout_data.iter() { let mut spend = vout.spending_input.clone(); let mut has_been_spent = spend.is_some(); @@ -442,13 +463,20 @@ impl Connection { } } - if let Some(pubkey) = &vout - .script_pubkey - .to_asm() - .split(" ") - .collect::>() - .last() - { + // Fast path: P2TR scripts are OP_1 <32-byte xonly key>, so the + // pubkey hex is the last 32 bytes directly, avoiding the + // script-to-asm allocation + split done previously. + let pubkey_hex = p2tr_pubkey_hex(&vout.script_pubkey).or_else(|| { + vout + .script_pubkey + .to_asm() + .split(" ") + .collect::>() + .last() + .map(|s| s.to_string()) + }); + + if let Some(pubkey) = pubkey_hex { let mut items = json!([pubkey, vout.amount]); if historical_mode && has_been_spent { diff --git a/src/new_index/schema.rs b/src/new_index/schema.rs index 6187c3198..c37ea2cd6 100644 --- a/src/new_index/schema.rs +++ b/src/new_index/schema.rs @@ -956,9 +956,8 @@ impl ChainQuery { pub fn get_tweak_cached_height(&self, height: u32) -> Option { self.store .tweak_db - .iter_scan(&TweakBlockRecordCacheRow::key(height)) - .map(|v| TweakBlockRecordCacheRow::from_row(v).value) - .next() + .get(&TweakBlockRecordCacheRow::key(height)) + .and_then(|v| bincode::deserialize_big(&v).ok()) } pub fn tweaks_iter_scan_reverse(&self, height: u32) -> ReverseScanIterator { From 400eb4997ef051c92e3ddb65e678addacbd6f4de Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:02:24 -0400 Subject: [PATCH 2/3] perf(sp): stream tweak rows and drop the per-tx TweakData clone - Iterate the tweaks range scan straight off the snapshot-consistent RocksDB iterator instead of collecting the entire requested range into a Vec before streaming. A dense historical-mode request can hold hundreds of thousands of fully-deserialized rows in memory and delays the first streamed block until the whole range has been read. Arc-cloning the query handle keeps `self` free for send_values(). - Borrow TweakData in place: get_tweak_data() deep-cloned every vout script and spend record once per transaction row. Wire output is unchanged: same rows, same order, same JSON. The RocksDB iterator is snapshot-consistent, so the mid-scan spend-cache writebacks observe the same view the collected Vec did. Co-Authored-By: Claude Fable 5 --- src/electrum/server.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/electrum/server.rs b/src/electrum/server.rs index 30342ea8e..b419f4370 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -388,12 +388,13 @@ impl Connection { // per dense block while keeping behavior identical. let mut block_cache_current: HashMap = HashMap::new(); - let rows: Vec<_> = self - .query - .tweaks_iter_scan(scan_height, final_scanned_height) - .collect(); - - for row in rows { + // Stream rows straight off the (snapshot-consistent) RocksDB iterator + // instead of collecting the entire requested range into memory first: a + // dense historical request can span hundreds of thousands of rows, and + // collecting delays the first streamed block until the whole range has + // been read. Cloning the Arc keeps `self` free for send_values(). + let query = Arc::clone(&self.query); + for row in query.tweaks_iter_scan(scan_height, final_scanned_height) { let tweak_row = TweakTxRow::from_row(row); let row_height = tweak_row.key.blockheight; let is_new_block = row_height != prev_height; @@ -419,7 +420,9 @@ impl Connection { } let txid = tweak_row.key.txid; - let tweak = tweak_row.get_tweak_data(); + // Borrow the tweak data in place: get_tweak_data() deep-clones the + // whole TweakData (every vout script + spend record) once per tx. + let tweak = &tweak_row.value; let mut vout_map = HashMap::new(); for vout in tweak.vout_data.iter() { From 735e15612fcbb75024a8c0bcf050cc51c16e7958 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:37:21 -0400 Subject: [PATCH 3/3] fix(sp): make P2TR fast-path script-type agnostic for liquid builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p2tr_pubkey_hex took &bitcoin::Script, but under the liquid feature script_pubkey is elements::Script, breaking the test-liquid CI build. Take the raw script bytes instead — identical logic for both types. Co-Authored-By: Claude Fable 5 --- src/electrum/server.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/electrum/server.rs b/src/electrum/server.rs index b419f4370..eea3ee425 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -56,8 +56,9 @@ fn usize_from_value(val: Option<&Value>, name: &str) -> Result { } /// Fast P2TR key extraction: OP_1 <32-byte xonly key> = 0x51 0x20 + 32 bytes. -fn p2tr_pubkey_hex(script: &bitcoin::Script) -> Option { - let bytes = script.as_bytes(); +/// Takes raw script bytes so it works with both bitcoin::Script and +/// elements::Script (liquid builds). +fn p2tr_pubkey_hex(bytes: &[u8]) -> Option { if bytes.len() == 34 && bytes[0] == 0x51 && bytes[1] == 0x20 { Some(bytes[2..].as_hex().to_string()) } else { @@ -469,7 +470,7 @@ impl Connection { // Fast path: P2TR scripts are OP_1 <32-byte xonly key>, so the // pubkey hex is the last 32 bytes directly, avoiding the // script-to-asm allocation + split done previously. - let pubkey_hex = p2tr_pubkey_hex(&vout.script_pubkey).or_else(|| { + let pubkey_hex = p2tr_pubkey_hex(vout.script_pubkey.as_bytes()).or_else(|| { vout .script_pubkey .to_asm()