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