sidestr_header/lib.rs
1//! `sidestr-header` — block headers for sidestr sidechains, in both families
2//! the parent table allows.
3//!
4//! A [sidestr](https://github.com/sidestr/spec) chain is an overlay on a
5//! Bitcoin-family parent, and SPEC 3 is explicit that the overlay names no
6//! header format and no hash: "everything the overlay does not set is
7//! inherited from the parent: header format and proof-of-work hash …
8//! Nothing in the document names a header format or a hash; the parent
9//! decides both." SPEC 3.2's parents table then fixes two families:
10//!
11//! | parent alias | chain | headers, proof of work | this crate |
12//! |---|---|---|---|
13//! | `btc`, `tbtc4` | Bitcoin mainnet, testnet4 | stock 80 bytes, SHA-256d | [`StockHeader`] |
14//! | `xbt`, `txbt4` | Bitcoin Knots' BLAKE2b fork of each | 164-byte v2, BLAKE2b | [`Blake2bV2Header`] |
15//!
16//! `ltc` and `vtc` are reserved upstream and absent here. The crate is a port
17//! of the reference JavaScript — the bitcoin-desktop/schema kernel's header
18//! codec, Knots proof of work and Knots overlay rules, and Melvin Carvalho's
19//! `siding` for how a sidestr block shapes its header and what its signature
20//! covers — under the same AGPL-3.0 licence (agentbox ADR-2106). Without the
21//! `core` feature it is `#![no_std]`, allocates nothing, and takes every
22//! primitive from RustCrypto ([`sha2`], [`blake2`]) with no `bitcoin` crate
23//! dependency (ADR-2096 D2): the header and its proof of work are the part of
24//! consensus that the parent's serialisation library does not own.
25//!
26//! With `core` (default) the [`family`] module implements
27//! `sidestr_core::HeaderFamily` for both header types, so
28//! `sidestr_core::StateOf<Blake2bV2>` and `ChainOf<Blake2bV2>` validate and
29//! produce a chain beside `xbt` or `txbt4` end to end — the v2 header, the
30//! BLAKE2b proof of work, the Knots overlay's rules and Knots' unified sighash
31//! on every spend — proven by replaying the live `sidestr:txbt4-siding` chain.
32//! The edge points from this crate to `sidestr-core`, never back.
33//!
34//! # What it gives a validator
35//!
36//! Per family, through [`HeaderFamily`] and the enum [`Header`] or the two
37//! structs directly:
38//!
39//! - the wire size and a strict `decode` / `encode` pair;
40//! - `hash()`, the block id, which in both families **is** the proof-of-work
41//! hash (SHA-256d of the 80 bytes; the v2 pipeline for Knots) — so there is
42//! no separate `pow_hash`;
43//! - [`Target`] with compact `bits` decoding, and [`check_pow`] with the
44//! `powLimit` semantics siding uses — `bits` is pinned to `powLimit`'s
45//! compact form and never retargets (SPEC 3, SPEC 4 step 1);
46//! - the BIP-325 block-data preimage over that family's serialisation
47//! ([`signet`]), so `sidestr-core` signs and verifies blocks without knowing
48//! the layout;
49//! - the version-bit-31 rule: bit 31 selects the family, so a stock header
50//! with it set and a v2 header without it are both refused at decode;
51//! - the fork activation constants of SPEC 3.2 ([`fork`]);
52//! - with `core`, the two families as `sidestr-core` sees them
53//! ([`Blake2bV2`], [`family::Stock`]).
54//!
55//! # Where this port departs from the reference
56//!
57//! - **The genesis is judged under the family's rules.** The reference
58//! applies block 0 on its hash; through `sidestr-core`'s
59//! `StateOf::from_genesis` a typed v2 genesis is held to
60//! `knots:rule-header-v2-from-fork`, `-height` and `-flags-reserved`, the
61//! proof of work and the signature before its hash is compared to the
62//! document, so a header the decoder would refuse cannot enter as a struct
63//! either (`tests/audit_regressions.rs`).
64//! - **Version bit 31 is refused on every stock path.** [`StockHeader::decode`]
65//! refuses the bytes; [`family::Stock`] reports the version as the kernel
66//! types it (`i32le`, so bit 31 is negative) and `btc:rule-header-version`
67//! refuses a typed header, as the kernel does on the same block.
68//! - **Compact targets Bitcoin Core rejects** (negative, overflow) are
69//! rejected by [`Target::from_compact`] where the kernel is lenient —
70//! unreachable on a valid sidestr chain, where `bits` is pinned to
71//! `powLimit`.
72//! - **A mirror's record framing is checked** on replay (`sidestr-core`'s
73//! `blockfile::read_block`): the `[u32 height][u32 size]` prefix of every
74//! record must agree with the index entry, and the entry must lie within
75//! the file.
76//!
77//! # The v2 header, field by field
78//!
79//! The 164-byte Knots v2 header keeps the classic 80-byte prefix (version with
80//! bit 31 set, previous hash, merkle root, time on wire, bits, nonce) and
81//! appends 84 bytes: two more nonces, a 128-bit extranonce, a time offset, the
82//! committed transaction count, a flags byte (ASIC profile in bits 0–1, time
83//! offset in bit 2, bits 6–7 reserved), the XOR-mask clear count, a 128-bit XOR
84//! key, the committed height, and a 32-byte merge-mining hook. The table with
85//! offsets is on [`Blake2bV2Header`]. Its hash is not a hash of the bytes but a
86//! commitment tree: two BIP-340 tagged-hash rounds over the fields, then two
87//! BLAKE2b-256 rounds whose second input layout the ASIC profile selects, then
88//! an XOR mask derived from the key (see [`Blake2bV2Header::hash`]).
89//!
90//! # The signet preimage
91//!
92//! Siding's `blockData` hashes the first 72 header bytes — version, previous
93//! hash, merkle root, time on wire — with the merkle root recomputed over the
94//! coinbase stripped of its solution. Those 72 bytes have the same layout in
95//! both families, so [`Header::block_data`] is one function.
96//!
97//! # Example
98//!
99//! ```
100//! use sidestr_header::{Header, HeaderFamily, Target};
101//!
102//! // sidestr:dreamlab block 0, beside tbtc4: stock family.
103//! let bytes = hex::decode(
104//! "0000002000000000000000000000000000000000000000000000000000000000000000003a87d59ecf60ab58ee75948cc39d1bb44ac4285747e64b5a1e7a960d37764cb40e67b26affff7f2002000000",
105//! ).unwrap();
106//! let header = HeaderFamily::Stock.decode(&bytes).unwrap();
107//! assert_eq!(header.hash().to_string(),
108//! "4db37517728bd509c0cb96ee5a2e3e2a77f9e965a092e9f67948b413d453dbc0");
109//!
110//! // SPEC 4 step 1 against the chain document's powLimit.
111//! let pow_limit = Target::from_hex(
112//! "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").unwrap();
113//! assert!(header.check_pow(&pow_limit).is_ok());
114//!
115//! // The same 80 bytes are not a v2 header, and 81 bytes are not a header.
116//! assert!(HeaderFamily::Blake2bV2.decode(&bytes).is_err());
117//! assert!(HeaderFamily::Stock.decode(&bytes[..79]).is_err());
118//!
119//! // Re-encoding is the identity.
120//! assert_eq!(header.encode().as_ref(), bytes.as_slice());
121//! # let _: Header = header;
122//! ```
123//!
124//! # Provenance
125//!
126//! - `codec/pow/knots-header-v2.js`, `codec/pow/blake2b.js`, `codec/hash.js`,
127//! `codec/codec.js`, `codec/headers.js`, `codec/overlays/knots-blake2b.js`
128//! (the overlay's header and block checks), `schema/overlays/knots-blake2b.jsonld`
129//! of [bitcoin-desktop/schema](https://github.com/bitcoin-desktop/schema)
130//! (AGPL-3.0), at commit `b8cbf6337c7450fe14ddc5bce00c7280059aab5d`.
131//! - `siding/lib/block.mjs`, `siding/lib/parents.mjs`, `siding/lib/chain.mjs`,
132//! `siding/lib/overlay.mjs` (`blake2bHeight: 0`, `unifiedSighashParam`),
133//! `SPEC.md` §3, §3.2, §4 of [sidestr/spec](https://github.com/sidestr/spec)
134//! (AGPL-3.0, Melvin Carvalho), at commit
135//! `2de40bdac4cba01be0864156a553d8287c22e279`.
136//! - Test vectors: Knots' own `block_header_v2.json` and real fork headers
137//! captured from a Knots 29.4.1 node on 2026-09-05, both carried by the
138//! schema kernel; siding's `blockData` on the live `sidestr:dreamlab` block 0
139//! and on kernel-hashed v2 headers, computed with the JavaScript engine as
140//! the oracle; and the live `sidestr:txbt4-siding` chain (229 blocks as of
141//! 2026-09-22) as the oracle for the whole BLAKE2b family through
142//! `sidestr-core` (`tests/core_family.rs`).
143
144#![cfg_attr(not(feature = "std"), no_std)]
145#![forbid(unsafe_code)]
146// The crate docs link `family`, `Blake2bV2` and `family::Stock`, which exist
147// only with `core`; without it those links have nowhere to point and are not
148// an error.
149#![cfg_attr(not(feature = "core"), allow(rustdoc::broken_intra_doc_links))]
150#![deny(missing_docs)]
151
152mod error;
153#[cfg(feature = "core")]
154pub mod family;
155pub mod fork;
156pub mod hash;
157pub mod signet;
158mod stock;
159mod target;
160mod v2;
161
162pub use error::Error;
163#[cfg(feature = "core")]
164pub use family::Blake2bV2;
165pub use stock::StockHeader;
166pub use target::Target;
167pub use v2::{
168 Blake2bV2Header, V2HashStages, FLAG_ASIC_PROFILE_MASK, FLAG_RESERVED_MASK,
169 FLAG_USE_TIME_OFFSET, POW_HASH_NAME as BLAKE2B_V2_POW_HASH_NAME,
170};
171
172use core::fmt;
173
174/// Version bit 31: set on every v2 header, clear on every stock header
175/// (`VERSION_HEADER_V2_FLAG` in `codec/pow/knots-header-v2.js`; the kernel's
176/// `structVariants` selects the v2 struct on it).
177pub const VERSION_HEADER_V2_FLAG: u32 = 0x8000_0000;
178
179/// The kernel's name for the stock family's proof-of-work hash.
180pub const SHA256D_POW_HASH_NAME: &str = "sha256d";
181
182/// A block hash in **display order** — the byte order bitcoind prints and a
183/// chain document's `genesisHash` uses; read as a big-endian number it
184/// compares directly against a [`Target`].
185///
186/// ```
187/// use sidestr_header::BlockHash;
188/// let h = BlockHash::from_hex("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f").unwrap();
189/// assert_eq!(h.to_string(), "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f");
190/// assert_eq!(h.to_wire()[0], 0x6f); // wire order is the reverse
191/// assert_eq!(BlockHash::from_wire(h.to_wire()), h);
192/// ```
193#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
194pub struct BlockHash(pub [u8; 32]);
195
196impl BlockHash {
197 /// All zeros: a genesis header's `prev_block_hash`.
198 pub const ZERO: BlockHash = BlockHash([0u8; 32]);
199
200 /// From the 32 bytes as they sit in a header (wire / internal order).
201 pub fn from_wire(mut wire: [u8; 32]) -> Self {
202 wire.reverse();
203 BlockHash(wire)
204 }
205
206 /// The 32 bytes as they sit in a header (wire / internal order).
207 pub fn to_wire(self) -> [u8; 32] {
208 let mut b = self.0;
209 b.reverse();
210 b
211 }
212
213 /// From 64 hex characters in display order.
214 pub fn from_hex(s: &str) -> Result<Self, Error> {
215 hash::hex32(s).map(BlockHash).ok_or(Error::InvalidHex)
216 }
217
218 /// `hash ≤ target`, both read as big-endian 256-bit numbers
219 /// (`BigInt('0x' + blockHash) <= expandCompact(bits)` in the kernel).
220 pub fn meets(&self, target: &Target) -> bool {
221 self.0 <= target.to_be_bytes()
222 }
223}
224
225impl fmt::Debug for BlockHash {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 f.write_str("BlockHash(")?;
228 hash::fmt_hex(f, &self.0)?;
229 f.write_str(")")
230 }
231}
232
233impl fmt::Display for BlockHash {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 hash::fmt_hex(f, &self.0)
236 }
237}
238
239/// The two header families of SPEC 3.2, named after the header they carry.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
241pub enum HeaderFamily {
242 /// Stock 80-byte header, SHA-256d: beside `btc` or `tbtc4`
243 /// (`family: 'stock'` in `siding/lib/parents.mjs`).
244 Stock,
245 /// Knots 164-byte v2 header, BLAKE2b: beside `xbt` or `txbt4`
246 /// (`family: 'blake2b'`).
247 Blake2bV2,
248}
249
250impl HeaderFamily {
251 /// The family's header size in bytes: 80 or 164.
252 pub const fn wire_size(self) -> usize {
253 match self {
254 HeaderFamily::Stock => StockHeader::WIRE_SIZE,
255 HeaderFamily::Blake2bV2 => Blake2bV2Header::WIRE_SIZE,
256 }
257 }
258
259 /// The kernel's name for the family's proof-of-work hash: `sha256d` or
260 /// `knots:blake2b-v2`.
261 pub const fn pow_hash_name(self) -> &'static str {
262 match self {
263 HeaderFamily::Stock => SHA256D_POW_HASH_NAME,
264 HeaderFamily::Blake2bV2 => v2::POW_HASH_NAME,
265 }
266 }
267
268 /// The family a version word claims: bit 31 set means v2. This is the
269 /// kernel's `structVariants` selector, useful when the bytes' provenance
270 /// is unknown; a validator knows its family from the chain document's
271 /// parent and should use [`HeaderFamily::decode`] with that.
272 pub const fn from_version(version: u32) -> HeaderFamily {
273 if version & VERSION_HEADER_V2_FLAG != 0 {
274 HeaderFamily::Blake2bV2
275 } else {
276 HeaderFamily::Stock
277 }
278 }
279
280 /// Decodes `bytes` as this family's header.
281 pub fn decode(self, bytes: &[u8]) -> Result<Header, Error> {
282 match self {
283 HeaderFamily::Stock => StockHeader::decode(bytes).map(Header::Stock),
284 HeaderFamily::Blake2bV2 => Blake2bV2Header::decode(bytes).map(Header::Blake2bV2),
285 }
286 }
287}
288
289impl fmt::Display for HeaderFamily {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 f.write_str(match self {
292 HeaderFamily::Stock => "stock",
293 HeaderFamily::Blake2bV2 => "blake2b-v2",
294 })
295 }
296}
297
298/// A header of either family, with the operations a sidestr validator needs
299/// dispatched to the right one.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum Header {
302 /// An 80-byte stock header.
303 Stock(StockHeader),
304 /// A 164-byte Knots v2 header.
305 Blake2bV2(Blake2bV2Header),
306}
307
308/// A header's wire bytes, sized for the larger family; `as_ref()` yields
309/// exactly the family's bytes.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub struct EncodedHeader {
312 buf: [u8; 164],
313 len: usize,
314}
315
316impl AsRef<[u8]> for EncodedHeader {
317 fn as_ref(&self) -> &[u8] {
318 &self.buf[..self.len]
319 }
320}
321
322impl core::ops::Deref for EncodedHeader {
323 type Target = [u8];
324 fn deref(&self) -> &[u8] {
325 self.as_ref()
326 }
327}
328
329impl Header {
330 /// Which family this header belongs to.
331 pub const fn family(&self) -> HeaderFamily {
332 match self {
333 Header::Stock(_) => HeaderFamily::Stock,
334 Header::Blake2bV2(_) => HeaderFamily::Blake2bV2,
335 }
336 }
337
338 /// The raw wire version.
339 pub const fn version(&self) -> u32 {
340 match self {
341 Header::Stock(h) => h.version,
342 Header::Blake2bV2(h) => h.version,
343 }
344 }
345
346 /// The previous block's hash.
347 pub const fn prev_block_hash(&self) -> BlockHash {
348 match self {
349 Header::Stock(h) => h.prev_block_hash,
350 Header::Blake2bV2(h) => h.prev_block_hash,
351 }
352 }
353
354 /// The merkle root, wire order.
355 pub const fn merkle_root(&self) -> [u8; 32] {
356 match self {
357 Header::Stock(h) => h.merkle_root,
358 Header::Blake2bV2(h) => h.merkle_root,
359 }
360 }
361
362 /// The consensus block time (for v2, with the offset applied when
363 /// flagged).
364 pub fn time(&self) -> u32 {
365 match self {
366 Header::Stock(h) => h.time,
367 Header::Blake2bV2(h) => h.time(),
368 }
369 }
370
371 /// Compact target.
372 pub const fn bits(&self) -> u32 {
373 match self {
374 Header::Stock(h) => h.bits,
375 Header::Blake2bV2(h) => h.bits,
376 }
377 }
378
379 /// The (first) nonce.
380 pub const fn nonce(&self) -> u32 {
381 match self {
382 Header::Stock(h) => h.nonce,
383 Header::Blake2bV2(h) => h.nonce,
384 }
385 }
386
387 /// The committed height: a v2 header carries it, a stock header does not
388 /// (siding's `blockHeight` then reads the coinbase's BIP 34 push, which
389 /// is `sidestr-core`'s job).
390 pub const fn height(&self) -> Option<u32> {
391 match self {
392 Header::Stock(_) => None,
393 Header::Blake2bV2(h) => Some(h.height),
394 }
395 }
396
397 /// The block hash — the family's proof-of-work hash, display order.
398 pub fn hash(&self) -> BlockHash {
399 match self {
400 Header::Stock(h) => h.hash(),
401 Header::Blake2bV2(h) => h.hash(),
402 }
403 }
404
405 /// The wire bytes.
406 pub fn encode(&self) -> EncodedHeader {
407 let mut buf = [0u8; 164];
408 let len = match self {
409 Header::Stock(h) => {
410 buf[..80].copy_from_slice(&h.encode());
411 80
412 }
413 Header::Blake2bV2(h) => {
414 buf.copy_from_slice(&h.encode());
415 164
416 }
417 };
418 EncodedHeader { buf, len }
419 }
420
421 /// The target `bits` encodes.
422 pub fn target(&self) -> Result<Target, Error> {
423 Target::from_compact(self.bits())
424 }
425
426 /// `hash ≤ target(bits)`.
427 pub fn meets_target(&self) -> bool {
428 match self {
429 Header::Stock(h) => h.meets_target(),
430 Header::Blake2bV2(h) => h.meets_target(),
431 }
432 }
433
434 /// SPEC 4 step 1 against the chain document's `powLimit`; see
435 /// [`check_pow`].
436 pub fn check_pow(&self, pow_limit: &Target) -> Result<(), Error> {
437 check_pow(self.bits(), self.hash(), pow_limit)
438 }
439
440 /// The 72-byte BIP-325 preimage with the stripped merkle root in place.
441 pub fn signet_preimage(&self, stripped_merkle_root: [u8; 32]) -> [u8; signet::BLOCK_DATA_LEN] {
442 match self {
443 Header::Stock(h) => h.signet_preimage(stripped_merkle_root),
444 Header::Blake2bV2(h) => h.signet_preimage(stripped_merkle_root),
445 }
446 }
447
448 /// The BIP-325 block data the block signature commits to (SPEC 4 step 2).
449 pub fn block_data(&self, stripped_merkle_root: [u8; 32]) -> [u8; 32] {
450 signet::block_data(&self.signet_preimage(stripped_merkle_root))
451 }
452}
453
454/// SPEC 4 step 1, "the header meets `powLimit`. No difficulty adjustment, no
455/// minimum-difficulty window", as siding and the kernel enforce it together:
456///
457/// 1. `bits` equals `pow_limit.to_compact()` — siding writes that value into
458/// every block (`siding/lib/chain.mjs`) and the kernel's difficulty rule
459/// under `powNoRetargeting` requires each block's bits to equal the
460/// previous block's, so the value never moves from genesis;
461/// 2. `hash ≤ Target::from_compact(bits)` (`btc:rule-header-pow`).
462///
463/// Compact encoding keeps only three significant bytes, so the target a
464/// block is actually checked against is `powLimit` truncated to its compact
465/// form — for the usual `7fff…ff` limit that is `7fffff00…00`, exactly as
466/// the kernel's `expandCompact(bits)` computes it.
467///
468/// Errors are [`Error::BitsNotPowLimit`] and [`Error::TargetNotMet`] in that
469/// order; a `bits` that does not decode is reported by [`Target::from_compact`].
470///
471/// ```
472/// use sidestr_header::{check_pow, BlockHash, Target};
473/// let lim = Target::from_hex("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").unwrap();
474/// let easy = BlockHash::from_hex("4db37517728bd509c0cb96ee5a2e3e2a77f9e965a092e9f67948b413d453dbc0").unwrap();
475/// assert!(check_pow(0x207f_ffff, easy, &lim).is_ok());
476/// // Wrong bits, even an easier target, is refused before the hash is looked at.
477/// assert!(check_pow(0x2100_ffff, easy, &lim).is_err());
478/// // A hash above the limit is refused.
479/// let high = BlockHash::from_hex("8000000000000000000000000000000000000000000000000000000000000000").unwrap();
480/// assert!(check_pow(0x207f_ffff, high, &lim).is_err());
481/// ```
482pub fn check_pow(bits: u32, hash: BlockHash, pow_limit: &Target) -> Result<(), Error> {
483 let expected = pow_limit.to_compact();
484 if bits != expected {
485 return Err(Error::BitsNotPowLimit { bits, expected });
486 }
487 let target = Target::from_compact(bits)?;
488 if hash.meets(&target) {
489 Ok(())
490 } else {
491 Err(Error::TargetNotMet)
492 }
493}