Skip to main content

paykit_lib/
delivery_confirmation.rs

1//! Stateless Delivery Confirmation wire support.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    validation::{invalid_plaintext_json, validate_wire_version_kind},
7    EventId, PaykitAppId, PaykitError, PrivateMessageKind, Result,
8};
9
10/// Confirmation that a counterparty durably received one Event Message.
11///
12/// This is not an Event Message: `event_id` identifies the original event,
13/// not this confirmation. Delivery Confirmations must never be confirmed.
14/// They do not indicate payment acceptance, execution, or settlement.
15///
16/// The caller owns durable receipt and sends this over the authenticated
17/// Encrypted Link to the original sender only after persisting the event.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct DeliveryConfirmation {
20    app_id: PaykitAppId,
21    event_id: EventId,
22    payload_hash: String,
23}
24
25impl DeliveryConfirmation {
26    /// Create a version-1 confirmation for an existing Event Message.
27    ///
28    /// `app_id` is the local confirming App, and `event_id` is the original
29    /// Event ID. `payload_hash` must be SHA-256 of the exact raw UTF-8 original
30    /// JSON, including whitespace, formatted as `sha256:<64 lowercase hex>`.
31    /// This validates the hash's format; the caller computes it without
32    /// reserializing the original JSON and confirms only Event Message kinds.
33    pub fn new(app_id: PaykitAppId, event_id: EventId, payload_hash: String) -> Result<Self> {
34        let valid_hash = payload_hash.strip_prefix("sha256:").is_some_and(|hex| {
35            hex.len() == 64
36                && hex
37                    .bytes()
38                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
39        });
40        if !valid_hash {
41            return Err(PaykitError::Validation(
42                "Delivery Confirmation payload_hash must be sha256: followed by 64 lowercase hex digits"
43                    .into(),
44            ));
45        }
46        Ok(Self {
47            app_id,
48            event_id,
49            payload_hash,
50        })
51    }
52
53    /// Local App confirming durable receipt of the original Event Message.
54    pub fn app_id(&self) -> &PaykitAppId {
55        &self.app_id
56    }
57
58    /// Original Event ID; no fresh Event ID is allocated for this confirmation.
59    pub fn event_id(&self) -> &EventId {
60        &self.event_id
61    }
62
63    /// SHA-256 of the exact raw UTF-8 original JSON, prefixed with `sha256:`.
64    pub fn payload_hash(&self) -> &str {
65        &self.payload_hash
66    }
67}
68
69#[derive(Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71struct DeliveryConfirmationWire {
72    version: u8,
73    kind: String,
74    app_id: String,
75    event_id: String,
76    payload_hash: String,
77}
78
79/// Parse a version-1 Delivery Confirmation, rejecting malformed wire data.
80///
81/// A valid confirmation must still be matched to its authenticated sender,
82/// original Event ID, and exact payload hash by the caller before use.
83pub fn parse_delivery_confirmation_json(json: &str) -> Result<DeliveryConfirmation> {
84    // Decrypted private plaintext must not enter error chains, including
85    // validation errors for the App ID, Event ID, and payload hash.
86    let invalid = || invalid_plaintext_json("invalid Delivery Confirmation JSON");
87    let wire: DeliveryConfirmationWire = serde_json::from_str(json).map_err(|_| invalid())?;
88    validate_wire_version_kind(
89        wire.version,
90        &wire.kind,
91        PrivateMessageKind::DeliveryConfirmation,
92        "Delivery Confirmation",
93    )?;
94    DeliveryConfirmation::new(
95        PaykitAppId::new(wire.app_id).map_err(|_| invalid())?,
96        EventId::new(wire.event_id).map_err(|_| invalid())?,
97        wire.payload_hash,
98    )
99    .map_err(|_| invalid())
100}
101
102/// Serialize a Delivery Confirmation into its compact JSON wire representation.
103pub fn serialize_delivery_confirmation(confirmation: &DeliveryConfirmation) -> Result<String> {
104    let wire = DeliveryConfirmationWire {
105        version: 1,
106        kind: PrivateMessageKind::DeliveryConfirmation.as_str().to_owned(),
107        app_id: confirmation.app_id.as_str().to_owned(),
108        event_id: confirmation.event_id.as_str().to_owned(),
109        payload_hash: confirmation.payload_hash.clone(),
110    };
111    serde_json::to_string(&wire)
112        .map_err(|_| invalid_plaintext_json("failed to serialize Delivery Confirmation JSON"))
113}
114
115#[cfg(test)]
116mod tests;