1use crate::body::Signature;
17use crate::receipt::{
18 is_canonical_ms_utc, preimage_hash_of_object, verify_signature_over_hash_with, ReceiptSigner,
19};
20use acdp_primitives::error::AcdpError;
21use acdp_primitives::primitives::{ContentHash, CtxId};
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26pub const EVENT_RECEIPT_TYPE: &str = "acdp-event-receipt/1";
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct EventReceipt {
37 pub receipt_type: String,
39 pub registry_did: String,
41 pub ctx_id: CtxId,
43 pub event_id: String,
45 pub event_hash: String,
47 pub index: u64,
49 #[serde(with = "crate::receipt::ms_rfc3339")]
52 pub accepted_at: DateTime<Utc>,
53 pub signature: Signature,
55}
56
57impl EventReceipt {
58 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
64 let raw_accepted_at = value.get("accepted_at").and_then(|v| v.as_str());
65 if !raw_accepted_at.is_some_and(is_canonical_ms_utc) {
66 return Err(AcdpError::InvalidReceipt(
67 "event receipt accepted_at is missing or not canonical millisecond-precision \
68 RFC 3339 UTC (RFC-ACDP-0017 §10.1)"
69 .into(),
70 ));
71 }
72 let receipt = Self::deserialize(value)
73 .map_err(|e| AcdpError::InvalidReceipt(format!("event receipt does not parse: {e}")))?;
74 receipt.validate()?;
75 Ok(receipt)
76 }
77
78 pub fn validate(&self) -> Result<(), AcdpError> {
80 if self.receipt_type != EVENT_RECEIPT_TYPE {
81 return Err(AcdpError::InvalidReceipt(format!(
82 "event receipt receipt_type '{}' ≠ '{EVENT_RECEIPT_TYPE}'",
83 self.receipt_type
84 )));
85 }
86 CtxId::parse(self.ctx_id.as_str().to_string())
87 .map_err(|e| AcdpError::InvalidReceipt(format!("event receipt ctx_id: {e}")))?;
88 if !is_canonical_uuid(&self.event_id) {
89 return Err(AcdpError::InvalidReceipt(format!(
90 "event receipt event_id '{}' is not a canonical lowercase UUID",
91 self.event_id
92 )));
93 }
94 if !is_sha256_digest(&self.event_hash) {
95 return Err(AcdpError::InvalidReceipt(format!(
96 "event receipt event_hash '{}' is not sha256:<64 lowercase hex>",
97 self.event_hash
98 )));
99 }
100 match self.signature.key_id.split_once('#') {
101 Some((did, frag)) if did == self.registry_did && !frag.is_empty() => Ok(()),
102 _ => Err(AcdpError::InvalidReceipt(format!(
103 "event receipt signature.key_id '{}' is not a DID URL under registry_did '{}'",
104 self.signature.key_id, self.registry_did
105 ))),
106 }
107 }
108
109 pub fn event_hash_of_value(raw_event: &serde_json::Value) -> Result<String, AcdpError> {
113 let canonical = acdp_jcs::try_canonicalize_value(raw_event)?;
114 Ok(format!(
115 "sha256:{}",
116 hex::encode(Sha256::digest(&canonical))
117 ))
118 }
119
120 pub fn check_binding(
127 &self,
128 origin_registry: &str,
129 ctx_id: &CtxId,
130 raw_event: &serde_json::Value,
131 index: u64,
132 created_at: DateTime<Utc>,
133 ) -> Result<(), AcdpError> {
134 let fail = |m: String| Err(AcdpError::InvalidReceipt(m));
135 let expected_did = acdp_did::web::authority_to_did_web(origin_registry);
136 if self.registry_did != expected_did {
137 return fail(format!(
138 "event receipt registry_did '{}' ≠ '{expected_did}' (§10.3 step 2)",
139 self.registry_did
140 ));
141 }
142 if &self.ctx_id != ctx_id {
143 return fail("event receipt ctx_id ≠ the context's (§10.3 step 3)".into());
144 }
145 if raw_event.get("event_id").and_then(|v| v.as_str()) != Some(self.event_id.as_str()) {
146 return fail("event receipt event_id ≠ the event's (§10.3 step 4)".into());
147 }
148 if self.index != index {
149 return fail(format!(
150 "event receipt index {} ≠ position {index} (§10.3 step 5)",
151 self.index
152 ));
153 }
154 if Self::event_hash_of_value(raw_event)? != self.event_hash {
155 return fail("event receipt event_hash does not recompute (§10.3 step 6)".into());
156 }
157 if self.accepted_at < created_at {
158 return fail(
159 "event receipt accepted_at precedes the context's created_at (§10.3 step 9)".into(),
160 );
161 }
162 Ok(())
163 }
164
165 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
168 preimage_hash_of_object(value, "event receipt")
169 }
170
171 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
173 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
174 }
175
176 pub fn verify_signature_against_hash(
179 &self,
180 hash: &ContentHash,
181 registry_pub_ed25519: Option<&[u8; 32]>,
182 registry_pub_p256_sec1: Option<&[u8]>,
183 ) -> Result<(), AcdpError> {
184 verify_signature_over_hash_with(
185 &self.signature,
186 hash,
187 registry_pub_ed25519,
188 registry_pub_p256_sec1,
189 "event receipt",
190 AcdpError::InvalidReceipt,
191 )
192 }
193}
194
195impl ReceiptSigner {
196 pub fn mint_event_receipt(
205 &self,
206 ctx_id: &CtxId,
207 raw_event: &serde_json::Value,
208 index: u64,
209 accepted_at: DateTime<Utc>,
210 ) -> Result<EventReceipt, AcdpError> {
211 let event_id = raw_event
212 .get("event_id")
213 .and_then(|v| v.as_str())
214 .ok_or_else(|| {
215 AcdpError::SchemaViolation("lifecycle event has no string event_id".into())
216 })?
217 .to_string();
218 let mut receipt = EventReceipt {
219 receipt_type: EVENT_RECEIPT_TYPE.to_string(),
220 registry_did: self.registry_did().to_string(),
221 ctx_id: ctx_id.clone(),
222 event_id,
223 event_hash: EventReceipt::event_hash_of_value(raw_event)?,
224 index,
225 accepted_at: acdp_primitives::time::trunc_ms(accepted_at),
226 signature: Signature {
227 algorithm: self.signing_key().algorithm().into(),
228 key_id: self.key_id().to_string(),
229 value: String::new(),
230 },
231 };
232 receipt.validate()?;
233 let hash = receipt.preimage_hash()?;
234 let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
235 receipt.signature.algorithm = algorithm.into();
236 receipt.signature.value = value;
237 Ok(receipt)
238 }
239}
240
241fn is_canonical_uuid(s: &str) -> bool {
242 let b = s.as_bytes();
243 b.len() == 36
244 && b.iter().enumerate().all(|(i, &c)| match i {
245 8 | 13 | 18 | 23 => c == b'-',
246 _ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
247 })
248}
249
250fn is_sha256_digest(s: &str) -> bool {
251 s.strip_prefix("sha256:").is_some_and(|hex| {
252 hex.len() == 64
253 && hex
254 .bytes()
255 .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
256 })
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use acdp_crypto::SigningKey;
263
264 const CTX: &str = "acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
265
266 fn signer() -> ReceiptSigner {
267 ReceiptSigner::new(
268 SigningKey::from_bytes(&[0x11; 32]),
269 "did:web:registry.example.com",
270 "did:web:registry.example.com#receipt-key-1",
271 )
272 .unwrap()
273 }
274
275 fn event() -> serde_json::Value {
276 serde_json::json!({
277 "event_id": "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f",
278 "ctx_id": CTX,
279 "event_type": "retracted",
280 "occurred_at": "2026-10-03T00:00:00.000Z",
281 "actor": "did:web:registry.example.com"
282 })
283 }
284
285 fn accepted() -> DateTime<Utc> {
286 DateTime::parse_from_rfc3339("2026-10-03T00:00:01.000Z")
287 .unwrap()
288 .with_timezone(&Utc)
289 }
290
291 #[test]
292 fn mint_round_trips_and_verifies() {
293 let receipt = signer()
294 .mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
295 .unwrap();
296 let wire = serde_json::to_value(&receipt).unwrap();
297 assert_eq!(wire["accepted_at"], "2026-10-03T00:00:01.000Z");
298 let parsed = EventReceipt::from_value(&wire).unwrap();
299 assert_eq!(parsed, receipt);
300 let hash = EventReceipt::preimage_hash_of_value(&wire).unwrap();
301 let key = SigningKey::from_bytes(&[0x11; 32]).verifying_key_bytes();
302 parsed
303 .verify_signature_against_hash(&hash, Some(&key), None)
304 .unwrap();
305 assert_eq!(
306 parsed.event_hash,
307 EventReceipt::event_hash_of_value(&event()).unwrap()
308 );
309 }
310
311 #[test]
312 fn check_binding_covers_steps_2_to_6_and_9() {
313 let ctx = CtxId::parse(CTX).unwrap();
314 let created = DateTime::parse_from_rfc3339("2026-09-01T00:00:00.000Z")
315 .unwrap()
316 .with_timezone(&Utc);
317 let r = signer()
318 .mint_event_receipt(&ctx, &event(), 0, accepted())
319 .unwrap();
320 r.check_binding("registry.example.com", &ctx, &event(), 0, created)
321 .unwrap();
322 assert!(r
324 .check_binding("other.example.com", &ctx, &event(), 0, created)
325 .is_err());
326 let other =
328 CtxId::parse("acdp://registry.example.com/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
329 .unwrap();
330 assert!(r
331 .check_binding("registry.example.com", &other, &event(), 0, created)
332 .is_err());
333 assert!(r
335 .check_binding("registry.example.com", &ctx, &event(), 1, created)
336 .is_err());
337 let mut changed = event();
339 changed["occurred_at"] = "2026-10-03T00:00:00.001Z".into();
340 assert!(r
341 .check_binding("registry.example.com", &ctx, &changed, 0, created)
342 .is_err());
343 changed = event();
344 changed["event_id"] = "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e30".into();
345 assert!(r
346 .check_binding("registry.example.com", &ctx, &changed, 0, created)
347 .is_err());
348 let later = accepted() + chrono::Duration::days(1);
350 assert!(r
351 .check_binding("registry.example.com", &ctx, &event(), 0, later)
352 .is_err());
353 }
354
355 #[test]
356 fn registry_state_event_receipts_round_trip() {
357 use crate::body::RegistryState;
358 let r = signer()
359 .mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
360 .unwrap();
361 let mut state: RegistryState =
362 serde_json::from_value(serde_json::json!({"status": "active"})).unwrap();
363 assert!(state.event_receipts.is_none());
364 state.event_receipts = Some(vec![r.clone()]);
365 let wire = serde_json::to_value(&state).unwrap();
366 assert_eq!(
367 wire["event_receipts"][0]["receipt_type"],
368 EVENT_RECEIPT_TYPE
369 );
370 assert!(!wire.as_object().unwrap().contains_key("extensions"));
371 let back: RegistryState = serde_json::from_value(wire).unwrap();
372 assert_eq!(back.event_receipts, Some(vec![r]));
373 assert!(back.extensions.is_empty());
374 state.event_receipts = None;
376 assert!(serde_json::to_value(&state)
377 .unwrap()
378 .get("event_receipts")
379 .is_none());
380 for bad in [
382 serde_json::json!({"status": "active", "event_receipts": [{"x": 1}]}),
383 serde_json::json!({"status": "active", "event_receipts": {"x": 1}}),
384 serde_json::json!({"status": "active", "event_receipts": null}),
385 ] {
386 assert!(serde_json::from_value::<RegistryState>(bad).is_err());
387 }
388 }
389
390 #[test]
394 fn retraction_absent_events_from_lifecycle_registry_is_not_retracted() {
395 use crate::body::RegistryState;
396 let fresh: RegistryState =
397 serde_json::from_value(serde_json::json!({"status": "active"})).unwrap();
398 assert_eq!(fresh.retraction(true), Some(false));
399 assert_eq!(fresh.retraction(false), None);
400
401 let retracted_event = {
402 let mut e = event();
403 e["signature"] = serde_json::json!({
404 "algorithm": "ed25519",
405 "key_id": "did:web:registry.example.com#receipt-key-1",
406 "value": "AA=="
407 });
408 e
409 };
410 let state: RegistryState = serde_json::from_value(serde_json::json!({
411 "status": "active",
412 "lifecycle_events": [retracted_event]
413 }))
414 .unwrap();
415 assert_eq!(state.retraction(true), Some(true));
416 assert_eq!(state.retraction(false), Some(true));
417
418 let by_status: RegistryState =
419 serde_json::from_value(serde_json::json!({"status": "retracted"})).unwrap();
420 assert_eq!(by_status.retraction(false), Some(true));
421 }
422
423 #[test]
424 fn closed_schema_rejects_unknown_member_and_bad_forms() {
425 let receipt = signer()
426 .mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
427 .unwrap();
428 let wire = serde_json::to_value(&receipt).unwrap();
429
430 let mut extra = wire.clone();
431 extra["note"] = "x".into();
432 assert!(EventReceipt::from_value(&extra).is_err());
433
434 let mut short_ts = wire.clone();
435 short_ts["accepted_at"] = "2026-10-03T00:00:01Z".into();
436 assert!(EventReceipt::from_value(&short_ts).is_err());
437
438 let mut wrong_type = wire.clone();
439 wrong_type["receipt_type"] = "acdp-event-receipt/2".into();
440 assert!(EventReceipt::from_value(&wrong_type).is_err());
441
442 let mut foreign_key = wire;
443 foreign_key["signature"]["key_id"] = "did:web:evil.example.com#k".into();
444 assert!(EventReceipt::from_value(&foreign_key).is_err());
445 }
446}