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
282/// Stable row shape for receipt indexers and dashboards.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub struct ReceiptIndexRecord {
285    /// Deterministic grouping key: `layout_id || after_fingerprint`.
286    pub index_key: [u8; 16],
287    /// Layout identifier of the account this receipt was produced for.
288    pub layout_id: [u8; 8],
289    /// Instruction phase at which the receipt was sealed.
290    pub phase: Phase,
291    /// Compatibility class of the mutation.
292    pub compat_impact: CompatImpact,
293    /// Bitmask of migration-related flags.
294    pub migration_flags: u8,
295    /// Bitmask of changed field indices.
296    pub changed_fields: u64,
297    /// Number of changed field bits set in `changed_fields`.
298    pub changed_field_count: u32,
299    /// Bitmask of changed segment indices.
300    pub segment_changed_mask: u16,
301    /// Number of changed segment bits set in `segment_changed_mask`.
302    pub changed_segment_count: u32,
303    /// Policy/capability flags active for the instruction.
304    pub policy_flags: u32,
305    /// Program-defined validation bundle identifier.
306    pub validation_bundle_id: u16,
307    /// Account data size before mutation.
308    pub old_size: u32,
309    /// Account data size after mutation.
310    pub new_size: u32,
311    /// Total number of changed bytes.
312    pub changed_bytes: u32,
313    /// Whether the receipt recorded a failure path.
314    pub had_failure: bool,
315    /// Program error code associated with the failure, or zero when none.
316    pub failed_error_code: u32,
317    /// Invariant index associated with the failure, or `FAILED_INVARIANT_NONE`.
318    pub failed_invariant_idx: u8,
319    /// Stage at which the failure occurred.
320    pub failure_stage: FailureStage,
321}
322
323impl DecodedReceipt {
324    /// Parse a 72-byte wire receipt.
325    ///
326    /// Accepts a 64-byte legacy receipt as a fallback: in that case the
327    /// failure-payload fields default to "no failure recorded".
328    pub fn parse(buf: &[u8]) -> Result<Self, ReceiptError> {
329        if buf.len() < RECEIPT_SIZE_LEGACY {
330            return Err(ReceiptError::TooShort { got: buf.len() });
331        }
332
333        let mut layout_id = [0u8; 8];
334        layout_id.copy_from_slice(&buf[0..8]);
335
336        let changed_fields = u64::from_le_bytes([
337            buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
338        ]);
339        let changed_bytes = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
340        let changed_regions = u16::from_le_bytes([buf[20], buf[21]]);
341        let old_size = u32::from_le_bytes([buf[22], buf[23], buf[24], buf[25]]);
342        let new_size = u32::from_le_bytes([buf[26], buf[27], buf[28], buf[29]]);
343        let invariants_checked = u16::from_le_bytes([buf[30], buf[31]]);
344
345        let flags = buf[32];
346        let was_resized = flags & (1 << 0) != 0;
347        let invariants_passed = flags & (1 << 1) != 0;
348        let cpi_invoked = flags & (1 << 2) != 0;
349        let committed = flags & (1 << 3) != 0;
350        let had_failure = flags & (1 << 4) != 0;
351
352        let mut before_fingerprint = [0u8; 8];
353        before_fingerprint.copy_from_slice(&buf[33..41]);
354        let mut after_fingerprint = [0u8; 8];
355        after_fingerprint.copy_from_slice(&buf[41..49]);
356
357        let segment_changed_mask = u16::from_le_bytes([buf[49], buf[50]]);
358        let policy_flags = u32::from_le_bytes([buf[51], buf[52], buf[53], buf[54]]);
359        let journal_appends = u16::from_le_bytes([buf[55], buf[56]]);
360        let cpi_count = buf[57];
361        let phase = Phase::from_u8(buf[58]).ok_or(ReceiptError::InvalidPhase(buf[58]))?;
362        let validation_bundle_id = u16::from_le_bytes([buf[59], buf[60]]);
363        let compat_impact =
364            CompatImpact::from_u8(buf[61]).ok_or(ReceiptError::InvalidCompatImpact(buf[61]))?;
365        let migration_flags = buf[62];
366
367        // Failure payload. When the caller only has a legacy 64-byte
368        // receipt, default everything to "no failure" rather than fail
369        // the parse. old producers never emitted this slot.
370        let (failed_invariant_idx, failed_error_code, failure_stage) = if buf.len() >= RECEIPT_SIZE
371        {
372            // Reserved bytes (69..72) must be zero; producers always
373            // zero-pad. A non-zero byte here signals wire drift and
374            // should surface to the caller.
375            let mut i = 69usize;
376            while i < RECEIPT_SIZE {
377                if buf[i] != 0 {
378                    return Err(ReceiptError::ReservedNonZero);
379                }
380                i += 1;
381            }
382            let idx = buf[63];
383            let code = u32::from_le_bytes([buf[64], buf[65], buf[66], buf[67]]);
384            let stage =
385                FailureStage::from_u8(buf[68]).ok_or(ReceiptError::InvalidFailureStage(buf[68]))?;
386            (idx, code, stage)
387        } else {
388            (FAILED_INVARIANT_NONE, 0u32, FailureStage::None)
389        };
390
391        Ok(Self {
392            layout_id,
393            changed_fields,
394            changed_bytes,
395            changed_regions,
396            old_size,
397            new_size,
398            invariants_checked,
399            was_resized,
400            invariants_passed,
401            cpi_invoked,
402            committed,
403            had_failure,
404            before_fingerprint,
405            after_fingerprint,
406            segment_changed_mask,
407            policy_flags,
408            journal_appends,
409            cpi_count,
410            phase,
411            validation_bundle_id,
412            compat_impact,
413            migration_flags,
414            failed_invariant_idx,
415            failed_error_code,
416            failure_stage,
417        })
418    }
419
420    /// Iterate the indices of fields that changed.
421    pub fn changed_field_indices(&self) -> ChangedFieldIter {
422        ChangedFieldIter {
423            mask: self.changed_fields,
424            idx: 0,
425        }
426    }
427
428    /// Number of changed fields recorded in the receipt bitmask.
429    pub const fn changed_field_count(&self) -> u32 {
430        self.changed_fields.count_ones()
431    }
432
433    /// Iterate the indices of segments that were touched.
434    pub fn changed_segment_indices(&self) -> ChangedSegmentIter {
435        ChangedSegmentIter {
436            mask: self.segment_changed_mask,
437            idx: 0,
438        }
439    }
440
441    /// Number of changed segments recorded in the receipt bitmask.
442    pub const fn changed_segment_count(&self) -> u32 {
443        self.segment_changed_mask.count_ones()
444    }
445
446    /// Deterministic index key: `layout_id || after_fingerprint`.
447    pub fn index_key(&self) -> [u8; 16] {
448        let mut key = [0u8; 16];
449        key[..8].copy_from_slice(&self.layout_id);
450        key[8..].copy_from_slice(&self.after_fingerprint);
451        key
452    }
453
454    /// Project this decoded receipt into a stable indexer row.
455    pub fn index_record(&self) -> ReceiptIndexRecord {
456        ReceiptIndexRecord {
457            index_key: self.index_key(),
458            layout_id: self.layout_id,
459            phase: self.phase,
460            compat_impact: self.compat_impact,
461            migration_flags: self.migration_flags,
462            changed_fields: self.changed_fields,
463            changed_field_count: self.changed_field_count(),
464            segment_changed_mask: self.segment_changed_mask,
465            changed_segment_count: self.changed_segment_count(),
466            policy_flags: self.policy_flags,
467            validation_bundle_id: self.validation_bundle_id,
468            old_size: self.old_size,
469            new_size: self.new_size,
470            changed_bytes: self.changed_bytes,
471            had_failure: self.had_failure,
472            failed_error_code: self.failed_error_code,
473            failed_invariant_idx: self.failed_invariant_idx,
474            failure_stage: self.failure_stage,
475        }
476    }
477
478    /// Whether any state was actually modified.
479    pub const fn is_mutation(&self) -> bool {
480        self.committed && (self.changed_bytes > 0 || self.was_resized)
481    }
482
483    /// Whether this receipt is safe to treat as a *read-through* receipt.
484    pub const fn is_readonly(&self) -> bool {
485        self.committed
486            && !self.was_resized
487            && self.changed_bytes == 0
488            && !self.cpi_invoked
489            && self.journal_appends == 0
490    }
491
492    /// Size delta in bytes (post minus pre).
493    pub const fn size_delta(&self) -> i64 {
494        (self.new_size as i64) - (self.old_size as i64)
495    }
496}
497
498/// Iterator over indices of fields that changed according to the receipt.
499pub struct ChangedFieldIter {
500    mask: u64,
501    idx: u32,
502}
503
504impl Iterator for ChangedFieldIter {
505    type Item = u32;
506    fn next(&mut self) -> Option<u32> {
507        while self.idx < 64 {
508            let cur = self.idx;
509            let bit = 1u64 << cur;
510            self.idx += 1;
511            if self.mask & bit != 0 {
512                return Some(cur);
513            }
514        }
515        None
516    }
517}
518
519/// Iterator over indices of segments that changed.
520pub struct ChangedSegmentIter {
521    mask: u16,
522    idx: u32,
523}
524
525impl Iterator for ChangedSegmentIter {
526    type Item = u32;
527    fn next(&mut self) -> Option<u32> {
528        while self.idx < 16 {
529            let cur = self.idx;
530            let bit = 1u16 << cur;
531            self.idx += 1;
532            if self.mask & bit != 0 {
533                return Some(cur);
534            }
535        }
536        None
537    }
538}
539
540#[cfg(feature = "narrate")]
541pub mod narrative {
542    //! Human-readable receipt narration.
543    //!
544    //! Turns a `DecodedReceipt` plus its matching `LayoutManifest` and
545    //! optional `ErrorRegistry` into a sentence an indexer or UI can
546    //! display without needing to know Solana or Hopper semantics.
547    //!
548    //! **The invariant→name lookup is the payoff of the provable-safety
549    //! chain.** When the receipt reports `had_failure=true` with a
550    //! populated `failed_error_code`, the narrator cross-references the
551    //! program's `ErrorRegistry` to render:
552    //!
553    //! ```text
554    //! Execution aborted at invariant stage: Invariant `balance_nonzero` failed (code 0x1001).
555    //! ```
556    //!
557    //! without requiring any per-program hand-written mapping code.
558
559    use super::{DecodedReceipt, FailureStage};
560    use alloc::string::{String, ToString};
561    use alloc::vec::Vec;
562    use hopper_schema::{ErrorRegistry, LayoutManifest};
563
564    /// Structured narrative ready for rendering.
565    #[derive(Debug, Clone)]
566    pub struct ReceiptNarrative {
567        /// Root sentence.
568        pub summary: String,
569        /// Per-field change lines.
570        pub field_changes: Vec<String>,
571        /// Flags (resized, CPI, journal, migration).
572        pub flags: Vec<String>,
573        /// Severity bucket: "info" | "notice" | "warn" | "error".
574        pub severity: &'static str,
575        /// If the receipt carries a failure, the rendered "Invariant X
576        /// failed" sentence the operator should see first.
577        pub failure_line: Option<String>,
578    }
579
580    /// Convert a decoded receipt into a narrative using optional layout
581    /// and error registries. Without them, indices and raw codes are used.
582    pub struct Narrator<'a> {
583        /// Optional layout manifest. If provided, field names replace indices.
584        pub layout: Option<&'a LayoutManifest>,
585        /// Optional error registry. If provided, failing codes are
586        /// rendered as "Invariant `x` failed" instead of "code 0xNNNN".
587        pub errors: Option<&'a ErrorRegistry>,
588    }
589
590    impl<'a> Narrator<'a> {
591        /// Build a narrator with only a layout manifest.
592        pub const fn with_layout(layout: &'a LayoutManifest) -> Self {
593            Self {
594                layout: Some(layout),
595                errors: None,
596            }
597        }
598
599        /// Build a narrator with both a layout and error registry.
600        pub const fn with_all(layout: &'a LayoutManifest, errors: &'a ErrorRegistry) -> Self {
601            Self {
602                layout: Some(layout),
603                errors: Some(errors),
604            }
605        }
606
607        /// Build a `ReceiptNarrative` from a decoded receipt.
608        pub fn narrate(&self, r: &DecodedReceipt) -> ReceiptNarrative {
609            // Render failure first because it dominates the story.
610            let failure_line = if r.had_failure {
611                Some(render_failure(r, self.errors))
612            } else {
613                None
614            };
615
616            let mut field_changes = Vec::new();
617            for idx in r.changed_field_indices() {
618                let name = self
619                    .layout
620                    .and_then(|m| m.fields.get(idx as usize))
621                    .map(|f| f.name.to_string())
622                    .unwrap_or_else(|| format!("field[{}]", idx));
623                field_changes.push(name);
624            }
625
626            let mut flags = Vec::new();
627            if r.was_resized {
628                flags.push(format!(
629                    "resized {} → {} bytes (Δ {})",
630                    r.old_size,
631                    r.new_size,
632                    r.size_delta()
633                ));
634            }
635            if r.cpi_invoked {
636                flags.push(format!("invoked {} CPI(s)", r.cpi_count));
637            }
638            if r.journal_appends > 0 {
639                flags.push(format!("appended {} journal entr(ies)", r.journal_appends));
640            }
641            if r.migration_flags != 0 {
642                flags.push(format!("migration flags = 0x{:02x}", r.migration_flags));
643            }
644
645            let (summary, severity) = summarize(r, &field_changes, failure_line.as_deref());
646
647            ReceiptNarrative {
648                summary,
649                field_changes,
650                flags,
651                severity,
652                failure_line,
653            }
654        }
655    }
656
657    /// Format the failure line for a receipt that records one.
658    ///
659    /// Uses the registry to promote raw error codes to invariant names
660    /// when possible. Falls back to "error code 0xNNNN" otherwise.
661    fn render_failure(r: &DecodedReceipt, errors: Option<&ErrorRegistry>) -> String {
662        let stage_label = r.failure_stage.name();
663        // Prefer invariant name via registry lookup.
664        if let Some(reg) = errors {
665            if let Some(desc) = reg.find_by_code(r.failed_error_code) {
666                if !desc.invariant.is_empty() {
667                    return format!(
668                        "Execution aborted at {} stage: invariant `{}` failed \
669                         ({}::{} = 0x{:x}).",
670                        stage_label, desc.invariant, reg.enum_name, desc.name, desc.code,
671                    );
672                }
673                return format!(
674                    "Execution aborted at {} stage: {}::{} (code 0x{:x}).",
675                    stage_label, reg.enum_name, desc.name, desc.code,
676                );
677            }
678        }
679        // No registry available. render raw code.
680        if r.failure_stage == FailureStage::Invariant
681            && r.failed_invariant_idx != super::FAILED_INVARIANT_NONE
682        {
683            format!(
684                "Execution aborted at invariant stage: invariant #{} failed (code 0x{:x}).",
685                r.failed_invariant_idx, r.failed_error_code
686            )
687        } else {
688            format!(
689                "Execution aborted at {} stage: error code 0x{:x}.",
690                stage_label, r.failed_error_code
691            )
692        }
693    }
694
695    fn summarize(
696        r: &DecodedReceipt,
697        changed: &[String],
698        failure_line: Option<&str>,
699    ) -> (String, &'static str) {
700        if let Some(line) = failure_line {
701            return (line.to_string(), "error");
702        }
703        if !r.committed {
704            return (
705                format!(
706                    "Frame rolled back in phase '{}' (invariants {}/{}).",
707                    r.phase.name(),
708                    if r.invariants_passed {
709                        "passed"
710                    } else {
711                        "failed"
712                    },
713                    r.invariants_checked
714                ),
715                "warn",
716            );
717        }
718        if r.is_readonly() {
719            return (
720                format!(
721                    "Read-through committed at phase '{}'; no state mutated.",
722                    r.phase.name()
723                ),
724                "info",
725            );
726        }
727        let names = if changed.is_empty() {
728            "no named fields".to_string()
729        } else if changed.len() <= 3 {
730            changed.join(", ")
731        } else {
732            format!("{} and {} more", changed[..3].join(", "), changed.len() - 3)
733        };
734        let severity = if r.compat_impact as u8 >= super::CompatImpact::Migration as u8 {
735            "warn"
736        } else if r.compat_impact as u8 >= super::CompatImpact::Append as u8 {
737            "notice"
738        } else {
739            "info"
740        };
741        (
742            format!(
743                "Committed at phase '{}': mutated {} ({} byte{}, {} region{}), compat={}.",
744                r.phase.name(),
745                names,
746                r.changed_bytes,
747                if r.changed_bytes == 1 { "" } else { "s" },
748                r.changed_regions,
749                if r.changed_regions == 1 { "" } else { "s" },
750                r.compat_impact.name(),
751            ),
752            severity,
753        )
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    fn sample_wire() -> [u8; RECEIPT_SIZE] {
762        let mut b = [0u8; RECEIPT_SIZE];
763        b[0..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); // layout_id
764        b[8..16].copy_from_slice(&(0b1011u64).to_le_bytes()); // changed_fields
765        b[16..20].copy_from_slice(&16u32.to_le_bytes()); // changed_bytes
766        b[20..22].copy_from_slice(&2u16.to_le_bytes()); // changed_regions
767        b[22..26].copy_from_slice(&128u32.to_le_bytes()); // old_size
768        b[26..30].copy_from_slice(&128u32.to_le_bytes()); // new_size
769        b[30..32].copy_from_slice(&3u16.to_le_bytes()); // invariants_checked
770                                                        // flags: invariants_passed | committed
771        b[32] = (1 << 1) | (1 << 3);
772        b[33..41].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x00]);
773        b[41..49].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00]);
774        b[49..51].copy_from_slice(&0b10u16.to_le_bytes()); // seg mask
775        b[51..55].copy_from_slice(&0x42u32.to_le_bytes()); // policy_flags
776        b[55..57].copy_from_slice(&0u16.to_le_bytes()); // journal_appends
777        b[57] = 0; // cpi_count
778        b[58] = 0; // phase = Update
779        b[59..61].copy_from_slice(&7u16.to_le_bytes()); // validation_bundle_id
780        b[61] = 0; // compat_impact = None (Breaking not used here)
781        b[62] = 0; // migration_flags
782        b[63] = FAILED_INVARIANT_NONE; // no invariant failure
783        b[64..68].copy_from_slice(&0u32.to_le_bytes()); // failed_error_code
784        b[68] = 0; // failure_stage = None
785                   // 69..72 reserved (zero)
786        b
787    }
788
789    #[test]
790    fn parses_valid_wire() {
791        let wire = sample_wire();
792        let r = DecodedReceipt::parse(&wire).expect("should parse");
793        assert_eq!(r.phase, Phase::Update);
794        assert!(r.committed);
795        assert!(r.invariants_passed);
796        assert_eq!(r.changed_fields, 0b1011);
797        assert_eq!(r.changed_bytes, 16);
798        assert_eq!(r.compat_impact, CompatImpact::None);
799        assert_eq!(r.validation_bundle_id, 7);
800        assert!(!r.had_failure);
801        assert_eq!(r.failed_error_code, 0);
802        assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
803        assert!(!r.is_readonly());
804        // changed_bytes=16 + committed → receipt represents a real mutation.
805        assert!(r.is_mutation());
806    }
807
808    #[test]
809    fn rejects_short() {
810        let buf = [0u8; 32];
811        assert!(matches!(
812            DecodedReceipt::parse(&buf),
813            Err(ReceiptError::TooShort { got: 32 })
814        ));
815    }
816
817    #[test]
818    fn accepts_legacy_64_byte_receipt() {
819        let wire = sample_wire();
820        let legacy = &wire[..RECEIPT_SIZE_LEGACY];
821        let r = DecodedReceipt::parse(legacy).expect("should parse legacy");
822        assert!(!r.had_failure);
823        assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
824        assert_eq!(r.failed_error_code, 0);
825        assert_eq!(r.failure_stage, FailureStage::None);
826    }
827
828    #[test]
829    fn decodes_invariant_failure() {
830        let mut wire = sample_wire();
831        // Clear invariants_passed, set had_failure.
832        wire[32] = (1 << 3) | (1 << 4); // committed | had_failure
833        wire[63] = 0x02; // invariant idx 2
834        wire[64..68].copy_from_slice(&0x1001u32.to_le_bytes()); // code
835        wire[68] = 3; // FailureStage::Invariant
836        let r = DecodedReceipt::parse(&wire).expect("should parse failure");
837        assert!(r.had_failure);
838        assert!(!r.invariants_passed);
839        assert_eq!(r.failed_invariant_idx, 0x02);
840        assert_eq!(r.failed_error_code, 0x1001);
841        assert_eq!(r.failure_stage, FailureStage::Invariant);
842    }
843
844    #[test]
845    fn index_record_projects_filterable_receipt_fields() {
846        let wire = sample_wire();
847        let receipt = DecodedReceipt::parse(&wire).unwrap();
848        let record = receipt.index_record();
849
850        assert_eq!(&record.index_key[..8], &receipt.layout_id);
851        assert_eq!(&record.index_key[8..], &receipt.after_fingerprint);
852        assert_eq!(record.changed_field_count, receipt.changed_field_count());
853        assert_eq!(
854            record.changed_segment_count,
855            receipt.changed_segment_count()
856        );
857        assert_eq!(record.phase, receipt.phase);
858        assert_eq!(record.compat_impact, receipt.compat_impact);
859        assert_eq!(record.validation_bundle_id, 7);
860    }
861
862    #[test]
863    fn rejects_reserved_nonzero() {
864        let mut wire = sample_wire();
865        wire[70] = 1; // poison reserved
866        assert!(matches!(
867            DecodedReceipt::parse(&wire),
868            Err(ReceiptError::ReservedNonZero)
869        ));
870    }
871
872    #[test]
873    fn changed_field_iter_enumerates_bits() {
874        let wire = sample_wire();
875        let r = DecodedReceipt::parse(&wire).unwrap();
876        let indices: alloc::vec::Vec<u32> = r.changed_field_indices().collect();
877        assert_eq!(indices, alloc::vec![0u32, 1u32, 3u32]);
878    }
879
880    #[test]
881    fn changed_segment_iter_enumerates_bits() {
882        let wire = sample_wire();
883        let r = DecodedReceipt::parse(&wire).unwrap();
884        let indices: alloc::vec::Vec<u32> = r.changed_segment_indices().collect();
885        assert_eq!(indices, alloc::vec![1u32]);
886    }
887}