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
646/// Stable, indexer-oriented projection of a decoded receipt.
647///
648/// This keeps the 72-byte wire format unchanged while giving off-chain stores a
649/// compact row shape to key, filter, and aggregate by layout, phase, changed
650/// fields, touched segments, compatibility impact, and failure metadata.
651#[derive(Clone, Copy, Debug, PartialEq, Eq)]
652pub struct ReceiptIndexRecord {
653    /// Deterministic grouping key: `layout_id || after_fingerprint`.
654    pub index_key: [u8; 16],
655    /// Layout identifier of the account this receipt was produced for.
656    pub layout_id: [u8; 8],
657    /// Instruction phase at which the receipt was sealed.
658    pub phase: u8,
659    /// Compatibility class of the mutation.
660    pub compat_impact: u8,
661    /// Bitmask of migration-related flags.
662    pub migration_flags: u8,
663    /// Bitmask of changed field indices.
664    pub changed_fields: u64,
665    /// Number of changed field bits set in `changed_fields`.
666    pub changed_field_count: u16,
667    /// Bitmask of changed segment indices.
668    pub segment_changed_mask: u16,
669    /// Number of changed segment bits set in `segment_changed_mask`.
670    pub changed_segment_count: u8,
671    /// Policy/capability flags active for the instruction.
672    pub policy_flags: u32,
673    /// Program-defined validation bundle identifier.
674    pub validation_bundle_id: u16,
675    /// Account data size before mutation.
676    pub old_size: u32,
677    /// Account data size after mutation.
678    pub new_size: u32,
679    /// Total number of changed bytes.
680    pub changed_bytes: u32,
681    /// Whether the receipt recorded a failure path.
682    pub had_failure: bool,
683    /// Program error code associated with the failure, or zero when none.
684    pub failed_error_code: u32,
685    /// Invariant index associated with the failure, or `FAILED_INVARIANT_NONE`.
686    pub failed_invariant_idx: u8,
687    /// Stage at which the failure occurred.
688    pub failure_stage: u8,
689}
690
691impl DecodedReceipt {
692    /// Decode a receipt from its 72-byte wire representation.
693    ///
694    /// Accepts legacy 64-byte receipts for backwards compatibility:
695    /// when given exactly `RECEIPT_SIZE_LEGACY` bytes, the failure
696    /// payload fields are populated from the legacy flag bits and
697    /// otherwise left zeroed.
698    ///
699    /// Returns `None` if the slice is shorter than the legacy size.
700    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
701        if bytes.len() < RECEIPT_SIZE_LEGACY {
702            return None;
703        }
704        let mut layout_id = [0u8; 8];
705        layout_id.copy_from_slice(&bytes[0..8]);
706        let changed_fields = u64::from_le_bytes([
707            bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
708        ]);
709        let changed_bytes = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
710        let changed_regions = u16::from_le_bytes([bytes[20], bytes[21]]);
711        let old_size = u32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
712        let new_size = u32::from_le_bytes([bytes[26], bytes[27], bytes[28], bytes[29]]);
713        let invariants_checked = u16::from_le_bytes([bytes[30], bytes[31]]);
714        let flags = bytes[32];
715        let was_resized = flags & (1 << 0) != 0;
716        let invariants_passed = flags & (1 << 1) != 0;
717        let cpi_invoked = flags & (1 << 2) != 0;
718        let committed = flags & (1 << 3) != 0;
719        let had_failure = flags & (1 << 4) != 0;
720
721        let mut before_fingerprint = [0u8; 8];
722        before_fingerprint.copy_from_slice(&bytes[33..41]);
723        let mut after_fingerprint = [0u8; 8];
724        after_fingerprint.copy_from_slice(&bytes[41..49]);
725        let segment_changed_mask = u16::from_le_bytes([bytes[49], bytes[50]]);
726        let policy_flags = u32::from_le_bytes([bytes[51], bytes[52], bytes[53], bytes[54]]);
727        let journal_appends = u16::from_le_bytes([bytes[55], bytes[56]]);
728        let cpi_count = bytes[57];
729        let phase = bytes[58];
730        let validation_bundle_id = u16::from_le_bytes([bytes[59], bytes[60]]);
731        let compat_impact = bytes[61];
732        let migration_flags = bytes[62];
733
734        // Failure payload. In a legacy 64-byte receipt the upper bytes do
735        // not exist; we fall back to sensible defaults rather than fail
736        // the parse so callers that only have the shortened format still
737        // get the remaining fields.
738        let (failed_invariant_idx, failed_error_code, failure_stage) =
739            if bytes.len() >= RECEIPT_SIZE {
740                let idx = bytes[63];
741                let code = u32::from_le_bytes([bytes[64], bytes[65], bytes[66], bytes[67]]);
742                let stage = bytes[68];
743                (idx, code, stage)
744            } else {
745                (FAILED_INVARIANT_NONE, 0u32, FailureStage::None as u8)
746            };
747
748        Some(Self {
749            layout_id,
750            changed_fields,
751            changed_bytes,
752            changed_regions,
753            old_size,
754            new_size,
755            invariants_checked,
756            was_resized,
757            invariants_passed,
758            cpi_invoked,
759            committed,
760            before_fingerprint,
761            after_fingerprint,
762            segment_changed_mask,
763            policy_flags,
764            journal_appends,
765            cpi_count,
766            phase,
767            validation_bundle_id,
768            compat_impact,
769            migration_flags,
770            had_failure,
771            failed_error_code,
772            failed_invariant_idx,
773            failure_stage,
774        })
775    }
776
777    /// Deterministic index key: `layout_id || after_fingerprint`.
778    ///
779    /// Indexers can combine this with the transaction signature/log index for a
780    /// unique primary key while still grouping by layout and final state.
781    #[inline]
782    pub fn index_key(&self) -> [u8; 16] {
783        let mut key = [0u8; 16];
784        key[..8].copy_from_slice(&self.layout_id);
785        key[8..].copy_from_slice(&self.after_fingerprint);
786        key
787    }
788
789    /// Number of changed fields recorded in the bitmask.
790    #[inline(always)]
791    pub fn changed_field_count(&self) -> u16 {
792        self.changed_fields.count_ones() as u16
793    }
794
795    /// Number of changed segments recorded in the bitmask.
796    #[inline(always)]
797    pub fn changed_segment_count(&self) -> u8 {
798        self.segment_changed_mask.count_ones() as u8
799    }
800
801    /// Project this receipt into a stable indexer row.
802    #[inline]
803    pub fn index_record(&self) -> ReceiptIndexRecord {
804        ReceiptIndexRecord {
805            index_key: self.index_key(),
806            layout_id: self.layout_id,
807            phase: self.phase,
808            compat_impact: self.compat_impact,
809            migration_flags: self.migration_flags,
810            changed_fields: self.changed_fields,
811            changed_field_count: self.changed_field_count(),
812            segment_changed_mask: self.segment_changed_mask,
813            changed_segment_count: self.changed_segment_count(),
814            policy_flags: self.policy_flags,
815            validation_bundle_id: self.validation_bundle_id,
816            old_size: self.old_size,
817            new_size: self.new_size,
818            changed_bytes: self.changed_bytes,
819            had_failure: self.had_failure,
820            failed_error_code: self.failed_error_code,
821            failed_invariant_idx: self.failed_invariant_idx,
822            failure_stage: self.failure_stage,
823        }
824    }
825
826    /// Resolve the `failure_stage` byte to a [`FailureStage`] enum.
827    #[inline(always)]
828    pub fn failure_stage_enum(&self) -> FailureStage {
829        FailureStage::from_tag(self.failure_stage)
830    }
831
832    /// Whether data actually changed according to this receipt.
833    #[inline(always)]
834    pub fn has_changes(&self) -> bool {
835        self.changed_bytes > 0 || self.was_resized
836    }
837
838    /// Whether before/after fingerprints differ.
839    #[inline(always)]
840    pub fn fingerprint_changed(&self) -> bool {
841        self.before_fingerprint != self.after_fingerprint
842    }
843
844    /// Resolve the `phase` byte to a [`Phase`] enum.
845    #[inline(always)]
846    pub fn phase_enum(&self) -> Phase {
847        Phase::from_tag(self.phase)
848    }
849
850    /// Resolve the `compat_impact` byte to a [`CompatImpact`] enum.
851    #[inline(always)]
852    pub fn compat_impact_enum(&self) -> CompatImpact {
853        CompatImpact::from_tag(self.compat_impact)
854    }
855
856    /// Return a structured human-readable explanation of this receipt.
857    ///
858    /// This is the "operator UX" layer-every numeric field gets a semantic
859    /// label so tools, dashboards, and CLI output can show meaningful text
860    /// instead of raw bytes.
861    pub fn explain(&self) -> ReceiptExplain {
862        let phase = self.phase_enum();
863        let compat = self.compat_impact_enum();
864
865        let mutation_desc = if !self.has_changes() {
866            "No mutations detected"
867        } else if self.was_resized {
868            "Account was resized"
869        } else {
870            "Account data modified in-place"
871        };
872
873        let integrity_desc = if self.had_failure {
874            // Failure payload dominates every other integrity signal.
875            // Operators and the SDK need to know *this run aborted*
876            // before they consume any other field.
877            match self.failure_stage_enum() {
878                FailureStage::Invariant => "INVARIANT FAILED. execution aborted",
879                FailureStage::Validation => "Account validation failed. execution aborted",
880                FailureStage::Handler => "Handler aborted before invariant evaluation",
881                FailureStage::Post => "Failure during receipt commit path",
882                FailureStage::Teardown => "Failure during close/teardown",
883                FailureStage::None => "FAILURE flagged without stage (malformed receipt)",
884            }
885        } else if !self.committed {
886            "Receipt was NOT committed (incomplete)"
887        } else if self.invariants_passed && self.invariants_checked > 0 {
888            "All invariants passed"
889        } else if self.invariants_checked > 0 {
890            "INVARIANT VIOLATION detected"
891        } else {
892            "No invariants checked"
893        };
894
895        let cpi_desc = if self.cpi_invoked {
896            "CPI was invoked during execution"
897        } else {
898            "No CPI calls"
899        };
900
901        ReceiptExplain {
902            phase_name: phase.name(),
903            compat_label: compat.name(),
904            policy_name: "unknown",
905            mutation_summary: mutation_desc,
906            integrity_summary: integrity_desc,
907            cpi_summary: cpi_desc,
908            changed_field_count: self.changed_fields.count_ones() as u16,
909            segment_count: self.segment_changed_mask.count_ones() as u8,
910            fingerprint_changed: self.fingerprint_changed(),
911            segment_role_names: [""; 8],
912            segment_role_count: 0,
913        }
914    }
915}
916
917/// Human-readable explanation of a decoded receipt.
918///
919/// Produced by [`DecodedReceipt::explain()`]. Every field is a semantic
920/// label, not a raw number-designed for operator dashboards, CLI output,
921/// and audit logs.
922pub struct ReceiptExplain {
923    /// Phase name ("Update", "Init", "Close", "Migrate", "ReadOnly").
924    pub phase_name: &'static str,
925    /// Compatibility impact label ("None", "Append", "Migration", "Breaking").
926    pub compat_label: &'static str,
927    /// Policy pack name that governed this instruction ("unknown" when not embedded).
928    pub policy_name: &'static str,
929    /// One-sentence description of what mutation occurred.
930    pub mutation_summary: &'static str,
931    /// One-sentence description of invariant check result.
932    pub integrity_summary: &'static str,
933    /// One-sentence CPI summary.
934    pub cpi_summary: &'static str,
935    /// Number of individual fields changed (popcount of changed_fields mask).
936    pub changed_field_count: u16,
937    /// Number of segments that were modified.
938    pub segment_count: u8,
939    /// Whether the before/after fingerprints differ.
940    pub fingerprint_changed: bool,
941    /// Role names for modified segments (up to 8). Use `segment_role_count`
942    /// to know how many entries are valid.
943    pub segment_role_names: [&'static str; 8],
944    /// Number of valid entries in `segment_role_names`.
945    pub segment_role_count: u8,
946}
947
948impl ReceiptExplain {
949    /// Return a copy with the given policy name injected.
950    ///
951    /// The receipt wire format does not carry the policy pack name-only a
952    /// bitmask of flags. Call this after constructing an explain from the
953    /// decoded receipt when you know which policy pack governed the
954    /// instruction (e.g. from the program manifest).
955    #[inline]
956    pub const fn with_policy_name(mut self, name: &'static str) -> Self {
957        self.policy_name = name;
958        self
959    }
960
961    /// Inject a segment role name at the given index.
962    ///
963    /// Call once per modified segment, using the `SegmentRole::name()`
964    /// output for each bit set in `segment_changed_mask`. This enriches
965    /// the explain with human-readable role labels.
966    #[inline]
967    pub const fn with_segment_role(mut self, idx: u8, name: &'static str) -> Self {
968        if (idx as usize) < 8 {
969            self.segment_role_names[idx as usize] = name;
970            if idx >= self.segment_role_count {
971                self.segment_role_count = idx + 1;
972            }
973        }
974        self
975    }
976
977    /// One-line human-readable summary combining phase, mutation,
978    /// and integrity status.
979    #[inline]
980    pub const fn summary(&self) -> &'static str {
981        // Phase + mutation + integrity condensed into a single static label.
982        // Because we're no_std we return the most descriptive static string
983        // based on the phase and mutation state.
984        if !crate::const_str_eq(self.phase_name, "Update")
985            && !crate::const_str_eq(self.phase_name, "Init")
986            && !crate::const_str_eq(self.phase_name, "Close")
987            && !crate::const_str_eq(self.phase_name, "Migrate")
988        {
989            return "Read-only operation, no state changes";
990        }
991        if crate::const_str_eq(self.phase_name, "Init") {
992            return "Account initialized";
993        }
994        if crate::const_str_eq(self.phase_name, "Close") {
995            return "Account closed";
996        }
997        if crate::const_str_eq(self.phase_name, "Migrate") {
998            if self.fingerprint_changed {
999                return "Migration applied, layout fingerprint updated";
1000            }
1001            return "Migration applied";
1002        }
1003        // Update phase, provide more detail based on what changed
1004        if !self.fingerprint_changed && self.changed_field_count == 0 {
1005            return "Update executed with no observable state changes";
1006        }
1007        if self.fingerprint_changed && self.changed_field_count > 0 && self.segment_count > 1 {
1008            return "State mutated across multiple segments with fingerprint change";
1009        }
1010        if self.fingerprint_changed && self.changed_field_count > 0 {
1011            return "State mutated with fingerprint change";
1012        }
1013        if self.fingerprint_changed {
1014            return "Fingerprint changed without field-level mutations";
1015        }
1016        if self.changed_field_count > 0 && self.segment_count > 1 {
1017            return "State mutated across multiple segments";
1018        }
1019        if self.changed_field_count > 0 {
1020            return "State mutated";
1021        }
1022        "Update completed"
1023    }
1024}
1025
1026// ---------------------------------------------------------------------------
1027// Receipt Narrative -- auto-generated human explanations
1028// ---------------------------------------------------------------------------
1029
1030/// An auto-generated human-readable narrative describing a mutation.
1031///
1032/// Built from the receipt explain plus optional field intents, policy class,
1033/// and segment roles. This is the "operator artifact" layer that turns
1034/// binary receipt data into sentences an operator can actually read.
1035pub struct ReceiptNarrative {
1036    /// Sentence fragments describing what happened. Up to 8 fragments.
1037    pub fragments: [&'static str; 8],
1038    /// Number of valid fragments.
1039    pub count: u8,
1040    /// Overall risk level of this mutation.
1041    pub risk_level: NarrativeRisk,
1042}
1043
1044/// Risk level for a receipt narrative.
1045#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1046#[repr(u8)]
1047pub enum NarrativeRisk {
1048    /// No observable state changes.
1049    None = 0,
1050    /// Standard mutation, nothing unusual.
1051    Low = 1,
1052    /// Mutation touches authority or financial fields.
1053    Medium = 2,
1054    /// Migration, resize, or integrity violation detected.
1055    High = 3,
1056    /// Invariant failure or uncommitted receipt.
1057    Critical = 4,
1058}
1059
1060impl NarrativeRisk {
1061    /// Human-readable label.
1062    pub const fn name(self) -> &'static str {
1063        match self {
1064            Self::None => "none",
1065            Self::Low => "low",
1066            Self::Medium => "medium",
1067            Self::High => "high",
1068            Self::Critical => "critical",
1069        }
1070    }
1071}
1072
1073impl core::fmt::Display for NarrativeRisk {
1074    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1075        f.write_str(self.name())
1076    }
1077}
1078
1079impl ReceiptNarrative {
1080    /// Generate a narrative from a receipt explanation.
1081    ///
1082    /// Produces a sequence of human-readable fragments describing the
1083    /// mutation, along with a risk assessment.
1084    pub fn from_explain(explain: &ReceiptExplain) -> Self {
1085        let mut frags: [&'static str; 8] = [""; 8];
1086        let mut n = 0u8;
1087        let mut risk = NarrativeRisk::None;
1088
1089        // Phase description
1090        let phase_frag = match explain.phase_name {
1091            "Init" => "Account was initialized.",
1092            "Close" => "Account was closed.",
1093            "Migrate" => "Migration was applied to the account.",
1094            "ReadOnly" => "Read-only operation executed.",
1095            _ => "State mutation executed.",
1096        };
1097        if n < 8 {
1098            frags[n as usize] = phase_frag;
1099            n += 1;
1100        }
1101
1102        // Mutation details
1103        if explain.changed_field_count > 0 {
1104            risk = NarrativeRisk::Low;
1105            if explain.segment_count > 1 {
1106                if n < 8 {
1107                    frags[n as usize] = "Changes span multiple segments.";
1108                    n += 1;
1109                }
1110            }
1111        }
1112
1113        // Fingerprint change
1114        if explain.fingerprint_changed {
1115            if n < 8 {
1116                frags[n as usize] = "Layout fingerprint changed.";
1117                n += 1;
1118            }
1119            if risk as u8 == NarrativeRisk::Low as u8 {
1120                risk = NarrativeRisk::Medium;
1121            }
1122        }
1123
1124        // Compatibility impact
1125        match explain.compat_label {
1126            "Append" => {
1127                if n < 8 {
1128                    frags[n as usize] = "Append-safe extension applied.";
1129                    n += 1;
1130                }
1131            }
1132            "Migration" => {
1133                if n < 8 {
1134                    frags[n as usize] = "Migration-level change detected.";
1135                    n += 1;
1136                }
1137                risk = NarrativeRisk::High;
1138            }
1139            "Breaking" => {
1140                if n < 8 {
1141                    frags[n as usize] = "Breaking compatibility change.";
1142                    n += 1;
1143                }
1144                risk = NarrativeRisk::High;
1145            }
1146            _ => {}
1147        }
1148
1149        // CPI
1150        if !crate::const_str_eq(explain.cpi_summary, "No CPI calls") {
1151            if n < 8 {
1152                frags[n as usize] = "Cross-program invocation occurred.";
1153                n += 1;
1154            }
1155        }
1156
1157        // Integrity
1158        if crate::const_str_eq(explain.integrity_summary, "INVARIANT VIOLATION detected") {
1159            if n < 8 {
1160                frags[n as usize] = "INVARIANT VIOLATION: post-mutation checks failed.";
1161                n += 1;
1162            }
1163            risk = NarrativeRisk::Critical;
1164        }
1165        if crate::const_str_eq(
1166            explain.integrity_summary,
1167            "Receipt was NOT committed (incomplete)",
1168        ) {
1169            if n < 8 {
1170                frags[n as usize] = "Receipt was not committed. Mutation may be incomplete.";
1171                n += 1;
1172            }
1173            risk = NarrativeRisk::Critical;
1174        }
1175
1176        // Segment roles
1177        if explain.segment_role_count > 0 {
1178            let mut i = 0u8;
1179            while i < explain.segment_role_count && i < 8 {
1180                let role = explain.segment_role_names[i as usize];
1181                if crate::const_str_eq(role, "audit") || crate::const_str_eq(role, "Audit") {
1182                    if n < 8 {
1183                        frags[n as usize] = "Audit segment was touched.";
1184                        n += 1;
1185                    }
1186                }
1187                i += 1;
1188            }
1189        }
1190
1191        // Phase-level risk escalation
1192        if crate::const_str_eq(explain.phase_name, "Migrate") {
1193            if (risk as u8) < NarrativeRisk::High as u8 {
1194                risk = NarrativeRisk::High;
1195            }
1196        }
1197
1198        Self {
1199            fragments: frags,
1200            count: n,
1201            risk_level: risk,
1202        }
1203    }
1204}
1205
1206impl core::fmt::Display for ReceiptNarrative {
1207    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1208        let mut i = 0u8;
1209        while i < self.count {
1210            if i > 0 {
1211                write!(f, " ")?;
1212            }
1213            write!(f, "{}", self.fragments[i as usize])?;
1214            i += 1;
1215        }
1216        write!(f, " [risk: {}]", self.risk_level.name())
1217    }
1218}