txindex: allow running in pruned mode #36002

pull andrewtoth wants to merge 7 commits into bitcoin:master from andrewtoth:txindex-prune changing 21 files +377 −97
  1. andrewtoth commented at 12:01 AM on August 18, 2026: contributor

    After #35531, each txindex entry now points to the block hash containing the tx instead of the file location of the block. The block hash is then used to find the file position of the block to read the tx from. This allows us to determine the hash of the containing block even if the block is later pruned, and also determine the location of the block on disk if it is later downloaded again via getblockfrompeer.

    This PR enables maintaining a txindex while pruned. If a txid lookup tries to read a tx from a pruned block, the hash of the missing block is surfaced to the caller.

    If desired, a user may call getblockfrompeer using the returned hash to download the block, then try again successfully. The downloaded block will be added to the front of the block queue, so won't be pruned again for at least 288 blocks. Of course a larger prune setting is desired if lots of historical blocks will be read, and there are privacy issues if the user is looking up their own transactions. Ideally the blocks can be fetched from a trusted node or from random peers with decoy blocks fetched occasionally.

    Resolves #10609, #12651, #16182, https://github.com/bitcoin/bitcoin/issues/35240

  2. DrahtBot commented at 12:01 AM on August 18, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    Concept ACK ajtowns, optout21, arejula27, l0rinc

    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:

    • #36480 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36480.svg"></sub> (txospenderindex: hash keys and pack positions to reduce disk usage by arejula27)
    • #35728 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35728.svg"></sub> (rpc: Properly throw on internal I/O errors in GetTransaction by maflcko)
    • #34132 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/34132.svg"></sub> (coins, dbwrapper: remove error catcher, make point-read failures fatal by l0rinc)

    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. andrewtoth force-pushed on Aug 18, 2026
  4. DrahtBot added the label CI failed on Aug 18, 2026
  5. DrahtBot commented at 12:14 AM on August 18, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/32082864144/job/95549090994</sub> <sub>LLM reason (✨ experimental): CI failed because IWYU reported an include issue (“Failure generated from IWYU”) in src/index/txindex.cpp.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  6. l0rinc commented at 12:20 AM on August 18, 2026: contributor

    Big concept ACK \:D/

  7. DrahtBot removed the label CI failed on Aug 18, 2026
  8. ajtowns commented at 5:24 AM on August 25, 2026: contributor

    This allows us to determine the hash of the containing block even if the block is later pruned,

    Hmm, I think that means the txindex starts off at ~30GB and grows at ~2.5GB/year indefinitely, so in 40 years' time might be 130GB. I guess that makes sense for nodes wanting to run a local block-explorer type setup; particularly if they keep a significant range of real blocks around; I think expected block usage is about 100GB per year, so 5 years worth of history would be half a terabyte, and probably cover most lookups.

    I'd definitely prefer to have a -prunetxindex=1 option I could set, so that my prune=2000 (~1 week worth of blocks) node could lookup txs without needing to be reprovisioned with extra disk though. (With such an option enabled, should be feasible to setup a txindex on an already pruned node without redownloading blocks, I think)

    Concept ACK, looks okay to me at first glance.

    What's the (intended) behaviour if you enable txindex on a node that is already pruned? I think it will just hit FatalErrorf in BaseIndex::ProcessBlock and shut the node down?

  9. andrewtoth commented at 8:03 PM on August 29, 2026: contributor

    What's the (intended) behaviour if you enable txindex on a node that is already pruned?

    It's the same behavior if you try and start coinstatsindex or blockfilterindex after you've pruned past their last indexed block - the startup error: Error: <index> best block of the index goes beyond pruned data (including undo data). Please disable the index or reindex (which will download the whole blockchain again)

    I'd definitely prefer to have a -prunetxindex=1 option I could set

    I think that could be added as a follow-up, and would not conflict with this approach. We could just delete entries by sequence until the last sequence maps to a block we have not yet pruned. We would have to compact the db afterwards though. That way though you will just get a "tx not found" response for a historical lookup where the index has also been pruned past, instead of the block hash.

  10. mzumsande commented at 3:04 PM on September 7, 2026: contributor

    I wonder if the current design would be more helpful for users - or a genuine partial index that would only report on data of the non-pruned range and clean up old entries every now and then.

    The alternative design would have the downside that it wouldn't report the possible blocks pruned txns might be in. Plus, it would need changes to baseindex, so more complicated.

    However, it would have the big upside that it could be enabled on an already pruned node.

  11. andrewtoth commented at 3:28 PM on September 7, 2026: contributor

    should be feasible to setup a txindex on an already pruned node without redownloading blocks

    it would need changes to baseindex, so more complicated.

    I think we can come up with something that would be interesting for both use cases, but without the complexity if we removed the "cleanup" part.

    If we start with an empty index and are pruned, we set the locator to the last pruned block we have and sync. This way we get all the blocks indexed that we have, and future blocks that are pruned we can still return the block hash. When we want to "cleanup", we can just delete the txindex folder and restart.

    the downside that it wouldn't report the possible blocks pruned txns might be in

    Indeed, this has the downside that some existing txs will return not found, and some will return the pruned block hash. So it introduces some ambiguity into the result if you create the index while already pruned. It also does not bound the txindex disk size, but that has already been reduced significantly via #35531.

  12. andrewtoth force-pushed on Sep 9, 2026
  13. andrewtoth commented at 12:34 AM on September 9, 2026: contributor

    Thanks @ajtowns @mzumsande for your thoughts.

    I've updated this change to also allow starting an index with an already pruned node. For any tx lookup miss where we don't get a block hash and started indexing after genesis, the response returns context in the error message that the tx may have been in a block that we never indexed.

    We also return the starting block of the index in getindexinfo.

    Regarding cleaning up entries in the txindex that are pruned, I don't think the complexity of this is worth it. If you are running pruned, you need at least 11 GB, and the txindex grows at about 2.5 GB per year. So every few months you can just nuke your index directory and rebuild. If you're running with a very low prune target, this shouldn't take more than a few minutes max (likely under a minute for -prune=2000).

    Wdyt?

  14. DrahtBot added the label Needs rebase on Sep 9, 2026
  15. andrewtoth force-pushed on Sep 10, 2026
  16. andrewtoth commented at 1:50 AM on September 10, 2026: contributor

    Rebased due to include conflicts with #36116.

  17. andrewtoth force-pushed on Sep 10, 2026
  18. DrahtBot added the label CI failed on Sep 10, 2026
  19. DrahtBot commented at 2:41 AM on September 10, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/34426977165/job/102714188149</sub> <sub>LLM reason (✨ experimental): CI failed because IWYU reported include/header issues (generated “Failure generated from IWYU” and exited non-zero after applying include changes).</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  20. DrahtBot removed the label Needs rebase on Sep 10, 2026
  21. DrahtBot removed the label CI failed on Sep 10, 2026
  22. arejula27 commented at 1:59 PM on September 13, 2026: contributor

    Concept ACK.

    I'm excited about this PR and I love the idea and the approach.

    I have to go deeper into the code (just did a first review to see the idea), but meanwhile I have some questions.

    BaseIndex now has two prune-related flags, AllowPrune() and AllowPartialHistory(), and they aren't really independent. Partial history is only ever set up on a pruned node, so (as I understand it) an index with AllowPartialHistory() but not AllowPrune() can't work. Would a single enum value express that relationship better, so the invariant lives in the type instead of a convention that a future index could break? Something like an AllowPrune enum with values Disallowed, FullHistory and PartialHistory.

    Another option would be to move AllowPartialHistory() to TxIndex instead. As far as I can understand no other current index could set it to true anyway. Maybe your idea was for future ones?

    Finally, I think one big addition with this is that we can even access txs on pruned nodes, since #35531 the index stores the block "virtual" reference rather than a file position , so the node knows exactly which block is missing. From there the caller can fetch that block with getblockfrompeer and retry the lookup. Right now those hashes only reach the caller inside the error string, so automating the fetch and retry means parsing the message. Would returning them as structured data be worth it? Or even an optional flag on getrawtransaction that requests the block for us in the same rpc call?

  23. andrewtoth force-pushed on Sep 18, 2026
  24. andrewtoth force-pushed on Sep 18, 2026
  25. DrahtBot added the label CI failed on Sep 18, 2026
  26. andrewtoth commented at 3:39 AM on September 18, 2026: contributor

    Thanks @arejula27. Those are good suggestions.

    I renamed AllowPrune to GetPrunePolicy which returns an enum.

    Another option would be to move AllowPartialHistory() to TxIndex instead. As far as I can understand no other current index could set it to true anyway. Maybe your idea was for future ones?

    Yes it is for now, but if these changes are accepted we could do the same exercise for txospenderindex.

    I added returning the pruned block hashes in an optional data field of the JSON error response. I kept the REST error response as a string. All REST errors are strings, and I didn't want to introduce a special case. That would be a more invasive change, since all existing clients likely always expect plain/text string error responses. Of course that can be done as a follow-up if there is interest.

  27. andrewtoth force-pushed on Sep 18, 2026
  28. DrahtBot removed the label CI failed on Sep 18, 2026
  29. arejula27 commented at 7:24 AM on September 18, 2026: contributor

    Thx! Will try the exercise! I am finishing the (deeper) code review, hope to post it soon :D

  30. in src/index/tx_lookup_result.h:22 in 94a7beb9cf
      17 | +    /// Hash of the block containing the transaction. null if the transaction
      18 | +    /// was found in the mempool or was not found.
      19 | +    uint256 block_hash;
      20 | +    /// Only set when the transaction was not found but may exist in a pruned block.
      21 | +    /// Note that these blocks may be false positives and not contain the transaction.
      22 | +    std::vector<uint256> pruned_block_hashes;
    


    arejula27 commented at 10:00 AM on September 24, 2026:

    This struct represents two possible outcomes, the tx was found or it wasn't, but the type doesn't keep its fields consistent: it can hold a tx together with pruned_block_hashes, or a block_hash without a tx. My proposal is to use std::variant<TxFound, TxMiss> rather than one struct where the outcome is implied by which fields are set.

    For example, wallet::TxState in src/wallet/transaction.h models a transaction's state the same way, as a variant of structs.

    I suggest something like:

    /// A transaction found in the mempool or in a block.
    struct TxFound {
        CTransactionRef tx;
        /// Null if the transaction was found in the mempool.
        uint256 block_hash;
    };
    
    /// A lookup that did not find the transaction.
    struct TxMiss {
        /// Blocks that may contain the transaction but were pruned. They may be false positives.
        std::vector<uint256> pruned_block_hashes;
    };
    

    arejula27 commented at 9:45 PM on September 24, 2026:

    I would personally prefer use util::expected, but i think the project approach is to use it only over real errors (like BlockReadFailed kind of errors)


    andrewtoth commented at 6:29 PM on September 29, 2026:

    Done.

  31. in src/test/txindex_tests.cpp:365 in 94a7beb9cf
     361 | @@ -352,4 +362,28 @@ BOOST_FIXTURE_TEST_CASE(txindex_reorg_keeps_stale_entries, TestChain100Setup)
     362 |      txindex.Stop();
     363 |  }
     364 |  
     365 | +BOOST_FIXTURE_TEST_CASE(txindex_pruned_lookup, TestChain100Setup)
    


    arejula27 commented at 10:04 AM on September 24, 2026:

    I would add two more scenarios, as I have tested them and they are not covered:

    • A pruned block outside the active chain must not be reported (dropping the if (in_active_chain) condition leaves txindex_tests and feature_txindex_prune.py passing). The test mines a tx, invalidates its block so it becomes stale, prunes that block and checks the lookup returns no candidates.

    • Several pruned candidates must all be returned, which is what makes pruned_block_hashes a vector (keeping only the first hash also leaves both suites passing). The test forges an entry with a colliding prefix pointing at another block, the way txindex_collision_scan_path does, prunes both blocks and checks that both hashes come back.

    I checked both cases with mutation testing, and can share the diff if it helps.


    andrewtoth commented at 6:30 PM on September 29, 2026:

    Added these cases.

  32. in src/node/transaction.cpp:158 in 94a7beb9cf
     159 | -                hashBlock = result->block_hash;
     160 | -                return result->tx;
     161 | +                return result;
     162 |              }
     163 | +        } else if (!block_index) {
     164 | +            return TxLookupResult{std::move(result.pruned_block_hashes)};
    


    arejula27 commented at 10:09 AM on September 24, 2026:

    nit: result has no tx and no block_hash in this branch, so this builds the same object again. Is there a reason not to return result; directly?

  33. in src/rpc/txoutproof.cpp:112 in 94a7beb9cf
     110 | +                    throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(result.pruned_block_hashes),
     111 | +                                       PrunedBlocksErrorData(result.pruned_block_hashes));
     112 |                  }
     113 | +                if (!result.tx || result.block_hash.IsNull()) {
     114 | +                    std::string message{"Transaction not yet in block"};
     115 | +                    if (g_txindex) message += TxIndexMissErrorDetails(g_txindex->GetSummary());
    


    arejula27 commented at 10:10 AM on September 24, 2026:

    nit: with a partial index the full error reads "Transaction not yet in block. The transaction may be in an earlier block not covered by this index, which starts at height N". "Not yet in block" suggests the tx is unconfirmed, while the added sentence suggests it may be confirmed in an old block. It sounds weird. Maybe use a neutral base message here too when the partial history detail is appended, e.g. "Transaction not found"?


    andrewtoth commented at 6:43 PM on September 29, 2026:

    Thanks, but this is no longer relevant since we will not support partial indexes in this change.

  34. arejula27 commented at 11:01 AM on September 24, 2026: contributor

    Thanks for applying the changes I asked for in the first review. I've done a deeper pass now, built it and ran txindex_tests and feature_txindex_prune.py, and have a few more notes.

    gettxoutproof only reports the pruned block hash when the tx is fully spent: with an unspent output the block comes from the UTXO set and the error is still "Block not available (pruned data)", even though pblockindex is right there. Would it make sense to report that hash too?

    When an existing txindex falls behind pruned data, the error still offers only "disable the index or reindex (which will download the whole blockchain again)", while deleting the index directory now gives a working partial index without downloading anything. Would it be worth mentioning that for indexes that allow partial history? Something like "%s best block of the index goes beyond pruned data. Please disable the index, delete its directory to rebuild it from the earliest block still on disk, or reindex (which will download the whole blockchain again)".

    On partial history ( which i really like the idea), some users prune only to save block storage but could afford a full txindex (around 30 GB plus ~2.5 GB per year, as mentioned earlier in this PR). Today they would need a full reindex to get the whole index (no partial), which on a pruned node means downloading and validating the chain again. Would a follow-up similar to the background chainstate in assumeutxo make sense? Serving lookups from the partial index while a background task fetches the missing historical blocks, indexes them and discards them, moving first_block_height down until it reaches genesis.

  35. in src/rpc/node.cpp:368 in 94a7beb9cf
     364 | @@ -365,6 +365,7 @@ static UniValue SummaryToJSON(const IndexSummary&& summary, std::string index_na
     365 |      UniValue entry(UniValue::VOBJ);
     366 |      entry.pushKV("synced", summary.synced);
     367 |      entry.pushKV("best_block_height", summary.best_block_height);
     368 | +    entry.pushKV("first_block_height", summary.first_block_height);
    


    fjahr commented at 10:42 AM on September 28, 2026:

    synced: true doesn't really mean the same thing as before. So some clients which are not checking the new first_block_height may not get what they think they are getting.


    andrewtoth commented at 6:43 PM on September 29, 2026:

    Thanks, but this is no longer relevant since we will not support partial indexes in this change.

  36. in src/index/base.cpp:133 in 94a7beb9cf
     129 | +        const CBlockIndex* start{nullptr};
     130 | +        if (GetPrunePolicy() == IndexPrunePolicy::PartialHistory && m_chainstate->m_blockman.IsPruneMode() && index_chain.Tip()) {
     131 | +            const auto& first{m_chainstate->m_blockman.GetFirstBlock(*index_chain.Tip(), BLOCK_HAVE_DATA)};
     132 | +            start = first.pprev;
     133 | +            first_block_height = first.nHeight;
     134 | +            if (start) LogInfo("%s starting at height %d; earlier history will not be indexed", GetName(), first.nHeight);
    


    fjahr commented at 10:45 AM on September 28, 2026:

    I am not sure if this is enough to communicate what is happening to the user. Starting with -prune=XXX -txindex=1 may give you a partial or full txindex depending on if you had the index on before. Possibly the partial index could use an explicit opt-in? Not sure if that is needed but it seems to be closer to what @ajtowns had in mind originally as well when he asked for -prunetxindex=1.


    andrewtoth commented at 6:43 PM on September 29, 2026:

    Thanks, but this is no longer relevant since we will not support partial indexes in this change.

  37. in src/rpc/rawtransaction.cpp:340 in 94a7beb9cf
     336 | @@ -341,10 +337,12 @@ static RPCMethod getrawtransaction()
     337 |          } else if (!f_txindex_ready) {
     338 |              errmsg = "No such mempool transaction. Blockchain transactions are still in the process of being indexed";
     339 |          } else {
     340 | -            errmsg = "No such mempool or blockchain transaction";
     341 | +            errmsg = "No such mempool or blockchain transaction" + TxIndexMissErrorDetails(g_txindex->GetSummary());
    


    fjahr commented at 11:01 AM on September 28, 2026:

    Because of what we do with f_txindex_ready above I am not sure all the infos in TxIndexMissErrorDetails would ever be relevant or possible to hit, e.g. at least the synced check and added message.


    andrewtoth commented at 6:45 PM on September 29, 2026:

    Removed this, but also it seems no longer relevant since we will not support partial indexes in this change.

  38. fjahr commented at 11:01 AM on September 28, 2026: contributor

    Would you mind splitting the PR into enabling plain prune mode and keep the partial index and all the related refactorings for a follow-up to that? I see that you added it as response to feedback but the plain prune mode should be a much simpler change, pretty much uncontroversial and relatively quick to merge. For the partial index there are a lot of edge cases imaginable, and it needs to be explained to the user pretty well. I added some comments with examples (from a very shallow first pass) that indicate to me that the partial may require more discussion and shouldn't block the plain prune mode. @arejula27 also has raised some good, related questions as well.

  39. andrewtoth force-pushed on Sep 29, 2026
  40. andrewtoth force-pushed on Sep 29, 2026
  41. andrewtoth force-pushed on Sep 29, 2026
  42. DrahtBot added the label CI failed on Sep 29, 2026
  43. andrewtoth commented at 6:41 PM on September 29, 2026: contributor

    Thank you for your reviews @arejula27 @fjahr.

    I have updated this to remove the partial index, so this can only work if synced from genesis. Partially pruned indexes are out of scope and can be left for a follow-up.

    I took the suggestions that still made sense in light of no partial pruning.

  44. DrahtBot removed the label CI failed on Sep 29, 2026
  45. in src/init.cpp:1974 in d3bc095ab2 outdated
    1969 | @@ -1972,6 +1970,9 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
    1970 |  
    1971 |      if (args.GetBoolArg("-txindex", DEFAULT_TXINDEX)) {
    1972 |          g_txindex = std::make_unique<TxIndex>(interfaces::MakeChain(node), index_cache_sizes.tx_index, false, do_reindex);
    1973 | +        if (chainman.m_blockman.IsPruneMode() && !g_txindex->AllowPrune()) {
    1974 | +            return InitError(_("Prune mode is incompatible with a txindex that still contains legacy entries. Stop the node, delete the txindex directory, and restart to rebuild the index."));
    


    optout21 commented at 9:20 PM on September 29, 2026:

    d3bc095 txindex: allow prune mode when there are no legacy entries:

    If blocks are pruned, the rebuilt txindex will be pruned. The other option is to also restart block download; that way the txindex will be full. If that's the case, is this worth mentioning as well?


    andrewtoth commented at 2:37 AM on September 30, 2026:

    I'm not sure I follow. If this line is reached, the txindex is necessarily full and the node has not yet had any blocks pruned. This error will throw when a user wants to prune but still has a legacy txindex. By "restart block download" you mean do a -reindex? If so, that's not necessary since all blocks are still present and indexed.


    optout21 commented at 7:49 AM on September 30, 2026:

    I was wrong, if blocks are pruned, and txindex is deleted, the node will fail with en error ("txindex best block of the index goes beyond pruned data").

    To the claim "If this line is reached, the txindex is necessarily full and the node has not yet had any blocks pruned": I think some weird combinations can violate this.

    Ex1: Old version, -txindex=1, no prune, fully synced to height H1; restart with -txindex=0 -prune=550 and sync for some time; upgrade and start with -txindex=1 -prune=550. There is an incomplete index with legacy entries, but blocks have been pruned. Following the advice here will result in a node not starting (with "the index goes beyond pruned data").

    Ex2: Start with an old synced unpruned node with no txindex; enable index (legacy); stop well before indexing completes; upgrade; restart with enabled prune. In this case the if will be triggered with an incomplete index, but following the advice will work, because all blocks are still present.

    The diagnostic and advice is not fully complete in Ex1 case, but since the sequence is very unlikely, and the result is non-critical (the node failure message will be shown), this should be fine.

    Feel free to Resolve.


    andrewtoth commented at 2:55 PM on September 30, 2026:

    Ex1: Old version, -txindex=1, no prune, fully synced to height H1; restart with -txindex=0 -prune=550 and sync for some time; upgrade and start with -txindex=1 -prune=550. There is an incomplete index with legacy entries, but blocks have been pruned. Following the advice here will result in a node not starting (with "the index goes beyond pruned data").

    Thanks, this edge case makes sense to me. I updated the first commit to check for this and report the correct instructions, along with a regression test.

  46. in src/index/txindex.cpp:195 in 5a90ecce1c outdated
     189 | @@ -189,9 +190,13 @@ std::optional<TxIndexResult> TxIndex::FindTx(const Txid& tx_hash) const
     190 |                  LogWarning("Block index entry %s not found for txid %s", candidate_block_hash.ToString(), tx_hash.ToString());
     191 |                  continue;
     192 |              }
     193 | -            if (!(block_index->nStatus & BLOCK_HAVE_DATA)) continue;
     194 | +            const bool in_active_chain{m_chainstate->m_chain.Contains(*block_index)};
     195 | +            if (!(block_index->nStatus & BLOCK_HAVE_DATA)) {
     196 | +                if (in_active_chain) pruned_block_hashes.push_back(candidate_block_hash);
    


    optout21 commented at 9:31 PM on September 29, 2026:

    5a90ecc txindex: return pruned block hashes from FindTx:

    I just wonder in what cases can a TX hash be present in the active chain more than once? Only duplicate coinbase (BIP30)?


    andrewtoth commented at 2:41 AM on September 30, 2026:

    Yes, the BIP30 excepted duplicate coinbases is the only case. However, multiple colliding entries can point to different pruned blocks in the active chain. We don't know which one actually contains our transaction without downloading it from a peer.


    optout21 commented at 7:54 AM on September 30, 2026:

    Ok, so the main reason is potential false positives. TXID duplication due to BIP30 may happen (but very unlikely). TXID duplication in stale blocks is not the case, the in_active_chain check filters stale blocks. Clear, thanks.


    optout21 commented at 12:08 PM on October 5, 2026:

    071a868 txindex: return pruned block hashes from FindTx:

    Theoretically multiple false positives are possible for the same block, in which case a block hash will be included multiple times. The list could be de-duplicated.


    andrewtoth commented at 12:05 AM on October 9, 2026:

    I changed the TxMiss::pruned_block_hashes from a std::vector to a std::set to reflect this.

  47. in src/index/tx_lookup_result.h:25 in 5a90ecce1c outdated
      20 | +};
      21 | +
      22 | +struct TxMiss {
      23 | +    /// Only set when the transaction was not found but may exist in a pruned block.
      24 | +    /// Note that these blocks may be false positives and not contain the transaction.
      25 | +    std::vector<uint256> pruned_block_hashes;
    


    optout21 commented at 9:41 PM on September 29, 2026:

    5a90ecc txindex: return pruned block hashes from FindTx:

    I think here we could also return tx_offset_in_block; it may be useful in some usage scenarios to pass this to the caller as well.


    andrewtoth commented at 2:44 AM on September 30, 2026:

    Can you expand on what scenarios this will be useful for? Would it be better to introduce new data in the response in the change that actually consumes it? Otherwise we will potentially introduce dead code into the codebase.


    optout21 commented at 8:01 AM on September 30, 2026:

    I've just recalled that TX pos has been added to our RPC to be able to return a specific part of the block data; and that electrs takes use of that. A caller may want to retrieve blocks from another source, and need that information. But this doesn't make sense, as the node has all the infrastructure for redownloading blocks. In conclusion, I'm not able to come up with a concrete use case for this, and you are right, then it's best to leave it.

  48. in src/node/tx_lookup_result.h:22 in 5a90ecce1c outdated
      17 | +    /// Hash of the block containing the transaction. null if the transaction
      18 | +    /// was found in the mempool.
      19 | +    uint256 block_hash;
      20 | +};
      21 | +
      22 | +struct TxMiss {
    


    optout21 commented at 9:48 PM on September 29, 2026:

    5a90ecc txindex: return pruned block hashes from FindTx:

    This struct is used both when a TXID is not found in the index, and when it is found, but the block is not found (pruned). This could be mentioned in a doc comment on the struct. Also, on the next line the "was not found but" is redundant and a bit misleading, as TxMiss is used only in case the TX was not found.


    andrewtoth commented at 2:48 AM on September 30, 2026:

    Technically it is used when the transaction is not found in the index, but it may be found in a specific block hash. We don't actually know if it is in that block. The node will have to download that block to find out. It is possible after downloading that the transaction does not actually exist in that block, and then the subsequent result will be a TxMiss with an empty pruned_block_hashes vector.


    optout21 commented at 8:08 AM on September 30, 2026:

    I would've argued for a stronger conceptual distinction between "Not in the index" and the "In the index but in a pruned node", but I have realized, that, due to the potential prefix false positives, this is in fact not possible.

  49. in src/index/txindex.h:60 in 5a90ecce1c outdated
      55 | @@ -63,8 +56,9 @@ class TxIndex final : public BaseIndex
      56 |      /// Look up a transaction by hash.
      57 |      ///
      58 |      /// @param[in]   tx_hash  The hash of the transaction to be returned.
      59 | -    /// @return  the transaction and containing block hash, or nullopt if it is not found
      60 | -    std::optional<TxIndexResult> FindTx(const Txid& tx_hash) const;
      61 | +    /// @return  The transaction and containing block hash if found.
      62 | +    ///          If not found, the hashes of blocks that may contain the tx but were pruned are returned instead.
    


    optout21 commented at 9:51 PM on September 29, 2026:

    5a90ecc txindex: return pruned block hashes from FindTx:

    Nit: For clarity it would be better to separate 3 cases: found, found in pruned block, and not found at all in the index.


    andrewtoth commented at 2:52 AM on September 30, 2026:

    Technically the tx can be found, or not found. If not found and we return one or more block hashes, it may be in one of those blocks, or not.


    optout21 commented at 8:09 AM on September 30, 2026:

    Same as above: I would've argued for a stronger conceptual distinction between "Not in the index" and the "In the index but in a pruned node", but I have realized, that, due to the potential prefix false positives, this is in fact not possible.

  50. in doc/REST-interface.md:41 in 601669b5eb
      37 | @@ -38,7 +38,7 @@ Supported API
      38 |  `GET /rest/tx/<TX-HASH>.<bin|hex|json>`
      39 |  
      40 |  Given a transaction hash: returns a transaction in binary, hex-encoded binary, or JSON formats.
      41 | -Responds with 404 if the transaction doesn't exist.
      42 | +Responds with 404 if the transaction doesn't exist or is in a block which has been pruned.
    


    optout21 commented at 10:03 PM on September 29, 2026:

    601669b rpc: report pruned blocks from GetTransaction:

    What about if it exists in a block that was not indexed? More correct would be "in a block that has not been indexed or that has been pruned"


    optout21 commented at 8:11 AM on September 30, 2026:

    Refined: "or is in a block that has been pruned (or has not been indexed yet)."


    andrewtoth commented at 2:56 PM on September 30, 2026:

    Taken, thanks.

  51. optout21 commented at 10:07 PM on September 29, 2026: contributor

    Concept ACK

    The ability to have a txindex holding information of all transactions even in case of a pruned node is a huge improvement, it makes pruned nodes much more capable.

    I agree that the space concern due to "full" txindex in case of a pruned node is secondary for now, and can be addressed later. A pruned txindex containing only TXs from the non-pruned blocks is of limited use in general, I would say.

    I'm a bit concerned that the interplay of different dimensions could lead to a complexity explosion. To limit that, some combinations have to be excluded outright. This is outside of the scope of this PR, but it's good to have in sight.

    Just so far these dimensions have been mentioned:

    • blocks full or pruned
    • txindex enabled or not
    • txindex enabled before/with block pruning or after
    • txindex contains legacy keys or not
    • txindex is wished to be full or pruned.
  52. in src/node/tx_lookup_result.h:17 in 5a90ecce1c outdated
      12 | +#include <vector>
      13 | +
      14 | +struct TxFound {
      15 | +    /// The transaction that was found.
      16 | +    CTransactionRef tx;
      17 | +    /// Hash of the block containing the transaction. null if the transaction
    


    optout21 commented at 10:11 PM on September 29, 2026:

    5a90ecc txindex: return pruned block hashes from FindTx:

    Nit: null here is actually {} or uint256::ZERO.


    andrewtoth commented at 3:00 AM on September 30, 2026:

    We intend the caller to check for uint256::IsNull(), which is technically uint256::ZERO. But, I think it's clearer to specify null here for that reason.


    optout21 commented at 8:12 AM on September 30, 2026:

    Fine as is, thanks.

  53. optout21 commented at 10:27 PM on September 29, 2026: contributor

    Looked through (5047435466062e2dec15f9649d904adc739d84df), LGTM, left some minor comments.

  54. optout21 commented at 8:17 AM on September 30, 2026: contributor

    Thanks for the replies, @andrewtoth. I've realized some of my comments make little sense, as I assumed a "partial txindex" is possible; a txindex that has not processed some block range (on a pruned, synced node). This state is not possible through any sequence of node restarts and feature enabling/disabling. (It may be possible through some forced inconsistent state on disk, but it's fair to treat those as error.)

    To summarize in a table:

    Pruned? TXID found? <br>(min. 1 TXID prefix matches) TX found?<br>(and it matches) Result, semantics
    No No -- The TX is not present in any of the block of the active chain. <br>Miss is returned.

    | No | Yes | No | The TXID match is a false positive. The TX is not present in any of the block of the active chain. <br>Miss is returned. | No | Yes | Yes | The block exists, TX found. <br>TX and its block hash is returned. | | Yes | No | -- | The TX is not present in any of the block of the active chain. <br>Miss is returned. | | Yes | Yes | No | The block hash is known, but either the block is not found (pruned), or it is, but the tx does not match (false positive). <br>Miss with a list of potential block hashes is returned (may be empty, e.g. only false positives). | | Yes | Yes | Yes | The block hash is known, the block is found. <br>TX and its block hash is returned. |

    I went through the comments having in mind the impossibility of partial index, sorry for the burnt cycles. The implementation is quite solid.

  55. andrewtoth force-pushed on Sep 30, 2026
  56. andrewtoth commented at 3:47 PM on September 30, 2026: contributor

    Thanks @optout21 for your review. I took a few of your suggestions.

  57. in src/init.cpp:1975 in 9dfeaffd30 outdated
    1969 | @@ -1972,6 +1970,12 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
    1970 |  
    1971 |      if (args.GetBoolArg("-txindex", DEFAULT_TXINDEX)) {
    1972 |          g_txindex = std::make_unique<TxIndex>(interfaces::MakeChain(node), index_cache_sizes.tx_index, false, do_reindex);
    1973 | +        if (chainman.m_blockman.IsPruneMode() && !g_txindex->AllowPrune()) {
    1974 | +            if (chainman.m_blockman.m_have_pruned) {
    1975 | +                return InitError(_("Prune mode is incompatible with a txindex that still contains legacy entries. Restart with -reindex to rebuild the index and redownload the blockchain."));
    


    optout21 commented at 11:01 AM on October 5, 2026:

    9dfeaff txindex: allow prune mode when there are no legacy entries:

    This string is not added to bitcoinstrings.cpp, while the next one is. May be missing.


    andrewtoth commented at 12:03 AM on October 9, 2026:

    I removed them from bitcoinstrings.cpp. I believe that will be generated later.

  58. in src/rpc/txoutproof.cpp:131 in 3039c42b84 outdated
     123 | @@ -115,6 +124,11 @@ static RPCMethod gettxoutproof()
     124 |  
     125 |              {
     126 |                  LOCK(cs_main);
     127 | +                if (chainman.m_blockman.IsBlockPruned(*pblockindex)) {
     128 | +                    const std::array block_hashes{pblockindex->GetBlockHash()};
     129 | +                    throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(block_hashes),
     130 | +                                       PrunedBlocksErrorData(block_hashes));
     131 | +                }
    


    optout21 commented at 11:52 AM on October 5, 2026:

    3039c42 rpc: report pruned blocks from GetTransaction:

    This if may trigger even in cases when txindex is not set at all, but hash is provided, or found in the UTXO set. In those cases the "Transaction may be in pruned block X." message is not correct. This looks like a changed error message, even in some cases independent of txindex. If the change is kept, it should be better documented.


    andrewtoth commented at 12:03 AM on October 9, 2026:

    Updated documentation to reflect this.

  59. in src/rpc/rawtransaction.cpp:380 in 3039c42b84 outdated
     376 | @@ -380,7 +377,7 @@ static RPCMethod getrawtransaction()
     377 |      CBlockUndo blockUndo;
     378 |      CBlock block;
     379 |  
     380 | -    if (tx->IsCoinBase() || !blockindex || WITH_LOCK(::cs_main, return !(blockindex->nStatus & BLOCK_HAVE_MASK))) {
     381 | +    if (tx->IsCoinBase() || !blockindex || WITH_LOCK(::cs_main, return !(blockindex->nStatus & BLOCK_HAVE_UNDO))) {
    


    optout21 commented at 12:04 PM on October 5, 2026:

    3039c42 rpc: report pruned blocks from GetTransaction:

    I wonder if this change may affect non-txindex cases as well, e.g. when a block has been downloaded by getblockfrompeer (block data but no undo), and the blockhash is provided.


    andrewtoth commented at 12:07 AM on October 9, 2026:

    Yes, this fixes a bug without txindex as well. Previously, calling getrawtransaction with verbosity 2 and no undo data would result in an RPC error Undo data expected but can't be read. Now It will return the tx but without fee and prevout (these are listed as optional in the help text).

  60. optout21 commented at 12:14 PM on October 5, 2026: contributor

    Reviewed be19bbac4eb7e6185c1113d4cc25632421fdba1d , left some comments.

    Some general comments:

    • The RPC docs are not updated to explain the new behavior, and how possible block hashes are returned.
    • This change (esp. because of the RPC changes) may warrant a release note.
    • The REST interface has no structured return for possible block hashes.
    • The error.data doesn't show in bitcoin-cli output.
  61. in test/functional/feature_pruning.py:127 in be19bbac4e outdated
     123 | @@ -124,7 +124,8 @@ def test_invalid_command_line_options(self):
     124 |              extra_args=['-prune=549'],
     125 |          )
     126 |          self.nodes[0].assert_start_raises_init_error(
     127 | -            expected_msg='Error: Prune mode is incompatible with -txindex.',
     128 | +            expected_msg='Error: txindex best block of the index goes beyond pruned data. Please disable the index or reindex (which will download the whole blockchain again)'
    


    arejula27 commented at 8:04 PM on October 5, 2026:

    I ran this test in a build without the wallet (-DENABLE_WALLET=OFF) and it fails. The reason is that this expectation now relies on node0 having pruned blocks before this step, which only happens in the wallet-only rescan tests, so without the wallet nothing gets pruned. Could this step prune explicitly first (e.g. with pruneblockchain), or move the case to feature_txindex_prune.py, which already prunes?


    andrewtoth commented at 12:09 AM on October 9, 2026:

    Fixed this by using self.nodes[2] instead of self.nodes[0]. That node does always has pruned blocks.

  62. in src/rpc/rawtransaction.cpp:333 in be19bbac4e


    arejula27 commented at 8:07 PM on October 5, 2026:

    With an explicit blockhash whose block is pruned, gettxoutproof now returns Transaction may be in pruned block <hash>., while getrawtransaction still returns Block not available. Is there a reason to treat these two cases differently?


    andrewtoth commented at 12:11 AM on October 9, 2026:

    Fixed this so both cases return the same error.

  63. arejula27 commented at 8:46 PM on October 5, 2026: contributor

    Tested be19bbac4eb7e6185c1113d4cc25632421fdba1d.

    I agree with moving the partial index to a follow-up, it keeps this PR simpler. I still find it an interesting feature, I hope it isn't forgotten :D

    I ran txindex_tests, feature_txindex_prune.py, feature_txindex_compatibility.py and feature_pruning.py, and they pass (except feature_pruning.py in a build without the wallet, see inline). I also went through the cases from @optout21's table manually on a testnet4 node synced from genesis with -prune=550 -txindex, at height 155266 with pruneheight 148133, and everything behaved as expected.

    <details> <summary>testnet4 commands and output</summary>

    Node: bitcoind -testnet4 -prune=550 -txindex -rest, network disabled

    # Node state
    $ bitcoin-cli -testnet4 getblockchaininfo
      "blocks": 155266,
      "bestblockhash": "0000000009874248f2ff9bc8c6e26fe288a0e7e3bbb967bdab2ae6b8b0d553a1",
      "pruned": true,
      "pruneheight": 148133,
    
    # A txid that is not in the index: plain not-found error (-5)
    $ bitcoin-cli -testnet4 getrawtransaction 0000000000000000000000000000000000000000000000000000000000000001
    error code: -5
    error message:
    No such mempool or blockchain transaction. Use gettransaction for wallet transactions.
    
    # A tx in a recent block (155256) that still has its data on disk: returned normally
    $ bitcoin-cli -testnet4 getrawtransaction df56e5ad93e9479ad96b4546c5d686c09ade3f1c1356a1035e3587dd90c14b4b 1
    {
      "txid": "df56e5ad93e9479ad96b4546c5d686c09ade3f1c1356a1035e3587dd90c14b4b",
      ...
    }
    
    # A fully spent tx in a pruned block (122494): the txindex returns the block hash, from both getrawtransaction and gettxoutproof
    $ bitcoin-cli -testnet4 getrawtransaction 01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac
    error code: -1
    error message:
    Transaction may be in pruned block 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e.
    
    $ bitcoin-cli -testnet4 gettxoutproof '["01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac"]'
    error code: -1
    error message:
    Transaction may be in pruned block 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e.
    
    # Same tx and block, passing the blockhash explicitly: gettxoutproof reports the pruned block, getrawtransaction does not
    $ bitcoin-cli -testnet4 gettxoutproof '["01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac"]' 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e
    error code: -1
    error message:
    Transaction may be in pruned block 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e.
    
    $ bitcoin-cli -testnet4 getrawtransaction 01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac 1 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e
    error code: -1
    error message:
    Block not available
    
    # A coinbase in a pruned block (147399) with an unspent output: gettxoutproof finds the block through the UTXO set and also reports it
    $ bitcoin-cli -testnet4 gettxoutproof '["13d6d66879dacf0b0b2f2b8c3361399e15e5d81ad671d78e9a15bbeba9b6048a"]'
    error code: -1
    error message:
    Transaction may be in pruned block 000000000000000286f38d79da8406e88f572b4298c9ef0c2879101b7b56757e.
    
    # REST returns the same message as text, without the structured hash
    $ curl -s http://127.0.0.1:48332/rest/tx/01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac.hex
    Transaction may be in pruned block 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e.
    (HTTP 404)
    
    # Fetch the pruned block from a peer and retry: the lookups now succeed
    $ bitcoin-cli -testnet4 getblockfrompeer 00000000000000000138e178873369c3b371331e29aa6038105f992ef539741e 15
    {
    }
    $ bitcoin-cli -testnet4 getrawtransaction 01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac 2
    {
      "txid": "01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac",
      ...                      (no "fee" or "prevout", the block comes without undo data)
    }
    $ bitcoin-cli -testnet4 gettxoutproof '["01da48b914d0c78825c9010654863d803c90d52cde9a0706997a9390725a24ac"]'
    0060e42735e2e66485d2ab215e53a1ec2d34a8197e249e1398a8e64ba5fca705...
    

    </details>

    I also noticed that ProcessPSBT now goes through GetTransaction, so it looks in the mempool before the txindex (this is a behavioral change). I measured utxoupdatepsbt and descriptorprocesspsbt against the base commit with 2500 txs in the mempool . I saw no time difference, I'm not sure if this needs further analysis (I think it's fine, but let me know).

    Left a couple of comments below, these should be my last ones. Once they're addressed I'm happy to ACK.

  64. DrahtBot added the label Needs rebase on Oct 6, 2026
  65. andrewtoth force-pushed on Oct 8, 2026
  66. andrewtoth force-pushed on Oct 9, 2026
  67. andrewtoth force-pushed on Oct 9, 2026
  68. DrahtBot added the label CI failed on Oct 9, 2026
  69. DrahtBot commented at 12:15 AM on October 9, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/37862719885/job/113602028381</sub> <sub>LLM reason (✨ experimental): CI failed because IWYU added a missing #include <set> in src/rest.cpp, leaving an uncommitted diff that the lint check rejects.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  70. andrewtoth commented at 12:23 AM on October 9, 2026: contributor

    Thanks @optout21 @arejula27 for your reviews. I have taken most of your suggestions.

    Rebased due to #34729 and #36447.

    • Switched pruned_block_hashes from std::vector to std::set to ensure returned block hashes are unique.
    • Aligned behavior of getrawtransaction and gettxoutproof when a tx inside a pruned block is requested with either txindex or a provided block hash.
    • Updated help text for getrawtransaction and gettxoutproof.
    • Added release notes.

    The REST interface has no structured return for possible block hashes.

    I opted not to add structured errors for REST (https://github.com/bitcoin/bitcoin/pull/36002#issuecomment-5724803370):

    I kept the REST error response as a string. All REST errors are strings, and I didn't want to introduce a special case. That would be a more invasive change, since all existing clients likely always expect plain/text string error responses. Of course that can be done as a follow-up if there is interest.

    The error.data doesn't show in bitcoin-cli output.

    I'm not sure this is necessary, since the error text will show the block hashes anyway. I left this alone.

    I also noticed that ProcessPSBT now goes through GetTransaction, so it looks in the mempool before the txindex (this is a behavioral change). I measured utxoupdatepsbt and descriptorprocesspsbt against the base commit with 2500 txs in the mempool . I saw no time difference, I'm not sure if this needs further analysis (I think it's fine, but let me know).

    Yes, I think this is ok. It aligns lookups with the other RPCs and REST endpoint.

    git range-diff 619185d5ce..be19bbac4e acdb326934..23f14ab325

  71. DrahtBot removed the label CI failed on Oct 9, 2026
  72. DrahtBot removed the label Needs rebase on Oct 9, 2026
  73. in doc/release-notes-36002.md:5 in 23f14ab325
       0 | @@ -0,0 +1,22 @@
       1 | +Updated settings
       2 | +----------------
       3 | +
       4 | +- The transaction index (`-txindex`) can now be used with pruning (`-prune`).
       5 | +  The index must be built from genesis; indexes built in a version prior to v31
    


    l0rinc commented at 12:36 PM on October 12, 2026:

    23f14ab doc: add release note for txindex pruning support:

    This should probably be v32.

    <details> <summary>Name the first release without legacy entries</summary>

    diff --git a/doc/release-notes-36002.md b/doc/release-notes-36002.md
    index bbab16e2c9..ca4f96295f 100644
    --- a/doc/release-notes-36002.md
    +++ b/doc/release-notes-36002.md
    @@ -2,7 +2,7 @@ Updated settings
     ----------------
     
     - The transaction index (`-txindex`) can now be used with pruning (`-prune`).
    -  The index must be built from genesis; indexes built in a version prior to v31
    +  The index must be built from genesis. Indexes built in a version prior to v32
       must be rebuilt first. (#36002)
     
     Updated RPCs
    

    </details>

  74. in src/index/tx_lookup_result.h:29 in 23f14ab325
      24 | +    /// Note that these blocks may be false positives and not contain the transaction.
      25 | +    std::set<uint256> pruned_block_hashes;
      26 | +};
      27 | +
      28 | +/// Result of a transaction lookup that may be answered by the txindex.
      29 | +using TxLookupResult = std::variant<TxFound, TxMiss>;
    


    l0rinc commented at 12:38 PM on October 12, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    Given that GetTransaction also uses the result for mempool lookups we could move this one to node/.

    <details> <summary>Keep the shared lookup result under node</summary>

    diff --git a/src/index/txindex.cpp b/src/index/txindex.cpp
    index 4f26fa28ff..d14c02d88e 100644
    --- a/src/index/txindex.cpp
    +++ b/src/index/txindex.cpp
    @@ -11,10 +11,10 @@
     #include <flatfile.h>
     #include <index/base.h>
     #include <index/disktxpos.h>
    -#include <index/tx_lookup_result.h>
     #include <index/txindex_key.h>
     #include <interfaces/chain.h>
     #include <node/blockstorage.h>
    +#include <node/tx_lookup_result.h>
     #include <primitives/block.h>
     #include <primitives/transaction.h>
     #include <random.h>
    diff --git a/src/index/txindex.h b/src/index/txindex.h
    index beb7d0e3db..c65a2433f0 100644
    --- a/src/index/txindex.h
    +++ b/src/index/txindex.h
    @@ -6,7 +6,7 @@
     #define BITCOIN_INDEX_TXINDEX_H
     
     #include <index/base.h>
    -#include <index/tx_lookup_result.h>
    +#include <node/tx_lookup_result.h>
     #include <primitives/transaction.h>
     
     #include <cstddef>
    diff --git a/src/node/transaction.cpp b/src/node/transaction.cpp
    index 3e88d13d18..60338e9af0 100644
    --- a/src/node/transaction.cpp
    +++ b/src/node/transaction.cpp
    @@ -6,18 +6,19 @@
     #include <node/transaction.h>
     
     #include <consensus/validation.h>
    -#include <index/tx_lookup_result.h>
     #include <index/txindex.h>
     #include <net.h>
     #include <net_processing.h>
     #include <node/blockstorage.h>
     #include <node/context.h>
    +#include <node/tx_lookup_result.h>
     #include <node/types.h>
     #include <policy/feerate.h>
     #include <txmempool.h>
     #include <validation.h>
     #include <validationinterface.h>
     
    +#include <utility>
     #include <variant>
     
     namespace node {
    diff --git a/src/node/transaction.h b/src/node/transaction.h
    index 80149d0c0b..c05e6bd36b 100644
    --- a/src/node/transaction.h
    +++ b/src/node/transaction.h
    @@ -6,7 +6,7 @@
     #define BITCOIN_NODE_TRANSACTION_H
     
     #include <common/messages.h>
    -#include <index/tx_lookup_result.h>
    +#include <node/tx_lookup_result.h>
     #include <node/types.h>
     #include <policy/feerate.h>
     #include <primitives/transaction.h>
    diff --git a/src/index/tx_lookup_result.h b/src/node/tx_lookup_result.h
    similarity index 87%
    rename from src/index/tx_lookup_result.h
    rename to src/node/tx_lookup_result.h
    index 9cbb9f5312..9c1808d42c 100644
    --- a/src/index/tx_lookup_result.h
    +++ b/src/node/tx_lookup_result.h
    @@ -2,8 +2,8 @@
     // Distributed under the MIT software license, see the accompanying
     // file COPYING or http://www.opensource.org/licenses/mit-license.php.
     
    -#ifndef BITCOIN_INDEX_TX_LOOKUP_RESULT_H
    -#define BITCOIN_INDEX_TX_LOOKUP_RESULT_H
    +#ifndef BITCOIN_NODE_TX_LOOKUP_RESULT_H
    +#define BITCOIN_NODE_TX_LOOKUP_RESULT_H
     
     #include <primitives/transaction.h>
     #include <uint256.h>
    @@ -28,4 +28,4 @@ struct TxMiss {
     /// Result of a transaction lookup that may be answered by the txindex.
     using TxLookupResult = std::variant<TxFound, TxMiss>;
     
    -#endif // BITCOIN_INDEX_TX_LOOKUP_RESULT_H
    +#endif // BITCOIN_NODE_TX_LOOKUP_RESULT_H
    diff --git a/src/rest.cpp b/src/rest.cpp
    index 7913c2d611..49676cf828 100644
    --- a/src/rest.cpp
    +++ b/src/rest.cpp
    @@ -14,11 +14,11 @@
     #include <flatfile.h>
     #include <httpserver.h>
     #include <index/blockfilterindex.h>
    -#include <index/tx_lookup_result.h>
     #include <index/txindex.h>
     #include <node/blockstorage.h>
     #include <node/context.h>
     #include <node/transaction.h>
    +#include <node/tx_lookup_result.h>
     #include <primitives/block.h>
     #include <primitives/transaction.h>
     #include <rpc/blockchain.h>
    diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
    index 8b94667857..1df1604803 100644
    --- a/src/rpc/rawtransaction.cpp
    +++ b/src/rpc/rawtransaction.cpp
    @@ -15,7 +15,6 @@
     #include <crypto/common.h>
     #include <crypto/hex_base.h>
     #include <hash.h>
    -#include <index/tx_lookup_result.h>
     #include <index/txindex.h>
     #include <kernel/chainparams.h>
     #include <key.h>
    @@ -25,6 +24,7 @@
     #include <node/context.h>
     #include <node/psbt.h>
     #include <node/transaction.h>
    +#include <node/tx_lookup_result.h>
     #include <policy/feerate.h>
     #include <primitives/block.h>
     #include <primitives/transaction.h>
    diff --git a/src/rpc/txoutproof.cpp b/src/rpc/txoutproof.cpp
    index 8d2b0cb3c5..57f1cb2bb3 100644
    --- a/src/rpc/txoutproof.cpp
    +++ b/src/rpc/txoutproof.cpp
    @@ -8,11 +8,11 @@
     #include <chain.h>
     #include <coins.h>
     #include <crypto/hex_base.h>
    -#include <index/tx_lookup_result.h>
     #include <index/txindex.h>
     #include <merkleblock.h>
     #include <node/blockstorage.h>
     #include <node/transaction.h>
    +#include <node/tx_lookup_result.h>
     #include <primitives/block.h>
     #include <primitives/transaction.h>
     #include <rpc/blockchain.h>
    diff --git a/src/test/txindex_tests.cpp b/src/test/txindex_tests.cpp
    index a2fe62b7a0..19bf438324 100644
    --- a/src/test/txindex_tests.cpp
    +++ b/src/test/txindex_tests.cpp
    @@ -12,12 +12,12 @@
     #include <dbwrapper.h>
     #include <flatfile.h>
     #include <index/disktxpos.h>
    -#include <index/tx_lookup_result.h>
     #include <index/txindex.h>
     #include <index/txindex_key.h>
     #include <interfaces/chain.h>
     #include <key.h>
     #include <node/blockstorage.h>
    +#include <node/tx_lookup_result.h>
     #include <primitives/block.h>
     #include <script/script.h>
     #include <streams.h>
    

    </details>


    l0rinc commented at 2:38 AM on October 14, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    I find result.GetFound() and result.GetMiss() easier to read than repeating std::get_if at each call site. Deriving from the variant, like GenTxid does, keeps the found/miss distinction and the existing std::get calls.

    <details> <summary>Give the lookup result named accessors</summary>

    diff --git a/src/node/transaction.cpp b/src/node/transaction.cpp
    index 60338e9af0..48fe0016b7 100644
    --- a/src/node/transaction.cpp
    +++ b/src/node/transaction.cpp
    @@ -19,7 +19,6 @@
     #include <validationinterface.h>
     
     #include <utility>
    -#include <variant>
     
     namespace node {
     static TransactionError HandleATMPError(const TxValidationState& state, std::string& err_string_out)
    @@ -146,21 +145,15 @@ TransactionError BroadcastTransaction(NodeContext& node, const CTransactionRef t
     TxLookupResult GetTransaction(const CBlockIndex* const block_index, const CTxMemPool* const mempool, const Txid& hash, const BlockManager& blockman)
     {
         if (mempool && !block_index) {
    -        CTransactionRef ptx = mempool->get(hash);
    -        if (ptx) return TxFound{std::move(ptx), {}};
    +        if (auto ptx{mempool->get(hash)}) return TxFound{std::move(ptx), {}};
         }
         if (g_txindex) {
    -        TxLookupResult result{g_txindex->FindTx(hash)};
    -        if (const auto* found{std::get_if<TxFound>(&result)}) {
    -            if (!block_index || block_index->GetBlockHash() == found->block_hash) {
    -                // Don't return the transaction if the provided block hash doesn't match.
    -                // The case where a transaction appears in multiple blocks (e.g. reorgs or
    -                // BIP30) is handled by the block lookup below.
    -                return result;
    -            }
    -        } else if (!block_index) {
    -            return result;
    -        }
    +        auto result{g_txindex->FindTx(hash)};
    +        if (!block_index) return result;
    +        // Don't return the transaction if the provided block hash doesn't match.
    +        // The case where a transaction appears in multiple blocks (e.g. reorgs or
    +        // BIP30) is handled by the block lookup below.
    +        if (const auto* found{result.GetFound()}; found && block_index->GetBlockHash() == found->block_hash) return result;
         }
         if (block_index) {
             CBlock block;
    diff --git a/src/node/tx_lookup_result.h b/src/node/tx_lookup_result.h
    index 9c1808d42c..e1c8e56b12 100644
    --- a/src/node/tx_lookup_result.h
    +++ b/src/node/tx_lookup_result.h
    @@ -26,6 +26,14 @@ struct TxMiss {
     };
     
     /// Result of a transaction lookup that may be answered by the txindex.
    -using TxLookupResult = std::variant<TxFound, TxMiss>;
    +struct TxLookupResult : public std::variant<TxFound, TxMiss>
    +{
    +    using variant::variant;
    +
    +    TxFound* GetFound() { return std::get_if<TxFound>(this); }
    +    const TxFound* GetFound() const { return std::get_if<TxFound>(this); }
    +    TxMiss* GetMiss() { return std::get_if<TxMiss>(this); }
    +    const TxMiss* GetMiss() const { return std::get_if<TxMiss>(this); }
    +};
     
     #endif // BITCOIN_NODE_TX_LOOKUP_RESULT_H
    diff --git a/src/rest.cpp b/src/rest.cpp
    index b583bb1abe..004e31778a 100644
    --- a/src/rest.cpp
    +++ b/src/rest.cpp
    @@ -903,7 +903,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string
         const NodeContext* const node = GetNodeContext(context, req);
         if (!node) return false;
         const TxLookupResult result{GetTransaction(/*block_index=*/nullptr, node->mempool.get(), *hash, node->chainman->m_blockman)};
    -    if (const auto* miss{std::get_if<TxMiss>(&result)}) {
    +    if (const auto* miss{result.GetMiss()}) {
             if (!miss->pruned_block_hashes.empty()) {
                 return RESTERR(req, HTTP_NOT_FOUND, PrunedBlocksErrorMessage(miss->pruned_block_hashes));
             }
    diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
    index 3714ee26cc..77b75cb18b 100644
    --- a/src/rpc/rawtransaction.cpp
    +++ b/src/rpc/rawtransaction.cpp
    @@ -178,8 +178,7 @@ PartiallySignedTransaction ProcessPSBT(const std::string& psbt_string, const std
             // The `non_witness_utxo` is the whole previous transaction
             if (psbt_input.non_witness_utxo) continue;
     
    -        TxLookupResult result{GetTransaction(/*block_index=*/nullptr, node.mempool.get(), psbt_input.prev_txid, chainman.m_blockman)};
    -        if (auto* found{std::get_if<TxFound>(&result)}) {
    +        if (auto result{GetTransaction(/*block_index=*/nullptr, node.mempool.get(), psbt_input.prev_txid, chainman.m_blockman)}; auto* found{result.GetFound()}) {
                 psbt_input.non_witness_utxo = std::move(found->tx);
             } else {
                 coins[psbt_input.GetOutPoint()]; // Create empty map entry keyed by prevout
    @@ -326,7 +325,7 @@ static RPCMethod getrawtransaction()
         }
     
         const TxLookupResult tx_result{GetTransaction(blockindex, node.mempool.get(), txid, chainman.m_blockman)};
    -    if (const auto* miss{std::get_if<TxMiss>(&tx_result)}) {
    +    if (const auto* miss{tx_result.GetMiss()}) {
             if (!miss->pruned_block_hashes.empty()) {
                 throw JSONRPCPrunedBlocksError(miss->pruned_block_hashes);
             }
    diff --git a/src/rpc/txoutproof.cpp b/src/rpc/txoutproof.cpp
    index 57f1cb2bb3..e433578075 100644
    --- a/src/rpc/txoutproof.cpp
    +++ b/src/rpc/txoutproof.cpp
    @@ -32,7 +32,6 @@
     #include <span>
     #include <string>
     #include <utility>
    -#include <variant>
     #include <vector>
     
     using node::GetTransaction;
    @@ -108,9 +107,9 @@ static RPCMethod gettxoutproof()
     
                 if (pblockindex == nullptr) {
                     const TxLookupResult result{GetTransaction(/*block_index=*/nullptr, /*mempool=*/nullptr, *setTxids.begin(), chainman.m_blockman)};
    -                const auto* found{std::get_if<TxFound>(&result)};
    +                const auto* found{result.GetFound()};
                     if (!found || found->block_hash.IsNull()) {
    -                    if (const auto* miss{std::get_if<TxMiss>(&result)}; miss && !miss->pruned_block_hashes.empty()) {
    +                    if (const auto* miss{result.GetMiss()}; miss && !miss->pruned_block_hashes.empty()) {
                             throw JSONRPCPrunedBlocksError(miss->pruned_block_hashes);
                         }
                         throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Transaction not yet in block");
    diff --git a/src/test/txindex_tests.cpp b/src/test/txindex_tests.cpp
    index d57a7074a4..e1a3666da1 100644
    --- a/src/test/txindex_tests.cpp
    +++ b/src/test/txindex_tests.cpp
    @@ -35,7 +35,6 @@
     #include <string>
     #include <string_view>
     #include <utility>
    -#include <variant>
     #include <vector>
     
     #include <boost/test/unit_test.hpp>
    @@ -88,16 +87,15 @@ FlatFilePos BlockFilePos(const ChainstateManager& chainman, uint32_t height)
     uint256 LookupTx(const TxIndex& txindex, const Txid& txid)
     {
         const auto result{txindex.FindTx(txid)};
    -    const auto* found{std::get_if<TxFound>(&result)};
    -    BOOST_CHECK(Assert(found)->tx->GetHash() == txid);
    -    return found->block_hash;
    +    const auto& [tx, block_hash]{*Assert(result.GetFound())};
    +    BOOST_CHECK(tx->GetHash() == txid);
    +    return block_hash;
     }
     
     void CheckNotFound(const TxIndex& txindex, const Txid& txid, const std::set<uint256>& expected_pruned_block_hashes = {})
     {
         const auto result{txindex.FindTx(txid)};
    -    const auto* miss{std::get_if<TxMiss>(&result)};
    -    BOOST_CHECK(Assert(miss)->pruned_block_hashes == expected_pruned_block_hashes);
    +    BOOST_CHECK(Assert(result.GetMiss())->pruned_block_hashes == expected_pruned_block_hashes);
     }
     
     void InvalidateBlock(ChainstateManager& chainman, const uint256& block_hash)
    

    </details>

  75. in src/index/txindex.cpp:195 in 23f14ab325
     190 | @@ -184,9 +191,13 @@ std::optional<TxIndexResult> TxIndex::FindTx(const Txid& tx_hash) const
     191 |                  LogWarning("Block index entry %s not found for txid %s", candidate_block_hash.ToString(), tx_hash.ToString());
     192 |                  continue;
     193 |              }
     194 | -            if (!(block_index->nStatus & BLOCK_HAVE_DATA)) continue;
     195 | +            const bool in_active_chain{m_chainstate->m_chain.Contains(*block_index)};
     196 | +            if (!(block_index->nStatus & BLOCK_HAVE_DATA)) {
    


    l0rinc commented at 12:39 PM on October 12, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    Nit: a slightly simpler version:

    <details> <summary>Use the complement notation</summary>

    diff --git a/src/index/txindex.cpp b/src/index/txindex.cpp
    index d14c02d88e..b6e38d0abe 100644
    --- a/src/index/txindex.cpp
    +++ b/src/index/txindex.cpp
    @@ -192,7 +192,7 @@ TxLookupResult TxIndex::FindTx(const Txid& tx_hash) const
                     continue;
                 }
                 const bool in_active_chain{m_chainstate->m_chain.Contains(*block_index)};
    -            if (!(block_index->nStatus & BLOCK_HAVE_DATA)) {
    +            if (~block_index->nStatus & BLOCK_HAVE_DATA) {
                     if (in_active_chain) pruned_block_hashes.insert(candidate_block_hash);
                     continue;
                 }
    

    </details>

  76. in src/init.cpp:2013 in 23f14ab325 outdated
    2005 | @@ -2008,6 +2006,12 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
    2006 |  
    2007 |      if (args.GetBoolArg("-txindex", DEFAULT_TXINDEX)) {
    2008 |          g_txindex = std::make_unique<TxIndex>(interfaces::MakeChain(node), index_cache_sizes.tx_index, false, do_reindex);
    2009 | +        if (chainman.m_blockman.IsPruneMode() && !g_txindex->AllowPrune()) {
    2010 | +            if (chainman.m_blockman.m_have_pruned) {
    2011 | +                return InitError(_("Prune mode is incompatible with a txindex that still contains legacy entries. Restart with -reindex to rebuild the index and redownload the blockchain."));
    2012 | +            }
    2013 | +            return InitError(_("Prune mode is incompatible with a txindex that still contains legacy entries. Stop the node, delete the txindex directory, and restart to rebuild the index."));
    


    l0rinc commented at 1:39 AM on October 14, 2026:

    46542ed txindex: allow prune mode when there are no legacy entries:

    Let's test this before pruning too. We currently only check the other error message.

    AllowPrune() always returns true for the in-memory index because it assumes there are no legacy entries. Could we move this assertion to the existing on-disk test, where it actually checks the database?

    <details> <summary>Cover both sides of the legacy prune policy</summary>

    diff --git a/src/test/txindex_tests.cpp b/src/test/txindex_tests.cpp
    index 4e4cfbae1b..af3abb9806 100644
    --- a/src/test/txindex_tests.cpp
    +++ b/src/test/txindex_tests.cpp
    @@ -149,7 +149,6 @@ BOOST_AUTO_TEST_CASE(txindex_hash_prefix)
     BOOST_FIXTURE_TEST_CASE(txindex_initial_sync, TestChain100Setup)
     {
         TxIndex txindex(interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB, /*f_memory=*/true);
    -    BOOST_CHECK(txindex.AllowPrune());
         BOOST_REQUIRE(txindex.Init());
     
         // Transaction should not be found in the index before it is started.
    @@ -192,6 +191,7 @@ BOOST_FIXTURE_TEST_CASE(txindex_collision_scan_path, TestChain100Setup)
         // On-disk, so the legacy-entry probe at construction runs against a fresh
         // database, as it would on a node whose index was created by this version.
         TxIndex txindex(interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB, /*f_memory=*/false);
    +    BOOST_CHECK(txindex.AllowPrune());
         BOOST_REQUIRE(txindex.Init());
         txindex.Sync();
     
    diff --git a/test/functional/feature_txindex_compatibility.py b/test/functional/feature_txindex_compatibility.py
    index 1949b3dcc9..94afffe498 100755
    --- a/test/functional/feature_txindex_compatibility.py
    +++ b/test/functional/feature_txindex_compatibility.py
    @@ -53,6 +53,11 @@ class TxIndexTest(BitcoinTestFramework):
             self.stop_nodes()
             self.cleanup_folder(node.chain_path)
             shutil.copytree(legacy_node.chain_path, node.chain_path)
    +        self.log.info("Ensure that prune mode rejects a legacy txindex")
    +        node.assert_start_raises_init_error(
    +            extra_args=["-prune=1", "-txindex"],
    +            expected_msg="Error: Prune mode is incompatible with a txindex that still contains legacy entries. Stop the node, delete the txindex directory, and restart to rebuild the index.",
    +        )
             msg = "txindex contains entries in the legacy format"
             with node.assert_debug_log(expected_msgs=[msg]):
                 self.start_node(0)
    

    </details>

  77. in test/functional/feature_txindex_prune.py:60 in 23f14ab325 outdated
      55 | +        self.connect_nodes(0, 1)
      56 | +
      57 | +        self.log.info("RPC lookups return pruned block hashes through txindex, UTXO, and explicit-block paths")
      58 | +        for rpc, rpc_args, expected_blockhash in (
      59 | +            (pruned.getrawtransaction, [txid], blockhash),
      60 | +            (pruned.getrawtransaction, [txid, 0, blockhash], blockhash),
    


    l0rinc commented at 1:41 AM on October 14, 2026:

    2cce7aa test: cover txindex lookups of pruned blocks:

    With txindex enabled, its fallback returns the same error and can mask a broken UTXO lookup.

    We could also check that we can still find the downloaded transaction after restarting. That part already works today, so feel free to leave it out.

    We could also reuse try_rpc for the download wait and check the hex from the verbose response.

    <details> <summary>Exercise no-txindex lookups and recovery across restart</summary>

    diff --git a/test/functional/feature_txindex_prune.py b/test/functional/feature_txindex_prune.py
    index 3b9981f9d9..d330f3a8e3 100755
    --- a/test/functional/feature_txindex_prune.py
    +++ b/test/functional/feature_txindex_prune.py
    @@ -10,7 +10,9 @@ from urllib.parse import urlparse
     from test_framework.test_framework import BitcoinTestFramework
     from test_framework.util import (
         assert_equal,
    +    assert_raises_rpc_error,
         sync_txindex,
    +    try_rpc,
         JSONRPCException,
     )
     from test_framework.wallet import MiniWallet, getnewdestination
    @@ -50,6 +52,10 @@ class TxIndexPruneTest(BitcoinTestFramework):
             self.log.info("Mine enough blocks to prune the transaction's block")
             self.generate(full, 600)
             pruned.pruneblockchain(pruned.getblockcount())
    +
    +        self.log.info("Pruned UTXO lookups work without txindex")
    +        self.restart_node(0, extra_args=["-fastprune", "-prune=1", "-txindex=0"])
    +        assert_raises_rpc_error(-1, f"Transaction may be in pruned block {blockhash}.", pruned.gettxoutproof, [txid])
             self.restart_node(0)
             sync_txindex(self, pruned)
             self.connect_nodes(0, 1)
    @@ -67,9 +73,7 @@ class TxIndexPruneTest(BitcoinTestFramework):
                     rpc(*rpc_args)
                     raise AssertionError("Expected pruned transaction lookup failure")
                 except JSONRPCException as error:
    -                assert_equal(error.error["code"], -1)
    -                assert_equal(error.error["message"], f"Transaction may be in pruned block {expected_blockhash}.")
    -                assert_equal(error.error["data"]["pruned_block_hashes"], [expected_blockhash])
    +                assert_equal(error.error, {"code": -1, "message": f"Transaction may be in pruned block {expected_blockhash}.", "data": {"pruned_block_hashes": [expected_blockhash]}})
     
             self.log.info("REST /tx returns the pruned block's hash")
             assert_equal(self.rest_tx(pruned, txid, status=404), f"Transaction may be in pruned block {blockhash}.")
    @@ -82,13 +86,9 @@ class TxIndexPruneTest(BitcoinTestFramework):
             self.log.info("Fetch the pruned block and retry RPC, REST, and PSBT lookups")
             pruned.getblockfrompeer(blockhash, pruned.getpeerinfo()[0]["id"])
     
    -        def tx_available():
    -            try:
    -                return pruned.getrawtransaction(txid) == tx["hex"]
    -            except JSONRPCException:
    -                return False
    -        self.wait_until(tx_available)
    +        self.wait_until(lambda: not try_rpc(None, None, pruned.getrawtransaction, txid))
             tx_verbose = pruned.getrawtransaction(txid, 2)
    +        assert_equal(tx_verbose["hex"], tx["hex"])
             # verbose fields are missing because there's no undo fetched from peer
             assert "fee" not in tx_verbose
             assert "prevout" not in tx_verbose["vin"][0]
    @@ -97,5 +97,9 @@ class TxIndexPruneTest(BitcoinTestFramework):
             updated = pruned.utxoupdatepsbt(psbt)
             assert "non_witness_utxo" in pruned.decodepsbt(updated)["inputs"][0]
     
    +        self.log.info("Recovered transaction lookup survives a restart")
    +        self.restart_node(0)
    +        assert_equal(pruned.getrawtransaction(txid), tx["hex"])
    +
     if __name__ == '__main__':
         TxIndexPruneTest(__file__).main()
    

    </details>

  78. in src/rpc/util.h:153 in 23f14ab325
     146 | @@ -146,6 +147,11 @@ RPCErrorCode RPCErrorFromTransactionError(node::TransactionError terr);
     147 |  UniValue JSONRPCPSBTError(common::PSBTError err);
     148 |  UniValue JSONRPCTransactionError(node::TransactionError terr, const std::string& err_string = "");
     149 |  
     150 | +//! Error message when a transaction lookup only matched blocks whose data was pruned.
     151 | +std::string PrunedBlocksErrorMessage(const std::set<uint256>& pruned_block_hashes);
     152 | +//! Structured JSON-RPC error.data for a pruned-block transaction lookup miss.
     153 | +UniValue PrunedBlocksErrorData(const std::set<uint256>& pruned_block_hashes);
    


    l0rinc commented at 1:43 AM on October 14, 2026:

    012c384 rpc: report pruned blocks from GetTransaction:

    Returning the whole error here would save repeating the same code, message and data at all four call sites. JSONRPCTransactionError already does something similar.

    <details> <summary>Build the pruned-block error in one place</summary>

    diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
    index 667f92f377..62069455eb 100644
    --- a/src/rpc/rawtransaction.cpp
    +++ b/src/rpc/rawtransaction.cpp
    @@ -327,16 +327,13 @@ static RPCMethod getrawtransaction()
         const TxLookupResult tx_result{GetTransaction(blockindex, node.mempool.get(), txid, chainman.m_blockman)};
         if (const auto* miss{std::get_if<TxMiss>(&tx_result)}) {
             if (!miss->pruned_block_hashes.empty()) {
    -            throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(miss->pruned_block_hashes),
    -                               PrunedBlocksErrorData(miss->pruned_block_hashes));
    +            throw JSONRPCPrunedBlocksError(miss->pruned_block_hashes);
             }
             std::string errmsg;
             if (blockindex) {
                 LOCK(cs_main);
                 if (chainman.m_blockman.IsBlockPruned(*blockindex)) {
    -                const std::set<uint256> block_hashes{blockindex->GetBlockHash()};
    -                throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(block_hashes),
    -                                   PrunedBlocksErrorData(block_hashes));
    +                throw JSONRPCPrunedBlocksError({blockindex->GetBlockHash()});
                 }
                 if (!(blockindex->nStatus & BLOCK_HAVE_DATA)) {
                     throw JSONRPCError(RPC_MISC_ERROR, "Block not available");
    diff --git a/src/rpc/txoutproof.cpp b/src/rpc/txoutproof.cpp
    index b9ff37d93a..8d2b0cb3c5 100644
    --- a/src/rpc/txoutproof.cpp
    +++ b/src/rpc/txoutproof.cpp
    @@ -111,8 +111,7 @@ static RPCMethod gettxoutproof()
                     const auto* found{std::get_if<TxFound>(&result)};
                     if (!found || found->block_hash.IsNull()) {
                         if (const auto* miss{std::get_if<TxMiss>(&result)}; miss && !miss->pruned_block_hashes.empty()) {
    -                        throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(miss->pruned_block_hashes),
    -                                           PrunedBlocksErrorData(miss->pruned_block_hashes));
    +                        throw JSONRPCPrunedBlocksError(miss->pruned_block_hashes);
                         }
                         throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Transaction not yet in block");
                     }
    @@ -128,9 +127,7 @@ static RPCMethod gettxoutproof()
                 {
                     LOCK(cs_main);
                     if (chainman.m_blockman.IsBlockPruned(*pblockindex)) {
    -                    const std::set<uint256> block_hashes{pblockindex->GetBlockHash()};
    -                    throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(block_hashes),
    -                                       PrunedBlocksErrorData(block_hashes));
    +                    throw JSONRPCPrunedBlocksError({pblockindex->GetBlockHash()});
                     }
                     CheckBlockDataAvailability(chainman.m_blockman, *pblockindex, /*check_for_undo=*/false);
                 }
    diff --git a/src/rpc/util.cpp b/src/rpc/util.cpp
    index fa4801ba8e..3a444097af 100644
    --- a/src/rpc/util.cpp
    +++ b/src/rpc/util.cpp
    @@ -431,7 +431,7 @@ std::string PrunedBlocksErrorMessage(const std::set<uint256>& pruned_block_hashe
         return strprintf("Transaction may be in one of the following pruned blocks: %s.", hashes);
     }
     
    -UniValue PrunedBlocksErrorData(const std::set<uint256>& pruned_block_hashes)
    +UniValue JSONRPCPrunedBlocksError(const std::set<uint256>& pruned_block_hashes)
     {
         UniValue hashes{UniValue::VARR};
         for (const auto& hash : pruned_block_hashes) {
    @@ -439,7 +439,7 @@ UniValue PrunedBlocksErrorData(const std::set<uint256>& pruned_block_hashes)
         }
         UniValue data{UniValue::VOBJ};
         data.pushKV("pruned_block_hashes", std::move(hashes));
    -    return data;
    +    return JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(pruned_block_hashes), data);
     }
     
     /**
    diff --git a/src/rpc/util.h b/src/rpc/util.h
    index 4acd77cf4b..d5ac400b31 100644
    --- a/src/rpc/util.h
    +++ b/src/rpc/util.h
    @@ -149,8 +149,8 @@ UniValue JSONRPCTransactionError(node::TransactionError terr, const std::string&
     
     //! Error message when a transaction lookup only matched blocks whose data was pruned.
     std::string PrunedBlocksErrorMessage(const std::set<uint256>& pruned_block_hashes);
    -//! Structured JSON-RPC error.data for a pruned-block transaction lookup miss.
    -UniValue PrunedBlocksErrorData(const std::set<uint256>& pruned_block_hashes);
    +//! JSON-RPC error for a pruned-block transaction lookup miss.
    +UniValue JSONRPCPrunedBlocksError(const std::set<uint256>& pruned_block_hashes);
     
     //! Parse a JSON range specified as int64, or [int64, int64]
     std::pair<int64_t, int64_t> ParseDescriptorRange(const UniValue& value);
    

    </details>

  79. in src/rpc/rawtransaction.cpp:354 in 23f14ab325
     350 | @@ -345,6 +351,9 @@ static RPCMethod getrawtransaction()
     351 |          }
     352 |          throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, errmsg + ". Use gettransaction for wallet transactions.");
     353 |      }
     354 | +    const auto& found{std::get<TxFound>(tx_result)};
    


    l0rinc commented at 1:44 AM on October 14, 2026:

    012c384 rpc: report pruned blocks from GetTransaction:

    nit: these three lines could be a structured binding, here and in rest_tx:

    <details> <summary>Use structured bindings for the found transaction</summary>

    diff --git a/src/rest.cpp b/src/rest.cpp
    index 49676cf828..b583bb1abe 100644
    --- a/src/rest.cpp
    +++ b/src/rest.cpp
    @@ -909,9 +909,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string
             }
             return RESTERR(req, HTTP_NOT_FOUND, hashStr + " not found");
         }
    -    const auto& found{std::get<TxFound>(result)};
    -    const CTransactionRef& tx{found.tx};
    -    const uint256& hashBlock{found.block_hash};
    +    const auto& [tx, hashBlock]{std::get<TxFound>(result)};
         switch (rf) {
         case RESTResponseFormat::BINARY: {
             DataStream ssTx;
    diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
    index 5e38401c48..ebf9f955e7 100644
    --- a/src/rpc/rawtransaction.cpp
    +++ b/src/rpc/rawtransaction.cpp
    @@ -348,9 +348,7 @@ static RPCMethod getrawtransaction()
             }
             throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, errmsg + ". Use gettransaction for wallet transactions.");
         }
    -    const auto& found{std::get<TxFound>(tx_result)};
    -    const CTransactionRef& tx{found.tx};
    -    const uint256& hash_block{found.block_hash};
    +    const auto& [tx, hash_block]{std::get<TxFound>(tx_result)};
     
         if (verbosity <= 0) {
             return EncodeHexTx(*tx);
    

    </details>

  80. in src/test/txindex_tests.cpp:93 in 23f14ab325
      87 | @@ -85,9 +88,18 @@ FlatFilePos BlockFilePos(const ChainstateManager& chainman, uint32_t height)
      88 |  uint256 LookupTx(const TxIndex& txindex, const Txid& txid)
      89 |  {
      90 |      const auto result{txindex.FindTx(txid)};
      91 | -    BOOST_REQUIRE(result);
      92 | -    BOOST_CHECK(result->tx->GetHash() == txid);
      93 | -    return result->block_hash;
      94 | +    const auto* found{std::get_if<TxFound>(&result)};
      95 | +    BOOST_REQUIRE(found);
      96 | +    BOOST_CHECK(found->tx->GetHash() == txid);
    


    l0rinc commented at 1:45 AM on October 14, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    nit: given that found is a prerequisite (i.e. part of the setup) and not the main assertion, I usually include it in the assertion. You're already using this pattern later, but resolve without comment if you disagree.

    The pruning setup is repeated four times below, so it could also use a small helper next to InvalidateBlock.

    <details> <summary>Compact the lookup assertions and pruning setup</summary>

    diff --git a/src/test/txindex_tests.cpp b/src/test/txindex_tests.cpp
    index ed64b92b75..94de39b35c 100644
    --- a/src/test/txindex_tests.cpp
    +++ b/src/test/txindex_tests.cpp
    @@ -89,8 +89,7 @@ uint256 LookupTx(const TxIndex& txindex, const Txid& txid)
     {
         const auto result{txindex.FindTx(txid)};
         const auto* found{std::get_if<TxFound>(&result)};
    -    BOOST_REQUIRE(found);
    -    BOOST_CHECK(found->tx->GetHash() == txid);
    +    BOOST_CHECK(Assert(found)->tx->GetHash() == txid);
         return found->block_hash;
     }
     
    @@ -98,8 +97,7 @@ void CheckNotFound(const TxIndex& txindex, const Txid& txid, const std::set<uint
     {
         const auto result{txindex.FindTx(txid)};
         const auto* miss{std::get_if<TxMiss>(&result)};
    -    BOOST_REQUIRE(miss);
    -    BOOST_CHECK(miss->pruned_block_hashes == expected_pruned_block_hashes);
    +    BOOST_CHECK(Assert(miss)->pruned_block_hashes == expected_pruned_block_hashes);
     }
     
     void InvalidateBlock(ChainstateManager& chainman, const uint256& block_hash)
    @@ -110,6 +108,12 @@ void InvalidateBlock(ChainstateManager& chainman, const uint256& block_hash)
         BOOST_REQUIRE(chainman.ActiveChainstate().InvalidateBlock(state, block_index));
     }
     
    +void PruneBlock(ChainstateManager& chainman, const uint256& block_hash)
    +{
    +    LOCK(cs_main);
    +    Assert(chainman.m_blockman.LookupBlockIndex(block_hash))->nStatus &= ~BLOCK_HAVE_DATA;
    +}
    +
     } // namespace
     
     BOOST_AUTO_TEST_CASE(txindex_position_encoding)
    @@ -237,16 +241,10 @@ BOOST_FIXTURE_TEST_CASE(txindex_collision_scan_path, TestChain100Setup)
         db.Write(txindex::LegacyTxKey(fake_txid), fake_physical);
         CheckNotFound(txindex, fake_txid);
     
    -    {
    -        LOCK(cs_main);
    -        Assert(m_node.chainman->m_blockman.LookupBlockIndex(fake_block_hash))->nStatus &= ~BLOCK_HAVE_DATA;
    -    }
    +    PruneBlock(*m_node.chainman, fake_block_hash);
         BOOST_CHECK(LookupTx(txindex, target_txid) == target_block_hash);
     
    -    {
    -        LOCK(cs_main);
    -        Assert(m_node.chainman->m_blockman.LookupBlockIndex(target_block_hash))->nStatus &= ~BLOCK_HAVE_DATA;
    -    }
    +    PruneBlock(*m_node.chainman, target_block_hash);
         CheckNotFound(txindex, target_txid, {target_block_hash, fake_block_hash});
     
         StopIndex(txindex, m_node);
    @@ -376,11 +374,8 @@ BOOST_FIXTURE_TEST_CASE(txindex_reorg_keeps_stale_entries, TestChain100Setup)
         BOOST_REQUIRE_EQUAL(reorg_bucket.size(), 2U);
         BOOST_CHECK(reorg_bucket.front() == original_bucket.front());
     
    -    {
    -        LOCK(cs_main);
    -        Assert(chainman.m_blockman.LookupBlockIndex(stale_block_hash))->nStatus &= ~BLOCK_HAVE_DATA;
    -        Assert(chainman.m_blockman.LookupBlockIndex(branch_block_hash))->nStatus &= ~BLOCK_HAVE_DATA;
    -    }
    +    PruneBlock(chainman, stale_block_hash);
    +    PruneBlock(chainman, branch_block_hash);
         CheckNotFound(txindex, unique_txid, {stale_block_hash});
     
         StopIndex(txindex, m_node);
    

    </details>

  81. in src/rpc/rawtransaction.cpp:341 in 23f14ab325
     341 | +            if (chainman.m_blockman.IsBlockPruned(*blockindex)) {
     342 | +                const std::set<uint256> block_hashes{blockindex->GetBlockHash()};
     343 | +                throw JSONRPCError(RPC_MISC_ERROR, PrunedBlocksErrorMessage(block_hashes),
     344 | +                                   PrunedBlocksErrorData(block_hashes));
     345 | +            }
     346 | +            if (!(blockindex->nStatus & BLOCK_HAVE_DATA)) {
    


    l0rinc commented at 1:46 AM on October 14, 2026:

    012c384 rpc: report pruned blocks from GetTransaction:

    gettxoutproof follows the same pruned check with CheckBlockDataAvailability, which we could reuse here. A block that was never downloaded would then report Block not available (not fully downloaded), as gettxoutproof and getblock do, which the existing header-only test can check:

    <details> <summary>Reuse the block availability check</summary>

    (I prepared this locally in commits so the patches may not apply cleanly)

    diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
    index ebf9f955e7..3714ee26cc 100644
    --- a/src/rpc/rawtransaction.cpp
    +++ b/src/rpc/rawtransaction.cpp
    @@ -31,6 +31,7 @@
     #include <psbt.h>
     #include <pubkey.h>
     #include <random.h>
    +#include <rpc/blockchain.h>
     #include <rpc/protocol.h>
     #include <rpc/rawtransaction_util.h>
     #include <rpc/request.h>
    @@ -335,9 +336,7 @@ static RPCMethod getrawtransaction()
                 if (chainman.m_blockman.IsBlockPruned(*blockindex)) {
                     throw JSONRPCPrunedBlocksError({blockindex->GetBlockHash()});
                 }
    -            if (!(blockindex->nStatus & BLOCK_HAVE_DATA)) {
    -                throw JSONRPCError(RPC_MISC_ERROR, "Block not available");
    -            }
    +            CheckBlockDataAvailability(chainman.m_blockman, *blockindex, /*check_for_undo=*/false);
                 errmsg = "No such transaction found in the provided block";
             } else if (!g_txindex) {
                 errmsg = "No such mempool transaction. Use -txindex or provide a block hash to enable blockchain transaction queries";
    diff --git a/test/functional/rpc_blockchain.py b/test/functional/rpc_blockchain.py
    index c61b49c9fc..7b94d2d7e9 100755
    --- a/test/functional/rpc_blockchain.py
    +++ b/test/functional/rpc_blockchain.py
    @@ -747,6 +747,7 @@ class BlockchainTest(BitcoinTestFramework):
             block.solve()
             node.submitheader(block.serialize().hex())
             assert_raises_rpc_error(-1, "Block not available (not fully downloaded)", lambda: node.getblock(block.hash_hex))
    +        assert_raises_rpc_error(-1, "Block not available (not fully downloaded)", node.getrawtransaction, block.vtx[0].txid_hex, 0, block.hash_hex)
     
             self.log.info("Test getblock when block data is available but undo data isn't")
             # Submits a block building on the header-only block, so it can't be connected and has no undo data
    

    </details>

  82. in doc/release-notes-36002.md:17 in 23f14ab325 outdated
      12 | +  error -1 when a transaction may be found in one or more pruned blocks.
      13 | +  Their hashes are returned in the error message and the
      14 | +  `error.data.pruned_block_hashes` array. The blocks are not guaranteed to contain
      15 | +  the transaction. This also applies without txindex for explicit block hashes
      16 | +  and `gettxoutproof` lookups through the UTXO set. (#36002)
      17 | +
    


    l0rinc commented at 1:49 AM on October 14, 2026:

    23f14ab doc: add release note for txindex pruning support:

    - `getrawtransaction` with verbosity 2 now omits `fee` and `prevout` instead of
      returning an error when the block is available but its undo data is not,
      e.g. after `getblockfrompeer`. (#36002)
    
  83. in src/test/txindex_tests.cpp:381 in 23f14ab325
     375 | @@ -349,6 +376,13 @@ BOOST_FIXTURE_TEST_CASE(txindex_reorg_keeps_stale_entries, TestChain100Setup)
     376 |      BOOST_REQUIRE_EQUAL(reorg_bucket.size(), 2U);
     377 |      BOOST_CHECK(reorg_bucket.front() == original_bucket.front());
     378 |  
     379 | +    {
     380 | +        LOCK(cs_main);
     381 | +        Assert(chainman.m_blockman.LookupBlockIndex(stale_block_hash))->nStatus &= ~BLOCK_HAVE_DATA;
    


    l0rinc commented at 1:50 AM on October 14, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    What should happen when the active-chain copy is pruned but a stale copy is still readable? We return the stale block, so getrawtransaction shows zero confirmations and verifytxoutproof rejects the proof from gettxoutproof.

    We cannot just prefer the pruned hash either, since it might be a prefix collision. Is the current fallback what we want? If so, we could cover it in the existing reorg test (using the PruneBlock helper from my other comment):

    <details> <summary>Cover the readable stale-block fallback</summary>

    diff --git a/src/index/txindex.cpp b/src/index/txindex.cpp
    index 538ada2caf..00256b329f 100644
    --- a/src/index/txindex.cpp
    +++ b/src/index/txindex.cpp
    @@ -170,7 +170,7 @@ TxLookupResult TxIndex::FindTx(const Txid& tx_hash) const
             uint32_t block_seq;
             //! Whether this candidate's block is currently in the active chain.
             //! Active chain candidates are attempted first, so duplicate entries
    -        //! in both active and stale blocks will always return the active block hash.
    +        //! in both active and stale blocks will return the active block hash if readable.
             bool in_active_chain;
         };
         std::vector<Candidate> candidates;
    diff --git a/src/test/txindex_tests.cpp b/src/test/txindex_tests.cpp
    index 94de39b35c..d57a7074a4 100644
    --- a/src/test/txindex_tests.cpp
    +++ b/src/test/txindex_tests.cpp
    @@ -375,6 +375,8 @@ BOOST_FIXTURE_TEST_CASE(txindex_reorg_keeps_stale_entries, TestChain100Setup)
         BOOST_CHECK(reorg_bucket.front() == original_bucket.front());
     
         PruneBlock(chainman, stale_block_hash);
    +    // A readable stale copy still supplies the transaction when the active copy is pruned.
    +    BOOST_CHECK(LookupTx(txindex, unique_txid) == branch_block_hash);
         PruneBlock(chainman, branch_block_hash);
         CheckNotFound(txindex, unique_txid, {stale_block_hash});
     
    

    </details>

  84. in src/index/txindex.cpp:223 in 23f14ab325
     219 | @@ -209,25 +220,26 @@ std::optional<TxIndexResult> TxIndex::FindTx(const Txid& tx_hash) const
     220 |              continue;
     221 |          }
     222 |          if (tx->GetHash() == tx_hash) {
     223 | -            return TxIndexResult{candidate.block_hash, std::move(tx)};
     224 | +            return TxFound{std::move(tx), candidate.block_hash};
    


    l0rinc commented at 1:55 AM on October 14, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    This can be a bit shorter if we construct the transaction from the file and check its hash inside the try block. That also keeps tx local and makes the continue unnecessary.

    I'm conflicted about whether a read failure should just log a warning and try the next candidate - but that's preexisting.

    <details> <summary>Keep candidate reading and matching together</summary>

    diff --git a/src/index/txindex.cpp b/src/index/txindex.cpp
    index b6e38d0abe..a47727cdb8 100644
    --- a/src/index/txindex.cpp
    +++ b/src/index/txindex.cpp
    @@ -218,15 +218,11 @@ TxLookupResult TxIndex::FindTx(const Txid& tx_hash) const
                 LogWarning("OpenBlockFile failed for txid %s", tx_hash.ToString());
                 continue;
             }
    -        CTransactionRef tx;
             try {
    -            file >> TX_WITH_WITNESS(tx);
    +            auto tx{std::make_shared<const CTransaction>(deserialize, TX_WITH_WITNESS, file)};
    +            if (tx->GetHash() == tx_hash) return TxFound{std::move(tx), candidate.block_hash};
             } catch (const std::exception& e) {
                 LogWarning("Deserialize or I/O error - %s", e.what());
    -            continue;
    -        }
    -        if (tx->GetHash() == tx_hash) {
    -            return TxFound{std::move(tx), candidate.block_hash};
             }
         }
         // Fall back to legacy if no hashed entry matched. This makes misses pay an
    

    </details>


    l0rinc commented at 2:39 AM on October 14, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    This can be a bit shorter if we construct the transaction from the file and check its hash inside the try block. That also keeps tx local and makes the continue unnecessary.

    Whether a read failure should just log a warning and try the next candidate is a separate question. That behavior predates this PR, and this suggestion leaves it unchanged.

    <details> <summary>Keep candidate reading and matching together</summary>

    diff --git a/src/index/txindex.cpp b/src/index/txindex.cpp
    index b6e38d0abe..a47727cdb8 100644
    --- a/src/index/txindex.cpp
    +++ b/src/index/txindex.cpp
    @@ -218,15 +218,11 @@ TxLookupResult TxIndex::FindTx(const Txid& tx_hash) const
                 LogWarning("OpenBlockFile failed for txid %s", tx_hash.ToString());
                 continue;
             }
    -        CTransactionRef tx;
             try {
    -            file >> TX_WITH_WITNESS(tx);
    +            auto tx{std::make_shared<const CTransaction>(deserialize, TX_WITH_WITNESS, file)};
    +            if (tx->GetHash() == tx_hash) return TxFound{std::move(tx), candidate.block_hash};
             } catch (const std::exception& e) {
                 LogWarning("Deserialize or I/O error - %s", e.what());
    -            continue;
    -        }
    -        if (tx->GetHash() == tx_hash) {
    -            return TxFound{std::move(tx), candidate.block_hash};
             }
         }
         // Fall back to legacy if no hashed entry matched. This makes misses pay an
    

    </details>

  85. in src/rpc/rawtransaction.cpp:389 in 23f14ab325 outdated
     385 | @@ -377,7 +386,7 @@ static RPCMethod getrawtransaction()
     386 |      CBlockUndo blockUndo;
     387 |      CBlock block;
     388 |  
     389 | -    if (tx->IsCoinBase() || !blockindex || WITH_LOCK(::cs_main, return !(blockindex->nStatus & BLOCK_HAVE_MASK))) {
     390 | +    if (tx->IsCoinBase() || !blockindex || WITH_LOCK(::cs_main, return !(blockindex->nStatus & BLOCK_HAVE_UNDO))) {
    


    l0rinc commented at 1:58 AM on October 14, 2026:

    012c384 rpc: report pruned blocks from GetTransaction:

    I think this would be easier to review as smaller commits. I tried this split locally to help me with review, consider if you agree whether it separates concerns better:

    1. Move the missing-undo fix to the start of the PR. It fixes getrawtransaction without txindex too, so it could also go in a separate PR. I can push that PR if you want, but it would conflict with this one.
    2. Add the optional error.data argument to JSONRPCError on its own.
    3. Keep the pruned-block reporting in GetTransaction, RPC and REST together.
    4. Switch ProcessPSBT to GetTransaction separately, since that also changes the lookup order to mempool first.
  86. in src/index/txindex.cpp:229 in 23f14ab325 outdated
     226 |      }
     227 |      // Fall back to legacy if no hashed entry matched. This makes misses pay an
     228 |      // extra lookup, but keeps existing full-txid entries readable after upgrade.
     229 | -    return m_db->m_has_legacy ? FindLegacyTx(tx_hash) : std::nullopt;
     230 | +    if (m_db->m_has_legacy) return FindLegacyTx(tx_hash);
     231 | +    return TxMiss{std::move(pruned_block_hashes)};
    


    l0rinc commented at 2:36 AM on October 14, 2026:

    2e9cf9d txindex: return pruned block hashes from FindTx:

    Based on Ralph's review: could pruning remove the file between the scan above and OpenBlockFile?

    It predates this PR, but this PR enables that combination, so the race becomes possible in normal use and belongs with this change.

    <details> <summary>Preserve the candidate after concurrent pruning</summary>

    diff --git a/src/index/txindex.cpp b/src/index/txindex.cpp
    index 00256b329f..b6e38d0abe 100644
    --- a/src/index/txindex.cpp
    +++ b/src/index/txindex.cpp
    @@ -209,6 +209,12 @@ TxLookupResult TxIndex::FindTx(const Txid& tx_hash) const
         for (const auto& candidate : candidates) {
             AutoFile file{m_chainstate->m_blockman.OpenBlockFile(candidate.tx_position, /*fReadOnly=*/true)};
             if (file.IsNull()) {
    +            LOCK(cs_main);
    +            const CBlockIndex* block_index{m_chainstate->m_blockman.LookupBlockIndex(candidate.block_hash)};
    +            if (block_index && m_chainstate->m_chain.Contains(*block_index) && m_chainstate->m_blockman.IsBlockPruned(*block_index)) {
    +                pruned_block_hashes.insert(candidate.block_hash);
    +                continue;
    +            }
                 LogWarning("OpenBlockFile failed for txid %s", tx_hash.ToString());
                 continue;
             }
    

    </details>

  87. in test/functional/feature_txindex_compatibility.py:70 in 23f14ab325 outdated
      66 | @@ -67,6 +67,16 @@ def _test_txindex_compatibility(self):
      67 |          assert_equal(node.getrawtransaction(txId1), tx1['hex'])
      68 |          assert_equal(node.getrawtransaction(txId2), tx2['hex'])
      69 |  
      70 | +        self.log.info("Check rebuild advice after pruning with the legacy txindex disabled")
    


    l0rinc commented at 2:41 AM on October 14, 2026:

    46542ed txindex: allow prune mode when there are no legacy entries:

    nit: we already have sync_txindex for these three waits. This also gives us its sync logging.

    <details> <summary>Reuse the index-sync helper</summary>

    diff --git a/test/functional/feature_txindex_compatibility.py b/test/functional/feature_txindex_compatibility.py
    index 94afffe498..970dba47d5 100755
    --- a/test/functional/feature_txindex_compatibility.py
    +++ b/test/functional/feature_txindex_compatibility.py
    @@ -9,7 +9,7 @@
     import shutil
     
     from test_framework.test_framework import BitcoinTestFramework
    -from test_framework.util import assert_equal
    +from test_framework.util import assert_equal, sync_txindex
     from test_framework.wallet import MiniWallet
     
     class TxIndexTest(BitcoinTestFramework):
    @@ -43,7 +43,7 @@ class TxIndexTest(BitcoinTestFramework):
             txId1 = tx1['txid']
     
             for n in self.nodes:
    -            self.wait_until(lambda: n.getindexinfo()['txindex']['synced'])
    +            sync_txindex(self, n)
     
             self.log.info("Ensure that queries to the txindex are consistent between the different index versions")
             assert_equal(node.getrawtransaction(txId1), tx1['hex'])
    @@ -61,14 +61,14 @@ class TxIndexTest(BitcoinTestFramework):
             msg = "txindex contains entries in the legacy format"
             with node.assert_debug_log(expected_msgs=[msg]):
                 self.start_node(0)
    -        self.wait_until(lambda: node.getindexinfo()['txindex']['synced'])
    +        sync_txindex(self, node)
             assert_equal(node.getrawtransaction(txId1), tx1['hex'])
     
             self.log.info("Test that looking up a newly added transaction in a mixed-format db is possible")
             tx2 = self.wallet.send_self_transfer(from_node=self.nodes[0])
             self.generate(self.nodes[0], 1, sync_fun=self.no_op)
             txId2 = tx2['txid']
    -        self.wait_until(lambda: node.getindexinfo()['txindex']['synced'])
    +        sync_txindex(self, node)
             assert_equal(node.getrawtransaction(txId1), tx1['hex'])
             assert_equal(node.getrawtransaction(txId2), tx2['hex'])
     
    

    </details>

  88. l0rinc changes_requested
  89. l0rinc commented at 2:43 AM on October 14, 2026: contributor

    re-concept ACK, overall LGTM, left a few remaining suggestions, it's looks pretty good already.

  90. rpc: handle missing undo data in getrawtransaction
    Check for BLOCK_HAVE_UNDO before reading undo data for verbosity 2.
    
    Return the transaction without fee and prevout when undo data is
    unavailable, matching the documented behavior.
    11a92844e5
  91. rpc: allow optional data in JSONRPCError
    Accept an optional data value and include it in the error object when
    non-null. Preserve the existing response shape when no data is supplied.
    ae0447b2a4
  92. txindex: allow prune mode when there are no legacy entries afde5bfad4
  93. txindex: return pruned block hashes from FindTx
    FindTx now returns a TxLookupResult so callers can distinguish a
    missing transaction from one that may only be in pruned blocks.
    faf42446ba
  94. rpc: report pruned blocks from GetTransaction
    Propagate lookup results through GetTransaction to RPC and REST callers.
    Include candidate pruned block hashes in error messages and JSON-RPC
    error.data, including the known block when gettxoutproof uses the UTXO
    set. Callers can fetch missing blocks with getblockfrompeer and retry.
    
    Use GetTransaction when updating a PSBT so unavailable previous
    transactions are skipped and lookups check the mempool first.
    c8714eb725
  95. test: cover txindex lookups of pruned blocks
    Extend the prune+txindex functional test to check that
    getrawtransaction, gettxoutproof, and REST /tx return the pruned block hash,
    and that utxoupdatepsbt skips those previous transactions until the
    block is fetched.
    b45c0004b9
  96. doc: add release note for txindex pruning support 4ab48b8276
  97. andrewtoth force-pushed on Oct 18, 2026
  98. andrewtoth commented at 6:42 PM on October 18, 2026: contributor

    Thanks @l0rinc for your detailed review. I took all your suggestions.


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-10-19 04:51 UTC

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