From 83f3bc002d08006996ab100313fa2ddcd6568a80 Mon Sep 17 00:00:00 2001 From: w0xlt <94266259+w0xlt@users.noreply.github.com> Date: Thu, 28 May 2026 13:06:39 -0700 Subject: [PATCH 1/4] mining: clarify SubmitBlock result handling Make the submitBlock return value explicit and check that it stays consistent with the BIP22 reason string, so future changes do not return success with a reason or failure without one. Report "inconclusive" when no specific block rejection reason is available. This covers blocks accepted without being connected, and processing failures where ProcessNewBlock returns false without an invalid BlockChecked result, for example when ActivateBestChain fails after BlockChecked reported a valid block. Also document why no validation-interface queue drain is needed before unregistering: BlockChecked is emitted synchronously by ProcessNewBlock, unlike most validation signals. --- src/node/interfaces.cpp | 4 ++- src/node/miner.cpp | 15 +++++++---- test/functional/interface_ipc_mining.py | 36 ++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 2f68f414f0d..6747dd60aaa 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -1031,7 +1031,9 @@ public: // ProcessNewBlock() can accept and store a block before it is checked // for validity. Treat duplicates as errors for mining clients, and only // return success when validation completed without setting a reason. - return accepted && new_block && reason.empty(); + const bool result{accepted && new_block && reason.empty()}; + CHECK_NONFATAL(result == reason.empty()); + return result; } const NodeContext* context() override { return &m_node; } diff --git a/src/node/miner.cpp b/src/node/miner.cpp index ccd9cc7c57a..66ae870f844 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -396,16 +396,21 @@ bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptr(block->GetHash()); CHECK_NONFATAL(chainman.m_options.signals)->RegisterSharedValidationInterface(sc); bool accepted = chainman.ProcessNewBlock(block, /*force_processing=*/true, /*min_pow_checked=*/true, /*new_block=*/new_block); + // No queue drain is needed. The BlockChecked notification used above is + // emitted synchronously by ProcessNewBlock, unlike most validation signals. CHECK_NONFATAL(chainman.m_options.signals)->UnregisterSharedValidationInterface(sc); if (new_block && !*new_block && accepted) { reason = "duplicate"; + } else if (!accepted && (!sc->m_found || sc->m_state.IsValid())) { + // ProcessNewBlock can fail without a validation result, for example + // from an activation or system error. It can also fail after a valid + // BlockChecked result. In these cases the validation result is + // inconclusive. + reason = "inconclusive"; } else if (!sc->m_found) { - // A block can be accepted and stored without being connected, for - // example if it does not have more work than the current tip. In that - // case no BlockChecked callback is emitted, so the validation result is - // inconclusive. Mining::submitBlock treats this as an error for mining - // clients, but it does not mean the block is invalid. + // The block was accepted but not connected, for example if it does not + // have more work than the current tip. reason = "inconclusive"; } else if (!sc->m_state.IsValid()) { reason = sc->m_state.GetRejectReason(); diff --git a/test/functional/interface_ipc_mining.py b/test/functional/interface_ipc_mining.py index 4cd9c17c99f..8d2ee85964c 100755 --- a/test/functional/interface_ipc_mining.py +++ b/test/functional/interface_ipc_mining.py @@ -111,10 +111,15 @@ class IPCMiningTest(BitcoinTestFramework): coinbase_tx.nLockTime = coinbase_res.lockTime return coinbase_tx - async def build_candidate_block(self, template, ctx): - """Build a complete block from a remote BlockTemplate.""" + async def build_candidate_block(self, template, ctx, extra_nonce=b""): + """Build a complete block from a remote BlockTemplate. + + The returned block replaces the dummy coinbase from CreateNewBlock() + with one constructed from getCoinbaseTx(). + """ block = await mining_get_block(template, ctx) - coinbase = await self.build_coinbase_test(template, ctx, self.miniwallet) + coinbase = await self.build_coinbase_test( + template, ctx, self.miniwallet, extra_nonce=extra_nonce) # Reduce payout for balance comparison simplicity. coinbase.vout[0].nValue = COIN block.vtx[0] = coinbase @@ -623,6 +628,31 @@ class IPCMiningTest(BitcoinTestFramework): assert_equal(submitted, True) self.sync_all() + self.log.debug("submitBlock should report inconclusive for a valid stale block") + async with AsyncExitStack() as stack: + active_template = await mining_create_block_template( + mining2, stack, ctx2, self.default_block_create_options) + submit_block_template = await mining_create_block_template( + mining2, stack, ctx2, self.default_block_create_options) + assert active_template is not None + assert submit_block_template is not None + + active_block = await self.build_candidate_block( + active_template, ctx2, extra_nonce=b"\x01") + submit_block = await self.build_candidate_block( + submit_block_template, ctx2, extra_nonce=b"\x02") + active_block.solve() + submit_block.solve() + + # Both templates share a parent. The first block becomes active, + # so the remaining valid block is accepted as stale. + await self.assert_submit_block( + mining2, ctx2, active_block, result=True) + + await self.assert_submit_block( + mining2, ctx2, submit_block, result=False, reason="inconclusive") + self.sync_all() + self.log.debug("Submit the same invalid block twice") async with destroying((await mining2.createNewBlock(ctx2, self.default_block_create_options)).result, ctx2) as template2: invalid_block = await self.build_candidate_block(template2, ctx2) From cbaa1696f3748c98466152a17687042f59de684f Mon Sep 17 00:00:00 2001 From: w0xlt <94266259+w0xlt@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:24:29 -0800 Subject: [PATCH 2/4] mining: add reason and debug output to submitSolution Add reason and debug output parameters to submitSolution, matching submitBlock. This relays the specific failure reason (e.g. "bad-version(...)", "bad-witness-nonce-size", "duplicate") to callers instead of just a bool. Use a new capnp ordinal for the updated method and keep the old @7 method as a deprecated entry point returning an explicit error, so old clients do not decode corrupt result fields and are directed to update. --- src/interfaces/mining.h | 18 +++++--- src/ipc/capnp/mining.capnp | 5 +- src/node/interfaces.cpp | 8 ++-- src/test/miner_tests.cpp | 13 ++++-- test/functional/interface_ipc_mining.py | 61 ++++++++++++++++++------- 5 files changed, 75 insertions(+), 30 deletions(-) diff --git a/src/interfaces/mining.h b/src/interfaces/mining.h index ff4f87109f9..1ed224e3051 100644 --- a/src/interfaces/mining.h +++ b/src/interfaces/mining.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -59,6 +60,8 @@ public: * @param[in] timestamp time block header field (unix timestamp) * @param[in] nonce nonce block header field * @param[in] coinbase complete coinbase transaction (including witness) + * @param[out] reason failure reason (BIP22) + * @param[out] debug more detailed rejection reason * * @note Unlike the submitblock RPC, this method does not call * UpdateUncommittedBlockStructures to add a missing coinbase witness @@ -69,13 +72,16 @@ public: * is only one byte long, so the coinbase scriptSig needs at least * one additional byte of data to avoid bad-cb-length. * - * @returns if the block was processed, does not necessarily indicate validity. - * - * @note Returns true if the block is already known, which can happen if - * the solved block is constructed and broadcast by multiple nodes - * (e.g. both the miner who constructed the template and the pool). + * @returns true if the block was accepted as a new block */ - virtual bool submitSolution(uint32_t version, uint32_t timestamp, uint32_t nonce, CTransactionRef coinbase) = 0; + virtual bool submitSolution(uint32_t version, uint32_t timestamp, uint32_t nonce, CTransactionRef coinbase, std::string& reason, std::string& debug) = 0; + + //! Deprecated older method preserved to return an explicit error for IPC + //! clients using mining.capnp @7. + virtual bool submitSolutionOld7(uint32_t, uint32_t, uint32_t, CTransactionRef) + { + throw std::runtime_error("Old submitSolution (@7) not supported. Please update your client!"); + } /** * Waits for fees in the next block to rise, a new tip or the timeout. diff --git a/src/ipc/capnp/mining.capnp b/src/ipc/capnp/mining.capnp index a6dd8d71f31..f2aee662e03 100644 --- a/src/ipc/capnp/mining.capnp +++ b/src/ipc/capnp/mining.capnp @@ -36,9 +36,12 @@ interface BlockTemplate $Proxy.wrap("interfaces::BlockTemplate") { getTxSigops @4 (context: Proxy.Context) -> (result: List(Int64)); getCoinbaseTx @5 (context: Proxy.Context) -> (result: CoinbaseTx); getCoinbaseMerklePath @6 (context: Proxy.Context) -> (result: List(Data)); - submitSolution @7 (context: Proxy.Context, version: UInt32, timestamp: UInt32, nonce: UInt32, coinbase :Data) -> (result: Bool); + submitSolution @10 (context: Proxy.Context, version: UInt32, timestamp: UInt32, nonce: UInt32, coinbase :Data) -> (reason: Text, debug: Text, result: Bool); waitNext @8 (context: Proxy.Context, options: BlockWaitOptions) -> (result: BlockTemplate); interruptWait @9() -> (); + + # DEPRECATED: older version of submitSolution which returns an error. + submitSolutionOld7 @7 (context: Proxy.Context, version: UInt32, timestamp: UInt32, nonce: UInt32, coinbase :Data) -> (result: Bool); } struct BlockCreateOptions $Proxy.wrap("node::BlockCreateOptions") { diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 6747dd60aaa..0699ff8b2f1 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -917,12 +917,12 @@ public: return TransactionMerklePath(m_block_template->block, 0); } - bool submitSolution(uint32_t version, uint32_t timestamp, uint32_t nonce, CTransactionRef coinbase) override + bool submitSolution(uint32_t version, uint32_t timestamp, uint32_t nonce, CTransactionRef coinbase, std::string& reason, std::string& debug) override { AddMerkleRootAndCoinbase(m_block_template->block, std::move(coinbase), version, timestamp, nonce); - std::string reason; - std::string debug; - return SubmitBlock(chainman(), std::make_shared(m_block_template->block), /*new_block=*/nullptr, reason, debug); + bool new_block; + const bool accepted = SubmitBlock(chainman(), std::make_shared(m_block_template->block), &new_block, reason, debug); + return accepted && new_block && reason.empty(); } std::unique_ptr waitNext(BlockWaitOptions options) override diff --git a/src/test/miner_tests.cpp b/src/test/miner_tests.cpp index fd9559b543c..51fe5699403 100644 --- a/src/test/miner_tests.cpp +++ b/src/test/miner_tests.cpp @@ -860,9 +860,9 @@ BOOST_AUTO_TEST_CASE(CreateNewBlock_validity) } // Alternate calls between submitBlock and submitSolution via the // Mining interface. + std::string reason{"stale reason"}; + std::string debug{"stale debug"}; if (current_height % 2 == 0) { - std::string reason{"stale reason"}; - std::string debug{"stale debug"}; BOOST_REQUIRE(mining->submitBlock(block, reason, debug)); BOOST_REQUIRE_EQUAL(reason, ""); BOOST_REQUIRE_EQUAL(debug, ""); @@ -873,7 +873,14 @@ BOOST_AUTO_TEST_CASE(CreateNewBlock_validity) BOOST_REQUIRE_EQUAL(reason, "duplicate"); BOOST_REQUIRE_EQUAL(debug, ""); } else { - BOOST_REQUIRE(block_template->submitSolution(block.nVersion, block.nTime, block.nNonce, MakeTransactionRef(txCoinbase))); + reason = "stale reason"; + debug = "stale debug"; + BOOST_REQUIRE(block_template->submitSolution(block.nVersion, block.nTime, block.nNonce, MakeTransactionRef(txCoinbase), reason, debug)); + BOOST_REQUIRE_EQUAL(reason, ""); + BOOST_REQUIRE_EQUAL(debug, ""); + BOOST_CHECK_THROW(block_template->submitSolutionOld7(block.nVersion, block.nTime, block.nNonce, + MakeTransactionRef(txCoinbase)), + std::runtime_error); } { LOCK(cs_main); diff --git a/test/functional/interface_ipc_mining.py b/test/functional/interface_ipc_mining.py index 8d2ee85964c..89d91c96ef5 100755 --- a/test/functional/interface_ipc_mining.py +++ b/test/functional/interface_ipc_mining.py @@ -531,8 +531,10 @@ class IPCMiningTest(BitcoinTestFramework): assert_equal(check.reason, "bad-version(0x00000000)") assert_equal(check.debug, "rejected nVersion=0x00000000 block") self.log.debug("submitSolution should reject a bad-version block") - submitted = (await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize())).result - assert_equal(submitted, False) + result = await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize()) + assert_equal(result.result, False) + assert_equal(result.reason, "bad-version(0x00000000)") + assert_equal(result.debug, "rejected nVersion=0x00000000 block") self.log.debug("submitBlock should reject a bad-version block") await self.assert_submit_block( mining2, @@ -565,8 +567,10 @@ class IPCMiningTest(BitcoinTestFramework): missing_witness_block.hashMerkleRoot = missing_witness_block.calc_merkle_root() missing_witness_block.solve() self.log.debug("submitSolution should reject a coinbase missing witness") - submitted = (await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize_without_witness())).result - assert_equal(submitted, False) + result = await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize_without_witness()) + assert_equal(result.result, False) + assert_equal(result.reason, "bad-witness-nonce-size") + assert_equal(result.debug, "CheckWitnessMalleation : invalid witness reserved value size") self.log.debug("Even a rejected submitSolution() mutates the template's block") # Can be used by clients to download and inspect the (rejected) @@ -585,8 +589,10 @@ class IPCMiningTest(BitcoinTestFramework): ) self.log.debug("Submit again, with the witness") - submitted = (await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize())).result - assert_equal(submitted, True) + result = await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize()) + assert_equal(result.result, True) + assert_equal(result.reason, "") + assert_equal(result.debug, "") self.log.debug("Submit a valid complete block through the disconnected node") await self.assert_submit_block(mining2, ctx2, block, result=True) @@ -615,7 +621,7 @@ class IPCMiningTest(BitcoinTestFramework): self.log.debug("submitBlock on the same node should fail with duplicate after submitSolution succeeds") await self.assert_submit_block(mining, ctx, block, result=False, reason="duplicate") - self.log.debug("submitSolution should still return True for a duplicate after submitBlock succeeds") + self.log.debug("submitSolution should return duplicate after submitBlock succeeds") async with destroying((await mining2.createNewBlock(ctx2, self.default_block_create_options)).result, ctx2) as template2: duplicate_block = await self.build_candidate_block(template2, ctx2) duplicate_coinbase = duplicate_block.vtx[0] @@ -623,32 +629,53 @@ class IPCMiningTest(BitcoinTestFramework): self.log.debug("Submit a valid complete block before duplicate submitSolution") await self.assert_submit_block(mining2, ctx2, duplicate_block, result=True) self.nodes[2].waitforblockheight(current_block_height + 2) - self.log.debug("submitSolution should accept the duplicate block") - submitted = (await template2.submitSolution(ctx2, duplicate_block.nVersion, duplicate_block.nTime, duplicate_block.nNonce, duplicate_coinbase.serialize())).result - assert_equal(submitted, True) + self.log.debug("submitSolution should reject the duplicate block") + result = await template2.submitSolution(ctx2, duplicate_block.nVersion, duplicate_block.nTime, duplicate_block.nNonce, duplicate_coinbase.serialize()) + assert_equal(result.result, False) + assert_equal(result.reason, "duplicate") + assert_equal(result.debug, "") self.sync_all() - self.log.debug("submitBlock should report inconclusive for a valid stale block") + self.log.debug( + "submitSolution and submitBlock should report inconclusive for valid stale blocks") async with AsyncExitStack() as stack: active_template = await mining_create_block_template( mining2, stack, ctx2, self.default_block_create_options) + solution_template = await mining_create_block_template( + mining2, stack, ctx2, self.default_block_create_options) submit_block_template = await mining_create_block_template( mining2, stack, ctx2, self.default_block_create_options) assert active_template is not None + assert solution_template is not None assert submit_block_template is not None active_block = await self.build_candidate_block( active_template, ctx2, extra_nonce=b"\x01") + solution_block = await self.build_candidate_block( + solution_template, ctx2, extra_nonce=b"\x02") submit_block = await self.build_candidate_block( - submit_block_template, ctx2, extra_nonce=b"\x02") + submit_block_template, ctx2, extra_nonce=b"\x03") active_block.solve() + solution_block.solve() submit_block.solve() - # Both templates share a parent. The first block becomes active, - # so the remaining valid block is accepted as stale. + # All three templates share a parent. The first block becomes + # active, so the remaining valid blocks are accepted as stale. await self.assert_submit_block( mining2, ctx2, active_block, result=True) + solution_coinbase = solution_block.vtx[0] + result = await solution_template.submitSolution( + ctx2, + solution_block.nVersion, + solution_block.nTime, + solution_block.nNonce, + solution_coinbase.serialize(), + ) + assert_equal(result.result, False) + assert_equal(result.reason, "inconclusive") + assert_equal(result.debug, "") + await self.assert_submit_block( mining2, ctx2, submit_block, result=False, reason="inconclusive") self.sync_all() @@ -724,8 +751,10 @@ class IPCMiningTest(BitcoinTestFramework): block.vtx[0] = coinbase block.hashMerkleRoot = block.calc_merkle_root() block.solve() - submitted = (await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize())).result - assert_equal(submitted, True) + result = await template.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize()) + assert_equal(result.result, True) + assert_equal(result.reason, "") + assert_equal(result.debug, "") assert_equal(node.getblockcount(), height) asyncio.run(capnp.run(async_routine())) From ed75d70fdb82042c9f44f3bd51b33c8b415f50aa Mon Sep 17 00:00:00 2001 From: w0xlt <94266259+w0xlt@users.noreply.github.com> Date: Thu, 28 May 2026 11:24:28 -0700 Subject: [PATCH 3/4] refactor: centralize SubmitBlock result handling Move the accepted/new-block/reason consistency check into SubmitBlock() so submitBlock() and submitSolution() use the same success criteria. This keeps duplicate and inconclusive handling in one place, removes the new_block output parameter from the helper, and makes the helper return whether the submitted block was accepted as a new valid block. --- src/node/interfaces.cpp | 14 ++------------ src/node/miner.cpp | 14 ++++++++------ src/node/miner.h | 4 ++-- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 0699ff8b2f1..feaa9a9d2bf 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -920,9 +920,7 @@ public: bool submitSolution(uint32_t version, uint32_t timestamp, uint32_t nonce, CTransactionRef coinbase, std::string& reason, std::string& debug) override { AddMerkleRootAndCoinbase(m_block_template->block, std::move(coinbase), version, timestamp, nonce); - bool new_block; - const bool accepted = SubmitBlock(chainman(), std::make_shared(m_block_template->block), &new_block, reason, debug); - return accepted && new_block && reason.empty(); + return SubmitBlock(chainman(), std::make_shared(m_block_template->block), reason, debug); } std::unique_ptr waitNext(BlockWaitOptions options) override @@ -1025,15 +1023,7 @@ public: bool submitBlock(const CBlock& block_in, std::string& reason, std::string& debug) override { - auto block = std::make_shared(block_in); - bool new_block; - const bool accepted = SubmitBlock(chainman(), block, &new_block, reason, debug); - // ProcessNewBlock() can accept and store a block before it is checked - // for validity. Treat duplicates as errors for mining clients, and only - // return success when validation completed without setting a reason. - const bool result{accepted && new_block && reason.empty()}; - CHECK_NONFATAL(result == reason.empty()); - return result; + return SubmitBlock(chainman(), std::make_shared(block_in), reason, debug); } const NodeContext* context() override { return &m_node; } diff --git a/src/node/miner.cpp b/src/node/miner.cpp index 66ae870f844..40e8f524e31 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -381,7 +381,7 @@ protected: }; } // namespace -bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptr& block, bool* new_block, std::string& reason, std::string& debug) +bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptr& block, std::string& reason, std::string& debug) { reason.clear(); debug.clear(); @@ -391,16 +391,16 @@ bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptr(block->GetHash()); CHECK_NONFATAL(chainman.m_options.signals)->RegisterSharedValidationInterface(sc); - bool accepted = chainman.ProcessNewBlock(block, /*force_processing=*/true, /*min_pow_checked=*/true, /*new_block=*/new_block); + bool new_block; + bool accepted = chainman.ProcessNewBlock(block, /*force_processing=*/true, /*min_pow_checked=*/true, /*new_block=*/&new_block); // No queue drain is needed. The BlockChecked notification used above is // emitted synchronously by ProcessNewBlock, unlike most validation signals. CHECK_NONFATAL(chainman.m_options.signals)->UnregisterSharedValidationInterface(sc); - if (new_block && !*new_block && accepted) { + if (!new_block && accepted) { reason = "duplicate"; } else if (!accepted && (!sc->m_found || sc->m_state.IsValid())) { // ProcessNewBlock can fail without a validation result, for example @@ -416,7 +416,9 @@ bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptrm_state.GetRejectReason(); debug = sc->m_state.GetDebugMessage(); } - return accepted; + const bool result{accepted && new_block && reason.empty()}; + CHECK_NONFATAL(result == reason.empty()); + return result; } void InterruptWait(KernelNotifications& kernel_notifications, bool& interrupt_wait) diff --git a/src/node/miner.h b/src/node/miner.h index af327307329..7b5702ac594 100644 --- a/src/node/miner.h +++ b/src/node/miner.h @@ -131,8 +131,8 @@ void RegenerateCommitments(CBlock& block, ChainstateManager& chainman); void AddMerkleRootAndCoinbase(CBlock& block, CTransactionRef coinbase, uint32_t version, uint32_t timestamp, uint32_t nonce); //! Submit a block and capture the validation state via the BlockChecked callback. -//! Returns whether ProcessNewBlock accepted the block. -bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptr& block, bool* new_block, std::string& reason, std::string& debug); +//! Returns whether the block was accepted as a new valid block. +bool SubmitBlock(ChainstateManager& chainman, const std::shared_ptr& block, std::string& reason, std::string& debug); /* Interrupt a blocking call. */ void InterruptWait(KernelNotifications& kernel_notifications, bool& interrupt_wait); From 75929b11edb4a59755b38788855b41b63ff4113d Mon Sep 17 00:00:00 2001 From: woltx <94266259+w0xlt@users.noreply.github.com> Date: Fri, 29 May 2026 09:47:28 -0700 Subject: [PATCH 4/4] doc: add release note for submitSolution IPC changes --- doc/release-notes-34672.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 doc/release-notes-34672.md diff --git a/doc/release-notes-34672.md b/doc/release-notes-34672.md new file mode 100644 index 00000000000..060d91428f0 --- /dev/null +++ b/doc/release-notes-34672.md @@ -0,0 +1,11 @@ +IPC Interface +------------- + +- `BlockTemplate.submitSolution` now returns `reason` and `debug` rejection + details in addition to the boolean result. Clients must regenerate IPC + bindings from the updated `mining.capnp` schema to use the new method. The + previous `@7` method now returns an error directing clients to update. (#34672) + +- `BlockTemplate.submitSolution` now reports duplicate blocks as failures with + `reason="duplicate"`, matching `Mining.submitBlock`, instead of returning + success for duplicate submissions. (#34672)