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 {
93 let b = bytes.as_ref();
94
95 if b.len() != 8 {
96 return Self::default();
97 }
98
99 let mut arr = [0u8; 8];
100 arr.copy_from_slice(b);
101
102 Self(u64::from_be_bytes(arr))
103 }
104}
105
106impl Storable for OperationId {
107 const BOUND: Bound = Bound::Bounded {
108 max_size: 32,
109 is_fixed_size: true,
110 };
111
112 fn to_bytes(&self) -> Cow<'_, [u8]> {
113 Cow::Borrowed(self.as_bytes())
114 }
115
116 fn into_bytes(self) -> Vec<u8> {
117 self.as_bytes().to_vec()
118 }
119
120 fn from_bytes(bytes: Cow<[u8]>) -> Self {
121 let mut operation_id = [0_u8; 32];
122 if bytes.len() == operation_id.len() {
123 operation_id.copy_from_slice(bytes.as_ref());
124 }
125 Self::from_bytes(operation_id)
126 }
127}
128
129#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
134pub enum IntentState {
135 Pending,
136 Committed,
137 Aborted,
138}
139
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
145pub struct IntentRecord {
146 pub id: IntentId,
147 pub resource_key: IntentResourceKey,
148 pub quantity: u64,
149 pub state: IntentState,
150 pub created_at: u64,
151 pub ttl_secs: Option<u64>,
153}
154
155impl IntentRecord {
156 pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
157 pub const STORABLE_MAX_SIZE: u32 = 256;
158}
159
160impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
161
162#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
167pub struct IntentStoreMetaRecord {
168 pub schema_version: u32,
169 pub next_intent_id: IntentId,
170 pub pending_total: u64,
171 pub committed_total: u64,
172 pub aborted_total: u64,
173}
174
175impl IntentStoreMetaRecord {
176 pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
177 pub const STORABLE_MAX_SIZE: u32 = 96;
178}
179
180impl Default for IntentStoreMetaRecord {
181 fn default() -> Self {
182 Self {
183 schema_version: INTENT_STORE_SCHEMA_VERSION,
184 next_intent_id: IntentId(1),
185 pending_total: 0,
186 committed_total: 0,
187 aborted_total: 0,
188 }
189 }
190}
191
192impl_storable_bounded!(
193 IntentStoreMetaRecord,
194 IntentStoreMetaRecord::STORABLE_MAX_SIZE,
195 false
196);
197
198#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
203pub struct IntentResourceTotalsRecord {
204 pub reserved_qty: u64,
205 pub committed_qty: u64,
206 pub pending_count: u64,
207}
208
209impl IntentResourceTotalsRecord {
210 pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
211 pub const STORABLE_MAX_SIZE: u32 = 64;
212}
213
214impl_storable_bounded!(
215 IntentResourceTotalsRecord,
216 IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
217 false
218);
219
220#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
225pub struct IntentPendingEntryRecord {
226 pub resource_key: IntentResourceKey,
227 pub quantity: u64,
228 pub created_at: u64,
229 pub ttl_secs: Option<u64>,
231}
232
233impl IntentPendingEntryRecord {
234 pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
235 pub const STORABLE_MAX_SIZE: u32 = 224;
236}
237
238impl_storable_bounded!(
239 IntentPendingEntryRecord,
240 IntentPendingEntryRecord::STORABLE_MAX_SIZE,
241 false
242);
243
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
246pub struct ReceiptBackedIntentRecord {
247 pub schema_version: u32,
248 pub operation_id: OperationId,
249 pub payload_binding: PayloadBinding,
250 pub resource_key: IntentResourceKey,
251 pub quantity: u64,
252 pub state: ReceiptBackedIntentState,
253 pub revision: u64,
254 pub created_at_ns: u64,
255 pub updated_at_ns: u64,
256}
257
258impl ReceiptBackedIntentRecord {
259 pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentRecord";
260 pub const STORABLE_MAX_SIZE: u32 = 1024;
261
262 #[must_use]
263 pub fn into_intent(self) -> ReceiptBackedIntent {
264 ReceiptBackedIntent {
265 schema_version: self.schema_version,
266 operation_id: self.operation_id,
267 payload_binding: self.payload_binding,
268 resource_key: self.resource_key,
269 quantity: self.quantity,
270 state: self.state,
271 revision: self.revision,
272 created_at_ns: self.created_at_ns,
273 updated_at_ns: self.updated_at_ns,
274 }
275 }
276}
277
278impl_storable_bounded!(
279 ReceiptBackedIntentRecord,
280 ReceiptBackedIntentRecord::STORABLE_MAX_SIZE,
281 false
282);
283
284#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
291pub struct IntentMetaData {
292 pub record: IntentStoreMetaRecord,
293}
294
295impl IntentMetaData {
296 pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
297}
298
299#[derive(Clone, Debug, Eq, PartialEq)]
306pub struct IntentRecordEntryRecord {
307 pub intent_id: IntentId,
308 pub record: IntentRecord,
309}
310
311#[derive(Clone, Debug, Default, Eq, PartialEq)]
318pub struct IntentRecordsData {
319 pub entries: Vec<IntentRecordEntryRecord>,
320}
321
322impl IntentRecordsData {
323 pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
324}
325
326#[derive(Clone, Debug, Eq, PartialEq)]
333pub struct IntentTotalsEntryRecord {
334 pub resource_key: IntentResourceKey,
335 pub record: IntentResourceTotalsRecord,
336}
337
338#[derive(Clone, Debug, Default, Eq, PartialEq)]
345pub struct IntentTotalsData {
346 pub entries: Vec<IntentTotalsEntryRecord>,
347}
348
349impl IntentTotalsData {
350 pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
351}
352
353#[derive(Clone, Debug, Eq, PartialEq)]
360pub struct IntentPendingIndexEntryRecord {
361 pub intent_id: IntentId,
362 pub record: IntentPendingEntryRecord,
363}
364
365#[derive(Clone, Debug, Default, Eq, PartialEq)]
372pub struct IntentPendingData {
373 pub entries: Vec<IntentPendingIndexEntryRecord>,
374}
375
376impl IntentPendingData {
377 pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
378}
379
380#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct ReceiptBackedIntentEntryRecord {
383 pub operation_id: OperationId,
384 pub record: ReceiptBackedIntentRecord,
385}
386
387#[derive(Clone, Debug, Default, Eq, PartialEq)]
389pub struct ReceiptBackedIntentsData {
390 pub entries: Vec<ReceiptBackedIntentEntryRecord>,
391}
392
393impl ReceiptBackedIntentsData {
394 pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentsData";
395}
396
397pub struct IntentStore;
402
403impl IntentStore {
404 #[must_use]
409 pub(crate) fn meta() -> IntentStoreMetaRecord {
410 INTENT_META.with_borrow(|cell| *cell.get())
411 }
412
413 pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
414 INTENT_META.with_borrow_mut(|cell| cell.set(meta));
415 }
416
417 #[must_use]
422 pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
423 INTENT_RECORDS.with_borrow(|map| map.get(&id))
424 }
425
426 pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
427 INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
428 }
429
430 #[must_use]
435 pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
436 INTENT_TOTALS.with_borrow(|map| map.get(key))
437 }
438
439 pub(crate) fn set_totals(
440 key: IntentResourceKey,
441 totals: IntentResourceTotalsRecord,
442 ) -> Option<IntentResourceTotalsRecord> {
443 INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
444 }
445
446 #[must_use]
451 pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
452 INTENT_PENDING.with_borrow(|map| map.get(&id))
453 }
454
455 pub(crate) fn insert_pending(
456 id: IntentId,
457 entry: IntentPendingEntryRecord,
458 ) -> Option<IntentPendingEntryRecord> {
459 INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
460 }
461
462 pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
463 INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
464 }
465
466 pub(crate) fn with_pending_entries<R>(
467 f: impl FnOnce(
468 &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
469 ) -> R,
470 ) -> R {
471 INTENT_PENDING.with_borrow(|map| f(map))
472 }
473}
474
475pub struct ReceiptBackedIntentStore;
477
478impl ReceiptBackedIntentStore {
479 #[must_use]
480 pub(crate) fn len() -> u64 {
481 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
482 }
483
484 #[must_use]
485 pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
486 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
487 }
488
489 pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
490 RECEIPT_BACKED_INTENT_RECORDS
491 .with_borrow_mut(|records| records.insert(record.operation_id, record))
492 }
493
494 pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
495 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
496 }
497
498 pub(crate) fn with_records<R>(
499 f: impl FnOnce(
500 &StableBtreeMap<
501 OperationId,
502 ReceiptBackedIntentRecord,
503 VirtualMemory<DefaultMemoryImpl>,
504 >,
505 ) -> R,
506 ) -> R {
507 RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
508 }
509}
510
511#[cfg(test)]
518impl IntentStore {
519 #[must_use]
520 pub(crate) fn export_meta() -> IntentMetaData {
521 IntentMetaData {
522 record: Self::meta(),
523 }
524 }
525
526 pub(crate) fn import_meta(data: IntentMetaData) {
527 Self::set_meta(data.record);
528 }
529
530 #[must_use]
531 pub(crate) fn export_records() -> IntentRecordsData {
532 IntentRecordsData {
533 entries: INTENT_RECORDS.with_borrow(|map| {
534 map.iter()
535 .map(|entry| IntentRecordEntryRecord {
536 intent_id: *entry.key(),
537 record: entry.value(),
538 })
539 .collect()
540 }),
541 }
542 }
543
544 pub(crate) fn import_records(data: IntentRecordsData) {
545 INTENT_RECORDS.with_borrow_mut(|map| {
546 map.clear_new();
547 for entry in data.entries {
548 map.insert(entry.intent_id, entry.record);
549 }
550 });
551 }
552
553 #[must_use]
554 pub(crate) fn export_totals() -> IntentTotalsData {
555 IntentTotalsData {
556 entries: INTENT_TOTALS.with_borrow(|map| {
557 map.iter()
558 .map(|entry| IntentTotalsEntryRecord {
559 resource_key: entry.key().clone(),
560 record: entry.value(),
561 })
562 .collect()
563 }),
564 }
565 }
566
567 pub(crate) fn import_totals(data: IntentTotalsData) {
568 INTENT_TOTALS.with_borrow_mut(|map| {
569 map.clear_new();
570 for entry in data.entries {
571 map.insert(entry.resource_key, entry.record);
572 }
573 });
574 }
575
576 #[must_use]
577 pub(crate) fn export_pending() -> IntentPendingData {
578 IntentPendingData {
579 entries: INTENT_PENDING.with_borrow(|map| {
580 map.iter()
581 .map(|entry| IntentPendingIndexEntryRecord {
582 intent_id: *entry.key(),
583 record: entry.value(),
584 })
585 .collect()
586 }),
587 }
588 }
589
590 pub(crate) fn import_pending(data: IntentPendingData) {
591 INTENT_PENDING.with_borrow_mut(|map| {
592 map.clear_new();
593 for entry in data.entries {
594 map.insert(entry.intent_id, entry.record);
595 }
596 });
597 }
598
599 pub(crate) fn reset_for_tests() {
600 INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
601 INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
602 INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
603 INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
604 ReceiptBackedIntentStore::reset_for_tests();
605 }
606}
607
608#[cfg(test)]
609impl ReceiptBackedIntentStore {
610 #[must_use]
611 pub(crate) fn export_records() -> ReceiptBackedIntentsData {
612 ReceiptBackedIntentsData {
613 entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
614 records
615 .iter()
616 .map(|entry| ReceiptBackedIntentEntryRecord {
617 operation_id: *entry.key(),
618 record: entry.value(),
619 })
620 .collect()
621 }),
622 }
623 }
624
625 pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
626 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
627 records.clear_new();
628 for entry in data.entries {
629 records.insert(entry.operation_id, entry.record);
630 }
631 });
632 }
633
634 pub(crate) fn reset_for_tests() {
635 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::{
643 cdk::types::Principal,
644 model::intent::{
645 RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
646 },
647 };
648
649 #[test]
650 fn intent_allocations_round_trip_through_canonical_data_snapshots() {
651 IntentStore::reset_for_tests();
652 let intent_id = IntentId(7);
653 let resource_key = IntentResourceKey::new("storage:uploads");
654 let record = IntentRecord {
655 id: intent_id,
656 resource_key: resource_key.clone(),
657 quantity: 11,
658 state: IntentState::Pending,
659 created_at: 13,
660 ttl_secs: Some(17),
661 };
662 let totals = IntentResourceTotalsRecord {
663 reserved_qty: 11,
664 committed_qty: 19,
665 pending_count: 1,
666 };
667 let pending = IntentPendingEntryRecord {
668 resource_key: resource_key.clone(),
669 quantity: 11,
670 created_at: 13,
671 ttl_secs: Some(17),
672 };
673 let meta = IntentStoreMetaRecord {
674 schema_version: INTENT_STORE_SCHEMA_VERSION,
675 next_intent_id: IntentId(8),
676 pending_total: 1,
677 committed_total: 2,
678 aborted_total: 3,
679 };
680
681 IntentStore::set_meta(meta);
682 IntentStore::insert_record(record);
683 IntentStore::set_totals(resource_key, totals);
684 IntentStore::insert_pending(intent_id, pending);
685
686 let meta_data = IntentStore::export_meta();
687 let records_data = IntentStore::export_records();
688 let totals_data = IntentStore::export_totals();
689 let pending_data = IntentStore::export_pending();
690
691 IntentStore::reset_for_tests();
692 IntentStore::import_meta(meta_data);
693 IntentStore::import_records(records_data.clone());
694 IntentStore::import_totals(totals_data.clone());
695 IntentStore::import_pending(pending_data.clone());
696
697 assert_eq!(IntentStore::export_meta(), meta_data);
698 assert_eq!(IntentStore::export_records(), records_data);
699 assert_eq!(IntentStore::export_totals(), totals_data);
700 assert_eq!(IntentStore::export_pending(), pending_data);
701 IntentStore::reset_for_tests();
702 }
703
704 #[test]
705 fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
706 IntentStore::reset_for_tests();
707 let operation_id = OperationId::from_bytes([7; 32]);
708 let evidence = TerminalEvidence::new(
709 Principal::from_slice(&[1; 29]),
710 TerminalEvidenceDecision::Committed,
711 [8; 32],
712 );
713 let record = ReceiptBackedIntentRecord {
714 schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
715 operation_id,
716 payload_binding: PayloadBinding::new([9; 32]),
717 resource_key: IntentResourceKey::new("mint:collection"),
718 quantity: 11,
719 state: ReceiptBackedIntentState::Committed { evidence },
720 revision: 2,
721 created_at_ns: 13,
722 updated_at_ns: 17,
723 };
724 ReceiptBackedIntentStore::insert(record);
725 let records_data = ReceiptBackedIntentStore::export_records();
726
727 ReceiptBackedIntentStore::reset_for_tests();
728 assert_eq!(
729 ReceiptBackedIntentStore::export_records(),
730 ReceiptBackedIntentsData::default()
731 );
732
733 ReceiptBackedIntentStore::import_records(records_data.clone());
734 assert_eq!(ReceiptBackedIntentStore::len(), 1);
735 assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
736 IntentStore::reset_for_tests();
737 }
738}