ipc: Serialize null CTransactionRef as empty Data
What changed, and why it matters
This commit fixes a serialization edge case in Bitcoin Core's inter-process communication (IPC) layer. Previously, a null (empty) transaction reference could not be sent across the IPC boundary in a list of transactions because the Cap'n Proto protocol cannot distinguish between an empty data field and a null value. The fix treats empty transaction data as null, and adds a safety check so that submitting a mining solution with an empty coinbase is rejected instead of potentially causing a crash or undefined behavior.
No immediate action required beyond normal review and deployment. The change is defensive and improves IPC robustness. Users running the IPC mining interface should ensure they are on a version including this commit to avoid potential null-dereference or deserialization issues when submitting mining solutions.
Security signals we found
Null pointer dereference prevention in submitSolution
IPC serialization robustness improvement for nullable transaction references
Defensive input validation added to mining interface
Test coverage added for malformed/empty IPC mining inputs
Evidence from the diff
The patch adds a CustomHasField specialization for CTransaction in the IPC Cap’n Proto common-types.h, so that empty Data fields are interpreted as null CTransactionRef values. This enables std::vector
Changed components
src/ipc/capnp/common-types.hsrc/node/interfaces.cppsrc/ipc/test/ipc_test.capnpsrc/ipc/test/ipc_test.cppsrc/ipc/test/ipc_test.htest/functional/interface_ipc_mining.pyInspect captured patch +35 / −1
diff --git a/src/ipc/capnp/common-types.h b/src/ipc/capnp/common-types.h
index 309799b8..9b6fa464 100644
--- a/src/ipc/capnp/common-types.h
+++ b/src/ipc/capnp/common-types.h
@@ -127,6 +127,18 @@ decltype(auto) CustomReadField(TypeList<UniValue>, Priority<1>, InvokeContext& i
});
}
+//! Interpret empty Data fields as null CTransactionRef values. This is safe to
+//! do because no CTransaction is ever serialized as empty Data, and it is
+//! convenient because this allows std::vector<CTransactionRef> to be passed as
+//! List(Data) even if the vector contains null values, and even though Cap'n
+//! Proto does not (currently) allow distinguishing between null and empty Data
+//! values in a List. Interpreting empty Data values as null CTransactionRef
+//! values works well for this purpose.
+template <typename Input>
+bool CustomHasField(TypeList<CTransaction>, InvokeContext& invoke_context, const Input& input)
+{
+ return input.get().size() > 0;
+}
} // namespace mp
#endif // BITCOIN_IPC_CAPNP_COMMON_TYPES_H
diff --git a/src/ipc/test/ipc_test.capnp b/src/ipc/test/ipc_test.capnp
index adb92825..4aa196b6 100644
--- a/src/ipc/test/ipc_test.capnp
+++ b/src/ipc/test/ipc_test.capnp
@@ -18,6 +18,7 @@ interface FooInterface $Proxy.wrap("FooImplementation") {
passOutPoint @1 (arg :Data) -> (result :Data);
passUniValue @2 (arg :Text) -> (result :Text);
passTransaction @3 (arg :Data) -> (result :Data);
+ passTransactions @6 (arg :List(Data)) -> (result :List(Data));
passVectorChar @4 (arg :Data) -> (result :Data);
passScript @5 (arg :Data) -> (result :Data);
}
diff --git a/src/ipc/test/ipc_test.cpp b/src/ipc/test/ipc_test.cpp
index 46366cef..d5c68950 100644
--- a/src/ipc/test/ipc_test.cpp
+++ b/src/ipc/test/ipc_test.cpp
@@ -103,6 +103,14 @@ void IpcPipeTest()
CTransactionRef tx2{foo->passTransaction(tx1)};
BOOST_CHECK(*Assert(tx1) == *Assert(tx2));
+ std::vector<CTransactionRef> txs1;
+ txs1.push_back(tx1);
+ txs1.push_back(nullptr);
+ std::vector<CTransactionRef> txs2(foo->passTransactions(txs1));
+ BOOST_CHECK_EQUAL(txs2.size(), 2);
+ BOOST_CHECK(*Assert(txs1[0]) == *Assert(txs2[0]));
+ BOOST_CHECK(!txs2[1]);
+
std::vector<char> vec1{'H', 'e', 'l', 'l', 'o'};
std::vector<char> vec2{foo->passVectorChar(vec1)};
BOOST_CHECK_EQUAL(std::string_view(vec1.begin(), vec1.end()), std::string_view(vec2.begin(), vec2.end()));
diff --git a/src/ipc/test/ipc_test.h b/src/ipc/test/ipc_test.h
index 8ef3bc90..392f2b48 100644
--- a/src/ipc/test/ipc_test.h
+++ b/src/ipc/test/ipc_test.h
@@ -18,6 +18,7 @@ public:
COutPoint passOutPoint(COutPoint o) { return o; }
UniValue passUniValue(UniValue v) { return v; }
CTransactionRef passTransaction(CTransactionRef t) { return t; }
+ std::vector<CTransactionRef> passTransactions(std::vector<CTransactionRef> t) { return t; }
std::vector<char> passVectorChar(std::vector<char> v) { return v; }
BlockValidationState passBlockState(BlockValidationState s) { return s; }
CScript passScript(CScript s) { return s; }
diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp
index 2f68f414..641252d2 100644
--- a/src/node/interfaces.cpp
+++ b/src/node/interfaces.cpp
@@ -919,6 +919,7 @@ public:
bool submitSolution(uint32_t version, uint32_t timestamp, uint32_t nonce, CTransactionRef coinbase) override
{
+ if (!coinbase) return false;
AddMerkleRootAndCoinbase(m_block_template->block, std::move(coinbase), version, timestamp, nonce);
std::string reason;
std::string debug;
diff --git a/test/functional/interface_ipc_mining.py b/test/functional/interface_ipc_mining.py
index 4cd9c17c..b5a03280 100755
--- a/test/functional/interface_ipc_mining.py
+++ b/test/functional/interface_ipc_mining.py
@@ -511,9 +511,13 @@ class IPCMiningTest(BitcoinTestFramework):
# lets node 2 accept/reject complete blocks independently.
self.disconnect_nodes(1, 2)
+ self.log.debug("submitSolution should reject an empty coinbase")
+ submitted = (await template.submitSolution(ctx, 0, 0, 0, b"")).result
+ assert_equal(submitted, False)
+
self.log.debug("Submit solution that can't be deserialized")
try:
- await template.submitSolution(ctx, 0, 0, 0, b"")
+ await template.submitSolution(ctx, 0, 0, 0, b"\x00")
raise AssertionError("submitSolution unexpectedly succeeded")
except capnp.lib.capnp.KjException as e:
assert_capnp_failed(e, "remote exception: std::exception: SpanReader::read(): end of data:")
@@ -654,6 +658,13 @@ class IPCMiningTest(BitcoinTestFramework):
raise AssertionError("submitBlock unexpectedly succeeded")
except capnp.lib.capnp.KjException as e:
assert_capnp_failed(e, "remote exception: std::exception: SpanReader::read(): end of data:")
+
+ self.log.debug("Submit empty block data")
+ try:
+ await mining2.submitBlock(ctx2, b"")
+ raise AssertionError("submitBlock unexpectedly succeeded")
+ except capnp.lib.capnp.KjException as e:
+ assert_capnp_failed(e, "remote exception: std::exception: SpanReader::read(): end of data:")
assert_equal(self.nodes[2].is_node_stopped(), False)
asyncio.run(capnp.run(async_routine()))
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.