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