Skip to main content

sidestr_core/
rules.rs

1//! The rules (SPEC 4, 6, 7): Bitcoin's block rules as the reference kernel
2//! runs them for a `btc:regtest`-derived network, with the sidestr overlay —
3//! zero subsidy, the signature challenge, the claim rule and the burn rule —
4//! and, beside a BLAKE2b parent, the Knots overlay's header and block rules.
5//!
6//! A port of the checks in `bitcoin-desktop/schema` `codec/blocks.js` and
7//! `codec/headers.js` (Melvin Carvalho, AGPL-3.0) that a sidestr chain
8//! exercises, and of `siding/lib/overlay.mjs`. Rule ids are the kernel's, so
9//! a refusal names the same rule the reference names. Rule order within a
10//! phase is `schema/validate.jsonld`'s.
11//!
12//! # Verdicts
13//!
14//! Every check answers `Some(true)` (pass), `Some(false)` (fail) or `None`
15//! (skipped: the context to judge is absent, or the rule is not yet active at
16//! this height). A phase passes when no check failed. This is the kernel's
17//! three-valued convention; the one place this crate departs from it is
18//! script verification, where an input this crate cannot verify *fails*
19//! rather than skips (see [`crate::sighash::verify_taproot_key_path`]).
20//!
21//! # Phases
22//!
23//! | phase | function | checks |
24//! |---|---|---|
25//! | header | [`validate_header`] | prev link, proof of work, `bits` unchanged (no retarget), median time past, not too far in the future, version; then the family's own (`knots:rule-header-height`, `knots:rule-header-flags-reserved`) |
26//! | transaction | [`validate_transaction`] | inputs and outputs non-empty, weight, values, unique inputs, coinbase shape, coinbase script 2–100 bytes |
27//! | block | [`validate_block_structure`] | coinbase first and alone, merkle root, no duplicates, sigops, weight, every transaction; **`sidestr:rule-block-signature`**; the family's own (`knots:rule-block-txcount`) |
28//! | block-context | [`validate_block_context`] | BIP 34 height, finality, BIP 68, inputs available, coinbase maturity, fees, **coinbase amount ≤ fees + claims**, witness commitment, scripts under the family's sighash rules; **`sidestr:rule-pegouts`**, **`sidestr:rule-claims`** |
29//!
30//! Extension point: [`BlockRule`] adds a block-context rule (the assets and
31//! pool rules of SPEC 12 are rules in that sense) without touching this file.
32
33use std::collections::{BTreeMap, HashMap, HashSet};
34
35use bitcoin::hashes::Hash;
36use bitcoin::{BlockHash, OutPoint, Script, Target, Transaction, TxOut, Txid};
37
38use crate::block::{
39    block_weight, merkle_root_of_txs, verify_block_signature, witness_root_of_txs, HeaderFamily,
40    SidestrBlock,
41};
42use crate::marker::{looks_like_pegout, parse_claims, parse_pegout, Burn};
43use crate::sighash::verify_taproot_key_path;
44
45/// Network parameters a sidestr chain inherits (`btc:regtest` in
46/// `schema/chain.jsonld`, as `sidestrGraph` extends it) — everything the
47/// checks read. The subsidy is zero by construction (SPEC 1).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Params {
50    /// 4,000,000 weight units.
51    pub max_block_weight: u64,
52    /// 21,000,000 BTC in sats.
53    pub max_money: u64,
54    /// 80,000: four times the legacy sigop count must stay under it.
55    pub max_block_sigops_cost: u64,
56    /// A coinbase output may be spent this many blocks later: 100.
57    pub coinbase_maturity: u32,
58    /// A header may be at most this far ahead of the clock: 7,200 s.
59    pub max_future_block_time: u32,
60    /// BIP 34 applies from this height: 1.
61    pub bip34_height: u32,
62    /// The witness commitment rule applies from this height: 0.
63    pub segwit_height: u32,
64    /// Header versions: ≥ 4 from `bip65_height`, ≥ 3 from `bip66_height`, ≥ 2 from `bip34_height`.
65    pub bip65_height: u32,
66    /// See `bip65_height`.
67    pub bip66_height: u32,
68}
69
70impl Default for Params {
71    fn default() -> Self {
72        Self {
73            max_block_weight: 4_000_000,
74            max_money: 2_100_000_000_000_000,
75            max_block_sigops_cost: 80_000,
76            coinbase_maturity: 100,
77            max_future_block_time: 7_200,
78            bip34_height: 1,
79            segwit_height: 0,
80            bip65_height: 1,
81            bip66_height: 1,
82        }
83    }
84}
85
86/// One rule's outcome.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct RuleResult {
89    /// The rule id, e.g. `btc:rule-block-merkle-root` or `sidestr:rule-claims`.
90    pub rule: String,
91    /// `Some(true)` pass, `Some(false)` fail, `None` skipped.
92    pub ok: Option<bool>,
93}
94
95impl RuleResult {
96    /// A result for a rule.
97    pub fn new(rule: &str, ok: Option<bool>) -> Self {
98        Self {
99            rule: rule.to_string(),
100            ok,
101        }
102    }
103}
104
105/// A phase's outcomes.
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct Verdict {
108    /// Every rule that ran, in order.
109    pub results: Vec<RuleResult>,
110}
111
112impl Verdict {
113    fn push(&mut self, rule: &str, ok: Option<bool>) {
114        self.results.push(RuleResult::new(rule, ok));
115    }
116    /// No rule failed.
117    pub fn ok(&self) -> bool {
118        self.results.iter().all(|r| r.ok != Some(false))
119    }
120    /// The ids of the rules that failed.
121    pub fn failed(&self) -> Vec<String> {
122        self.results
123            .iter()
124            .filter(|r| r.ok == Some(false))
125            .map(|r| r.rule.clone())
126            .collect()
127    }
128    /// Append another phase's results.
129    pub fn extend(&mut self, other: Verdict) {
130        self.results.extend(other.results);
131    }
132}
133
134/// A coin in the UTXO set.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Coin {
137    /// The output.
138    pub output: TxOut,
139    /// The height of the block that created it.
140    pub height: u32,
141    /// Whether the creating transaction was a coinbase (maturity applies).
142    pub coinbase: bool,
143}
144
145/// The UTXO set: outpoint → coin. `OP_RETURN` outputs are never coins.
146pub type Utxo = HashMap<OutPoint, Coin>;
147
148/// What the sidestr overlay remembers across blocks (`siding/lib/overlay.mjs`):
149/// outpoints claimed so far by the height that claimed them, and burns seen.
150/// Validation is idempotent for one height (a block re-validated, or a
151/// competing block at the same height, may claim the same outpoint); a
152/// different height may not. Level 1: no reorgs, so nothing is unwound.
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
154pub struct Records {
155    /// Parent outpoint (`txid:vout`, txid display order) → height that claimed it.
156    pub claims: HashMap<(String, u32), u32>,
157    /// Sidechain outpoint → the burn.
158    pub pegouts: BTreeMap<(String, u32), Burn>,
159}
160
161impl Records {
162    /// Whether the parent outpoint has been claimed on this chain.
163    pub fn claimed(&self, txid: &str, vout: u32) -> bool {
164        self.claims.contains_key(&(txid.to_string(), vout))
165    }
166    /// Every burn the chain has validated, oldest first (SPEC 7).
167    pub fn pegouts(&self) -> Vec<Burn> {
168        let mut v: Vec<Burn> = self.pegouts.values().cloned().collect();
169        v.sort_by_key(|b| b.height);
170        v
171    }
172}
173
174/// The chain-specific inputs to the sidestr rules.
175#[derive(Debug, Clone)]
176pub struct Overlay<'a> {
177    /// The challenge every block's solution must satisfy.
178    pub challenge: &'a Script,
179    /// The least a burn may carry.
180    pub pegout_min: u64,
181    /// The only subsidy a sidestr chain ever pays: the document's pegs, minted
182    /// by the genesis coinbase (SPEC 5), in sats. `btc:rule-blockctx-coinbase-amount`
183    /// allows it at height 0 and nothing at any other height (SPEC 1).
184    pub genesis_subsidy: u64,
185}
186
187// --- header phase (codec/headers.js) ------------------------------------------------
188
189/// What [`validate_header`] needs beyond the header.
190#[derive(Debug, Clone)]
191pub struct HeaderContext<'a, F: HeaderFamily> {
192    /// The header's height.
193    pub height: u32,
194    /// The previous header, when known.
195    pub prev: Option<&'a F::Header>,
196    /// The headers immediately before this one, up to 11, oldest first.
197    pub mtp_window: &'a [F::Header],
198    /// The clock, for the future-time rule; `None` skips it.
199    pub now: Option<u32>,
200}
201
202/// Median of the last (up to) 11 block timestamps (`headers.js medianTimePast`).
203pub fn median_time_past<F: HeaderFamily>(family: &F, window: &[F::Header]) -> u32 {
204    let start = window.len().saturating_sub(11);
205    let mut times: Vec<u32> = window[start..].iter().map(|h| family.time(h)).collect();
206    times.sort_unstable();
207    times[times.len() >> 1]
208}
209
210/// The header phase: `btc:rule-header-*` as the kernel runs them for a chain
211/// with `powNoRetargeting` and no timewarp fix, then the family's own rules.
212pub fn validate_header<F: HeaderFamily>(
213    family: &F,
214    params: &Params,
215    header: &F::Header,
216    ctx: &HeaderContext<F>,
217) -> Verdict {
218    let mut v = Verdict::default();
219    v.push(
220        "btc:rule-header-prev-link",
221        ctx.prev
222            .map(|p| family.prev(header) == family.block_hash(p)),
223    );
224    v.push(
225        "btc:rule-header-pow",
226        Some(Target::from_compact(family.bits(header)).is_met_by(family.block_hash(header))),
227    );
228    // powNoRetargeting: the bits required of the block following prev are prev's
229    v.push(
230        "btc:rule-header-difficulty",
231        ctx.prev.map(|p| family.bits(header) == family.bits(p)),
232    );
233    let need = 11.min(ctx.height as usize);
234    v.push(
235        "btc:rule-header-mtp",
236        (ctx.mtp_window.len() >= need && !ctx.mtp_window.is_empty())
237            .then(|| family.time(header) > median_time_past(family, ctx.mtp_window)),
238    );
239    v.push(
240        "btc:rule-header-time-future",
241        ctx.now.map(|now| {
242            u64::from(family.time(header))
243                <= u64::from(now) + u64::from(params.max_future_block_time)
244        }),
245    );
246    let min_version = if ctx.height >= params.bip65_height {
247        4
248    } else if ctx.height >= params.bip66_height {
249        3
250    } else if ctx.height >= params.bip34_height {
251        2
252    } else {
253        1
254    };
255    // compared as the kernel's codec types the field: i32le on a stock header (bit 31 set is negative and
256    // fails), u32le on a Knots v2 header (bit 31 is mandatory there); see HeaderFamily::version_number
257    v.push(
258        "btc:rule-header-version",
259        Some(family.version_number(header) >= min_version),
260    );
261    v.push("btc:rule-header-timewarp", None); // no timewarpFix on a regtest-derived network
262    v.results.extend(family.header_rules(header, ctx.height));
263    v
264}
265
266// --- transaction phase (codec/blocks.js txChecks) ------------------------------------
267
268fn is_coinbase(tx: &Transaction) -> bool {
269    tx.input.len() == 1 && tx.input[0].previous_output == OutPoint::null()
270}
271
272fn sum_out(tx: &Transaction) -> Option<u64> {
273    tx.output
274        .iter()
275        .try_fold(0u64, |s, o| s.checked_add(o.value.to_sat()))
276}
277
278/// The transaction phase: `btc:rule-tx-*`. `coinbase` says whether the
279/// transaction is the block's first.
280pub fn validate_transaction(params: &Params, tx: &Transaction, coinbase: bool) -> Verdict {
281    let mut v = Verdict::default();
282    v.push("btc:rule-tx-inputs-nonempty", Some(!tx.input.is_empty()));
283    v.push("btc:rule-tx-outputs-nonempty", Some(!tx.output.is_empty()));
284    v.push(
285        "btc:rule-tx-size",
286        Some(tx.weight().to_wu() <= params.max_block_weight),
287    );
288    v.push(
289        "btc:rule-tx-output-values",
290        Some(
291            tx.output
292                .iter()
293                .all(|o| o.value.to_sat() <= params.max_money)
294                && sum_out(tx).is_some_and(|s| s <= params.max_money),
295        ),
296    );
297    v.push(
298        "btc:rule-tx-inputs-unique",
299        Some(
300            tx.input
301                .iter()
302                .map(|i| i.previous_output)
303                .collect::<HashSet<_>>()
304                .len()
305                == tx.input.len(),
306        ),
307    );
308    v.push(
309        "btc:rule-tx-prevouts",
310        Some(if coinbase {
311            is_coinbase(tx)
312        } else {
313            tx.input
314                .iter()
315                .all(|i| i.previous_output.txid != Txid::all_zeros())
316        }),
317    );
318    v.push(
319        "btc:rule-tx-coinbase-script",
320        coinbase.then(|| {
321            tx.input
322                .first()
323                .is_some_and(|i| (2..=100).contains(&i.script_sig.len()))
324        }),
325    );
326    v
327}
328
329// --- block phase (codec/blocks.js blockChecks + the signature rule) ---------------------
330
331/// Legacy signature-operation count (Core's `GetSigOpCount` with
332/// `fAccurate=false`): `CHECKSIG(VERIFY)` counts 1, `CHECKMULTISIG(VERIFY)` 20.
333fn legacy_sigops(txdata: &[Transaction]) -> u64 {
334    txdata.iter().fold(0u64, |n, tx| {
335        let ins = tx.input.iter().fold(0u64, |n, i| {
336            n.saturating_add(i.script_sig.count_sigops_legacy() as u64)
337        });
338        let outs = tx.output.iter().fold(0u64, |n, o| {
339            n.saturating_add(o.script_pubkey.count_sigops_legacy() as u64)
340        });
341        n.saturating_add(ins).saturating_add(outs)
342    })
343}
344
345/// The block phase: `btc:rule-block-*`, then `sidestr:rule-block-signature`,
346/// then the family's own block rules.
347pub fn validate_block_structure<F: HeaderFamily>(
348    family: &F,
349    params: &Params,
350    overlay: &Overlay,
351    block: &F::Block,
352) -> Verdict {
353    let txdata = block.txdata();
354    let mut v = Verdict::default();
355    let txids: Vec<Txid> = txdata.iter().map(Transaction::compute_txid).collect();
356    v.push(
357        "btc:rule-block-coinbase-first",
358        Some(txdata.first().is_some_and(is_coinbase)),
359    );
360    v.push(
361        "btc:rule-block-coinbase-single",
362        Some(txdata.iter().skip(1).all(|tx| !is_coinbase(tx))),
363    );
364    v.push(
365        "btc:rule-block-merkle-root",
366        Some(
367            !txdata.is_empty() && merkle_root_of_txs(txdata) == family.merkle_root(block.header()),
368        ),
369    );
370    v.push(
371        "btc:rule-block-tx-duplicates",
372        Some(txids.iter().collect::<HashSet<_>>().len() == txids.len()),
373    );
374    v.push(
375        "btc:rule-block-sigops",
376        Some(legacy_sigops(txdata).saturating_mul(4) <= params.max_block_sigops_cost),
377    );
378    v.push(
379        "btc:rule-block-weight",
380        Some(block_weight(family, block) <= params.max_block_weight),
381    );
382    v.push(
383        "btc:rule-block-transactions",
384        Some(
385            txdata
386                .iter()
387                .enumerate()
388                .all(|(i, tx)| validate_transaction(params, tx, i == 0).ok()),
389        ),
390    );
391    v.push(
392        "sidestr:rule-block-signature",
393        Some(verify_block_signature(family, block, overlay.challenge)),
394    );
395    v.results
396        .extend(family.block_rules(block.header(), txdata.len()));
397    v
398}
399
400// --- block-context phase (codec/blocks.js contextChecks + the overlay) ------------------
401
402/// The block's spending, resolved against the UTXO set and the block itself
403/// (`blocks.js #resolveSpending`). With a full UTXO set nothing is
404/// "unresolved": a missing coin is a definite violation.
405#[derive(Debug, Clone, Default)]
406pub struct Spending {
407    /// Total fees of the non-coinbase transactions.
408    pub fees: u64,
409    /// Inputs spending a coin that does not exist or was spent earlier in the block.
410    pub missing: Vec<OutPoint>,
411    /// Transactions whose outputs exceed their inputs.
412    pub deficits: Vec<Txid>,
413    /// Inputs spending an immature coinbase.
414    pub premature: Vec<OutPoint>,
415    /// BIP 68 violations.
416    pub seqlock_violations: Vec<OutPoint>,
417    /// BIP 68 locks this context cannot judge (time-based).
418    pub seqlock_unknown: usize,
419    /// Every resolved input: (transaction index, input index, its prevout).
420    pub resolved: Vec<(usize, usize, TxOut)>,
421}
422
423/// Resolve the block's inputs against `utxo` and the block's own earlier
424/// outputs, in order; does not mutate the set.
425pub fn resolve_spending(
426    params: &Params,
427    txdata: &[Transaction],
428    utxo: &Utxo,
429    height: u32,
430) -> Spending {
431    const SEQ_DISABLE: u32 = 0x8000_0000;
432    const SEQ_TYPE: u32 = 0x0040_0000;
433    const SEQ_MASK: u32 = 0x0000_ffff;
434    let mut s = Spending::default();
435    let mut spent_here: HashSet<OutPoint> = HashSet::new();
436    let mut created_here: HashMap<OutPoint, Coin> = HashMap::new();
437    for (ti, tx) in txdata.iter().enumerate() {
438        let txid = tx.compute_txid();
439        if ti > 0 {
440            let mut in_sum = 0u64;
441            let mut values_ok = true;
442            for (ii, inp) in tx.input.iter().enumerate() {
443                let key = inp.previous_output;
444                if spent_here.contains(&key) {
445                    s.missing.push(key);
446                    values_ok = false;
447                } else {
448                    let coin = created_here.get(&key).or_else(|| utxo.get(&key));
449                    let mut coin_height = None;
450                    match coin {
451                        Some(c) => {
452                            in_sum = in_sum.saturating_add(c.output.value.to_sat());
453                            s.resolved.push((ti, ii, c.output.clone()));
454                            coin_height = Some(c.height);
455                            if c.coinbase
456                                && height.saturating_sub(c.height) < params.coinbase_maturity
457                            {
458                                s.premature.push(key);
459                            }
460                        }
461                        None => {
462                            s.missing.push(key);
463                            values_ok = false;
464                        }
465                    }
466                    let seq = inp.sequence.0;
467                    if tx.version.0 >= 2 && seq & SEQ_DISABLE == 0 {
468                        let value = seq & SEQ_MASK;
469                        if value > 0 {
470                            if seq & SEQ_TYPE != 0 {
471                                s.seqlock_unknown += 1;
472                            } else if let Some(ch) = coin_height {
473                                if u64::from(height) < u64::from(ch) + u64::from(value) {
474                                    s.seqlock_violations.push(key);
475                                }
476                            } else {
477                                s.seqlock_unknown += 1;
478                            }
479                        }
480                    }
481                }
482                spent_here.insert(key);
483            }
484            if values_ok {
485                match sum_out(tx) {
486                    Some(out_sum) if in_sum >= out_sum => {
487                        s.fees = s.fees.saturating_add(in_sum - out_sum)
488                    }
489                    _ => s.deficits.push(txid),
490                }
491            }
492        }
493        for (vout, o) in tx.output.iter().enumerate() {
494            if !o.script_pubkey.is_op_return() {
495                created_here.insert(
496                    OutPoint {
497                        txid,
498                        vout: vout as u32,
499                    },
500                    Coin {
501                        output: o.clone(),
502                        height,
503                        coinbase: ti == 0,
504                    },
505                );
506            }
507        }
508    }
509    s
510}
511
512/// What a block-context rule sees.
513#[derive(Debug)]
514pub struct BlockContext<'a, F: HeaderFamily> {
515    /// The block.
516    pub block: &'a F::Block,
517    /// Its height.
518    pub height: u32,
519    /// The resolved spending.
520    pub spending: &'a Spending,
521    /// Median time past of the previous 11 headers, when known.
522    pub mtp: Option<u32>,
523    /// The overlay's records before this block.
524    pub records: &'a Records,
525}
526
527/// An additional block-context rule: the extension point for rules a chain
528/// document may name beyond the core (SPEC 12). It sees the same context
529/// the built-in rules see and answers the same three ways.
530pub trait BlockRule<F: HeaderFamily>: core::fmt::Debug {
531    /// The rule id reported in the verdict.
532    fn id(&self) -> &str;
533    /// The check.
534    fn check(&self, ctx: &BlockContext<F>) -> Option<bool>;
535}
536
537/// The kernel's lenient BIP 34 read (`blocks.js bip34Height`): `OP_1`..`OP_16`
538/// or a 1–5 byte little-endian push, no minimality check. What
539/// `btc:rule-blockctx-bip34-height` compares against the height; the strict
540/// reading is [`crate::block::coinbase_height`], which decides which height a
541/// block *claims* before the rules run.
542fn bip34_height_lenient(coinbase: &Transaction) -> Option<u32> {
543    let script = coinbase.input.first()?.script_sig.as_bytes();
544    let op = *script.first()?;
545    if (0x51..=0x60).contains(&op) {
546        return Some(u32::from(op - 0x50));
547    }
548    let len = usize::from(op);
549    if !(1..=5).contains(&len) || script.len() < 1 + len {
550        return None;
551    }
552    let n = (1..=len)
553        .rev()
554        .fold(0u64, |n, i| n * 256 + u64::from(script[i]));
555    u32::try_from(n).ok()
556}
557
558/// The witness commitment carried in a coinbase output (`blocks.js
559/// witnessCommitment`): the last output starting `OP_RETURN 0x24 aa21a9ed`.
560fn witness_commitment_in(coinbase: &Transaction) -> Option<[u8; 32]> {
561    coinbase
562        .output
563        .iter()
564        .rev()
565        .find(|o| {
566            o.script_pubkey.len() >= 38
567                && o.script_pubkey
568                    .as_bytes()
569                    .starts_with(&[0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed])
570        })
571        .and_then(|o| o.script_pubkey.as_bytes()[6..38].try_into().ok())
572}
573
574/// `dsha256(witness merkle root || witness reserved value)`: the coinbase
575/// wtxid is all zeros; the reserved value is the coinbase witness's first
576/// item (32 zero bytes when absent).
577fn witness_commitment_hash(txdata: &[Transaction]) -> [u8; 32] {
578    let root = witness_root_of_txs(txdata);
579    let mut cat = [0u8; 64];
580    cat[..32].copy_from_slice(&root);
581    if let Some(reserved) = txdata[0]
582        .input
583        .first()
584        .and_then(|i| i.witness.iter().next())
585    {
586        let n = reserved.len().min(32);
587        cat[32..32 + n].copy_from_slice(&reserved[..n]);
588    }
589    bitcoin::hashes::sha256d::Hash::hash(&cat).to_byte_array()
590}
591
592/// A candidate block with the chain state it is judged against.
593#[derive(Debug)]
594pub struct Candidate<'a, F: HeaderFamily> {
595    /// The block.
596    pub block: &'a F::Block,
597    /// The height it claims.
598    pub height: u32,
599    /// The UTXO set before it.
600    pub utxo: &'a Utxo,
601    /// Median time past of the headers before it, when known.
602    pub mtp: Option<u32>,
603    /// The overlay's records before it.
604    pub records: &'a Records,
605    /// Rules the document names beyond the core.
606    pub extra: &'a [Box<dyn BlockRule<F>>],
607}
608
609/// The block-context phase for a candidate. Returns the verdict, the resolved
610/// spending, and the records the block *would* leave — claims and burns keyed
611/// by its height — which the caller commits when the block is applied.
612pub fn validate_block_context<F: HeaderFamily>(
613    family: &F,
614    params: &Params,
615    overlay: &Overlay,
616    c: &Candidate<F>,
617) -> (Verdict, Spending, Records) {
618    let Candidate {
619        block,
620        height,
621        utxo,
622        mtp,
623        records,
624        extra,
625    } = *c;
626    let txdata = block.txdata();
627    let spending = resolve_spending(params, txdata, utxo, height);
628    let mut v = Verdict::default();
629    let cb = &txdata[0];
630    let mut next = Records::default();
631
632    v.push(
633        "btc:rule-blockctx-bip34-height",
634        (height >= params.bip34_height).then(|| bip34_height_lenient(cb) == Some(height)),
635    );
636    // lockTime 0 and the all-final-sequences escape need no context; a time-based lockTime needs median-time-past
637    let mut unknown = false;
638    let mut final_ok = true;
639    for tx in txdata {
640        let lt = tx.lock_time.to_consensus_u32();
641        if lt == 0 || tx.input.iter().all(|i| i.sequence.0 == 0xffff_ffff) {
642            continue;
643        }
644        if lt < 500_000_000 {
645            if lt >= height {
646                final_ok = false;
647            }
648        } else if let Some(m) = mtp {
649            if lt >= m {
650                final_ok = false;
651            }
652        } else {
653            unknown = true;
654        }
655    }
656    v.push(
657        "btc:rule-blockctx-finality",
658        if !final_ok {
659            Some(false)
660        } else if unknown {
661            None
662        } else {
663            Some(true)
664        },
665    );
666    v.push(
667        "btc:rule-blockctx-sequence-locks",
668        if !spending.seqlock_violations.is_empty() {
669            Some(false)
670        } else if spending.seqlock_unknown > 0 {
671            None
672        } else {
673            Some(true)
674        },
675    );
676    v.push(
677        "btc:rule-blockctx-inputs-available",
678        Some(spending.missing.is_empty()),
679    );
680    v.push(
681        "btc:rule-blockctx-coinbase-maturity",
682        Some(spending.premature.is_empty()),
683    );
684    v.push("btc:rule-blockctx-fees", Some(spending.deficits.is_empty()));
685    // the kernel's rule, plus the paid claims: coinbase value <= subsidy + fees + claims (siding/lib/overlay.mjs).
686    // The subsidy is the pegs at height 0 (SPEC 5) and zero after (SPEC 1); the claim sum is checked, so a
687    // coinbase whose payouts overflow u64 fails rather than wraps
688    let (claims, claim_errors) = parse_claims(cb);
689    let paid = claims
690        .iter()
691        .try_fold(0u64, |s, c| s.checked_add(c.payout.value));
692    let subsidy = if height == 0 {
693        overlay.genesis_subsidy
694    } else {
695        0
696    };
697    v.push(
698        "btc:rule-blockctx-coinbase-amount",
699        Some(
700            claim_errors.is_empty()
701                && paid.is_some_and(|paid| {
702                    sum_out(cb).is_some_and(|s| {
703                        s <= subsidy.saturating_add(spending.fees).saturating_add(paid)
704                    })
705                }),
706        ),
707    );
708    let has_witness = txdata
709        .iter()
710        .any(|tx| tx.input.iter().any(|i| !i.witness.is_empty()));
711    v.push(
712        "btc:rule-blockctx-witness-commitment",
713        (height >= params.segwit_height && has_witness)
714            .then(|| witness_commitment_in(cb) == Some(witness_commitment_hash(txdata))),
715    );
716    // real script + signature verification of every input under the family's sighash rules at this
717    // height (blocks.js: unifiedSighash from the unifiedSighashParam height); anything this crate cannot
718    // verify fails
719    let sighash = family.sighash_rules(height);
720    let mut scripts_ok = true;
721    let mut by_tx: BTreeMap<usize, BTreeMap<usize, TxOut>> = BTreeMap::new();
722    for (ti, ii, prevout) in &spending.resolved {
723        by_tx.entry(*ti).or_default().insert(*ii, prevout.clone());
724    }
725    for (ti, resolved) in &by_tx {
726        let tx = &txdata[*ti];
727        if resolved.len() != tx.input.len() {
728            scripts_ok = false;
729            continue;
730        }
731        let prevouts: Vec<TxOut> = (0..tx.input.len()).map(|i| resolved[&i].clone()).collect();
732        for ii in 0..tx.input.len() {
733            if verify_taproot_key_path(tx, ii, &prevouts, sighash).is_err() {
734                scripts_ok = false;
735            }
736        }
737    }
738    v.push("btc:rule-blockctx-scripts", Some(scripts_ok));
739
740    // sidestr:rule-pegouts (SPEC 7): a pegout:<script> OP_RETURN names a parent output script of 2 to 40
741    // bytes and carries at least pegoutMin sats; the coinbase carries none. The value leaves the supply.
742    let pegouts_ok = (|| {
743        if cb
744            .output
745            .iter()
746            .any(|o| parse_pegout(&o.script_pubkey).is_some())
747        {
748            return false;
749        }
750        for tx in txdata.iter().skip(1) {
751            let txid = tx.compute_txid().to_string();
752            for (vout, o) in tx.output.iter().enumerate() {
753                // one push whose data starts `pegout:`, in either push form; anything else is not
754                // a burn and is ignored, but a burn-shaped output that does not parse refuses the block
755                if !looks_like_pegout(o) {
756                    continue;
757                }
758                let Some(script) = parse_pegout(&o.script_pubkey) else {
759                    return false;
760                };
761                if o.value.to_sat() < overlay.pegout_min {
762                    return false;
763                }
764                let key = (txid.clone(), vout as u32);
765                if records
766                    .pegouts
767                    .get(&key)
768                    .is_some_and(|b| b.height != height)
769                {
770                    return false;
771                }
772                next.pegouts.insert(
773                    key,
774                    Burn {
775                        txid: txid.clone(),
776                        vout: vout as u32,
777                        script,
778                        value: o.value.to_sat(),
779                        height,
780                    },
781                );
782            }
783        }
784        true
785    })();
786    v.push("sidestr:rule-pegouts", Some(pegouts_ok));
787    // sidestr:rule-claims (SPEC 6): each claim marker is immediately preceded by its payout; no outpoint is
788    // claimed twice in the block or on the chain. A level-1 validator accepts what the signers claim.
789    let claims_ok = (|| {
790        if !claim_errors.is_empty() {
791            return false;
792        }
793        let mut in_block = HashSet::new();
794        for c in &claims {
795            let op = (c.txid.clone(), c.vout);
796            if !in_block.insert(op.clone()) {
797                return false;
798            }
799            if records.claims.get(&op).is_some_and(|&at| at != height) {
800                return false;
801            }
802        }
803        for op in in_block {
804            next.claims.insert(op, height);
805        }
806        true
807    })();
808    v.push("sidestr:rule-claims", Some(claims_ok));
809    let ctx = BlockContext {
810        block,
811        height,
812        spending: &spending,
813        mtp,
814        records,
815    };
816    for rule in extra {
817        let ok = rule.check(&ctx);
818        v.push(rule.id(), ok);
819    }
820    (v, spending, next)
821}
822
823/// Apply a fully validated block to the UTXO set (`blocks.js applyBlock`):
824/// spend its inputs, create its non-`OP_RETURN` outputs. Returns
825/// `(created, spent)`.
826pub fn apply_block(utxo: &mut Utxo, txdata: &[Transaction], height: u32) -> (usize, usize) {
827    let mut created = 0;
828    let mut spent = 0;
829    for (i, tx) in txdata.iter().enumerate() {
830        if i > 0 {
831            for inp in &tx.input {
832                if utxo.remove(&inp.previous_output).is_some() {
833                    spent += 1;
834                }
835            }
836        }
837        let txid = tx.compute_txid();
838        for (vout, o) in tx.output.iter().enumerate() {
839            if !o.script_pubkey.is_op_return() {
840                utxo.insert(
841                    OutPoint {
842                        txid,
843                        vout: vout as u32,
844                    },
845                    Coin {
846                        output: o.clone(),
847                        height,
848                        coinbase: i == 0,
849                    },
850                );
851                created += 1;
852            }
853        }
854    }
855    (created, spent)
856}
857
858/// The hash of a block 0: what a document's `genesisHash` pins and a mirror's
859/// index promises. The reference applies the genesis on that hash alone
860/// (`siding/lib/chain.mjs #apply`, `h === 0`); this crate does not —
861/// [`crate::state::StateOf::from_genesis`] judges block 0 under every rule
862/// that applies at height 0 and only then compares the pin.
863pub fn genesis_hash<F: HeaderFamily>(family: &F, block: &F::Block) -> BlockHash {
864    family.block_hash(block.header())
865}