Skip to main content

canic_core/storage/stable/
intent.rs

1//! Stable-memory intent store primitives.
2//!
3//! Data-only storage slots for cross-canister intent tracking. The ops layer
4//! enforces mechanical invariants (uniqueness, monotonic state transitions,
5//! aggregate consistency). Policy and capacity decisions live above this layer.
6
7use crate::cdk::structures::btreemap::BTreeMap as StableBtreeMap;
8use crate::{
9    cdk::structures::{
10        DefaultMemoryImpl, Storable, cell::Cell, memory::VirtualMemory, storable::Bound,
11    },
12    ids::{IntentId, IntentResourceKey},
13    model::{
14        intent::{PayloadBinding, ReceiptBackedIntent, ReceiptBackedIntentState},
15        replay::OperationId,
16    },
17    role_contract::allocation::memory::intent::{
18        INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID, INTENT_TOTALS_ID,
19        RECEIPT_BACKED_INTENT_RECORDS_ID,
20    },
21    storage::prelude::*,
22};
23use std::{borrow::Cow, cell::RefCell};
24
25//
26// INTENT STORE
27//
28
29pub const INTENT_STORE_SCHEMA_VERSION: u32 = 1;
30
31eager_static! {
32    static INTENT_META: RefCell<Cell<IntentStoreMetaRecord, VirtualMemory<DefaultMemoryImpl>>> =
33        RefCell::new(Cell::init(
34            crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_meta.v1", ty = IntentStoreMetaRecord, id = INTENT_META_ID),
35            IntentStoreMetaRecord::default(),
36        ));
37}
38
39eager_static! {
40    static RECEIPT_BACKED_INTENT_RECORDS: RefCell<
41        StableBtreeMap<
42            OperationId,
43            ReceiptBackedIntentRecord,
44            VirtualMemory<DefaultMemoryImpl>,
45        >
46    > = RefCell::new(StableBtreeMap::init(crate::ic_memory_key!(
47        authority = CANIC_CORE_MEMORY_AUTHORITY,
48        key = "canic.core.receipt_backed_intent_records.v1",
49        ty = ReceiptBackedIntentRecord,
50        id = RECEIPT_BACKED_INTENT_RECORDS_ID
51    )));
52}
53
54eager_static! {
55    static INTENT_RECORDS: RefCell<
56        StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
57    > = RefCell::new(
58        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_records.v1", ty = IntentRecord, id = INTENT_RECORDS_ID)),
59    );
60}
61
62eager_static! {
63    static INTENT_TOTALS: RefCell<
64        StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
65    > = RefCell::new(
66        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_totals.v1", ty = IntentResourceTotalsRecord, id = INTENT_TOTALS_ID)),
67    );
68}
69
70eager_static! {
71    static INTENT_PENDING: RefCell<
72        StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
73    > = RefCell::new(
74        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_pending.v1", ty = IntentPendingEntryRecord, id = INTENT_PENDING_ID)),
75    );
76}
77
78impl Storable for IntentId {
79    const BOUND: Bound = Bound::Bounded {
80        max_size: 8,
81        is_fixed_size: true,
82    };
83
84    fn to_bytes(&self) -> Cow<'_, [u8]> {
85        Cow::Owned(self.0.to_be_bytes().to_vec())
86    }
87
88    fn into_bytes(self) -> Vec<u8> {
89        self.0.to_be_bytes().to_vec()
90    }
91
92    /// Decode the exact fixed-width stable intent identity.
93    ///
94    /// # Panics
95    ///
96    /// Panics when stable memory contains an intent ID that is not exactly eight bytes.
97    fn from_bytes(bytes: Cow<[u8]>) -> Self {
98        let bytes = <[u8; 8]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
99            panic!(
100                "stable IntentId is {} bytes; expected 8",
101                bytes.as_ref().len()
102            )
103        });
104
105        Self(u64::from_be_bytes(bytes))
106    }
107}
108
109impl Storable for OperationId {
110    const BOUND: Bound = Bound::Bounded {
111        max_size: 32,
112        is_fixed_size: true,
113    };
114
115    fn to_bytes(&self) -> Cow<'_, [u8]> {
116        Cow::Borrowed(self.as_bytes())
117    }
118
119    fn into_bytes(self) -> Vec<u8> {
120        self.as_bytes().to_vec()
121    }
122
123    /// Decode the exact fixed-width stable operation identity.
124    ///
125    /// # Panics
126    ///
127    /// Panics when stable memory contains an operation ID that is not exactly 32 bytes.
128    fn from_bytes(bytes: Cow<[u8]>) -> Self {
129        let operation_id = <[u8; 32]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
130            panic!(
131                "stable OperationId is {} bytes; expected 32",
132                bytes.as_ref().len()
133            )
134        });
135        Self::from_bytes(operation_id)
136    }
137}
138
139///
140/// IntentState
141///
142
143#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
144pub enum IntentState {
145    Pending,
146    Committed,
147    Aborted,
148}
149
150///
151/// IntentRecord
152///
153
154#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct IntentRecord {
156    pub id: IntentId,
157    pub resource_key: IntentResourceKey,
158    pub quantity: u64,
159    pub state: IntentState,
160    pub created_at: u64,
161    // TTL is enforced logically at read time; cleanup is asynchronous.
162    pub ttl_secs: Option<u64>,
163}
164
165impl IntentRecord {
166    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
167    pub const STORABLE_MAX_SIZE: u32 = 256;
168}
169
170impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
171
172///
173/// IntentStoreMetaRecord
174///
175
176#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
177pub struct IntentStoreMetaRecord {
178    pub schema_version: u32,
179    pub next_intent_id: IntentId,
180    pub pending_total: u64,
181    pub committed_total: u64,
182    pub aborted_total: u64,
183}
184
185impl IntentStoreMetaRecord {
186    pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
187    pub const STORABLE_MAX_SIZE: u32 = 96;
188}
189
190impl Default for IntentStoreMetaRecord {
191    fn default() -> Self {
192        Self {
193            schema_version: INTENT_STORE_SCHEMA_VERSION,
194            next_intent_id: IntentId(1),
195            pending_total: 0,
196            committed_total: 0,
197            aborted_total: 0,
198        }
199    }
200}
201
202impl_storable_bounded!(
203    IntentStoreMetaRecord,
204    IntentStoreMetaRecord::STORABLE_MAX_SIZE,
205    false
206);
207
208///
209/// IntentResourceTotalsRecord
210///
211
212#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
213pub struct IntentResourceTotalsRecord {
214    pub reserved_qty: u64,
215    pub committed_qty: u64,
216    pub pending_count: u64,
217}
218
219impl IntentResourceTotalsRecord {
220    pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
221    pub const STORABLE_MAX_SIZE: u32 = 64;
222}
223
224impl_storable_bounded!(
225    IntentResourceTotalsRecord,
226    IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
227    false
228);
229
230///
231/// IntentPendingEntryRecord
232///
233
234#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
235pub struct IntentPendingEntryRecord {
236    pub resource_key: IntentResourceKey,
237    pub quantity: u64,
238    pub created_at: u64,
239    // TTL is enforced logically at read time; cleanup is asynchronous.
240    pub ttl_secs: Option<u64>,
241}
242
243impl IntentPendingEntryRecord {
244    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
245    pub const STORABLE_MAX_SIZE: u32 = 224;
246}
247
248impl_storable_bounded!(
249    IntentPendingEntryRecord,
250    IntentPendingEntryRecord::STORABLE_MAX_SIZE,
251    false
252);
253
254/// Stable representation of one durable receipt-backed intent.
255#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
256pub struct ReceiptBackedIntentRecord {
257    pub schema_version: u32,
258    pub operation_id: OperationId,
259    pub payload_binding: PayloadBinding,
260    pub resource_key: IntentResourceKey,
261    pub quantity: u64,
262    pub state: ReceiptBackedIntentState,
263    pub revision: u64,
264    pub created_at_ns: u64,
265    pub updated_at_ns: u64,
266}
267
268impl ReceiptBackedIntentRecord {
269    pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentRecord";
270    pub const STORABLE_MAX_SIZE: u32 = 1024;
271
272    #[must_use]
273    pub fn into_intent(self) -> ReceiptBackedIntent {
274        ReceiptBackedIntent {
275            schema_version: self.schema_version,
276            operation_id: self.operation_id,
277            payload_binding: self.payload_binding,
278            resource_key: self.resource_key,
279            quantity: self.quantity,
280            state: self.state,
281            revision: self.revision,
282            created_at_ns: self.created_at_ns,
283            updated_at_ns: self.updated_at_ns,
284        }
285    }
286}
287
288impl_storable_bounded!(
289    ReceiptBackedIntentRecord,
290    ReceiptBackedIntentRecord::STORABLE_MAX_SIZE,
291    false
292);
293
294///
295/// IntentMetaData
296///
297/// Canonical intent-store metadata allocation snapshot.
298///
299
300#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
301pub struct IntentMetaData {
302    pub record: IntentStoreMetaRecord,
303}
304
305impl IntentMetaData {
306    pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
307}
308
309///
310/// IntentRecordEntryRecord
311///
312/// One logical intent-record snapshot row preserving its stable intent ID key.
313///
314
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct IntentRecordEntryRecord {
317    pub intent_id: IntentId,
318    pub record: IntentRecord,
319}
320
321///
322/// IntentRecordsData
323///
324/// Canonical intent-records allocation snapshot.
325///
326
327#[derive(Clone, Debug, Default, Eq, PartialEq)]
328pub struct IntentRecordsData {
329    pub entries: Vec<IntentRecordEntryRecord>,
330}
331
332impl IntentRecordsData {
333    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
334}
335
336///
337/// IntentTotalsEntryRecord
338///
339/// One logical intent-total snapshot row preserving its stable resource key.
340///
341
342#[derive(Clone, Debug, Eq, PartialEq)]
343pub struct IntentTotalsEntryRecord {
344    pub resource_key: IntentResourceKey,
345    pub record: IntentResourceTotalsRecord,
346}
347
348///
349/// IntentTotalsData
350///
351/// Canonical intent-resource-totals allocation snapshot.
352///
353
354#[derive(Clone, Debug, Default, Eq, PartialEq)]
355pub struct IntentTotalsData {
356    pub entries: Vec<IntentTotalsEntryRecord>,
357}
358
359impl IntentTotalsData {
360    pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
361}
362
363///
364/// IntentPendingIndexEntryRecord
365///
366/// One logical pending-intent snapshot row preserving its stable intent ID key.
367///
368
369#[derive(Clone, Debug, Eq, PartialEq)]
370pub struct IntentPendingIndexEntryRecord {
371    pub intent_id: IntentId,
372    pub record: IntentPendingEntryRecord,
373}
374
375///
376/// IntentPendingData
377///
378/// Canonical pending-intent allocation snapshot.
379///
380
381#[derive(Clone, Debug, Default, Eq, PartialEq)]
382pub struct IntentPendingData {
383    pub entries: Vec<IntentPendingIndexEntryRecord>,
384}
385
386impl IntentPendingData {
387    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
388}
389
390/// One logical receipt-backed intent snapshot row.
391#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct ReceiptBackedIntentEntryRecord {
393    pub operation_id: OperationId,
394    pub record: ReceiptBackedIntentRecord,
395}
396
397/// Canonical receipt-backed intent record allocation snapshot.
398#[derive(Clone, Debug, Default, Eq, PartialEq)]
399pub struct ReceiptBackedIntentsData {
400    pub entries: Vec<ReceiptBackedIntentEntryRecord>,
401}
402
403impl ReceiptBackedIntentsData {
404    pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentsData";
405}
406
407///
408/// IntentStore
409///
410
411pub struct IntentStore;
412
413impl IntentStore {
414    // -------------------------------------------------------------
415    // Meta
416    // -------------------------------------------------------------
417
418    #[must_use]
419    pub(crate) fn meta() -> IntentStoreMetaRecord {
420        INTENT_META.with_borrow(|cell| *cell.get())
421    }
422
423    pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
424        INTENT_META.with_borrow_mut(|cell| cell.set(meta));
425    }
426
427    // -------------------------------------------------------------
428    // Records
429    // -------------------------------------------------------------
430
431    #[must_use]
432    pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
433        INTENT_RECORDS.with_borrow(|map| map.get(&id))
434    }
435
436    pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
437        INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
438    }
439
440    // -------------------------------------------------------------
441    // Totals
442    // -------------------------------------------------------------
443
444    #[must_use]
445    pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
446        INTENT_TOTALS.with_borrow(|map| map.get(key))
447    }
448
449    pub(crate) fn set_totals(
450        key: IntentResourceKey,
451        totals: IntentResourceTotalsRecord,
452    ) -> Option<IntentResourceTotalsRecord> {
453        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
454    }
455
456    // -------------------------------------------------------------
457    // Pending index
458    // -------------------------------------------------------------
459
460    #[must_use]
461    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
462        INTENT_PENDING.with_borrow(|map| map.get(&id))
463    }
464
465    pub(crate) fn insert_pending(
466        id: IntentId,
467        entry: IntentPendingEntryRecord,
468    ) -> Option<IntentPendingEntryRecord> {
469        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
470    }
471
472    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
473        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
474    }
475
476    pub(crate) fn with_pending_entries<R>(
477        f: impl FnOnce(
478            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
479        ) -> R,
480    ) -> R {
481        INTENT_PENDING.with_borrow(|map| f(map))
482    }
483}
484
485/// Stable store for receipt-backed operations addressed by exact operation ID.
486pub struct ReceiptBackedIntentStore;
487
488impl ReceiptBackedIntentStore {
489    #[must_use]
490    pub(crate) fn len() -> u64 {
491        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
492    }
493
494    #[must_use]
495    pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
496        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
497    }
498
499    pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
500        RECEIPT_BACKED_INTENT_RECORDS
501            .with_borrow_mut(|records| records.insert(record.operation_id, record))
502    }
503
504    pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
505        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
506    }
507
508    pub(crate) fn with_records<R>(
509        f: impl FnOnce(
510            &StableBtreeMap<
511                OperationId,
512                ReceiptBackedIntentRecord,
513                VirtualMemory<DefaultMemoryImpl>,
514            >,
515        ) -> R,
516    ) -> R {
517        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
518    }
519}
520
521//
522// ─────────────────────────────────────────────────────────────
523// Test helpers
524// ─────────────────────────────────────────────────────────────
525//
526
527#[cfg(test)]
528impl IntentStore {
529    #[must_use]
530    pub(crate) fn export_meta() -> IntentMetaData {
531        IntentMetaData {
532            record: Self::meta(),
533        }
534    }
535
536    pub(crate) fn import_meta(data: IntentMetaData) {
537        Self::set_meta(data.record);
538    }
539
540    #[must_use]
541    pub(crate) fn export_records() -> IntentRecordsData {
542        IntentRecordsData {
543            entries: INTENT_RECORDS.with_borrow(|map| {
544                map.iter()
545                    .map(|entry| IntentRecordEntryRecord {
546                        intent_id: *entry.key(),
547                        record: entry.value(),
548                    })
549                    .collect()
550            }),
551        }
552    }
553
554    pub(crate) fn import_records(data: IntentRecordsData) {
555        INTENT_RECORDS.with_borrow_mut(|map| {
556            map.clear_new();
557            for entry in data.entries {
558                map.insert(entry.intent_id, entry.record);
559            }
560        });
561    }
562
563    #[must_use]
564    pub(crate) fn export_totals() -> IntentTotalsData {
565        IntentTotalsData {
566            entries: INTENT_TOTALS.with_borrow(|map| {
567                map.iter()
568                    .map(|entry| IntentTotalsEntryRecord {
569                        resource_key: entry.key().clone(),
570                        record: entry.value(),
571                    })
572                    .collect()
573            }),
574        }
575    }
576
577    pub(crate) fn import_totals(data: IntentTotalsData) {
578        INTENT_TOTALS.with_borrow_mut(|map| {
579            map.clear_new();
580            for entry in data.entries {
581                map.insert(entry.resource_key, entry.record);
582            }
583        });
584    }
585
586    #[must_use]
587    pub(crate) fn export_pending() -> IntentPendingData {
588        IntentPendingData {
589            entries: INTENT_PENDING.with_borrow(|map| {
590                map.iter()
591                    .map(|entry| IntentPendingIndexEntryRecord {
592                        intent_id: *entry.key(),
593                        record: entry.value(),
594                    })
595                    .collect()
596            }),
597        }
598    }
599
600    pub(crate) fn import_pending(data: IntentPendingData) {
601        INTENT_PENDING.with_borrow_mut(|map| {
602            map.clear_new();
603            for entry in data.entries {
604                map.insert(entry.intent_id, entry.record);
605            }
606        });
607    }
608
609    pub(crate) fn reset_for_tests() {
610        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
611        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
612        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
613        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
614        ReceiptBackedIntentStore::reset_for_tests();
615    }
616}
617
618#[cfg(test)]
619impl ReceiptBackedIntentStore {
620    #[must_use]
621    pub(crate) fn export_records() -> ReceiptBackedIntentsData {
622        ReceiptBackedIntentsData {
623            entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
624                records
625                    .iter()
626                    .map(|entry| ReceiptBackedIntentEntryRecord {
627                        operation_id: *entry.key(),
628                        record: entry.value(),
629                    })
630                    .collect()
631            }),
632        }
633    }
634
635    pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
636        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
637            records.clear_new();
638            for entry in data.entries {
639                records.insert(entry.operation_id, entry.record);
640            }
641        });
642    }
643
644    pub(crate) fn reset_for_tests() {
645        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::{
653        cdk::types::Principal,
654        model::intent::{
655            RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
656        },
657    };
658
659    #[test]
660    #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
661    fn malformed_stable_intent_id_fails_closed() {
662        let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
663    }
664
665    #[test]
666    #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
667    fn malformed_stable_operation_id_fails_closed() {
668        let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
669    }
670
671    #[test]
672    fn intent_allocations_round_trip_through_canonical_data_snapshots() {
673        IntentStore::reset_for_tests();
674        let intent_id = IntentId(7);
675        let resource_key = IntentResourceKey::new("storage:uploads");
676        let record = IntentRecord {
677            id: intent_id,
678            resource_key: resource_key.clone(),
679            quantity: 11,
680            state: IntentState::Pending,
681            created_at: 13,
682            ttl_secs: Some(17),
683        };
684        let totals = IntentResourceTotalsRecord {
685            reserved_qty: 11,
686            committed_qty: 19,
687            pending_count: 1,
688        };
689        let pending = IntentPendingEntryRecord {
690            resource_key: resource_key.clone(),
691            quantity: 11,
692            created_at: 13,
693            ttl_secs: Some(17),
694        };
695        let meta = IntentStoreMetaRecord {
696            schema_version: INTENT_STORE_SCHEMA_VERSION,
697            next_intent_id: IntentId(8),
698            pending_total: 1,
699            committed_total: 2,
700            aborted_total: 3,
701        };
702
703        IntentStore::set_meta(meta);
704        IntentStore::insert_record(record);
705        IntentStore::set_totals(resource_key, totals);
706        IntentStore::insert_pending(intent_id, pending);
707
708        let meta_data = IntentStore::export_meta();
709        let records_data = IntentStore::export_records();
710        let totals_data = IntentStore::export_totals();
711        let pending_data = IntentStore::export_pending();
712
713        IntentStore::reset_for_tests();
714        IntentStore::import_meta(meta_data);
715        IntentStore::import_records(records_data.clone());
716        IntentStore::import_totals(totals_data.clone());
717        IntentStore::import_pending(pending_data.clone());
718
719        assert_eq!(IntentStore::export_meta(), meta_data);
720        assert_eq!(IntentStore::export_records(), records_data);
721        assert_eq!(IntentStore::export_totals(), totals_data);
722        assert_eq!(IntentStore::export_pending(), pending_data);
723        IntentStore::reset_for_tests();
724    }
725
726    #[test]
727    fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
728        IntentStore::reset_for_tests();
729        let operation_id = OperationId::from_bytes([7; 32]);
730        let evidence = TerminalEvidence::new(
731            Principal::from_slice(&[1; 29]),
732            TerminalEvidenceDecision::Committed,
733            [8; 32],
734        );
735        let record = ReceiptBackedIntentRecord {
736            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
737            operation_id,
738            payload_binding: PayloadBinding::new([9; 32]),
739            resource_key: IntentResourceKey::new("mint:collection"),
740            quantity: 11,
741            state: ReceiptBackedIntentState::Committed { evidence },
742            revision: 2,
743            created_at_ns: 13,
744            updated_at_ns: 17,
745        };
746        ReceiptBackedIntentStore::insert(record);
747        let records_data = ReceiptBackedIntentStore::export_records();
748
749        ReceiptBackedIntentStore::reset_for_tests();
750        assert_eq!(
751            ReceiptBackedIntentStore::export_records(),
752            ReceiptBackedIntentsData::default()
753        );
754
755        ReceiptBackedIntentStore::import_records(records_data.clone());
756        assert_eq!(ReceiptBackedIntentStore::len(), 1);
757        assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
758        IntentStore::reset_for_tests();
759    }
760}