use crate::body::Signature;
use crate::receipt::{
is_canonical_ms_utc, preimage_hash_of_object, verify_signature_over_hash_with, ReceiptSigner,
};
use acdp_primitives::error::AcdpError;
use acdp_primitives::primitives::{ContentHash, CtxId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const EVENT_RECEIPT_TYPE: &str = "acdp-event-receipt/1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventReceipt {
pub receipt_type: String,
pub registry_did: String,
pub ctx_id: CtxId,
pub event_id: String,
pub event_hash: String,
pub index: u64,
#[serde(with = "crate::receipt::ms_rfc3339")]
pub accepted_at: DateTime<Utc>,
pub signature: Signature,
}
impl EventReceipt {
pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
let raw_accepted_at = value.get("accepted_at").and_then(|v| v.as_str());
if !raw_accepted_at.is_some_and(is_canonical_ms_utc) {
return Err(AcdpError::InvalidReceipt(
"event receipt accepted_at is missing or not canonical millisecond-precision \
RFC 3339 UTC (RFC-ACDP-0017 §10.1)"
.into(),
));
}
let receipt = Self::deserialize(value)
.map_err(|e| AcdpError::InvalidReceipt(format!("event receipt does not parse: {e}")))?;
receipt.validate()?;
Ok(receipt)
}
pub fn validate(&self) -> Result<(), AcdpError> {
if self.receipt_type != EVENT_RECEIPT_TYPE {
return Err(AcdpError::InvalidReceipt(format!(
"event receipt receipt_type '{}' ≠ '{EVENT_RECEIPT_TYPE}'",
self.receipt_type
)));
}
CtxId::parse(self.ctx_id.as_str().to_string())
.map_err(|e| AcdpError::InvalidReceipt(format!("event receipt ctx_id: {e}")))?;
if !is_canonical_uuid(&self.event_id) {
return Err(AcdpError::InvalidReceipt(format!(
"event receipt event_id '{}' is not a canonical lowercase UUID",
self.event_id
)));
}
if !is_sha256_digest(&self.event_hash) {
return Err(AcdpError::InvalidReceipt(format!(
"event receipt event_hash '{}' is not sha256:<64 lowercase hex>",
self.event_hash
)));
}
match self.signature.key_id.split_once('#') {
Some((did, frag)) if did == self.registry_did && !frag.is_empty() => Ok(()),
_ => Err(AcdpError::InvalidReceipt(format!(
"event receipt signature.key_id '{}' is not a DID URL under registry_did '{}'",
self.signature.key_id, self.registry_did
))),
}
}
pub fn event_hash_of_value(raw_event: &serde_json::Value) -> Result<String, AcdpError> {
let canonical = acdp_jcs::try_canonicalize_value(raw_event)?;
Ok(format!(
"sha256:{}",
hex::encode(Sha256::digest(&canonical))
))
}
pub fn check_binding(
&self,
origin_registry: &str,
ctx_id: &CtxId,
raw_event: &serde_json::Value,
index: u64,
created_at: DateTime<Utc>,
) -> Result<(), AcdpError> {
let fail = |m: String| Err(AcdpError::InvalidReceipt(m));
let expected_did = acdp_did::web::authority_to_did_web(origin_registry);
if self.registry_did != expected_did {
return fail(format!(
"event receipt registry_did '{}' ≠ '{expected_did}' (§10.3 step 2)",
self.registry_did
));
}
if &self.ctx_id != ctx_id {
return fail("event receipt ctx_id ≠ the context's (§10.3 step 3)".into());
}
if raw_event.get("event_id").and_then(|v| v.as_str()) != Some(self.event_id.as_str()) {
return fail("event receipt event_id ≠ the event's (§10.3 step 4)".into());
}
if self.index != index {
return fail(format!(
"event receipt index {} ≠ position {index} (§10.3 step 5)",
self.index
));
}
if Self::event_hash_of_value(raw_event)? != self.event_hash {
return fail("event receipt event_hash does not recompute (§10.3 step 6)".into());
}
if self.accepted_at < created_at {
return fail(
"event receipt accepted_at precedes the context's created_at (§10.3 step 9)".into(),
);
}
Ok(())
}
pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
preimage_hash_of_object(value, "event receipt")
}
pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
Self::preimage_hash_of_value(&serde_json::to_value(self)?)
}
pub fn verify_signature_against_hash(
&self,
hash: &ContentHash,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
verify_signature_over_hash_with(
&self.signature,
hash,
registry_pub_ed25519,
registry_pub_p256_sec1,
"event receipt",
AcdpError::InvalidReceipt,
)
}
}
impl ReceiptSigner {
pub fn mint_event_receipt(
&self,
ctx_id: &CtxId,
raw_event: &serde_json::Value,
index: u64,
accepted_at: DateTime<Utc>,
) -> Result<EventReceipt, AcdpError> {
let event_id = raw_event
.get("event_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AcdpError::SchemaViolation("lifecycle event has no string event_id".into())
})?
.to_string();
let mut receipt = EventReceipt {
receipt_type: EVENT_RECEIPT_TYPE.to_string(),
registry_did: self.registry_did().to_string(),
ctx_id: ctx_id.clone(),
event_id,
event_hash: EventReceipt::event_hash_of_value(raw_event)?,
index,
accepted_at: acdp_primitives::time::trunc_ms(accepted_at),
signature: Signature {
algorithm: self.signing_key().algorithm().into(),
key_id: self.key_id().to_string(),
value: String::new(),
},
};
receipt.validate()?;
let hash = receipt.preimage_hash()?;
let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
receipt.signature.algorithm = algorithm.into();
receipt.signature.value = value;
Ok(receipt)
}
}
fn is_canonical_uuid(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 36
&& b.iter().enumerate().all(|(i, &c)| match i {
8 | 13 | 18 | 23 => c == b'-',
_ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
})
}
fn is_sha256_digest(s: &str) -> bool {
s.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
})
}
#[cfg(test)]
mod tests {
use super::*;
use acdp_crypto::SigningKey;
const CTX: &str = "acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
fn signer() -> ReceiptSigner {
ReceiptSigner::new(
SigningKey::from_bytes(&[0x11; 32]),
"did:web:registry.example.com",
"did:web:registry.example.com#receipt-key-1",
)
.unwrap()
}
fn event() -> serde_json::Value {
serde_json::json!({
"event_id": "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f",
"ctx_id": CTX,
"event_type": "retracted",
"occurred_at": "2026-10-03T00:00:00.000Z",
"actor": "did:web:registry.example.com"
})
}
fn accepted() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-10-03T00:00:01.000Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn mint_round_trips_and_verifies() {
let receipt = signer()
.mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
.unwrap();
let wire = serde_json::to_value(&receipt).unwrap();
assert_eq!(wire["accepted_at"], "2026-10-03T00:00:01.000Z");
let parsed = EventReceipt::from_value(&wire).unwrap();
assert_eq!(parsed, receipt);
let hash = EventReceipt::preimage_hash_of_value(&wire).unwrap();
let key = SigningKey::from_bytes(&[0x11; 32]).verifying_key_bytes();
parsed
.verify_signature_against_hash(&hash, Some(&key), None)
.unwrap();
assert_eq!(
parsed.event_hash,
EventReceipt::event_hash_of_value(&event()).unwrap()
);
}
#[test]
fn check_binding_covers_steps_2_to_6_and_9() {
let ctx = CtxId::parse(CTX).unwrap();
let created = DateTime::parse_from_rfc3339("2026-09-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc);
let r = signer()
.mint_event_receipt(&ctx, &event(), 0, accepted())
.unwrap();
r.check_binding("registry.example.com", &ctx, &event(), 0, created)
.unwrap();
assert!(r
.check_binding("other.example.com", &ctx, &event(), 0, created)
.is_err());
let other =
CtxId::parse("acdp://registry.example.com/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.unwrap();
assert!(r
.check_binding("registry.example.com", &other, &event(), 0, created)
.is_err());
assert!(r
.check_binding("registry.example.com", &ctx, &event(), 1, created)
.is_err());
let mut changed = event();
changed["occurred_at"] = "2026-10-03T00:00:00.001Z".into();
assert!(r
.check_binding("registry.example.com", &ctx, &changed, 0, created)
.is_err());
changed = event();
changed["event_id"] = "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e30".into();
assert!(r
.check_binding("registry.example.com", &ctx, &changed, 0, created)
.is_err());
let later = accepted() + chrono::Duration::days(1);
assert!(r
.check_binding("registry.example.com", &ctx, &event(), 0, later)
.is_err());
}
#[test]
fn registry_state_event_receipts_round_trip() {
use crate::body::RegistryState;
let r = signer()
.mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
.unwrap();
let mut state: RegistryState =
serde_json::from_value(serde_json::json!({"status": "active"})).unwrap();
assert!(state.event_receipts().is_none());
state.set_event_receipts(vec![r.clone()]).unwrap();
let wire = serde_json::to_value(&state).unwrap();
assert_eq!(
wire["event_receipts"][0]["receipt_type"],
EVENT_RECEIPT_TYPE
);
let back: RegistryState = serde_json::from_value(wire).unwrap();
assert_eq!(back.event_receipts().unwrap().unwrap(), vec![r]);
state.set_event_receipts(vec![]).unwrap();
assert!(serde_json::to_value(&state)
.unwrap()
.get("event_receipts")
.is_none());
let bad: RegistryState = serde_json::from_value(
serde_json::json!({"status": "active", "event_receipts": [{"x": 1}]}),
)
.unwrap();
assert!(matches!(
bad.event_receipts(),
Some(Err(AcdpError::InvalidReceipt(_)))
));
}
#[test]
fn retraction_absent_events_from_lifecycle_registry_is_not_retracted() {
use crate::body::RegistryState;
let fresh: RegistryState =
serde_json::from_value(serde_json::json!({"status": "active"})).unwrap();
assert_eq!(fresh.retraction(true), Some(false));
assert_eq!(fresh.retraction(false), None);
let retracted_event = {
let mut e = event();
e["signature"] = serde_json::json!({
"algorithm": "ed25519",
"key_id": "did:web:registry.example.com#receipt-key-1",
"value": "AA=="
});
e
};
let state: RegistryState = serde_json::from_value(serde_json::json!({
"status": "active",
"lifecycle_events": [retracted_event]
}))
.unwrap();
assert_eq!(state.retraction(true), Some(true));
assert_eq!(state.retraction(false), Some(true));
let by_status: RegistryState =
serde_json::from_value(serde_json::json!({"status": "retracted"})).unwrap();
assert_eq!(by_status.retraction(false), Some(true));
}
#[test]
fn closed_schema_rejects_unknown_member_and_bad_forms() {
let receipt = signer()
.mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
.unwrap();
let wire = serde_json::to_value(&receipt).unwrap();
let mut extra = wire.clone();
extra["note"] = "x".into();
assert!(EventReceipt::from_value(&extra).is_err());
let mut short_ts = wire.clone();
short_ts["accepted_at"] = "2026-10-03T00:00:01Z".into();
assert!(EventReceipt::from_value(&short_ts).is_err());
let mut wrong_type = wire.clone();
wrong_type["receipt_type"] = "acdp-event-receipt/2".into();
assert!(EventReceipt::from_value(&wrong_type).is_err());
let mut foreign_key = wire;
foreign_key["signature"]["key_id"] = "did:web:evil.example.com#k".into();
assert!(EventReceipt::from_value(&foreign_key).is_err());
}
}