Skip to content

fix(mempool): reconcile conflicts before insertion - #254

Open
EddieHouston wants to merge 2 commits into
Blockstream:new-indexfrom
EddieHouston:fix/mempool-conflict-reconciliation
Open

fix(mempool): reconcile conflicts before insertion#254
EddieHouston wants to merge 2 commits into
Blockstream:new-indexfrom
EddieHouston:fix/mempool-conflict-reconciliation

Conversation

@EddieHouston

Copy link
Copy Markdown
Collaborator

Summary

Prevent submitted replacement transactions from temporarily coexisting with the transactions they replace in electrs' local mempool.

  • detect locally indexed transactions that spend the same prevouts as an incoming transaction or package
  • follow mempool spend edges to collect and remove all descendants of those conflicts
  • reconcile conflicts before mutating any indexes, while keeping the ownership-aware edge removal from fix(mempool): tolerate missing/foreign edges on eviction #234 as defense in depth
  • reject an internally conflicting incoming batch before insertion
  • cover both broadcast_raw and submit_package immediate-insertion paths through the shared Mempool::add() implementation

This prevents the inconsistent state tracked in #236, where txstore and history could contain two conflicting spenders while edges could represent only one.

Fixes #236

Tests

  • cargo test --test rest test_rest_mempool_rbf_reconciled -- --nocapture
  • cargo test --test rest -- --nocapture (26 passed)
  • cargo check --tests
  • cargo check --tests --features liquid

The REST regressions verify immediately after both raw and package broadcast—before the next periodic mempool sync—that the replaced transaction is absent and the replacement is queryable.

@EddieHouston
EddieHouston force-pushed the fix/mempool-conflict-reconciliation branch from 7b7b677 to 4356819 Compare August 27, 2026 15:52
@EddieHouston
EddieHouston marked this pull request as ready for review August 28, 2026 07:24
@EddieHouston
EddieHouston requested a review from Randy808 August 28, 2026 07:24
Comment thread src/new_index/mempool.rs Outdated
// 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)?;

@shesek shesek Aug 28, 2026

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.

Periodic full snapshot syncing through Mempool::update() is already guaranteed to be consistent and free of conflicts (we evict anything that bitcoind has before adding), so we could avoid the conflicts_and_descendants check in that path.

I would make a separate method for manual tx insertion, that first evicts the conflicts and then calls Mempool::add(), which can continue to assume the given transactions are conflict-free (would be good to also clearly document that expectation).

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. Moved conflict/descendant reconciliation into a separate add_submitted() method used only by add_by_txid() and add_by_txids(). The periodic snapshot path continues calling add() directly and retains its conflict-free input assumption.

Comment thread src/new_index/mempool.rs
.collect();

// Lookup remaining spent prevouts in mempool & on-chain
// 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add_by_txids not evicting replaced txs in submit_package

2 participants