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 = 69;
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    pub(crate) fn remove_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
596        INTENT_TOTALS.with_borrow_mut(|map| map.remove(key))
597    }
598
599    // -------------------------------------------------------------
600    // Pending index
601    // -------------------------------------------------------------
602
603    #[must_use]
604    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
605        INTENT_PENDING.with_borrow(|map| map.get(&id))
606    }
607
608    pub(crate) fn insert_pending(
609        id: IntentId,
610        entry: IntentPendingEntryRecord,
611    ) -> Option<IntentPendingEntryRecord> {
612        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
613    }
614
615    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
616        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
617    }
618
619    pub(crate) fn with_pending_entries<R>(
620        f: impl FnOnce(
621            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
622        ) -> R,
623    ) -> R {
624        INTENT_PENDING.with_borrow(|map| f(map))
625    }
626
627    // -------------------------------------------------------------
628    // Finite-expiry derived index
629    // -------------------------------------------------------------
630
631    #[must_use]
632    pub(crate) fn get_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
633        INTENT_EXPIRY_INDEX.with_borrow(|map| map.get(&key))
634    }
635
636    pub(crate) fn insert_expiry(
637        key: IntentExpiryKeyRecord,
638        record: IntentExpiryEntryRecord,
639    ) -> Option<IntentExpiryEntryRecord> {
640        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.insert(key, record))
641    }
642
643    pub(crate) fn remove_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
644        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.remove(&key))
645    }
646
647    pub(crate) fn clear_expiry_index() {
648        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
649    }
650
651    pub(crate) fn with_expiry_entries<R>(
652        f: impl FnOnce(
653            &StableBtreeMap<
654                IntentExpiryKeyRecord,
655                IntentExpiryEntryRecord,
656                VirtualMemory<DefaultMemoryImpl>,
657            >,
658        ) -> R,
659    ) -> R {
660        INTENT_EXPIRY_INDEX.with_borrow(|map| f(map))
661    }
662}
663
664/// Stable store for receipt-backed operations addressed by exact operation ID.
665pub struct ReceiptBackedIntentStore;
666
667impl ReceiptBackedIntentStore {
668    #[must_use]
669    pub(crate) fn len() -> u64 {
670        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
671    }
672
673    #[must_use]
674    pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
675        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
676    }
677
678    pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
679        RECEIPT_BACKED_INTENT_RECORDS
680            .with_borrow_mut(|records| records.insert(record.operation_id, record))
681    }
682
683    pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
684        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
685    }
686
687    pub(crate) fn with_records<R>(
688        f: impl FnOnce(
689            &StableBtreeMap<
690                OperationId,
691                ReceiptBackedIntentRecord,
692                VirtualMemory<DefaultMemoryImpl>,
693            >,
694        ) -> R,
695    ) -> R {
696        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
697    }
698
699    #[must_use]
700    pub(crate) fn get_placement_acknowledgement(
701        operation_id: OperationId,
702    ) -> Option<PlacementAcknowledgementEntryRecord> {
703        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| index.get(&operation_id))
704    }
705
706    pub(crate) fn insert_placement_acknowledgement(
707        record: PlacementAcknowledgementEntryRecord,
708    ) -> Option<PlacementAcknowledgementEntryRecord> {
709        PLACEMENT_ACKNOWLEDGEMENT_INDEX
710            .with_borrow_mut(|index| index.insert(record.operation_id, record))
711    }
712
713    pub(crate) fn remove_placement_acknowledgement(
714        operation_id: OperationId,
715    ) -> Option<PlacementAcknowledgementEntryRecord> {
716        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(|index| index.remove(&operation_id))
717    }
718
719    pub(crate) fn clear_placement_acknowledgement_index() {
720        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
721    }
722
723    pub(crate) fn with_placement_acknowledgements<R>(
724        f: impl FnOnce(
725            &StableBtreeMap<
726                OperationId,
727                PlacementAcknowledgementEntryRecord,
728                VirtualMemory<DefaultMemoryImpl>,
729            >,
730        ) -> R,
731    ) -> R {
732        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| f(index))
733    }
734}
735
736//
737// ─────────────────────────────────────────────────────────────
738// Test helpers
739// ─────────────────────────────────────────────────────────────
740//
741
742#[cfg(test)]
743impl IntentStore {
744    #[must_use]
745    pub(crate) fn export_meta() -> IntentMetaData {
746        IntentMetaData {
747            record: Self::meta(),
748        }
749    }
750
751    pub(crate) fn import_meta(data: IntentMetaData) {
752        Self::set_meta(data.record);
753    }
754
755    #[must_use]
756    pub(crate) fn export_records() -> IntentRecordsData {
757        IntentRecordsData {
758            entries: INTENT_RECORDS.with_borrow(|map| {
759                map.iter()
760                    .map(|entry| IntentRecordEntryRecord {
761                        intent_id: *entry.key(),
762                        record: entry.value(),
763                    })
764                    .collect()
765            }),
766        }
767    }
768
769    pub(crate) fn import_records(data: IntentRecordsData) {
770        INTENT_RECORDS.with_borrow_mut(|map| {
771            map.clear_new();
772            for entry in data.entries {
773                map.insert(entry.intent_id, entry.record);
774            }
775        });
776    }
777
778    #[must_use]
779    pub(crate) fn export_totals() -> IntentTotalsData {
780        IntentTotalsData {
781            entries: INTENT_TOTALS.with_borrow(|map| {
782                map.iter()
783                    .map(|entry| IntentTotalsEntryRecord {
784                        resource_key: entry.key().clone(),
785                        record: entry.value(),
786                    })
787                    .collect()
788            }),
789        }
790    }
791
792    pub(crate) fn import_totals(data: IntentTotalsData) {
793        INTENT_TOTALS.with_borrow_mut(|map| {
794            map.clear_new();
795            for entry in data.entries {
796                map.insert(entry.resource_key, entry.record);
797            }
798        });
799    }
800
801    #[must_use]
802    pub(crate) fn export_pending() -> IntentPendingData {
803        IntentPendingData {
804            entries: INTENT_PENDING.with_borrow(|map| {
805                map.iter()
806                    .map(|entry| IntentPendingIndexEntryRecord {
807                        intent_id: *entry.key(),
808                        record: entry.value(),
809                    })
810                    .collect()
811            }),
812        }
813    }
814
815    pub(crate) fn import_pending(data: IntentPendingData) {
816        INTENT_PENDING.with_borrow_mut(|map| {
817            map.clear_new();
818            for entry in data.entries {
819                map.insert(entry.intent_id, entry.record);
820            }
821        });
822    }
823
824    #[must_use]
825    pub(crate) fn export_expiry_index() -> IntentExpiryIndexData {
826        IntentExpiryIndexData {
827            entries: INTENT_EXPIRY_INDEX.with_borrow(|map| {
828                map.iter()
829                    .map(|entry| IntentExpiryIndexEntryRecord {
830                        key: *entry.key(),
831                        record: entry.value(),
832                    })
833                    .collect()
834            }),
835        }
836    }
837
838    pub(crate) fn import_expiry_index(data: IntentExpiryIndexData) {
839        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| {
840            map.clear_new();
841            for entry in data.entries {
842                map.insert(entry.key, entry.record);
843            }
844        });
845    }
846
847    pub(crate) fn reset_for_tests() {
848        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
849        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
850        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
851        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
852        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
853        ReceiptBackedIntentStore::reset_for_tests();
854    }
855}
856
857#[cfg(test)]
858impl ReceiptBackedIntentStore {
859    #[must_use]
860    pub(crate) fn export_records() -> ReceiptBackedIntentsData {
861        ReceiptBackedIntentsData {
862            entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
863                records
864                    .iter()
865                    .map(|entry| ReceiptBackedIntentEntryRecord {
866                        operation_id: *entry.key(),
867                        record: entry.value(),
868                    })
869                    .collect()
870            }),
871        }
872    }
873
874    pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
875        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
876            records.clear_new();
877            for entry in data.entries {
878                records.insert(entry.operation_id, entry.record);
879            }
880        });
881    }
882
883    #[must_use]
884    pub(crate) fn export_placement_acknowledgement_index() -> PlacementAcknowledgementIndexData {
885        PlacementAcknowledgementIndexData {
886            entries: PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| {
887                index
888                    .iter()
889                    .map(|entry| PlacementAcknowledgementIndexEntryRecord {
890                        operation_id: *entry.key(),
891                        record: entry.value(),
892                    })
893                    .collect()
894            }),
895        }
896    }
897
898    pub(crate) fn import_placement_acknowledgement_index(data: PlacementAcknowledgementIndexData) {
899        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(|index| {
900            index.clear_new();
901            for entry in data.entries {
902                index.insert(entry.operation_id, entry.record);
903            }
904        });
905    }
906
907    pub(crate) fn reset_for_tests() {
908        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
909        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    use crate::{
917        cdk::types::Principal,
918        model::intent::{
919            RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
920        },
921    };
922
923    #[test]
924    #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
925    fn malformed_stable_intent_id_fails_closed() {
926        let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
927    }
928
929    #[test]
930    #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
931    fn malformed_stable_operation_id_fails_closed() {
932        let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
933    }
934
935    #[test]
936    #[should_panic(expected = "stable IntentExpiryKeyRecord is 15 bytes; expected 16")]
937    fn malformed_stable_intent_expiry_key_fails_closed() {
938        let _ = <IntentExpiryKeyRecord as Storable>::from_bytes(Cow::Owned(vec![0; 15]));
939    }
940
941    #[test]
942    fn stable_intent_expiry_key_preserves_deadline_then_identity_order() {
943        let keys = [
944            IntentExpiryKeyRecord {
945                due_at_secs: 11,
946                intent_id: IntentId(1),
947            },
948            IntentExpiryKeyRecord {
949                due_at_secs: 10,
950                intent_id: IntentId(2),
951            },
952            IntentExpiryKeyRecord {
953                due_at_secs: 10,
954                intent_id: IntentId(1),
955            },
956        ];
957        let mut encoded = keys.map(Storable::into_bytes);
958        encoded.sort();
959        assert_eq!(
960            encoded,
961            [
962                keys[2].into_bytes(),
963                keys[1].into_bytes(),
964                keys[0].into_bytes()
965            ]
966        );
967    }
968
969    #[test]
970    fn intent_allocations_round_trip_through_canonical_data_snapshots() {
971        IntentStore::reset_for_tests();
972        let intent_id = IntentId(7);
973        let resource_key = IntentResourceKey::new("storage:uploads");
974        let record = IntentRecord {
975            id: intent_id,
976            resource_key: resource_key.clone(),
977            quantity: 11,
978            state: IntentState::Pending,
979            created_at: 13,
980            ttl_secs: Some(17),
981        };
982        let totals = IntentResourceTotalsRecord {
983            reserved_qty: 11,
984            committed_qty: 19,
985            pending_count: 1,
986        };
987        let pending = IntentPendingEntryRecord {
988            resource_key: resource_key.clone(),
989            quantity: 11,
990            created_at: 13,
991            ttl_secs: Some(17),
992        };
993        let meta = IntentStoreMetaRecord {
994            schema_version: INTENT_STORE_SCHEMA_VERSION,
995            next_intent_id: IntentId(8),
996            pending_total: 1,
997            committed_total: 2,
998            aborted_total: 3,
999        };
1000
1001        IntentStore::set_meta(meta);
1002        IntentStore::insert_record(record);
1003        IntentStore::set_totals(resource_key, totals);
1004        IntentStore::insert_pending(intent_id, pending);
1005        let expiry_key = IntentExpiryKeyRecord {
1006            due_at_secs: 31,
1007            intent_id,
1008        };
1009        IntentStore::insert_expiry(expiry_key, IntentExpiryEntryRecord { intent_id });
1010
1011        let meta_data = IntentStore::export_meta();
1012        let records_data = IntentStore::export_records();
1013        let totals_data = IntentStore::export_totals();
1014        let pending_data = IntentStore::export_pending();
1015        let expiry_data = IntentStore::export_expiry_index();
1016
1017        IntentStore::reset_for_tests();
1018        IntentStore::import_meta(meta_data);
1019        IntentStore::import_records(records_data.clone());
1020        IntentStore::import_totals(totals_data.clone());
1021        IntentStore::import_pending(pending_data.clone());
1022        IntentStore::import_expiry_index(expiry_data.clone());
1023
1024        assert_eq!(IntentStore::export_meta(), meta_data);
1025        assert_eq!(IntentStore::export_records(), records_data);
1026        assert_eq!(IntentStore::export_totals(), totals_data);
1027        assert_eq!(IntentStore::export_pending(), pending_data);
1028        assert_eq!(IntentStore::export_expiry_index(), expiry_data);
1029        IntentStore::reset_for_tests();
1030    }
1031
1032    #[test]
1033    fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
1034        IntentStore::reset_for_tests();
1035        let operation_id = OperationId::from_bytes([7; 32]);
1036        let evidence = TerminalEvidence::new(
1037            Principal::from_slice(&[1; 29]),
1038            TerminalEvidenceDecision::Committed,
1039            [8; 32],
1040        );
1041        let record = ReceiptBackedIntentRecord {
1042            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
1043            operation_id,
1044            payload_binding: PayloadBinding::new([9; 32]),
1045            resource_key: IntentResourceKey::new("mint:collection"),
1046            quantity: 11,
1047            state: ReceiptBackedIntentState::Committed { evidence },
1048            revision: 2,
1049            created_at_ns: 13,
1050            updated_at_ns: 17,
1051        };
1052        ReceiptBackedIntentStore::insert(record);
1053        ReceiptBackedIntentStore::insert_placement_acknowledgement(
1054            PlacementAcknowledgementEntryRecord { operation_id },
1055        );
1056        let records_data = ReceiptBackedIntentStore::export_records();
1057        let acknowledgement_data =
1058            ReceiptBackedIntentStore::export_placement_acknowledgement_index();
1059
1060        ReceiptBackedIntentStore::reset_for_tests();
1061        assert_eq!(
1062            ReceiptBackedIntentStore::export_records(),
1063            ReceiptBackedIntentsData::default()
1064        );
1065        assert_eq!(
1066            ReceiptBackedIntentStore::export_placement_acknowledgement_index(),
1067            PlacementAcknowledgementIndexData::default()
1068        );
1069
1070        ReceiptBackedIntentStore::import_records(records_data.clone());
1071        ReceiptBackedIntentStore::import_placement_acknowledgement_index(
1072            acknowledgement_data.clone(),
1073        );
1074        assert_eq!(ReceiptBackedIntentStore::len(), 1);
1075        assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
1076        assert_eq!(
1077            ReceiptBackedIntentStore::export_placement_acknowledgement_index(),
1078            acknowledgement_data
1079        );
1080        IntentStore::reset_for_tests();
1081    }
1082}