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