§ 07 · Blocks
Proof commitment
The 32 bytes where Bitcoin keeps its nonce
On this page 8 sections
Bitcoin’s header ends with a nonce, a 4-byte number miners change billions of times a second. Pearl’s header has no nonce. Its last 32 bytes are a proof commitmentProof commitment: The last 32 bytes of a Pearl block header, where Bitcoin has its nonce. It is a hash of the certificate's public data, so the block's ID is tied to the winning lottery ticket. instead.
A commitmentCommitment: A hash published in advance that pins down some data without showing all of it. Change the data later and the hash no longer matches, so the data is locked in. is a hash published as a promise: it pins some data down without carrying it. Here the data is the public part of the block’s certificateCertificate: The proof of work that travels in front of every block: about 60 KB, mostly a zero-knowledge proof. Any node can check it in milliseconds without seeing the miner's matrices.: which matrices were multiplied, how, and which lottery ticketTicket: A 256-bit BLAKE3 hash of a tile's transcript. It wins a block if, read as a number, it is at most the target times the work that tile cost. won. Because the commitment sits inside the header, the block’s ID vouches for all of that.
Try it
Run the toy miner to the end. Its last steps build 164 bytes of public data, hash them into a proof commitment, and append it to the 76-byte template to make a full 108-byte header.
What does it look like?
- Size
- 32 bytes
- Type
- SHA256d hash
- Format
- Raw hash bytes, as SHA-256 outputs them
| Field | Bytes | Value |
|---|---|---|
| Version header.version | 4 @0 | 0x20000000 int32 LE. BIP-9 style 0x20000000 since block 1; no consensus meaning in Pearl. |
| Previous block header.prevBlock | 32 @4 | eb555c52c695cfa0b2ad27f0d1f96ca55f1dc32d864e13218b4bd9041b969c99 Hash of the parent block, stored byte-reversed; shown in display order. |
| Merkle root header.merkleRoot | 32 @36 | c6c21c0c4204b9f39e17d7f79811aff53eac88ea0d80fcdeffdad96ba3fa8963 Root of the SHA256d Merkle tree of this block’s txids, stored byte-reversed. |
| Time header.timestamp | 4 @68 | 1792513562 (2026-10-20 16:26:02 UTC) uint32 LE Unix seconds. Must be at least 1 s after the parent and at most 5 min in the future. |
| Bits header.bits | 4 @72 | 0x176af3a6 Compact target. Difficulty 40157376.49334089. The certificate's jackpot hash (not this header's hash) must meet it. |
| Proof commitment header.proofCommitment | 32 @76 | 49e67ac73638201f2815a24689b8b1df672f9176df2ee232b00c1632fda3524d SHA256d(LE32(certificate version) ‖ public data). Replaces Bitcoin’s nonce: Pearl headers have no nonce. |
Bytes 76 to 107 of block 130,827:
4d52a3fd32160cb032e22edf76912f67dfb1b88946a215281f203836c77ae649 Like every hash, it’s stored as SHA-256 produced it. Tools that display it the Bitcoin way reverse it to 49e67ac7…fda3524d. Explorers rarely show it at all.
What is it a hash of?
Of exactly two things, joined together:
| Field | Size | Type | Description |
|---|---|---|---|
| Certificate version | 4 | uint32 LE | 03000000 for blocks since 99,000; 02000000 and 01000000 before. |
| Public data | 164 | bytes | Mining settings (52) ‖ hash_A (32) ‖ hash_B (32) ‖ hash_jackpot, the ticket (32) ‖ m, n, tile row, tile column (4 each). |
Here is the real input for block 130,827, all 168 bytes:
03000000
00200000 8000 0000 070101030000 00010f070000 0000 0000 00000000000000000000000000000000000000000000000000000000
9e1adb4617d7d05111fe2a7e074f81738df4e2181b966ac48f66289b1cab5d4a
d1b42f82cfe36192a36ed37be30a6f9f75e7bca372449aec615e7d0721dc06ef
7540013c2866ec7f6b076e1445f907276b9f27ede2b298603604000000000000
00000200 00000200 84500100 12080100 Line by line: the version (3); the mining settings (k = 8,192, rank 128, an 8 × 16 tile pattern, dense); the fingerprint of matrix A; the fingerprint of matrix B; the winning ticket; and m = 131,072, n = 131,072, tile row 86,148, tile column 67,602. SHA256d of those bytes is 4d52a3fd…c77ae649, exactly the header’s last field. Every field of the public data is explained on certificate.
| Field | Bytes | Value |
|---|---|---|
| Certificate version certificate.version | 4 @0 | 3 u32 LE. V1 before block 71,935, V2 until 98,999, V3 (salted noise seeds) from 99,000. 0 = no certificate. |
| Block hash certificate.blockHash | 32 @4 | 1df2f2c1bf39b1a444e622c1788c11736da2f91f4e9a875a38dc23063c6d45b9 SHA256d of the 108-byte header this certificate proves (stored byte-reversed); must equal the header’s hash. |
| Public data length certificate.publicDataLength | 4 @36 | 164 164 for dense jobs; longer means MoE (invalid since 91,630). |
| Common dimension k certificate.publicData.k | 4 @40 | 8192 u32 LE: the shared dimension of A (m×k) and B (k×n). |
| Noise rank r certificate.publicData.rank | 2 @44 | 128 u16 LE: rank of the low-rank noise; must be ≥ 128 since block 96,251. |
| MMA type certificate.publicData.mmaType | 2 @46 | 0 0 = Int7 × Int7 → Int32. |
| Rows pattern certificate.publicData.rowsPattern | 6 @48 | [0,8,32,40,64,72,96,104] (h=8) Periodic row offsets of the hash tile, stored as (factor−1, length−1) × 3. |
| Columns pattern certificate.publicData.colsPattern | 6 @54 | [0,1,32,33,64,65,96,97,128,129,160,161,192,193,224,225] (w=16) Periodic column offsets of the hash tile. |
| Experts e certificate.publicData.experts | 2 @60 | 0 Number of MoE experts; 0 = dense (MoE is invalid on mainnet since 91,630). |
| Top-k certificate.publicData.topK | 2 @62 | 0 0 for dense jobs. |
| Reserved certificate.publicData.reserved | 28 @64 | 00000000000000000000000000000000000000000000000000000000 Must be zero. |
| hash_A certificate.publicData.hashA | 32 @92 | 9e1adb4617d7d05111fe2a7e074f81738df4e2181b966ac48f66289b1cab5d4a Keyed-BLAKE3 Merkle root of matrix A (key = job key). |
| hash_B certificate.publicData.hashB | 32 @124 | d1b42f82cfe36192a36ed37be30a6f9f75e7bca372449aec615e7d0721dc06ef Keyed-BLAKE3 Merkle root of Bᵀ (B column-major). |
| hash_jackpot certificate.publicData.hashJackpot | 32 @156 | 7540013c2866ec7f6b076e1445f907276b9f27ede2b298603604000000000000 The winning ticket: BLAKE3 keyed with seed_A over the tile transcript; read as a little-endian number and compared with the bound. |
| m (rows of A) certificate.publicData.m | 4 @188 | 131072 u32 LE; bound into the noise seeds since certificate V3. |
| n (columns of B) certificate.publicData.n | 4 @192 | 131072 u32 LE; bound into the noise seeds since certificate V3. |
| Tile row offset certificate.publicData.tRows | 4 @196 | 86148 Smallest row index of the winning hash tile. |
| Tile column offset certificate.publicData.tCols | 4 @200 | 67602 Smallest column index of the winning hash tile. |
| Proof length certificate.proofLength | 4 @204 | 59560 u32 LE; at most 60,000 bytes. |
| ZK proof (first 32 of 59,560 bytes) certificate.proof | 32 @208 | 59,560 bytes, collapsed pow_bits(3) ‖ rate_bits(3) ‖ zeta(16) ‖ Plonky2 proof: a STARK wrapped in two Plonky2 recursions. Not covered by the block hash. |
Why include the version?
To keep certificate versions apart. A version 2 and a version 3 certificate have the same byte layout but derive their noise differently, so the same public data means a different puzzle. Hashing the version in gives them different commitments: block 130,827’s public data under version 2 would commit to b1b8847d…0c4de66f, not 4d52a3fd…. Pearl’s code calls this domain separation, and makes version 3 override the version 2 method just to hash a 3.
Why leave the proof out?
Because zero-knowledge proofs are randomized. The same statement has many valid proofs, and proving the same winning ticket twice gives different bytes.
If the header committed to the proof, two copies of one block could have different hashes. Leaving it out means:
- The block’s ID is fixed early. As soon as a ticket wins, its public data is known, so the commitment, the header and the block hash are final. The slow part, proving (several seconds), happens afterwards and can’t change them.
- Any valid proof will do. A node accepts the block with whichever valid proof it receives.
The proof still can’t be reused for a different block. It is bound to this header and this public data, as the certificate explains.
Isn’t that circular?
It looks circular: the certificate contains the block hash, the block hash covers the proof commitment, and the proof commitment comes from the certificate. But each step only uses things made before it:
The key is that mining only ever reads the first 76 header bytes, through the job keyJob key: A BLAKE3 hash of the block template (the header minus its proof commitment) and the mining settings. All of the puzzle's randomness flows from it, tying the work to one block.. The proof commitment is computed after mining, from its result. In Pearl’s mining software, the gateway that builds the certificate writes the commitment into the header at that point.
What does a node check?
Two cheap comparisons first, then the expensive check:
-
The certificate names this header
The block hash stored in the certificate must equal SHA256d of the 108-byte header.
-
The header commits to this certificate
SHA256d(version ‖ public data), computed from the certificate, must equal the header’s last 32 bytes.
-
The proof verifies against the first 76 bytes
The Rust verifier gets the version, previous block, Merkle root, time and bits, but not the proof commitment, which would be circular. From them it rebuilds the job key and checks the ticket and the proof.
Together these tie header, public data and proof into one package: a certificate can’t be moved to another block, and a header can’t be paired with another certificate.
What about the genesis block?
Genesis is hard-coded and never verified, and its proof commitment is 32 zero bytes. Its stand-in certificate (164 zero bytes of public data and a 4-byte “proof”, deadbeef) would commit to 1879296c…30466d22 (shown byte-reversed, the way explorers display hashes), so the two don’t even match. It doesn’t matter: no node checks genesis.
Code: computing and checking the commitment
From certificate_v2.go (node/wire/certificate_v2.go:65-77 at v1.5.2, on GitHub); version 3 reuses it with its
own version number (certificate_v3.go (node/wire/certificate_v3.go:21-24 at v1.5.2, on GitHub)):
// proofCommitment computes SHA256d(CertificateVersion_LE(4) || publicData).
// V3 overrides ProofCommitment to hash version 3 (domain separation from V2).
func proofCommitment(version CertificateVersion, publicData []byte) chainhash.Hash {
buf := make([]byte, 4+len(publicData))
binary.LittleEndian.PutUint32(buf[:4], uint32(version))
copy(buf[4:], publicData)
return chainhash.DoubleHashH(buf)
} The node’s two comparisons, from verify.go (node/zkpow/verify.go:114-130 at v1.5.2, on GitHub):
certHash := cert.BlockHash()
blockHash := header.BlockHash()
if !certHash.IsEqual(&blockHash) { /* block hash mismatch */ }
proofCommitment := cert.ProofCommitment()
if header.ProofCommitment != proofCommitment { /* proof commitment mismatch */ }