Deserialization
Published 13 Jun 2026At first glance, deserialization appears to mirror serialization almost exactly.
A Reader concept naturally complements Writer, a type-erased reader_ref
serves the same purpose as writer_ref, decoder utilities compose in the same
way as encoding utilities, and only the top-level parse_block and
parse_transaction entry points need to be exposed as part of the public
interface.
And because reader_ref introduces an indirect function call on every read
operation, a buffered reader seems like the obvious counterpart to the buffered
writer used during serialization. But while a buffered writer accumulates bytes
locally and defers emission until a large enough chunk is ready, a buffered
reader must eagerly acquire bytes before any consumer requests them.
This requires a primitive that treats partial reads as normal. Bitcoin Core's current reader implementations instead model reads as either fully satisfied or failed via exceptions. That model works when every read is assumed to be exact and immediately fatal on mismatch, but it is fundamentally incompatible with buffering.
Aside: Invalid input is not exceptional. Serialization typically fails because of I/O errors or resource exhaustion. Deserialization, on the other hand, routinely operates on untrusted network input. Truncated or malformed byte sequences are expected and should therefore not be modeled using exceptions.
template <typename R>
concept Reader = requires(R& r, std::span<std::byte> out) {
{ r.read_some(out) } -> std::same_as<std::size_t>;
};
The contract is intentionally tiny:
read_some(out)returns a value in[0, out.size()]- returning fewer bytes than requested is normal
- returning
0indicates end-of-stream
This primitive allows to be wrapped in a buffered reader:
template <Reader R, std::size_t S>
class buffered_reader
{
public:
std::size_t read_some(std::span<std::byte> out)
{
std::size_t total = 0;
while (!out.empty()) {
if (pos == size) {
size = r.read_some(buffer);
pos = 0;
if (size == 0) {
break;
}
}
auto n = std::min(out.size(), size - pos);
std::memcpy(out.data(), buffer.data() + pos, n);
out = out.subspan(n);
pos += n;
total += n;
}
return total;
}
private:
R& r;
std::array<std::byte, S> buffer;
std::size_t pos = 0;
std::size_t size = 0;
};
Decoders have different requirements. A decoder for std::uint32_t cannot do
anything useful with only three bytes. Partial reads are therefore handled by a
decoder_context, which presents an exact-read interface while maintaining a
failure state.
template <Reader R>
class decoder_context
{
public:
bool good() const
{
return !failed;
}
void fail()
{
failed = true;
}
void read(std::span<std::byte> out)
{
while (!out.empty() && good()) {
auto n = r.read_some(out);
if (n == 0) {
fail();
return;
}
out = out.subspan(n);
}
}
private:
R& r;
bool failed = false;
};
As with serialization, the primitive building blocks are callable objects.
inline constexpr auto decode_u8 = [](auto& r, std::uint8_t& v) {
r.read(as_writable_bytes(std::span{&v, 1}));
};
inline constexpr auto decode_u16 = [](auto& r, std::uint16_t& v) {
r.read(as_writable_bytes(std::span{&v, 1}));
v = little_endian_to_native(v);
};
inline constexpr auto decode_u32 = [](auto& r, std::uint32_t& v) {
r.read(as_writable_bytes(std::span{&v, 1}));
v = little_endian_to_native(v);
};
inline constexpr auto decode_u64 = [](auto& r, std::uint64_t& v) {
r.read(as_writable_bytes(std::span{&v, 1}));
v = little_endian_to_native(v);
};
The size decoder flags errors in the parser context rather than by throwing exceptions.
inline constexpr auto decode_size = [](auto& r, std::size_t& size) {
std::uint8_t tag;
decode_u8(r, tag);
if (!r.good()) {
return;
}
if (tag < 253) {
size = tag;
return;
}
if (tag == 253) {
std::uint16_t x;
decode_u16(r, x);
if (x < 253) {
r.fail();
return;
}
size = x;
}
else if (tag == 254) {
std::uint32_t x;
decode_u32(r, x);
if (x < 0x1'0000u) {
r.fail();
return;
}
size = x;
}
else {
std::uint64_t x;
decode_u64(r, x);
if (x < 0x1'0000'0000ull) {
r.fail();
return;
}
size = x;
}
if (size > MAX_SIZE) {
r.fail();
}
};
Range decoding is almost the exact counterpart to encode_range. A notable
subtlety is avoiding speculative allocation based on attacker-controlled size
fields.
inline constexpr auto decode_range = [](
auto& r, auto& range, auto decode_elem)
{
std::size_t size;
decode_size(r, size);
range.clear();
while (range.size() < size && r.good()) {
if (range.size() == range.capacity()) {
constexpr auto batch = MAX_VECTOR_ALLOCATE /
sizeof(std::ranges::range_value_t<decltype(range)>);
range.reserve(std::min(size, range.size() + batch));
}
decode_elem(r, range.emplace_back());
}
if (!r.good()) {
range.clear();
}
};
inline constexpr auto decode_bytes = [](auto& r, auto& out) {
auto size = std::size_t{};
if (decode_size(r, size); !r.good()) {
return;
}
auto buf = std::vector<std::byte>(size);
if (r.read(buf); !r.good()) {
return;
}
out = decltype(out){std::move(buf)};
};
With these utilities in place, higher-level decoders become little more than a description of the wire format.
inline constexpr auto decode_outpoint = [](auto& r, COutPoint& out) {
decode_uint256(r, out.hash);
decode_u32(r, out.n);
};
inline constexpr auto decode_txin = [](auto& r, CTxIn& in) {
decode_outpoint(r, in.prevout);
decode_bytes(r, in.scriptSig);
decode_u32(r, in.nSequence);
};
inline constexpr auto decode_txout = [](auto& r, CTxOut& out) {
decode_i64(r, out.nValue);
decode_bytes(r, out.scriptPubKey);
};
inline constexpr auto decode_tx = [](witness wmode) {
return [=](auto& r, CTransaction& tx) {
bool has_witness = false;
decode_u32(r, tx.version);
decode_range(r, tx.vin, decode_txin);
if (tx.vin.empty() && wmode == witness::allow) {
auto flags = std::uint8_t{};
decode_u8(r, flags);
if (flags == 1) {
has_witness = true;
decode_range(r, tx.vin, decode_txin);
decode_range(r, tx.vout, decode_txout);
}
else if (flags == 0) {
tx.vout.clear();
}
else {
r.fail();
}
}
else {
decode_range(r, tx.vout, decode_txout);
}
if (has_witness) {
for (auto& in : tx.vin) {
decode_range(r, in.scriptWitness.stack, decode_bytes);
}
if (!tx.HasWitness()) {
r.fail();
}
}
decode_u32(r, tx.nLockTime);
};
};
Finally, the public interface simply constructs the required layers and returns
std::nullopt on failure:
std::optional<CBlock> parse_block(reader_ref r)
{
auto br = buffered_reader{r};
auto ctx = decoder_context{br};
auto decode = decode_block(witness::allow);
auto block = CBlock{};
if (decode(ctx, block); !ctx.good()) {
return std::nullopt;
}
return block;
}
The entire framework fits on little more than a page. Reader transports bytes,
buffered_reader optimizes transport, decoder_context turns partial reads
into exact reads while tracking failure, and the decode_* function objects
remain tiny, stateless, and composable in exactly the same way as their
serialization counterparts.
With this framework, serialization and deserialization is decoupled from the
vocabulary types. The next topic will be about the ToString functions.