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_EXPIRY_INDEX_ID, INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID,
19        INTENT_TOTALS_ID, 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_EXPIRY_INDEX: RefCell<
56        StableBtreeMap<IntentExpiryKeyRecord, IntentExpiryEntryRecord, VirtualMemory<DefaultMemoryImpl>>
57    > = RefCell::new(
58        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_expiry_index.v1", ty = IntentExpiryEntryRecord, id = INTENT_EXPIRY_INDEX_ID)),
59    );
60}
61
62eager_static! {
63    static INTENT_RECORDS: RefCell<
64        StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
65    > = RefCell::new(
66        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_records.v1", ty = IntentRecord, id = INTENT_RECORDS_ID)),
67    );
68}
69
70eager_static! {
71    static INTENT_TOTALS: RefCell<
72        StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
73    > = RefCell::new(
74        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_totals.v1", ty = IntentResourceTotalsRecord, id = INTENT_TOTALS_ID)),
75    );
76}
77
78eager_static! {
79    static INTENT_PENDING: RefCell<
80        StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
81    > = RefCell::new(
82        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_pending.v1", ty = IntentPendingEntryRecord, id = INTENT_PENDING_ID)),
83    );
84}
85
86impl Storable for IntentId {
87    const BOUND: Bound = Bound::Bounded {
88        max_size: 8,
89        is_fixed_size: true,
90    };
91
92    fn to_bytes(&self) -> Cow<'_, [u8]> {
93        Cow::Owned(self.0.to_be_bytes().to_vec())
94    }
95
96    fn into_bytes(self) -> Vec<u8> {
97        self.0.to_be_bytes().to_vec()
98    }
99
100    /// Decode the exact fixed-width stable intent identity.
101    ///
102    /// # Panics
103    ///
104    /// Panics when stable memory contains an intent ID that is not exactly eight bytes.
105    fn from_bytes(bytes: Cow<[u8]>) -> Self {
106        let bytes = <[u8; 8]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
107            panic!(
108                "stable IntentId is {} bytes; expected 8",
109                bytes.as_ref().len()
110            )
111        });
112
113        Self(u64::from_be_bytes(bytes))
114    }
115}
116
117impl Storable for OperationId {
118    const BOUND: Bound = Bound::Bounded {
119        max_size: 32,
120        is_fixed_size: true,
121    };
122
123    fn to_bytes(&self) -> Cow<'_, [u8]> {
124        Cow::Borrowed(self.as_bytes())
125    }
126
127    fn into_bytes(self) -> Vec<u8> {
128        self.as_bytes().to_vec()
129    }
130
131    /// Decode the exact fixed-width stable operation identity.
132    ///
133    /// # Panics
134    ///
135    /// Panics when stable memory contains an operation ID that is not exactly 32 bytes.
136    fn from_bytes(bytes: Cow<[u8]>) -> Self {
137        let operation_id = <[u8; 32]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
138            panic!(
139                "stable OperationId is {} bytes; expected 32",
140                bytes.as_ref().len()
141            )
142        });
143        Self::from_bytes(operation_id)
144    }
145}
146
147/// Ordered stable key for one finite local-intent cleanup deadline.
148#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
149pub struct IntentExpiryKeyRecord {
150    pub due_at_secs: u64,
151    pub intent_id: IntentId,
152}
153
154impl Storable for IntentExpiryKeyRecord {
155    const BOUND: Bound = Bound::Bounded {
156        max_size: 16,
157        is_fixed_size: true,
158    };
159
160    fn to_bytes(&self) -> Cow<'_, [u8]> {
161        Cow::Owned(self.into_bytes())
162    }
163
164    fn into_bytes(self) -> Vec<u8> {
165        let mut bytes = Vec::with_capacity(16);
166        bytes.extend_from_slice(&self.due_at_secs.to_be_bytes());
167        bytes.extend_from_slice(&self.intent_id.0.to_be_bytes());
168        bytes
169    }
170
171    /// Decode the exact fixed-width stable intent-expiry key.
172    ///
173    /// # Panics
174    ///
175    /// Panics when stable memory contains a key that is not exactly sixteen bytes.
176    fn from_bytes(bytes: Cow<[u8]>) -> Self {
177        let bytes = <[u8; 16]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
178            panic!(
179                "stable IntentExpiryKeyRecord is {} bytes; expected 16",
180                bytes.as_ref().len()
181            )
182        });
183        let (due_at_secs, intent_id) = bytes.split_at(8);
184        Self {
185            due_at_secs: u64::from_be_bytes(
186                due_at_secs.try_into().expect("expiry key deadline width"),
187            ),
188            intent_id: IntentId(u64::from_be_bytes(
189                intent_id.try_into().expect("expiry key intent width"),
190            )),
191        }
192    }
193}
194
195/// Stable value for one finite local-intent cleanup deadline.
196#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub struct IntentExpiryEntryRecord {
198    pub intent_id: IntentId,
199}
200
201impl IntentExpiryEntryRecord {
202    pub const STATE_CONTRACT_NAME: &'static str = "IntentExpiryEntryRecord";
203    pub const STORABLE_MAX_SIZE: u32 = 32;
204}
205
206impl_storable_bounded!(
207    IntentExpiryEntryRecord,
208    IntentExpiryEntryRecord::STORABLE_MAX_SIZE,
209    false
210);
211
212///
213/// IntentState
214///
215
216#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub enum IntentState {
218    Pending,
219    Committed,
220    Aborted,
221}
222
223///
224/// IntentRecord
225///
226
227#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228pub struct IntentRecord {
229    pub id: IntentId,
230    pub resource_key: IntentResourceKey,
231    pub quantity: u64,
232    pub state: IntentState,
233    pub created_at: u64,
234    // TTL is enforced logically at read time; the derived index schedules cleanup.
235    pub ttl_secs: Option<u64>,
236}
237
238impl IntentRecord {
239    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
240    pub const STORABLE_MAX_SIZE: u32 = 256;
241}
242
243impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
244
245///
246/// IntentStoreMetaRecord
247///
248
249#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
250pub struct IntentStoreMetaRecord {
251    pub schema_version: u32,
252    pub next_intent_id: IntentId,
253    pub pending_total: u64,
254    pub committed_total: u64,
255    pub aborted_total: u64,
256}
257
258impl IntentStoreMetaRecord {
259    pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
260    pub const STORABLE_MAX_SIZE: u32 = 96;
261}
262
263impl Default for IntentStoreMetaRecord {
264    fn default() -> Self {
265        Self {
266            schema_version: INTENT_STORE_SCHEMA_VERSION,
267            next_intent_id: IntentId(1),
268            pending_total: 0,
269            committed_total: 0,
270            aborted_total: 0,
271        }
272    }
273}
274
275impl_storable_bounded!(
276    IntentStoreMetaRecord,
277    IntentStoreMetaRecord::STORABLE_MAX_SIZE,
278    false
279);
280
281///
282/// IntentResourceTotalsRecord
283///
284
285#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
286pub struct IntentResourceTotalsRecord {
287    pub reserved_qty: u64,
288    pub committed_qty: u64,
289    pub pending_count: u64,
290}
291
292impl IntentResourceTotalsRecord {
293    pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
294    pub const STORABLE_MAX_SIZE: u32 = 64;
295}
296
297impl_storable_bounded!(
298    IntentResourceTotalsRecord,
299    IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
300    false
301);
302
303///
304/// IntentPendingEntryRecord
305///
306
307#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct IntentPendingEntryRecord {
309    pub resource_key: IntentResourceKey,
310    pub quantity: u64,
311    pub created_at: u64,
312    // TTL is enforced logically at read time; cleanup is asynchronous.
313    pub ttl_secs: Option<u64>,
314}
315
316impl IntentPendingEntryRecord {
317    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
318    pub const STORABLE_MAX_SIZE: u32 = 224;
319}
320
321impl_storable_bounded!(
322    IntentPendingEntryRecord,
323    IntentPendingEntryRecord::STORABLE_MAX_SIZE,
324    false
325);
326
327/// Stable representation of one durable receipt-backed intent.
328#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
329pub struct ReceiptBackedIntentRecord {
330    pub schema_version: u32,
331    pub operation_id: OperationId,
332    pub payload_binding: PayloadBinding,
333    pub resource_key: IntentResourceKey,
334    pub quantity: u64,
335    pub state: ReceiptBackedIntentState,
336    pub revision: u64,
337    pub created_at_ns: u64,
338    pub updated_at_ns: u64,
339}
340
341impl ReceiptBackedIntentRecord {
342    pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentRecord";
343    pub const STORABLE_MAX_SIZE: u32 = 1024;
344
345    #[must_use]
346    pub fn into_intent(self) -> ReceiptBackedIntent {
347        ReceiptBackedIntent {
348            schema_version: self.schema_version,
349            operation_id: self.operation_id,
350            payload_binding: self.payload_binding,
351            resource_key: self.resource_key,
352            quantity: self.quantity,
353            state: self.state,
354            revision: self.revision,
355            created_at_ns: self.created_at_ns,
356            updated_at_ns: self.updated_at_ns,
357        }
358    }
359}
360
361impl_storable_bounded!(
362    ReceiptBackedIntentRecord,
363    ReceiptBackedIntentRecord::STORABLE_MAX_SIZE,
364    false
365);
366
367///
368/// IntentMetaData
369///
370/// Canonical intent-store metadata allocation snapshot.
371///
372
373#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
374pub struct IntentMetaData {
375    pub record: IntentStoreMetaRecord,
376}
377
378impl IntentMetaData {
379    pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
380}
381
382///
383/// IntentRecordEntryRecord
384///
385/// One logical intent-record snapshot row preserving its stable intent ID key.
386///
387
388#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct IntentRecordEntryRecord {
390    pub intent_id: IntentId,
391    pub record: IntentRecord,
392}
393
394///
395/// IntentRecordsData
396///
397/// Canonical intent-records allocation snapshot.
398///
399
400#[derive(Clone, Debug, Default, Eq, PartialEq)]
401pub struct IntentRecordsData {
402    pub entries: Vec<IntentRecordEntryRecord>,
403}
404
405impl IntentRecordsData {
406    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
407}
408
409///
410/// IntentTotalsEntryRecord
411///
412/// One logical intent-total snapshot row preserving its stable resource key.
413///
414
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct IntentTotalsEntryRecord {
417    pub resource_key: IntentResourceKey,
418    pub record: IntentResourceTotalsRecord,
419}
420
421///
422/// IntentTotalsData
423///
424/// Canonical intent-resource-totals allocation snapshot.
425///
426
427#[derive(Clone, Debug, Default, Eq, PartialEq)]
428pub struct IntentTotalsData {
429    pub entries: Vec<IntentTotalsEntryRecord>,
430}
431
432impl IntentTotalsData {
433    pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
434}
435
436///
437/// IntentPendingIndexEntryRecord
438///
439/// One logical pending-intent snapshot row preserving its stable intent ID key.
440///
441
442#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct IntentPendingIndexEntryRecord {
444    pub intent_id: IntentId,
445    pub record: IntentPendingEntryRecord,
446}
447
448///
449/// IntentPendingData
450///
451/// Canonical pending-intent allocation snapshot.
452///
453
454#[derive(Clone, Debug, Default, Eq, PartialEq)]
455pub struct IntentPendingData {
456    pub entries: Vec<IntentPendingIndexEntryRecord>,
457}
458
459impl IntentPendingData {
460    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
461}
462
463/// One logical finite-expiry snapshot row preserving its stable ordered key.
464#[derive(Clone, Copy, Debug, Eq, PartialEq)]
465pub struct IntentExpiryIndexEntryRecord {
466    pub key: IntentExpiryKeyRecord,
467    pub record: IntentExpiryEntryRecord,
468}
469
470/// Canonical finite-expiry index allocation snapshot.
471#[derive(Clone, Debug, Default, Eq, PartialEq)]
472pub struct IntentExpiryIndexData {
473    pub entries: Vec<IntentExpiryIndexEntryRecord>,
474}
475
476impl IntentExpiryIndexData {
477    pub const STATE_CONTRACT_NAME: &'static str = "IntentExpiryIndexData";
478}
479
480/// One logical receipt-backed intent snapshot row.
481#[derive(Clone, Debug, Eq, PartialEq)]
482pub struct ReceiptBackedIntentEntryRecord {
483    pub operation_id: OperationId,
484    pub record: ReceiptBackedIntentRecord,
485}
486
487/// Canonical receipt-backed intent record allocation snapshot.
488#[derive(Clone, Debug, Default, Eq, PartialEq)]
489pub struct ReceiptBackedIntentsData {
490    pub entries: Vec<ReceiptBackedIntentEntryRecord>,
491}
492
493impl ReceiptBackedIntentsData {
494    pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentsData";
495}
496
497///
498/// IntentStore
499///
500
501pub struct IntentStore;
502
503impl IntentStore {
504    // -------------------------------------------------------------
505    // Meta
506    // -------------------------------------------------------------
507
508    #[must_use]
509    pub(crate) fn meta() -> IntentStoreMetaRecord {
510        INTENT_META.with_borrow(|cell| *cell.get())
511    }
512
513    pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
514        INTENT_META.with_borrow_mut(|cell| cell.set(meta));
515    }
516
517    // -------------------------------------------------------------
518    // Records
519    // -------------------------------------------------------------
520
521    #[must_use]
522    pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
523        INTENT_RECORDS.with_borrow(|map| map.get(&id))
524    }
525
526    pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
527        INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
528    }
529
530    // -------------------------------------------------------------
531    // Totals
532    // -------------------------------------------------------------
533
534    #[must_use]
535    pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
536        INTENT_TOTALS.with_borrow(|map| map.get(key))
537    }
538
539    pub(crate) fn set_totals(
540        key: IntentResourceKey,
541        totals: IntentResourceTotalsRecord,
542    ) -> Option<IntentResourceTotalsRecord> {
543        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
544    }
545
546    // -------------------------------------------------------------
547    // Pending index
548    // -------------------------------------------------------------
549
550    #[must_use]
551    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
552        INTENT_PENDING.with_borrow(|map| map.get(&id))
553    }
554
555    pub(crate) fn insert_pending(
556        id: IntentId,
557        entry: IntentPendingEntryRecord,
558    ) -> Option<IntentPendingEntryRecord> {
559        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
560    }
561
562    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
563        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
564    }
565
566    pub(crate) fn with_pending_entries<R>(
567        f: impl FnOnce(
568            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
569        ) -> R,
570    ) -> R {
571        INTENT_PENDING.with_borrow(|map| f(map))
572    }
573
574    // -------------------------------------------------------------
575    // Finite-expiry derived index
576    // -------------------------------------------------------------
577
578    #[must_use]
579    pub(crate) fn get_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
580        INTENT_EXPIRY_INDEX.with_borrow(|map| map.get(&key))
581    }
582
583    pub(crate) fn insert_expiry(
584        key: IntentExpiryKeyRecord,
585        record: IntentExpiryEntryRecord,
586    ) -> Option<IntentExpiryEntryRecord> {
587        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.insert(key, record))
588    }
589
590    pub(crate) fn remove_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
591        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.remove(&key))
592    }
593
594    pub(crate) fn clear_expiry_index() {
595        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
596    }
597
598    pub(crate) fn with_expiry_entries<R>(
599        f: impl FnOnce(
600            &StableBtreeMap<
601                IntentExpiryKeyRecord,
602                IntentExpiryEntryRecord,
603                VirtualMemory<DefaultMemoryImpl>,
604            >,
605        ) -> R,
606    ) -> R {
607        INTENT_EXPIRY_INDEX.with_borrow(|map| f(map))
608    }
609}
610
611/// Stable store for receipt-backed operations addressed by exact operation ID.
612pub struct ReceiptBackedIntentStore;
613
614impl ReceiptBackedIntentStore {
615    #[must_use]
616    pub(crate) fn len() -> u64 {
617        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
618    }
619
620    #[must_use]
621    pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
622        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
623    }
624
625    pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
626        RECEIPT_BACKED_INTENT_RECORDS
627            .with_borrow_mut(|records| records.insert(record.operation_id, record))
628    }
629
630    pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
631        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
632    }
633
634    pub(crate) fn with_records<R>(
635        f: impl FnOnce(
636            &StableBtreeMap<
637                OperationId,
638                ReceiptBackedIntentRecord,
639                VirtualMemory<DefaultMemoryImpl>,
640            >,
641        ) -> R,
642    ) -> R {
643        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
644    }
645}
646
647//
648// ─────────────────────────────────────────────────────────────
649// Test helpers
650// ─────────────────────────────────────────────────────────────
651//
652
653#[cfg(test)]
654impl IntentStore {
655    #[must_use]
656    pub(crate) fn export_meta() -> IntentMetaData {
657        IntentMetaData {
658            record: Self::meta(),
659        }
660    }
661
662    pub(crate) fn import_meta(data: IntentMetaData) {
663        Self::set_meta(data.record);
664    }
665
666    #[must_use]
667    pub(crate) fn export_records() -> IntentRecordsData {
668        IntentRecordsData {
669            entries: INTENT_RECORDS.with_borrow(|map| {
670                map.iter()
671                    .map(|entry| IntentRecordEntryRecord {
672                        intent_id: *entry.key(),
673                        record: entry.value(),
674                    })
675                    .collect()
676            }),
677        }
678    }
679
680    pub(crate) fn import_records(data: IntentRecordsData) {
681        INTENT_RECORDS.with_borrow_mut(|map| {
682            map.clear_new();
683            for entry in data.entries {
684                map.insert(entry.intent_id, entry.record);
685            }
686        });
687    }
688
689    #[must_use]
690    pub(crate) fn export_totals() -> IntentTotalsData {
691        IntentTotalsData {
692            entries: INTENT_TOTALS.with_borrow(|map| {
693                map.iter()
694                    .map(|entry| IntentTotalsEntryRecord {
695                        resource_key: entry.key().clone(),
696                        record: entry.value(),
697                    })
698                    .collect()
699            }),
700        }
701    }
702
703    pub(crate) fn import_totals(data: IntentTotalsData) {
704        INTENT_TOTALS.with_borrow_mut(|map| {
705            map.clear_new();
706            for entry in data.entries {
707                map.insert(entry.resource_key, entry.record);
708            }
709        });
710    }
711
712    #[must_use]
713    pub(crate) fn export_pending() -> IntentPendingData {
714        IntentPendingData {
715            entries: INTENT_PENDING.with_borrow(|map| {
716                map.iter()
717                    .map(|entry| IntentPendingIndexEntryRecord {
718                        intent_id: *entry.key(),
719                        record: entry.value(),
720                    })
721                    .collect()
722            }),
723        }
724    }
725
726    pub(crate) fn import_pending(data: IntentPendingData) {
727        INTENT_PENDING.with_borrow_mut(|map| {
728            map.clear_new();
729            for entry in data.entries {
730                map.insert(entry.intent_id, entry.record);
731            }
732        });
733    }
734
735    #[must_use]
736    pub(crate) fn export_expiry_index() -> IntentExpiryIndexData {
737        IntentExpiryIndexData {
738            entries: INTENT_EXPIRY_INDEX.with_borrow(|map| {
739                map.iter()
740                    .map(|entry| IntentExpiryIndexEntryRecord {
741                        key: *entry.key(),
742                        record: entry.value(),
743                    })
744                    .collect()
745            }),
746        }
747    }
748
749    pub(crate) fn import_expiry_index(data: IntentExpiryIndexData) {
750        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| {
751            map.clear_new();
752            for entry in data.entries {
753                map.insert(entry.key, entry.record);
754            }
755        });
756    }
757
758    pub(crate) fn reset_for_tests() {
759        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
760        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
761        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
762        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
763        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
764        ReceiptBackedIntentStore::reset_for_tests();
765    }
766}
767
768#[cfg(test)]
769impl ReceiptBackedIntentStore {
770    #[must_use]
771    pub(crate) fn export_records() -> ReceiptBackedIntentsData {
772        ReceiptBackedIntentsData {
773            entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
774                records
775                    .iter()
776                    .map(|entry| ReceiptBackedIntentEntryRecord {
777                        operation_id: *entry.key(),
778                        record: entry.value(),
779                    })
780                    .collect()
781            }),
782        }
783    }
784
785    pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
786        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
787            records.clear_new();
788            for entry in data.entries {
789                records.insert(entry.operation_id, entry.record);
790            }
791        });
792    }
793
794    pub(crate) fn reset_for_tests() {
795        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::{
803        cdk::types::Principal,
804        model::intent::{
805            RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
806        },
807    };
808
809    #[test]
810    #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
811    fn malformed_stable_intent_id_fails_closed() {
812        let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
813    }
814
815    #[test]
816    #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
817    fn malformed_stable_operation_id_fails_closed() {
818        let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
819    }
820
821    #[test]
822    #[should_panic(expected = "stable IntentExpiryKeyRecord is 15 bytes; expected 16")]
823    fn malformed_stable_intent_expiry_key_fails_closed() {
824        let _ = <IntentExpiryKeyRecord as Storable>::from_bytes(Cow::Owned(vec![0; 15]));
825    }
826
827    #[test]
828    fn stable_intent_expiry_key_preserves_deadline_then_identity_order() {
829        let keys = [
830            IntentExpiryKeyRecord {
831                due_at_secs: 11,
832                intent_id: IntentId(1),
833            },
834            IntentExpiryKeyRecord {
835                due_at_secs: 10,
836                intent_id: IntentId(2),
837            },
838            IntentExpiryKeyRecord {
839                due_at_secs: 10,
840                intent_id: IntentId(1),
841            },
842        ];
843        let mut encoded = keys.map(Storable::into_bytes);
844        encoded.sort();
845        assert_eq!(
846            encoded,
847            [
848                keys[2].into_bytes(),
849                keys[1].into_bytes(),
850                keys[0].into_bytes()
851            ]
852        );
853    }
854
855    #[test]
856    fn intent_allocations_round_trip_through_canonical_data_snapshots() {
857        IntentStore::reset_for_tests();
858        let intent_id = IntentId(7);
859        let resource_key = IntentResourceKey::new("storage:uploads");
860        let record = IntentRecord {
861            id: intent_id,
862            resource_key: resource_key.clone(),
863            quantity: 11,
864            state: IntentState::Pending,
865            created_at: 13,
866            ttl_secs: Some(17),
867        };
868        let totals = IntentResourceTotalsRecord {
869            reserved_qty: 11,
870            committed_qty: 19,
871            pending_count: 1,
872        };
873        let pending = IntentPendingEntryRecord {
874            resource_key: resource_key.clone(),
875            quantity: 11,
876            created_at: 13,
877            ttl_secs: Some(17),
878        };
879        let meta = IntentStoreMetaRecord {
880            schema_version: INTENT_STORE_SCHEMA_VERSION,
881            next_intent_id: IntentId(8),
882            pending_total: 1,
883            committed_total: 2,
884            aborted_total: 3,
885        };
886
887        IntentStore::set_meta(meta);
888        IntentStore::insert_record(record);
889        IntentStore::set_totals(resource_key, totals);
890        IntentStore::insert_pending(intent_id, pending);
891        let expiry_key = IntentExpiryKeyRecord {
892            due_at_secs: 31,
893            intent_id,
894        };
895        IntentStore::insert_expiry(expiry_key, IntentExpiryEntryRecord { intent_id });
896
897        let meta_data = IntentStore::export_meta();
898        let records_data = IntentStore::export_records();
899        let totals_data = IntentStore::export_totals();
900        let pending_data = IntentStore::export_pending();
901        let expiry_data = IntentStore::export_expiry_index();
902
903        IntentStore::reset_for_tests();
904        IntentStore::import_meta(meta_data);
905        IntentStore::import_records(records_data.clone());
906        IntentStore::import_totals(totals_data.clone());
907        IntentStore::import_pending(pending_data.clone());
908        IntentStore::import_expiry_index(expiry_data.clone());
909
910        assert_eq!(IntentStore::export_meta(), meta_data);
911        assert_eq!(IntentStore::export_records(), records_data);
912        assert_eq!(IntentStore::export_totals(), totals_data);
913        assert_eq!(IntentStore::export_pending(), pending_data);
914        assert_eq!(IntentStore::export_expiry_index(), expiry_data);
915        IntentStore::reset_for_tests();
916    }
917
918    #[test]
919    fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
920        IntentStore::reset_for_tests();
921        let operation_id = OperationId::from_bytes([7; 32]);
922        let evidence = TerminalEvidence::new(
923            Principal::from_slice(&[1; 29]),
924            TerminalEvidenceDecision::Committed,
925            [8; 32],
926        );
927        let record = ReceiptBackedIntentRecord {
928            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
929            operation_id,
930            payload_binding: PayloadBinding::new([9; 32]),
931            resource_key: IntentResourceKey::new("mint:collection"),
932            quantity: 11,
933            state: ReceiptBackedIntentState::Committed { evidence },
934            revision: 2,
935            created_at_ns: 13,
936            updated_at_ns: 17,
937        };
938        ReceiptBackedIntentStore::insert(record);
939        let records_data = ReceiptBackedIntentStore::export_records();
940
941        ReceiptBackedIntentStore::reset_for_tests();
942        assert_eq!(
943            ReceiptBackedIntentStore::export_records(),
944            ReceiptBackedIntentsData::default()
945        );
946
947        ReceiptBackedIntentStore::import_records(records_data.clone());
948        assert_eq!(ReceiptBackedIntentStore::len(), 1);
949        assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
950        IntentStore::reset_for_tests();
951    }
952}