RFC: consensus: Make `CAmount` a class #35511

pull hodlinator wants to merge 15 commits into bitcoin:master from hodlinator:2026/06/safer_camount.take2 changing 175 files +1461 −1256
  1. hodlinator commented at 10:06 AM on June 11, 2026: contributor

    We should treat people's money with care across node and wallet.

    Having our CAmount be a typedef for raw int64_t as on master has the following drawbacks:

    • Allows assigning to from bools, leading to the issues like the one previously fixed by 0026b330c4abbbbdb96e4f0c4d380d70d8e592ab.
    • Allows direct comparison with integral types of differing size, leading to things like the issue previously fixed by 890a09b1e49925315a5636f29cc5af36928fe092 (reverting it on top of this PR leads to compile errors).
    • Allows forgetting to initialize amounts (CAmount fee;).
    • Allows direct operations with decimal types as in CAmount target = 49.5L * COIN in src/wallet/test/coinselector_tests.cpp.
    • Allows multiplying two CAmounts with each other, which does not make sense.

    Beyond fixing the above, this PR also makes amounts stick out more in code - either by the CAmount type itself or by using the _sats user defined literal.

    This work is also a stepping stone for possible future work introducing an even safer amount type which only allows values in the range [0, MAX_MONEY].

    Already merged PRs spawned out of this work: #35456, #35372

    Prior attempts in similar directions: #5430, #4067

  2. DrahtBot commented at 10:06 AM on June 11, 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/35511.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK sedited, ryanofsky

    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:

    • #bitcoin-core/gui/911 (Adds non-mempool wallet balance to overview by ajtowns)
    • #35657 (refactor: avoid redundant input lookup in CheckTxInputs by arejula27)
    • #35641 (kernel: Add script evaluation tracer by sedited)
    • #35633 (wallet: avoid call bumpfeediscount with negative values by polespinasa)
    • #35591 ([DO NOT MERGE] Erlay: bandwidth-efficient transaction relay protocol (Full implementation) by sr-gi)
    • #35587 (Remove boost as a unit test runner by rustaceanrob)
    • #35581 (node: add block template manager and track waitNext fee inflow by ismaelsadeeq)
    • #35580 (bugfix: compare non-adjusted chunk weight against block weight limit by ismaelsadeeq)
    • #35569 (Encapsulation for CTransaction by purpleKarrot)
    • #35557 (kernel, validation: Add btck_chainstate_manager_set_clock_time by ryanofsky)
    • #35522 (refactor: Extract per-message helpers from SendMessages() (move-only) by pablomartin4btc)
    • #35501 (wallet: store all witness variants of a transaction by achow101)
    • #35302 (Silent Payments: Sending (take 2) by Eunovo)
    • #35105 (Refactor: Updated TransactionError to use util::Expected and removed TransactionError:OK by kevkevinpal)
    • #35084 (ipc: Add nonunix platform support by ryanofsky)
    • #34872 (wallet: fix mixed-input transaction accounting in history RPCs by w0xlt)
    • #34824 (net: encapsulate TxRelay state and replace recursive mutexes by w0xlt)
    • #34698 (wallet: handle MiniMiner bump fee calculation failures by shuv-amp)
    • #34628 (p2p: Replace per-peer transaction rate-limiting with global rate limits by ajtowns)
    • #34617 (fees: wallet: remove block policy fee estimator internals from wallet by ismaelsadeeq)
    • #34514 (refactor: remove unnecessary std::move for a few trivially copyable types by l0rinc)
    • #34405 (wallet: skip APS when no partial spend exists by 8144225309)
    • #34075 (fees: Introduce Mempool Based Fee Estimation to reduce overestimation by ismaelsadeeq)
    • #34020 (mining: add getTransactions(ByWitnessID) IPC methods by Sjors)
    • #33741 (rpc: Optionally print feerates in sat/vb by polespinasa)
    • #32317 (kernel: Separate UTXO set access from validation functions by sedited)
    • #30343 (wallet, logging: Replace WalletLogPrintf() with LogInfo() by ryanofsky)
    • #30342 (kernel, logging: Pass Logger instances to kernel objects by ryanofsky)
    • #29700 (kernel, refactor: return error status on all fatal errors by ryanofsky)
    • #29409 (multiprocess: Add capnp wrapper for Chain interface by ryanofsky)
    • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)
    • #27865 (wallet: Track no-longer-spendable TXOs separately by achow101)
    • #19461 (multiprocess: Add bitcoin-gui -ipcconnect option by ryanofsky)
    • #19460 (multiprocess: Add bitcoin-wallet -ipcconnect option by ryanofsky)
    • #10102 (Multiprocess bitcoin by ryanofsky)

    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 typos and grammar issues:

    • // everytime adding a transaction, test the chunk status -> // every time adding a transaction, test the chunk status [“everytime” is misspelled; it should be two words.]

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

    • StageAddition(tx1_conflict, tx1_fee+1_sats, 0, 1, 0, false, 4, LockPoints()) in src/test/rbf_tests.cpp
    • StageAddition(entry4.GetSharedTx(), tx2_fee + entry5->GetModifiedFee() + 1_sats, 0, 1, 0, false, 4, LockPoints()) in src/test/rbf_tests.cpp
    • CreateValidMempoolTransaction(tx_parent, 0, 101, child_key, child_spk, coinbase_value - low_fee_amt - 300_sats, false) in src/test/txpackage_tests.cpp

    Possible places where comparison-specific test macros should replace generic comparisons:

    • [src/test/coins_tests.cpp] BOOST_CHECK_THROW(cache.AddCoin(outpoint, Coin{coin2}, /*possible_overwrite=*/false), std::logic_error); -> Prefer BOOST_CHECK_EXCEPTION(..., std::logic_error, HasReason(...)) if the error text is stable.
    • [src/test/rpc_tests.cpp] BOOST_CHECK_THROW(AmountFromValue(ValueFromString("-0.00000001")), UniValue); -> Use BOOST_CHECK_EXCEPTION(..., UniValue, HasJSON(...)) or another exact predicate instead of a generic throw check.
    • [src/test/util_tests.cpp] BOOST_CHECK_THROW(AmountFromValue(ValueFromString("1e-9")), UniValue); and the other invalid-amount BOOST_CHECK_THROW(..., UniValue) cases -> Prefer BOOST_CHECK_EXCEPTION(..., UniValue, HasJSON(...)) / a message-specific predicate for the expected failure.

    <sup>2026-07-01 12:19:49</sup>

  3. sedited commented at 10:26 AM on June 11, 2026: contributor

    Concept ACK

  4. in src/consensus/amount.h:16 in c8fa9d5fbe outdated
      11 | +#include <limits>
      12 |  
      13 |  /** Amount in satoshis (Can be negative) */
      14 |  typedef int64_t CAmount;
      15 |  
      16 | +consteval CAmount operator""_sats(unsigned long long amount) noexcept
    


    l0rinc commented at 10:32 AM on June 11, 2026:

    c8fa9d5 _consensus: Add sats user defined literal:

    ... guess this will finally put an end to the sats-vs-bits debate ...

  5. in src/consensus/amount.h:23 in c14d67a309 outdated
      19 | +    std::integral<std::remove_cvref_t<T>> &&
      20 | +    !std::same_as<std::remove_cvref_t<T>, bool>;
      21 |  
      22 |  /** Amount in satoshis (Can be negative) */
      23 | -typedef int64_t CAmount;
      24 | +class CAmount
    


    l0rinc commented at 10:35 AM on June 11, 2026:

    Do we need to keep the Hungarian notation C prefix?


    hodlinator commented at 8:02 AM on July 1, 2026:

    Agree it would be nice to rename to Amount as a before/after marker that the codebase is more typesafe, just want to keep the file count down a bit for now.

  6. in src/consensus/amount.h:56 in c14d67a309 outdated
      52 | +    constexpr CAmount operator-(const CAmount other) const noexcept
      53 | +    {
      54 | +        return CAmount{m_sats - other.m_sats};
      55 | +    }
      56 | +
      57 | +    constexpr inner_type operator/(const CAmount other) const noexcept
    


    l0rinc commented at 10:35 AM on June 11, 2026:

    What are the expected use cases for dividing one CAmount by another? What is the expected outcome of 10_sats / 3_sats? Since this returns a scalar ratio rather than another amount, would it be clearer to avoid exposing this as a generic operator and keep the few ratio-style uses explicit?


    hodlinator commented at 11:36 AM on July 1, 2026:

    When would it be harmful to allow calculating the scalar ratio? Exploring this helped me find a CeilDiv.

    <details><summary>Diff from disabling `amount / amount` and `amount % amount` while still being able to divide & mod by scalars.</summary>

    diff --git a/src/consensus/amount.h b/src/consensus/amount.h
    index 143d76b7b7..e42d55dedf 100644
    --- a/src/consensus/amount.h
    +++ b/src/consensus/amount.h
    @@ -52,7 +52,7 @@ public:
         {
             return CAmount{m_sats - other.m_sats};
         }
    -
    +/*
         constexpr inner_type operator/(const CAmount other) const noexcept
         {
             return m_sats / other.m_sats;
    @@ -62,7 +62,7 @@ public:
         {
             return m_sats % other.m_sats;
         }
    -
    +*/
         constexpr CAmount& operator+=(const CAmount other) noexcept
         {
             *this = CAmount{m_sats + other.m_sats};
    diff --git a/src/core_io.cpp b/src/core_io.cpp
    index a5ad9f618f..38988f980c 100644
    --- a/src/core_io.cpp
    +++ b/src/core_io.cpp
    @@ -283,8 +283,8 @@ util::Result<int> SighashFromStr(const std::string& sighash)
     UniValue ValueFromAmount(const CAmount amount)
     {
         static_assert(COIN > 1_sats);
    -    int64_t quotient = amount / COIN;
    -    int64_t remainder = amount % COIN;
    +    int64_t quotient = amount.Int() / COIN.Int();
    +    int64_t remainder = amount.Int() % COIN.Int();
         if (amount < 0_sats) {
             quotient = -quotient;
             remainder = -remainder;
    diff --git a/src/policy/feerate.cpp b/src/policy/feerate.cpp
    index 5cacd5c4f2..ad926dde14 100644
    --- a/src/policy/feerate.cpp
    +++ b/src/policy/feerate.cpp
    @@ -30,7 +30,7 @@ std::string CFeeRate::ToString(FeeRateFormat fee_rate_format) const
     {
         const CAmount feerate_per_kvb{GetFeePerK()};
         switch (fee_rate_format) {
    -    case FeeRateFormat::BTC_KVB: return strprintf("%d.%08d %s/kvB", feerate_per_kvb / COIN, feerate_per_kvb % COIN, CURRENCY_UNIT);
    +    case FeeRateFormat::BTC_KVB: return strprintf("%d.%08d %s/kvB", feerate_per_kvb / COIN.Int(), feerate_per_kvb % COIN.Int(), CURRENCY_UNIT);
         case FeeRateFormat::SAT_VB: return strprintf("%d.%03d %s/vB", feerate_per_kvb / 1000, feerate_per_kvb % 1000, CURRENCY_ATOM);
         } // no default case, so the compiler can warn about missing cases
         assert(false);
    diff --git a/src/primitives/transaction.cpp b/src/primitives/transaction.cpp
    index 286150a834..1241e0c664 100644
    --- a/src/primitives/transaction.cpp
    +++ b/src/primitives/transaction.cpp
    @@ -59,7 +59,7 @@ CTxOut::CTxOut(const CAmount& nValueIn, CScript scriptPubKeyIn)
     
     std::string CTxOut::ToString() const
     {
    -    return strprintf("CTxOut(nValue=%d.%08d, scriptPubKey=%s)", nValue / COIN, nValue % COIN, HexStr(scriptPubKey).substr(0, 30));
    +    return strprintf("CTxOut(nValue=%d.%08d, scriptPubKey=%s)", nValue.Int() / COIN.Int(), nValue.Int() % COIN.Int(), HexStr(scriptPubKey).substr(0, 30));
     }
     
     CMutableTransaction::CMutableTransaction() : version{CTransaction::CURRENT_VERSION}, nLockTime{0} {}
    diff --git a/src/util/moneystr.cpp b/src/util/moneystr.cpp
    index fe488bca92..a4b9aa603e 100644
    --- a/src/util/moneystr.cpp
    +++ b/src/util/moneystr.cpp
    @@ -22,8 +22,8 @@ std::string FormatMoney(const CAmount n)
         // Note: not using straight sprintf here because we do NOT want
         // localized number formatting.
         static_assert(COIN > 1_sats);
    -    int64_t quotient = n / COIN;
    -    int64_t remainder = n % COIN;
    +    int64_t quotient = n.Int() / COIN.Int();
    +    int64_t remainder = n.Int() % COIN.Int();
         if (n < 0_sats) {
             quotient = -quotient;
             remainder = -remainder;
    diff --git a/src/wallet/coinselection.cpp b/src/wallet/coinselection.cpp
    index 457f6ae290..750ae51647 100644
    --- a/src/wallet/coinselection.cpp
    +++ b/src/wallet/coinselection.cpp
    @@ -12,6 +12,7 @@
     #include <util/check.h>
     #include <util/log.h>
     #include <util/moneystr.h>
    +#include <util/overflow.h>
     
     #include <numeric>
     #include <optional>
    @@ -528,7 +529,7 @@ util::Result<SelectionResult> CoinGrinder(std::vector<OutputGroup>& utxo_pool, c
                     best_selection_weight = curr_weight;
                     best_selection_amount = curr_amount;
                 }
    -        } else if (!best_selection.empty() && curr_weight + int64_t{min_tail_weight[curr_tail]} * ((total_target - curr_amount + utxo_pool[curr_tail].GetSelectionAmount() - 1_sats) / utxo_pool[curr_tail].GetSelectionAmount()) > best_selection_weight) {
    +        } else if (!best_selection.empty() && curr_weight + int64_t{min_tail_weight[curr_tail]} * int64_t(CeilDiv(uint64_t((total_target - curr_amount).Int()), uint64_t(utxo_pool[curr_tail].GetSelectionAmount().Int()))) > best_selection_weight) {
                 // Compare minimal tail weight and last selected amount with the amount missing to gauge whether a better weight is still possible.
                 if (utxo_pool[curr_tail].m_weight <= min_tail_weight[curr_tail]) {
                     should_cut = true;
    
  7. DrahtBot added the label CI failed on Jun 11, 2026
  8. DrahtBot commented at 11:40 AM on June 11, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/27339510417/job/80774458061</sub> <sub>LLM reason (✨ experimental): CI failed because the fuzz target wouldn’t compile: clang++ reported -Wc++11-narrowing errors in coinselection.cpp when initializing CAmount from group_pos.size().</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>

  9. in src/ipc/capnp/common-types.h:137 in c14d67a309
     132 | +void CustomBuildField(TypeList<CAmount>, Priority<1>, InvokeContext& invoke_context, Value&& value, Output&& output)
     133 | +{
     134 | +    const CAmount::inner_type& source{value.Int()};
     135 | +    CAmount::inner_type dest{output.get()};
     136 | +    // Assumes both ends are using the same byte order.
     137 | +    memcpy(&dest, &source, sizeof(source));
    


    janb84 commented at 1:17 PM on June 11, 2026:
        output.set(value.Int());
        // OR output.set(static_cast<CAmount::inner_type>(value.Int()));
    

    Current code gets a local copy, overrides the local copy and local copy gets destroyed at the end of the function. Nothing is written back to the Cap'n Proto message.

    <details>

    Regression test:

    diff --git a/src/ipc/test/ipc_test.capnp b/src/ipc/test/ipc_test.capnp
    @@ -15,6 +15,7 @@ using Mining = import "../capnp/mining.capnp";
     
     interface FooInterface $Proxy.wrap("FooImplementation") {
         add [@0](/github-metadata-backup-bitcoin-bitcoin/contributor/0/) (a :Int32, b :Int32) -> (result :Int32);
    +    passAmounts [@6](/github-metadata-backup-bitcoin-bitcoin/contributor/6/) (arg :List(Int64)) -> (result :List(Int64));
         passOutPoint [@1](/github-metadata-backup-bitcoin-bitcoin/contributor/1/) (arg :Data) -> (result :Data);
         passUniValue [@2](/github-metadata-backup-bitcoin-bitcoin/contributor/2/) (arg :Text) -> (result :Text);
         passTransaction [@3](/github-metadata-backup-bitcoin-bitcoin/contributor/3/) (arg :Data) -> (result :Data);
    
    diff --git a/src/ipc/test/ipc_test.h b/src/ipc/test/ipc_test.h
    @@ -15,6 +15,7 @@ class FooImplementation
     {
     public:
         int add(int a, int b) { return a + b; }
    +    std::vector<CAmount> passAmounts(std::vector<CAmount> v) { return v; }
         COutPoint passOutPoint(COutPoint o) { return o; }
         UniValue passUniValue(UniValue v) { return v; }
         CTransactionRef passTransaction(CTransactionRef t) { return t; }
    
    diff --git a/src/ipc/test/ipc_test.cpp b/src/ipc/test/ipc_test.cpp
    @@ -84,6 +84,15 @@ void IpcPipeTest()
         // Test: make sure arguments were sent and return value is received
         BOOST_CHECK_EQUAL(foo->add(1, 2), 3);
     
    +    // Test for CustomBuildField / CustomReadField overloads for 
    +    //  CAmount in ipc/capnp/common-types.h. Each value is serialized
    +    //  once as the argument and once as the return value, so
    +    // a build field that fails to write its value shows up as a zeroed result.
    +    const std::vector<CAmount> amounts1{0_sats, 1_sats, COIN, MAX_MONEY, CAmount{-1}};
    +    const std::vector<CAmount> amounts2{foo->passAmounts(amounts1)};
    +    BOOST_CHECK_EQUAL_COLLECTIONS(amounts1.begin(), amounts1.end(), amounts2.begin(), amounts2.end());
    +
         COutPoint txout1{Txid::FromUint256(uint256{100}), 200};
         COutPoint txout2{foo->passOutPoint(txout1)};
         BOOST_CHECK(txout1 == txout2);
    

    </details>


    hodlinator commented at 6:16 PM on June 11, 2026:

    Thanks for catching this! I'm surprised it was not covered by tests earlier. I guess transaction serialization has the CAmounts outputs serialized as part of a byte-stream before reaching our Cap'n Proto integration layer. Added your test in a separate commit (44e5ba33d8ded7c1d72ce29321bcf839aacfb7e4) in the latest push (6b8a616b60541c77120525f74ebb11e422373a44), omitting the comment (let me know if you disagree, I think it was good context for your comment here in the PR but a bit verbose in the context of the test).


    janb84 commented at 8:31 AM on June 12, 2026:

    yeah test was for proving the bug not the intention to add it so i'm glad you altered it somewhat !

  10. in src/node/mempool_persist.cpp:99 in c14d67a309
      95 | @@ -96,8 +96,8 @@ bool LoadMempool(CTxMemPool& pool, const fs::path& load_path, Chainstate& active
      96 |                  nTime = TicksSinceEpoch<std::chrono::seconds>(now);
      97 |              }
      98 |  
      99 | -            CAmount amountdelta = nFeeDelta;
     100 | -            if (amountdelta && opts.apply_fee_delta_priority) {
     101 | +            CAmount amountdelta{nFeeDelta};
    


    janb84 commented at 3:57 PM on June 11, 2026:
    
    

    NIT: Change suggestion isn't helpful but now that we have new Serialization/deserialization, deserializes straight into a CAmount amountdelta. Dropping the need for the int64_t nFeeDelta temporary and the separate CAmount amountdelta{nFeeDelta} wrap.

    
    CAmount amountdelta{0};
    file >> TX_WITH_WITNESS(tx);
    file >> nTime;
    file >> amountdelta;
    
    if (opts.use_current_time) {
    nTime = TicksSinceEpoch<std::chrono::seconds>(now);
    }
    
    

    hodlinator commented at 6:19 PM on June 11, 2026:

    Agreed - switched in latest push (6b8a616b60541c77120525f74ebb11e422373a44) to serializing and checking CAmount fee_delta{0};. I think nFeeDelta was a more descriptive name, but altered it to match current naming style.

  11. hodlinator force-pushed on Jun 11, 2026
  12. DrahtBot removed the label CI failed on Jun 11, 2026
  13. ryanofsky commented at 9:30 PM on June 15, 2026: contributor

    Concept ACK. New CAmount implementation seems simple and clean and adds safety benefits.

    I haven't looked through the commits yet, but the PR seems very big. Could it make sense to split up somehow, maybe by introducing a new amount class and beginning to use that in places where it's most important, and gradually replacing CAmount, instead of replacing it everywhere? Or maybe there are other ways to split it up? Or maybe it is trivial to review and splitting it up would not be helpful? Curious what you think. This is just larger than I might have expected.

  14. in src/consensus/amount.h:113 in 6b8a616b60
     109 | +    constexpr const inner_type& Int() const noexcept { return m_sats; }
     110 | +};
     111 | +
     112 | +consteval CAmount operator""_sats(unsigned long long amount) noexcept
     113 | +{
     114 | +    assert(amount <= decltype(amount){std::numeric_limits<CAmount::inner_type>::max()});
    


    maflcko commented at 6:27 AM on June 16, 2026:

    q: Shouldn't this check against MAX_MONEY?

  15. in src/consensus/amount.h:108 in 6b8a616b60
     104 | +    {
     105 | +        *this = CAmount{m_sats >> other};
     106 | +        return *this;
     107 | +    }
     108 | +
     109 | +    constexpr const inner_type& Int() const noexcept { return m_sats; }
    


    maflcko commented at 6:27 AM on June 16, 2026:

    Either mark with LIFETIMEBOUND, or just return a copy, which is usually not slower anyway?

  16. maflcko commented at 6:33 AM on June 16, 2026: member

    I haven't looked through the commits yet, but the PR seems very big. Could it make sense to split up somehow, maybe by introducing a new amount class and beginning to use that in places where it's most important, and gradually replacing CAmount, instead of replacing it everywhere? Or maybe there are other ways to split it up? Or maybe it is trivial to review and splitting it up would not be helpful? Curious what you think. This is just larger than I might have expected.

    I think the main reason why this looks large is commit c8fa9d5fbef02ccb2150448cb84ed6e9bba383ef and the two following ones. It should be trivial to split the them up and then reduce the size of this pull by two thirds. After this, it seems small enough and easy enough to do in one go.

  17. Sjors commented at 8:21 AM on June 17, 2026: member

    I think the main reason why this looks large is commit https://github.com/bitcoin/bitcoin/commit/c8fa9d5fbef02ccb2150448cb84ed6e9bba383ef and the two following ones.

    In addition to making b7af8d94d6708e50ebede9a513d006ac0031606c a followup or earlier PR (to introduce the _sats literal), it could be a scripted diff? The script is already in the commit description, but then CI can check it too.

  18. ryanofsky commented at 12:30 PM on June 17, 2026: contributor

    I think the main reason why this looks large is commit c8fa9d5 and the two following ones. It should be trivial to split the them up and then reduce the size of this pull by two thirds. After this, it seems small enough and easy enough to do in one go.

    Nice. I'd be ok with reviewing this all together, but IMO it would be good to introduce the _sats suffix as a standalone readability improvement so this PR is smaller and just focused on safety issues, which seem more subtle.

  19. hodlinator force-pushed on Jun 17, 2026
  20. hodlinator commented at 1:42 PM on June 17, 2026: contributor

    Thanks for the encouraging feedback & ideas to facilitate review! Migrating to a new class gradually is worth at least keeping in the back-pocket (https://github.com/bitcoin/bitcoin/pull/35511#issuecomment-4712614640). Agree with #35511 (comment) that peeling off the beginning half of this PR up until before CAmount starts becoming an actual class could make sense (everything before "ipc: Add coverage for passing CAmount"?).

    Regarding #35511 (comment), making a scripted diff commit out of the largest one ("refactor: Apply _sats literals (trivial)"): In that commit I'm preemptively manually inserting _sats in places which will otherwise no longer compile due to later commits. Outside of some kind of heavily C++ AST-aware Clang-integrated tooling, I don't see a way of doing that in a scripted fashion. Maybe there is some creative way of running the sed-script I have in reverse... I just cannot come up with it now, but I'm open to suggestions.

  21. maflcko commented at 1:48 PM on June 17, 2026: member

    Ah, it is an inverse scripted diff. In theory (after #35547) you could do an empty scripted diff in the next commit with:

    • Your sed command on ./src
    • git --no-pager diff --exit-code --quiet HEAD~
    • git checkout -- ./src

    Not sure if that is too spicy.

  22. DrahtBot added the label CI failed on Jun 17, 2026
  23. DrahtBot commented at 3:26 PM on June 17, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/27693235629/job/81909522457</sub> <sub>LLM reason (✨ experimental): CI failed because IWYU reported include issues (“Failure generated from IWYU”), causing the test script to exit with status 1.</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>

  24. optout21 commented at 9:16 AM on June 18, 2026: contributor

    A strong like for the concept of CAmount as a class rather than a simple typedef.

    These properties make a lot of sense:

    • constructible from a numerical value
    • convertible to to a numerical value, but only explicitly
    • automatic extra sanity checks (e.g. max)

    However, to be constructible only from _sats literals, and to be comparable only against other CAmount seems a bit of an overkill to me, and it hinders introduction (large change). An amount is a special kind of number, but it is a number, and I don't see the benefit of abstracting away that fact that it is a number.

    I would be happier to see a more gradual introduction:

    • constructible from a number (with internal max check)
    • using conversion to number for comparisons, computations (i.e., amount.Int() < 1000 instead of amount < 1000_sat
    • for an even more gradual introduction, as an intermediary step, the conversion to number can be automatic
    • forcing to exclusive comparison and arithmetics (as proposed currently) could be done as a later step.

    Also, I'm a bit uneasy with the allowed negative values. Maybe such a change would be a good time to separate absolute amounts and amount differences, with the former being strictly non-negative (e.g. CAmount andd CAmountDelta; an analogue would be time points and time differences). I suspect for most of the usages, non-negative absolute amount would fit.

  25. test: correct CAmount argument type for AddDuplicateCoins() 7897c90358
  26. fees: Use CAmount in FeeFrac where appropriate
    block_policy_estimator.cpp: Also static_cast to primitive type rather than CAmount.
    d45a4756da
  27. mempool: Make TxMempoolInfo::nFeeDelta a proper CAmount 7b73b7d6a1
  28. hodlinator commented at 11:45 AM on June 18, 2026: contributor

    Thanks for your feedback @optout21!

    constructible from a number (with internal max check) [...] I'm a bit uneasy with the allowed negative values. Maybe such a change would be a good time to separate absolute amounts and amount differences, with the former being strictly non-negative (e.g. CAmount andd CAmountDelta; an analogue would be time points and time differences). I suspect for most of the usages, non-negative absolute amount would fit.

    One of my first experiments in this area was having distinct Amount (signed) and UAmount (unsigned) types (older branch: https://github.com/hodlinator/bitcoin/tree/2026/03/safer_camount).

    After presenting this experiment at https://btctranscripts.com/bitcoin-core-dev-tech/2026-05/camount I got feedback about the pitfalls of unsigned integers (mixing signed with unsigned in the same expression makes the result unsigned, which might result in unintentional conversion from negative values to positive).

    This made me change my approach to only keep signed amounts, but to experiment with having an amount type which only allows [0,21M BTC] in addition to a type which allows any int64_t value. A first attempt in this direction is made here: https://github.com/hodlinator/bitcoin/tree/2026/06/safer_camount.take2.unchecked. But will probably go with a more careful approach as described in the final commit message.

    using conversion to number for comparisons, computations (i.e., amount.Int() < 1000 instead of amount < 1000_sat

    If we were going to introduce the requirement to use .Int() everywhere we might as well instead introduce the literal directly?

    • for an even more gradual introduction, as an intermediary step, the conversion to number can be automatic
    • forcing to exclusive comparison and arithmetics (as proposed currently) could be done as a later step.

    I briefly experimented with having an operator int64_t() method and allowing comparisons against integers. What I ran into a lot was that the compiler couldn't resolve the resulting ambiguities. Maybe there's a way to resolve those ambiguities, but my impression from earlier feedback is that the current approach without that intermediate step may be acceptable.

  29. hodlinator force-pushed on Jun 18, 2026
  30. hodlinator force-pushed on Jun 18, 2026
  31. mempool: TxGraph - Correct CAmount argument for SetTransactionFee() and SetFee() 6c7e77e17b
  32. consensus: Add _sats user defined literal
    The plan is to make CAmount more type safe in the future, so that it cannot be directly compared with random integer literals.
    d2c5ceb128
  33. refactor: Apply _sats literals (trivial)
    Next commit contains a scripted diff which proves we only inserted _sats-suffixes.
    c732c48f99
  34. scripted-diff: Prove the simplicity of previous commit
    Can be tested locally through running:
    cargo run --manifest-path ./test/lint/test_runner/Cargo.toml -- --lint=scripted_diff
    
    Verifiable through changing the sed-expression to no longer match _sats.
    
    -BEGIN VERIFY SCRIPT-
    sed -i -E 's/([0-9a-f])_sats\b/\1/g' $(git grep -l '_sats' ./src/) && \
    git --no-pager diff --exit-code --quiet HEAD~ && \
    git checkout -- ./src
    -END VERIFY SCRIPT-
    5efa0760fa
  35. refactor: Apply _sats literals (more manual)
    Also changes some implicitly bool conditions to != 0_sats.
    
    The reason "/*nValueIn=*/" was removed in src/wallet/test/fuzz/coinselection.cpp was that clang-tidy (mis)matched it with the v-argument for the _sats literal:
    /home/runner/work/_temp/src/wallet/test/fuzz/coinselection.cpp:377:45: error: argument name 'nValueIn' in comment does not match parameter name 'v' [bugprone-argument-comment,-warnings-as-errors]
      377 |     const auto dust{GetDustThreshold(CTxOut{/*nValueIn=*/0_sats, change_out_script}, coin_params.m_discard_feerate)};
          |                                             ^
    /home/runner/work/_temp/src/consensus/amount.h:136:53: note: 'v' declared here
      136 | constexpr Amount operator""_sats(unsigned long long v)
          |                                                     ^
    69fd3009a3
  36. test: Add _BTC literal to coinselector_tests.cpp fe04bd335b
  37. hodlinator force-pushed on Jun 18, 2026
  38. DrahtBot removed the label CI failed on Jun 18, 2026
  39. purpleKarrot commented at 6:30 AM on June 22, 2026: contributor

    It might be worth having a look into the crystal ball. If P3045 gets accepted into the C++ standard in one form or another, then our preferred way of defining amount might become something like this:

    using Amount = std::quantity<units::satoshi, std::int64_t>;
    

    I don't want to suggest using a polyfill for std::quantity, but I strongly recommend having a look into the proposal linked above and see whether some of its design decisions could influence the design of a custom Amount class. For example, there is a design decision against user defined literals that contains:

    UDLs work only with literals (compile-time known values). Our observation is that besides the unit tests, only a few compile-time known quantity values are used in the production code.

    Which is absolutely true and convinces me to vote against a _sat UDL.

    I also strongly believe that whether an amount is inside a particular value range should be a predicate rather than an invarant.

  40. ryanofsky commented at 8:35 PM on June 22, 2026: contributor

    re: #35511 (comment)

    I also strongly believe that whether an amount is inside a particular value range should be a predicate rather than an invarant.

    This seems to already be the case if you look at ed38d9ffa1da4cfafb17fc7977edc579e5147207. The CAmount class doesn't enforce any range requirements. It could be useful to have other types that enforce runtime invariants like not_null or boost's safe_signed_range type do, but this would be a separate concern. There is also a MoneyRange predicate already.

    convinces me to vote against a _sat UDL.

    The std::quantity proposal is interesting and seems to write units with multiplication syntax (quantity dist1 = 80 * km;) instead of literal suffixes. This does seem more general and could be a good idea to apply here.

  41. purpleKarrot commented at 6:26 AM on June 23, 2026: contributor

    The CAmount class doesn't enforce any range requirements. It could be useful to have other types that enforce runtime invariants like not_null or boost's safe_signed_range type do,

    True. Such a utility may be handy in some areas of the codebase, but we should keep in mind (and probably document somewhere) that there are areas where such a utility must not be used. A function like validate(Block) -> bool has three different outcomes:

    1. true when the block was found valid
    2. false when the block was found invalid
    3. An exception being thrown in case of a runtime failure, in which case the function is unable to determine whether the block is valid or invalid.

    It is especially crucial that we are always able to distinguish between 2 and 3. That means:

    • A block must be allowed to represent invalid data. None of its parts should have an invariant that disallows checking it for validity.
    • Checking an invalid block must not result in runtime failure. There is no throw in case of consensus violations.
    • Runtime errors must not be interpreted as the block being invalid. There should be no catch in validation code.

    But outside of consensus, yes, such a utility may be useful.

  42. hodlinator commented at 1:00 PM on June 26, 2026: contributor

    Thanks for your comments @purpleKarrot & @ryanofsky! (https://github.com/bitcoin/bitcoin/pull/35511#issuecomment-4765530904, #35511 (comment), #35511 (comment)).

    User-defined literals

    The future quantities & units library makes sense for use-cases mixing different quantities (radius vs distance), units (metre vs foot) and kinds (energy vs length). In our domain we are typically interested in sats, sat/vbyte and similar cost/weight unit ratios. At least one test had me define a _BTC literal (fe04bd335bc407c2754f332c697c1f7475f1dd17).

    Writing 0_sats rather than CAmount{0} was nice for readability and is okay for code that is not exposed by the kernel IMO. However, my next plan is to experiment with a version of this PR which does not introduce any UDLs and instead tries to survive on explicit CAmount-ctors and possibly increased usage of .Int().

    Constraining valid ranges - [0,21M] BTC

    My experimentation so far confirms that it would be safer to introduce constraints on amount types outside of the consensus code.

  43. ryanofsky commented at 6:28 PM on June 29, 2026: contributor

    re: #35511 (comment)

    Writing 0_sats rather than CAmount{0} was nice for readability and is okay for code that is not exposed by the kernel IMO. However, my next plan is to experiment with a version of this PR which does not introduce any UDLs and instead tries to survive on explicit CAmount-ctors

    I think you should consider using the * sats multiplication syntax like std::quantity. It seems pretty nice, and better than literal suffixes because it can work with all integer expressions, not just integer literals. It should also be more readable and self-documenting than passing numbers without units to the CAmount constructor.

  44. in src/txmempool.cpp:1026 in cc90782e0c outdated
    1022 | @@ -1017,7 +1023,7 @@ CTxMemPool::ChangeSet::TxHandle CTxMemPool::ChangeSet::StageAddition(const CTran
    1023 |      FeePerWeight feerate(fee, GetSigOpsAdjustedWeight(GetTransactionWeight(*tx), sigops_cost, ::nBytesPerSigOp));
    1024 |      auto newit = m_to_add.emplace(tx, fee, time, entry_height, entry_sequence, spends_coinbase, sigops_cost, lp).first;
    1025 |      m_pool->m_txgraph->AddTransaction(const_cast<CTxMemPoolEntry&>(*newit), feerate);
    1026 | -    if (delta) {
    1027 | +    if (delta != 0_sats) {
    


    l0rinc commented at 7:05 PM on June 29, 2026:

    alternatively we could add a bool operator to it (we have many other cases when we're checking for value > 0)


    hodlinator commented at 7:59 AM on July 1, 2026:

    What I'm going for is to make amounts stand out more in code and have less implicit conversions with raw types.

  45. in src/serialize.h:276 in cc90782e0c
     272 | @@ -271,6 +273,12 @@ template <typename Stream> void Unserialize(Stream& s, uint32_t& a)  { a = ser_r
     273 |  template <typename Stream> void Unserialize(Stream& s, int64_t& a)   { a = int64_t(ser_readdata64(s)); }
     274 |  template <typename Stream> void Unserialize(Stream& s, uint64_t& a)  { a = ser_readdata64(s); }
     275 |  
     276 | +template <typename Stream> void Unserialize(Stream& s, CAmount& a)
    


    l0rinc commented at 7:06 PM on June 29, 2026:

    Could we rather encapsulate the serialization inside the new type?


    hodlinator commented at 11:43 AM on July 1, 2026:

    Agreed, thanks for pushing back on this.

  46. in src/wallet/wallet.cpp:3009 in cc90782e0c outdated
    3005 | @@ -3006,7 +3006,7 @@ bool CWallet::LoadWalletArgs(std::shared_ptr<CWallet> wallet, const WalletContex
    3006 |      if (const auto arg{args.GetArg("-maxapsfee")}) {
    3007 |          const std::string& max_aps_fee{*arg};
    3008 |          if (max_aps_fee == "-1") {
    3009 | -            wallet->m_max_aps_fee = -1;
    3010 | +            wallet->m_max_aps_fee = -1_sats;
    


    l0rinc commented at 7:07 PM on June 29, 2026:

    Wouldn't it make more sense to make this optional instead of using a magic value?


    hodlinator commented at 7:58 AM on July 1, 2026:

    Agreed, that would be cleaner. However we do have: https://github.com/bitcoin/bitcoin/blob/341360964a6fef725825639edc507c275f8ad0b2/src/wallet/spend.cpp#L1485 So I'd prefer if that change happened in another PR.

  47. in src/wallet/test/coinselector_tests.cpp:1161 in cc90782e0c outdated
    1160 |          const auto& res = CoinGrinder(target, dummy_params, m_node, max_selection_weight, [&](CWallet& wallet) {
    1161 |              CoinsResult available_coins;
    1162 | -            add_coin(available_coins, wallet, CAmount(1.8 * COIN), CFeeRate(5000), 144, false, 0, true, 2500);
    1163 | -            add_coin(available_coins, wallet, CAmount(1 * COIN), CFeeRate(5000), 144, false, 0, true, 1000);
    1164 | -            add_coin(available_coins, wallet, CAmount(1 * COIN), CFeeRate(5000), 144, false, 0, true, 1000);
    1165 | +            add_coin(available_coins, wallet, 1.8_BTC, CFeeRate(5000_sats), 144, false, 0, true, 2500);
    


    l0rinc commented at 7:11 PM on June 29, 2026:

    Won't this introduce unnecessary headaches around floating-point money, even if this is only test code? This was already doing something similar before, but adding a _BTC literal seems to make that pattern more attractive rather than less. See 5aadd4b and fa2ac58.


    hodlinator commented at 7:57 AM on July 1, 2026:

    The literal is only declared in this one .CPP file and has a comment that it should only be used for tests due to imprecision.

    https://github.com/bitcoin/bitcoin/blob/cc90782e0cfa602d2b312f0d669d47a7d3d4dd37/src/wallet/test/coinselector_tests.cpp#L34-L39

    (The standard non-long double type has more than enough bits^1 to represent satoshi-amounts, which is part of why we have 21M BTC as I got confirmed when I presented this).

  48. in src/wallet/test/coinselector_tests.cpp:349 in cc90782e0c outdated
     351 |      // Add spendable coin at the BnB selection upper bound
     352 |      CoinsResult available_coins;
     353 |      add_coin(available_coins, *wallet, COIN + params.m_cost_of_change, /*feerate=*/params.m_effective_feerate, /*nAge=*/6, /*fIsFromMe=*/true, /*nInput=*/0, /*spendable=*/true);
     354 | -    add_coin(available_coins, *wallet, 0.5 * COIN + params.m_cost_of_change, /*feerate=*/params.m_effective_feerate, /*nAge=*/6, /*fIsFromMe=*/true, /*nInput=*/0, /*spendable=*/true);
     355 | -    add_coin(available_coins, *wallet, 0.5 * COIN, /*feerate=*/params.m_effective_feerate, /*nAge=*/6, /*fIsFromMe=*/true, /*nInput=*/0, /*spendable=*/true);
     356 | +    add_coin(available_coins, *wallet, COIN / 2 + params.m_cost_of_change, /*feerate=*/params.m_effective_feerate, /*nAge=*/6, /*fIsFromMe=*/true, /*nInput=*/0, /*spendable=*/true);
    


    l0rinc commented at 7:13 PM on June 29, 2026:

    note that 0.5 * COIN may not be the same as COIN / 2


    hodlinator commented at 2:27 PM on June 30, 2026:

    0.5 * COIN on master is double * int64_t which somehow ends up being sent into a int64_t parameter. Making COIN a typed amount and disallowing implicit double operations is a step in the right direction I think. (We also know that 100'000'000 is divisible by 2 without any remainder).

  49. in src/wallet/test/fuzz/coinselection.cpp:274 in cc90782e0c
     270 | @@ -271,7 +271,7 @@ FUZZ_TARGET(bnb_finds_min_waste)
     271 |      assert(num_groups <= max_output_groups);
     272 |  
     273 |      // Only choose targets below max_spendable
     274 | -    const CAmount target{fuzzed_data_provider.ConsumeIntegralInRange<CAmount>(1, std::max(CAmount{1}, max_spendable - coin_params.m_cost_of_change))};
     275 | +    const CAmount target{ConsumeMoney(fuzzed_data_provider, 1_sats, std::max(CAmount{1}, max_spendable - coin_params.m_cost_of_change))};
    


    l0rinc commented at 7:14 PM on June 29, 2026:

    why do we have CAmount{1} here instead of 1_sats


    hodlinator commented at 7:43 AM on July 1, 2026:

    Good point, it's inconsistent with the similar case around L177.

  50. in src/util/moneystr.cpp:64 in cc90782e0c outdated
      60 | @@ -60,7 +61,7 @@ std::optional<CAmount> ParseMoney(const std::string& money_string)
      61 |          if (*p == '.')
      62 |          {
      63 |              p++;
      64 | -            int64_t nMult = COIN / 10;
      65 | +            int64_t nMult = COIN.Int() / 10;
    


    l0rinc commented at 7:15 PM on June 29, 2026:

    seems contrived to do math with the new types, wouldn't it make a lot more sense to extract the value, do the math and reparse at the end?


    hodlinator commented at 7:48 AM on July 1, 2026:

    The idea is for CAmount to not implicitly convert back & forth with raw integers. Not sure I follow, are you objecting to there being two calls to COIN.Int()?

  51. l0rinc changes_requested
  52. l0rinc commented at 7:29 PM on June 29, 2026: contributor

    As mentioned by others, I am also not comfortable with taking such a large change in one step. I would prefer introducing a new amount type first, with a few focused examples, and then converting call sites gradually. I also think _sats, * sats, and explicit CAmount{} construction should be debated independently.

    I share the concern around value-range semantics. If the goal is that an amount object is always valid once constructed, then the boundary needs to be explicit: parsing should probably produce an unchecked/raw amount first, and validation should then finalize it into a bounded monetary amount, for example through a factory returning std::optional<MoneyAmount> or util::Result<MoneyAmount>. If negative values are needed, they may deserve a separate MoneyDelta type instead of being folded into the same bounded money type.

    One area I would like clarified is amount compression and serialization. After this change, AmountCompression still appears to deserialize compressed amounts into CAmount without enforcing MoneyRange. Is that intentional and byte-for-byte compatible with the current behavior? Several fuzz targets in src/test/fuzz/deserialize.cpp can already construct invalid monetary values, and coins/undo data previously seemed to treat a successful parse as sufficient, as far as I can tell.

  53. hodlinator commented at 12:10 PM on July 1, 2026: contributor

    Thanks for checking this out @l0rinc!

    As mentioned by others, I am also not comfortable with taking such a large change in one step. I would prefer introducing a new amount type first, with a few focused examples, and then converting call sites gradually. I also think _sats, * sats, and explicit CAmount{} construction should be debated independently.

    Yes, explicit construction could be split off as a later PR.

    I share the concern around value-range semantics. If the goal is that an amount object is always valid once constructed, then the boundary needs to be explicit: parsing should probably produce an unchecked/raw amount first, and validation should then finalize it into a bounded monetary amount, for example through a factory returning std::optional<MoneyAmount> or util::Result<MoneyAmount>. If negative values are needed, they may deserve a separate MoneyDelta type instead of being folded into the same bounded money type.

    Yup, introducing an additional bounded type should come after this work. Something like MoneyDelta/AmountDelta sounds good.

    One area I would like clarified is amount compression and serialization. After this change, AmountCompression still appears to deserialize compressed amounts into CAmount without enforcing MoneyRange. Is that intentional and byte-for-byte compatible with the current behavior? Several fuzz targets in src/test/fuzz/deserialize.cpp can already construct invalid monetary values, and coins/undo data previously seemed to treat a successful parse as sufficient, as far as I can tell.

    As far as I can see there is no change to the on the wire format. This comment makes me uneasy but the number of out of bounds values serialized should be the same as on master.

    https://github.com/bitcoin/bitcoin/blob/341360964a6fef725825639edc507c275f8ad0b2/src/compressor.h#L30-L39

  54. fuzz: Add ConsumeMoney() overloads with support for specifying minimum cfb9d23ca6
  55. ipc: Add coverage for passing CAmount
    Co-authored-by: janb84 <githubjanb.drainer976@passmail.net>
    24c97a2865
  56. consensus: Make CAmount a class
    No longer allows implicit conversions from non-integer types such as floats or bools.
    
    Would prevent issues such as the one fixed by 0026b330c4abbbbdb96e4f0c4d380d70d8e592ab.
    2a10e92ebf
  57. refactor(mempool): Return nullopt instead of default-initialized TxMempoolInfo
    Preparation for CAmount not having default ctor.
    265d7b0eb0
  58. consensus: Require initialization of CAmount ab253949ac
  59. consensus: Make CAmount(T) constructor explicit 9c11263d41
  60. hodlinator force-pushed on Jul 1, 2026
  61. DrahtBot added the label CI failed on Jul 1, 2026
  62. DrahtBot added the label Needs rebase on Jul 7, 2026
  63. DrahtBot commented at 9:37 PM on July 7, 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