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
495#[cfg(test)]
502impl IntentStore {
503 #[must_use]
504 pub(crate) fn export_meta() -> IntentMetaData {
505 IntentMetaData {
506 record: Self::meta(),
507 }
508 }
509
510 pub(crate) fn import_meta(data: IntentMetaData) {
511 Self::set_meta(data.record);
512 }
513
514 #[must_use]
515 pub(crate) fn export_records() -> IntentRecordsData {
516 IntentRecordsData {
517 entries: INTENT_RECORDS.with_borrow(|map| {
518 map.iter()
519 .map(|entry| IntentRecordEntryRecord {
520 intent_id: *entry.key(),
521 record: entry.value(),
522 })
523 .collect()
524 }),
525 }
526 }
527
528 pub(crate) fn import_records(data: IntentRecordsData) {
529 INTENT_RECORDS.with_borrow_mut(|map| {
530 map.clear_new();
531 for entry in data.entries {
532 map.insert(entry.intent_id, entry.record);
533 }
534 });
535 }
536
537 #[must_use]
538 pub(crate) fn export_totals() -> IntentTotalsData {
539 IntentTotalsData {
540 entries: INTENT_TOTALS.with_borrow(|map| {
541 map.iter()
542 .map(|entry| IntentTotalsEntryRecord {
543 resource_key: entry.key().clone(),
544 record: entry.value(),
545 })
546 .collect()
547 }),
548 }
549 }
550
551 pub(crate) fn import_totals(data: IntentTotalsData) {
552 INTENT_TOTALS.with_borrow_mut(|map| {
553 map.clear_new();
554 for entry in data.entries {
555 map.insert(entry.resource_key, entry.record);
556 }
557 });
558 }
559
560 #[must_use]
561 pub(crate) fn export_pending() -> IntentPendingData {
562 IntentPendingData {
563 entries: INTENT_PENDING.with_borrow(|map| {
564 map.iter()
565 .map(|entry| IntentPendingIndexEntryRecord {
566 intent_id: *entry.key(),
567 record: entry.value(),
568 })
569 .collect()
570 }),
571 }
572 }
573
574 pub(crate) fn import_pending(data: IntentPendingData) {
575 INTENT_PENDING.with_borrow_mut(|map| {
576 map.clear_new();
577 for entry in data.entries {
578 map.insert(entry.intent_id, entry.record);
579 }
580 });
581 }
582
583 pub(crate) fn reset_for_tests() {
584 INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
585 INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
586 INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
587 INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
588 ReceiptBackedIntentStore::reset_for_tests();
589 }
590}
591
592#[cfg(test)]
593impl ReceiptBackedIntentStore {
594 #[must_use]
595 pub(crate) fn export_records() -> ReceiptBackedIntentsData {
596 ReceiptBackedIntentsData {
597 entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
598 records
599 .iter()
600 .map(|entry| ReceiptBackedIntentEntryRecord {
601 operation_id: *entry.key(),
602 record: entry.value(),
603 })
604 .collect()
605 }),
606 }
607 }
608
609 pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
610 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
611 records.clear_new();
612 for entry in data.entries {
613 records.insert(entry.operation_id, entry.record);
614 }
615 });
616 }
617
618 pub(crate) fn reset_for_tests() {
619 RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::{
627 cdk::types::Principal,
628 model::intent::{
629 RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
630 },
631 };
632
633 #[test]
634 fn intent_allocations_round_trip_through_canonical_data_snapshots() {
635 IntentStore::reset_for_tests();
636 let intent_id = IntentId(7);
637 let resource_key = IntentResourceKey::new("storage:uploads");
638 let record = IntentRecord {
639 id: intent_id,
640 resource_key: resource_key.clone(),
641 quantity: 11,
642 state: IntentState::Pending,
643 created_at: 13,
644 ttl_secs: Some(17),
645 };
646 let totals = IntentResourceTotalsRecord {
647 reserved_qty: 11,
648 committed_qty: 19,
649 pending_count: 1,
650 };
651 let pending = IntentPendingEntryRecord {
652 resource_key: resource_key.clone(),
653 quantity: 11,
654 created_at: 13,
655 ttl_secs: Some(17),
656 };
657 let meta = IntentStoreMetaRecord {
658 schema_version: INTENT_STORE_SCHEMA_VERSION,
659 next_intent_id: IntentId(8),
660 pending_total: 1,
661 committed_total: 2,
662 aborted_total: 3,
663 };
664
665 IntentStore::set_meta(meta);
666 IntentStore::insert_record(record);
667 IntentStore::set_totals(resource_key, totals);
668 IntentStore::insert_pending(intent_id, pending);
669
670 let meta_data = IntentStore::export_meta();
671 let records_data = IntentStore::export_records();
672 let totals_data = IntentStore::export_totals();
673 let pending_data = IntentStore::export_pending();
674
675 IntentStore::reset_for_tests();
676 IntentStore::import_meta(meta_data);
677 IntentStore::import_records(records_data.clone());
678 IntentStore::import_totals(totals_data.clone());
679 IntentStore::import_pending(pending_data.clone());
680
681 assert_eq!(IntentStore::export_meta(), meta_data);
682 assert_eq!(IntentStore::export_records(), records_data);
683 assert_eq!(IntentStore::export_totals(), totals_data);
684 assert_eq!(IntentStore::export_pending(), pending_data);
685 IntentStore::reset_for_tests();
686 }
687
688 #[test]
689 fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
690 IntentStore::reset_for_tests();
691 let operation_id = OperationId::from_bytes([7; 32]);
692 let evidence = TerminalEvidence::new(
693 Principal::from_slice(&[1; 29]),
694 TerminalEvidenceDecision::Committed,
695 [8; 32],
696 );
697 let record = ReceiptBackedIntentRecord {
698 schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
699 operation_id,
700 payload_binding: PayloadBinding::new([9; 32]),
701 resource_key: IntentResourceKey::new("mint:collection"),
702 quantity: 11,
703 state: ReceiptBackedIntentState::Committed { evidence },
704 revision: 2,
705 created_at_ns: 13,
706 updated_at_ns: 17,
707 };
708 ReceiptBackedIntentStore::insert(record);
709 let records_data = ReceiptBackedIntentStore::export_records();
710
711 ReceiptBackedIntentStore::reset_for_tests();
712 assert_eq!(
713 ReceiptBackedIntentStore::export_records(),
714 ReceiptBackedIntentsData::default()
715 );
716
717 ReceiptBackedIntentStore::import_records(records_data.clone());
718 assert_eq!(ReceiptBackedIntentStore::len(), 1);
719 assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
720 IntentStore::reset_for_tests();
721 }
722}