Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/new_index/mempool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,19 @@ impl Mempool {
// Fails if any are missing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can fail when inserting submitted txs, if they descent from ancestor transactions that bitcoind has in its mempool view but we don't yet.

We could proactively fetch missing ancestors for submitted txs, but I would opt to keep this simpler and just avoid adding it to the local mempool view until the next periodic sync (the current behavior).

There's one thing we could improve though: broadcast_raw()/submit_package() could explicitly identify this failure case and return a 202 Accepted with a message saying the tx was submitted to the network but still not available in the local view. Currently this will fail with a 400, despite the network submission being successful.

@EddieHouston EddieHouston Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that we should not proactively fetch missing unconfirmed ancestors here. If a submitted transaction depends on an ancestor present in bitcoind but not yet in electrs’s local view, immediate insertion fails and the next periodic snapshot adds the complete dependency set.

I traced the response path again: both broadcast_raw() and submit_package() currently discard the local add_by_txid(s) result after successful daemon submission (let _ = ...). Therefore this case returns the normal successful daemon response (currently 200, not 400) while local visibility is deferred. I left that behavior unchanged. We could separately expose this outcome as 202 Accepted, but that would require changing the query/REST response contract. Maybe better in a new PR?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, sorry, I misremembered add_by_txid(s) errors as being propagated.

I agree that the current behavior, returning 200 in both cases, is already acceptable.

But I still think it would be nicer to use 202 to indicate deferred processing to clients, e.g. so that Esplora can display an appropriate message instead of immediately redirecting to the tx page and showing a "tx not found" error.

It's not high priority and could be done in a separate PR, but it's also a small change that could make sense to bundle with this PR if we expand its scope to more generally improve handling of submitted txs. It does have some potential to break clients that explicitly check for 200, but I think it should be quite common to treat any 2xx as successful.

Otoh, the fact that we don't have official point releases, a changelog for breaking changes or versioned API endpoints does make me more hesitant to change the REST contract. Perhaps a good time to prioritize addressing that?

txos.extend(self.lookup_txos(remain_prevouts)?);

// Transactions submitted through broadcast_raw()/submit_package() are inserted into the
// local view immediately, before the next periodic sync has a chance to remove transactions
// they replaced in bitcoind. Reconcile those conflicts here so the indexes never contain two
// spenders for one outpoint. Descendants of a replaced transaction are no longer valid either.
let conflicts = self.conflicts_and_descendants(&txs_map)?;
Comment thread
shesek marked this conversation as resolved.
Outdated
if !conflicts.is_empty() {
debug!(
"removing {} conflicting mempool transactions before insertion",
conflicts.len()
);
self.remove(conflicts.iter().collect());
}

// Add to txstore and indexes
for (txid, tx) in txs_map {
self.txstore.insert(txid, tx);
Expand Down Expand Up @@ -464,6 +477,58 @@ impl Mempool {
Ok(())
}

fn conflicts_and_descendants(
&self,
txs_map: &HashMap<Txid, Transaction>,
) -> Result<HashSet<Txid>> {
let mut incoming_spends = HashMap::new();
let mut to_remove = HashSet::new();

for (txid, tx) in txs_map {
for txin in &tx.input {
if let Some(other_txid) = incoming_spends.insert(txin.previous_output, *txid) {
if other_txid != *txid {
bail!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this failure case actually happen if the tx package was considered valid and accepted by bitcoind?

"incoming mempool transactions {} and {} both spend outpoint {}:{}",
other_txid,
txid,
txin.previous_output.txid,
txin.previous_output.vout
);
}
}

if let Some((indexed_txid, _)) = self.edges.get(&txin.previous_output) {
if indexed_txid != txid && !txs_map.contains_key(indexed_txid) {
to_remove.insert(*indexed_txid);
}
}
}
}

// Follow spend edges from every output of each conflict to collect the full descendant
// closure without scanning the whole mempool.
let mut pending = to_remove.iter().copied().collect::<Vec<_>>();
while let Some(txid) = pending.pop() {
let Some(tx) = self.txstore.get(&txid) else {
continue;
};
for vout in 0..tx.output.len() {
let outpoint = OutPoint {
txid,
vout: vout as u32,
};
if let Some((descendant_txid, _)) = self.edges.get(&outpoint) {
if to_remove.insert(*descendant_txid) {
pending.push(*descendant_txid);
}
}
}
}

Ok(to_remove)
}

fn lookup_txo(&self, outpoint: &OutPoint) -> Option<TxOut> {
self.txstore
.get(&outpoint.txid)
Expand Down
70 changes: 42 additions & 28 deletions tests/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1576,13 +1576,22 @@ fn test_rest_liquid_block() -> Result<()> {

#[cfg(not(feature = "liquid"))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Cover descendant eviction and Liquid’s affected indexes — tests/rest.rs:1577

The tests exercise only a direct A→B replacement and both are disabled under liquid. The new implementation promises descendant closure and, under Liquid, mutates asset_history and asset_issuance. Add a production-path case where A has descendants before B replaces it, asserting the complete closure disappears from histories, UTXOs, spends, and transaction lookup. The equivalent Elements integration path also needs coverage.

#[test]
fn test_rest_mempool_rbf_eviction() -> Result<()> {
// Regression test for the mempool eviction panic ("missing mempool edge
// for outpoint"): tx A and its RBF replacement B spend the same outpoint
// and transiently coexist in the local mempool view when B is injected
// through the broadcast endpoint (add_by_txid) while A is still indexed.
// B's add() clobbers A's `edges` entry; the next sync round evicts A and
// must tolerate the missing/foreign edge instead of panicking.
fn test_rest_mempool_rbf_reconciled_on_broadcast() -> Result<()> {
test_rest_mempool_rbf_reconciliation(false)
}

#[cfg(not(feature = "liquid"))]
#[test]
fn test_rest_mempool_rbf_reconciled_on_package_broadcast() -> Result<()> {
test_rest_mempool_rbf_reconciliation(true)
}

#[cfg(not(feature = "liquid"))]
fn test_rest_mempool_rbf_reconciliation(use_package: bool) -> Result<()> {
// Tx A and its RBF replacement B spend the same outpoint. B is inserted
// immediately through add_by_txid(s), before the periodic sync can evict A.
// The insertion must reconcile the conflict instead of exposing both
// transactions in the local indexes.
let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap();

// Broadcast tx A via the node wallet, explicitly BIP125-replaceable,
Expand Down Expand Up @@ -1616,22 +1625,28 @@ fn test_rest_mempool_rbf_eviction() -> Result<()> {
.node_client()
.call("finalizepsbt", &[processed["psbt"].clone()])?;
let b_hex = finalized["hex"].as_str().expect("finalized tx hex");
let b_bytes = Vec::from_hex(b_hex).expect("valid finalized tx hex");
let txid_b = bitcoin::consensus::deserialize::<bitcoin::Transaction>(&b_bytes)
.expect("valid finalized transaction")
.compute_txid()
.to_string();

// Inject B through the electrs broadcast endpoint: the node accepts the
// replacement (evicting A node-side), and add_by_txid() indexes B locally
// while A is still present - clobbering A's edges entry for the shared
// outpoint.
let broadcast_resp = ureq::post(&format!("http://{}/tx", rest_addr)).send(b_hex)?;
assert_eq!(broadcast_resp.status(), 200);
let txid_b = broadcast_resp.into_body().read_to_string()?;

// The next sync evicts A from the local view. The unfixed code passes the
// eviction assert here - but only by STEALING B's edge entry (any Some()
// satisfied it), which is the actual arming step of the crash.
tester.sync()?;
// Inject B through either immediate insertion path. bitcoind accepts the
// replacement and evicts A node-side; electrs must do the same locally as
// part of this request, without waiting for tester.sync().
if use_package {
let response = ureq::post(&format!("http://{}/txs/package", rest_addr))
.send_json([b_hex])?;
assert_eq!(response.status(), 200);
} else {
let response = ureq::post(&format!("http://{}/tx", rest_addr)).send(b_hex)?;
assert_eq!(response.status(), 200);
assert_eq!(response.into_body().read_to_string()?.trim(), txid_b);
}

// B remains queryable in the mempool; A is gone.
let res = get_json(rest_addr, &format!("/tx/{}", txid_b.trim()))?;
// B is immediately queryable and A is immediately absent. Before #236,
// this assertion observed both conflicting transactions until the poll.
let res = get_json(rest_addr, &format!("/tx/{}", txid_b))?;
assert_eq!(res["status"]["confirmed"].as_bool(), Some(false));
let gone = ureq::get(&format!("http://{}/tx/{}", rest_addr, txid_a))
.config()
Expand All @@ -1640,16 +1655,15 @@ fn test_rest_mempool_rbf_eviction() -> Result<()> {
.call()?;
assert_eq!(gone.status(), 404);

// Now B itself leaves the mempool (confirmed here; RBF-of-B or expiry are
// equivalent). Evicting B finds its edge entry gone - stolen by A's
// eviction above - and the unfixed code panics with "missing mempool edge
// for outpoint", killing the sync loop. The fixed code only removes an
// edge its evicted tx still owns, so B's edge survived A's eviction and
// this round stays clean.
// A periodic sync should preserve the already-reconciled state.
tester.sync()?;

// B can subsequently leave the mempool without missing-edge warnings or
// a panic, and the server remains fully alive.
tester.mine()?;
tester.sync()?;

let res = get_json(rest_addr, &format!("/tx/{}", txid_b.trim()))?;
let res = get_json(rest_addr, &format!("/tx/{}", txid_b))?;
assert_eq!(res["status"]["confirmed"].as_bool(), Some(true));

// And the server is still fully alive.
Expand Down
Loading