policy: fix negative CFeeRate::ToString() formatting #35303

pull joaonevess wants to merge 1 commits into bitcoin:master from joaonevess:policy-fix-negative-feerate-string changing 2 files +23 −2
  1. joaonevess commented at 7:30 AM on May 16, 2026: contributor

    CFeeRate may represent modified/effective fee rates, for example after a negative prioritisetransaction fee delta.

    Previously, CFeeRate::ToString() formatted the quotient and remainder directly. For negative values, C++ % produces a negative remainder, which could result in malformed strings such as 0.-01 sat/vB.

    This changes formatting to emit the sign once and format non-negative quotient/remainder parts. Positive fee rate formatting is unchanged.

    This is a display-only change and does not affect fee calculation or policy behavior.

    Includes unit test coverage for negative fee rates, including a whole-plus-fractional BTC/kvB value and the CAmount minimum value.

    Testing:

    • build-coretest/bin/test_bitcoin --run_test=amount_tests/ToStringTest
    • build-coretest/bin/test_bitcoin
    • git diff --check
  2. DrahtBot added the label TX fees and policy on May 16, 2026
  3. DrahtBot commented at 7:31 AM on May 16, 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/35303.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK polespinasa

    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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. maflcko commented at 7:39 AM on May 16, 2026: member

    Was this LLM generated? What are the steps to test this outside of unit tests? What is the RPC output before and after the changes here?

  5. joaonevess commented at 8:52 AM on May 16, 2026: contributor

    Yes, I used LLM assistance while working on this, but I reviewed the code myself and reproduced the behavior locally.

    I tested this outside unit tests with a regtest node using -printpriority=1:

    1. Create a parent/child transaction pair in the mempool.
    2. Use prioritisetransaction to apply a negative fee delta to the parent. In my reproducer, the parent had a 10000 sat fee and I applied fee_delta=-10001, making its modified fee -1 sat.
    3. Mine a block with generatetoaddress.
    4. Check the AddToBlock line in debug.log.

    The RPC return values themselves do not change. prioritisetransaction returns true, and generatetoaddress returns the generated block hash. The visible change is in the log output produced while handling the mining RPC.

    Before:

    fee rate 0.-0000010 BTC/kvB txid <txid>
    

    After:

    fee rate -0.00000010 BTC/kvB txid <txid>
    
  6. joaonevess closed this on May 17, 2026

  7. joaonevess deleted the branch on May 17, 2026
  8. joaonevess restored the branch on May 17, 2026
  9. joaonevess reopened this on May 17, 2026

  10. polespinasa commented at 9:03 PM on May 17, 2026: member

    I am unable to replicate this. Maybe I am doing something wrong but if I have a transaction with negative feerate my node will not mine it. I don't think we allow -blockmintxfee negative values. (or at least I don't manage to set it).

    Can you write the rpc calls step by step so I can replicate? Also can you share your regtest node bitcoin.conf file and startup options?

  11. joaonevess commented at 9:52 PM on May 17, 2026: contributor

    Hi, the important detail is that this does not require a negative -blockmintxfee.

    The parent transaction has a negative modified fee after prioritisetransaction. It would not be mined by itself. It is mined here because it is selected together with its child, and the combined package feerate is still above the normal block min fee.

    Config:

    bitcoin.conf: empty
    startup: bitcoind -regtest -daemonwait -printpriority=1
    

    Repro, run from the repo root after building, requires jq:

    #!/usr/bin/env bash
    set -euo pipefail
    
    BITCOIND=${BITCOIND:-./build/bin/bitcoind}
    BITCOIN_CLI=${BITCOIN_CLI:-./build/bin/bitcoin-cli}
    DATADIR=$(mktemp -d "${TMPDIR:-/tmp}/bitcoin-neg-feerate.XXXXXX")
    
    bcli() { "$BITCOIN_CLI" -regtest -datadir="$DATADIR" "$@"; }
    cleanup() { bcli stop >/dev/null 2>&1 || true; }
    trap cleanup EXIT
    
    ADDR=mjTkW3DjgyZck4KbiRusZsqTgaYTxdSz6z
    WIF=cVpF924EspNh8KjYsfhgY96mmxvT6DgdWiTYMtMjuM74hJaU5psW
    
    "$BITCOIND" -regtest -datadir="$DATADIR" -daemonwait -printpriority=1 >/dev/null
    bcli generatetoaddress 101 "$ADDR" >/dev/null
    
    BLOCK1=$(bcli getblockhash 1)
    COINBASE_TXID=$(bcli getblock "$BLOCK1" 2 | jq -r '.tx[0].txid')
    COINBASE_AMOUNT=$(bcli getblock "$BLOCK1" 2 | jq -r '.tx[0].vout[0].value')
    COINBASE_SPK=$(bcli getblock "$BLOCK1" 2 | jq -r '.tx[0].vout[0].scriptPubKey.hex')
    
    PARENT_RAW=$(bcli createrawtransaction "[{\"txid\":\"$COINBASE_TXID\",\"vout\":0}]" "{\"$ADDR\":49.99990000}")
    PARENT_HEX=$(bcli signrawtransactionwithkey "$PARENT_RAW" "[\"$WIF\"]" "[{\"txid\":\"$COINBASE_TXID\",\"vout\":0,\"scriptPubKey\":\"$COINBASE_SPK\",\"amount\":$COINBASE_AMOUNT}]" | jq -r '.hex')
    PARENT_TXID=$(bcli sendrawtransaction "$PARENT_HEX")
    
    PARENT_FEE_SAT=$(bcli getmempoolentry "$PARENT_TXID" | jq -r '(.fees.base * 100000000) | round')
    FEE_DELTA=$((-PARENT_FEE_SAT - 1))
    bcli prioritisetransaction "$PARENT_TXID" 0 "$FEE_DELTA" >/dev/null
    
    PARENT_SPK=$(bcli getrawtransaction "$PARENT_TXID" true | jq -r '.vout[0].scriptPubKey.hex')
    
    CHILD_RAW=$(bcli createrawtransaction "[{\"txid\":\"$PARENT_TXID\",\"vout\":0}]" "{\"$ADDR\":49.99980000}")
    CHILD_HEX=$(bcli signrawtransactionwithkey "$CHILD_RAW" "[\"$WIF\"]" "[{\"txid\":\"$PARENT_TXID\",\"vout\":0,\"scriptPubKey\":\"$PARENT_SPK\",\"amount\":49.99990000}]" | jq -r '.hex')
    CHILD_TXID=$(bcli sendrawtransaction "$CHILD_HEX")
    
    MINED_BLOCK=$(bcli generatetoaddress 1 "$ADDR" | jq -r '.[0]')
    
    bcli getblock "$MINED_BLOCK" 1 | jq --arg p "$PARENT_TXID" --arg c "$CHILD_TXID" \
      '{parent_mined: (.tx | index($p) != null), child_mined: (.tx | index($c) != null)}'
    
    grep "fee rate .* txid" "$DATADIR/regtest/debug.log"
    

    Expected mined status:

    {
      "parent_mined": true,
      "child_mined": true
    }
    

    On master I get:

    fee rate 0.-0000006 BTC/kvB txid <parent_txid>
    

    With this PR I get:

    fee rate -0.00000006 BTC/kvB txid <parent_txid>
    

    So this is display-only. The RPC results and mining behavior are unchanged; only the debug.log formatting from CFeeRate::ToString() changes.

  12. polespinasa commented at 10:07 PM on May 17, 2026: member

    I see, thanks for clarification.

  13. polespinasa commented at 10:15 PM on May 17, 2026: member

    concept ACK

    I don't think there's any need for a helper function and structs, you are only using it inside CFeeRate::ToString. You can do the same with a lambda function.

    std::string CFeeRate::ToString(FeeRateFormat fee_rate_format) const
    {
        const CAmount feerate_per_kvb{GetFeePerK()};
    
        const auto format_feerate = [](CAmount fee_rate, int64_t divisor, int decimals, const std::string& currency_unit, const std::string& size_unit) {
            const char* sign = fee_rate < 0 ? "-" : "";
            const CAmount abs_rate = fee_rate < 0 ? -fee_rate : fee_rate;
            return strprintf("%s%d.%0*d %s/%s", sign, abs_rate / divisor, decimals, abs_rate % divisor, currency_unit, size_unit);
        };
    
        switch (fee_rate_format) {
        case FeeRateFormat::BTC_KVB: return format_feerate(feerate_per_kvb, COIN, 8, CURRENCY_UNIT, "kvB");
        case FeeRateFormat::SAT_VB:  return format_feerate(feerate_per_kvb, 1000, 3, CURRENCY_ATOM, "vB");
        } // no default case, so the compiler can warn about missing cases
        assert(false);
    }
    
  14. joaonevess commented at 10:23 PM on May 17, 2026: contributor

    Thanks, agree that keeping this local to CFeeRate::ToString() is simpler. I’ll switch this to a lambda.

    One small caveat with negating the full value first: CAmount is int64_t, so -std::numeric_limits<CAmount>::min() would overflow. I’ll use the lambda approach but keep the split-before-negating behavior.

  15. joaonevess force-pushed on May 17, 2026
  16. in src/policy/feerate.cpp:33 in f233b87a5c
      28 | @@ -29,9 +29,21 @@ CAmount CFeeRate::GetFee(int32_t virtual_bytes) const
      29 |  std::string CFeeRate::ToString(FeeRateFormat fee_rate_format) const
      30 |  {
      31 |      const CAmount feerate_per_kvb{GetFeePerK()};
      32 | +    const auto format_feerate = [](const CAmount fee_rate, const CAmount divisor, const int decimals, const std::string& currency_unit, const std::string& size_unit) {
      33 | +        Assume(divisor > 1);
    


    polespinasa commented at 6:45 AM on May 18, 2026:

    shouldn't it be Assert(divisor != 0) ?


    joaonevess commented at 9:06 AM on May 18, 2026:

    Thanks, changed this to Assert(divisor > 0). The divisor is an internal formatting scale, so it should be positive; this also covers the divide-by-zero case


    winterrdog commented at 8:00 PM on May 18, 2026:

    regarding Assert(divisor > 0): practically speaking, the divisor here is always a hardcoded positive scale (COIN or 1000; i doubt we can ever have a negative value per vB in a practical sense), so a negative input is impossible in this context and divisor != 0 would technically suffice to block the division-by-zero.

    that said, keeping it as divisor > 0 will not hurt though. it is great for defensive programming since it explicitly enforces that the formatting scale must be a positive magnitude.

  17. in src/policy/feerate.cpp:38 in f233b87a5c
      33 | +        Assume(divisor > 1);
      34 | +        const bool negative{fee_rate < 0};
      35 | +        CAmount quotient{fee_rate / divisor};
      36 | +        CAmount remainder{fee_rate % divisor};
      37 | +        if (negative) {
      38 | +            // Split before negating so std::numeric_limits<CAmount>::min() is handled safely.
    


    maflcko commented at 6:51 AM on May 18, 2026:

    This isn't a valid/safe value anyway, because it leads to overflow elsewhere anyway, fwiw, see #21893#issue-882033736


    joaonevess commented at 9:08 AM on May 18, 2026:

    Thanks, fair point. Dropped the CAmount::min() test/comment to keep this focused on ordinary negative modified fees

  18. joaonevess force-pushed on May 18, 2026
  19. in src/policy/feerate.cpp:9 in 6a57efaaec
       5 | @@ -6,7 +6,7 @@
       6 |  #include <consensus/amount.h>
       7 |  #include <policy/feerate.h>
       8 |  #include <tinyformat.h>
       9 | -
      10 | +#include <util/check.h>
    


    polespinasa commented at 9:21 AM on May 18, 2026:

    I don't think this include is needed.


    joaonevess commented at 3:17 PM on May 18, 2026:

    Assert is available transitively through util/feefrac.h, so this include is not required for compilation. I can drop it to keep the diff smaller

  20. in src/policy/feerate.cpp:41 in 6a57efaaec
      36 | +        CAmount remainder{fee_rate % divisor};
      37 | +        if (negative) {
      38 | +            quotient = -quotient;
      39 | +            remainder = -remainder;
      40 | +        }
      41 | +        return strprintf("%s%d.%0*d %s/%s", negative ? "-" : "", quotient, decimals, remainder, currency_unit, size_unit);
    


    polespinasa commented at 9:29 AM on May 18, 2026:

    nit: I think this is less verbose but enough self-explanatory.

            const std::string sign = fee_rate < 0 ? "-" : "";
            CAmount quotient{std::abs(fee_rate / divisor)};
            CAmount remainder{std::abs(fee_rate % divisor)};
            return strprintf("%s%d.%0*d %s/%s", sign, quotient, decimals, remainder, currency_unit, size_unit);
    

    joaonevess commented at 3:28 PM on May 18, 2026:

    Thanks, applied this with const char* sign to avoid constructing a std::string

  21. policy: fix negative CFeeRate::ToString() formatting 3ce49f9000
  22. joaonevess force-pushed on May 18, 2026
  23. in src/policy/feerate.cpp:39 in 3ce49f9000
      34 | +        Assert(divisor > 0);
      35 | +        const char* sign{fee_rate < 0 ? "-" : ""};
      36 | +        const CAmount quotient{std::abs(fee_rate / divisor)};
      37 | +        const CAmount remainder{std::abs(fee_rate % divisor)};
      38 | +        return strprintf("%s%d.%0*d %s/%s", sign, quotient, decimals, remainder, currency_unit, size_unit);
      39 | +    };
    


    winterrdog commented at 8:18 PM on May 18, 2026:

    as a minor style nit (feel free to take or leave), since feerate_per_kvb is computed once at the very top of ToString() and remains constant for the lifetime of the function, providing it explicitly into every lambda invocation adds a bit of visual noise.

    capturing it by reference instead lets the lambda access it directly from the surrounding scope, which simplifies the signature a little and keeps the switch cases slightly cleaner/readable:

    diff --git a/src/policy/feerate.cpp b/src/policy/feerate.cpp
    index d511c5fb28..2c574f774b 100644
    --- a/src/policy/feerate.cpp
    +++ b/src/policy/feerate.cpp
    @@ -30,16 +30,16 @@ CAmount CFeeRate::GetFee(int32_t virtual_bytes) const
     std::string CFeeRate::ToString(FeeRateFormat fee_rate_format) const
     {
         const CAmount feerate_per_kvb{GetFeePerK()};
    -    const auto format_feerate = [](const CAmount fee_rate, const CAmount divisor, const int decimals, const std::string& currency_unit, const std::string& size_unit) {
    +    const auto format_feerate = [&feerate_per_kvb](const CAmount divisor, const int decimals, const std::string& currency_unit, const std::string& size_unit) {
             Assert(divisor > 0);
    -        const char* sign{fee_rate < 0 ? "-" : ""};
    -        const CAmount quotient{std::abs(fee_rate / divisor)};
    -        const CAmount remainder{std::abs(fee_rate % divisor)};
    +        const char* sign{feerate_per_kvb < 0 ? "-" : ""};
    +        const CAmount quotient{std::abs(feerate_per_kvb / divisor)};
    +        const CAmount remainder{std::abs(feerate_per_kvb % divisor)};
             return strprintf("%s%d.%0*d %s/%s", sign, quotient, decimals, remainder, currency_unit, size_unit);
         };
         switch (fee_rate_format) {
    -    case FeeRateFormat::BTC_KVB: return format_feerate(feerate_per_kvb, COIN, 8, CURRENCY_UNIT, "kvB");
    -    case FeeRateFormat::SAT_VB: return format_feerate(feerate_per_kvb, 1000, 3, CURRENCY_ATOM, "vB");
    +    case FeeRateFormat::BTC_KVB: return format_feerate(COIN, 8, CURRENCY_UNIT, "kvB");
    +    case FeeRateFormat::SAT_VB: return format_feerate(1000, 3, CURRENCY_ATOM, "vB");
         } // no default case, so the compiler can warn about missing cases
         assert(false);
     }
    

    what do you think ?


    joaonevess commented at 9:45 PM on May 18, 2026:

    Thanks, that makes sense. I’d slightly prefer to keep fee_rate explicit here though, since the lambda is formatting that value directly, avoids adding a capture, and the call sites are still clear enough


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-05-19 06:51 UTC