Encapsulation for CTransaction #35569

pull purpleKarrot wants to merge 4 commits into bitcoin:master from purpleKarrot:transaction-abstraction changing 94 files +644 −513
  1. purpleKarrot commented at 3:52 PM on June 19, 2026: contributor

    CTransaction currently has public data members that are marked const. Details why this is problematic, what the alternatives are, and how to fix it are given here or here.

    The code is refactored using the following steps:

    1. Public observer functions are added to CTransaction for each data member.
    2. A bitcoin-tidy check is added to rewrite direct data member access to use an observer function.
    3. A "scripted-diff" that is the result of running the bitcoin-tidy check with -fix.
    4. Making CTransaction's data members private, without changing semantics of the type.

    Implementing regular semantics for CTransaction is deferred. The refactoring is a prerequisite for further refactoring that will untangle the type from serialization logic (read about those plans here or here) which will result in better reasoning and faster compilation.

  2. DrahtBot commented at 3:52 PM on June 19, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

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

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK josibake
    Concept NACK ajtowns
    Concept ACK w0xlt, janb84
    Approach NACK 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:

    • #35671 (mining: add TxCollection to bandwidth-efficiently validate external block templates by Sjors)
    • #35587 (Remove boost as a unit test runner by rustaceanrob)
    • #35580 (bugfix: compare non-adjusted chunk weight against block weight limit by ismaelsadeeq)
    • #35570 (refactor: Change some validation.cpp methods to return BlockValidationState by optout21)
    • #35501 (wallet: store all witness variants of a transaction by achow101)
    • #35302 (Silent Payments: Sending (take 2) by Eunovo)
    • #35295 (validation: fetch block input prevouts in parallel during ConnectBlock by andrewtoth)
    • #34875 (refactor: separate deferred script check collection from CheckInputScripts by l0rinc)
    • #34864 (coins: tighten cache entry state invariants by l0rinc)
    • #32958 (wallet/refactor: Update SignPSBTInput to return util::Expected<void, PSBTError> and remove PSBTError:Ok by kevkevinpal)
    • #32729 (test, refactor: extract script template helpers and expand sigop coverage by l0rinc)
    • #32575 (consensus: Remove special treatment for single threaded script checking by fjahr)
    • #32317 (kernel: Separate UTXO set access from validation functions by sedited)
    • #31682 ([IBD] specialize CheckBlock's input & coinbase checks by l0rinc)
    • #31252 (rpc: print P2WSH and P2SH redem Script in getrawtransaction and getblock by polespinasa)
    • #29843 (policy: Allow non-standard scripts with -acceptnonstdtxn=1 (test nets only) by ajtowns)
    • #27865 (wallet: Track no-longer-spendable TXOs separately by achow101)

    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-->

    LLM Linter (✨ experimental)

    Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

    • emplace_back(COutPoint(pblock->vtx[0]->GetHash(), 0), CScript(), 0) in src/test/validation_block_tests.cpp
    • Coin(ptx->GetOutputs()[outpoint.n], MEMPOOL_HEIGHT, false) in src/txmempool.cpp
    • Coin(tx->GetOutputs()[n], MEMPOOL_HEIGHT, false) in src/txmempool.cpp

    <sup>2026-06-26 15:19:42</sup>

  3. fanquake commented at 4:51 PM on June 19, 2026: member

    More details at

    Can you put all the parts relevant to reviewers into the PR description?

  4. DrahtBot added the label CI failed on Jun 19, 2026
  5. DrahtBot commented at 5:07 PM on June 19, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task lint: https://github.com/bitcoin/bitcoin/actions/runs/27835663586/job/82382956280</sub> <sub>LLM reason (✨ experimental): CI failed because the lint scripted-diff check errored out with cmake: not found (and run-clang-tidy: not found), indicating missing tools in the lint container.</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. purpleKarrot force-pushed on Jun 20, 2026
  7. purpleKarrot marked this as ready for review on Jun 20, 2026
  8. purpleKarrot commented at 5:12 AM on June 20, 2026: contributor

    export CXX="$(which clang)"

    should be clang++. But before I push that change and trigger another rebuild, I will wait for more comments.

    The linter fails validating the scripted_diff due to cmake and run-clang-tidy not being found. How can this be fixed?

  9. ajtowns commented at 4:07 PM on June 20, 2026: contributor

    The result is that CTransaction has regular semantics for copy, move, and assignment.

    This doesn't seem like a good idea at all to me. If you want an object that can represent different transactions over its lifetime, you should be using CMutableTransaction, and afaics we don't want that for things like the mempool, or for anything we reference via a CTransactionRef.

    Note that the comment ("CTransaction is not actually immutable; deserialization and assignment are implemented, and bypass the constness.") was made incorrect by #8580 in 2016.

  10. janb84 commented at 11:34 AM on June 21, 2026: contributor

    export CXX="$(which clang)"

    should be clang++. But before I push that change and trigger another rebuild, I will wait for more comments.

    The linter fails validating the scripted_diff due to cmake and run-clang-tidy not being found. How can this be fixed?

    I do not think you can do this, as far as I can tell the linter container image does not contain cmake. Drop the scripted diff validation ?

  11. purpleKarrot force-pushed on Jun 21, 2026
  12. purpleKarrot commented at 4:24 PM on June 21, 2026: contributor

    The result is that CTransaction has regular semantics for copy, move, and assignment.

    This doesn't seem like a good idea at all to me.

    Just to make sure there is no misunderstanding here. All builtin types in C++ as well as nearly all types in the standard library have regular semantics. You are saying that is a bad design, right?

    we don't want [an object that can represent different transactions over its lifetime] for things like the mempool

    So instead, the mempool uses CTransactionRef, an object that can not only represent different transactions over its lifetime, but can also change whether it actually holds a transaction or not. I don't get how that is better.

  13. purpleKarrot force-pushed on Jun 21, 2026
  14. DrahtBot removed the label CI failed on Jun 21, 2026
  15. w0xlt commented at 8:29 PM on June 21, 2026: contributor

    Concept ACK

  16. ajtowns commented at 6:54 AM on June 22, 2026: contributor

    The result is that CTransaction has regular semantics for copy, move, and assignment.

    This doesn't seem like a good idea at all to me.

    Just to make sure there is no misunderstanding here. All builtin types in C++ as well as nearly all types in the standard library have regular semantics. You are saying that is a bad design, right?

    No, I said exactly what I intended to say: making CTransaction mutable is a bad idea. We already have CMutableTransaction for cases where mutability is needed.

    we don't want [an object that can represent different transactions over its lifetime] for things like the mempool

    So instead, the mempool uses CTransactionRef, an object that can not only represent different transactions over its lifetime, but can also change whether it actually holds a transaction or not. I don't get how that is better.

    Passing a CTransactionRef around prevents the recipient from modifying it (ignoring const casts), but doesn't prevent the creator from doing so.

        std::shared_ptr<CTransaction> foo;
        vRecv >> TX_WITH_WITNESS(foo);
    
        ProcessTransaction(foo); // add to mempool
    
        foo->version = 42; // mempool copy is automatically updated, how neat!
    
  17. josibake commented at 9:28 AM on June 22, 2026: member

    Concept ACK

    Moving the data members behind observers, vs having the class layout define the API seems a solid improvement.

    The result is that CTransaction has regular semantics for copy, move, and assignment ..making CTransaction mutable is a bad idea

    Porque no los dos? Turns out, afaict, there is a C++ idiom for this: handle/body idiom with shared immutable representation! What I mean by this is we want to make the exposed transaction type a regular value handle to immutable transaction data^1. Concretely, something like this:

    class CTransaction {
    public:
        CTransaction(const CTransaction&) = default;
        CTransaction(CTransaction&&) noexcept = default;
        CTransaction& operator=(const CTransaction&) = default;
        CTransaction& operator=(CTransaction&&) noexcept = default;
    
        // the observers added in this PR 
       ...
    
    private:
        std::shared_ptr<const TransactionData> m_data;
    };
    

    TransactionData would of course contain vin, vout, version, nLockTime, cached hashes, etc., and would be immutable after construction from CMutableTransaction or deserialisation. Now assignment has normal value semantics:

    auto a = tx1;
    auto b = a;
    a = tx2; // b still observes tx1
    

    That is different from mutating a shared transaction object:

    auto p = std::shared_ptr<CTransaction>{...};
    ProcessTransaction(p);
    *p = tx2; // bad: existing users may observe changed transaction contents
    

    While we still use using CTransactionRef = std::shared_ptr<const CTransaction>, perhaps its best to have CTransaction assignment deleted, or the ref type needs to be updated so the shared thing is the immutable TransactionData, not the assignable wrapper.

    Based on my understanding of the linked blogs, this seems in line with their stated goals. We can get rid of the "class layout is the API," eventually allowing serialisation/deserialisation to move behind an explicit construction/codec boundary as described in the blogs. It also plays nice with the vocabulary type direction: the eventual public transaction type can be regular and idiomatic, while preserving the invariant of once a transaction contents are published/shared, they are immutable.

  18. purpleKarrot commented at 9:50 AM on June 22, 2026: contributor

    @josibake, exactly. As highlighted in my blog post, the next refactoring step would be moving the std::shared_ptr<const> behind the API boundary. This would result in the exact same runtime indirection as currently, it is just a different level of abstraction. However,

    While we still use using CTransactionRef = std::shared_ptr<const CTransaction>, perhaps its best to have CTransaction assignment deleted.

    for what benefit? It is pointing to a const object. The pointer can be changed to point to a different object (or no object at all) but the shared pointee can not be changed. There can be no spooky action at a distance.

  19. josibake commented at 11:17 AM on June 22, 2026: member

    for what benefit? It is pointing to a const object. The pointer can be changed to point to a different object (or no object at all) but the shared pointee can not be changed. There can be no spooky action at a distance.

    The benefit is removing a footgun while refactoring. As long as CTransactionRef shares the CTransaction wrapper, making that wrapper assignable leaves a possible non-const-alias footgun. So we either keep assignment deleted during that transition, or move the shared state behind the API boundary first.

    Once we reach stage 7 detailed in your bitcoin-tidy blog (which more or less matches my earlier comment, perhaps sans a few details), CTransaction is the value handle and the shared state is behind the API boundary and thus default assignment is fine (and desirable!) because it only rebinds to the handle.

  20. in src/primitives/transaction.h:273 in 18b0efc5ad


    janb84 commented at 11:55 AM on June 22, 2026:
        return std::accumulate(tx.GetOutputs().cbegin(), tx.GetOutputs().cend(), CAmount{0}, [](CAmount sum, const auto& txout) { return sum + txout.nValue; });
    

    Intentional skipped ? (It's ok as is)


    purpleKarrot commented at 12:18 PM on June 22, 2026:

    Interesting catch. The clang-tidy check was not fired on this code and making the data members private did not break it. Apparently, the function template is never instantiated with CTransaction; only with CMutableTransaction. There is also CTransaction::GetValueOut().


    josibake commented at 8:35 AM on June 23, 2026:

    I don't see anything that would prevent this from being used with CTransaction, though? Seems we could handle this case cleanly by recognising TxType in a template argument as actually a CTx. This would avoid more broadly rewriting vout, vin, version, etc in other places.


    josibake commented at 11:46 AM on June 23, 2026:

    Per #35569 (comment), I convinced myself this isn't an issue.

  21. janb84 commented at 11:56 AM on June 22, 2026: contributor

    Concept ACK

    The benefit is removing a footgun while refactoring. As long as CTransactionRef shares the CTransaction wrapper, making that wrapper assignable leaves a possible non-const-alias footgun. So we either keep assignment deleted during that transition, or move the shared state behind the API boundary first.

    I second this approach. Also reaching "stage 7" can take a while, if ever reached.

  22. purpleKarrot commented at 12:28 PM on June 22, 2026: contributor
    auto p = std::shared_ptr<CTransaction>{...};
    ProcessTransaction(p);
    *p = tx2; // bad: existing users may observe changed transaction contents
    

    I get it now. CTransactionRef being a shared_ptr<const> can only prevent mutation through that handle, it cannot prove that no non-const alias exists. Yes, as long as shared_ptr<const> is used, it would be better to explicitly disallow both assignment operators. I'll update that.

  23. purpleKarrot force-pushed on Jun 22, 2026
  24. purpleKarrot renamed this:
    Making CTransaction a Regular Type
    Encapsulation for CTransaction
    on Jun 22, 2026
  25. purpleKarrot force-pushed on Jun 22, 2026
  26. DrahtBot added the label CI failed on Jun 22, 2026
  27. DrahtBot removed the label CI failed on Jun 22, 2026
  28. in src/primitives/transaction.h:291 in 926618a85c
     308 |      /** Convert a CMutableTransaction into a CTransaction. */
     309 |      explicit CTransaction(const CMutableTransaction& tx);
     310 |      explicit CTransaction(CMutableTransaction&& tx);
     311 |  
     312 | +    CTransaction(const CTransaction&) = default;
     313 | +    CTransaction(CTransaction&&) = default;
    


    josibake commented at 2:50 PM on June 22, 2026:

    As part of reviewing this, I nerd sniped myself into reading about move semantics and had the thought: are vectors treated differently than fixed length values? Turns out they are! So for uint256 moves are copy like, but for vin/vout, being vectors, move could actually mean move the underlying storage. In light of that, I think we should delete the move constructer during the transition.

    Concretely, if vin/vout are no longer const then this would be a memberwise move. Shouldn't matter for the moved to transaction, but the moved from one could be left with m_witness_hash, m_has_witness etc members that still describe the pre-moved state, but the vin/vouts of the moved from could now be empty.

    Obviously, this would no longer be a problem once CTransaction represents a value reference to an immutable struct, at which point the move constructer can be added back along with the assignment operation.

    I applied this suggestion locally and everything compiled, so doesn't seem to break any existing behaviour.


    purpleKarrot commented at 3:01 PM on June 22, 2026:

    Good point! But instead of deleting the move constructor, we should simply not provide it.


    maflcko commented at 4:06 PM on June 26, 2026:

    Just read this comment, and deleting (or better not providing it) is fine, because a move ctor didn't exist before either. Though, in this project it should be fine to provide one, as bugprone-use-after-move prevents unwanted access to the moved-from object.

  29. purpleKarrot force-pushed on Jun 22, 2026
  30. purpleKarrot force-pushed on Jun 22, 2026
  31. DrahtBot added the label CI failed on Jun 22, 2026
  32. DrahtBot removed the label CI failed on Jun 22, 2026
  33. josibake commented at 8:09 AM on June 23, 2026: member

    I find the commit message in a35a2b1 a bit confusing, specifically: "Make sure to add the same member functions to CMutableTransaction for symmetry, so that they can be used in generic code."

    It seems clear from the PR description and the code that the observer is not concerned with CMutableTransaction in this PR. Is this a stale commit message or a directive for a follow-up? If its a directive for a follow-up, I think it could be made more clear, e.g., "CMutableTransaction should have the the same member functions to allow both classes to be used in generic code. This is deferred to a follow-up."

  34. purpleKarrot commented at 9:01 AM on June 23, 2026: contributor

    @josibake, adding the observers to CMutableTransaction cannot be deferred to a follow-up. Adding them to both CTransaction and CMutableTransaction is a precondition for the automatic refactoring that follows. The reason is that we have code like this:

    template <typename TxType>
    void foo(const TxType& tx) {
      for (const auto& in : tx.vin) {
        ...
      }
      ...
    }
    

    If that .vin is refactored to .GetInputs(), the function template can no longer be instantiated with CMutableTransaction unless the observer is added there as well.

  35. josibake commented at 11:42 AM on June 23, 2026: member

    @purpleKarrot thanks for clarifying, I realised I was confusing myself. I do think a nice polish would be to update the tx-observer code to handle the template case. I started tinkering with it as a way of getting more familiar with the code and came up with this:

    diff --git a/contrib/devtools/bitcoin-tidy/bitcoin-tidy.cpp b/contrib/devtools/bitcoin-tidy/bitcoin-tidy.cpp
    index 57a8824c0c..379a550dec 100644
    --- a/contrib/devtools/bitcoin-tidy/bitcoin-tidy.cpp
    +++ b/contrib/devtools/bitcoin-tidy/bitcoin-tidy.cpp
    @@ -6,6 +6,7 @@
     #include "observers.h"
    
     #include <clang-tidy/ClangTidyModule.h>
    +#include <clang-tidy/ClangTidyModuleRegistry.h>
    
     class BitcoinModule final : public clang::tidy::ClangTidyModule
     {
    diff --git a/contrib/devtools/bitcoin-tidy/observers-base.cpp b/contrib/devtools/bitcoin-tidy/observers-base.cpp
    index 075e2c1d70..0b2c73eb5a 100644
    --- a/contrib/devtools/bitcoin-tidy/observers-base.cpp
    +++ b/contrib/devtools/bitcoin-tidy/observers-base.cpp
    @@ -1,5 +1,6 @@
     #include "observers-base.h"
    
    +#include <clang/AST/ExprCXX.h>
     #include <clang/Lex/Lexer.h>
    
     using namespace clang;
    @@ -13,10 +14,34 @@ void ObserversBase::registerMatchers(MatchFinder* Finder)
           member(fieldDecl(hasParent(cxxRecordDecl(hasName(ClassName))))))
           .bind("member"),
         this);
    +  Finder->addMatcher(
    +    cxxDependentScopeMemberExpr(
    +      anyOf(hasMemberName("vin"),
    +            hasMemberName("vout"),
    +            hasMemberName("version"),
    +            hasMemberName("nLockTime")),
    +      unless(hasAncestor(functionDecl(hasName("UnserializeTransaction")))))
    +      .bind("dependent_member"),
    +    this);
     }
    
     void ObserversBase::check(MatchFinder::MatchResult const& Result)
     {
    +  if (auto const* DME = Result.Nodes.getNodeAs<CXXDependentScopeMemberExpr>("dependent_member")) {
    +    if (DME->isImplicitAccess()) {
    +      return;
    +    }
    +
    +    auto It = MemberToAccessor.find(DME->getMember().getAsString());
    +    if (It == MemberToAccessor.end()) {
    +      return;
    +    }
    +
    +    diag(DME->getMemberLoc(), "replace direct member access with accessor")
    +      << FixItHint::CreateReplacement(DME->getMemberLoc(), It->second);
    +    return;
    +  }
    +
       auto const* ME = Result.Nodes.getNodeAs<MemberExpr>("member");
       if (!ME || ME->isImplicitAccess()) {
         return;
    

    I ran this and it does catch the case pointed out by @janb84.

    This, however, is a non-blocking suggestion and rather a nice to have, or even just a review tool. I mainly did this to convince myself there weren't other template functions that could take both CMutableTransaction and CTransaction that had been missed. In doing so, I ended up convincing myself its not really an issue considering there is no current usage of CTransaction in these template functions and if someone were to attempt it in the future, it would be a compile error.

  36. josibake commented at 12:06 PM on June 23, 2026: member

    ACK https://github.com/bitcoin/bitcoin/pull/35569/commits/c2dbec1ed279d73c76a28d23d02fcc3bdd433a1f

    Adding observer functions for data members moves towards an overall better design where the caller does not depend on the class layout. While not fully implemented in this PR, this is the first step towards a transaction type with regular semantics.

    More concretely, direct access to vin, vout, version, and nLockTime effectively makes the class layout the public API. Rather, we should have serialisation and the transaction API be expressed in an implementation agnostic interface instead of tightly coupled to today's concrete implementation.

    Thanks for taking the suggestions regarding removing the assignment operator, and removing the default move constructor. These changes ensure we preserve the same class invariants while refactoring.

  37. DrahtBot requested review from janb84 on Jun 23, 2026
  38. purpleKarrot commented at 12:43 PM on June 23, 2026: contributor

    @josibake, I don't want to add the names of one particular class into ObserversBase. That class is intended to be a common base and provides the logic for checks that inherit from it and parameterize it with names in their constructor.

    The mutation check in the current implementation is not powerful; it has some false negatives. For CTransaction, that is not an issue, because its members cannot be mutated anyway. But if I fix it and add a checker for CMutableTransaction, I get changes in another 119 lines, including the one that @janb84 identified.

    I can integrate that into this PR, or keep this PR focused on CTransaction and do CMutableTransaction as a follow-up.

  39. josibake commented at 12:55 PM on June 23, 2026: member

    I don't want to add the names of one particular class into ObserversBase

    Absolutely! "tinkering" was meant to imply this was not the proper way to do it, rather I do think it would be nice to detect the template case in a more clean way. However, once I ran the hacked code and realised there were no other such cases, it started to feel like a waste of time.

    do CMutableTransaction as a follow-up.

    I think this is the right call. As you said, it keeps this PR focused, and it may be that CMutableTransaction is made unnecessary as this refactor progress, but that is not a topic for this PR.

  40. sedited added this to a project on Jun 25, 2026
  41. github-project-automation[bot] changed the project status on Jun 25, 2026
  42. in src/primitives/transaction.h:312 in a35a2b108a
     308 | @@ -309,6 +309,11 @@ class CTransaction
     309 |      explicit CTransaction(const CMutableTransaction& tx);
     310 |      explicit CTransaction(CMutableTransaction&& tx);
     311 |  
     312 | +    [[nodiscard]] auto GetVersion() const -> uint32_t { return version; }
    


    maflcko commented at 12:43 PM on June 25, 2026:

    a35a2b108acf355293443d5ad42cbf84152a3363: I don't think nodiscard should be added here, because the boilerplate adds basically no value. It isn't in the dev notes, but the pattern is documented in:

    https://github.com/bitcoin/bitcoin/blob/7b84e5106c38c146f2393b9a337e2d604fd64faf/src/kernel/bitcoinkernel.h#L32-L40


    purpleKarrot commented at 1:53 PM on June 26, 2026:

    I can remove it if there is consensus that [[nodiscard]] is not wanted here. I just got used to following clang-tidy's modernize-use-nodiscard, which adds it to all const member functions.


    maflcko commented at 2:19 PM on June 26, 2026:

    I think the modernize-use-discard is harmful for promoting the exact opposite of a meaningful policy. I wonder if anyone has this check enabled in a useful way. Let's recall that in modern C++ (smart pointers and optional values), the isn't really a risk of resource leaks (like in the kernel C-header). So the remaining use cases are legacy code, or error/status codes, but both of those use-cases are excluded in modernize-use-discard.

    If we wanted to follow the clang-tidy rule, it should be enabled in the config, but again I don't think that is useful.

    Instead, it could make sense to copy-paste the existing kernel header policy to the dev notes, so that it is clear it applies to all C++ code in this repo?


    purpleKarrot commented at 2:29 PM on June 26, 2026:

    I don't consider passing around smart pointers modern C++. Pointers ("smart" or not) are low level utilities that should be used exclusively for implementing composite types and never appear in function signatures, neither as arguments nor as return type.

    The purpose of modernize-use-nodiscard is not to avoid resource leaks, but to indicate: "This function has no observable side effect. Only call this function if you are interested in its returned value."

  43. in src/primitives/transaction.h:313 in a35a2b108a
     308 | @@ -309,6 +309,11 @@ class CTransaction
     309 |      explicit CTransaction(const CMutableTransaction& tx);
     310 |      explicit CTransaction(CMutableTransaction&& tx);
     311 |  
     312 | +    [[nodiscard]] auto GetVersion() const -> uint32_t { return version; }
     313 | +    [[nodiscard]] auto GetInputs() const -> const std::vector<CTxIn>& { return vin; }
    


    maflcko commented at 9:46 AM on June 26, 2026:

    a35a2b108acf355293443d5ad42cbf84152a3363: You say that a span can not be returned here in the blog, but I wonder why that is. The blog says that some call sites have hard-coded vector::iterator types. However, those should be trivial to adjust as well.

    I think the real reason is that span serialization doesn't support non-Byte spans, and also doesn't support the length prefix.

    a bit unrelated, but I wonder if it could make sense to allow all spans to be serialized (iff the element can be serialized), but require an explicit mode to be set: Either without serializing the size prefix, or without.

    In any case, I think LIFETIMEBOUND could be added here?


    maflcko commented at 10:21 AM on June 26, 2026:

    edit: I read your other blog post and I see you want to replace this by encode_range(w, tx.vin, encode_txin); in which case my alternative suggestion may be stale.


    purpleKarrot commented at 2:00 PM on June 26, 2026:

    some call sites have hard-coded vector::iterator types. However, those should be trivial to adjust as well.

    Yes, they could be automatically refactored with clang-tidy's modernize-use-auto and further with modernize-loop-convert. But those cleanups are orthogonal. They could be done as a follow-up, or they could be done before this one. But I'd rather not squash them into this PR.

  44. in src/policy/truc_policy.h:20 in c2dbec1ed2
      16 | @@ -17,7 +17,7 @@
      17 |  
      18 |  // This module enforces rules for BIP 431 TRUC transactions which help make
      19 |  // RBF abilities more robust. A transaction with version=3 is treated as TRUC.
      20 | -static constexpr decltype(CTransaction::version) TRUC_VERSION{3};
      21 | +static constexpr std::uint32_t TRUC_VERSION{3};
    


    maflcko commented at 10:03 AM on June 26, 2026:

    nit in the last commit: Using the std:: prefix may be minimally more correct, but there is no place in the current codebase that uses the prefix for such integral types. It may be better to drop it for consistency. Also, while it doesn't help type safety, I minimally prefer to have named types here. It is unlikely that the type is going to change again (27e70f1f5be1f536f2314cd2ea42b4f80d927fbd), but it can't hurt. Just a nit, though.

  45. maflcko commented at 11:00 AM on June 26, 2026: member

    left some nits, but feel free to ignore.

  46. purpleKarrot force-pushed on Jun 26, 2026
  47. purpleKarrot commented at 3:14 PM on June 26, 2026: contributor

    I removed [[nodiscard]] and std:: as per @maflcko's comments. I still think they make sense in both places, but maybe it is better to add them with a repository wide cleanup, by applying modernize-use-nodiscard.

    Edit: Also added LIFETIMEBOUND.

  48. CTransaction: Add observer member functions
    Make sure that for each data member of `CTransaction`, there exists
    a public member function that is marked `const` and returns the data
    member either by value or by reference to const.
    Make sure to add the same member functions to `CMutableTransaction`
    for symmetry, so that they can be used in generic code.
    2e69721360
  49. bitcoin-tidy: Add tx-observers check
    Add a check to bitcoin-tidy that detects when a data member of a
    particular class is accessed outside of a member function and
    rewrites that access with an observer function.
    Parameterize the check for `CTransaction` with a mapping of its
    data members to observer functions added previously.
    ede288d1c5
  50. bitcoin-tidy: Apply tx-observers fixup
    Perform an automated replacement of all direct accesses of
    `CTransaction`'s data members, each with it's associated
    observer function.
    
    Produce the changeset with the following commands:
    
    ```sh
    export CC="$(which clang)"
    export CXX="$(which clang++)"
    cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_BENCH=ON -DBUILD_FUZZ_BINARY=ON -DBUILD_GUI=ON -DBUILD_KERNEL_LIB=ON -DBUILD_UTIL_CHAINSTATE=ON
    cmake -B build/tidy -S contrib/devtools/bitcoin-tidy
    cmake --build build
    cmake --build build/tidy
    run-clang-tidy -p build -load='build/tidy/libbitcoin-tidy.so' -checks='-*,bitcoin-tx-observers' -fix
    ```
    858899ef52
  51. CTransaction: Make data members private
    Now that `CTransaction`'s data members are no longer accessed
    outside of member functions, they can be made non-`const` and
    `private`.
    a2b339547f
  52. purpleKarrot force-pushed on Jun 26, 2026
  53. DrahtBot added the label CI failed on Jun 26, 2026
  54. DrahtBot removed the label CI failed on Jun 26, 2026
  55. purpleKarrot requested review from josibake on Jun 26, 2026
  56. purpleKarrot requested review from maflcko on Jun 26, 2026
  57. maflcko commented at 10:55 AM on June 27, 2026: member

    I am still thinking about this change conceptually. As of this pull request, the changes are mostly a no-op cleanup. Yes, it is a bit nicer to use private over const here, but externally, the same copy and move ctors are provided, so I think this change is mostly a style change. It seems there are several goals:

    • https://purplekarrot.net/blog/bitcoin-tidy-transaction.html (step 4): "Implement Deserialization". I think this is equally a mostly style-wise cleanup and otherwise a no-op. I think it can probably be skipped as optional.
    • "Step 7: One Indirection": I think this is nice. Practically in most places CTransactionRef is used (but it is not really a reference), so hiding the shared pointer and allowing to pass CTransaction as-is with shared pointer semantics seems simpler and nicer.

    I am happy to review this pull as-is, and it seems fine to merge as-is, but I wonder if other reviewers think that step 7 is worthwhile, (and whether step 4 is worthwhile or can be skipped)?

  58. alexanderwiederin commented at 10:34 AM on June 29, 2026: contributor

    I think this PR can be assessed independent from steps 4 and 7. The old design violated the C++ Core Guidelines C.12.

  59. josibake commented at 11:14 AM on June 29, 2026: member

    I am still thinking about this change conceptually. As of this pull request, the changes are mostly a no-op cleanup. Yes, it is a bit nicer to use private over const here, but externally, the same copy and move ctors are provided, so I think this change is mostly a style change

    Perhaps we use the word style differently, but I tend to think of style as two different representations of the same architectural principle.

    I'd argue that style is the wrong framing here since we are talking about an architectural refactor, specifically encapsulation. This PR makes the invariant the responsibility of the class, enforced through a private representation rather than a const keyword on a public field. This fundamentally changes which designs are possible, now and in the future. Identical semantics today, as you point out, but the set of possibilities open for discussion is now open.

    Said differently, I don't think reviewers being interested in exactly 4 and 7 as they are proposed today is particularly relevant for merging this PR. Rather, being interested in discussing 4 and 7 at all and perhaps other follow ups is the strongest argument for this PR on its own: these follow up discussions are now possible.

  60. ajtowns commented at 6:30 PM on June 29, 2026: contributor

    This still seems a waste of review resources to me. If you want to be able to mutate a transaction, use CMutableTransaction, don't add a +644-513 PR because you dislike the coding style.

    The old design violated the C++ Core Guidelines C.12.

    That guideline only says "Don’t make data members const or references in a copyable or movable type" which is fine -- CTransaction doesn't have much need to be copied or moved (if different parts of the code want different handles on the same tx, that's what CTransactionRef is for), and copying/moving is only possible because of implicit constructors. Deleting those constructors catches a few mistakes: https://github.com/ajtowns/bitcoin/commits/202606-del-tx-copy-cons/

  61. josibake commented at 11:12 AM on June 30, 2026: member

    If you want to be able to mutate a transaction, use CMutableTransaction

    I disagree with this framing, and I don't think your objections apply here. This PR is not talking about mutating a transaction. The new API is const observer access, assignment remains deleted, and move is not provided.

    Furthermore, CMutableTransaction existing is not a reason for CTransaction to expose its layout. The question here is not whether mutation should exist somewhere. It is whether callers of the immutable transaction type should depend directly on vin, vout, version, and nLockTime. They should not. Those fields make up the invariant that transaction data, hash, m_witness_hash, and m_has_witness all describe the same object. That invariant belongs behind the CTransaction API boundary.

    CTransactionRef is also not a sufficient answer. CTransactionRef is std::shared_ptr<const CTransaction> exposed as a public vocabulary type. What I mean here is APIs that want “a transaction” therefore inherit pointer semantics: ownership, nullability, aliasing, .get(), .reset(), and use_count(). Those are not transaction semantics. If shared immutable storage is useful (it is), it should be an implementation detail behind a transaction API, not something every caller has to pull in.

    This is why I said “coding style” is the wrong characterisation. Public fields vs private fields behind observers are not two styles of implementing the same abstraction. Public fields make the class layout the API. Private fields make CTransaction responsible for its own representation and invariants. I very much disagree that reviewing these types of architectural changes are a waste of reviewer time as they have implications on what is possible or not possible in the codebase today and into the future. They also matter for the safety, correctness, and maintainability of the code. These seem like topics reviewers would be interested in, to me.

    Regarding the copy/move branch you posted, this reinforces the C.12 point rather than refuting it. Public const data members create an irregular type surface where copy exists, assignment does not, and move behaviour is surprising. If making those operations explicit exposes call sites that need cleanup (which your branch does), then the current design already has accidental special member semantics. C.12’s point is that those semantics should be intentional, not side effects of const fields. Deleting copy/move might be an interesting follow up, but it is not an alternative to this PR. It addresses one symptom while leaving layout as API (the root cause) untouched. This PR instead establishes the boundary: callers should not depend on the storage layout of a type whose fields participate in an invariant. Said differently, your branch seems to be treating a symptom, not addressing the root cause.

    I will reiterate: we do not need agreement on those future designs for this PR to be valuable today. Reviewers can still argue later for non copyable CTransaction, an internal shared immutable body, a different CTransactionRef, or a value handle over an immutable body. We could also agree on no further change. This PR only establishes the prerequisite boundary for those discussions: callers stop depending on concrete layout, while today’s immutability and aliasing needs are preserved.

  62. purpleKarrot commented at 11:32 AM on June 30, 2026: contributor

    That guideline only says

    I can give a few more guidelines that are violated in the current design:

    Should I continue and dig out more C++ Core Guidelines that are violated here? Or would you rather prefer examples from C++ literature? Conference talks? You name it.

  63. ajtowns commented at 1:11 PM on June 30, 2026: contributor

    Concept NACK. At this point my impression is that providing the kernel API is primarily acting as a supply-chain attack vector, encouraging multiple significant refactors into consensus critical code for extremely spurious reasons, and wasting limited development resources on low and negative impact activities.

    I can give a few more guidelines that are violated in the current design:

    Perhaps you should review the introductory section: "We do not suffer the delusion that every one of these rules can be effectively applied to every code base.", "The rules are not perfect. A rule can do harm by prohibiting something that is useful in a given situation."

    * [C.3: Represent the distinction between an interface and an implementation using a class](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rc-interface)

    I could see the argument for declaring CTransaction as-is as a struct rather than a class per C.2 or C.8, but that's not a rule we consistently use in this codebase, and because we don't do that, the follow-on assumptions about how classes should act are also not justified:

    * [C.4: Make a function a member only if it needs direct access to the representation of a class](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rc-member)
    
    * [C.9: Minimize exposure of members](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rc-private)
    
    * [C.11: Make concrete types regular](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rc-regular)
    * [C.81: Use =delete when you want to disable default behavior (**without wanting an alternative**)](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rc-delete)

    Yes, that one is a good idea, and it catches real implementation flaws, and I included it in the patchset I linked.

    Should I continue and dig out more C++ Core Guidelines that are violated here? Or would you rather prefer examples from C++ literature? Conference talks? You name it.

    "Should I continue with arguments to authority?" No, you shouldn't. You should justify changes with the practical benefits they achieve.

    In this case, separating an implementation and interface allows you to change the implementation without impacting the places where that interface is used, but that's not the case here: we shouldn't be changing the implementation because "create a CTransaction, cache its hashes, treat it as immutable, and keep it around as a shared_ptr" is already what we want. Instead, for 0% benefit, this PR is hitting 100% of the cost by changing every place that touches the interface. The only way you could get a worse tradeoff is by actively introducing bugs, as well.

    I disagree with this framing, and I don't think your objections apply here. This PR is not talking about mutating a transaction. The new API is const observer access, assignment remains deleted, and move is not provided.

    The only reason for making that change now is to prepare for making it mutable in future. Pretending otherwise just comes across as dishonest. I am not currently very convinced that the proposed serialization changes are a worthwhile improvement (eg, where are the benchmarks, if compile time improvements are the main argument?), but claiming that is the rationale while constantly arguing about style guidelines and private fields also comes across as fairly dishonest.

    Furthermore, CMutableTransaction existing is not a reason for CTransaction to expose its layout.

    The reason for CTransaction to expose its layout is that adding an abstraction layer over the top buys nothing but complexity and code churn. Dealing with transactions is pretty much the primary job of our codebase, so the implementation details are relevant to pretty much every part of the code, not something that should be abstracted away.

    What I mean here is APIs that want “a transaction” therefore inherit pointer semantics

    APIs that want "a transaction" can accept const CTransaction& if they simply want to use it for the lifetime of that call, or a CTransactionRef if they want to be able to extend the lifetime of that transaction, at which point ownership/lifetime/etc are worth worrying about anyway. If they want to modify the transaction, they can either copy it or accept some form of CMutableTransaction in the first place.

  64. ryanofsky commented at 1:43 PM on June 30, 2026: contributor

    re: #35569 (comment)

    The only reason for making that change now is to prepare for making it mutable in future.

    For the record, josibake's description is accurate: as of the latest revision, assignment is deleted and the observers are const.

    On the direction: step 6 from the blog post would have CTransaction hold shared_ptr<const TransactionData> internally, making the shared body immutable at the type level. That reads to me as increasing immutability guarantees rather than decreasing them. Am I reading the end goal right? If there's a specific harm you see in the steps between here and there, it would be useful to understand concretely.

    EDIT: Corrected reference to step 6 instead of step 7 in blog post.

  65. purpleKarrot commented at 1:57 PM on June 30, 2026: contributor

    Am I reading the end goal right?

    100%, @ryanofsky. My goal is to make CBlock, CTransaction and a few more primitive types immutable. But a properly immutable type is a type that has no accessible modifier functions. It is not a type with deleted copy/assignment.

    Making CTransaction non-copyable and then storing it as a pointer in CBlock and making it publicly accessible does not gain any immutability at all, as clients can freely reassign it to another transaction object or even to nullptr. I understand that some clients need nullability, but CBlock is not one of them and consensus code is not defined to operate on nullable transactions.

  66. purpleKarrot commented at 2:09 PM on June 30, 2026: contributor

    compile time improvements are the main argument

    Where did you read that, @ajtowns? My blog mentions compile times, but then proceeds with:

    The most fundamental problem, however, is architectural. The current design treats the C++ class layout as the source of truth for the serialization format.

  67. josibake commented at 2:16 PM on June 30, 2026: member

    The only reason for making that change now is to prepare for making it mutable in future. Pretending otherwise just comes across as dishonest.

    The proposed direction is not toward mutable transaction data. It is toward a value handle over immutable transaction data, as explained in the blogs, by @purpleKarrot , and myself in this PR. This is a stronger immutability model than exposing shared pointer semantics as the transaction API.

  68. alexanderwiederin commented at 5:45 PM on July 2, 2026: contributor

    Verified 858899e reproduces.

    Checked out ede288d1 and ran the documented fixup with clang/clang-tidy 22.1.8 (per ci/test/00_setup_env_native_tidy.sh):

    export CC="$(which clang-22)" 
    export CXX="$(which clang++-22)"
    cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_BENCH=ON -DBUILD_FUZZ_BINARY=ON -DBUILD_GUI=ON -DBUILD_KERNEL_LIB=ON -DBUILD_UTIL_CHAINSTATE=ON
    cmake -B build/tidy -S contrib/devtools/bitcoin-tidy
    cmake --build build
    cmake --build build/tidy
    run-clang-tidy-22 -p build -load='build/tidy/libbitcoin-tidy.so' -checks='-*,bitcoin-tx-observers' -fix
    

    The resulting git diff is identical to 858899e.


    Tests: suite passes on tip

    Ran the full unit test suite. The tip (CTransaction: Make data members private) depends on every external access having been correctly converted to observer calls in the prior commit.

  69. mzumsande commented at 8:46 PM on July 2, 2026: contributor

    I will reiterate: we do not need agreement on those future designs for this PR to be valuable today.

    I disagree with that. In my opinion, refactors of the very basic consensus code should have a concrete advantage and should not be done on general grounds such as C++ guidelines alone. So I do think that this would need to be justified by possible follow-ups, or at least some non-generic examples such as preventable bugs or footguns (which @ajtowns alternative branch does).

    On a language level, terms such as vin, vout etc. have long become part of the public protocol: For example encapsulating vin through GetInputs() internally within bitcoin core, just to expose them as vin again to the outside via RPC (getrawtransaction) seems weird to me and it would probably be a nuisance for devs to remember the right term on the right level.

    In summary, I think that CTransaction is not just a random C++ class, but at the heart of the bitcoin protocol itself, so I don't think arguing only with generic C++ guidelines re: encapsulation is sufficient here.

    providing the kernel API (...)

    "this might set the direction to a bunch of further work" from Kernel WG IRC update

    The kernel project is mentioned neither in the PR description nor the blog post. If this helps the kernel project to design their API better (or if there is any other relation to the kernel work) it would be good to explain this in more detail.

  70. in src/primitives/transaction.h:359 in a2b339547f
     354 | +    uint32_t nLockTime;
     355 | +
     356 | +    /** Memory only. */
     357 | +    bool m_has_witness;
     358 | +    Txid hash;
     359 | +    Wtxid m_witness_hash;
    


    l0rinc commented at 11:08 PM on July 2, 2026:

    What's the reason for dropping the consts from these?

        const bool m_has_witness;
        const Txid hash;
        const Wtxid m_witness_hash;
    
  71. in contrib/devtools/bitcoin-tidy/observers-base.cpp:8 in a2b339547f
       0 | @@ -0,0 +1,72 @@
       1 | +#include "observers-base.h"
       2 | +
       3 | +#include <clang/Lex/Lexer.h>
       4 | +
       5 | +using namespace clang;
       6 | +using namespace clang::ast_matchers;
       7 | +
       8 | +void ObserversBase::registerMatchers(MatchFinder* Finder)
    


    l0rinc commented at 11:14 PM on July 2, 2026:

    I agree that this check is useful during development, but I'm not sure it should remain in the tree after the migration.

    After the final commit, the compiler enforces the CTransaction boundary, so this check is only scaffolding unless we plan to keep using it for other classes. If it is only scaffolding, could the PR end with a commit that removes it, or could the helper live outside the repo?

    If it is meant to stay as reusable bitcoin-tidy infrastructure, could we align it with the existing check style first? The new files currently use different conventions from nontrivial-threadlocal (no MIT header, #pragma once, global namespace, 2-space indentation). Or is that also something we should change because other other guidelines request it?

  72. in src/primitives/transaction.h:301 in a2b339547f
     318 | +    CTransaction& operator=(const CTransaction&) = delete;
     319 | +
     320 | +    auto GetVersion() const -> uint32_t { return version; }
     321 | +    auto GetInputs() const LIFETIMEBOUND -> const std::vector<CTxIn>& { return vin; }
     322 | +    auto GetOutputs() const LIFETIMEBOUND -> const std::vector<CTxOut>& { return vout; }
     323 | +    auto GetLockTime() const -> uint32_t { return nLockTime; }
    


    l0rinc commented at 11:16 PM on July 2, 2026:

    What's the purpose of introducing trailing-return syntax here? What role does auto serve, apart from aligning the names? Seems aggressive...

        uint32_t GetVersion() const { return version; }
        const std::vector<CTxIn>& GetInputs() const LIFETIMEBOUND { return vin; }
        const std::vector<CTxOut>& GetOutputs() const LIFETIMEBOUND { return vout; }
        uint32_t GetLockTime() const { return nLockTime; }
    

    More broadly, as @mzumsande also mentioned, I am not convinced that introducing new names for vin, vout, version, and nLockTime is an improvement. Could we keep the protocol names instead?

    uint32_t version() const { return version; }
    const std::vector<CTxIn>& vin() const LIFETIMEBOUND { return vin; }
    const std::vector<CTxOut>& vout() const LIFETIMEBOUND { return vout; }
    uint32_t nLockTime() const { return nLockTime; }
    

    With indexed helpers, the diff becomes even closer (and safer) to the current code:

    const CTxIn& vin(size_t index) const LIFETIMEBOUND { return m_vin.at(index); }
    const CTxOut& vout(size_t index) const LIFETIMEBOUND { return m_vout.at(index); }
    

    For example:

    - if (tx.vin.empty())
    + if (tx.vin().empty())
    
    - if (tx.vout.empty())
    + if (tx.vout().empty())
    
    - for (const auto& txout : tx.vout)
    + for (const auto& txout : tx.vout())
    
    - for (const auto& txin : tx.vin) {
    + for (const auto& txin : tx.vin()) {
    
    - if (tx.vin[0].scriptSig.size() < 2 || tx.vin[0].scriptSig.size() > 100)
    + if (tx.vin(0).scriptSig.size() < 2 || tx.vin(0).scriptSig.size() > 100)
    
    - for (const auto& txin : tx.vin)
    + for (const auto& txin : tx.vin())
    

    That would make the mechanical diff much easier to review manually. The renaming question can be debated separately, but as-is the change reads like a broad spelling rewrite with non-negligible review cost and little immediate benefit.


    purpleKarrot commented at 4:37 AM on July 3, 2026:

    What's the purpose of introducing trailing-return syntax here?

    Readability. Did you see how the names of the function names are nicely aligned? Is this not something that directly jumps to the eye?

    Could we keep the protocol names instead?

    uint32_t version() const { return version; }
    

    I have explained the naming choice in my blog referenced in the PR description. It is not possible in C++ because it creates a naming conflict. You cannot overload a member function with a data member.

    With indexed helpers ...

    Let's not create more technical debt. We currently have lots of index based loops that could be rewritten as range based loops or algorithms. Introducing "helpers" complicates such modernization. When only the first element is needed, .front() should be preferred.


    l0rinc commented at 5:11 AM on July 3, 2026:

    Did you see how the names of the function names are nicely aligned

    Sure, I even mentioned it on the same line: "What role does auto serve, apart from aligning the names?"

    Is this not something that directly jumps to the eye?

    Are you planning on changing it in the whole codebase? Or will this be the only place where we use "readable" code?

    It is not possible in C++ because it creates a naming conflict

    Not sure what you mean, I simply didn't migrate CMutableTransaction and renamed the fields to start with m_ prefix locally.


    purpleKarrot commented at 5:25 AM on July 3, 2026:

    ... renamed the fields to start with m_ prefix locally.

    Sure, the naming conflict can be solved by changing the data members, but then the three-step refactoring approach is not possible:

    1. Introduce new API without any other changes.
    2. Migrate from old API to new API fully automated with no manual intervention.
    3. Retire old API.

    Since you said you disagree with the "Approach", can you explain another approach and also explain what problems you see with the approach that I follow?


    l0rinc commented at 6:10 AM on July 3, 2026:

    The approach that I'm objecting to is aiming to do things "by the book" without understanding the project specifics. I fell into the same trap at the beginning, and I also received a lot of pushback. This is a huge refactor at the most critical part of the code, and it's not obvious what we're getting in return (besides appeals to authority, which most of us are allergic to). Especially since the refactor revealed incidental loose coupling (CMutableTransaction and CTransaction having to have the same field names) which should be hardened instead of papered over by adding yet another getter to the already public fields. It's also trying to force new names instead of considering what the domain already has. When I suggest alternatives, the reply is just a patronizing "that's impossible in C++". This approach seems too aggressive and dismissive to me, hence my nack.

  73. in src/consensus/tx_verify.cpp:24 in a2b339547f
      21 |          return true;
      22 | -    if ((int64_t)tx.nLockTime < ((int64_t)tx.nLockTime < LOCKTIME_THRESHOLD ? (int64_t)nBlockHeight : nBlockTime))
      23 | +    if ((int64_t)tx.GetLockTime() < ((int64_t)tx.GetLockTime() < LOCKTIME_THRESHOLD ? (int64_t)nBlockHeight : nBlockTime))
      24 |          return true;
      25 |  
      26 |      // Even if tx.nLockTime isn't satisfied by nBlockHeight/nBlockTime, a
    


    l0rinc commented at 11:33 PM on July 2, 2026:

    It's awkward to keep these references now that the field is hidden:

    - // Even if tx.nLockTime() isn't satisfied by nBlockHeight/nBlockTime, a
    + // Even if the transaction's nLockTime isn't satisfied by nBlockHeight/nBlockTime, a
    
    - // Note these tests were originally written with tx.version=1
    + // Note these tests were originally written with transaction version 1
    
    - // We want to make sure tx.vout is not used now that we are passing outputs as a vector of recipients.
    + // We want to make sure the transaction outputs are not used now that we are passing outputs as a vector of recipients.
    
    - // Clear tx.vout since it is not meant to be used now that we are passing outputs directly.
    + // Clear the transaction outputs since they are not meant to be used now that we are passing outputs directly.
    
    - // Clear tx.vout since it is not meant to be used now that we are passing outputs directly.
    + // Clear the transaction outputs since they are not meant to be used now that we are passing outputs directly.
    
    - // Clear tx.vout since it is not meant to be used now that we are passing outputs directly.
    + // Clear the transaction outputs since they are not meant to be used now that we are passing outputs directly.
    
    - // txouts needs to be in the order of tx.vin
    + // txouts needs to be in the order of the transaction inputs
    
    - // We want to make sure tx.vout is not used now that we are passing outputs as a vector of recipients.
    + // We want to make sure the transaction outputs are not used now that we are passing outputs as a vector of recipients.
    
  74. in src/primitives/transaction.h:372 in 2e69721360
     368 | @@ -364,6 +369,11 @@ struct CMutableTransaction
     369 |      explicit CMutableTransaction();
     370 |      explicit CMutableTransaction(const CTransaction& tx);
     371 |  
     372 | +    auto GetVersion() const -> uint32_t { return version; }
    


    l0rinc commented at 12:18 AM on July 3, 2026:

    2e69721 CTransaction: Add observer member functions:

    These extra accessors feel awkward, especially on CMutableTransaction.

    add the same member functions to CMutableTransaction for symmetry

    This seems like a code smell worth addressing before adding more API surface. If the issue is that some generic read-only code is instantiated with both CTransaction and CMutableTransaction, could we make that dependency explicit instead of adding duplicate read APIs to the mutable builder type?

    Or could the generic read-only code use an immutable transaction view instead? For example, serialization/read-only helpers could depend on a cheap non-owning transaction view constructible from both types, or mutable transactions could be moved into an immutable serializable type at the boundary where that is needed. That would let CTransaction own its private representation while CMutableTransaction remains the straightforward mutable construction type.

    Another option would be to move a mutable transaction into an immutable serializable type at the boundaries where serialization is needed.

  75. in src/policy/policy.h:151 in a2b339547f
     147 | @@ -148,8 +148,8 @@ std::vector<uint32_t> GetDust(const CTransaction& tx, CFeeRate dust_relay_rate);
     148 |  // Changing the default transaction version requires a two step process: first
     149 |  // adapting relay policy by bumping TX_MAX_STANDARD_VERSION, and then later
     150 |  // allowing the new transaction version in the wallet/RPC.
     151 | -static constexpr decltype(CTransaction::version) TX_MIN_STANDARD_VERSION{1};
     152 | -static constexpr decltype(CTransaction::version) TX_MAX_STANDARD_VERSION{3};
     153 | +static constexpr uint32_t TX_MIN_STANDARD_VERSION{1};
    


    l0rinc commented at 12:35 AM on July 3, 2026:

    a2b3395 CTransaction: Make data members private:

    I would be for these changes in a separate PR.

  76. l0rinc changes_requested
  77. l0rinc commented at 1:03 AM on July 3, 2026: contributor

    Approach NACK for now.

    I don't see a concrete benefit here that justifies rewriting so many transaction call sites in consensus, policy, mempool, wallet, tests, fuzzers, and kernel-facing code.

    Technically, the current patch seems mostly behavior-preserving: callers could not mutate CTransaction before because the public fields were const, and callers still cannot mutate it after because the new observers are const. Assignment remains deleted and move is not provided. So the practical safety delta appears small, while the review cost is high.

    I also share the concern raised above about “arguments to authority.” Guidelines and literature can be useful for framing, but they should not substitute for showing why this particular change improves Bitcoin Core specifically.

    The PR also has knock-on effects that make the abstraction feel premature: CMutableTransaction gets duplicate const observers just so generic code keeps compiling.

    If the goal is a later shared immutable transaction body or a new serialization boundary, I think that design should be motivated directly first, with concrete examples of why it is worth doing. Without that, this feels like a broad mechanical refactor of very central code for speculative follow-up work.

  78. josibake commented at 8:24 AM on July 3, 2026: member

    I disagree with that. In my opinion, refactors of the very basic consensus code should have a concrete advantage and should not be done on general grounds such as C++ guidelines alone.

    Your response seems to be arguing against a claim I did not make. My argument is not “merge this because the C++ Core Guidelines say so.” The guidelines are excellent supporting evidence, but not the justification. The justification I am arguing for is that CTransaction currently exposes the layout as the API. By making fields participating in the invariant public, we are tightly coupling all callers to the representation. This is a fragile design. Rather, the representation of the invariant should be owned entirely by CTransaction behind an API, i.e. encapsulation.

    The question is not whether encapsulation is idiomatic in the abstract, or performative to some set of guidelines. The question is whether CTransaction’s concrete layout defining its public API is better than CTransaction exposing transaction behaviour while owning its representation and invariants internally. I have not seen an argument for why the current public layout design is better. Most objections seem to defend the status quo by pointing to review cost or familiarity, but those are not arguments that public storage is the better abstraction. @ajtowns 's branch, as I already pointed out, illustrates my point. It found places where the current design allows accidental full transaction copies when the intent was to keep or pass a transaction reference. Deleting copy/move catches some of those mistakes, which is good, but it addresses one symptom. The broader issue remains: callers depend directly on the storage layout of a type whose fields participate in an invariant. Encapsulation gives us a boundary where those semantics can be made intentional, whether the followup is deleting copy/move, using an internal immutable body (as explained in the blogs and the design I strongly prefer!), changing CTransactionRef, or something else. The C++ core guidelines are relevant here because they describe the class of failure mode ajs branch attempts to fix. This is not to say "oh we must have strict adherence to the guidelines," rather its the C++ core guidelines pointing out: "you guys aren't the first project to make this mistake."

    The naming concern you raise is orthogonal, imo. Whether the observers should be called GetInputs() / GetOutputs() or use familiar vocabulary such as vin() / vout() is a separate API naming discussion (and likely a mechanical rename/scripted diff). It is not an argument for keeping the storage layout public. Likewise, vin and vout being protocol vocabulary does not mean the in-memory C++ representation must expose public fields. I do not agree with the the framing of CTransaction as being the heart of the bitcoin protocol. Its not. Its a C++ class in this project that represents protocol data. The protocol and serialisation format must remain precise and stable (and implementation agnostic!), but that does not require class layout to be the public API. This is mentioned explicitly in the serialisation blog.

    As I argued before: this refactor stands own its own because it establishes a clear API boundary not tightly coupled to representation. This enables the discussion and implementation of future design, e.g. the stronger immutability model detailed by @purpleKarrot in his blogs. The status quo keeps API defined by class layout, which is fragile and forces us into suboptimal design space.

  79. in contrib/devtools/bitcoin-tidy/bitcoin-tidy.cpp:15 in ede288d1c5
      11 | @@ -11,6 +12,7 @@ class BitcoinModule final : public clang::tidy::ClangTidyModule
      12 |  public:
      13 |      void addCheckFactories(clang::tidy::ClangTidyCheckFactories& CheckFactories) override
      14 |      {
      15 | +        CheckFactories.registerCheck<TxObservers>("bitcoin-tx-observers");
    


    l0rinc commented at 3:10 AM on July 6, 2026:

    ede288d bitcoin-tidy: Add tx-observers check:

    will this be executed by tidy on every run now?

  80. l0rinc changes_requested
  81. l0rinc commented at 2:17 AM on July 7, 2026: contributor

    To be clear, I can agree with the concept of moving CTransaction away from public storage given good motivation and easy review. My main concern is the approach: I could agree with it if the path were broken into smaller manually reviewable steps with non-general, concrete Bitcoin Core reasoning for each step: what each step enables, what reviewers should check, and why it is worth doing even if the later design changes. General C++ best-practice arguments are not enough for me here.

    It would also help to have the rest of the intended series prepared as draft PRs or demonstration branches, so reviewers can evaluate the path end-to-end instead of reviewing a preparatory refactor against a series of "to be continued" blog posts.

    For comparison, I tried a smaller shape here over the weekend: l0rinc/bitcoin#212 commits. It differs from this PR in three main ways:

    1. It first routes generic mutable and immutable transaction reads through explicit helpers, so CMutableTransaction can stay the mutable builder instead of getting mirror observers just to preserve shared public field spelling.
    2. It uses a reusable, parameterized, tested bitcoin-tidy field-observer helper and runs it one field at a time (nLockTime, version, vout, vin), so each scripted diff is a smaller, CI-passing review unit.
    3. It keeps the protocol names as observers (tx.vout(), tx.vin(), etc.) instead of renaming the API to GetOutputs() / GetInputs(), and updates the related code comments.

    That shape makes the call-site diff mostly adding parentheses after the generic-read split, keeps mutable writes and exceptional serialization paths out of the immutable-transaction migration, and should reduce the need for repository-specific macro skips such as READWRITE or VARINT in the tidy check. If compile-time improvements, a future serialization boundary, kernel API shape, or stronger immutability motivate this refactor, it would be helpful to state that in the PR with concrete examples or evidence. Linked posts can provide background, but the GitHub PR itself should carry the reviewer-relevant motivation and tradeoffs.

  82. ajtowns commented at 11:51 AM on July 8, 2026: contributor

    compile time improvements are the main argument

    Where did you read that, @ajtowns?

    "The refactoring is a prerequisite for further refactoring that will untangle the type from serialization logic (...) which will result in ... faster compilation."

  83. DrahtBot added the label Needs rebase on Jul 9, 2026
  84. DrahtBot commented at 2:42 AM on July 9, 2026: contributor

    <!--cf906140f33d8803c4a75a2196329ecb-->

    🐙 This pull request conflicts with the target branch and needs rebase.


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-07-09 06:47 UTC