Skip to content

fuzz: keep coins_view fuzzers within caller contracts#34655

Merged
achow101 merged 4 commits intobitcoin:masterfrom
l0rinc:l0rinc/coins_view_fuzzer_cleanup
Feb 24, 2026
Merged

fuzz: keep coins_view fuzzers within caller contracts#34655
achow101 merged 4 commits intobitcoin:masterfrom
l0rinc:l0rinc/coins_view_fuzzer_cleanup

Conversation

@l0rinc
Copy link
Contributor

@l0rinc l0rinc commented Feb 23, 2026

Problem

This is an alternative approach to #34647, fixes #34645.

Fix

First, add CheckedSub and use it for decrements of m_dirty_count and cachedCoinsUsage, so unsigned underflows turn into immediate failures instead of silently wrapping and only failing later.

Assertion `j <= i' failed.
util/overflow.h:44 T CheckedSub(const T, const U) [T = unsigned long, U = bool]: Assertion `j <= i' failed.
==72817== ERROR: libFuzzer: deadly signal
    #0 0x556e9225eab5 in __sanitizer_print_stack_trace (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x191dab5) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #1 0x556e921acafc in fuzzer::PrintStackTrace() (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x186bafc) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #2 0x556e92191bb7 in fuzzer::Fuzzer::CrashCallback() (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x1850bb7) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #3 0x7164cfc458cf  (/lib/x86_64-linux-gnu/libc.so.6+0x458cf) (BuildId: ae7440bbdce614e0e79280c3b2e45b1df44e639c)
    #4 0x7164cfca49bb in __pthread_kill_implementation nptl/pthread_kill.c:43:17
    #5 0x7164cfca49bb in __pthread_kill_internal nptl/pthread_kill.c:89:10
    #6 0x7164cfca49bb in pthread_kill nptl/pthread_kill.c:100:10
    #7 0x7164cfc4579d in raise signal/../sysdeps/posix/raise.c:26:13
    #8 0x7164cfc288cc in abort stdlib/abort.c:73:3
    #9 0x556e92f9d591 in assertion_fail(std::source_location const&, std::basic_string_view<char, std::char_traits<char>>) /mnt/my_storage/bitcoin/src/util/check.cpp:41:5
    #10 0x556e9250daf0 in bool&& inline_assertion_check<false, bool>(bool&&, std::source_location const&, std::basic_string_view<char, std::char_traits<char>>) /mnt/my_storage/bitcoin/src/util/check.h:90:13
    #11 0x556e9250daf0 in unsigned long CheckedSub<unsigned long, bool>(unsigned long, bool) /mnt/my_storage/bitcoin/src/util/overflow.h:44:5
    #12 0x556e9250daf0 in CoinsViewCacheCursor::NextAndMaybeErase(std::pair<COutPoint const, CCoinsCacheEntry>&) /mnt/my_storage/bitcoin/src/coins.h:282:25
    #13 0x556e92507eb2 in (anonymous namespace)::MutationGuardCoinsViewCache::BatchWrite(CoinsViewCacheCursor&, uint256 const&) /mnt/my_storage/bitcoin/src/test/fuzz/coins_view.cpp:90:75
    #14 0x556e92c17a2b in CCoinsViewCache::Flush(bool) /mnt/my_storage/bitcoin/src/coins.cpp:282:11
    #15 0x556e924fb732 in TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_1::operator()() const /mnt/my_storage/bitcoin/src/test/fuzz/coins_view.cpp:135:34
    #16 0x556e924fb732 in unsigned long CallOneOf<TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_0, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_1, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_2, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_3, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_4, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_5, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_6, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_7, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_8, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_9, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_10, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_11>(FuzzedDataProvider&, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_0, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_1, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_2, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_3, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_4, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_5, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_6, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_7, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_8, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_9, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_10, TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool)::$_11) /mnt/my_storage/bitcoin/src/test/fuzz/util.h:42:27
    #17 0x556e924fb732 in TestCoinsView(FuzzedDataProvider&, CCoinsViewCache&, CCoinsView&, bool) /mnt/my_storage/bitcoin/src/test/fuzz/coins_view.cpp:114:9
    #18 0x556e92503b0c in coins_view_overlay_fuzz_target(std::span<unsigned char const, 18446744073709551615ul>) /mnt/my_storage/bitcoin/src/test/fuzz/coins_view.cpp:404:5
    #19 0x556e92bcb7a5 in std::function<void (std::span<unsigned char const, 18446744073709551615ul>)>::operator()(std::span<unsigned char const, 18446744073709551615ul>) const /usr/lib/gcc/x86_64-linux-gnu/15/../../../../include/c++/15/bits/std_function.h:593:9
    #20 0x556e92bcb7a5 in test_one_input(std::span<unsigned char const, 18446744073709551615ul>) /mnt/my_storage/bitcoin/src/test/fuzz/fuzz.cpp:88:5
    #21 0x556e92bcb7a5 in LLVMFuzzerTestOneInput /mnt/my_storage/bitcoin/src/test/fuzz/fuzz.cpp:216:5
    #22 0x556e9219318f in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x185218f) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #23 0x556e92192799 in fuzzer::Fuzzer::RunOne(unsigned char const*, unsigned long, bool, fuzzer::InputInfo*, bool, bool*) (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x1851799) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #24 0x556e92194139 in fuzzer::Fuzzer::MutateAndTestOne() (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x1853139) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #25 0x556e92194c95 in fuzzer::Fuzzer::Loop(std::vector<fuzzer::SizedFile, std::allocator<fuzzer::SizedFile>>&) (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x1853c95) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #26 0x556e92181255 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x1840255) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #27 0x556e921ad696 in main (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x186c696) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)
    #28 0x7164cfc2a577 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #29 0x7164cfc2a63a in __libc_start_main csu/../csu/libc-start.c:360:3
    #30 0x556e921757e4 in _start (/mnt/my_storage/bitcoin/build_fuzz/bin/fuzz+0x18347e4) (BuildId: d77c4d5f9dfd38ea06fab463f49341735205e109)

NOTE: libFuzzer has rudimentary signal handlers.
      Combine libFuzzer with AddressSanitizer or similar for better crash reports.
SUMMARY: libFuzzer: deadly signal
MS: 2 PersAutoDict-CopyPart- DE: "\005\000"-; base unit: ecb626aff8724f0fdde38a0a6965718f2096d474
artifact_prefix='/tmp/fuzz_artifacts/'; Test unit written to /tmp/fuzz_artifacts/crash-1d19026c1a23f08bfe693fd684a56ce51187c6e5
./build_fuzz/bin/fuzz /tmp/fuzz_corpus/coins_view_overlay -max_total_time=3600 -rss_limit_mb=2560 -artifact_prefix=/tmp/fuzz_artifacts/ >fuzz-16.log 2>&1

The coins view fuzz targets can call AddCoin/AddCoins and construct BatchWrite cursors in ways that violate CCoinsViewCache caller contracts. These invalid states can trigger BatchWrite std::logic_error and can desync dirty-entry accounting (caught by Assume(m_dirty_count == 0) currently).

Make the fuzzer avoid generating invalid states instead of catching and resetting:

  • Derive AddCoin’s possible_overwrite from PeekCoin, so possible_overwrite=false is only used when the outpoint is absent - similarly to
    // Look up in simulation data (to know whether we must set possible_overwrite or not).
    auto sim = lookup(outpointidx);
    // Invoke on real caches.
    Coin coin = data.coins[coinidx];
    coin.nHeight = current_height;
    caches.back()->AddCoin(data.outpoints[outpointidx], std::move(coin), sim.has_value());
  • Only use AddCoins(check=false) when we have confirmed the txid has no unspent outputs; otherwise fall back to check=true so AddCoins determines overwrites via the view.
  • When constructing a CoinsViewCacheCursor, avoid setting FRESH when the parent already has an unspent coin, and ensure FRESH implies DIRTY.

Fuzzing

The original error could be reproduced in ~10 minutes using coins_view_overlay. I ran the coins_view, coins_view_db, coins_view_overlay, and coinscache_sim fuzzers for this PR overnight and they didn't fail anymore.

@DrahtBot
Copy link
Contributor

DrahtBot commented Feb 23, 2026

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

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK sipa, achow101, andrewtoth

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

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #34124 (refactor: make CCoinsView a pure virtual interface by l0rinc)

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

expected_code_path = true;
const bool check_for_overwrite{transaction.IsCoinBase() || fuzzed_data_provider.ConsumeBool() || [&] {
for (uint32_t i{0}; i < transaction.vout.size(); ++i) {
if (coins_view_cache.PeekCoin(COutPoint{transaction.GetHash(), i})) return true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's slightly unfortunate that this requires correctness of PeekCoin in order for the test to be meaningful; ideally the fuzzing harness itself knows whether it's a potential overwrite. However, since coinscache_sim already uses such an approach, this seems fine here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, added

// Look up in simulation data (to know whether we must set possible_overwrite or not).
auto sim = lookup(outpointidx);
// Invoke on real caches.
Coin coin = data.coins[coinidx];
coin.nHeight = current_height;
caches.back()->AddCoin(data.outpoints[outpointidx], std::move(coin), sim.has_value());
to the commit and PR description

l0rinc and others added 4 commits February 23, 2026 15:56
Introduce `TrySub(T&, U)` which subtracts an unsigned integral `U` from an unsigned integral `T`, returning `false` on underflow.
Use with `Assume(TrySub(...))` at coins cache accounting decrement sites so invariant violations fail immediately rather than silently wrapping.

Co-authored-by: MarcoFalke <*~=`'#}+{/-|&$^[email protected]>
Co-authored-by: Pieter Wuille <[email protected]>
In validation, `AddCoins(check_for_overwrite=false)` is only used after BIP30 has already ensured the transaction does not overwrite any unspent outputs in the UTXO view.
The coins view fuzz target can call `AddCoins` with arbitrary txids, so using the `check_for_overwrite=false` fast path on non-coinbase transactions may violate the `AddCoin` caller contract and trigger logic errors.
Only use `check_for_overwrite=false` when we have first confirmed that none of the outputs are currently unspent.
Otherwise, fall back to `check_for_overwrite=true` so `AddCoins` determines overwrites via the view.
The coins view fuzzer can call `AddCoin` with `possible_overwrite=false` for an outpoint that already exists unspent in the view, which violates the `AddCoin` caller contract.
Derive `possible_overwrite` from `PeekCoin` so `possible_overwrite=false` is only used when the outpoint is absent.
This matches the approach used by the `coinscache_sim` fuzzer, which derives the overwrite flag from simulated state.
Modify fuzzer logic to avoid setting `FRESH` for an outpoint that already exists unspent in the parent view, and ensure `FRESH` implies `DIRTY`.
This keeps cursor invariants realistic and lets `BatchWrite` failures expose real bugs without resetting state.
@l0rinc l0rinc force-pushed the l0rinc/coins_view_fuzzer_cleanup branch from a8e6c5f to 3281824 Compare February 23, 2026 14:58
Copy link
Member

@sipa sipa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 3281824. Ran the 4 relevant fuzz tests for ~1 CPU day each. Will run more overnight.

}

template <std::unsigned_integral T, std::unsigned_integral U>
[[nodiscard]] constexpr bool TrySub(T& i, const U j) noexcept
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nitty, but:

  • I think the name is slightly misleading, as there is no reason why subtracting would ever fail on an unsigned type, so it is not clear what is being "tried". The name should suggest that it avoids wraparound.
  • It's unfortunate that this has a runtime impact even outside fuzzing. It's obviously fine within the context of this PR (nothing in it is that performance critical), but in that context just Assume(x >= y); x -= y; is fine too. If we're introducing this as a new generic utility function that's usable anywhere, I think bool ret = i >= T{j}; i -= T{j}; return ret; is nicer, because in contexts where the return value is ignored, it is exactly equivalent to just a subtraction, and may be optimized to it.

Copy link
Contributor Author

@l0rinc l0rinc Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no reason why subtracting would ever fail on an unsigned type

TrySub is meant as "try to subtract without wraparound": if i < j, it leaves i unchanged (hence the Try prefix) and returns false, so callers can act.

It's unfortunate that this has a runtime impact even outside fuzzing

I think bool ret = i >= T{j}; i -= T{j}; return ret; is nicer, because in contexts where the return value is ignored, it is exactly equivalent to just a subtraction

Do we want to be exactly equivalent though?
The current cheap branching prevents the accounting counters from wrapping, so even if accounting is wrong elsewhere, the values stay closer to what we expect.


I have tried a few variants to see the effect of the change (plain subtraction vs return + internal Assume vs mutating TrySub + caller Assume with [[unlikely]]), see https://godbolt.org/z/nf9nT9Mjj

Details

Both compilers emit the same single subtract (plus the bool zero-extend in the bool case).
For example, GCC:

sub_return_size:
    sub     QWORD PTR [rdi], rsi
    ret

and for bool:

sub_return_bool:
    movzx   esi, sil
    sub     QWORD PTR [rdi], rsi
    ret

So return + internal Assume has zero codegen cost in non-abort builds, and adding [[unlikely]] doesn't seem to change anything there either.


Result 2: Only the mutating TrySub variant changes semantics (avoids wrap), and it necessarily adds a branch. [[unlikely]] didn't change codegen here either.
GCC (size_t case):

sub_trysub_size:
    mov     rax, QWORD PTR [rdi]
    cmp     rax, rsi
    jb      .Lskip
    sub     rax, rsi
    mov     QWORD PTR [rdi], rax
.Lskip:
    ret

clang emits the same idea, just as sub then jb then store:

sub_trysub_size:
    mov     rax, qword ptr [rdi]
    sub     rax, rsi
    jb      .Lskip
    mov     qword ptr [rdi], rax
.Lskip:
    ret

…and the sub_trysub_unlikely_* versions were identical to sub_trysub_* for both compilers (so [[unlikely]] doesn't seem to buy anything here either; the cold path is already the taken jump).

Note: I have experimented a bit with __builtin_sub_overflow(), it does change GCC codegen slighly to the same sub; jb shape clang already emits (removing the explicit cmp), but I’m not sure it’s an actual win, so I’ll leave it for a follow-up.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(this is all very nitty, and no reason to hold up this PR)

TrySub is meant as "try to subtract without wraparound": if i < j, it leaves i unchanged (hence the Try prefix) and returns false, so callers can act.

Yes, I understand what it does. My point is that this is not clear from the name "TrySub", because it doesn't suggest anything about avoiding wrapping (which is perfectly well-defined behavior on unsigned types).

Do we want to be exactly equivalent though?

If our assumptions are wrong, I don't think we care about what actually happens to the m_dirty_count value, it's going to be meaningless regardless. So I would suggest using whatever is fastest, and I'm guessing that x -= y; is faster than if (x >= y) x -= y;.

@achow101
Copy link
Member

ACK 3281824

Copy link
Contributor

@andrewtoth andrewtoth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 3281824

Fuzzed coins_view_overlay overnight with no issues.

}
// Avoid setting FRESH for an outpoint that already exists unspent in the parent view.
bool fresh{!coins_view_cache.PeekCoin(random_out_point) && fuzzed_data_provider.ConsumeBool()};
bool dirty{fresh || fuzzed_data_provider.ConsumeBool()};
Copy link
Contributor

@andrewtoth andrewtoth Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this is backwards. fresh is dependent on dirty, not vice versa. See 797fcc4.

I also don't think this change to dirty is relevant to the fix here. We only need to gate fresh on PeekCoin. The exception being thrown is not relevant to FRESH-but-not-DIRTY. It's actually reducing coverage here.

Copy link
Contributor Author

@l0rinc l0rinc Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have the capacity at the moment to react to this in detail.
Is this important to do it here, or do you think we can tackle it independently in #33018?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a blocker. The change here fixes the linked issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have addressed this in #34864

@achow101 achow101 added this to the 31.0 milestone Feb 24, 2026
@achow101 achow101 merged commit 76eb04b into bitcoin:master Feb 24, 2026
51 of 52 checks passed
@l0rinc l0rinc mentioned this pull request Mar 19, 2026
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

oss-fuzz: coins_view_overlay: ASSERT: m_dirty_count == 0

6 participants