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