Hi mempool team 👋
My colleague at @ExodusMovement @saravanan7mani7 has uncovered an issue with https://github.com/mempool/electrs, but is unable to create an issue or pull request.
Below is the issue and a potential fix:
Summary
GET /address/:address/txs and POST /addresses/txs - if after_txid is the last mempool tx for an address, the next page can return empty instead of continuing with confirmed txs. This breaks client-side full-history pagination.
commit fa0999c4168a72acdcc781a3c7758e710942b0c6
Author: Saravanan Mani <228955468+saravanan7mani7@users.noreply.github.com>
Date: Tue Mar 24 06:34:13 2026 +0530
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.
diff --git a/contributors/saravanan7mani7.txt b/contributors/saravanan7mani7.txt
new file mode 100644
index 0000000..b3d2fb6
--- /dev/null
+++ b/contributors/saravanan7mani7.txt
@@ -0,0 +1,3 @@
+I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022.
+
+Signed: saravanan7mani7
diff --git a/src/rest.rs b/src/rest.rs
index 48bdb90..cebf43e 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,7 @@ 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 +1074,7 @@ 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,7 +2149,9 @@ impl From<address::AddressError> for HttpError {
#[cfg(test)]
mod tests {
+ use super::{confirmed_after_txid, TxidLocation};
use crate::rest::HttpError;
+ use bitcoin::Txid;
use serde_json::Value;
use std::collections::HashMap;
@@ -2214,6 +2217,39 @@ 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;
Hi mempool team 👋
My colleague at @ExodusMovement @saravanan7mani7 has uncovered an issue with https://github.com/mempool/electrs, but is unable to create an issue or pull request.
Below is the issue and a potential fix:
commit fa0999c4168a72acdcc781a3c7758e710942b0c6 Author: Saravanan Mani <228955468+saravanan7mani7@users.noreply.github.com> Date: Tue Mar 24 06:34:13 2026 +0530 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. diff --git a/contributors/saravanan7mani7.txt b/contributors/saravanan7mani7.txt new file mode 100644 index 0000000..b3d2fb6 --- /dev/null +++ b/contributors/saravanan7mani7.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. + +Signed: saravanan7mani7 diff --git a/src/rest.rs b/src/rest.rs index 48bdb90..cebf43e 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,7 @@ 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 +1074,7 @@ 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,7 +2149,9 @@ impl From<address::AddressError> for HttpError { #[cfg(test)] mod tests { + use super::{confirmed_after_txid, TxidLocation}; use crate::rest::HttpError; + use bitcoin::Txid; use serde_json::Value; use std::collections::HashMap; @@ -2214,6 +2217,39 @@ 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;