1use crate::{
7 cdk::types::Principal,
8 ids::{CanisterRole, IntentId},
9};
10use std::{fmt, str::FromStr};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15pub const REPLAY_RECEIPT_SCHEMA_VERSION: u32 = 1;
16pub const REPLAY_PAYLOAD_HASH_SCHEMA_VERSION: u32 = 1;
17pub const PLACEMENT_CHILD_REPLAY_COMMAND_KIND: &str = "root.allocate_placement_child";
18pub const ROOT_PROVISION_REPLAY_COMMAND_KIND: &str = "root.provision";
19
20const REPLAY_PAYLOAD_HASH_DOMAIN: &[u8] = b"canic-replay-payload-hash:v1";
21
22#[derive(Clone, Copy, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
30pub struct OperationId([u8; 32]);
31
32impl OperationId {
33 #[must_use]
35 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
36 Self(bytes)
37 }
38
39 #[must_use]
41 pub const fn as_bytes(&self) -> &[u8; 32] {
42 &self.0
43 }
44
45 #[must_use]
47 pub const fn into_bytes(self) -> [u8; 32] {
48 self.0
49 }
50}
51
52impl fmt::Debug for OperationId {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(f, "OperationId({self})")
55 }
56}
57
58impl fmt::Display for OperationId {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 for byte in self.0 {
61 write!(f, "{byte:02x}")?;
62 }
63 Ok(())
64 }
65}
66
67impl From<[u8; 32]> for OperationId {
68 fn from(value: [u8; 32]) -> Self {
69 Self::from_bytes(value)
70 }
71}
72
73impl From<OperationId> for [u8; 32] {
74 fn from(value: OperationId) -> Self {
75 value.into_bytes()
76 }
77}
78
79impl TryFrom<&[u8]> for OperationId {
80 type Error = OperationIdParseError;
81
82 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
83 let bytes: [u8; 32] =
84 value
85 .try_into()
86 .map_err(|_| OperationIdParseError::InvalidByteLength {
87 actual: value.len(),
88 })?;
89 Ok(Self(bytes))
90 }
91}
92
93impl FromStr for OperationId {
94 type Err = OperationIdParseError;
95
96 fn from_str(value: &str) -> Result<Self, Self::Err> {
97 if value.len() != 64 {
98 return Err(OperationIdParseError::InvalidHexLength {
99 actual: value.len(),
100 });
101 }
102
103 let mut bytes = [0u8; 32];
104 for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
105 let high = decode_hex_nibble(chunk[0])?;
106 let low = decode_hex_nibble(chunk[1])?;
107 bytes[index] = (high << 4) | low;
108 }
109 Ok(Self(bytes))
110 }
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
121pub enum OperationIdParseError {
122 InvalidByteLength { actual: usize },
123 InvalidHexLength { actual: usize },
124 InvalidHexCharacter { byte: u8 },
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
135pub struct CommandKind(String);
136
137impl CommandKind {
138 pub fn new(value: impl Into<String>) -> Result<Self, CommandKindError> {
140 let value = value.into();
141 if value.is_empty() {
142 return Err(CommandKindError::Empty);
143 }
144 if !value
145 .bytes()
146 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
147 {
148 return Err(CommandKindError::InvalidCharacter);
149 }
150 Ok(Self(value))
151 }
152
153 #[must_use]
155 pub fn as_str(&self) -> &str {
156 &self.0
157 }
158}
159
160#[derive(Clone, Debug, Eq, PartialEq)]
168pub enum CommandKindError {
169 Empty,
170 InvalidCharacter,
171}
172
173#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
181pub enum AuthKind {
182 DirectCaller,
183 DelegatedToken,
184 RoleAttestation,
185}
186
187#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
195pub struct ReplayActor {
196 pub effective_principal: Principal,
197 pub auth_kind: AuthKind,
198}
199
200impl ReplayActor {
201 #[must_use]
203 pub const fn direct_caller(caller: Principal) -> Self {
204 Self {
205 effective_principal: caller,
206 auth_kind: AuthKind::DirectCaller,
207 }
208 }
209}
210
211#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
219pub struct ReplayReceipt {
220 pub schema_version: u32,
221 pub command_kind: CommandKind,
222 pub operation_id: OperationId,
223 pub actor: ReplayActor,
224 pub payload_hash_schema_version: u32,
225 pub payload_hash: [u8; 32],
226 pub status: ReplayReceiptStatus,
227 pub created_at_ns: u64,
228 pub updated_at_ns: u64,
229 pub expires_at_ns: Option<u64>,
230 pub response_schema_version: Option<u32>,
231 pub response_bytes: Option<Vec<u8>>,
232 pub staged_response_schema_version: Option<u32>,
233 pub staged_response_bytes: Option<Vec<u8>>,
234 pub cost_guard_settlement: Option<ReplayCostGuardSettlement>,
235 pub effect: Option<ExternalEffectDescriptor>,
236}
237
238#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
247pub struct ReplayCostGuardSettlement {
248 pub quota_intent_id: IntentId,
249 pub reservation_intent_id: IntentId,
250}
251
252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
260pub enum ReplayReceiptStatus {
261 Reserved,
262 ExternalEffectInFlight,
263 Committed,
264 RecoveryRequired { reason: RecoveryReason },
265}
266
267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
275pub enum RecoveryReason {
276 ExternalEffectStatusUnknown,
277 ResponseCommitFailed,
278 CostSettlementFailed,
279 StateProjectionFailed,
280 Other(String),
281}
282
283#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
291pub enum ExternalEffectDescriptor {
292 ManagementCreateCanister { command_kind: CommandKind },
293 ManagementCall { canister: Principal, method: String },
294 IcpTransfer { operation_id: OperationId },
295}
296
297#[must_use]
299pub fn placement_receipt_requires_acknowledgement(
300 status: &ReplayReceiptStatus,
301 effect: Option<&ExternalEffectDescriptor>,
302) -> bool {
303 *status == ReplayReceiptStatus::Committed
304 && matches!(
305 effect,
306 Some(ExternalEffectDescriptor::ManagementCreateCanister { command_kind })
307 if command_kind.as_str() == PLACEMENT_CHILD_REPLAY_COMMAND_KIND
308 )
309}
310
311pub struct ReplayPayloadHasher {
319 inner: Sha256,
320}
321
322impl ReplayPayloadHasher {
323 #[must_use]
325 pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
326 let mut inner = Sha256::new();
327 hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
328 hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
329 hash_str(&mut inner, command_kind.as_str());
330 hash_replay_actor(&mut inner, actor);
331 Self { inner }
332 }
333
334 pub fn hash_bool(&mut self, value: bool) {
336 hash_bool(&mut self.inner, value);
337 }
338
339 pub fn hash_u64(&mut self, value: u64) {
341 hash_u64(&mut self.inner, value);
342 }
343
344 pub fn hash_bytes(&mut self, value: &[u8]) {
346 hash_bytes(&mut self.inner, value);
347 }
348
349 pub fn hash_str(&mut self, value: &str) {
351 hash_str(&mut self.inner, value);
352 }
353
354 pub fn hash_principal(&mut self, value: &Principal) {
356 hash_principal(&mut self.inner, value);
357 }
358
359 pub fn hash_role(&mut self, value: &CanisterRole) {
361 hash_str(&mut self.inner, value.as_str());
362 }
363
364 #[must_use]
366 pub fn finish(self) -> [u8; 32] {
367 self.inner.finalize().into()
368 }
369}
370
371const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
372 match byte {
373 b'0'..=b'9' => Ok(byte - b'0'),
374 b'a'..=b'f' => Ok(byte - b'a' + 10),
375 b'A'..=b'F' => Ok(byte - b'A' + 10),
376 _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
377 }
378}
379
380fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
381 hash_principal(hasher, &actor.effective_principal);
382 hash_str(
383 hasher,
384 match actor.auth_kind {
385 AuthKind::DirectCaller => "DirectCaller",
386 AuthKind::DelegatedToken => "DelegatedToken",
387 AuthKind::RoleAttestation => "RoleAttestation",
388 },
389 );
390 hash_bool(hasher, false);
392}
393
394fn hash_bool(hasher: &mut Sha256, value: bool) {
395 hasher.update([u8::from(value)]);
396}
397
398fn hash_u32(hasher: &mut Sha256, value: u32) {
399 hasher.update(value.to_be_bytes());
400}
401
402fn hash_u64(hasher: &mut Sha256, value: u64) {
403 hasher.update(value.to_be_bytes());
404}
405
406fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
407 hasher.update((bytes.len() as u64).to_be_bytes());
408 hasher.update(bytes);
409}
410
411fn hash_str(hasher: &mut Sha256, value: &str) {
412 hash_bytes(hasher, value.as_bytes());
413}
414
415fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
416 hash_bytes(hasher, principal.as_slice());
417}
418
419#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn p(id: u8) -> Principal {
428 Principal::from_slice(&[id; 29])
429 }
430
431 #[test]
432 fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
433 let raw = [0xabu8; 32];
434 let id = OperationId::from_bytes(raw);
435 let text = id.to_string();
436
437 assert_eq!(text.len(), 64);
438 assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
439 assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
440 }
441
442 #[test]
443 fn operation_id_rejects_wrong_widths() {
444 assert!(matches!(
445 OperationId::try_from(&[1u8; 31][..]),
446 Err(OperationIdParseError::InvalidByteLength { actual: 31 })
447 ));
448 assert!(matches!(
449 "aa".parse::<OperationId>(),
450 Err(OperationIdParseError::InvalidHexLength { actual: 2 })
451 ));
452 }
453
454 #[test]
455 fn operation_id_rejects_invalid_hex() {
456 let text = format!("{}zz", "00".repeat(31));
457
458 assert!(matches!(
459 text.parse::<OperationId>(),
460 Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
461 ));
462 }
463
464 #[test]
465 fn command_kind_rejects_empty_and_space_values() {
466 assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
467 assert_eq!(
468 CommandKind::new("pool create"),
469 Err(CommandKindError::InvalidCharacter)
470 );
471 assert_eq!(
472 CommandKind::new("pool.create_empty.v1")
473 .expect("kind")
474 .as_str(),
475 "pool.create_empty.v1"
476 );
477 }
478
479 #[test]
480 fn payload_hash_binds_command_kind_actor_and_payload() {
481 let command = CommandKind::new("proof.issue.v1").expect("kind");
482 let actor = ReplayActor::direct_caller(p(1));
483
484 let mut first = ReplayPayloadHasher::new(&command, &actor);
485 first.hash_str("payload");
486 let first = first.finish();
487
488 let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
489 changed_payload.hash_str("other");
490 assert_ne!(first, changed_payload.finish());
491
492 let other_command = CommandKind::new("proof.issue.v2").expect("kind");
493 let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
494 changed_command.hash_str("payload");
495 assert_ne!(first, changed_command.finish());
496
497 let other_actor = ReplayActor::direct_caller(p(2));
498 let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
499 changed_actor.hash_str("payload");
500 assert_ne!(first, changed_actor.finish());
501 }
502}