Skip to content

fix(tx_pool): dedupe key-image conflict list; make remove_tx idempotent#199

Open
raw391 wants to merge 1 commit into
Beldex-Coin:devfrom
raw391:fix/tx-pool-dedupe-flash-conflicts
Open

fix(tx_pool): dedupe key-image conflict list; make remove_tx idempotent#199
raw391 wants to merge 1 commit into
Beldex-Coin:devfrom
raw391:fix/tx-pool-dedupe-flash-conflicts

Conversation

@raw391

@raw391 raw391 commented Jun 5, 2026

Copy link
Copy Markdown

tx_memory_pool::have_tx_keyimges_as_spent at src/cryptonote_core/tx_pool.cpp:1447-1465 appends every conflicting txid into the conflicting vector per spent key image, with no dedupe. If one mempool tx shares two or more key images with the incoming tx, its txid is appended multiple times.

The approved-flash path in tx_pool::add_tx (tx_pool.cpp:361-365) calls remove_flash_conflicts (which starts at tx_pool.cpp:674). remove_flash_conflicts iterates that vector and calls remove_tx per entry. The first call succeeds; the second call on the same txid fails at the sorted-container check at line 760. remove_flash_conflicts treats the failure as a hard error and returns early, so the LockedTXN destructor aborts the DB batch. The LMDB row is restored, but the in-memory mutations from the first call (m_txpool_weight decrement at line 787, remove_transaction_keyimages at line 788, sorted-container erase at line 789) are not rolled back. m_spent_key_images then no longer contains entries for that txs inputs, so a subsequent tx spending the same outputs is not rejected by have_tx_keyimges_as_spent until the orphaned row is aged out or the daemon restarts.

This is reachable when a single signer produces two transactions sharing 2+ key images (a normal mempool tx and an approved flash tx for the same inputs).

Patch dedupes the conflict list at source via unordered_set, and makes remove_tx idempotent for ad-hoc callers that pass stc_it = nullptr (with an MWARNING log so the path stays observable):

   bool tx_memory_pool::have_tx_keyimges_as_spent(const transaction& tx, std::vector<crypto::hash> *conflicting) const
   {
     auto locks = tools::unique_locks(m_transactions_lock, m_blockchain);

     bool ret = false;
+    std::unordered_set<crypto::hash> seen;
     for(const auto& in: tx.vin)
     {
       CHECKED_GET_SPECIFIC_VARIANT(in, txin_to_key, tokey_in, true);
       auto it = m_spent_key_images.find(tokey_in.k_image);
       if (it != m_spent_key_images.end())
       {
         if (!conflicting)
           return true;
         ret = true;
-        conflicting->insert(conflicting->end(), it->second.begin(), it->second.end());
+        for (const auto &h : it->second)
+          if (seen.insert(h).second)
+            conflicting->push_back(h);
       }
     }
     return ret;
   }
     const auto it = stc_it ? *stc_it : find_tx_in_sorted_container(txid);
     if (it == m_txs_by_fee_and_receive_time.end())
     {
+      if (!stc_it)
+      {
+        MWARNING("remove_tx: tx " << txid << " not in sorted container, treating as already-removed");
+        return true;
+      }
       MERROR("Failed to find tx in txpool sorted list");
       return false;
     }

The bool-only fast path of have_tx_keyimges_as_spent (conflicting == nullptr) is unchanged. The pruner at line 794 passes a known-good stc_it and keeps the loud-fail semantics.

have_tx_keyimges_as_spent walks tx.vin and appends every conflicting
txid per spent key image. If one mempool tx shares 2+ key images with
the incoming tx, its txid is appended multiple times.

remove_flash_conflicts iterates that vector and calls remove_tx per
entry. The first call succeeds; the second on the same txid fails at
the sorted-container check. remove_flash_conflicts treats failure as
fatal and returns early, so the LockedTXN destructor aborts the DB
batch. The LMDB row is restored, but in-memory mutations from the
first call (m_txpool_weight decrement, remove_transaction_keyimages,
sorted-container erase) are not rolled back. m_spent_key_images then
no longer contains entries for that txs inputs, so subsequent txs
spending the same outputs are not rejected by have_tx_keyimges_as_spent
until the orphaned row is aged out or the daemon restarts.

Triggered by a single signer producing two txs sharing 2+ inputs.

Dedupes the conflict list at source in have_tx_keyimges_as_spent via
unordered_set. Makes remove_tx idempotent when called without stc_it:
a missing sorted-container entry is treated as already-removed with
an MWARNING log so the path stays observable.
@raw391 raw391 force-pushed the fix/tx-pool-dedupe-flash-conflicts branch from 75a6616 to 1769d5a Compare June 5, 2026 16:01
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.

2 participants