Skip to main content

r402_server/hooks/
snapshot.rs

1//! Snapshots of payment requirements and facilitator-settled fields.
2
3use compact_str::CompactString;
4use r402_protocol::error::ErrorReason;
5use r402_protocol::payment::{PaymentRequirements, SettleResponse};
6
7use super::policy::HookPolicyError;
8
9/// Deep snapshot of `accepts` entries before enrichment.
10#[must_use]
11pub fn snapshot_payment_requirements_list(
12    requirements: &[PaymentRequirements],
13) -> Vec<PaymentRequirements> {
14    requirements.to_vec()
15}
16
17/// Facilitator-settled fields that extensions must not rewrite.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SettleResponseCoreSnapshot {
20    /// Whether settlement succeeded.
21    pub success: bool,
22    /// Broadcast transaction hash (empty when none).
23    pub transaction: CompactString,
24    /// CAIP-2 network.
25    pub network: CompactString,
26    /// Settled amount, when present.
27    pub amount: Option<CompactString>,
28    /// Payer address, when present.
29    pub payer: Option<CompactString>,
30    /// Failure reason, when settlement failed.
31    pub error_reason: Option<ErrorReason>,
32    /// Failure message, when settlement failed.
33    pub error_message: Option<CompactString>,
34}
35
36/// Captures facilitator-settled fields for later comparison.
37#[must_use]
38pub fn snapshot_settle_response_core(result: &SettleResponse) -> SettleResponseCoreSnapshot {
39    match result {
40        SettleResponse::Success {
41            payer,
42            transaction,
43            network,
44            amount,
45            ..
46        } => SettleResponseCoreSnapshot {
47            success: true,
48            transaction: transaction.clone(),
49            network: network.clone(),
50            amount: amount.clone(),
51            payer: payer.clone(),
52            error_reason: None,
53            error_message: None,
54        },
55        SettleResponse::Failure {
56            reason,
57            message,
58            payer,
59            transaction,
60            network,
61            ..
62        } => SettleResponseCoreSnapshot {
63            success: false,
64            transaction: transaction.clone(),
65            network: network.clone(),
66            amount: None,
67            payer: payer.clone(),
68            error_reason: Some(reason.clone()),
69            error_message: message.clone(),
70        },
71        _ => SettleResponseCoreSnapshot {
72            success: result.is_success(),
73            transaction: CompactString::default(),
74            network: CompactString::default(),
75            amount: None,
76            payer: None,
77            error_reason: None,
78            error_message: None,
79        },
80    }
81}
82
83/// Ensures `enrichSettlementResponse` did not rewrite facilitator outcome fields.
84///
85/// # Errors
86///
87/// [`HookPolicyError`] when a core field changed.
88pub fn assert_settle_response_core_unchanged(
89    before: &SettleResponseCoreSnapshot,
90    after: &SettleResponse,
91    extension_key: &str,
92) -> Result<(), HookPolicyError> {
93    let after_snap = snapshot_settle_response_core(after);
94    assert_settle_field(
95        "success",
96        before.success == after_snap.success,
97        extension_key,
98    )?;
99    assert_settle_field(
100        "transaction",
101        before.transaction == after_snap.transaction,
102        extension_key,
103    )?;
104    assert_settle_field(
105        "network",
106        before.network == after_snap.network,
107        extension_key,
108    )?;
109    assert_settle_field("amount", before.amount == after_snap.amount, extension_key)?;
110    assert_settle_field("payer", before.payer == after_snap.payer, extension_key)?;
111    assert_settle_field(
112        "errorReason",
113        before.error_reason == after_snap.error_reason,
114        extension_key,
115    )?;
116    assert_settle_field(
117        "errorMessage",
118        before.error_message == after_snap.error_message,
119        extension_key,
120    )
121}
122
123fn assert_settle_field(
124    field: &str,
125    equal: bool,
126    extension_key: &str,
127) -> Result<(), HookPolicyError> {
128    if equal {
129        Ok(())
130    } else {
131        Err(HookPolicyError::settle_extension(
132            extension_key,
133            &format!("field \"{field}\" is immutable after facilitator settle"),
134        ))
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use r402_protocol::payment::Extensions;
141
142    use super::*;
143
144    fn success_settle() -> SettleResponse {
145        SettleResponse::Success {
146            payer: Some("0xpayer".into()),
147            transaction: "0xtx".into(),
148            network: "eip155:8453".into(),
149            amount: None,
150            extensions: Extensions::new(),
151            extension_responses: Extensions::new(),
152            extra: None,
153        }
154    }
155
156    #[test]
157    fn settle_core_unchanged_allows_extension_map_edits() {
158        let mut base = success_settle();
159        let snap = snapshot_settle_response_core(&base);
160        if let SettleResponse::Success {
161            ref mut extensions, ..
162        } = base
163        {
164            extensions.insert(
165                "a",
166                r402_protocol::payment::ExtensionEntry::info(serde_json::json!(1)),
167            );
168        }
169        assert!(assert_settle_response_core_unchanged(&snap, &base, "ext").is_ok());
170    }
171
172    #[test]
173    fn settle_core_unchanged_rejects_transaction_change() {
174        let mut base = success_settle();
175        let snap = snapshot_settle_response_core(&base);
176        if let SettleResponse::Success {
177            ref mut transaction,
178            ..
179        } = base
180        {
181            *transaction = "0xother".into();
182        }
183        let err = assert_settle_response_core_unchanged(&snap, &base, "ext").unwrap_err();
184        assert!(err.to_string().contains("transaction"), "{err}");
185    }
186}