1. The dual-hash architecture
CBlockHeader (80 bytes) | +-- GetHash() = SHA-256d(serialize(header)) --> block ID, txid context, p2p inv, header chain | +-- GetPoWHash() = BLAKE3d(serialize(header)) --> CheckProofOfWork(...) only
Two methods, two algorithms, one input. Every consensus call site must pick the right one, and the audit verifies this statically and on a live regtest block.
2. What is being audited
- Static call-site scan. Every
CheckProofOfWork(...)invocation in the source tree must pass a value derived fromGetPoWHash(). Direct use ofGetHash()is flagged. - Static implementation scan. The PoW hashing implementation
in
src/primitives/block.cppmust reference BLAKE3. - Header API scan.
CBlockHeaderinsrc/primitives/block.hmust declare bothGetHash()andGetPoWHash(). - Functional check. Mine a regtest block and verify:
- Re-serialising and SHA-256d-ing the header reproduces the
block ID returned by
getblockhash. - BLAKE3d of the same bytes is a different value.
- The BLAKE3d value (interpreted as little-endian uint256) is below the block's compact target.
- Re-serialising and SHA-256d-ing the header reproduces the
block ID returned by
3. Why it matters
A bug here can fork the network in two distinct ways:
- If the chain accepts blocks where SHA-256d of the header is below target (instead of BLAKE3d), then any pre-existing Bitcoin ASIC can instantly attack b3chain. The whole reason for BLAKE3 disappears.
- If
getblockhashreturns the BLAKE3 hash instead of the SHA-256d hash, every block explorer, light client, and Merkle-proof verifier breaks immediately.
4. How to run
cd b3chain pip3 install blake3 # for the functional check python3 contrib/testing/audit/audit-pow-isolation.py
5. Expected output
[H-1] PoW / Block-ID hash isolation ======================================================================== PASS [H-1] 2 CheckProofOfWork call site(s) all use GetPoWHash PASS [H-1] PoW hashing references BLAKE3 (src/primitives/block.cpp, src/primitives/block.h) PASS [H-1] CBlockHeader::GetHash() declared PASS [H-1] CBlockHeader::GetPoWHash() declared Spawning regtest node and verifying live block hash relationships... PASS [H-1] getblockhash() returns SHA-256d (block ID), not BLAKE3 PASS [H-1] block ID and BLAKE3 PoW hash are different id=68c337ef569de154... pow=3adf8417d7586ce7... PASS [H-1] mined block's BLAKE3 PoW hash <= target hash_int < target: True ------------------------------------------------------------------------ 7/7 checks passed in 1.3s AUDIT RESULT: PASS [H-1]
6. Common pitfalls
- Variable name confusion. The audit also matches
pow_hash/powhashidentifiers, but if you store the PoW hash in a genericuint256 hashvariable the audit cannot tell which algorithm produced it — prefer descriptive names. - Forward declarations. Lines like
bool CheckProofOfWork(uint256 hash, ...)are whitelisted (function parameter, not a call). The audit detects this with a regex onuint256 hash. - Headers chain check. Bitcoin's
HeadersChainSyncverifies cumulative work using the target derived fromnBits, not the actual hash, so it is unaffected by this isolation.
7. Source files
- contrib/testing/audit/audit-pow-isolation.py
-
src/primitives/block.cpp —
GetHashandGetPoWHashdefinitions -
src/pow.cpp —
CheckProofOfWork - src/validation.cpp — the two consensus call sites
The problem in one sentence
B3Chain uses two different hash functions for two purposes — SHA-256d for block IDs (so external tools and explorers stay compatible) and BLAKE3d for the proof-of-work check — and confusing them is a catastrophic, silent failure.
The theory
Every block has two hashes:
GetHash() -> SHA-256d(serialized header) # used as block id, txid lookup, merkle leaf GetPoWHash() -> BLAKE3d(serialized header) # used by ContextualCheckProofOfWork
A miner's job is to find a header with GetPoWHash() <= target. The network's job is to verify that property. If ContextualCheckProofOfWork ever calls GetHash() instead of GetPoWHash():
- Block IDs are usually well below the target (because SHA-256 outputs
are uniform 32-byte numbers, just like BLAKE3 outputs).
- So almost any block submitted to such a buggy node would pass PoW
validation — including blocks with no actual mining work behind them.
- Result: the chain's PoW security collapses to "whatever the easiest
way to find a low SHA-256 hash is", which is effectively zero on modern CPUs.
Hands-on demo
python3 contrib/testing/audit/audit-pow-isolation.py
This audit does two things:
- Static grep over every
GetHash()andGetPoWHash()call site in
src/, classifying each one as "PoW context" or "ID context" and flagging mismatches.
- Functional test: constructs a header where SHA-256d ≤ target but
BLAKE3d > target, submits it to a regtest node, expects rejection. Then the inverse.
Exercise
In src/validation.cpp, locate ContextualCheckProofOfWork (or CheckProofOfWork depending on the call path). Replace the call to GetPoWHash() with GetHash(). Rebuild, re-run the audit:
// Before if (!CheckProofOfWork(block.GetPoWHash(), block.nBits, params)) ... // After (catastrophic) if (!CheckProofOfWork(block.GetHash(), block.nBits, params)) ...
Expected new output:
FAIL [H-1] static audit found PoW check using GetHash (line N of validation.cpp) FAIL [H-1] header with SHA256d-only PoW accepted by regtest node AUDIT RESULT: FAIL [H-1]
Why "looks fine in CI" doesn't help
Most unit tests work with regtest, where nBits is set very low and both hashes pass anyway. The bug only manifests in production once a real attacker notices it. That's exactly why this audit runs the contradiction check — a header that satisfies one hash function but not the other — rather than relying on "did the node accept the block?".
Further reading
- BIP-141 SegWit txid vs wtxid (similar dual-identity pattern):
github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
- The original BCH split confused some mining hardware about which
difficulty algorithm to use; this is the same class of bug at the hash-function level.