Skip to main content

hopper_sdk/
receipt.rs

1//! # Receipt decoder
2//!
3//! The Hopper receipt is a **fixed 72-byte wire format** that the program
4//! emits at the end of a mutating instruction. The exact offsets are
5//! authoritative in `hopper-core::receipt::StateReceipt::to_bytes`; this
6//! module mirrors that layout bit-for-bit so off-chain consumers can
7//! decode receipts without linking the on-chain crate.
8//!
9//! A 64-byte legacy receipt (pre-0.2) is accepted for backwards
10//! compatibility; the failure-payload fields are then populated with
11//! defaults (no failure recorded).
12//!
13//! ## Wire layout (authoritative)
14//!
15//! | off | sz | field                  | type    |
16//! |-----|----|------------------------|---------|
17//! |   0 |  8 | layout_id              | [u8;8]  |
18//! |   8 |  8 | changed_fields         | u64 LE  |
19//! |  16 |  4 | changed_bytes          | u32 LE  |
20//! |  20 |  2 | changed_regions        | u16 LE  |
21//! |  22 |  4 | old_size               | u32 LE  |
22//! |  26 |  4 | new_size               | u32 LE  |
23//! |  30 |  2 | invariants_checked     | u16 LE  |
24//! |  32 |  1 | flags                  | bitfield|
25//! |  33 |  8 | before_fingerprint     | [u8;8]  |
26//! |  41 |  8 | after_fingerprint      | [u8;8]  |
27//! |  49 |  2 | segment_changed_mask   | u16 LE  |
28//! |  51 |  4 | policy_flags           | u32 LE  |
29//! |  55 |  2 | journal_appends        | u16 LE  |
30//! |  57 |  1 | cpi_count              | u8      |
31//! |  58 |  1 | phase                  | u8      |
32//! |  59 |  2 | validation_bundle_id   | u16 LE  |
33//! |  61 |  1 | compat_impact          | u8      |
34//! |  62 |  1 | migration_flags        | u8      |
35//! |  63 |  1 | failed_invariant_idx   | u8      |
36//! |  64 |  4 | failed_error_code      | u32 LE  |
37//! |  68 |  1 | failure_stage          | u8      |
38//! |  69 |  3 | reserved               | zero    |
39//!
40//! Flags byte:
41//! - bit 0: was_resized
42//! - bit 1: invariants_passed
43//! - bit 2: cpi_invoked
44//! - bit 3: committed
45//! - bit 4: had_failure
46
47/// Fixed byte length of a Hopper receipt on the wire.
48pub const RECEIPT_SIZE: usize = 72;
49
50/// Legacy receipt byte length (pre-0.2). Accepted at parse time with the
51/// failure payload defaulted to "no failure recorded".
52pub const RECEIPT_SIZE_LEGACY: usize = 64;
53
54/// Sentinel value for `failed_invariant_idx` meaning "no invariant was
55/// associated with the failure".
56pub const FAILED_INVARIANT_NONE: u8 = 0xFF;
57
58/// Receipt parse error.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ReceiptError {
61    /// Input was shorter than `RECEIPT_SIZE_LEGACY` bytes.
62    TooShort {
63        /// Actual input length.
64        got: usize,
65    },
66    /// Reserved trailing region was non-zero. likely corrupt or stale.
67    ReservedNonZero,
68    /// `phase` byte is outside the documented enum range (0..=4).
69    InvalidPhase(u8),
70    /// `compat_impact` byte is outside the documented enum range (0..=3).
71    InvalidCompatImpact(u8),
72    /// `failure_stage` byte is outside the documented enum range (0..=5).
73    InvalidFailureStage(u8),
74}
75
76/// Execution phase a receipt was captured in.
77///
78/// Mirrors `hopper-core::receipt::Phase` exactly so consumers never
79/// need to link on-chain crates just to read a receipt.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[repr(u8)]
82pub enum Phase {
83    /// Normal update / mutation.
84    Update = 0,
85    /// Account initialization.
86    Init = 1,
87    /// Account close / deletion.
88    Close = 2,
89    /// Migration to a new layout version.
90    Migrate = 3,
91    /// Read-only / view (no mutation expected).
92    ReadOnly = 4,
93}
94
95impl Phase {
96    fn from_u8(v: u8) -> Option<Self> {
97        Some(match v {
98            0 => Phase::Update,
99            1 => Phase::Init,
100            2 => Phase::Close,
101            3 => Phase::Migrate,
102            4 => Phase::ReadOnly,
103            _ => return None,
104        })
105    }
106
107    /// Short human-readable name.
108    pub const fn name(self) -> &'static str {
109        match self {
110            Phase::Update => "update",
111            Phase::Init => "init",
112            Phase::Close => "close",
113            Phase::Migrate => "migrate",
114            Phase::ReadOnly => "readonly",
115        }
116    }
117}
118
119/// Compatibility impact class of the mutation carried by this receipt.
120/// Mirrors `hopper-core::receipt::CompatImpact`.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122#[repr(u8)]
123pub enum CompatImpact {
124    /// No wire-level change; readers at the prior layout still work.
125    None = 0,
126    /// Append-only change; readers ignoring new fields still work.
127    Append = 1,
128    /// Full migration required.
129    Migration = 2,
130    /// Breaking change.
131    Breaking = 3,
132}
133
134impl CompatImpact {
135    fn from_u8(v: u8) -> Option<Self> {
136        Some(match v {
137            0 => CompatImpact::None,
138            1 => CompatImpact::Append,
139            2 => CompatImpact::Migration,
140            3 => CompatImpact::Breaking,
141            _ => return None,
142        })
143    }
144
145    /// Short human-readable name.
146    pub const fn name(self) -> &'static str {
147        match self {
148            CompatImpact::None => "none",
149            CompatImpact::Append => "append",
150            CompatImpact::Migration => "migration",
151            CompatImpact::Breaking => "breaking",
152        }
153    }
154}
155
156/// Stage at which a failure was recorded on a receipt.
157///
158/// Mirrors `hopper-core::receipt::FailureStage`.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160#[repr(u8)]
161pub enum FailureStage {
162    /// No failure (receipt committed cleanly).
163    None = 0,
164    /// Failed during account/context validation (pre-handler).
165    Validation = 1,
166    /// Failed inside the instruction handler before any invariant.
167    Handler = 2,
168    /// Failed inside an invariant check.
169    Invariant = 3,
170    /// Failed during the post-handler receipt commit/emit path.
171    Post = 4,
172    /// Failed inside a close guard / teardown routine.
173    Teardown = 5,
174}
175
176impl FailureStage {
177    fn from_u8(v: u8) -> Option<Self> {
178        Some(match v {
179            0 => FailureStage::None,
180            1 => FailureStage::Validation,
181            2 => FailureStage::Handler,
182            3 => FailureStage::Invariant,
183            4 => FailureStage::Post,
184            5 => FailureStage::Teardown,
185            _ => return None,
186        })
187    }
188
189    /// Short human-readable name.
190    pub const fn name(self) -> &'static str {
191        match self {
192            FailureStage::None => "none",
193            FailureStage::Validation => "validation",
194            FailureStage::Handler => "handler",
195            FailureStage::Invariant => "invariant",
196            FailureStage::Post => "post",
197            FailureStage::Teardown => "teardown",
198        }
199    }
200}
201
202/// Raw wire receipt buffer.
203///
204/// Stores at least the legacy 64-byte receipt; the extra 8 bytes of the
205/// 0.2+ format live in the tail. This is primarily useful when the
206/// consumer wants to treat the receipt as an opaque blob for storage.
207#[derive(Debug, Clone, Copy)]
208pub struct ReceiptWire(pub [u8; RECEIPT_SIZE]);
209
210impl ReceiptWire {
211    /// Copy the first `RECEIPT_SIZE` bytes of `buf` into a new `ReceiptWire`.
212    ///
213    /// If `buf` is only `RECEIPT_SIZE_LEGACY` bytes, the tail is zero-filled
214    /// (meaning "no failure recorded").
215    pub fn from_slice(buf: &[u8]) -> Result<Self, ReceiptError> {
216        if buf.len() < RECEIPT_SIZE_LEGACY {
217            return Err(ReceiptError::TooShort { got: buf.len() });
218        }
219        let mut bytes = [0u8; RECEIPT_SIZE];
220        let n = core::cmp::min(buf.len(), RECEIPT_SIZE);
221        bytes[..n].copy_from_slice(&buf[..n]);
222        Ok(Self(bytes))
223    }
224}
225
226/// A fully decoded receipt in host-endian Rust types. Use this in indexers,
227/// receipt explorers, and receipt-aware UI.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct DecodedReceipt {
230    /// Layout identifier of the account this receipt was produced for.
231    pub layout_id: [u8; 8],
232    /// Bitmask of field indices that changed. Up to 64 fields.
233    pub changed_fields: u64,
234    /// Total changed bytes.
235    pub changed_bytes: u32,
236    /// Number of disjoint changed regions.
237    pub changed_regions: u16,
238    /// Size before mutation.
239    pub old_size: u32,
240    /// Size after mutation.
241    pub new_size: u32,
242    /// Number of invariants evaluated.
243    pub invariants_checked: u16,
244    /// Whether the account was reallocated.
245    pub was_resized: bool,
246    /// Whether all invariants passed.
247    pub invariants_passed: bool,
248    /// Whether a CPI was invoked during this frame.
249    pub cpi_invoked: bool,
250    /// Whether the frame was committed (`false` = rolled back / dry run).
251    pub committed: bool,
252    /// Whether a failure was recorded (populates `failed_*` fields).
253    pub had_failure: bool,
254    /// Fingerprint of the pre-mutation state (8 bytes, mixer-derived).
255    pub before_fingerprint: [u8; 8],
256    /// Fingerprint of the post-mutation state.
257    pub after_fingerprint: [u8; 8],
258    /// Bitmask of segment indices touched (up to 16).
259    pub segment_changed_mask: u16,
260    /// Policy flags bitmask.
261    pub policy_flags: u32,
262    /// Number of journal entries appended.
263    pub journal_appends: u16,
264    /// Count of CPIs.
265    pub cpi_count: u8,
266    /// Execution phase at which the receipt was sealed.
267    pub phase: Phase,
268    /// Identifier of the validation bundle used.
269    pub validation_bundle_id: u16,
270    /// Compatibility class of the mutation.
271    pub compat_impact: CompatImpact,
272    /// Bitmask of migration-related flags.
273    pub migration_flags: u8,
274    /// Invariant index for the failure (`FAILED_INVARIANT_NONE` when none).
275    pub failed_invariant_idx: u8,
276    /// User error code for the failing check (`0` when none).
277    pub failed_error_code: u32,
278    /// Stage at which the failure occurred.
279    pub failure_stage: FailureStage,
280}
281
282impl DecodedReceipt {
283    /// Parse a 72-byte wire receipt.
284    ///
285    /// Accepts a 64-byte legacy receipt as a fallback: in that case the
286    /// failure-payload fields default to "no failure recorded".
287    pub fn parse(buf: &[u8]) -> Result<Self, ReceiptError> {
288        if buf.len() < RECEIPT_SIZE_LEGACY {
289            return Err(ReceiptError::TooShort { got: buf.len() });
290        }
291
292        let mut layout_id = [0u8; 8];
293        layout_id.copy_from_slice(&buf[0..8]);
294
295        let changed_fields = u64::from_le_bytes([
296            buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
297        ]);
298        let changed_bytes = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
299        let changed_regions = u16::from_le_bytes([buf[20], buf[21]]);
300        let old_size = u32::from_le_bytes([buf[22], buf[23], buf[24], buf[25]]);
301        let new_size = u32::from_le_bytes([buf[26], buf[27], buf[28], buf[29]]);
302        let invariants_checked = u16::from_le_bytes([buf[30], buf[31]]);
303
304        let flags = buf[32];
305        let was_resized = flags & (1 << 0) != 0;
306        let invariants_passed = flags & (1 << 1) != 0;
307        let cpi_invoked = flags & (1 << 2) != 0;
308        let committed = flags & (1 << 3) != 0;
309        let had_failure = flags & (1 << 4) != 0;
310
311        let mut before_fingerprint = [0u8; 8];
312        before_fingerprint.copy_from_slice(&buf[33..41]);
313        let mut after_fingerprint = [0u8; 8];
314        after_fingerprint.copy_from_slice(&buf[41..49]);
315
316        let segment_changed_mask = u16::from_le_bytes([buf[49], buf[50]]);
317        let policy_flags = u32::from_le_bytes([buf[51], buf[52], buf[53], buf[54]]);
318        let journal_appends = u16::from_le_bytes([buf[55], buf[56]]);
319        let cpi_count = buf[57];
320        let phase = Phase::from_u8(buf[58]).ok_or(ReceiptError::InvalidPhase(buf[58]))?;
321        let validation_bundle_id = u16::from_le_bytes([buf[59], buf[60]]);
322        let compat_impact =
323            CompatImpact::from_u8(buf[61]).ok_or(ReceiptError::InvalidCompatImpact(buf[61]))?;
324        let migration_flags = buf[62];
325
326        // Failure payload. When the caller only has a legacy 64-byte
327        // receipt, default everything to "no failure" rather than fail
328        // the parse. old producers never emitted this slot.
329        let (failed_invariant_idx, failed_error_code, failure_stage) = if buf.len() >= RECEIPT_SIZE
330        {
331            // Reserved bytes (69..72) must be zero; producers always
332            // zero-pad. A non-zero byte here signals wire drift and
333            // should surface to the caller.
334            let mut i = 69usize;
335            while i < RECEIPT_SIZE {
336                if buf[i] != 0 {
337                    return Err(ReceiptError::ReservedNonZero);
338                }
339                i += 1;
340            }
341            let idx = buf[63];
342            let code = u32::from_le_bytes([buf[64], buf[65], buf[66], buf[67]]);
343            let stage =
344                FailureStage::from_u8(buf[68]).ok_or(ReceiptError::InvalidFailureStage(buf[68]))?;
345            (idx, code, stage)
346        } else {
347            (FAILED_INVARIANT_NONE, 0u32, FailureStage::None)
348        };
349
350        Ok(Self {
351            layout_id,
352            changed_fields,
353            changed_bytes,
354            changed_regions,
355            old_size,
356            new_size,
357            invariants_checked,
358            was_resized,
359            invariants_passed,
360            cpi_invoked,
361            committed,
362            had_failure,
363            before_fingerprint,
364            after_fingerprint,
365            segment_changed_mask,
366            policy_flags,
367            journal_appends,
368            cpi_count,
369            phase,
370            validation_bundle_id,
371            compat_impact,
372            migration_flags,
373            failed_invariant_idx,
374            failed_error_code,
375            failure_stage,
376        })
377    }
378
379    /// Iterate the indices of fields that changed.
380    pub fn changed_field_indices(&self) -> ChangedFieldIter {
381        ChangedFieldIter {
382            mask: self.changed_fields,
383            idx: 0,
384        }
385    }
386
387    /// Iterate the indices of segments that were touched.
388    pub fn changed_segment_indices(&self) -> ChangedSegmentIter {
389        ChangedSegmentIter {
390            mask: self.segment_changed_mask,
391            idx: 0,
392        }
393    }
394
395    /// Whether any state was actually modified.
396    pub const fn is_mutation(&self) -> bool {
397        self.committed && (self.changed_bytes > 0 || self.was_resized)
398    }
399
400    /// Whether this receipt is safe to treat as a *read-through* receipt.
401    pub const fn is_readonly(&self) -> bool {
402        self.committed
403            && !self.was_resized
404            && self.changed_bytes == 0
405            && !self.cpi_invoked
406            && self.journal_appends == 0
407    }
408
409    /// Size delta in bytes (post minus pre).
410    pub const fn size_delta(&self) -> i64 {
411        (self.new_size as i64) - (self.old_size as i64)
412    }
413}
414
415/// Iterator over indices of fields that changed according to the receipt.
416pub struct ChangedFieldIter {
417    mask: u64,
418    idx: u32,
419}
420
421impl Iterator for ChangedFieldIter {
422    type Item = u32;
423    fn next(&mut self) -> Option<u32> {
424        while self.idx < 64 {
425            let cur = self.idx;
426            let bit = 1u64 << cur;
427            self.idx += 1;
428            if self.mask & bit != 0 {
429                return Some(cur);
430            }
431        }
432        None
433    }
434}
435
436/// Iterator over indices of segments that changed.
437pub struct ChangedSegmentIter {
438    mask: u16,
439    idx: u32,
440}
441
442impl Iterator for ChangedSegmentIter {
443    type Item = u32;
444    fn next(&mut self) -> Option<u32> {
445        while self.idx < 16 {
446            let cur = self.idx;
447            let bit = 1u16 << cur;
448            self.idx += 1;
449            if self.mask & bit != 0 {
450                return Some(cur);
451            }
452        }
453        None
454    }
455}
456
457#[cfg(feature = "narrate")]
458pub mod narrative {
459    //! Human-readable receipt narration.
460    //!
461    //! Turns a `DecodedReceipt` plus its matching `LayoutManifest` and
462    //! optional `ErrorRegistry` into a sentence an indexer or UI can
463    //! display without needing to know Solana or Hopper semantics.
464    //!
465    //! **The invariant→name lookup is the payoff of the provable-safety
466    //! chain.** When the receipt reports `had_failure=true` with a
467    //! populated `failed_error_code`, the narrator cross-references the
468    //! program's `ErrorRegistry` to render:
469    //!
470    //! ```text
471    //! Execution aborted at invariant stage: Invariant `balance_nonzero` failed (code 0x1001).
472    //! ```
473    //!
474    //! without requiring any per-program hand-written mapping code.
475
476    use super::{DecodedReceipt, FailureStage};
477    use alloc::string::{String, ToString};
478    use alloc::vec::Vec;
479    use hopper_schema::{ErrorRegistry, LayoutManifest};
480
481    /// Structured narrative ready for rendering.
482    #[derive(Debug, Clone)]
483    pub struct ReceiptNarrative {
484        /// Root sentence.
485        pub summary: String,
486        /// Per-field change lines.
487        pub field_changes: Vec<String>,
488        /// Flags (resized, CPI, journal, migration).
489        pub flags: Vec<String>,
490        /// Severity bucket: "info" | "notice" | "warn" | "error".
491        pub severity: &'static str,
492        /// If the receipt carries a failure, the rendered "Invariant X
493        /// failed" sentence the operator should see first.
494        pub failure_line: Option<String>,
495    }
496
497    /// Convert a decoded receipt into a narrative using optional layout
498    /// and error registries. Without them, indices and raw codes are used.
499    pub struct Narrator<'a> {
500        /// Optional layout manifest. If provided, field names replace indices.
501        pub layout: Option<&'a LayoutManifest>,
502        /// Optional error registry. If provided, failing codes are
503        /// rendered as "Invariant `x` failed" instead of "code 0xNNNN".
504        pub errors: Option<&'a ErrorRegistry>,
505    }
506
507    impl<'a> Narrator<'a> {
508        /// Build a narrator with only a layout manifest.
509        pub const fn with_layout(layout: &'a LayoutManifest) -> Self {
510            Self {
511                layout: Some(layout),
512                errors: None,
513            }
514        }
515
516        /// Build a narrator with both a layout and error registry.
517        pub const fn with_all(layout: &'a LayoutManifest, errors: &'a ErrorRegistry) -> Self {
518            Self {
519                layout: Some(layout),
520                errors: Some(errors),
521            }
522        }
523
524        /// Build a `ReceiptNarrative` from a decoded receipt.
525        pub fn narrate(&self, r: &DecodedReceipt) -> ReceiptNarrative {
526            // Render failure first because it dominates the story.
527            let failure_line = if r.had_failure {
528                Some(render_failure(r, self.errors))
529            } else {
530                None
531            };
532
533            let mut field_changes = Vec::new();
534            for idx in r.changed_field_indices() {
535                let name = self
536                    .layout
537                    .and_then(|m| m.fields.get(idx as usize))
538                    .map(|f| f.name.to_string())
539                    .unwrap_or_else(|| format!("field[{}]", idx));
540                field_changes.push(name);
541            }
542
543            let mut flags = Vec::new();
544            if r.was_resized {
545                flags.push(format!(
546                    "resized {} → {} bytes (Δ {})",
547                    r.old_size,
548                    r.new_size,
549                    r.size_delta()
550                ));
551            }
552            if r.cpi_invoked {
553                flags.push(format!("invoked {} CPI(s)", r.cpi_count));
554            }
555            if r.journal_appends > 0 {
556                flags.push(format!("appended {} journal entr(ies)", r.journal_appends));
557            }
558            if r.migration_flags != 0 {
559                flags.push(format!("migration flags = 0x{:02x}", r.migration_flags));
560            }
561
562            let (summary, severity) = summarize(r, &field_changes, failure_line.as_deref());
563
564            ReceiptNarrative {
565                summary,
566                field_changes,
567                flags,
568                severity,
569                failure_line,
570            }
571        }
572    }
573
574    /// Format the failure line for a receipt that records one.
575    ///
576    /// Uses the registry to promote raw error codes to invariant names
577    /// when possible. Falls back to "error code 0xNNNN" otherwise.
578    fn render_failure(r: &DecodedReceipt, errors: Option<&ErrorRegistry>) -> String {
579        let stage_label = r.failure_stage.name();
580        // Prefer invariant name via registry lookup.
581        if let Some(reg) = errors {
582            if let Some(desc) = reg.find_by_code(r.failed_error_code) {
583                if !desc.invariant.is_empty() {
584                    return format!(
585                        "Execution aborted at {} stage: invariant `{}` failed \
586                         ({}::{} = 0x{:x}).",
587                        stage_label, desc.invariant, reg.enum_name, desc.name, desc.code,
588                    );
589                }
590                return format!(
591                    "Execution aborted at {} stage: {}::{} (code 0x{:x}).",
592                    stage_label, reg.enum_name, desc.name, desc.code,
593                );
594            }
595        }
596        // No registry available. render raw code.
597        if r.failure_stage == FailureStage::Invariant
598            && r.failed_invariant_idx != super::FAILED_INVARIANT_NONE
599        {
600            format!(
601                "Execution aborted at invariant stage: invariant #{} failed (code 0x{:x}).",
602                r.failed_invariant_idx, r.failed_error_code
603            )
604        } else {
605            format!(
606                "Execution aborted at {} stage: error code 0x{:x}.",
607                stage_label, r.failed_error_code
608            )
609        }
610    }
611
612    fn summarize(
613        r: &DecodedReceipt,
614        changed: &[String],
615        failure_line: Option<&str>,
616    ) -> (String, &'static str) {
617        if let Some(line) = failure_line {
618            return (line.to_string(), "error");
619        }
620        if !r.committed {
621            return (
622                format!(
623                    "Frame rolled back in phase '{}' (invariants {}/{}).",
624                    r.phase.name(),
625                    if r.invariants_passed {
626                        "passed"
627                    } else {
628                        "failed"
629                    },
630                    r.invariants_checked
631                ),
632                "warn",
633            );
634        }
635        if r.is_readonly() {
636            return (
637                format!(
638                    "Read-through committed at phase '{}'; no state mutated.",
639                    r.phase.name()
640                ),
641                "info",
642            );
643        }
644        let names = if changed.is_empty() {
645            "no named fields".to_string()
646        } else if changed.len() <= 3 {
647            changed.join(", ")
648        } else {
649            format!("{} and {} more", changed[..3].join(", "), changed.len() - 3)
650        };
651        let severity = if r.compat_impact as u8 >= super::CompatImpact::Migration as u8 {
652            "warn"
653        } else if r.compat_impact as u8 >= super::CompatImpact::Append as u8 {
654            "notice"
655        } else {
656            "info"
657        };
658        (
659            format!(
660                "Committed at phase '{}': mutated {} ({} byte{}, {} region{}), compat={}.",
661                r.phase.name(),
662                names,
663                r.changed_bytes,
664                if r.changed_bytes == 1 { "" } else { "s" },
665                r.changed_regions,
666                if r.changed_regions == 1 { "" } else { "s" },
667                r.compat_impact.name(),
668            ),
669            severity,
670        )
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn sample_wire() -> [u8; RECEIPT_SIZE] {
679        let mut b = [0u8; RECEIPT_SIZE];
680        b[0..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); // layout_id
681        b[8..16].copy_from_slice(&(0b1011u64).to_le_bytes()); // changed_fields
682        b[16..20].copy_from_slice(&16u32.to_le_bytes()); // changed_bytes
683        b[20..22].copy_from_slice(&2u16.to_le_bytes()); // changed_regions
684        b[22..26].copy_from_slice(&128u32.to_le_bytes()); // old_size
685        b[26..30].copy_from_slice(&128u32.to_le_bytes()); // new_size
686        b[30..32].copy_from_slice(&3u16.to_le_bytes()); // invariants_checked
687                                                        // flags: invariants_passed | committed
688        b[32] = (1 << 1) | (1 << 3);
689        b[33..41].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x00]);
690        b[41..49].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00]);
691        b[49..51].copy_from_slice(&0b10u16.to_le_bytes()); // seg mask
692        b[51..55].copy_from_slice(&0x42u32.to_le_bytes()); // policy_flags
693        b[55..57].copy_from_slice(&0u16.to_le_bytes()); // journal_appends
694        b[57] = 0; // cpi_count
695        b[58] = 0; // phase = Update
696        b[59..61].copy_from_slice(&7u16.to_le_bytes()); // validation_bundle_id
697        b[61] = 0; // compat_impact = None (Breaking not used here)
698        b[62] = 0; // migration_flags
699        b[63] = FAILED_INVARIANT_NONE; // no invariant failure
700        b[64..68].copy_from_slice(&0u32.to_le_bytes()); // failed_error_code
701        b[68] = 0; // failure_stage = None
702                   // 69..72 reserved (zero)
703        b
704    }
705
706    #[test]
707    fn parses_valid_wire() {
708        let wire = sample_wire();
709        let r = DecodedReceipt::parse(&wire).expect("should parse");
710        assert_eq!(r.phase, Phase::Update);
711        assert!(r.committed);
712        assert!(r.invariants_passed);
713        assert_eq!(r.changed_fields, 0b1011);
714        assert_eq!(r.changed_bytes, 16);
715        assert_eq!(r.compat_impact, CompatImpact::None);
716        assert_eq!(r.validation_bundle_id, 7);
717        assert!(!r.had_failure);
718        assert_eq!(r.failed_error_code, 0);
719        assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
720        assert!(!r.is_readonly());
721        // changed_bytes=16 + committed → receipt represents a real mutation.
722        assert!(r.is_mutation());
723    }
724
725    #[test]
726    fn rejects_short() {
727        let buf = [0u8; 32];
728        assert!(matches!(
729            DecodedReceipt::parse(&buf),
730            Err(ReceiptError::TooShort { got: 32 })
731        ));
732    }
733
734    #[test]
735    fn accepts_legacy_64_byte_receipt() {
736        let wire = sample_wire();
737        let legacy = &wire[..RECEIPT_SIZE_LEGACY];
738        let r = DecodedReceipt::parse(legacy).expect("should parse legacy");
739        assert!(!r.had_failure);
740        assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
741        assert_eq!(r.failed_error_code, 0);
742        assert_eq!(r.failure_stage, FailureStage::None);
743    }
744
745    #[test]
746    fn decodes_invariant_failure() {
747        let mut wire = sample_wire();
748        // Clear invariants_passed, set had_failure.
749        wire[32] = (1 << 3) | (1 << 4); // committed | had_failure
750        wire[63] = 0x02; // invariant idx 2
751        wire[64..68].copy_from_slice(&0x1001u32.to_le_bytes()); // code
752        wire[68] = 3; // FailureStage::Invariant
753        let r = DecodedReceipt::parse(&wire).expect("should parse failure");
754        assert!(r.had_failure);
755        assert!(!r.invariants_passed);
756        assert_eq!(r.failed_invariant_idx, 0x02);
757        assert_eq!(r.failed_error_code, 0x1001);
758        assert_eq!(r.failure_stage, FailureStage::Invariant);
759    }
760
761    #[test]
762    fn rejects_reserved_nonzero() {
763        let mut wire = sample_wire();
764        wire[70] = 1; // poison reserved
765        assert!(matches!(
766            DecodedReceipt::parse(&wire),
767            Err(ReceiptError::ReservedNonZero)
768        ));
769    }
770
771    #[test]
772    fn changed_field_iter_enumerates_bits() {
773        let wire = sample_wire();
774        let r = DecodedReceipt::parse(&wire).unwrap();
775        let indices: alloc::vec::Vec<u32> = r.changed_field_indices().collect();
776        assert_eq!(indices, alloc::vec![0u32, 1u32, 3u32]);
777    }
778
779    #[test]
780    fn changed_segment_iter_enumerates_bits() {
781        let wire = sample_wire();
782        let r = DecodedReceipt::parse(&wire).unwrap();
783        let indices: alloc::vec::Vec<u32> = r.changed_segment_indices().collect();
784        assert_eq!(indices, alloc::vec![1u32]);
785    }
786}