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_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID, INTENT_TOTALS_ID,
19 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_RECORDS: RefCell<
56 StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
57 > = RefCell::new(
58 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_records.v1", ty = IntentRecord, id = INTENT_RECORDS_ID)),
59 );
60}
61
62eager_static! {
63 static INTENT_TOTALS: RefCell<
64 StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
65 > = RefCell::new(
66 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_totals.v1", ty = IntentResourceTotalsRecord, id = INTENT_TOTALS_ID)),
67 );
68}
69
70eager_static! {
71 static INTENT_PENDING: RefCell<
72 StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
73 > = RefCell::new(
74 StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_pending.v1", ty = IntentPendingEntryRecord, id = INTENT_PENDING_ID)),
75 );
76}
77
78impl Storable for IntentId {
79 const BOUND: Bound = Bound::Bounded {
80 max_size: 8,
81 is_fixed_size: true,
82 };
83
84 fn to_bytes(&self) -> Cow<'_, [u8]> {
85 Cow::Owned(self.0.to_be_bytes().to_vec())
86 }
87
88 fn into_bytes(self) -> Vec<u8> {
89 self.0.to_be_bytes().to_vec()
90 }
91
92 fn from_bytes(bytes: Cow<[u8]>) -> Self {
98 let bytes = <[u8; 8]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
99 panic!(
100 "stable IntentId is {} bytes; expected 8",
101 bytes.as_ref().len()
102 )
103 });
104
105 Self(u64::from_be_bytes(bytes))
106 }
107}
108
109impl Storable for OperationId {
110 const BOUND: Bound = Bound::Bounded {
111 max_size: 32,
112 is_fixed_size: true,
113 };
114
115 fn to_bytes(&self) -> Cow<'_, [u8]> {
116 Cow::Borrowed(self.as_bytes())
117 }
118
119 fn into_bytes(self) -> Vec<u8> {
120 self.as_bytes().to_vec()
121 }
122
123 fn from_bytes(bytes: Cow<[u8]>) -> Self {
129 let operation_id = <[u8; 32]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
130 panic!(
131 "stable OperationId is {} bytes; expected 32",
132 bytes.as_ref().len()
133 )
134 });
135 Self::from_bytes(operation_id)
136 }
137}
138
139#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
144pub enum IntentState {
145 Pending,
146 Committed,
147 Aborted,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct IntentRecord {
156 pub id: IntentId,
157 pub resource_key: IntentResourceKey,
158 pub quantity: u64,
159 pub state: IntentState,
160 pub created_at: u64,
161 pub ttl_secs: Option<u64>,
163}
164
165impl IntentRecord {
166 pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
167 pub const STORABLE_MAX_SIZE: u32 = 256;
168}
169
170impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
171
172#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
177pub struct IntentStoreMetaRecord {
178 pub schema_version: u32,
179 pub next_intent_id: IntentId,
180 pub pending_total: u64,
181 pub committed_total: u64,
182 pub aborted_total: u64,
183}
184
185impl IntentStoreMetaRecord {
186 pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
187 pub const STORABLE_MAX_SIZE: u32 = 96;
188}
189
190impl Default for IntentStoreMetaRecord {
191 fn default() -> Self {
192 Self {
193 schema_version: INTENT_STORE_SCHEMA_VERSION,
194 next_intent_id: IntentId(1),
195 pending_total: 0,
196 committed_total: 0,
197 aborted_total: 0,
198 }
199 }
200}
201
202impl_storable_bounded!(
203 IntentStoreMetaRecord,
204 IntentStoreMetaRecord::STORABLE_MAX_SIZE,
205 false
206);
207
208#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
213pub struct IntentResourceTotalsRecord {
214 pub reserved_qty: u64,
215 pub committed_qty: u64,
216 pub pending_count: u64,
217}
218
219impl IntentResourceTotalsRecord {
220 pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
221 pub const STORABLE_MAX_SIZE: u32 = 64;
222}
223
224impl_storable_bounded!(
225 IntentResourceTotalsRecord,
226 IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
227 false
228);
229
230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
235pub struct IntentPendingEntryRecord {
236 pub resource_key: IntentResourceKey,
237 pub quantity: u64,
238 pub created_at: u64,
239 pub ttl_secs: Option<u64>,
241}
242
243impl IntentPendingEntryRecord {
244 pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
245 pub const STORABLE_MAX_SIZE: u32 = 224;
246}
247
248impl_storable_bounded!(
249 IntentPendingEntryRecord,
250 IntentPendingEntryRecord::STORABLE_MAX_SIZE,
251 false
252);
253
254#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
256pub struct ReceiptBackedIntentRecord {
257 pub schema_version: u32,
258 pub operation_id: OperationId,
259 pub payload_binding: PayloadBinding,
260 pub resource_key: IntentResourceKey,
261 pub quantity: u64,
262 pub state: ReceiptBackedIntentState,
263 pub revision: u64,
264 pub created_at_ns: u64,
265 pub updated_at_ns: u64,
266}
267
268impl ReceiptBackedIntentRecord {
269 pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentRecord";
270 pub const STORABLE_MAX_SIZE: u32 = 1024;
271
272 #[must_use]
273 pub fn into_intent(self) -> ReceiptBackedIntent {
274 ReceiptBackedIntent {
275 schema_version: self.schema_version,
276 operation_id: self.operation_id,
277 payload_binding: self.payload_binding,
278 resource_key: self.resource_key,
279 quantity: self.quantity,
280 state: self.state,
281 revision: self.revision,
282 created_at_ns: self.created_at_ns,
283 updated_at_ns: self.updated_at_ns,
284 }
285 }
286}
287
288impl_storable_bounded!(
289 ReceiptBackedIntentRecord,
290 ReceiptBackedIntentRecord::STORABLE_MAX_SIZE,
291 false
292);
293
294#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
301pub struct IntentMetaData {
302 pub record: IntentStoreMetaRecord,
303}
304
305impl IntentMetaData {
306 pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
307}
308
309#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct IntentRecordEntryRecord {
317 pub intent_id: IntentId,
318 pub record: IntentRecord,
319}
320
321#[derive(Clone, Debug, Default, Eq, PartialEq)]
328pub struct IntentRecordsData {
329 pub entries: Vec<IntentRecordEntryRecord>,
330}
331
332impl IntentRecordsData {
333 pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
334}
335
336#[derive(Clone, Debug, Eq, PartialEq)]
343pub struct IntentTotalsEntryRecord {
344 pub resource_key: IntentResourceKey,
345 pub record: IntentResourceTotalsRecord,
346}
347
348#[derive(Clone, Debug, Default, Eq, PartialEq)]
355pub struct IntentTotalsData {
356 pub entries: Vec<IntentTotalsEntryRecord>,
357}
358
359impl IntentTotalsData {
360 pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
361}
362
363#[derive(Clone, Debug, Eq, PartialEq)]
370pub struct IntentPendingIndexEntryRecord {
371 pub intent_id: IntentId,
372 pub record: IntentPendingEntryRecord,
373}
374
375#[derive(Clone, Debug, Default, Eq, PartialEq)]
382pub struct IntentPendingData {
383 pub entries: Vec<IntentPendingIndexEntryRecord>,
384}
385
386impl IntentPendingData {
387 pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
388}
389
390#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct ReceiptBackedIntentEntryRecord {
393 pub operation_id: OperationId,
394 pub record: ReceiptBackedIntentRecord,
395}
396
397#[derive(Clone, Debug, Default, Eq, PartialEq)]
399pub struct ReceiptBackedIntentsData {
400 pub entries: Vec<ReceiptBackedIntentEntryRecord>,
401}
402
403impl ReceiptBackedIntentsData {
404 pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentsData";
405}
406
407pub struct IntentStore;
412
413impl IntentStore {
414 #[must_use]
419 pub(crate) fn meta() -> IntentStoreMetaRecord {
420 INTENT_META.with_borrow(|cell| *cell.get())
421 }
422
423 pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
424 INTENT_META.with_borrow_mut(|cell| cell.set(meta));
425 }
426
427 #[must_use]
432 pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
433 INTENT_RECORDS.with_borrow(|map| map.get(&id))
434 }
435
436 pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
437 INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
438 }
439
440 #[must_use]
445 pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
446 INTENT_TOTALS.with_borrow(|map| map.get(key))
447 }
448
449 pub(crate) fn set_totals(
450 key: IntentResourceKey,
451 totals: IntentResourceTotalsRecord,
452 ) -> Option<IntentResourceTotalsRecord> {
453 INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
454 }
455
456 #[must_use]
461 pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
462 INTENT_PENDING.with_borrow(|map| map.get(&id))
463 }
464
465 pub(crate) fn insert_pending(
466 id: IntentId,
467 entry: IntentPendingEntryRecord,
468 ) -> Option<IntentPendingEntryRecord> {
469 INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
470 }
471
472 pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
473 INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
474 }
475
476 pub(crate) fn with_pending_entries<R>(
477 f: impl FnOnce(
478 &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
479 ) -> R,
480 ) -> R {
481 INTENT_PENDING.with_borrow(|map| f(map))
482 }
483}
484
485pub struct ReceiptBackedIntentStore;
487
488impl ReceiptBackedIntentStore {
489 #[must_use]
490 pub(crate) fn len() -> u64 {
491 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
492 }
493
494 #[must_use]
495 pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
496 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
497 }
498
499 pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
500 RECEIPT_BACKED_INTENT_RECORDS
501 .with_borrow_mut(|records| records.insert(record.operation_id, record))
502 }
503
504 pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
505 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
506 }
507
508 pub(crate) fn with_records<R>(
509 f: impl FnOnce(
510 &StableBtreeMap<
511 OperationId,
512 ReceiptBackedIntentRecord,
513 VirtualMemory<DefaultMemoryImpl>,
514 >,
515 ) -> R,
516 ) -> R {
517 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
518 }
519}
520
521#[cfg(test)]
528impl IntentStore {
529 #[must_use]
530 pub(crate) fn export_meta() -> IntentMetaData {
531 IntentMetaData {
532 record: Self::meta(),
533 }
534 }
535
536 pub(crate) fn import_meta(data: IntentMetaData) {
537 Self::set_meta(data.record);
538 }
539
540 #[must_use]
541 pub(crate) fn export_records() -> IntentRecordsData {
542 IntentRecordsData {
543 entries: INTENT_RECORDS.with_borrow(|map| {
544 map.iter()
545 .map(|entry| IntentRecordEntryRecord {
546 intent_id: *entry.key(),
547 record: entry.value(),
548 })
549 .collect()
550 }),
551 }
552 }
553
554 pub(crate) fn import_records(data: IntentRecordsData) {
555 INTENT_RECORDS.with_borrow_mut(|map| {
556 map.clear_new();
557 for entry in data.entries {
558 map.insert(entry.intent_id, entry.record);
559 }
560 });
561 }
562
563 #[must_use]
564 pub(crate) fn export_totals() -> IntentTotalsData {
565 IntentTotalsData {
566 entries: INTENT_TOTALS.with_borrow(|map| {
567 map.iter()
568 .map(|entry| IntentTotalsEntryRecord {
569 resource_key: entry.key().clone(),
570 record: entry.value(),
571 })
572 .collect()
573 }),
574 }
575 }
576
577 pub(crate) fn import_totals(data: IntentTotalsData) {
578 INTENT_TOTALS.with_borrow_mut(|map| {
579 map.clear_new();
580 for entry in data.entries {
581 map.insert(entry.resource_key, entry.record);
582 }
583 });
584 }
585
586 #[must_use]
587 pub(crate) fn export_pending() -> IntentPendingData {
588 IntentPendingData {
589 entries: INTENT_PENDING.with_borrow(|map| {
590 map.iter()
591 .map(|entry| IntentPendingIndexEntryRecord {
592 intent_id: *entry.key(),
593 record: entry.value(),
594 })
595 .collect()
596 }),
597 }
598 }
599
600 pub(crate) fn import_pending(data: IntentPendingData) {
601 INTENT_PENDING.with_borrow_mut(|map| {
602 map.clear_new();
603 for entry in data.entries {
604 map.insert(entry.intent_id, entry.record);
605 }
606 });
607 }
608
609 pub(crate) fn reset_for_tests() {
610 INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
611 INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
612 INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
613 INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
614 ReceiptBackedIntentStore::reset_for_tests();
615 }
616}
617
618#[cfg(test)]
619impl ReceiptBackedIntentStore {
620 #[must_use]
621 pub(crate) fn export_records() -> ReceiptBackedIntentsData {
622 ReceiptBackedIntentsData {
623 entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
624 records
625 .iter()
626 .map(|entry| ReceiptBackedIntentEntryRecord {
627 operation_id: *entry.key(),
628 record: entry.value(),
629 })
630 .collect()
631 }),
632 }
633 }
634
635 pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
636 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
637 records.clear_new();
638 for entry in data.entries {
639 records.insert(entry.operation_id, entry.record);
640 }
641 });
642 }
643
644 pub(crate) fn reset_for_tests() {
645 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::{
653 cdk::types::Principal,
654 model::intent::{
655 RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
656 },
657 };
658
659 #[test]
660 #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
661 fn malformed_stable_intent_id_fails_closed() {
662 let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
663 }
664
665 #[test]
666 #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
667 fn malformed_stable_operation_id_fails_closed() {
668 let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
669 }
670
671 #[test]
672 fn intent_allocations_round_trip_through_canonical_data_snapshots() {
673 IntentStore::reset_for_tests();
674 let intent_id = IntentId(7);
675 let resource_key = IntentResourceKey::new("storage:uploads");
676 let record = IntentRecord {
677 id: intent_id,
678 resource_key: resource_key.clone(),
679 quantity: 11,
680 state: IntentState::Pending,
681 created_at: 13,
682 ttl_secs: Some(17),
683 };
684 let totals = IntentResourceTotalsRecord {
685 reserved_qty: 11,
686 committed_qty: 19,
687 pending_count: 1,
688 };
689 let pending = IntentPendingEntryRecord {
690 resource_key: resource_key.clone(),
691 quantity: 11,
692 created_at: 13,
693 ttl_secs: Some(17),
694 };
695 let meta = IntentStoreMetaRecord {
696 schema_version: INTENT_STORE_SCHEMA_VERSION,
697 next_intent_id: IntentId(8),
698 pending_total: 1,
699 committed_total: 2,
700 aborted_total: 3,
701 };
702
703 IntentStore::set_meta(meta);
704 IntentStore::insert_record(record);
705 IntentStore::set_totals(resource_key, totals);
706 IntentStore::insert_pending(intent_id, pending);
707
708 let meta_data = IntentStore::export_meta();
709 let records_data = IntentStore::export_records();
710 let totals_data = IntentStore::export_totals();
711 let pending_data = IntentStore::export_pending();
712
713 IntentStore::reset_for_tests();
714 IntentStore::import_meta(meta_data);
715 IntentStore::import_records(records_data.clone());
716 IntentStore::import_totals(totals_data.clone());
717 IntentStore::import_pending(pending_data.clone());
718
719 assert_eq!(IntentStore::export_meta(), meta_data);
720 assert_eq!(IntentStore::export_records(), records_data);
721 assert_eq!(IntentStore::export_totals(), totals_data);
722 assert_eq!(IntentStore::export_pending(), pending_data);
723 IntentStore::reset_for_tests();
724 }
725
726 #[test]
727 fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
728 IntentStore::reset_for_tests();
729 let operation_id = OperationId::from_bytes([7; 32]);
730 let evidence = TerminalEvidence::new(
731 Principal::from_slice(&[1; 29]),
732 TerminalEvidenceDecision::Committed,
733 [8; 32],
734 );
735 let record = ReceiptBackedIntentRecord {
736 schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
737 operation_id,
738 payload_binding: PayloadBinding::new([9; 32]),
739 resource_key: IntentResourceKey::new("mint:collection"),
740 quantity: 11,
741 state: ReceiptBackedIntentState::Committed { evidence },
742 revision: 2,
743 created_at_ns: 13,
744 updated_at_ns: 17,
745 };
746 ReceiptBackedIntentStore::insert(record);
747 let records_data = ReceiptBackedIntentStore::export_records();
748
749 ReceiptBackedIntentStore::reset_for_tests();
750 assert_eq!(
751 ReceiptBackedIntentStore::export_records(),
752 ReceiptBackedIntentsData::default()
753 );
754
755 ReceiptBackedIntentStore::import_records(records_data.clone());
756 assert_eq!(ReceiptBackedIntentStore::len(), 1);
757 assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
758 IntentStore::reset_for_tests();
759 }
760}