1use crate::{cdk::types::Principal, ids::CanisterRole};
7use std::{fmt, str::FromStr};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12pub const REPLAY_RECEIPT_SCHEMA_VERSION: u32 = 1;
13pub const REPLAY_PAYLOAD_HASH_SCHEMA_VERSION: u32 = 1;
14
15const REPLAY_PAYLOAD_HASH_DOMAIN: &[u8] = b"canic-replay-payload-hash:v1";
16
17#[derive(Clone, Copy, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
25pub struct OperationId([u8; 32]);
26
27impl OperationId {
28 #[must_use]
30 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
31 Self(bytes)
32 }
33
34 #[must_use]
36 pub const fn as_bytes(&self) -> &[u8; 32] {
37 &self.0
38 }
39
40 #[must_use]
42 pub const fn into_bytes(self) -> [u8; 32] {
43 self.0
44 }
45}
46
47impl fmt::Debug for OperationId {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 write!(f, "OperationId({self})")
50 }
51}
52
53impl fmt::Display for OperationId {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 for byte in self.0 {
56 write!(f, "{byte:02x}")?;
57 }
58 Ok(())
59 }
60}
61
62impl From<[u8; 32]> for OperationId {
63 fn from(value: [u8; 32]) -> Self {
64 Self::from_bytes(value)
65 }
66}
67
68impl From<OperationId> for [u8; 32] {
69 fn from(value: OperationId) -> Self {
70 value.into_bytes()
71 }
72}
73
74impl TryFrom<&[u8]> for OperationId {
75 type Error = OperationIdParseError;
76
77 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
78 let bytes: [u8; 32] =
79 value
80 .try_into()
81 .map_err(|_| OperationIdParseError::InvalidByteLength {
82 actual: value.len(),
83 })?;
84 Ok(Self(bytes))
85 }
86}
87
88impl FromStr for OperationId {
89 type Err = OperationIdParseError;
90
91 fn from_str(value: &str) -> Result<Self, Self::Err> {
92 if value.len() != 64 {
93 return Err(OperationIdParseError::InvalidHexLength {
94 actual: value.len(),
95 });
96 }
97
98 let mut bytes = [0u8; 32];
99 for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
100 let high = decode_hex_nibble(chunk[0])?;
101 let low = decode_hex_nibble(chunk[1])?;
102 bytes[index] = (high << 4) | low;
103 }
104 Ok(Self(bytes))
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
116pub enum OperationIdParseError {
117 InvalidByteLength { actual: usize },
118 InvalidHexLength { actual: usize },
119 InvalidHexCharacter { byte: u8 },
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
130pub struct CommandKind(String);
131
132impl CommandKind {
133 pub fn new(value: impl Into<String>) -> Result<Self, CommandKindError> {
135 let value = value.into();
136 if value.is_empty() {
137 return Err(CommandKindError::Empty);
138 }
139 if !value
140 .bytes()
141 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
142 {
143 return Err(CommandKindError::InvalidCharacter);
144 }
145 Ok(Self(value))
146 }
147
148 #[must_use]
150 pub fn as_str(&self) -> &str {
151 &self.0
152 }
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
163pub enum CommandKindError {
164 Empty,
165 InvalidCharacter,
166}
167
168#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub enum AuthKind {
177 DirectCaller,
178 DelegatedToken,
179 RoleAttestation,
180}
181
182#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
190pub struct ReplayActor {
191 pub effective_principal: Principal,
192 pub auth_kind: AuthKind,
193}
194
195impl ReplayActor {
196 #[must_use]
198 pub const fn direct_caller(caller: Principal) -> Self {
199 Self {
200 effective_principal: caller,
201 auth_kind: AuthKind::DirectCaller,
202 }
203 }
204}
205
206#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
214pub struct ReplayReceipt {
215 pub schema_version: u32,
216 pub command_kind: CommandKind,
217 pub operation_id: OperationId,
218 pub actor: ReplayActor,
219 pub payload_hash_schema_version: u32,
220 pub payload_hash: [u8; 32],
221 pub status: ReplayReceiptStatus,
222 pub created_at_ns: u64,
223 pub updated_at_ns: u64,
224 pub expires_at_ns: Option<u64>,
225 pub response_schema_version: Option<u32>,
226 pub response_bytes: Option<Vec<u8>>,
227 pub effect: Option<ExternalEffectDescriptor>,
228}
229
230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
238pub enum ReplayReceiptStatus {
239 Reserved,
240 ExternalEffectInFlight,
241 Committed,
242 RecoveryRequired { reason: RecoveryReason },
243}
244
245#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
253pub enum RecoveryReason {
254 ExternalEffectStatusUnknown,
255 ResponseCommitFailed,
256 Other(String),
257}
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267pub enum ExternalEffectDescriptor {
268 ManagementCreateCanister { command_kind: CommandKind },
269 ManagementCall { canister: Principal, method: String },
270 IcpTransfer { operation_id: OperationId },
271}
272
273pub struct ReplayPayloadHasher {
281 inner: Sha256,
282}
283
284impl ReplayPayloadHasher {
285 #[must_use]
287 pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
288 let mut inner = Sha256::new();
289 hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
290 hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
291 hash_str(&mut inner, command_kind.as_str());
292 hash_replay_actor(&mut inner, actor);
293 Self { inner }
294 }
295
296 pub fn hash_bool(&mut self, value: bool) {
298 hash_bool(&mut self.inner, value);
299 }
300
301 pub fn hash_u64(&mut self, value: u64) {
303 hash_u64(&mut self.inner, value);
304 }
305
306 pub fn hash_bytes(&mut self, value: &[u8]) {
308 hash_bytes(&mut self.inner, value);
309 }
310
311 pub fn hash_str(&mut self, value: &str) {
313 hash_str(&mut self.inner, value);
314 }
315
316 pub fn hash_principal(&mut self, value: &Principal) {
318 hash_principal(&mut self.inner, value);
319 }
320
321 pub fn hash_role(&mut self, value: &CanisterRole) {
323 hash_str(&mut self.inner, value.as_str());
324 }
325
326 #[must_use]
328 pub fn finish(self) -> [u8; 32] {
329 self.inner.finalize().into()
330 }
331}
332
333const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
334 match byte {
335 b'0'..=b'9' => Ok(byte - b'0'),
336 b'a'..=b'f' => Ok(byte - b'a' + 10),
337 b'A'..=b'F' => Ok(byte - b'A' + 10),
338 _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
339 }
340}
341
342fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
343 hash_principal(hasher, &actor.effective_principal);
344 hash_str(
345 hasher,
346 match actor.auth_kind {
347 AuthKind::DirectCaller => "DirectCaller",
348 AuthKind::DelegatedToken => "DelegatedToken",
349 AuthKind::RoleAttestation => "RoleAttestation",
350 },
351 );
352 hash_bool(hasher, false);
354}
355
356fn hash_bool(hasher: &mut Sha256, value: bool) {
357 hasher.update([u8::from(value)]);
358}
359
360fn hash_u32(hasher: &mut Sha256, value: u32) {
361 hasher.update(value.to_be_bytes());
362}
363
364fn hash_u64(hasher: &mut Sha256, value: u64) {
365 hasher.update(value.to_be_bytes());
366}
367
368fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
369 hasher.update((bytes.len() as u64).to_be_bytes());
370 hasher.update(bytes);
371}
372
373fn hash_str(hasher: &mut Sha256, value: &str) {
374 hash_bytes(hasher, value.as_bytes());
375}
376
377fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
378 hash_bytes(hasher, principal.as_slice());
379}
380
381#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn p(id: u8) -> Principal {
390 Principal::from_slice(&[id; 29])
391 }
392
393 #[test]
394 fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
395 let raw = [0xabu8; 32];
396 let id = OperationId::from_bytes(raw);
397 let text = id.to_string();
398
399 assert_eq!(text.len(), 64);
400 assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
401 assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
402 }
403
404 #[test]
405 fn operation_id_rejects_wrong_widths() {
406 assert!(matches!(
407 OperationId::try_from(&[1u8; 31][..]),
408 Err(OperationIdParseError::InvalidByteLength { actual: 31 })
409 ));
410 assert!(matches!(
411 "aa".parse::<OperationId>(),
412 Err(OperationIdParseError::InvalidHexLength { actual: 2 })
413 ));
414 }
415
416 #[test]
417 fn operation_id_rejects_invalid_hex() {
418 let text = format!("{}zz", "00".repeat(31));
419
420 assert!(matches!(
421 text.parse::<OperationId>(),
422 Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
423 ));
424 }
425
426 #[test]
427 fn command_kind_rejects_empty_and_space_values() {
428 assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
429 assert_eq!(
430 CommandKind::new("pool create"),
431 Err(CommandKindError::InvalidCharacter)
432 );
433 assert_eq!(
434 CommandKind::new("pool.create_empty.v1")
435 .expect("kind")
436 .as_str(),
437 "pool.create_empty.v1"
438 );
439 }
440
441 #[test]
442 fn payload_hash_binds_command_kind_actor_and_payload() {
443 let command = CommandKind::new("proof.issue.v1").expect("kind");
444 let actor = ReplayActor::direct_caller(p(1));
445
446 let mut first = ReplayPayloadHasher::new(&command, &actor);
447 first.hash_str("payload");
448 let first = first.finish();
449
450 let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
451 changed_payload.hash_str("other");
452 assert_ne!(first, changed_payload.finish());
453
454 let other_command = CommandKind::new("proof.issue.v2").expect("kind");
455 let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
456 changed_command.hash_str("payload");
457 assert_ne!(first, changed_command.finish());
458
459 let other_actor = ReplayActor::direct_caller(p(2));
460 let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
461 changed_actor.hash_str("payload");
462 assert_ne!(first, changed_actor.finish());
463 }
464}