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