1use crate::cdk::structures::btreemap::BTreeMap as StableBtreeMap;
8use crate::{
9 cdk::structures::{
10 DefaultMemoryImpl, Storable, cell::Cell, memory::VirtualMemory, storable::Bound,
11 },
12 ids::{IntentId, IntentResourceKey},
13 model::{
14 intent::{PayloadBinding, ReceiptBackedIntent, ReceiptBackedIntentState},
15 replay::OperationId,
16 },
17 role_contract::allocation::memory::intent::{
18 INTENT_EXPIRY_INDEX_ID, INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID,
19 INTENT_TOTALS_ID, RECEIPT_BACKED_INTENT_RECORDS_ID,
20 },
21 storage::prelude::*,
22};
23use std::{borrow::Cow, cell::RefCell};
24
25pub const INTENT_STORE_SCHEMA_VERSION: u32 = 1;
30
31eager_static! {
32 static INTENT_META: RefCell<Cell<IntentStoreMetaRecord, VirtualMemory<DefaultMemoryImpl>>> =
33 RefCell::new(Cell::init(
34 crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_meta.v1", ty = IntentStoreMetaRecord, id = INTENT_META_ID),
35 IntentStoreMetaRecord::default(),
36 ));
37}
38
39eager_static! {
40 static RECEIPT_BACKED_INTENT_RECORDS: RefCell<
41 StableBtreeMap<
42 OperationId,
43 ReceiptBackedIntentRecord,
44 VirtualMemory<DefaultMemoryImpl>,
45 >
46 > = RefCell::new(StableBtreeMap::init(crate::ic_memory_key!(
47 authority = CANIC_CORE_MEMORY_AUTHORITY,
48 key = "canic.core.receipt_backed_intent_records.v1",
49 ty = ReceiptBackedIntentRecord,
50 id = RECEIPT_BACKED_INTENT_RECORDS_ID
51 )));
52}
53
54eager_static! {
55 static INTENT_EXPIRY_INDEX: RefCell<
56 StableBtreeMap<IntentExpiryKeyRecord, IntentExpiryEntryRecord, VirtualMemory<DefaultMemoryImpl>>
57 > = RefCell::new(
58 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_expiry_index.v1", ty = IntentExpiryEntryRecord, id = INTENT_EXPIRY_INDEX_ID)),
59 );
60}
61
62eager_static! {
63 static INTENT_RECORDS: RefCell<
64 StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
65 > = RefCell::new(
66 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_records.v1", ty = IntentRecord, id = INTENT_RECORDS_ID)),
67 );
68}
69
70eager_static! {
71 static INTENT_TOTALS: RefCell<
72 StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
73 > = RefCell::new(
74 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_totals.v1", ty = IntentResourceTotalsRecord, id = INTENT_TOTALS_ID)),
75 );
76}
77
78eager_static! {
79 static INTENT_PENDING: RefCell<
80 StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
81 > = RefCell::new(
82 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_pending.v1", ty = IntentPendingEntryRecord, id = INTENT_PENDING_ID)),
83 );
84}
85
86impl Storable for IntentId {
87 const BOUND: Bound = Bound::Bounded {
88 max_size: 8,
89 is_fixed_size: true,
90 };
91
92 fn to_bytes(&self) -> Cow<'_, [u8]> {
93 Cow::Owned(self.0.to_be_bytes().to_vec())
94 }
95
96 fn into_bytes(self) -> Vec<u8> {
97 self.0.to_be_bytes().to_vec()
98 }
99
100 fn from_bytes(bytes: Cow<[u8]>) -> Self {
106 let bytes = <[u8; 8]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
107 panic!(
108 "stable IntentId is {} bytes; expected 8",
109 bytes.as_ref().len()
110 )
111 });
112
113 Self(u64::from_be_bytes(bytes))
114 }
115}
116
117impl Storable for OperationId {
118 const BOUND: Bound = Bound::Bounded {
119 max_size: 32,
120 is_fixed_size: true,
121 };
122
123 fn to_bytes(&self) -> Cow<'_, [u8]> {
124 Cow::Borrowed(self.as_bytes())
125 }
126
127 fn into_bytes(self) -> Vec<u8> {
128 self.as_bytes().to_vec()
129 }
130
131 fn from_bytes(bytes: Cow<[u8]>) -> Self {
137 let operation_id = <[u8; 32]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
138 panic!(
139 "stable OperationId is {} bytes; expected 32",
140 bytes.as_ref().len()
141 )
142 });
143 Self::from_bytes(operation_id)
144 }
145}
146
147#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
149pub struct IntentExpiryKeyRecord {
150 pub due_at_secs: u64,
151 pub intent_id: IntentId,
152}
153
154impl Storable for IntentExpiryKeyRecord {
155 const BOUND: Bound = Bound::Bounded {
156 max_size: 16,
157 is_fixed_size: true,
158 };
159
160 fn to_bytes(&self) -> Cow<'_, [u8]> {
161 Cow::Owned(self.into_bytes())
162 }
163
164 fn into_bytes(self) -> Vec<u8> {
165 let mut bytes = Vec::with_capacity(16);
166 bytes.extend_from_slice(&self.due_at_secs.to_be_bytes());
167 bytes.extend_from_slice(&self.intent_id.0.to_be_bytes());
168 bytes
169 }
170
171 fn from_bytes(bytes: Cow<[u8]>) -> Self {
177 let bytes = <[u8; 16]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
178 panic!(
179 "stable IntentExpiryKeyRecord is {} bytes; expected 16",
180 bytes.as_ref().len()
181 )
182 });
183 let (due_at_secs, intent_id) = bytes.split_at(8);
184 Self {
185 due_at_secs: u64::from_be_bytes(
186 due_at_secs.try_into().expect("expiry key deadline width"),
187 ),
188 intent_id: IntentId(u64::from_be_bytes(
189 intent_id.try_into().expect("expiry key intent width"),
190 )),
191 }
192 }
193}
194
195#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub struct IntentExpiryEntryRecord {
198 pub intent_id: IntentId,
199}
200
201impl IntentExpiryEntryRecord {
202 pub const STATE_CONTRACT_NAME: &'static str = "IntentExpiryEntryRecord";
203 pub const STORABLE_MAX_SIZE: u32 = 32;
204}
205
206impl_storable_bounded!(
207 IntentExpiryEntryRecord,
208 IntentExpiryEntryRecord::STORABLE_MAX_SIZE,
209 false
210);
211
212#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub enum IntentState {
218 Pending,
219 Committed,
220 Aborted,
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228pub struct IntentRecord {
229 pub id: IntentId,
230 pub resource_key: IntentResourceKey,
231 pub quantity: u64,
232 pub state: IntentState,
233 pub created_at: u64,
234 pub ttl_secs: Option<u64>,
236}
237
238impl IntentRecord {
239 pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
240 pub const STORABLE_MAX_SIZE: u32 = 256;
241}
242
243impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
244
245#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
250pub struct IntentStoreMetaRecord {
251 pub schema_version: u32,
252 pub next_intent_id: IntentId,
253 pub pending_total: u64,
254 pub committed_total: u64,
255 pub aborted_total: u64,
256}
257
258impl IntentStoreMetaRecord {
259 pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
260 pub const STORABLE_MAX_SIZE: u32 = 96;
261}
262
263impl Default for IntentStoreMetaRecord {
264 fn default() -> Self {
265 Self {
266 schema_version: INTENT_STORE_SCHEMA_VERSION,
267 next_intent_id: IntentId(1),
268 pending_total: 0,
269 committed_total: 0,
270 aborted_total: 0,
271 }
272 }
273}
274
275impl_storable_bounded!(
276 IntentStoreMetaRecord,
277 IntentStoreMetaRecord::STORABLE_MAX_SIZE,
278 false
279);
280
281#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
286pub struct IntentResourceTotalsRecord {
287 pub reserved_qty: u64,
288 pub committed_qty: u64,
289 pub pending_count: u64,
290}
291
292impl IntentResourceTotalsRecord {
293 pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
294 pub const STORABLE_MAX_SIZE: u32 = 64;
295}
296
297impl_storable_bounded!(
298 IntentResourceTotalsRecord,
299 IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
300 false
301);
302
303#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct IntentPendingEntryRecord {
309 pub resource_key: IntentResourceKey,
310 pub quantity: u64,
311 pub created_at: u64,
312 pub ttl_secs: Option<u64>,
314}
315
316impl IntentPendingEntryRecord {
317 pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
318 pub const STORABLE_MAX_SIZE: u32 = 224;
319}
320
321impl_storable_bounded!(
322 IntentPendingEntryRecord,
323 IntentPendingEntryRecord::STORABLE_MAX_SIZE,
324 false
325);
326
327#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
329pub struct ReceiptBackedIntentRecord {
330 pub schema_version: u32,
331 pub operation_id: OperationId,
332 pub payload_binding: PayloadBinding,
333 pub resource_key: IntentResourceKey,
334 pub quantity: u64,
335 pub state: ReceiptBackedIntentState,
336 pub revision: u64,
337 pub created_at_ns: u64,
338 pub updated_at_ns: u64,
339}
340
341impl ReceiptBackedIntentRecord {
342 pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentRecord";
343 pub const STORABLE_MAX_SIZE: u32 = 1024;
344
345 #[must_use]
346 pub fn into_intent(self) -> ReceiptBackedIntent {
347 ReceiptBackedIntent {
348 schema_version: self.schema_version,
349 operation_id: self.operation_id,
350 payload_binding: self.payload_binding,
351 resource_key: self.resource_key,
352 quantity: self.quantity,
353 state: self.state,
354 revision: self.revision,
355 created_at_ns: self.created_at_ns,
356 updated_at_ns: self.updated_at_ns,
357 }
358 }
359}
360
361impl_storable_bounded!(
362 ReceiptBackedIntentRecord,
363 ReceiptBackedIntentRecord::STORABLE_MAX_SIZE,
364 false
365);
366
367#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
374pub struct IntentMetaData {
375 pub record: IntentStoreMetaRecord,
376}
377
378impl IntentMetaData {
379 pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
380}
381
382#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct IntentRecordEntryRecord {
390 pub intent_id: IntentId,
391 pub record: IntentRecord,
392}
393
394#[derive(Clone, Debug, Default, Eq, PartialEq)]
401pub struct IntentRecordsData {
402 pub entries: Vec<IntentRecordEntryRecord>,
403}
404
405impl IntentRecordsData {
406 pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
407}
408
409#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct IntentTotalsEntryRecord {
417 pub resource_key: IntentResourceKey,
418 pub record: IntentResourceTotalsRecord,
419}
420
421#[derive(Clone, Debug, Default, Eq, PartialEq)]
428pub struct IntentTotalsData {
429 pub entries: Vec<IntentTotalsEntryRecord>,
430}
431
432impl IntentTotalsData {
433 pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
434}
435
436#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct IntentPendingIndexEntryRecord {
444 pub intent_id: IntentId,
445 pub record: IntentPendingEntryRecord,
446}
447
448#[derive(Clone, Debug, Default, Eq, PartialEq)]
455pub struct IntentPendingData {
456 pub entries: Vec<IntentPendingIndexEntryRecord>,
457}
458
459impl IntentPendingData {
460 pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
461}
462
463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
465pub struct IntentExpiryIndexEntryRecord {
466 pub key: IntentExpiryKeyRecord,
467 pub record: IntentExpiryEntryRecord,
468}
469
470#[derive(Clone, Debug, Default, Eq, PartialEq)]
472pub struct IntentExpiryIndexData {
473 pub entries: Vec<IntentExpiryIndexEntryRecord>,
474}
475
476impl IntentExpiryIndexData {
477 pub const STATE_CONTRACT_NAME: &'static str = "IntentExpiryIndexData";
478}
479
480#[derive(Clone, Debug, Eq, PartialEq)]
482pub struct ReceiptBackedIntentEntryRecord {
483 pub operation_id: OperationId,
484 pub record: ReceiptBackedIntentRecord,
485}
486
487#[derive(Clone, Debug, Default, Eq, PartialEq)]
489pub struct ReceiptBackedIntentsData {
490 pub entries: Vec<ReceiptBackedIntentEntryRecord>,
491}
492
493impl ReceiptBackedIntentsData {
494 pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentsData";
495}
496
497pub struct IntentStore;
502
503impl IntentStore {
504 #[must_use]
509 pub(crate) fn meta() -> IntentStoreMetaRecord {
510 INTENT_META.with_borrow(|cell| *cell.get())
511 }
512
513 pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
514 INTENT_META.with_borrow_mut(|cell| cell.set(meta));
515 }
516
517 #[must_use]
522 pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
523 INTENT_RECORDS.with_borrow(|map| map.get(&id))
524 }
525
526 pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
527 INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
528 }
529
530 #[must_use]
535 pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
536 INTENT_TOTALS.with_borrow(|map| map.get(key))
537 }
538
539 pub(crate) fn set_totals(
540 key: IntentResourceKey,
541 totals: IntentResourceTotalsRecord,
542 ) -> Option<IntentResourceTotalsRecord> {
543 INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
544 }
545
546 #[must_use]
551 pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
552 INTENT_PENDING.with_borrow(|map| map.get(&id))
553 }
554
555 pub(crate) fn insert_pending(
556 id: IntentId,
557 entry: IntentPendingEntryRecord,
558 ) -> Option<IntentPendingEntryRecord> {
559 INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
560 }
561
562 pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
563 INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
564 }
565
566 pub(crate) fn with_pending_entries<R>(
567 f: impl FnOnce(
568 &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
569 ) -> R,
570 ) -> R {
571 INTENT_PENDING.with_borrow(|map| f(map))
572 }
573
574 #[must_use]
579 pub(crate) fn get_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
580 INTENT_EXPIRY_INDEX.with_borrow(|map| map.get(&key))
581 }
582
583 pub(crate) fn insert_expiry(
584 key: IntentExpiryKeyRecord,
585 record: IntentExpiryEntryRecord,
586 ) -> Option<IntentExpiryEntryRecord> {
587 INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.insert(key, record))
588 }
589
590 pub(crate) fn remove_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
591 INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.remove(&key))
592 }
593
594 pub(crate) fn clear_expiry_index() {
595 INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
596 }
597
598 pub(crate) fn with_expiry_entries<R>(
599 f: impl FnOnce(
600 &StableBtreeMap<
601 IntentExpiryKeyRecord,
602 IntentExpiryEntryRecord,
603 VirtualMemory<DefaultMemoryImpl>,
604 >,
605 ) -> R,
606 ) -> R {
607 INTENT_EXPIRY_INDEX.with_borrow(|map| f(map))
608 }
609}
610
611pub struct ReceiptBackedIntentStore;
613
614impl ReceiptBackedIntentStore {
615 #[must_use]
616 pub(crate) fn len() -> u64 {
617 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
618 }
619
620 #[must_use]
621 pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
622 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
623 }
624
625 pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
626 RECEIPT_BACKED_INTENT_RECORDS
627 .with_borrow_mut(|records| records.insert(record.operation_id, record))
628 }
629
630 pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
631 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
632 }
633
634 pub(crate) fn with_records<R>(
635 f: impl FnOnce(
636 &StableBtreeMap<
637 OperationId,
638 ReceiptBackedIntentRecord,
639 VirtualMemory<DefaultMemoryImpl>,
640 >,
641 ) -> R,
642 ) -> R {
643 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
644 }
645}
646
647#[cfg(test)]
654impl IntentStore {
655 #[must_use]
656 pub(crate) fn export_meta() -> IntentMetaData {
657 IntentMetaData {
658 record: Self::meta(),
659 }
660 }
661
662 pub(crate) fn import_meta(data: IntentMetaData) {
663 Self::set_meta(data.record);
664 }
665
666 #[must_use]
667 pub(crate) fn export_records() -> IntentRecordsData {
668 IntentRecordsData {
669 entries: INTENT_RECORDS.with_borrow(|map| {
670 map.iter()
671 .map(|entry| IntentRecordEntryRecord {
672 intent_id: *entry.key(),
673 record: entry.value(),
674 })
675 .collect()
676 }),
677 }
678 }
679
680 pub(crate) fn import_records(data: IntentRecordsData) {
681 INTENT_RECORDS.with_borrow_mut(|map| {
682 map.clear_new();
683 for entry in data.entries {
684 map.insert(entry.intent_id, entry.record);
685 }
686 });
687 }
688
689 #[must_use]
690 pub(crate) fn export_totals() -> IntentTotalsData {
691 IntentTotalsData {
692 entries: INTENT_TOTALS.with_borrow(|map| {
693 map.iter()
694 .map(|entry| IntentTotalsEntryRecord {
695 resource_key: entry.key().clone(),
696 record: entry.value(),
697 })
698 .collect()
699 }),
700 }
701 }
702
703 pub(crate) fn import_totals(data: IntentTotalsData) {
704 INTENT_TOTALS.with_borrow_mut(|map| {
705 map.clear_new();
706 for entry in data.entries {
707 map.insert(entry.resource_key, entry.record);
708 }
709 });
710 }
711
712 #[must_use]
713 pub(crate) fn export_pending() -> IntentPendingData {
714 IntentPendingData {
715 entries: INTENT_PENDING.with_borrow(|map| {
716 map.iter()
717 .map(|entry| IntentPendingIndexEntryRecord {
718 intent_id: *entry.key(),
719 record: entry.value(),
720 })
721 .collect()
722 }),
723 }
724 }
725
726 pub(crate) fn import_pending(data: IntentPendingData) {
727 INTENT_PENDING.with_borrow_mut(|map| {
728 map.clear_new();
729 for entry in data.entries {
730 map.insert(entry.intent_id, entry.record);
731 }
732 });
733 }
734
735 #[must_use]
736 pub(crate) fn export_expiry_index() -> IntentExpiryIndexData {
737 IntentExpiryIndexData {
738 entries: INTENT_EXPIRY_INDEX.with_borrow(|map| {
739 map.iter()
740 .map(|entry| IntentExpiryIndexEntryRecord {
741 key: *entry.key(),
742 record: entry.value(),
743 })
744 .collect()
745 }),
746 }
747 }
748
749 pub(crate) fn import_expiry_index(data: IntentExpiryIndexData) {
750 INTENT_EXPIRY_INDEX.with_borrow_mut(|map| {
751 map.clear_new();
752 for entry in data.entries {
753 map.insert(entry.key, entry.record);
754 }
755 });
756 }
757
758 pub(crate) fn reset_for_tests() {
759 INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
760 INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
761 INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
762 INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
763 INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
764 ReceiptBackedIntentStore::reset_for_tests();
765 }
766}
767
768#[cfg(test)]
769impl ReceiptBackedIntentStore {
770 #[must_use]
771 pub(crate) fn export_records() -> ReceiptBackedIntentsData {
772 ReceiptBackedIntentsData {
773 entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
774 records
775 .iter()
776 .map(|entry| ReceiptBackedIntentEntryRecord {
777 operation_id: *entry.key(),
778 record: entry.value(),
779 })
780 .collect()
781 }),
782 }
783 }
784
785 pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
786 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
787 records.clear_new();
788 for entry in data.entries {
789 records.insert(entry.operation_id, entry.record);
790 }
791 });
792 }
793
794 pub(crate) fn reset_for_tests() {
795 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
796 }
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use crate::{
803 cdk::types::Principal,
804 model::intent::{
805 RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
806 },
807 };
808
809 #[test]
810 #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
811 fn malformed_stable_intent_id_fails_closed() {
812 let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
813 }
814
815 #[test]
816 #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
817 fn malformed_stable_operation_id_fails_closed() {
818 let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
819 }
820
821 #[test]
822 #[should_panic(expected = "stable IntentExpiryKeyRecord is 15 bytes; expected 16")]
823 fn malformed_stable_intent_expiry_key_fails_closed() {
824 let _ = <IntentExpiryKeyRecord as Storable>::from_bytes(Cow::Owned(vec![0; 15]));
825 }
826
827 #[test]
828 fn stable_intent_expiry_key_preserves_deadline_then_identity_order() {
829 let keys = [
830 IntentExpiryKeyRecord {
831 due_at_secs: 11,
832 intent_id: IntentId(1),
833 },
834 IntentExpiryKeyRecord {
835 due_at_secs: 10,
836 intent_id: IntentId(2),
837 },
838 IntentExpiryKeyRecord {
839 due_at_secs: 10,
840 intent_id: IntentId(1),
841 },
842 ];
843 let mut encoded = keys.map(Storable::into_bytes);
844 encoded.sort();
845 assert_eq!(
846 encoded,
847 [
848 keys[2].into_bytes(),
849 keys[1].into_bytes(),
850 keys[0].into_bytes()
851 ]
852 );
853 }
854
855 #[test]
856 fn intent_allocations_round_trip_through_canonical_data_snapshots() {
857 IntentStore::reset_for_tests();
858 let intent_id = IntentId(7);
859 let resource_key = IntentResourceKey::new("storage:uploads");
860 let record = IntentRecord {
861 id: intent_id,
862 resource_key: resource_key.clone(),
863 quantity: 11,
864 state: IntentState::Pending,
865 created_at: 13,
866 ttl_secs: Some(17),
867 };
868 let totals = IntentResourceTotalsRecord {
869 reserved_qty: 11,
870 committed_qty: 19,
871 pending_count: 1,
872 };
873 let pending = IntentPendingEntryRecord {
874 resource_key: resource_key.clone(),
875 quantity: 11,
876 created_at: 13,
877 ttl_secs: Some(17),
878 };
879 let meta = IntentStoreMetaRecord {
880 schema_version: INTENT_STORE_SCHEMA_VERSION,
881 next_intent_id: IntentId(8),
882 pending_total: 1,
883 committed_total: 2,
884 aborted_total: 3,
885 };
886
887 IntentStore::set_meta(meta);
888 IntentStore::insert_record(record);
889 IntentStore::set_totals(resource_key, totals);
890 IntentStore::insert_pending(intent_id, pending);
891 let expiry_key = IntentExpiryKeyRecord {
892 due_at_secs: 31,
893 intent_id,
894 };
895 IntentStore::insert_expiry(expiry_key, IntentExpiryEntryRecord { intent_id });
896
897 let meta_data = IntentStore::export_meta();
898 let records_data = IntentStore::export_records();
899 let totals_data = IntentStore::export_totals();
900 let pending_data = IntentStore::export_pending();
901 let expiry_data = IntentStore::export_expiry_index();
902
903 IntentStore::reset_for_tests();
904 IntentStore::import_meta(meta_data);
905 IntentStore::import_records(records_data.clone());
906 IntentStore::import_totals(totals_data.clone());
907 IntentStore::import_pending(pending_data.clone());
908 IntentStore::import_expiry_index(expiry_data.clone());
909
910 assert_eq!(IntentStore::export_meta(), meta_data);
911 assert_eq!(IntentStore::export_records(), records_data);
912 assert_eq!(IntentStore::export_totals(), totals_data);
913 assert_eq!(IntentStore::export_pending(), pending_data);
914 assert_eq!(IntentStore::export_expiry_index(), expiry_data);
915 IntentStore::reset_for_tests();
916 }
917
918 #[test]
919 fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
920 IntentStore::reset_for_tests();
921 let operation_id = OperationId::from_bytes([7; 32]);
922 let evidence = TerminalEvidence::new(
923 Principal::from_slice(&[1; 29]),
924 TerminalEvidenceDecision::Committed,
925 [8; 32],
926 );
927 let record = ReceiptBackedIntentRecord {
928 schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
929 operation_id,
930 payload_binding: PayloadBinding::new([9; 32]),
931 resource_key: IntentResourceKey::new("mint:collection"),
932 quantity: 11,
933 state: ReceiptBackedIntentState::Committed { evidence },
934 revision: 2,
935 created_at_ns: 13,
936 updated_at_ns: 17,
937 };
938 ReceiptBackedIntentStore::insert(record);
939 let records_data = ReceiptBackedIntentStore::export_records();
940
941 ReceiptBackedIntentStore::reset_for_tests();
942 assert_eq!(
943 ReceiptBackedIntentStore::export_records(),
944 ReceiptBackedIntentsData::default()
945 );
946
947 ReceiptBackedIntentStore::import_records(records_data.clone());
948 assert_eq!(ReceiptBackedIntentStore::len(), 1);
949 assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
950 IntentStore::reset_for_tests();
951 }
952}