Skip to main content

hopper_core/
receipt.rs

1//! State Receipts -- structured mutation summaries.
2//!
3//! A `StateReceipt` captures a complete record of what happened during
4//! an instruction's execution: which fields changed, what the before/after
5//! fingerprints were, which invariants ran, which capabilities were active,
6//! and how many CPI calls or journal appends occurred.
7//!
8//! ## Use Cases
9//!
10//! - **Audit trails**: Emit receipts as events for off-chain indexing
11//! - **Test assertions**: Verify exact mutation footprint in tests
12//! - **Post-mutation validation**: Feed receipt to invariant checks
13//! - **Debugging**: Log receipts during development
14//! - **CLI inspection**: Decode receipt bytes with `hopper receipt`
15//!
16//! ## Usage
17//!
18//! ```ignore
19//! // Before mutation
20//! let mut receipt = StateReceipt::<8>::begin(
21//!     &layout_id,
22//!     account_data,
23//! );
24//!
25//! // ... mutations happen ...
26//!
27//! // After mutation
28//! receipt.commit(account_data);
29//! receipt.set_invariants(true, 3);
30//! receipt.set_policy_flags(DEPOSIT_CAPS.bits());
31//! receipt.set_cpi_count(1);
32//! receipt.set_journal_appends(2);
33//!
34//! // Emit as event
35//! emit_slices(&[&receipt.to_bytes()]);
36//! ```
37
38use crate::diff::StateSnapshot;
39
40/// Maximum fields tracked in a receipt's changed-field bitmask.
41pub const MAX_RECEIPT_FIELDS: usize = 64;
42
43/// Fast non-cryptographic 64-bit fingerprint of a byte slice.
44///
45/// Hopper receipts only need deterministic change detection, not a strong hash.
46/// On SBF, a per-byte multiply-heavy hash is disproportionately expensive, so
47/// receipts use a chunked mixer built from rotates, xor, and adds instead.
48#[inline]
49fn fast_fingerprint(data: &[u8]) -> [u8; 8] {
50    let len = data.len() as u32;
51    let mut lo = 0x243f_6a88_u32 ^ len;
52    let mut hi = 0x85a3_08d3_u32 ^ len.rotate_left(16);
53
54    let mut i = 0usize;
55    while i + 8 <= data.len() {
56        let a = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
57        let b = u32::from_le_bytes([data[i + 4], data[i + 5], data[i + 6], data[i + 7]]);
58        lo = (lo.rotate_left(5) ^ a).wrapping_add(0x9e37_79b9);
59        hi = (hi.rotate_left(7) ^ b).wrapping_add(0x7f4a_7c15);
60        i += 8;
61    }
62
63    if i < data.len() {
64        let mut tail = [0u8; 8];
65        let mut tail_i = 0usize;
66        while i < data.len() {
67            tail[tail_i] = data[i];
68            tail_i += 1;
69            i += 1;
70        }
71        let a = u32::from_le_bytes([tail[0], tail[1], tail[2], tail[3]]);
72        let b = u32::from_le_bytes([tail[4], tail[5], tail[6], tail[7]]);
73        lo = lo.rotate_left(5) ^ a;
74        hi = hi.rotate_left(7) ^ b;
75    }
76
77    let mixed_lo = lo ^ hi.rotate_left(13);
78    let mixed_hi = hi ^ lo.rotate_left(11) ^ len;
79    let mut out = [0u8; 8];
80    out[0..4].copy_from_slice(&mixed_lo.to_le_bytes());
81    out[4..8].copy_from_slice(&mixed_hi.to_le_bytes());
82    if out == [0u8; 8] {
83        out[0] = 1;
84    }
85    out
86}
87
88/// A structured record of a state mutation.
89///
90/// `SNAP_SIZE` is the maximum snapshot size (stack-allocated).
91pub struct StateReceipt<const SNAP_SIZE: usize> {
92    /// Layout ID of the account being mutated.
93    pub layout_id: [u8; 8],
94    /// Before-snapshot.
95    snapshot: StateSnapshot<SNAP_SIZE>,
96    /// Field-level change bitmask (bit N = field N changed).
97    pub changed_fields: u64,
98    /// Number of bytes that changed.
99    pub changed_bytes: usize,
100    /// Number of changed regions (contiguous runs).
101    pub changed_regions: usize,
102    /// Whether the account was resized.
103    pub was_resized: bool,
104    /// Old account data length.
105    pub old_size: usize,
106    /// New account data length.
107    pub new_size: usize,
108    /// Whether all invariants passed after mutation.
109    pub invariants_passed: bool,
110    /// Number of invariants checked.
111    pub invariants_checked: u16,
112    /// Whether CPI was invoked during the instruction.
113    pub cpi_invoked: bool,
114    /// Whether the receipt has been committed (post-mutation data provided).
115    committed: bool,
116    /// Whether execution hit a failure path.
117    ///
118    /// When `true`, `failed_error_code`, `failed_invariant_idx`, and
119    /// `failure_stage` identify the failing check. This closes the
120    /// provable-safety chain: a receipt that escapes the program
121    /// carries enough information for the off-chain SDK to render
122    /// "Invariant `balance_nonzero` failed" instead of an opaque hex code.
123    pub had_failure: bool,
124    /// User error code that aborted execution (e.g. `VaultError::InsufficientBalance as u32`).
125    ///
126    /// `0` when `had_failure` is `false`.
127    pub failed_error_code: u32,
128    /// Index into the program's `INVARIANT_TABLE` for the failing invariant.
129    ///
130    /// `0xFF` means "no invariant was the cause". the failure happened
131    /// outside an invariant check (e.g. a constraint guard).
132    pub failed_invariant_idx: u8,
133    /// Stage at which the failure occurred. See [`FailureStage`].
134    pub failure_stage: u8,
135    /// FNV-1a fingerprint of the data before mutation.
136    pub before_fingerprint: [u8; 8],
137    /// FNV-1a fingerprint of the data after mutation (set on commit).
138    pub after_fingerprint: [u8; 8],
139    /// Bitmask of which segments were touched (bit N = segment N changed).
140    pub segment_changed_mask: u16,
141    /// CapabilitySet bits describing what this instruction does.
142    pub policy_flags: u32,
143    /// Number of journal entries appended during this instruction.
144    pub journal_appends: u16,
145    /// Number of CPI calls made during this instruction.
146    pub cpi_count: u8,
147    /// Instruction phase tag (see [`Phase`]).
148    pub phase: u8,
149    /// Validation bundle identifier (program-defined).
150    pub validation_bundle_id: u16,
151    /// Compatibility impact of this mutation (see [`CompatImpact`]).
152    pub compat_impact: u8,
153    /// Migration flags (bit 0 = triggered, bit 1 = realloc, bit 2 = schema bump).
154    pub migration_flags: u8,
155}
156
157/// Stage of instruction execution at which a failure was recorded.
158///
159/// Surfaced in `StateReceipt::failure_stage` so operators can tell
160/// whether an error fired before any mutation, during a mutation's
161/// invariant check, after a successful mutation, or during teardown.
162/// Combined with `failed_error_code` this lets the SDK pinpoint
163/// exactly where in the instruction lifecycle the failure happened.
164#[repr(u8)]
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum FailureStage {
167    /// No failure (receipt committed cleanly).
168    None = 0,
169    /// Failed during account/context validation (pre-handler).
170    Validation = 1,
171    /// Failed inside the instruction handler before any invariant.
172    Handler = 2,
173    /// Failed inside an invariant check.
174    Invariant = 3,
175    /// Failed during the post-handler receipt commit/emit path.
176    Post = 4,
177    /// Failed inside a close guard / teardown routine.
178    Teardown = 5,
179}
180
181impl FailureStage {
182    #[inline(always)]
183    pub fn from_tag(tag: u8) -> Self {
184        match tag {
185            1 => Self::Validation,
186            2 => Self::Handler,
187            3 => Self::Invariant,
188            4 => Self::Post,
189            5 => Self::Teardown,
190            _ => Self::None,
191        }
192    }
193
194    #[inline(always)]
195    pub fn name(self) -> &'static str {
196        match self {
197            Self::None => "none",
198            Self::Validation => "validation",
199            Self::Handler => "handler",
200            Self::Invariant => "invariant",
201            Self::Post => "post",
202            Self::Teardown => "teardown",
203        }
204    }
205}
206
207/// Sentinel value for `failed_invariant_idx` meaning "no invariant
208/// was associated with the failure".
209pub const FAILED_INVARIANT_NONE: u8 = 0xFF;
210
211/// Instruction execution phase encoded in a receipt.
212#[repr(u8)]
213#[derive(Clone, Copy, Debug, PartialEq, Eq)]
214pub enum Phase {
215    /// Normal update / mutation.
216    Update = 0,
217    /// Account initialization.
218    Init = 1,
219    /// Account close / deletion.
220    Close = 2,
221    /// Migration to a new layout version.
222    Migrate = 3,
223    /// Read-only / view (no mutation expected).
224    ReadOnly = 4,
225}
226
227impl Phase {
228    /// Convert from raw tag.
229    #[inline(always)]
230    pub fn from_tag(tag: u8) -> Self {
231        match tag {
232            1 => Self::Init,
233            2 => Self::Close,
234            3 => Self::Migrate,
235            4 => Self::ReadOnly,
236            _ => Self::Update,
237        }
238    }
239
240    /// Human-readable name.
241    #[inline(always)]
242    pub fn name(self) -> &'static str {
243        match self {
244            Self::Update => "Update",
245            Self::Init => "Init",
246            Self::Close => "Close",
247            Self::Migrate => "Migrate",
248            Self::ReadOnly => "ReadOnly",
249        }
250    }
251}
252
253/// Compatibility impact level encoded in a receipt.
254#[repr(u8)]
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum CompatImpact {
257    /// No compatibility impact.
258    None = 0,
259    /// Append-only growth, backward readable.
260    Append = 1,
261    /// Full migration required.
262    Migration = 2,
263    /// Breaking change.
264    Breaking = 3,
265}
266
267impl CompatImpact {
268    /// Convert from raw tag.
269    #[inline(always)]
270    pub fn from_tag(tag: u8) -> Self {
271        match tag {
272            1 => Self::Append,
273            2 => Self::Migration,
274            3 => Self::Breaking,
275            _ => Self::None,
276        }
277    }
278
279    /// Human-readable name.
280    #[inline(always)]
281    pub fn name(self) -> &'static str {
282        match self {
283            Self::None => "none",
284            Self::Append => "append",
285            Self::Migration => "migration",
286            Self::Breaking => "breaking",
287        }
288    }
289}
290
291impl<const SNAP_SIZE: usize> StateReceipt<SNAP_SIZE> {
292    /// Begin recording a state receipt.
293    ///
294    /// Captures a before-snapshot and fingerprint of the account data.
295    #[inline]
296    pub fn begin(layout_id: &[u8; 8], data: &[u8]) -> Self {
297        Self {
298            layout_id: *layout_id,
299            snapshot: StateSnapshot::capture(data),
300            changed_fields: 0,
301            changed_bytes: 0,
302            changed_regions: 0,
303            was_resized: false,
304            old_size: data.len(),
305            new_size: data.len(),
306            invariants_passed: false,
307            invariants_checked: 0,
308            cpi_invoked: false,
309            committed: false,
310            had_failure: false,
311            failed_error_code: 0,
312            failed_invariant_idx: FAILED_INVARIANT_NONE,
313            failure_stage: FailureStage::None as u8,
314            before_fingerprint: fast_fingerprint(data),
315            after_fingerprint: [0; 8],
316            segment_changed_mask: 0,
317            policy_flags: 0,
318            journal_appends: 0,
319            cpi_count: 0,
320            phase: Phase::Update as u8,
321            validation_bundle_id: 0,
322            compat_impact: CompatImpact::None as u8,
323            migration_flags: 0,
324        }
325    }
326
327    /// Commit the receipt by providing post-mutation data.
328    ///
329    /// Computes the diff and after-fingerprint.
330    #[inline]
331    pub fn commit(&mut self, current_data: &[u8]) {
332        let diff = self.snapshot.diff(current_data);
333        self.changed_bytes = diff.changed_byte_count();
334        self.was_resized = diff.was_resized();
335        self.new_size = current_data.len();
336
337        let regions = diff.changed_regions::<16>();
338        self.changed_regions = regions.len();
339
340        self.after_fingerprint = fast_fingerprint(current_data);
341        self.committed = true;
342    }
343
344    /// Commit with field-level tracking.
345    ///
346    /// `fields` is `(name, offset, size)` per layout field.
347    /// Sets the `changed_fields` bitmask based on which fields actually changed.
348    #[inline]
349    pub fn commit_with_fields(&mut self, current_data: &[u8], fields: &[(&str, usize, usize)]) {
350        self.commit(current_data);
351        self.changed_fields =
352            crate::diff::field_diff_mask(self.snapshot.data(), current_data, fields);
353    }
354
355    /// Commit with segment-level tracking.
356    ///
357    /// `segments` is `(offset, size)` per segment in the account.
358    /// Sets `segment_changed_mask` based on which segments have byte changes.
359    #[inline]
360    pub fn commit_with_segments(&mut self, current_data: &[u8], segments: &[(usize, usize)]) {
361        self.commit(current_data);
362        let snap_data = self.snapshot.data();
363        let mut mask: u16 = 0;
364        let compare_len = if snap_data.len() < current_data.len() {
365            snap_data.len()
366        } else {
367            current_data.len()
368        };
369        for (i, &(offset, size)) in segments.iter().enumerate() {
370            if i >= 16 {
371                break;
372            }
373            let end = offset + size;
374            if end <= compare_len {
375                if snap_data[offset..end] != current_data[offset..end] {
376                    mask |= 1 << i;
377                }
378            } else if offset < compare_len {
379                // Partial overlap: segment extends beyond one of the buffers
380                mask |= 1 << i;
381            } else if self.was_resized {
382                // Segment entirely in new region
383                mask |= 1 << i;
384            }
385        }
386        self.segment_changed_mask = mask;
387    }
388
389    /// Set invariant results.
390    #[inline(always)]
391    pub fn set_invariants(&mut self, passed: bool, checked: u16) {
392        self.invariants_passed = passed;
393        self.invariants_checked = checked;
394    }
395
396    /// Set invariant pass status (convenience).
397    #[inline(always)]
398    pub fn set_invariants_passed(&mut self, passed: bool) {
399        self.invariants_passed = passed;
400    }
401
402    /// Mark that CPI was invoked during this instruction.
403    #[inline(always)]
404    pub fn set_cpi_invoked(&mut self, invoked: bool) {
405        self.cpi_invoked = invoked;
406    }
407
408    /// Set the number of CPI calls made. Also sets `cpi_invoked` if count > 0.
409    #[inline(always)]
410    pub fn set_cpi_count(&mut self, count: u8) {
411        self.cpi_count = count;
412        self.cpi_invoked = count > 0;
413    }
414
415    /// Set the policy/capability flags for this instruction.
416    ///
417    /// Pass `CapabilitySet::bits()` to record which capabilities were active.
418    #[inline(always)]
419    pub fn set_policy_flags(&mut self, flags: u32) {
420        self.policy_flags = flags;
421    }
422
423    /// Set the number of journal entries appended during this instruction.
424    #[inline(always)]
425    pub fn set_journal_appends(&mut self, count: u16) {
426        self.journal_appends = count;
427    }
428
429    /// Set the instruction phase.
430    #[inline(always)]
431    pub fn set_phase(&mut self, phase: Phase) {
432        self.phase = phase as u8;
433    }
434
435    /// Set the validation bundle identifier.
436    #[inline(always)]
437    pub fn set_validation_bundle_id(&mut self, id: u16) {
438        self.validation_bundle_id = id;
439    }
440
441    /// Set the compatibility impact level.
442    #[inline(always)]
443    pub fn set_compat_impact(&mut self, impact: CompatImpact) {
444        self.compat_impact = impact as u8;
445    }
446
447    /// Set migration flags (bit 0 = triggered, bit 1 = realloc, bit 2 = schema bump).
448    #[inline(always)]
449    pub fn set_migration_flags(&mut self, flags: u8) {
450        self.migration_flags = flags;
451    }
452
453    /// Record a failing error code, its associated invariant index (if any),
454    /// and the stage at which the failure occurred.
455    ///
456    /// This is the hook that closes the invariant-error chain: when an
457    /// instruction aborts, the program writes the *actual* user error
458    /// code (from the `#[hopper::error]`-derived enum) and the invariant
459    /// index (from `INVARIANT_TABLE`) into the receipt before emitting
460    /// it. The off-chain SDK can then map the code back to a variant name
461    /// and the idx to an invariant label without ever guessing.
462    ///
463    /// Pass `FAILED_INVARIANT_NONE` for `invariant_idx` when the failure
464    /// did not come from an invariant check (for example a constraint
465    /// guard or an account validation failure).
466    #[inline]
467    pub fn set_failure(&mut self, code: u32, invariant_idx: u8, stage: FailureStage) {
468        self.had_failure = true;
469        self.failed_error_code = code;
470        self.failed_invariant_idx = invariant_idx;
471        self.failure_stage = stage as u8;
472        // A recorded failure implies invariants did not all pass, but we
473        // don't touch `invariants_checked`. the caller still gets to
474        // report how many were evaluated before the abort.
475        self.invariants_passed = false;
476    }
477
478    /// Convenience: record a failure caused by a specific invariant index.
479    #[inline]
480    pub fn set_invariant_failure(&mut self, code: u32, invariant_idx: u8) {
481        self.set_failure(code, invariant_idx, FailureStage::Invariant);
482    }
483
484    /// Whether the receipt has been committed.
485    #[inline(always)]
486    pub fn is_committed(&self) -> bool {
487        self.committed
488    }
489
490    /// Whether any data actually changed.
491    #[inline(always)]
492    pub fn has_changes(&self) -> bool {
493        self.changed_bytes > 0 || self.was_resized
494    }
495
496    /// Whether the before and after fingerprints differ.
497    #[inline(always)]
498    pub fn fingerprint_changed(&self) -> bool {
499        self.before_fingerprint != self.after_fingerprint
500    }
501
502    /// Serialize the receipt summary into a fixed-size byte array.
503    ///
504    /// Wire format (72 bytes, 8-byte aligned):
505    /// ```text
506    /// [layout_id: 8 bytes]                  //  0.. 8
507    /// [changed_fields: 8 bytes (u64 LE)]    //  8..16
508    /// [changed_bytes: 4 bytes (u32 LE)]     // 16..20
509    /// [changed_regions: 2 bytes (u16 LE)]   // 20..22
510    /// [old_size: 4 bytes (u32 LE)]          // 22..26
511    /// [new_size: 4 bytes (u32 LE)]          // 26..30
512    /// [invariants_checked: 2 bytes (u16 LE)]// 30..32
513    /// [flags: 1 byte]                       // 32
514    ///   bit 0: was_resized
515    ///   bit 1: invariants_passed
516    ///   bit 2: cpi_invoked
517    ///   bit 3: committed
518    ///   bit 4: had_failure
519    /// [before_fingerprint: 8 bytes]         // 33..41
520    /// [after_fingerprint: 8 bytes]          // 41..49
521    /// [segment_changed_mask: 2 bytes (u16)] // 49..51
522    /// [policy_flags: 4 bytes (u32 LE)]      // 51..55
523    /// [journal_appends: 2 bytes (u16 LE)]   // 55..57
524    /// [cpi_count: 1 byte]                   // 57
525    /// [phase: 1 byte]                         // 58
526    /// [validation_bundle_id: 2 bytes (u16)]   // 59..61
527    /// [compat_impact: 1 byte]                 // 61
528    /// [migration_flags: 1 byte]               // 62
529    /// [failed_invariant_idx: 1 byte]          // 63   (0xFF = none)
530    /// [failed_error_code: 4 bytes (u32 LE)]   // 64..68 (0 = none)
531    /// [failure_stage: 1 byte]                 // 68   (see FailureStage)
532    /// [_reserved: 3 bytes]                    // 69..72 (future)
533    /// ```
534    ///
535    /// Pre-0.2 decoders that stopped at byte 64 still read correctly:
536    /// the first 64 bytes carry exactly the same fields they always
537    /// did, plus one new flag bit and one new idx byte that old
538    /// parsers can safely ignore.
539    #[inline]
540    pub fn to_bytes(&self) -> [u8; RECEIPT_SIZE] {
541        let mut out = [0u8; RECEIPT_SIZE];
542        // layout_id
543        out[0..8].copy_from_slice(&self.layout_id);
544        // changed_fields
545        out[8..16].copy_from_slice(&self.changed_fields.to_le_bytes());
546        // changed_bytes
547        out[16..20].copy_from_slice(&(self.changed_bytes as u32).to_le_bytes());
548        // changed_regions
549        out[20..22].copy_from_slice(&(self.changed_regions as u16).to_le_bytes());
550        // old_size
551        out[22..26].copy_from_slice(&(self.old_size as u32).to_le_bytes());
552        // new_size
553        out[26..30].copy_from_slice(&(self.new_size as u32).to_le_bytes());
554        // invariants_checked
555        out[30..32].copy_from_slice(&self.invariants_checked.to_le_bytes());
556        // flags
557        let mut flags: u8 = 0;
558        if self.was_resized {
559            flags |= 1 << 0;
560        }
561        if self.invariants_passed {
562            flags |= 1 << 1;
563        }
564        if self.cpi_invoked {
565            flags |= 1 << 2;
566        }
567        if self.committed {
568            flags |= 1 << 3;
569        }
570        if self.had_failure {
571            flags |= 1 << 4;
572        }
573        out[32] = flags;
574        // before_fingerprint
575        out[33..41].copy_from_slice(&self.before_fingerprint);
576        // after_fingerprint
577        out[41..49].copy_from_slice(&self.after_fingerprint);
578        // segment_changed_mask
579        out[49..51].copy_from_slice(&self.segment_changed_mask.to_le_bytes());
580        // policy_flags
581        out[51..55].copy_from_slice(&self.policy_flags.to_le_bytes());
582        // journal_appends
583        out[55..57].copy_from_slice(&self.journal_appends.to_le_bytes());
584        // cpi_count
585        out[57] = self.cpi_count;
586        // phase
587        out[58] = self.phase;
588        // validation_bundle_id
589        out[59..61].copy_from_slice(&self.validation_bundle_id.to_le_bytes());
590        // compat_impact
591        out[61] = self.compat_impact;
592        // migration_flags
593        out[62] = self.migration_flags;
594        // failed_invariant_idx
595        out[63] = self.failed_invariant_idx;
596        // failed_error_code
597        out[64..68].copy_from_slice(&self.failed_error_code.to_le_bytes());
598        // failure_stage
599        out[68] = self.failure_stage;
600        // out[69..72] intentionally left zero (reserved)
601        out
602    }
603}
604
605/// Receipt summary size in bytes.
606pub const RECEIPT_SIZE: usize = 72;
607
608/// Legacy receipt size, kept as a named constant for readers that want to
609/// quickly check if they received the shorter pre-0.2 receipt and ignore
610/// the failure-payload suffix.
611pub const RECEIPT_SIZE_LEGACY: usize = 64;
612
613/// Decoded receipt from wire bytes. Useful for CLI and off-chain tooling.
614pub struct DecodedReceipt {
615    pub layout_id: [u8; 8],
616    pub changed_fields: u64,
617    pub changed_bytes: u32,
618    pub changed_regions: u16,
619    pub old_size: u32,
620    pub new_size: u32,
621    pub invariants_checked: u16,
622    pub was_resized: bool,
623    pub invariants_passed: bool,
624    pub cpi_invoked: bool,
625    pub committed: bool,
626    pub before_fingerprint: [u8; 8],
627    pub after_fingerprint: [u8; 8],
628    pub segment_changed_mask: u16,
629    pub policy_flags: u32,
630    pub journal_appends: u16,
631    pub cpi_count: u8,
632    pub phase: u8,
633    pub validation_bundle_id: u16,
634    pub compat_impact: u8,
635    pub migration_flags: u8,
636    /// `true` when the receipt records a failure (flags bit 4).
637    pub had_failure: bool,
638    /// User error code for the failing check, or `0` when no failure.
639    pub failed_error_code: u32,
640    /// Invariant index for the failure, `FAILED_INVARIANT_NONE` (0xFF) when none.
641    pub failed_invariant_idx: u8,
642    /// Stage of execution at which the failure happened. See [`FailureStage`].
643    pub failure_stage: u8,
644}
645
646impl DecodedReceipt {
647    /// Decode a receipt from its 72-byte wire representation.
648    ///
649    /// Accepts legacy 64-byte receipts for backwards compatibility:
650    /// when given exactly `RECEIPT_SIZE_LEGACY` bytes, the failure
651    /// payload fields are populated from the legacy flag bits and
652    /// otherwise left zeroed.
653    ///
654    /// Returns `None` if the slice is shorter than the legacy size.
655    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
656        if bytes.len() < RECEIPT_SIZE_LEGACY {
657            return None;
658        }
659        let mut layout_id = [0u8; 8];
660        layout_id.copy_from_slice(&bytes[0..8]);
661        let changed_fields = u64::from_le_bytes([
662            bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
663        ]);
664        let changed_bytes = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
665        let changed_regions = u16::from_le_bytes([bytes[20], bytes[21]]);
666        let old_size = u32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
667        let new_size = u32::from_le_bytes([bytes[26], bytes[27], bytes[28], bytes[29]]);
668        let invariants_checked = u16::from_le_bytes([bytes[30], bytes[31]]);
669        let flags = bytes[32];
670        let was_resized = flags & (1 << 0) != 0;
671        let invariants_passed = flags & (1 << 1) != 0;
672        let cpi_invoked = flags & (1 << 2) != 0;
673        let committed = flags & (1 << 3) != 0;
674        let had_failure = flags & (1 << 4) != 0;
675
676        let mut before_fingerprint = [0u8; 8];
677        before_fingerprint.copy_from_slice(&bytes[33..41]);
678        let mut after_fingerprint = [0u8; 8];
679        after_fingerprint.copy_from_slice(&bytes[41..49]);
680        let segment_changed_mask = u16::from_le_bytes([bytes[49], bytes[50]]);
681        let policy_flags = u32::from_le_bytes([bytes[51], bytes[52], bytes[53], bytes[54]]);
682        let journal_appends = u16::from_le_bytes([bytes[55], bytes[56]]);
683        let cpi_count = bytes[57];
684        let phase = bytes[58];
685        let validation_bundle_id = u16::from_le_bytes([bytes[59], bytes[60]]);
686        let compat_impact = bytes[61];
687        let migration_flags = bytes[62];
688
689        // Failure payload. In a legacy 64-byte receipt the upper bytes do
690        // not exist; we fall back to sensible defaults rather than fail
691        // the parse so callers that only have the shortened format still
692        // get the remaining fields.
693        let (failed_invariant_idx, failed_error_code, failure_stage) =
694            if bytes.len() >= RECEIPT_SIZE {
695                let idx = bytes[63];
696                let code = u32::from_le_bytes([bytes[64], bytes[65], bytes[66], bytes[67]]);
697                let stage = bytes[68];
698                (idx, code, stage)
699            } else {
700                (FAILED_INVARIANT_NONE, 0u32, FailureStage::None as u8)
701            };
702
703        Some(Self {
704            layout_id,
705            changed_fields,
706            changed_bytes,
707            changed_regions,
708            old_size,
709            new_size,
710            invariants_checked,
711            was_resized,
712            invariants_passed,
713            cpi_invoked,
714            committed,
715            before_fingerprint,
716            after_fingerprint,
717            segment_changed_mask,
718            policy_flags,
719            journal_appends,
720            cpi_count,
721            phase,
722            validation_bundle_id,
723            compat_impact,
724            migration_flags,
725            had_failure,
726            failed_error_code,
727            failed_invariant_idx,
728            failure_stage,
729        })
730    }
731
732    /// Resolve the `failure_stage` byte to a [`FailureStage`] enum.
733    #[inline(always)]
734    pub fn failure_stage_enum(&self) -> FailureStage {
735        FailureStage::from_tag(self.failure_stage)
736    }
737
738    /// Whether data actually changed according to this receipt.
739    #[inline(always)]
740    pub fn has_changes(&self) -> bool {
741        self.changed_bytes > 0 || self.was_resized
742    }
743
744    /// Whether before/after fingerprints differ.
745    #[inline(always)]
746    pub fn fingerprint_changed(&self) -> bool {
747        self.before_fingerprint != self.after_fingerprint
748    }
749
750    /// Resolve the `phase` byte to a [`Phase`] enum.
751    #[inline(always)]
752    pub fn phase_enum(&self) -> Phase {
753        Phase::from_tag(self.phase)
754    }
755
756    /// Resolve the `compat_impact` byte to a [`CompatImpact`] enum.
757    #[inline(always)]
758    pub fn compat_impact_enum(&self) -> CompatImpact {
759        CompatImpact::from_tag(self.compat_impact)
760    }
761
762    /// Return a structured human-readable explanation of this receipt.
763    ///
764    /// This is the "operator UX" layer-every numeric field gets a semantic
765    /// label so tools, dashboards, and CLI output can show meaningful text
766    /// instead of raw bytes.
767    pub fn explain(&self) -> ReceiptExplain {
768        let phase = self.phase_enum();
769        let compat = self.compat_impact_enum();
770
771        let mutation_desc = if !self.has_changes() {
772            "No mutations detected"
773        } else if self.was_resized {
774            "Account was resized"
775        } else {
776            "Account data modified in-place"
777        };
778
779        let integrity_desc = if self.had_failure {
780            // Failure payload dominates every other integrity signal.
781            // Operators and the SDK need to know *this run aborted*
782            // before they consume any other field.
783            match self.failure_stage_enum() {
784                FailureStage::Invariant => "INVARIANT FAILED. execution aborted",
785                FailureStage::Validation => "Account validation failed. execution aborted",
786                FailureStage::Handler => "Handler aborted before invariant evaluation",
787                FailureStage::Post => "Failure during receipt commit path",
788                FailureStage::Teardown => "Failure during close/teardown",
789                FailureStage::None => "FAILURE flagged without stage (malformed receipt)",
790            }
791        } else if !self.committed {
792            "Receipt was NOT committed (incomplete)"
793        } else if self.invariants_passed && self.invariants_checked > 0 {
794            "All invariants passed"
795        } else if self.invariants_checked > 0 {
796            "INVARIANT VIOLATION detected"
797        } else {
798            "No invariants checked"
799        };
800
801        let cpi_desc = if self.cpi_invoked {
802            "CPI was invoked during execution"
803        } else {
804            "No CPI calls"
805        };
806
807        ReceiptExplain {
808            phase_name: phase.name(),
809            compat_label: compat.name(),
810            policy_name: "unknown",
811            mutation_summary: mutation_desc,
812            integrity_summary: integrity_desc,
813            cpi_summary: cpi_desc,
814            changed_field_count: self.changed_fields.count_ones() as u16,
815            segment_count: self.segment_changed_mask.count_ones() as u8,
816            fingerprint_changed: self.fingerprint_changed(),
817            segment_role_names: [""; 8],
818            segment_role_count: 0,
819        }
820    }
821}
822
823/// Human-readable explanation of a decoded receipt.
824///
825/// Produced by [`DecodedReceipt::explain()`]. Every field is a semantic
826/// label, not a raw number-designed for operator dashboards, CLI output,
827/// and audit logs.
828pub struct ReceiptExplain {
829    /// Phase name ("Update", "Init", "Close", "Migrate", "ReadOnly").
830    pub phase_name: &'static str,
831    /// Compatibility impact label ("None", "Append", "Migration", "Breaking").
832    pub compat_label: &'static str,
833    /// Policy pack name that governed this instruction ("unknown" when not embedded).
834    pub policy_name: &'static str,
835    /// One-sentence description of what mutation occurred.
836    pub mutation_summary: &'static str,
837    /// One-sentence description of invariant check result.
838    pub integrity_summary: &'static str,
839    /// One-sentence CPI summary.
840    pub cpi_summary: &'static str,
841    /// Number of individual fields changed (popcount of changed_fields mask).
842    pub changed_field_count: u16,
843    /// Number of segments that were modified.
844    pub segment_count: u8,
845    /// Whether the before/after fingerprints differ.
846    pub fingerprint_changed: bool,
847    /// Role names for modified segments (up to 8). Use `segment_role_count`
848    /// to know how many entries are valid.
849    pub segment_role_names: [&'static str; 8],
850    /// Number of valid entries in `segment_role_names`.
851    pub segment_role_count: u8,
852}
853
854impl ReceiptExplain {
855    /// Return a copy with the given policy name injected.
856    ///
857    /// The receipt wire format does not carry the policy pack name-only a
858    /// bitmask of flags. Call this after constructing an explain from the
859    /// decoded receipt when you know which policy pack governed the
860    /// instruction (e.g. from the program manifest).
861    #[inline]
862    pub const fn with_policy_name(mut self, name: &'static str) -> Self {
863        self.policy_name = name;
864        self
865    }
866
867    /// Inject a segment role name at the given index.
868    ///
869    /// Call once per modified segment, using the `SegmentRole::name()`
870    /// output for each bit set in `segment_changed_mask`. This enriches
871    /// the explain with human-readable role labels.
872    #[inline]
873    pub const fn with_segment_role(mut self, idx: u8, name: &'static str) -> Self {
874        if (idx as usize) < 8 {
875            self.segment_role_names[idx as usize] = name;
876            if idx >= self.segment_role_count {
877                self.segment_role_count = idx + 1;
878            }
879        }
880        self
881    }
882
883    /// One-line human-readable summary combining phase, mutation,
884    /// and integrity status.
885    #[inline]
886    pub const fn summary(&self) -> &'static str {
887        // Phase + mutation + integrity condensed into a single static label.
888        // Because we're no_std we return the most descriptive static string
889        // based on the phase and mutation state.
890        if !crate::const_str_eq(self.phase_name, "Update")
891            && !crate::const_str_eq(self.phase_name, "Init")
892            && !crate::const_str_eq(self.phase_name, "Close")
893            && !crate::const_str_eq(self.phase_name, "Migrate")
894        {
895            return "Read-only operation, no state changes";
896        }
897        if crate::const_str_eq(self.phase_name, "Init") {
898            return "Account initialized";
899        }
900        if crate::const_str_eq(self.phase_name, "Close") {
901            return "Account closed";
902        }
903        if crate::const_str_eq(self.phase_name, "Migrate") {
904            if self.fingerprint_changed {
905                return "Migration applied, layout fingerprint updated";
906            }
907            return "Migration applied";
908        }
909        // Update phase, provide more detail based on what changed
910        if !self.fingerprint_changed && self.changed_field_count == 0 {
911            return "Update executed with no observable state changes";
912        }
913        if self.fingerprint_changed && self.changed_field_count > 0 && self.segment_count > 1 {
914            return "State mutated across multiple segments with fingerprint change";
915        }
916        if self.fingerprint_changed && self.changed_field_count > 0 {
917            return "State mutated with fingerprint change";
918        }
919        if self.fingerprint_changed {
920            return "Fingerprint changed without field-level mutations";
921        }
922        if self.changed_field_count > 0 && self.segment_count > 1 {
923            return "State mutated across multiple segments";
924        }
925        if self.changed_field_count > 0 {
926            return "State mutated";
927        }
928        "Update completed"
929    }
930}
931
932// ---------------------------------------------------------------------------
933// Receipt Narrative -- auto-generated human explanations
934// ---------------------------------------------------------------------------
935
936/// An auto-generated human-readable narrative describing a mutation.
937///
938/// Built from the receipt explain plus optional field intents, policy class,
939/// and segment roles. This is the "operator artifact" layer that turns
940/// binary receipt data into sentences an operator can actually read.
941pub struct ReceiptNarrative {
942    /// Sentence fragments describing what happened. Up to 8 fragments.
943    pub fragments: [&'static str; 8],
944    /// Number of valid fragments.
945    pub count: u8,
946    /// Overall risk level of this mutation.
947    pub risk_level: NarrativeRisk,
948}
949
950/// Risk level for a receipt narrative.
951#[derive(Clone, Copy, Debug, PartialEq, Eq)]
952#[repr(u8)]
953pub enum NarrativeRisk {
954    /// No observable state changes.
955    None = 0,
956    /// Standard mutation, nothing unusual.
957    Low = 1,
958    /// Mutation touches authority or financial fields.
959    Medium = 2,
960    /// Migration, resize, or integrity violation detected.
961    High = 3,
962    /// Invariant failure or uncommitted receipt.
963    Critical = 4,
964}
965
966impl NarrativeRisk {
967    /// Human-readable label.
968    pub const fn name(self) -> &'static str {
969        match self {
970            Self::None => "none",
971            Self::Low => "low",
972            Self::Medium => "medium",
973            Self::High => "high",
974            Self::Critical => "critical",
975        }
976    }
977}
978
979impl core::fmt::Display for NarrativeRisk {
980    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
981        f.write_str(self.name())
982    }
983}
984
985impl ReceiptNarrative {
986    /// Generate a narrative from a receipt explanation.
987    ///
988    /// Produces a sequence of human-readable fragments describing the
989    /// mutation, along with a risk assessment.
990    pub fn from_explain(explain: &ReceiptExplain) -> Self {
991        let mut frags: [&'static str; 8] = [""; 8];
992        let mut n = 0u8;
993        let mut risk = NarrativeRisk::None;
994
995        // Phase description
996        let phase_frag = match explain.phase_name {
997            "Init" => "Account was initialized.",
998            "Close" => "Account was closed.",
999            "Migrate" => "Migration was applied to the account.",
1000            "ReadOnly" => "Read-only operation executed.",
1001            _ => "State mutation executed.",
1002        };
1003        if n < 8 {
1004            frags[n as usize] = phase_frag;
1005            n += 1;
1006        }
1007
1008        // Mutation details
1009        if explain.changed_field_count > 0 {
1010            risk = NarrativeRisk::Low;
1011            if explain.segment_count > 1 {
1012                if n < 8 {
1013                    frags[n as usize] = "Changes span multiple segments.";
1014                    n += 1;
1015                }
1016            }
1017        }
1018
1019        // Fingerprint change
1020        if explain.fingerprint_changed {
1021            if n < 8 {
1022                frags[n as usize] = "Layout fingerprint changed.";
1023                n += 1;
1024            }
1025            if risk as u8 == NarrativeRisk::Low as u8 {
1026                risk = NarrativeRisk::Medium;
1027            }
1028        }
1029
1030        // Compatibility impact
1031        match explain.compat_label {
1032            "Append" => {
1033                if n < 8 {
1034                    frags[n as usize] = "Append-safe extension applied.";
1035                    n += 1;
1036                }
1037            }
1038            "Migration" => {
1039                if n < 8 {
1040                    frags[n as usize] = "Migration-level change detected.";
1041                    n += 1;
1042                }
1043                risk = NarrativeRisk::High;
1044            }
1045            "Breaking" => {
1046                if n < 8 {
1047                    frags[n as usize] = "Breaking compatibility change.";
1048                    n += 1;
1049                }
1050                risk = NarrativeRisk::High;
1051            }
1052            _ => {}
1053        }
1054
1055        // CPI
1056        if !crate::const_str_eq(explain.cpi_summary, "No CPI calls") {
1057            if n < 8 {
1058                frags[n as usize] = "Cross-program invocation occurred.";
1059                n += 1;
1060            }
1061        }
1062
1063        // Integrity
1064        if crate::const_str_eq(explain.integrity_summary, "INVARIANT VIOLATION detected") {
1065            if n < 8 {
1066                frags[n as usize] = "INVARIANT VIOLATION: post-mutation checks failed.";
1067                n += 1;
1068            }
1069            risk = NarrativeRisk::Critical;
1070        }
1071        if crate::const_str_eq(
1072            explain.integrity_summary,
1073            "Receipt was NOT committed (incomplete)",
1074        ) {
1075            if n < 8 {
1076                frags[n as usize] = "Receipt was not committed. Mutation may be incomplete.";
1077                n += 1;
1078            }
1079            risk = NarrativeRisk::Critical;
1080        }
1081
1082        // Segment roles
1083        if explain.segment_role_count > 0 {
1084            let mut i = 0u8;
1085            while i < explain.segment_role_count && i < 8 {
1086                let role = explain.segment_role_names[i as usize];
1087                if crate::const_str_eq(role, "audit") || crate::const_str_eq(role, "Audit") {
1088                    if n < 8 {
1089                        frags[n as usize] = "Audit segment was touched.";
1090                        n += 1;
1091                    }
1092                }
1093                i += 1;
1094            }
1095        }
1096
1097        // Phase-level risk escalation
1098        if crate::const_str_eq(explain.phase_name, "Migrate") {
1099            if (risk as u8) < NarrativeRisk::High as u8 {
1100                risk = NarrativeRisk::High;
1101            }
1102        }
1103
1104        Self {
1105            fragments: frags,
1106            count: n,
1107            risk_level: risk,
1108        }
1109    }
1110}
1111
1112impl core::fmt::Display for ReceiptNarrative {
1113    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1114        let mut i = 0u8;
1115        while i < self.count {
1116            if i > 0 {
1117                write!(f, " ")?;
1118            }
1119            write!(f, "{}", self.fragments[i as usize])?;
1120            i += 1;
1121        }
1122        write!(f, " [risk: {}]", self.risk_level.name())
1123    }
1124}