From 98ae00224406fbc22e24bdf648357d478b83e339 Mon Sep 17 00:00:00 2001 From: junderw Date: Thu, 26 Mar 2026 20:39:36 +0900 Subject: [PATCH 1/6] fix: continue address tx pagination across mempool boundary The address tx history endpoints could stop pagination at the mempool/chain boundary when after_txid pointed to the last mempool transaction for an address or address group. The confirmed-history query was reusing after_txid whenever the mempool query returned no rows, even if that cursor only existed in mempool. This change makes the confirmed-history query reuse after_txid only when the cursor was actually found in chain history; mempool cursors now correctly fall through to the newest confirmed transactions. Co-authored-by: Saravanan Mani <228955468+saravanan7mani7@users.noreply.github.com> --- src/rest.rs | 64 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/src/rest.rs b/src/rest.rs index 48bdb90..27006a6 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -583,6 +583,19 @@ fn find_txid( } } +#[inline] +fn confirmed_after_txid<'a>( + after_txid_location: &TxidLocation, + after_txid: Option<&'a Txid>, +) -> Option<&'a Txid> { + match after_txid_location { + // A mempool cursor never exists in chain history, so always + // start from the newest confirmed tx when crossing the boundary. + TxidLocation::Mempool | TxidLocation::None => None, + TxidLocation::Chain(_) => after_txid, + } +} + /// Prepare transactions to be serialized in a JSON response /// /// Any transactions with missing prevouts will be filtered out of the response, rather than returned with incorrect data. @@ -960,13 +973,8 @@ fn handle_request( }; if txs.len() < max_txs { - let after_txid_ref = if !txs.is_empty() { - // If there are any txs, we know mempool found the - // after_txid IF it exists... so always return None. - None - } else { - after_txid.as_ref() - }; + let after_txid_ref = + confirmed_after_txid(&after_txid_location, after_txid.as_ref()); let mut confirmed_txs = query .chain() .history( @@ -1067,13 +1075,8 @@ fn handle_request( }; if txs.len() < max_txs { - let after_txid_ref = if !txs.is_empty() { - // If there are any txs, we know mempool found the - // after_txid IF it exists... so always return None. - None - } else { - after_txid.as_ref() - }; + let after_txid_ref = + confirmed_after_txid(&after_txid_location, after_txid.as_ref()); let mut confirmed_txs = query .chain() .history_group( @@ -2148,6 +2151,8 @@ impl From for HttpError { #[cfg(test)] mod tests { + use super::{confirmed_after_txid, TxidLocation}; + use crate::chain::Txid; use crate::rest::HttpError; use serde_json::Value; use std::collections::HashMap; @@ -2214,6 +2219,37 @@ mod tests { assert!(err.is_err()); } + #[test] + fn test_confirmed_after_txid_uses_chain_cursor_only() { + let txid: Txid = "0000000000000000000000000000000000000000000000000000000000000001" + .parse() + .unwrap(); + + assert_eq!( + confirmed_after_txid(&TxidLocation::Mempool, Some(&txid)), + None + ); + assert_eq!(confirmed_after_txid(&TxidLocation::None, Some(&txid)), None); + assert_eq!( + confirmed_after_txid(&TxidLocation::Chain(123), Some(&txid)), + Some(&txid) + ); + } + + #[test] + fn test_confirmed_after_txid_allows_mempool_chain_boundary_progress() { + let txid: Txid = "0000000000000000000000000000000000000000000000000000000000000002" + .parse() + .unwrap(); + + // If a mempool cursor returns no newer mempool txs, confirmed history + // must start from the newest confirmed tx instead of seeking this txid. + assert_eq!( + confirmed_after_txid(&TxidLocation::Mempool, Some(&txid)), + None + ); + } + #[test] fn test_difficulty_new() { use super::difficulty_new; From 67ed34e357fb704f593c73801c00829cb0bc2f69 Mon Sep 17 00:00:00 2001 From: wiz Date: Fri, 17 Apr 2026 00:47:04 +0900 Subject: [PATCH 2/6] ops: Use less CPU threads for electrs precache --- start | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/start b/start index 1449bb1..679515a 100755 --- a/start +++ b/start @@ -42,7 +42,7 @@ esac # which network? case "${1}" in mainnet) - THREADS=$((NPROC / 4)) + THREADS=$((NPROC / 8)) CRONJOB_TIMING="20 4 * * *" ;; testnet) From 12a70af0c0a7baca56c2ffd2ccd5e82e774c98ac Mon Sep 17 00:00:00 2001 From: wiz Date: Fri, 17 Apr 2026 02:08:39 +0900 Subject: [PATCH 3/6] ops: Run electrs nicely --- start | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/start b/start index 679515a..5acccb6 100755 --- a/start +++ b/start @@ -211,7 +211,7 @@ do fi # Run the electrs process (Note: db-dir is used in both commands) - cargo run \ + nice cargo run \ --release \ --bin electrs \ --features "${FEATURES}" \ From 60fb1029c97b880c615ffb7dc4c994bdd4f79041 Mon Sep 17 00:00:00 2001 From: junderw Date: Mon, 4 May 2026 14:59:15 +0900 Subject: [PATCH 4/6] Fix: Add some simple limits to Electrum RPC by default --- src/config.rs | 21 ++++++++++++ src/electrum/server.rs | 73 +++++++++++++++++++++++++++++++++++------- start | 36 +++++++++++++++++++++ 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index 813c641..00982a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,9 @@ pub struct Config { pub rest_default_max_address_summary_txs: usize, pub rest_max_mempool_page_size: usize, pub rest_max_mempool_txid_page_size: usize, + pub electrum_max_line_size: usize, + pub electrum_max_subscriptions: usize, + pub electrum_max_clients: usize, #[cfg(feature = "liquid")] pub parent_network: BNetwork, @@ -278,6 +281,21 @@ impl Config { .long("electrum-banner") .help("Welcome banner for the Electrum server, shown in the console to clients.") .takes_value(true) + ).arg( + Arg::with_name("electrum_max_line_size") + .long("electrum-max-line-size") + .help("Maximum size of a single Electrum request line in bytes (default: 1 MiB).") + .default_value("1048576") + ).arg( + Arg::with_name("electrum_max_subscriptions") + .long("electrum-max-subscriptions") + .help("Maximum number of scripthash subscriptions per client connection.") + .default_value("100") + ).arg( + Arg::with_name("electrum_max_clients") + .long("electrum-max-clients") + .help("Maximum number of concurrent Electrum client connections.") + .default_value("10") ); #[cfg(unix)] @@ -547,6 +565,9 @@ impl Config { "rest_max_mempool_txid_page_size", usize ), + electrum_max_line_size: value_t_or_exit!(m, "electrum_max_line_size", usize), + electrum_max_subscriptions: value_t_or_exit!(m, "electrum_max_subscriptions", usize), + electrum_max_clients: value_t_or_exit!(m, "electrum_max_clients", usize), jsonrpc_import: m.is_present("jsonrpc_import"), light_mode: m.is_present("light_mode"), main_loop_delay: value_t_or_exit!(m, "main_loop_delay", u64), diff --git a/src/electrum/server.rs b/src/electrum/server.rs index 134d53b..7a63600 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -122,17 +122,22 @@ struct Connection { chan: SyncChannel, stats: Arc, txs_limit: usize, + max_line_size: usize, + max_subscriptions: usize, die_please: Option>, #[cfg(feature = "electrum-discovery")] discovery: Option>, } impl Connection { + #[allow(clippy::too_many_arguments)] pub fn new( query: Arc, stream: ConnectionStream, stats: Arc, txs_limit: usize, + max_line_size: usize, + max_subscriptions: usize, die_please: Receiver<()>, #[cfg(feature = "electrum-discovery")] discovery: Option>, ) -> Connection { @@ -144,6 +149,8 @@ impl Connection { chan: SyncChannel::new(10), stats, txs_limit, + max_line_size, + max_subscriptions, die_please: Some(die_please), #[cfg(feature = "electrum-discovery")] discovery, @@ -294,6 +301,16 @@ impl Connection { fn blockchain_scripthash_subscribe(&mut self, params: &[Value]) -> Result { let script_hash = hash_from_value(params.first()).chain_err(|| "bad script_hash")?; + // Enforce per-client subscription limit (don't count re-subscriptions to the same hash) + if !self.status_hashes.contains_key(&script_hash) + && self.status_hashes.len() >= self.max_subscriptions + { + bail!( + "subscription limit reached ({} max per client)", + self.max_subscriptions + ); + } + let history_txids = get_history(&self.query, &script_hash[..], self.txs_limit)?; let status_hash = get_status_hash(history_txids, &self.query) .map_or(Value::Null, |h| json!(hex::encode(full_hash(&h[..])))); @@ -623,15 +640,25 @@ impl Connection { fn handle_requests( mut reader: BufReader, tx: crossbeam_channel::Sender, + max_line_size: usize, ) -> Result<()> { loop { let mut line = Vec::::new(); - reader + // Read up to max_line_size + 1 bytes to detect oversized lines + let mut limited = (&mut reader).take((max_line_size as u64).saturating_add(1)); + limited .read_until(b'\n', &mut line) .chain_err(|| "failed to read a request")?; if line.is_empty() { tx.send(Message::Done).chain_err(|| "channel closed")?; return Ok(()); + } else if line.len() > max_line_size { + let _ = tx.send(Message::Done); + bail!( + "request line too large ({} bytes, max is {})", + line.len(), + max_line_size + ) } else { if line.starts_with(&[22, 3, 1]) { // (very) naive SSL handshake detection @@ -671,7 +698,10 @@ impl Connection { let _ = reply_killer.send(()); }); - let child = spawn_thread("reader", || Connection::handle_requests(reader, tx)); + let max_line_size = self.max_line_size; + let child = spawn_thread("reader", move || { + Connection::handle_requests(reader, tx, max_line_size) + }); if let Err(e) = self.handle_replies(reply_receiver) { error!( "[{}] connection handling failed: {}", @@ -855,6 +885,9 @@ impl RPC { }); let txs_limit = config.electrum_txs_limit; + let max_line_size = config.electrum_max_line_size; + let max_subscriptions = config.electrum_max_subscriptions; + let max_clients = config.electrum_max_clients; RPC { notification: notification.sender(), @@ -872,10 +905,33 @@ impl RPC { acceptor_shutdown_sender, ); - let mut threads = HashMap::new(); + let mut threads: HashMap, Sender<()>)> = + HashMap::new(); let (garbage_sender, garbage_receiver) = crossbeam_channel::unbounded(); while let Some(stream) = acceptor.receiver().recv().unwrap() { + // Clean up finished threads before checking connection limit + while let Ok(id) = garbage_receiver.try_recv() { + if let Some((thread, killer)) = threads.remove(&id) { + let _ = killer.send(()); + if let Err(error) = thread.join() { + error!("failed to join {:?}: {:?}", id, error); + } + } + } + + // Enforce maximum connection limit + if threads.len() >= max_clients { + warn!( + "[{}] rejecting connection: max clients reached ({}/{})", + stream.addr_string(), + threads.len(), + max_clients + ); + let _ = stream.shutdown(Shutdown::Both); + continue; + } + let addr = stream.addr_string(); // explicitely scope the shadowed variables for the new thread let query = Arc::clone(&query); @@ -898,6 +954,8 @@ impl RPC { stream, stats, txs_limit, + max_line_size, + max_subscriptions, peace_receiver, #[cfg(feature = "electrum-discovery")] discovery, @@ -911,15 +969,6 @@ impl RPC { trace!("[{}] spawned {:?}", addr, spawned.thread().id()); threads.insert(spawned.thread().id(), (spawned, killer)); - while let Ok(id) = garbage_receiver.try_recv() { - if let Some((thread, killer)) = threads.remove(&id) { - trace!("[{}] joining {:?}", addr, id); - let _ = killer.send(()); - if let Err(error) = thread.join() { - error!("failed to join {:?}: {:?}", id, error); - } - } - } } // Drop these drop(acceptor); diff --git a/start b/start index 5acccb6..340da7d 100755 --- a/start +++ b/start @@ -153,6 +153,9 @@ do # prepare run-time variables UTXOS_LIMIT=500 ELECTRUM_TXS_LIMIT=500 + ELECTRUM_MAX_LINE_SIZE=1048576 # 1 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100 + ELECTRUM_MAX_CLIENTS=10 MAIN_LOOP_DELAY=500 DAEMON_CONF="${HOME}/${DAEMON}.conf" HTTP_SOCKET_FILE="${HOME}/socket/esplora-${DAEMON}-${NETWORK}" @@ -167,43 +170,73 @@ do if [ "${NODENAME}" = "node201" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 MAIN_LOOP_DELAY=14000 fi if [ "${NODENAME}" = "node204" ] && [ "${LOCATION}" = "sg1" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NODENAME}" = "node204" ] && [ "${LOCATION}" = "hnl" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NODENAME}" = "node206" ] && [ "${LOCATION}" = "tk7" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NODENAME}" = "node211" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NODENAME}" = "node212" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NODENAME}" = "node213" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NODENAME}" = "node214" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${NETWORK}" = "testnet4" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ "${LOCATION}" = "fmt" ];then UTXOS_LIMIT=9000 ELECTRUM_TXS_LIMIT=9000 + ELECTRUM_MAX_LINE_SIZE=16777216 # 16 MiB + ELECTRUM_MAX_SUBSCRIPTIONS=100000 + ELECTRUM_MAX_CLIENTS=10000 fi if [ ! -e "${POPULAR_SCRIPTS_FILE}" ];then @@ -229,6 +262,9 @@ do --address-search \ --utxos-limit "${UTXOS_LIMIT}" \ --electrum-txs-limit "${ELECTRUM_TXS_LIMIT}" \ + --electrum-max-line-size "${ELECTRUM_MAX_LINE_SIZE}" \ + --electrum-max-subscriptions "${ELECTRUM_MAX_SUBSCRIPTIONS}" \ + --electrum-max-clients "${ELECTRUM_MAX_CLIENTS}" \ -vv sleep 1 done From e53631a31875570b3686dbb0c8a4acc37b0dc8a0 Mon Sep 17 00:00:00 2001 From: junderw Date: Fri, 8 May 2026 22:14:56 +0900 Subject: [PATCH 5/6] Fix: Add Electrum timeout to connections --- src/config.rs | 7 +++++++ src/electrum/server.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/config.rs b/src/config.rs index 00982a4..1371c30 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,6 +68,7 @@ pub struct Config { pub electrum_max_line_size: usize, pub electrum_max_subscriptions: usize, pub electrum_max_clients: usize, + pub electrum_idle_timeout: u64, #[cfg(feature = "liquid")] pub parent_network: BNetwork, @@ -296,6 +297,11 @@ impl Config { .long("electrum-max-clients") .help("Maximum number of concurrent Electrum client connections.") .default_value("10") + ).arg( + Arg::with_name("electrum_idle_timeout") + .long("electrum-idle-timeout") + .help("Maximum idle time in seconds since the last client request before disconnecting the Electrum connection.") + .default_value("600") ); #[cfg(unix)] @@ -568,6 +574,7 @@ impl Config { electrum_max_line_size: value_t_or_exit!(m, "electrum_max_line_size", usize), electrum_max_subscriptions: value_t_or_exit!(m, "electrum_max_subscriptions", usize), electrum_max_clients: value_t_or_exit!(m, "electrum_max_clients", usize), + electrum_idle_timeout: value_t_or_exit!(m, "electrum_idle_timeout", u64), jsonrpc_import: m.is_present("jsonrpc_import"), light_mode: m.is_present("light_mode"), main_loop_delay: value_t_or_exit!(m, "main_loop_delay", u64), diff --git a/src/electrum/server.rs b/src/electrum/server.rs index 7a63600..e69d9d4 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -12,6 +12,7 @@ use std::sync::atomic::AtomicBool; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use bitcoin::hashes::sha256d::Hash as Sha256dHash; use error_chain::ChainedError; @@ -124,6 +125,8 @@ struct Connection { txs_limit: usize, max_line_size: usize, max_subscriptions: usize, + idle_timeout: u64, + last_request_at: Instant, die_please: Option>, #[cfg(feature = "electrum-discovery")] discovery: Option>, @@ -138,6 +141,7 @@ impl Connection { txs_limit: usize, max_line_size: usize, max_subscriptions: usize, + idle_timeout: u64, die_please: Receiver<()>, #[cfg(feature = "electrum-discovery")] discovery: Option>, ) -> Connection { @@ -151,6 +155,8 @@ impl Connection { txs_limit, max_line_size, max_subscriptions, + idle_timeout, + last_request_at: Instant::now(), die_please: Some(die_please), #[cfg(feature = "electrum-discovery")] discovery, @@ -563,6 +569,7 @@ impl Connection { } fn handle_replies(&mut self, shutdown: crossbeam_channel::Receiver<()>) -> Result<()> { + let idle_check = crossbeam_channel::tick(Duration::from_secs(5)); loop { crossbeam_channel::select! { recv(self.chan.receiver()) -> msg => { @@ -570,6 +577,7 @@ impl Connection { trace!("RPC {:?}", msg); match msg { Message::Request(line) => { + self.last_request_at = Instant::now(); let result = self.handle_line(&line); self.send_values(&[result])? } @@ -589,6 +597,19 @@ impl Connection { self.chan.close(); return Ok(()); } + recv(idle_check) -> _ => { + let idle_for = self.last_request_at.elapsed(); + if idle_for > Duration::from_secs(self.idle_timeout) { + info!( + "[{}] closing idle connection after {} seconds without requests (timeout: {} seconds)", + self.stream.addr_string(), + idle_for.as_secs(), + self.idle_timeout, + ); + self.chan.close(); + return Ok(()); + } + } } } } @@ -888,6 +909,7 @@ impl RPC { let max_line_size = config.electrum_max_line_size; let max_subscriptions = config.electrum_max_subscriptions; let max_clients = config.electrum_max_clients; + let idle_timeout = config.electrum_idle_timeout; RPC { notification: notification.sender(), @@ -956,6 +978,7 @@ impl RPC { txs_limit, max_line_size, max_subscriptions, + idle_timeout, peace_receiver, #[cfg(feature = "electrum-discovery")] discovery, From d6d580c097c7fb8c7e4acdabbf15c438a2158a65 Mon Sep 17 00:00:00 2001 From: junderw Date: Fri, 8 May 2026 23:04:16 +0900 Subject: [PATCH 6/6] Fix: More fine grained timeout control --- src/electrum/server.rs | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/electrum/server.rs b/src/electrum/server.rs index e69d9d4..1833384 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -568,9 +568,27 @@ impl Connection { Ok(()) } + fn close_idle_connection(&mut self, idle_for: Duration) { + info!( + "[{}] closing idle connection after {} seconds without requests (timeout: {} seconds)", + self.stream.addr_string(), + idle_for.as_secs(), + self.idle_timeout, + ); + self.chan.close(); + } + fn handle_replies(&mut self, shutdown: crossbeam_channel::Receiver<()>) -> Result<()> { - let idle_check = crossbeam_channel::tick(Duration::from_secs(5)); + let idle_timeout = Duration::from_secs(self.idle_timeout); loop { + let elapsed = self.last_request_at.elapsed(); + if elapsed > idle_timeout { + self.close_idle_connection(elapsed); + return Ok(()); + } + let remaining = idle_timeout.saturating_sub(elapsed); + let idle_deadline = crossbeam_channel::after(remaining); + crossbeam_channel::select! { recv(self.chan.receiver()) -> msg => { let msg = msg.chain_err(|| "channel closed")?; @@ -597,18 +615,10 @@ impl Connection { self.chan.close(); return Ok(()); } - recv(idle_check) -> _ => { + recv(idle_deadline) -> _ => { let idle_for = self.last_request_at.elapsed(); - if idle_for > Duration::from_secs(self.idle_timeout) { - info!( - "[{}] closing idle connection after {} seconds without requests (timeout: {} seconds)", - self.stream.addr_string(), - idle_for.as_secs(), - self.idle_timeout, - ); - self.chan.close(); - return Ok(()); - } + self.close_idle_connection(idle_for); + return Ok(()); } } }