Bug 42
Mining client that leaves block options unset crashes the node
- Status
- Fixed
- Symptom
- Assertion failure; worst effect seen: assertion failure
- Where seen
- Bitcoin Core functional tests
- Trigger
- A client that does not use libmultiprocess, such as a Python or Rust client
- First reported
- 2025-08-19 (first report)
- Fixed
- 2025-08-21
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
bitcoin-node aborted on an Assert when an IPC client called Mining.createNewBlock with BlockCreateOptions fields out of range, including a client that left the fields at their Cap'n Proto zero defaults. Any non-C++ client (Python, Rust) that did not set every field could crash the node. Fixed by bitcoin#33222 ("miner: clamp options instead of asserting"), which clamps the values instead, before v30.0 shipped bitcoin-node.
What happened
@sipa reported in the bitcoin#33222 description that "calling Mining::createNewBlock with a default-constructed BlockCreateOptions will right now instantly crash the bitcoin node", and called it "unergonomical to have the node crash while developing against the interface". No CI or user crash was posted.
A default-constructed C++ BlockCreateOptions has valid values, so the crashing struct was the Cap'n Proto one with every field zero, which is what pycapnp's BlockCreateOptions() produces. sipa had opened bitcoin#33201 ("Add functional test for IPC interface"), the first Python (pycapnp) IPC functional test, four days earlier, and its final version sets all three option fields explicitly. The crash was most likely hit while writing that test; the PR does not say so directly.
Root cause
ClampOptions() in src/node/miner.cpp checked client-supplied options with asserts:
Assert(options.block_reserved_weight <= MAX_BLOCK_WEIGHT)Assert(options.block_reserved_weight >= MINIMUM_BLOCK_RESERVED_WEIGHT)Assert(options.coinbase_output_max_additional_sigops <= MAX_BLOCK_SIGOPS_COST)
These held for every in-process caller, which built the options from validated -blockreservedweight settings, and for C++ IPC clients, whose BlockCreateOptions initializers (src/node/types.h) fill every field with valid defaults. Over IPC the struct comes from the client, and the BlockCreateOptions fields in mining.capnp have no defaults, so a client that leaves them unset sends blockReservedWeight = 0. That is below the minimum, and the lower-bound Assert aborts the node.
The code
Quoted from 7392b8b084be^. The BlockAssembler constructor passes the caller's options through ClampOptions in src/node/miner.cpp, which asserts that they are in range:
static BlockAssembler::Options ClampOptions(BlockAssembler::Options options)
{
Assert(options.block_reserved_weight <= MAX_BLOCK_WEIGHT);
Assert(options.block_reserved_weight >= MINIMUM_BLOCK_RESERVED_WEIGHT);
Assert(options.coinbase_output_max_additional_sigops <= MAX_BLOCK_SIGOPS_COST);
In C++ the fields start from valid defaults (src/node/types.h):
size_t block_reserved_weight{DEFAULT_BLOCK_RESERVED_WEIGHT};
// ...
size_t coinbase_output_max_additional_sigops{400};
but over IPC the values come from the client, and the schema in src/ipc/capnp/mining.capnp gives the fields no defaults:
struct BlockCreateOptions $Proxy.wrap("node::BlockCreateOptions") {
useMempool @0 :Bool $Proxy.name("use_mempool");
blockReservedWeight @1 :UInt64 $Proxy.name("block_reserved_weight");
coinbaseOutputMaxAdditionalSigops @2 :UInt64 $Proxy.name("coinbase_output_max_additional_sigops");
}
A Python or Rust client that builds BlockCreateOptions without setting blockReservedWeight sends 0, which is below MINIMUM_BLOCK_RESERVED_WEIGHT. Assert is checked in release builds too, so createNewBlock aborts bitcoin-node. An out-of-range value set on purpose does the same.
The fix clamps both fields into range instead (7392b8b084be):
@@ -77,9 +77,8 @@ void RegenerateCommitments(CBlock& block, ChainstateManager& chainman)
static BlockAssembler::Options ClampOptions(BlockAssembler::Options options)
{
- Assert(options.block_reserved_weight <= MAX_BLOCK_WEIGHT);
- Assert(options.block_reserved_weight >= MINIMUM_BLOCK_RESERVED_WEIGHT);
- Assert(options.coinbase_output_max_additional_sigops <= MAX_BLOCK_SIGOPS_COST);
+ options.block_reserved_weight = std::clamp<size_t>(options.block_reserved_weight, MINIMUM_BLOCK_RESERVED_WEIGHT, MAX_BLOCK_WEIGHT);
+ options.coinbase_output_max_additional_sigops = std::clamp<size_t>(options.coinbase_output_max_additional_sigops, 0, MAX_BLOCK_SIGOPS_COST);
Any value a client sends is now accepted and moved to the nearest valid one, so client input can no longer reach an assertion; an unset reserved weight becomes the minimum rather than the default.
Fix
Commit 7392b8b084be replaces the asserts with std::clamp on both fields (+2/-3). Reviewers (ryanofsky, @Sjors, @stickies-v) would have preferred returning an error to the caller and discussed setting sentinel defaults in the schema (ryanofsky's comment, Sjors's comment); clamping was accepted as "much better than crashing".
Timeline
- 2024-07-18: the first assert on client-supplied options (sigops upper bound) added by commit c504b6997b1a ("refactor: add coinbase constraints to BlockCreateOptions"), merged in bitcoin#30356.
- 2024-09-25: the Mining interface becomes reachable over IPC through bitcoin#30510, which added its IPC wrapper. At first only C++ clients built from the same headers used it.
- 2025-02-10: the reserved-weight asserts, including the lower bound that a zeroed struct trips (commit 3eaa0a3b6637, "miner: init: add
-blockreservedweightstartup option"), merged in bitcoin#31384, which fixed a duplicate coinbase weight reservation. - 2025-08-15: bitcoin#33201 (pycapnp IPC test) opened.
- 2025-08-19: bitcoin#33222 opened with the report; 2025-08-21: merged.
- Releases: v29.0 has the bug in source builds with IPC enabled; v30.0, the first release to ship
bitcoin-node, has the fix.
Lessons
What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.
Pattern An in-process interface trusting what now comes from another process: An assertion on what is now input from a peer
- Prevented by a design rule, a review checklist item
- Earliest detector a test with a non-C++ client (did not exist then)
- Diagnosis easy
- Rust no
Would Rust have prevented it? No. An assert! on a field received from the wire panics in Rust just as Assert aborts in C++; treating peer input as already validated is a contract mistake, not a memory one. More on Rust
Could it have been prevented?
Yes. ClampOptions() asserted ranges on BlockCreateOptions that were preconditions
when every caller was in-process C++ building the struct from validated settings. The
moment bitcoin#30510 put Mining over IPC (2024-09-25) those same fields became
untrusted input from a peer that may not even share the C++ defaults, and an Assert
on untrusted input is a remote abort. The fix is the rule: at an interface that can be
reached over the wire, argument checks are validation (clamp or return an error), never
assertions. This was knowable when the wrapper was added, and the review checklist
item is mechanical: for each method exposed over IPC, list every Assert/Assume
reachable from its arguments. The reviewers' preferred shape, returning an error or
giving the schema sentinel defaults, was not taken; clamping silently changes an unset
weight to the minimum rather than the C++ default, which is the residue of the choice.
Could it have been found sooner?
Yes, by any client that does not share the C++ headers. The C++ client's struct initializers filled every field, so the C++ functional tests could not send a zero, and no Python or Rust client existed against master until sipa's pycapnp test (bitcoin#33201, 2025-08-15) found it within days, 397 days after the first assert and 190 after the lower-bound one that a zeroed struct trips. A fuzzer at the IPC boundary (fuzzamoto found the malformed-argument sibling, record 57, a month later) would also have hit it on its first zero.
What made it hard to debug?
Nothing. The assert is deterministic and names the failed expression; the PR that reported it was the fix, two days from open to merge. The only work was deciding what to do instead of asserting, and that discussion (error vs clamp vs schema defaults) is the part still worth having.
Lesson
When an in-process interface is exposed to another process, every precondition on its arguments turns into input validation; audit the asserts on the day the wrapper lands, and test with a client that does not share your headers, because your own client will always send what you expect.
Notes
- The case became realistic when bitcoin#31802 (merged 2025-08-20) added
bitcoin-nodeto the release binaries for v30.0, and outside implementers started writing clients from the.capnpfile.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | assert |
| Worst effect observed | assert |
| Seen in | functional-test |
| Scope | production |
| Trigger | Non-C++ client (non-cpp-client) |
| Phase | steady-state |
| Side | server |
| Component | bitcoin-mining |
| Root cause kind | assumption-violated |
| Fix kind | one-line-tweak |
| Detection | new-test |
| Needs a peer that is not libmultiprocess C++ | yes |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |