Skip to content

electrs: after_txid pagination can stop at mempool/chain boundary for address tx history #142

@andrewtoth

Description

@andrewtoth

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;

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions