From d90101a24315f17e533d341746c158ca8af78c1b Mon Sep 17 00:00:00 2001 From: bluezr Date: Mon, 3 Aug 2026 16:56:53 -0700 Subject: [PATCH 1/5] block: let a header own its AuxPoW proof dogecoin_block_header carried an auxpow member holding only the validation hook -- check, ctx and is -- never the proof itself. The proof was built into a local dogecoin_auxpow_block inside dogecoin_block_header_deserialize and freed at cleanup, so by the time the caller had its header the parent coinbase, merkle branches and parent header were gone. dogecoin_block_header_copy made that worse by looking complete: it copied the three hook fields, so a copied merge-mined header was silently missing its proof with nothing to indicate it. Anything needing the proof after the parse therefore had to re-parse the wire bytes, or retain them separately. That is why BIP152 keeps a header_raw span on dogecoin_compact_block: not because a compact block wants raw bytes, but because the parsed header could not answer for itself. Add dogecoin_auxpow_payload, owned by the header. The deserializer moves the parsed fields onto it and nulls them on the scratch block, so ownership transfers rather than duplicating; _free releases it; _copy deep-copies it. The payload deliberately has no back-pointer to its header, unlike dogecoin_auxpow_block, which owns both its header and its parent_header and frees them. A header holding one of those would own the thing that owns it. The payload owns only parent_header, a plain 80-byte header whose own payload is NULL, so ownership terminates. auxpow.check / auxpow.ctx are untouched. That is a validation hook whose context the caller supplies at call time -- validation.c passes the block directly -- not a reference to this data. dogecoin_block_header_copy assigns the copied payload rather than freeing what dest held. Every other field there is a plain overwrite: the function treats dest as raw memory, and callers pass uninitialised stack headers to it -- net_tests.c does, through dogecoin_block_header_deserialize. Freeing dest->auxpow_payload dereferenced whatever the stack contained, which is a SEGV on eight platforms and the reason ASAN caught this and a local build did not: every local caller happened to use dogecoin_block_header_new, where the pointer is NULL. A dest that already owns a payload is the caller's to release, as with every other member. The test extends the height-371338 vector: the proof survives the parse, a copy owns an independent one, and freeing the source leaves the copy intact, which it can only do if nothing is shared. Disabling just the deep copy fails it at line 351. WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 78/78. WITH_NET=OFF: 72/72. ASAN+UBSAN: 78/78, no leaks. --- include/dogecoin/block.h | 36 ++++++++++++++++ src/block.c | 88 ++++++++++++++++++++++++++++++++++++++++ test/block_tests.c | 29 +++++++++++++ 3 files changed, 153 insertions(+) diff --git a/include/dogecoin/block.h b/include/dogecoin/block.h index af3a316aa..cffe9e5b1 100644 --- a/include/dogecoin/block.h +++ b/include/dogecoin/block.h @@ -48,6 +48,30 @@ typedef struct _auxpow { void *ctx; } auxpow; +/* The AuxPoW proof carried by a merge-mined header, owned by the header it + belongs to. + * + * This deliberately has no back-pointer to its owning header, unlike + * dogecoin_auxpow_block. That struct owns both its header and its parent_header + * and frees them, so a header could not hold one without the two owning each + * other. The payload owns only parent_header, which is a plain 80-byte header + * with no payload of its own, so ownership terminates. + * + * The auxpow.check / auxpow.ctx hook on dogecoin_block_header is unaffected: + * it is a validation hook whose context the caller supplies at call time, not a + * reference to this data. */ +typedef struct dogecoin_auxpow_payload_ { + dogecoin_tx* parent_coinbase; + uint256_t parent_hash; + uint8_t parent_merkle_count; + uint256_t* parent_coinbase_merkle; + uint32_t parent_merkle_index; + uint8_t aux_merkle_count; + uint256_t* aux_merkle_branch; + uint32_t aux_merkle_index; + struct dogecoin_block_header_* parent_header; +} dogecoin_auxpow_payload; + typedef struct dogecoin_block_header_ { int32_t version; uint256_t prev_block; @@ -56,8 +80,20 @@ typedef struct dogecoin_block_header_ { uint32_t bits; uint32_t nonce; auxpow auxpow[1]; + /** AuxPoW proof for a merge-mined header, NULL otherwise. Retained so the + header can reproduce the bytes it was parsed from: the deserializer used + to parse this into a local dogecoin_auxpow_block and free it, and + dogecoin_block_header_copy carried only the auxpow hook fields, so the + proof was discarded and anything needing it had to re-parse. */ + dogecoin_auxpow_payload* auxpow_payload; } dogecoin_block_header; +/** Free an AuxPoW payload and everything it owns. */ +LIBDOGECOIN_API void dogecoin_auxpow_payload_free(dogecoin_auxpow_payload* payload); + +/** Deep-copy an AuxPoW payload. Returns NULL if src is NULL. */ +LIBDOGECOIN_API dogecoin_auxpow_payload* dogecoin_auxpow_payload_copy(const dogecoin_auxpow_payload* src); + typedef struct dogecoin_auxpow_block_ { dogecoin_block_header* header; dogecoin_tx* parent_coinbase; diff --git a/src/block.c b/src/block.c index b1b780727..84df5c70a 100644 --- a/src/block.c +++ b/src/block.c @@ -175,6 +175,7 @@ dogecoin_block_header* dogecoin_block_header_new() { header->auxpow->check = check; header->auxpow->ctx = header; header->auxpow->is = false; + header->auxpow_payload = NULL; return header; } @@ -207,8 +208,61 @@ dogecoin_auxpow_block* dogecoin_auxpow_block_new() { * * @return Nothing. */ +void dogecoin_auxpow_payload_free(dogecoin_auxpow_payload* payload) { + if (!payload) return; + dogecoin_tx_free(payload->parent_coinbase); + dogecoin_free(payload->parent_coinbase_merkle); + dogecoin_free(payload->aux_merkle_branch); + /* parent_header is a plain 80-byte header: its own auxpow_payload is NULL, + so this does not recurse. */ + dogecoin_block_header_free(payload->parent_header); + dogecoin_free(payload); + } + +dogecoin_auxpow_payload* dogecoin_auxpow_payload_copy(const dogecoin_auxpow_payload* src) { + if (!src) return NULL; + dogecoin_auxpow_payload* dst = dogecoin_calloc(1, sizeof(*dst)); + if (!dst) return NULL; + + memcpy_safe(dst->parent_hash, src->parent_hash, sizeof(uint256_t)); + dst->parent_merkle_count = src->parent_merkle_count; + dst->parent_merkle_index = src->parent_merkle_index; + dst->aux_merkle_count = src->aux_merkle_count; + dst->aux_merkle_index = src->aux_merkle_index; + + if (src->parent_coinbase) { + dst->parent_coinbase = dogecoin_tx_new(); + if (!dst->parent_coinbase) goto fail; + dogecoin_tx_copy(dst->parent_coinbase, src->parent_coinbase); + } + if (src->parent_merkle_count && src->parent_coinbase_merkle) { + size_t n = (size_t)src->parent_merkle_count * sizeof(uint256_t); + dst->parent_coinbase_merkle = dogecoin_malloc(n); + if (!dst->parent_coinbase_merkle) goto fail; + memcpy_safe(dst->parent_coinbase_merkle, src->parent_coinbase_merkle, n); + } + if (src->aux_merkle_count && src->aux_merkle_branch) { + size_t n = (size_t)src->aux_merkle_count * sizeof(uint256_t); + dst->aux_merkle_branch = dogecoin_malloc(n); + if (!dst->aux_merkle_branch) goto fail; + memcpy_safe(dst->aux_merkle_branch, src->aux_merkle_branch, n); + } + if (src->parent_header) { + dst->parent_header = dogecoin_block_header_new(); + if (!dst->parent_header) goto fail; + dogecoin_block_header_copy(dst->parent_header, src->parent_header); + } + return dst; + +fail: + dogecoin_auxpow_payload_free(dst); + return NULL; + } + void dogecoin_block_header_free(dogecoin_block_header* header) { if (!header) return; + dogecoin_auxpow_payload_free(header->auxpow_payload); + header->auxpow_payload = NULL; header->version = 0; dogecoin_mem_zero(&header->prev_block, DOGECOIN_HASH_LENGTH); dogecoin_mem_zero(&header->merkle_root, DOGECOIN_HASH_LENGTH); @@ -342,6 +396,29 @@ int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct cons goto cleanup; } dogecoin_block_header_copy(header, block->header); + /* Move the proof onto the header instead of letting cleanup free it. + Ownership transfers: the fields are nulled on the block so + dogecoin_auxpow_block_free does not release what the header now owns. */ + dogecoin_auxpow_payload* payload = dogecoin_calloc(1, sizeof(*payload)); + if (!payload) goto cleanup; + payload->parent_coinbase = block->parent_coinbase; + memcpy_safe(payload->parent_hash, block->parent_hash, sizeof(uint256_t)); + payload->parent_merkle_count = block->parent_merkle_count; + payload->parent_coinbase_merkle = block->parent_coinbase_merkle; + payload->parent_merkle_index = block->parent_merkle_index; + payload->aux_merkle_count = block->aux_merkle_count; + payload->aux_merkle_branch = block->aux_merkle_branch; + payload->aux_merkle_index = block->aux_merkle_index; + payload->parent_header = block->parent_header; + block->parent_coinbase = NULL; + block->parent_coinbase_merkle = NULL; + block->aux_merkle_branch = NULL; + block->parent_header = NULL; + /* No free of a prior payload here: dogecoin_block_header_copy above has + already overwritten the pointer, and header may have arrived as an + uninitialised stack struct. Callers own dest's prior contents, the + same contract every other field in this function follows. */ + header->auxpow_payload = payload; } ret = true; cleanup: @@ -510,6 +587,17 @@ void dogecoin_block_header_copy(dogecoin_block_header* dest, const dogecoin_bloc dest->auxpow->check = src->auxpow->check; dest->auxpow->ctx = src->auxpow->ctx; dest->auxpow->is = src->auxpow->is; + /* Deep-copy the proof. Carrying only the hook fields is what discarded it + before, so a copied merge-mined header could not be re-serialized or + re-validated without going back to the wire bytes. + + Assign, do not free what dest held. Every other field here is a plain + overwrite: this function treats dest as raw memory, and callers pass + uninitialised stack headers to it -- net_tests.c does, via + dogecoin_block_header_deserialize. Freeing dest->auxpow_payload would + dereference whatever the stack happened to contain. A dest that already + owns a payload is the caller's to release, as with every other member. */ + dest->auxpow_payload = dogecoin_auxpow_payload_copy(src->auxpow_payload); } /** diff --git a/test/block_tests.c b/test/block_tests.c index 3d27c9e76..861d1e20f 100644 --- a/test/block_tests.c +++ b/test/block_tests.c @@ -333,7 +333,36 @@ void test_auxpow_deserialize_real_vector() { u_assert_uint32_eq(header->bits, 456184976); u_assert_uint32_eq(header->nonce, 0); + /* The AuxPoW proof must survive the parse. It used to be built into a local + dogecoin_auxpow_block and freed at cleanup, so a caller holding the header + could not re-serialize or re-validate it without going back to the wire. */ + u_assert_not_null(header->auxpow_payload); + u_assert_not_null(header->auxpow_payload->parent_coinbase); + u_assert_not_null(header->auxpow_payload->parent_header); + u_assert_int_eq(header->auxpow_payload->parent_merkle_count > 0, 1); + u_assert_not_null(header->auxpow_payload->parent_coinbase_merkle); + + /* And it must copy deeply. dogecoin_block_header_copy carried only the + auxpow hook fields, so a copied merge-mined header silently lost its + proof; now the copy owns its own, and freeing one must not disturb the + other. */ + dogecoin_block_header* dup = dogecoin_block_header_new(); + dogecoin_block_header_copy(dup, header); + u_assert_not_null(dup->auxpow_payload); + u_assert_int_eq(dup->auxpow_payload != header->auxpow_payload, 1); + u_assert_int_eq(dup->auxpow_payload->parent_coinbase + != header->auxpow_payload->parent_coinbase, 1); + u_assert_int_eq(dup->auxpow_payload->parent_merkle_count + == header->auxpow_payload->parent_merkle_count, 1); + u_assert_mem_eq(dup->auxpow_payload->parent_hash, + header->auxpow_payload->parent_hash, DOGECOIN_HASH_LENGTH); + + /* Free the source first: the copy must still be intact, which it can only + be if nothing is shared. */ dogecoin_block_header_free(header); + u_assert_not_null(dup->auxpow_payload); + u_assert_not_null(dup->auxpow_payload->parent_header); + dogecoin_block_header_free(dup); dogecoin_free(buf); } From f24782bf509c5eaa414dee41795fc3ac9225255f Mon Sep 17 00:00:00 2001 From: bluezr Date: Mon, 3 Aug 2026 18:40:42 -0700 Subject: [PATCH 2/5] block: separate parsing a header from validating it check_auxpow ran inside dogecoin_block_header_deserialize, so every caller that wanted a header's fields paid for scrypt work over the parent chain during parsing, before any peer-level gating could decide whether the message was worth the effort. Core defers this to CheckBlock. Split into dogecoin_block_header_parse, which reads the base fields and, when version bit 0x100 is set, the AuxPoW proof, and dogecoin_block_header_validate, which runs check_auxpow and fills chainwork. The split is deliberately this way round. Making dogecoin_block_header_ deserialize the pure parse and adding a _checked variant would silently stop verifying proof of work for every existing caller of the name, with no compile error anywhere to catch it. Wrong direction for a symbol whose job is validation. Instead the existing name keeps its signature and its behaviour -- it is now parse followed by validate -- and the opt-out is explicit at the call site. deserialize_dogecoin_auxpow_block is split the same way and keeps its signature: parse_dogecoin_auxpow_fields does the reading, the public function adds the check. Validation needs the proof to still exist after parsing, which is what the preceding commit made possible. check_auxpow takes a dogecoin_auxpow_block, so validate builds one that borrows from the header and its payload. It is never freed: dogecoin_auxpow_block_free would take the header and parent_header with it, which is the ownership tangle the payload type exists to avoid. A header with no AuxPoW validates trivially. Its proof of work is over the 80 base bytes and belongs to the caller -- headersdb_file.c already runs check_pow itself for that case and fills chainwork. That asymmetry is unchanged. Verified by stubbing check_auxpow to always fail: dogecoin_block_header_ parse still succeeds, dogecoin_block_header_validate does not. A refactor that merely moved the call would fail both. The test also asserts the deferred chainwork equals what the parse-and-validate path computes, so the split does not change the answer. WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 78/78. WITH_NET=OFF: 72/72. ASAN+UBSAN with leak detection: 78/78, clean. --- include/dogecoin/block.h | 29 ++++++++++++++++++++ src/block.c | 59 +++++++++++++++++++++++++++++++++++++--- test/block_tests.c | 28 +++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/include/dogecoin/block.h b/include/dogecoin/block.h index cffe9e5b1..92db7e5de 100644 --- a/include/dogecoin/block.h +++ b/include/dogecoin/block.h @@ -111,6 +111,35 @@ LIBDOGECOIN_API dogecoin_block_header* dogecoin_block_header_new(); LIBDOGECOIN_API void dogecoin_block_header_free(dogecoin_block_header* header); LIBDOGECOIN_API dogecoin_auxpow_block* dogecoin_auxpow_block_new(); LIBDOGECOIN_API void dogecoin_auxpow_block_free(dogecoin_auxpow_block* block); +/** Parse a block header off the wire without validating it. + * + * Reads the 80 base fields and, when version bit 0x100 is set, the AuxPoW + * proof, which is retained on the header. Runs no proof-of-work check. + * + * check_auxpow is scrypt work over the parent chain. Doing it during parsing + * means every caller pays for it whether or not it wants the answer yet, and + * before any peer-level gating has happened. Callers that want the fields and + * will decide about validation later use this; callers that want the existing + * parse-and-validate behaviour keep using dogecoin_block_header_deserialize. + */ +LIBDOGECOIN_API int dogecoin_block_header_parse(dogecoin_block_header* header, struct const_buffer* buf, const dogecoin_chainparams *params); + +/** Validate a parsed header's AuxPoW and fill @p chainwork. + * + * Returns true immediately for a header with no AuxPoW: its proof of work is + * over the 80 base bytes and is the caller's to verify. Requires the header to + * still own its proof, so it must have come from dogecoin_block_header_parse + * or dogecoin_block_header_deserialize. + */ +LIBDOGECOIN_API int dogecoin_block_header_validate(dogecoin_block_header* header, const dogecoin_chainparams *params, arith_uint256* chainwork); + +/** Parse and validate. Unchanged in behaviour and signature: this is + * dogecoin_block_header_parse followed by dogecoin_block_header_validate. + * + * Deliberately kept as the name that validates. Making this the pure parse and + * adding a checked variant would silently stop verifying proof of work for any + * caller of the existing name, with no compile error to catch it. + */ LIBDOGECOIN_API int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct const_buffer* buf, const dogecoin_chainparams *params, arith_uint256* chainwork); LIBDOGECOIN_API int deserialize_dogecoin_auxpow_block(dogecoin_auxpow_block* block, struct const_buffer* buffer, const dogecoin_chainparams *params, arith_uint256* chainwork); LIBDOGECOIN_API void dogecoin_block_header_serialize(cstring* s, const dogecoin_block_header* header); diff --git a/src/block.c b/src/block.c index 84df5c70a..743fe278b 100644 --- a/src/block.c +++ b/src/block.c @@ -374,7 +374,9 @@ void print_block(dogecoin_auxpow_block* block) { * * @return 1 if deserialization was successful, 0 otherwise. */ -int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct const_buffer* buf, const dogecoin_chainparams *params, arith_uint256* chainwork) { +static int parse_dogecoin_auxpow_fields(dogecoin_auxpow_block* block, struct const_buffer* buffer, const dogecoin_chainparams *params); + +int dogecoin_block_header_parse(dogecoin_block_header* header, struct const_buffer* buf, const dogecoin_chainparams *params) { dogecoin_auxpow_block* block = dogecoin_auxpow_block_new(); int ret = false; if (!deser_s32(&block->header->version, buf)) @@ -391,7 +393,7 @@ int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct cons goto cleanup; dogecoin_block_header_copy(header, block->header); if ((block->header->version & 0x100) != 0 && buf->len) { - if (!deserialize_dogecoin_auxpow_block(block, buf, params, chainwork)) { + if (!parse_dogecoin_auxpow_fields(block, buf, params)) { printf("%s:%d:%s:%s\n", __FILE__, __LINE__, __func__, strerror(errno)); goto cleanup; } @@ -426,7 +428,49 @@ int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct cons return ret; } -int deserialize_dogecoin_auxpow_block(dogecoin_auxpow_block* block, struct const_buffer* buffer, const dogecoin_chainparams *params, arith_uint256* chainwork) { +int dogecoin_block_header_validate(dogecoin_block_header* header, const dogecoin_chainparams *params, arith_uint256* chainwork) { + if (!header) return false; + /* Nothing to check for a header with no AuxPoW: its proof of work is over + the 80 base bytes, which is the caller's to verify -- headersdb_file.c + does exactly that for the non-AuxPoW case, and fills chainwork itself. */ + if (!header->auxpow_payload) return true; + + /* check_auxpow wants a dogecoin_auxpow_block. Build one that borrows from + the header and its payload rather than copying: it is never freed, so the + borrowed pointers are not released twice. dogecoin_auxpow_block_free + would take the header and parent_header with it, which is exactly the + ownership tangle the payload type exists to avoid. */ + dogecoin_auxpow_payload* p = header->auxpow_payload; + dogecoin_auxpow_block view; + dogecoin_mem_zero(&view, sizeof(view)); + view.header = header; + view.parent_coinbase = p->parent_coinbase; + memcpy_safe(view.parent_hash, p->parent_hash, sizeof(uint256_t)); + view.parent_merkle_count = p->parent_merkle_count; + view.parent_coinbase_merkle = p->parent_coinbase_merkle; + view.parent_merkle_index = p->parent_merkle_index; + view.aux_merkle_count = p->aux_merkle_count; + view.aux_merkle_branch = p->aux_merkle_branch; + view.aux_merkle_index = p->aux_merkle_index; + view.parent_header = p->parent_header; + + if (!check_auxpow(&view, (dogecoin_chainparams*)params, chainwork)) { + printf("check_auxpow failed!\n"); + return false; + } + return true; + } + +int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct const_buffer* buf, const dogecoin_chainparams *params, arith_uint256* chainwork) { + if (!dogecoin_block_header_parse(header, buf, params)) return false; + return dogecoin_block_header_validate(header, params, chainwork); + } + +/* Parse the AuxPoW fields off the wire. No validation: check_auxpow is scrypt + work on the parent chain, and doing it here means every caller pays for it + during parsing whether or not it wants the answer yet. */ +static int parse_dogecoin_auxpow_fields(dogecoin_auxpow_block* block, struct const_buffer* buffer, const dogecoin_chainparams *params) { + (void)params; if (buffer->len > DOGECOIN_MAX_P2P_MSG_SIZE) { return printf("\ntransaction is invalid or to large.\n\n"); } @@ -542,11 +586,18 @@ int deserialize_dogecoin_auxpow_block(dogecoin_auxpow_block* block, struct const return false; } + return true; + } + +/* Unchanged behaviour and signature: parse, then validate. Callers that want + only the fields use parse_dogecoin_auxpow_fields via + dogecoin_block_header_parse. */ +int deserialize_dogecoin_auxpow_block(dogecoin_auxpow_block* block, struct const_buffer* buffer, const dogecoin_chainparams *params, arith_uint256* chainwork) { + if (!parse_dogecoin_auxpow_fields(block, buffer, params)) return false; if (!check_auxpow(block, (dogecoin_chainparams*)params, chainwork)) { printf("check_auxpow failed!\n"); return false; } - return true; } diff --git a/test/block_tests.c b/test/block_tests.c index 861d1e20f..cb56bea7b 100644 --- a/test/block_tests.c +++ b/test/block_tests.c @@ -363,6 +363,34 @@ void test_auxpow_deserialize_real_vector() { u_assert_not_null(dup->auxpow_payload); u_assert_not_null(dup->auxpow_payload->parent_header); dogecoin_block_header_free(dup); + + /* The parse/check split: dogecoin_block_header_parse must produce the same + header and the same retained proof, without running check_auxpow. */ + dogecoin_block_header* parsed = dogecoin_block_header_new(); + struct const_buffer cb2 = { buf, blen }; + u_assert_int_eq(dogecoin_block_header_parse(parsed, &cb2, &dogecoin_chainparams_main), 1); + u_assert_uint32_eq((uint32_t)parsed->version, 0x00620102); + u_assert_uint32_eq(parsed->timestamp, 1410464609); + u_assert_not_null(parsed->auxpow_payload); + u_assert_not_null(parsed->auxpow_payload->parent_header); + + /* Validation deferred to a separate call, and it agrees with what the + parse-and-validate path computed. */ + arith_uint256 deferred_chainwork; + u_assert_int_eq(dogecoin_block_header_validate(parsed, &dogecoin_chainparams_main, + &deferred_chainwork), 1); + u_assert_mem_eq(utils_uint8_to_hex(arith_to_uint256(&deferred_chainwork), DOGECOIN_HASH_LENGTH), + utils_uint8_to_hex(arith_to_uint256(&chainwork), DOGECOIN_HASH_LENGTH), 64); + dogecoin_block_header_free(parsed); + + /* A header with no AuxPoW validates trivially: its proof of work is over + the 80 base bytes and belongs to the caller, which is why + headersdb_file.c runs check_pow itself for that case. */ + dogecoin_block_header* plain = dogecoin_block_header_new(); + plain->version = 1; + u_assert_int_eq(dogecoin_block_header_validate(plain, &dogecoin_chainparams_main, NULL), 1); + dogecoin_block_header_free(plain); + dogecoin_free(buf); } From c2fceca0834c5bc5eca314940ab1a406fafea011 Mon Sep 17 00:00:00 2001 From: bluezr Date: Mon, 3 Aug 2026 19:22:32 -0700 Subject: [PATCH 3/5] block: serialize a header with its AuxPoW There was no way to write an AuxPoW proof back out. The tree could parse one and, since the preceding commits, retain it, but nothing could emit it, so a header that arrived over the wire could not be reproduced. Add dogecoin_auxpow_payload_serialize, in the wire order parse_dogecoin_auxpow_fields reads, and dogecoin_block_header_serialize_full, which writes the 80 base bytes and then the proof when the header carries one. dogecoin_block_header_serialize is untouched and still emits exactly 80 bytes. That is deliberate: its output is what the block hash, the scrypt proof of work, check_auxpow's own hashing and the fixed-width headers.db record are computed over. Making it AuxPoW-aware would change all four. This is the same split Core draws between CPureBlockHeader and CBlockHeader, and the same reason. The full form keys off whether the proof is present rather than off version bit 0x100 alone, so a header carrying the bit without a proof serializes as the 80 bytes it actually has instead of emitting a truncated blob. Tested by round-tripping the height-371338 mainnet vector: parse it, write it back, and require the result to be byte-identical to the bytes it came from. Emitting something merely well-formed is not enough -- the point of this is that short IDs and hashes computed over the output match Core's, which only holds if the bytes match exactly. The pure form is asserted to stay at 80 bytes and to match the first 80 of the input. The first attempt at checking that test had no bite: it swapped parent_merkle_index with aux_merkle_index, which are both zero in this block, so the output was identical and the test passed either way. Zeroing parent_hash instead fails it at the memcmp, which is what a real serialization bug would do. WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 78/78. WITH_NET=OFF: 72/72. ASAN+UBSAN with leak detection: 78/78, clean. --- include/dogecoin/block.h | 15 +++++++++++++++ src/block.c | 30 ++++++++++++++++++++++++++++++ test/block_tests.c | 27 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/include/dogecoin/block.h b/include/dogecoin/block.h index 92db7e5de..7fb651a19 100644 --- a/include/dogecoin/block.h +++ b/include/dogecoin/block.h @@ -142,7 +142,22 @@ LIBDOGECOIN_API int dogecoin_block_header_validate(dogecoin_block_header* header */ LIBDOGECOIN_API int dogecoin_block_header_deserialize(dogecoin_block_header* header, struct const_buffer* buf, const dogecoin_chainparams *params, arith_uint256* chainwork); LIBDOGECOIN_API int deserialize_dogecoin_auxpow_block(dogecoin_auxpow_block* block, struct const_buffer* buffer, const dogecoin_chainparams *params, arith_uint256* chainwork); +/** Serialize the 80 base header fields. This is the pure header: it never + * emits AuxPoW, because its output is what the block hash, the scrypt proof of + * work and the fixed-width headers.db record are computed over. */ LIBDOGECOIN_API void dogecoin_block_header_serialize(cstring* s, const dogecoin_block_header* header); + +/** Serialize an AuxPoW proof in wire order. */ +LIBDOGECOIN_API void dogecoin_auxpow_payload_serialize(cstring* s, const dogecoin_auxpow_payload* payload); + +/** Serialize a header as it appears on the wire: the 80 base bytes, followed by + * the AuxPoW proof when the header carries one. + * + * This is Core's CBlockHeader to dogecoin_block_header_serialize's + * CPureBlockHeader. Messages that carry a whole header -- headers, block, + * cmpctblock -- want this one; anything hashing the header wants the pure form. + */ +LIBDOGECOIN_API void dogecoin_block_header_serialize_full(cstring* s, const dogecoin_block_header* header); LIBDOGECOIN_API void dogecoin_block_header_copy(dogecoin_block_header* dest, const dogecoin_block_header* src); LIBDOGECOIN_API dogecoin_bool dogecoin_block_header_hash(dogecoin_block_header* header, uint256_t hash); diff --git a/src/block.c b/src/block.c index 743fe278b..709273edf 100644 --- a/src/block.c +++ b/src/block.c @@ -610,6 +610,36 @@ int deserialize_dogecoin_auxpow_block(dogecoin_auxpow_block* block, struct const * * @return Nothing. */ +void dogecoin_auxpow_payload_serialize(cstring* s, const dogecoin_auxpow_payload* payload) { + if (!s || !payload) return; + /* Wire order mirrors parse_dogecoin_auxpow_fields exactly. */ + dogecoin_tx_serialize(s, payload->parent_coinbase); + ser_u256(s, payload->parent_hash); + ser_varlen(s, payload->parent_merkle_count); + uint8_t i; + for (i = 0; i < payload->parent_merkle_count; i++) + ser_u256(s, payload->parent_coinbase_merkle[i]); + ser_u32(s, payload->parent_merkle_index); + ser_varlen(s, payload->aux_merkle_count); + for (i = 0; i < payload->aux_merkle_count; i++) + ser_u256(s, payload->aux_merkle_branch[i]); + ser_u32(s, payload->aux_merkle_index); + /* Parent header is a pure 80-byte header: it carries no AuxPoW of its own. */ + dogecoin_block_header_serialize(s, payload->parent_header); + } + +void dogecoin_block_header_serialize_full(cstring* s, const dogecoin_block_header* header) { + if (!s || !header) return; + dogecoin_block_header_serialize(s, header); + /* Core's CBlockHeader::SerializationOp appends the AuxPoW whenever + nVersion & VERSION_AUXPOW is set. Mirror that, driven by whether the + proof is actually present rather than by the bit alone, so a header + carrying the bit but no proof serializes as the 80 bytes it really has + instead of emitting a truncated blob. */ + if (header->auxpow_payload) + dogecoin_auxpow_payload_serialize(s, header->auxpow_payload); + } + void dogecoin_block_header_serialize(cstring* s, const dogecoin_block_header* header) { ser_s32(s, header->version); ser_u256(s, header->prev_block); diff --git a/test/block_tests.c b/test/block_tests.c index cb56bea7b..b96520fb1 100644 --- a/test/block_tests.c +++ b/test/block_tests.c @@ -391,6 +391,33 @@ void test_auxpow_deserialize_real_vector() { u_assert_int_eq(dogecoin_block_header_validate(plain, &dogecoin_chainparams_main, NULL), 1); dogecoin_block_header_free(plain); + /* Round-trip: a parsed merge-mined header must serialize back to exactly the + bytes it came from. This is the check that makes the serializer worth + anything -- emitting something well-formed but different would still let + short IDs and block hashes diverge from Core. */ + dogecoin_block_header* rt = dogecoin_block_header_new(); + struct const_buffer cb3 = { buf, blen }; + u_assert_int_eq(dogecoin_block_header_parse(rt, &cb3, &dogecoin_chainparams_main), 1); + size_t hdr_span = blen - cb3.len; + u_assert_int_eq(hdr_span > 80, 1); + + cstring* full = cstr_new_sz(hdr_span + 16); + dogecoin_block_header_serialize_full(full, rt); + u_assert_int_eq(full->len == hdr_span, 1); + u_assert_int_eq(memcmp(full->str, buf, hdr_span), 0); + + /* And the pure form stays 80 bytes: the block hash, the scrypt proof of + work and the headers.db record are all computed over it, so it must not + start emitting AuxPoW just because the header now retains some. */ + cstring* pure = cstr_new_sz(96); + dogecoin_block_header_serialize(pure, rt); + u_assert_int_eq(pure->len == 80, 1); + u_assert_int_eq(memcmp(pure->str, buf, 80), 0); + + cstr_free(full, true); + cstr_free(pure, true); + dogecoin_block_header_free(rt); + dogecoin_free(buf); } From ad12e05626524a57a63936e97e200ac3a8614983 Mon Sep 17 00:00:00 2001 From: bluezr Date: Mon, 3 Aug 2026 20:22:23 -0700 Subject: [PATCH 4/5] block: serialize a whole block The tree could serialize a header and it could serialize a transaction, but nothing could serialize a block. Anything holding a header and a set of transactions -- a block assembled locally, or one reconstructed from a compact block -- had no way to produce the bytes the rest of the client parses, because every path that consumes a block takes wire bytes and deserializes them. dogecoin_block_serialize writes the header in wire form, so AuxPoW and all, then the transaction vector. It uses the full header serializer rather than the pure one for that reason: a block carries the header a peer sent, not the 80 bytes the block hash is computed over. It stops rather than emitting a short block if the transaction array contains a NULL. A vector with a hole in it is a reconstruction that did not finish, and a block that is well-formed but missing transactions is worse than no output at all. Tested by round-tripping the whole height-371338 mainnet block: parse the header span, parse the transaction vector, assert the vector accounts for every remaining byte, serialize it all back, and require byte-identity with the input. Omitting the transaction count alone fails it at the memcmp. WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 78/78. WITH_NET=OFF: 72/72. ASAN+UBSAN with leak detection: 78/78, clean. --- include/dogecoin/block.h | 13 +++++++++++++ src/block.c | 15 +++++++++++++++ test/block_tests.c | 30 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/include/dogecoin/block.h b/include/dogecoin/block.h index 7fb651a19..cc30ed4c7 100644 --- a/include/dogecoin/block.h +++ b/include/dogecoin/block.h @@ -150,6 +150,19 @@ LIBDOGECOIN_API void dogecoin_block_header_serialize(cstring* s, const dogecoin_ /** Serialize an AuxPoW proof in wire order. */ LIBDOGECOIN_API void dogecoin_auxpow_payload_serialize(cstring* s, const dogecoin_auxpow_payload* payload); +/** Serialize a whole block: the header in wire form, then the transaction + * vector. + * + * This is what a `block` message contains, and what code expecting a block off + * the network parses. A compact block that has been reconstructed has a header + * and a set of transactions but no wire bytes; this is how it becomes something + * the rest of the client can consume. + * + * Stops rather than emitting a short block if @p txs contains a NULL, since a + * vector with a hole in it is a reconstruction that did not finish. + */ +LIBDOGECOIN_API void dogecoin_block_serialize(cstring* s, const dogecoin_block_header* header, dogecoin_tx** txs, uint32_t txs_count); + /** Serialize a header as it appears on the wire: the 80 base bytes, followed by * the AuxPoW proof when the header carries one. * diff --git a/src/block.c b/src/block.c index 709273edf..666d87af9 100644 --- a/src/block.c +++ b/src/block.c @@ -640,6 +640,21 @@ void dogecoin_block_header_serialize_full(cstring* s, const dogecoin_block_heade dogecoin_auxpow_payload_serialize(s, header->auxpow_payload); } +void dogecoin_block_serialize(cstring* s, const dogecoin_block_header* header, + dogecoin_tx** txs, uint32_t txs_count) { + if (!s || !header) return; + /* A block is its header in wire form -- AuxPoW and all, which is why this + uses the full serializer rather than the pure one -- followed by the + transaction vector. */ + dogecoin_block_header_serialize_full(s, header); + ser_varlen(s, txs_count); + uint32_t i; + for (i = 0; i < txs_count; i++) { + if (!txs || !txs[i]) return; /* caller passed a hole; stop rather than emit a short block */ + dogecoin_tx_serialize(s, txs[i]); + } + } + void dogecoin_block_header_serialize(cstring* s, const dogecoin_block_header* header) { ser_s32(s, header->version); ser_u256(s, header->prev_block); diff --git a/test/block_tests.c b/test/block_tests.c index b96520fb1..049f601d3 100644 --- a/test/block_tests.c +++ b/test/block_tests.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -414,6 +415,35 @@ void test_auxpow_deserialize_real_vector() { u_assert_int_eq(pure->len == 80, 1); u_assert_int_eq(memcmp(pure->str, buf, 80), 0); + /* Whole-block round trip: header span plus the transaction vector must come + back byte-identical. This is the form a reconstructed compact block has to + be turned into before the rest of the client can consume it, so anything + less than exact would desync the very path it exists to serve. */ + struct const_buffer txbuf = { buf + hdr_span, blen - hdr_span }; + uint32_t tx_count = 0; + u_assert_int_eq(deser_varlen(&tx_count, &txbuf), 1); + u_assert_int_eq(tx_count > 0, 1); + + dogecoin_tx** txs = dogecoin_calloc(tx_count, sizeof(*txs)); + u_assert_not_null(txs); + uint32_t ti; + for (ti = 0; ti < tx_count; ti++) { + txs[ti] = dogecoin_tx_new(); + size_t consumed = 0; + u_assert_int_eq(dogecoin_tx_deserialize(txbuf.p, txbuf.len, txs[ti], &consumed), 1); + u_assert_int_eq(deser_skip(&txbuf, consumed), 1); + } + u_assert_int_eq((int)txbuf.len, 0); /* the vector accounted for every byte */ + + cstring* whole = cstr_new_sz(blen + 32); + dogecoin_block_serialize(whole, rt, txs, tx_count); + u_assert_int_eq(whole->len == blen, 1); + u_assert_int_eq(memcmp(whole->str, buf, blen), 0); + + cstr_free(whole, true); + for (ti = 0; ti < tx_count; ti++) dogecoin_tx_free(txs[ti]); + dogecoin_free(txs); + cstr_free(full, true); cstr_free(pure, true); dogecoin_block_header_free(rt); From a766d99794dce341e97da5b1ecb525e580f0617c Mon Sep 17 00:00:00 2001 From: bluezr Date: Tue, 4 Aug 2026 15:14:24 -0700 Subject: [PATCH 5/5] block: compute merkle roots, with mutation detection libdogecoin could verify a merkle branch (check_merkle_branch) but could not compute a root. Anything assembling a block -- a pool building an AuxPoW candidate, a test harness, anything checking a header against its own transactions -- had no way to produce the value the header commits to. dogecoin_compute_merkle_root reduces pre-hashed leaves; dogecoin_block_merkle_root hashes a transaction vector and reduces it. This is a port of Core's MerkleComputation (consensus/merkle.cpp), kept in its eager inner[] form rather than rewritten as the textbook loop, so the two can be compared line by line. The textbook version -- duplicate the last hash on an odd level, hash pairwise, repeat -- computes the same roots for well-formed input and diverges on exactly the case that matters. That case is CVE-2012-2459. An odd leaf count leaves the last leaf unpaired and the tree self-pairs it as hash(L,L). An attacker appends a copy of that leaf, making the count even, so the pair (L,L) now forms explicitly and produces the *same root* from a different transaction list. The root cannot distinguish them. Core detects it by noticing a node combined with a value equal to itself and reporting `mutated`; a caller that ignores that flag accepts the forged block. Verified against the height-371338 mainnet vector: the root computed from its six transactions equals the one in its own header, and reports no mutation. Ground truth from the chain rather than a fixture of our own. The mutation test builds the attack rather than asserting a flag: take five of the six transactions for an odd count, append a copy of the fifth, and assert both that the forged root *equals* the honest one and that only the flag separates them. Disabling the check alone fails it. An earlier version of that test duplicated into a count of seven and saw no mutation. That was correct behaviour -- with seven leaves the copy sits unpaired and is never combined with its twin -- but the test had been written expecting detection, so it reported a failure that was really its own. Worth recording, because a mutation test that never forms the duplicated pair looks like coverage and is not. WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 78/78. WITH_NET=OFF: 72/72. ASAN+UBSAN with leak detection: 78/78, clean. --- include/dogecoin/block.h | 13 ++++++ src/block.c | 90 ++++++++++++++++++++++++++++++++++++++++ test/block_tests.c | 42 +++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/include/dogecoin/block.h b/include/dogecoin/block.h index cc30ed4c7..29374e8b4 100644 --- a/include/dogecoin/block.h +++ b/include/dogecoin/block.h @@ -150,6 +150,19 @@ LIBDOGECOIN_API void dogecoin_block_header_serialize(cstring* s, const dogecoin_ /** Serialize an AuxPoW proof in wire order. */ LIBDOGECOIN_API void dogecoin_auxpow_payload_serialize(cstring* s, const dogecoin_auxpow_payload* payload); +/** Compute a merkle root over pre-hashed leaves. + * + * @param mutated_out set true when the tree contains a duplicated subtree. + * A block whose merkle tree is mutated must be rejected: two different + * transaction lists can otherwise produce the same root (CVE-2012-2459). + * Callers that ignore this flag are accepting mutated blocks. + */ +LIBDOGECOIN_API void dogecoin_compute_merkle_root(const uint256_t* leaves, size_t leaf_count, uint256_t root_out, dogecoin_bool* mutated_out); + +/** Compute the merkle root of a transaction vector. Hashes each transaction and + * reduces. Returns false on allocation failure or a NULL entry. */ +LIBDOGECOIN_API dogecoin_bool dogecoin_block_merkle_root(dogecoin_tx** txs, size_t txs_count, uint256_t root_out, dogecoin_bool* mutated_out); + /** Serialize a whole block: the header in wire form, then the transaction * vector. * diff --git a/src/block.c b/src/block.c index 666d87af9..317ac3e53 100644 --- a/src/block.c +++ b/src/block.c @@ -655,6 +655,96 @@ void dogecoin_block_serialize(cstring* s, const dogecoin_block_header* header, } } +/* Port of Core's MerkleComputation (consensus/merkle.cpp). Deliberately a port + rather than the textbook algorithm. + * + * The naive version -- duplicate the last hash when a level has an odd count, + * hash pairwise, repeat -- produces the same root for two different transaction + * lists, because appending a copy of the final transaction is invisible in the + * root. That is CVE-2012-2459, and a block can be mutated into an invalid copy + * that still matches the header. Core detects it by noticing when a node is + * combined with a value equal to itself and reporting `mutated`; callers treat a + * mutated block as invalid rather than merely unusual. + * + * The eager `inner[]` formulation is Core's, kept so the two implementations can + * be compared line by line. Anything simpler risks agreeing with Core on the + * common case and diverging on exactly the case that matters. */ +void dogecoin_compute_merkle_root(const uint256_t* leaves, size_t leaf_count, + uint256_t root_out, dogecoin_bool* mutated_out) { + if (!root_out) return; + if (!leaves || leaf_count == 0) { + dogecoin_mem_zero(root_out, sizeof(uint256_t)); + if (mutated_out) *mutated_out = false; + return; + } + + dogecoin_bool mutated = false; + uint32_t count = 0; + uint256_t inner[32]; + dogecoin_mem_zero(inner, sizeof(inner)); + + while (count < leaf_count) { + uint256_t h; + memcpy_safe(h, leaves[count], sizeof(uint256_t)); + count++; + int level; + for (level = 0; !(count & (((uint32_t)1) << level)); level++) { + /* A node combined with a value equal to itself can only arise from a + duplicated subtree: the mutation this check exists to catch. */ + if (memcmp(inner[level], h, sizeof(uint256_t)) == 0) mutated = true; + uint8_t cat[64]; + memcpy_safe(cat, inner[level], 32); + memcpy_safe(cat + 32, h, 32); + dogecoin_hash(cat, 64, h); + } + memcpy_safe(inner[level], h, sizeof(uint256_t)); + } + + /* Sweep the rightmost branch, folding odd levels upward. */ + int level = 0; + while (!(count & (((uint32_t)1) << level))) level++; + uint256_t h; + memcpy_safe(h, inner[level], sizeof(uint256_t)); + while (count != (((uint32_t)1) << level)) { + /* Bitcoin's rule for an odd level: combine the node with itself. */ + uint8_t cat[64]; + memcpy_safe(cat, h, 32); + memcpy_safe(cat + 32, h, 32); + dogecoin_hash(cat, 64, h); + count += (((uint32_t)1) << level); + level++; + while (!(count & (((uint32_t)1) << level))) { + if (memcmp(inner[level], h, sizeof(uint256_t)) == 0) mutated = true; + uint8_t cat2[64]; + memcpy_safe(cat2, inner[level], 32); + memcpy_safe(cat2 + 32, h, 32); + dogecoin_hash(cat2, 64, h); + level++; + } + } + + memcpy_safe(root_out, h, sizeof(uint256_t)); + if (mutated_out) *mutated_out = mutated; + } + +dogecoin_bool dogecoin_block_merkle_root(dogecoin_tx** txs, size_t txs_count, + uint256_t root_out, dogecoin_bool* mutated_out) { + if (!txs || txs_count == 0 || !root_out) return false; + uint256_t* leaves = dogecoin_calloc(txs_count, sizeof(uint256_t)); + if (!leaves) return false; + size_t i; + for (i = 0; i < txs_count; i++) { + if (!txs[i]) { + dogecoin_free(leaves); + return false; + } + dogecoin_tx_hash(txs[i], leaves[i]); + } + dogecoin_compute_merkle_root(leaves, txs_count, root_out, mutated_out); + dogecoin_free(leaves); + return true; + } + void dogecoin_block_header_serialize(cstring* s, const dogecoin_block_header* header) { ser_s32(s, header->version); ser_u256(s, header->prev_block); diff --git a/test/block_tests.c b/test/block_tests.c index 049f601d3..3ad110ec5 100644 --- a/test/block_tests.c +++ b/test/block_tests.c @@ -440,6 +440,48 @@ void test_auxpow_deserialize_real_vector() { u_assert_int_eq(whole->len == blen, 1); u_assert_int_eq(memcmp(whole->str, buf, blen), 0); + /* The merkle root computed from this block's transactions must equal the + one in its own header. Ground truth from mainnet, not a fixture we chose. */ + uint256_t computed_root; + dogecoin_bool mutated = true; + u_assert_int_eq(dogecoin_block_merkle_root(txs, tx_count, computed_root, &mutated), 1); + u_assert_mem_eq(computed_root, rt->merkle_root, DOGECOIN_HASH_LENGTH); + u_assert_int_eq(mutated, 0); + + /* CVE-2012-2459. An odd number of leaves leaves the last one unpaired, and + the tree self-pairs it: hash(L,L). An attacker can append a copy of that + leaf, making the count even, so the pair (L,L) now forms explicitly -- + producing the *same root* from a different transaction list. The root + alone cannot tell the two apart, so a caller that ignores the mutation + flag will accept the forged block. + + This block has 6 transactions, so take 5 to get an odd count, then append + a duplicate of the fifth. Duplicating into an odd count instead would not + demonstrate anything: with 7 leaves the copy sits unpaired and is never + combined with its twin, which is why an earlier version of this test saw + no mutation and was wrong rather than reassuring. */ + if (tx_count >= 5) { + uint256_t odd_root, forged_root; + dogecoin_bool odd_mutated = true, forged_mutated = false; + + u_assert_int_eq(dogecoin_block_merkle_root(txs, 5, odd_root, &odd_mutated), 1); + u_assert_int_eq(odd_mutated, 0); /* honest tree, no duplicate */ + + dogecoin_tx** forged = dogecoin_calloc(6, sizeof(*forged)); + u_assert_not_null(forged); + for (ti = 0; ti < 5; ti++) forged[ti] = txs[ti]; + forged[5] = txs[4]; /* append a copy of the last */ + + u_assert_int_eq(dogecoin_block_merkle_root(forged, 6, forged_root, &forged_mutated), 1); + + /* The attack works: same root from a different transaction list. */ + u_assert_mem_eq(forged_root, odd_root, DOGECOIN_HASH_LENGTH); + /* And the flag is the only thing that distinguishes them. */ + u_assert_int_eq(forged_mutated, 1); + + dogecoin_free(forged); + } + cstr_free(whole, true); for (ti = 0; ti < tx_count; ti++) dogecoin_tx_free(txs[ti]); dogecoin_free(txs);