blockencodings: fix extra transaction count #35727

pull instagibbs wants to merge 2 commits into bitcoin:master from instagibbs:2026-07-extra_count_under changing 2 files +58 −17
  1. instagibbs commented at 11:36 AM on July 15, 2026: member

    A short ID collision can invalidate a mempool-sourced transaction after an unrelated transaction was found in extra_txn.

    Track each slot's source so extra_count is decremented only when the invalidated slot came from extra_txn. Retain the source after a collision to preserve the rule that later candidates do not refill the slot.

  2. DrahtBot commented at 11:37 AM on July 15, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35727.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    ACK l0rinc, andrewtoth, sedited
    Stale ACK davidgumberg

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #35724 (cmpctblock: Improve logging of cmpctblock message reconstruction statistics [part of prefill series] by davidgumberg)
    • #35558 (p2p: Prefill compact blocks by davidgumberg)
    • #35368 (tracing: add block header and compact block tracepoints by w0xlt)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. in src/blockencodings.cpp:167 in 758640a0ba
     161 | @@ -162,7 +162,9 @@ ReadStatus PartiallyDownloadedBlock::InitData(const CBlockHeaderAndShortTxIDs& c
     162 |                          txn_available[idit->second]->GetWitnessHash() != extra_txn[i].second->GetWitnessHash()) {
     163 |                      txn_available[idit->second].reset();
     164 |                      mempool_count--;
     165 | -                    extra_count--;
     166 | +                    // The incumbent may have come from the mempool, so keep
     167 | +                    // extra_count as a conservative lower bound.
     168 | +                    if (extra_count > 0) extra_count--;
    


    l0rinc commented at 8:55 PM on July 15, 2026:

    My main concern is that this masks the accounting problem rather than fixing it. If the collision invalidates a mempool-sourced transaction after an unrelated extra transaction has been counted, extra_count > 0, so the guard still subtracts that unrelated credit. I've reimplemented it in https://github.com/l0rinc/bitcoin/pull/229, which fails with [0 != 1] against this implementation.

    Could we track the source of each slot and decrement extra_count only when the invalidated transaction came from extra_txn?


    instagibbs commented at 9:03 PM on July 15, 2026:

    yes, but I think it involves another vector allocation of bools, and I'm not sure it's worth slowing down anything for a log detail... thoughts? Maybe something simpler I didn't consider


    l0rinc commented at 9:18 PM on July 15, 2026:

    tx_source replaces the previous have_txn, extending the two states to three - but we don't have an extra vector allocation there.


    instagibbs commented at 10:52 AM on July 16, 2026:

    ah sorry I have reading comprehension at the end of the day, will look


    instagibbs commented at 11:17 AM on July 16, 2026:

    feel free to open the PR, I'll close this one

    edit: Was told to just take it, so I shall

  4. l0rinc changes_requested
  5. instagibbs force-pushed on Jul 16, 2026
  6. instagibbs renamed this:
    blockencodings: avoid extra_count underflow
    blockencodings: fix extra transaction count
    on Jul 16, 2026
  7. in src/blockencodings.cpp:167 in 0c69909e3a
     163 | @@ -162,7 +164,7 @@ ReadStatus PartiallyDownloadedBlock::InitData(const CBlockHeaderAndShortTxIDs& c
     164 |                          txn_available[idit->second]->GetWitnessHash() != extra_txn[i].second->GetWitnessHash()) {
     165 |                      txn_available[idit->second].reset();
     166 |                      mempool_count--;
     167 | -                    extra_count--;
     168 | +                    if (tx_source[idit->second] == TxSource::EXTRA) extra_count--;
    


    l0rinc commented at 5:10 PM on July 16, 2026:

    0c69909 blockencodings: fix extra transaction count:

    nit: we can make this branchless if you think that's better (not sure, but that also aligns with the other field updated):

                        extra_count -= (tx_source[idit->second] == TxSource::EXTRA);
    

    davidgumberg commented at 6:19 PM on July 16, 2026:

    feel-free-to-ignore: Would be nice to separate the extra_count vs the mempool_count e.g.:

    https://github.com/bitcoin/bitcoin/commit/c9632a4cc1fa47d5a07d8f1207a2dce2982c2be8

    But that can also be done after this PR


    davidgumberg commented at 6:26 PM on July 16, 2026:

    On second thought I think this should definitely be done outside of this PR, since there should probably be tests added


    instagibbs commented at 2:58 PM on July 17, 2026:

    yes I have an eye for a follow-up; I'd like to increase fuzz coverage and might be a good fit


    instagibbs commented at 2:59 PM on July 17, 2026:

    done

  8. in src/test/blockencodings_tests.cpp:376 in 0c69909e3a outdated
     371 | +        extra_txn[2] = {block.vtx[2]->GetWitnessHash(), non_block_tx};
     372 | +        TestPartiallyDownloadedBlock partial_block_with_extra_collision{&pool};
     373 | +        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.InitData(cmpctblock, extra_txn), READ_STATUS_OK);
     374 | +        BOOST_CHECK(partial_block_with_extra_collision.IsTxAvailable(1));
     375 | +        BOOST_CHECK(!partial_block_with_extra_collision.IsTxAvailable(2));
     376 | +        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.GetExtraCount(), 1U);
    


    davidgumberg commented at 5:41 PM on July 16, 2026:

    not blocking: probably a good idea to assert the mempool count here.


  9. in src/test/blockencodings_tests.cpp:147 in 43bca48c89
     143 | @@ -144,6 +144,13 @@ class TestHeaderAndShortIDs {
     144 |      SERIALIZE_METHODS(TestHeaderAndShortIDs, obj) { READWRITE(obj.header, obj.nonce, Using<VectorFormatter<CustomUintFormatter<CBlockHeaderAndShortTxIDs::SHORTTXIDS_LENGTH>>>(obj.shorttxids), obj.prefilledtxn); }
     145 |  };
     146 |  
     147 | +class TestPartiallyDownloadedBlock : public PartiallyDownloadedBlock {
    


    l0rinc commented at 5:52 PM on July 16, 2026:

    43bca48 test: characterize extra transaction miscount:

    nit: can be struct and we can omit the public


    instagibbs commented at 2:59 PM on July 17, 2026:

    done

  10. in src/blockencodings.cpp:117 in 0c69909e3a
     112 | @@ -113,16 +113,18 @@ ReadStatus PartiallyDownloadedBlock::InitData(const CBlockHeaderAndShortTxIDs& c
     113 |      if (shorttxids.size() != cmpctblock.shorttxids.size())
     114 |          return READ_STATUS_FAILED; // Short ID collision
     115 |  
     116 | -    std::vector<bool> have_txn(txn_available.size());
     117 | +    enum class TxSource : uint8_t { NONE, MEMPOOL, EXTRA };
     118 | +    // A non-NONE source also marks a collided slot as seen
    


    l0rinc commented at 5:53 PM on July 16, 2026:

    0c69909 blockencodings: fix extra transaction count:

    nit: we could add a COLLIDED here instead of the comment


    davidgumberg commented at 6:07 PM on July 16, 2026:

    +1 and then COLLIDED can be used in place of the txn_available check, e.g.:

    } else if (tx_source[idit->second] != TxSource::COLLIDED) {
        // If we find two mempool txn that match the short id, just request it.
        // This should be rare enough that the extra bandwidth doesn't matter,
        // but eating a round-trip due to FillBlock failure would be annoying
        txn_available[idit->second].reset();
        mempool_count--;
        tx_source[idit->second] = TxSource::COLLIDED;
    }
    

    That makes the collision logic a lot less subtle.


    instagibbs commented at 2:59 PM on July 17, 2026:

    done, readability improvements are good if we're already here

  11. l0rinc approved
  12. l0rinc commented at 5:54 PM on July 16, 2026: contributor

    ACK 0c69909e3a2e8671e48ed360cec20b232674605c

    Left a few non-blocking nits (applied them to https://github.com/l0rinc/bitcoin/pull/229 for convenience) - happy to reack if you take them.

  13. davidgumberg commented at 6:25 PM on July 16, 2026: contributor

    crACK https://github.com/bitcoin/bitcoin/commit/0c69909e3a2e8671e48ed360cec20b232674605c

    TxSource is nice, IMO makes this code easier to reason about. I think adding an explicit COLLIDED state as suggested above by l0rinc is a good idea.

  14. test: characterize extra transaction miscount be4e64d9e4
  15. blockencodings: fix extra transaction count
    A short ID collision can invalidate a mempool-sourced transaction after an unrelated transaction was found in extra_txn.
    
    Track each slot's source so extra_count is decremented only when the invalidated slot came from extra_txn. Mark collided slots explicitly so later candidates do not refill them.
    6aa5d8d948
  16. instagibbs force-pushed on Jul 17, 2026
  17. instagibbs commented at 2:59 PM on July 17, 2026: member

    addressed all feedback, pushed a couple more coverage cases. will work on making fuzz coverage of collisions as a followup

  18. in src/blockencodings.cpp:176 in 6aa5d8d948


    l0rinc commented at 7:27 PM on July 17, 2026:

    nit: not necessarily suggesting we add them, but it's what I used to test the changes locally.

    Assume(size_t(std::ranges::count_if(txn_available, [](auto& tx) { return tx != nullptr; })) == prefilled_count + mempool_count);
    Assume(size_t(std::ranges::count(tx_source, TxSource::EXTRA)) == extra_count);
    Assume(extra_count <= mempool_count);
    
  19. in src/test/blockencodings_tests.cpp:150 in 6aa5d8d948
     146 | @@ -147,6 +147,7 @@ class TestHeaderAndShortIDs {
     147 |  struct TestPartiallyDownloadedBlock : PartiallyDownloadedBlock {
     148 |      using PartiallyDownloadedBlock::PartiallyDownloadedBlock;
     149 |  
     150 | +    size_t GetMempoolCount() const { return mempool_count; }
    


    l0rinc commented at 8:29 PM on July 17, 2026:

    6aa5d8d blockencodings: fix extra transaction count:

    I think this should move to the characterization test to prove that the fix doesn't change that behavior


    instagibbs commented at 2:47 PM on July 20, 2026:

    will consider if I need to retouch

  20. in src/test/blockencodings_tests.cpp:386 in 6aa5d8d948
     382 | +        TestPartiallyDownloadedBlock partial_block_with_extra_source_collision{&pool};
     383 | +        BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.InitData(cmpctblock, extra_txn), READ_STATUS_OK);
     384 | +        BOOST_CHECK(!partial_block_with_extra_source_collision.IsTxAvailable(1));
     385 | +        BOOST_CHECK(!partial_block_with_extra_source_collision.IsTxAvailable(2));
     386 | +        BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.GetMempoolCount(), 0U);
     387 | +        BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.GetExtraCount(), 0U);
    


    l0rinc commented at 8:35 PM on July 17, 2026:

    6aa5d8d blockencodings: fix extra transaction count:

    Same here, the first test commit is meant to help us characterize the extent of the fix. It's not immediately obvious, for example, if this line would have passed before the fix as well.

    <details><summary>test: characterize extra transaction miscount</summary>

    diff --git a/src/test/blockencodings_tests.cpp b/src/test/blockencodings_tests.cpp
    index e4200cace2..45ecfcc595 100644
    --- a/src/test/blockencodings_tests.cpp
    +++ b/src/test/blockencodings_tests.cpp
    @@ -144,6 +144,13 @@ public:
         SERIALIZE_METHODS(TestHeaderAndShortIDs, obj) { READWRITE(obj.header, obj.nonce, Using<VectorFormatter<CustomUintFormatter<CBlockHeaderAndShortTxIDs::SHORTTXIDS_LENGTH>>>(obj.shorttxids), obj.prefilledtxn); }
     };
     
    +struct TestPartiallyDownloadedBlock : PartiallyDownloadedBlock {
    +    using PartiallyDownloadedBlock::PartiallyDownloadedBlock;
    +
    +    size_t GetMempoolCount() const { return mempool_count; }
    +    size_t GetExtraCount() const { return extra_count; }
    +};
    +
     BOOST_AUTO_TEST_CASE(NonCoinbasePreforwardRTTest)
     {
         CTxMemPool& pool = *Assert(m_node.mempool);
    @@ -319,6 +326,13 @@ BOOST_AUTO_TEST_CASE(ReceiveWithExtraTransactions) {
         const CTransactionRef non_block_tx = MakeTransactionRef(std::move(mtx));
     
         CBlock block(BuildBlockTestCase(rand_ctx));
    +    // Leave one transaction missing so scanning doesn't stop before the collision.
    +    mtx = BuildTransactionTestCase();
    +    mtx.vin[0].prevout.hash = Txid::FromUint256(rand_ctx.rand256());
    +    block.vtx.push_back(MakeTransactionRef(std::move(mtx)));
    +    block.hashMerkleRoot = BlockMerkleRoot(block);
    +    while (!CheckProofOfWork(block.GetHash(), block.nBits, Params().GetConsensus())) ++block.nNonce;
    +
         std::vector<std::pair<Wtxid, CTransactionRef>> extra_txn;
         extra_txn.resize(10);
     
    @@ -352,6 +366,34 @@ BOOST_AUTO_TEST_CASE(ReceiveWithExtraTransactions) {
             // This transaction is now available via extra_txn:
             BOOST_CHECK(partial_block_with_extra.IsTxAvailable(1));
             BOOST_CHECK(partial_block_with_extra.IsTxAvailable(2));
    +
    +        // Simulate a mempool collision after finding an unrelated extra transaction.
    +        extra_txn[2] = {block.vtx[2]->GetWitnessHash(), non_block_tx};
    +        TestPartiallyDownloadedBlock partial_block_with_extra_collision{&pool};
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.InitData(cmpctblock, extra_txn), READ_STATUS_OK);
    +        BOOST_CHECK( partial_block_with_extra_collision.IsTxAvailable(1));
    +        BOOST_CHECK(!partial_block_with_extra_collision.IsTxAvailable(2));
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.GetMempoolCount(), 1);
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.GetExtraCount(), 0); // TODO: This should be 1
    +
    +        // Now also collide the extra-sourced slot: both counters decrement exactly once.
    +        extra_txn[3] = {block.vtx[1]->GetWitnessHash(), non_block_tx};
    +        TestPartiallyDownloadedBlock partial_block_with_extra_source_collision{&pool};
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.InitData(cmpctblock, extra_txn), READ_STATUS_OK);
    +        BOOST_CHECK(!partial_block_with_extra_source_collision.IsTxAvailable(1));
    +        BOOST_CHECK(!partial_block_with_extra_source_collision.IsTxAvailable(2));
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.GetMempoolCount(), 0);
    +        BOOST_CHECK_NE(partial_block_with_extra_source_collision.GetExtraCount(), 0); // TODO: This should be 0
    +
    +        // Collided slots are terminal: not even the genuine transactions refill them.
    +        extra_txn[4] = {block.vtx[2]->GetWitnessHash(), block.vtx[2]};
    +        extra_txn[5] = {block.vtx[1]->GetWitnessHash(), block.vtx[1]};
    +        TestPartiallyDownloadedBlock partial_block_no_refill{&pool};
    +        BOOST_CHECK_EQUAL(partial_block_no_refill.InitData(cmpctblock, extra_txn), READ_STATUS_OK);
    +        BOOST_CHECK(!partial_block_no_refill.IsTxAvailable(1));
    +        BOOST_CHECK(!partial_block_no_refill.IsTxAvailable(2));
    +        BOOST_CHECK_EQUAL(partial_block_no_refill.GetMempoolCount(), 0);
    +        BOOST_CHECK_NE(partial_block_no_refill.GetExtraCount(), 0); // TODO: This should be 0
         }
     }
    

    </details>

    In which case the critical fix commit would be minimal and would show the extend of its reach:

    diff --git a/src/test/blockencodings_tests.cpp b/src/test/blockencodings_tests.cpp
    --- a/src/test/blockencodings_tests.cpp	(revision fa4411f8f31e2ea4397e929e706e003e3ba4762f)
    +++ b/src/test/blockencodings_tests.cpp	(revision 1a6233f82bb6e8f88379360be5b6b94694a8fd04)
    @@ -374,7 +374,7 @@
             BOOST_CHECK( partial_block_with_extra_collision.IsTxAvailable(1));
             BOOST_CHECK(!partial_block_with_extra_collision.IsTxAvailable(2));
             BOOST_CHECK_EQUAL(partial_block_with_extra_collision.GetMempoolCount(), 1);
    -        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.GetExtraCount(), 0); // TODO: This should be 1
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_collision.GetExtraCount(), 1);
     
             // Now also collide the extra-sourced slot: both counters decrement exactly once.
             extra_txn[3] = {block.vtx[1]->GetWitnessHash(), non_block_tx};
    @@ -383,7 +383,7 @@
             BOOST_CHECK(!partial_block_with_extra_source_collision.IsTxAvailable(1));
             BOOST_CHECK(!partial_block_with_extra_source_collision.IsTxAvailable(2));
             BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.GetMempoolCount(), 0);
    -        BOOST_CHECK_NE(partial_block_with_extra_source_collision.GetExtraCount(), 0); // TODO: This should be 0
    +        BOOST_CHECK_EQUAL(partial_block_with_extra_source_collision.GetExtraCount(), 0);
     
             // Collided slots are terminal: not even the genuine transactions refill them.
             extra_txn[4] = {block.vtx[2]->GetWitnessHash(), block.vtx[2]};
    @@ -393,7 +393,7 @@
             BOOST_CHECK(!partial_block_no_refill.IsTxAvailable(1));
             BOOST_CHECK(!partial_block_no_refill.IsTxAvailable(2));
             BOOST_CHECK_EQUAL(partial_block_no_refill.GetMempoolCount(), 0);
    -        BOOST_CHECK_NE(partial_block_no_refill.GetExtraCount(), 0); // TODO: This should be 0
    +        BOOST_CHECK_EQUAL(partial_block_no_refill.GetExtraCount(), 0);
         }
     }
    

    instagibbs commented at 2:47 PM on July 20, 2026:

    will consider if I need to retouch

  21. in src/blockencodings.cpp:124 in 6aa5d8d948
     121 |      for (const auto& [wtxid, txit] : pool->txns_randomized) {
     122 |          uint64_t shortid = cmpctblock.GetShortID(wtxid);
     123 |          std::unordered_map<uint64_t, uint16_t>::iterator idit = shorttxids.find(shortid);
     124 |          if (idit != shorttxids.end()) {
     125 | -            if (!have_txn[idit->second]) {
     126 | +            if (tx_source[idit->second] == TxSource::NONE) {
    


    l0rinc commented at 8:43 PM on July 17, 2026:

    super-nit: if you feel like refactoring we could extract the repeated index here and below:

    const uint16_t idx{idit->second};
    

    instagibbs commented at 2:47 PM on July 20, 2026:

    will consider if I need to retouch

  22. l0rinc approved
  23. l0rinc commented at 8:50 PM on July 17, 2026: contributor

    retested ACK 6aa5d8d9481f5e06b10095df7f46f0532f7ecdb7

    Left a few nits, I'm also fine with merging as is.

    PR description needs updating, we no longer retain the original source:

    Track each slot's source so extra_count is decremented only when the invalidated slot came from extra_txn. Mark collided slots explicitly to preserve the rule that later candidates do not refill them.

  24. DrahtBot requested review from davidgumberg on Jul 17, 2026
  25. andrewtoth approved
  26. andrewtoth commented at 12:19 AM on July 21, 2026: contributor

    ACK 6aa5d8d9481f5e06b10095df7f46f0532f7ecdb7

  27. sedited approved
  28. sedited commented at 8:54 PM on July 21, 2026: contributor

    ACK 6aa5d8d9481f5e06b10095df7f46f0532f7ecdb7

  29. sedited merged this on Jul 21, 2026
  30. sedited closed this on Jul 21, 2026


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-06 18:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me