Skip to main content

r402_server/hooks/
policy.rs

1//! Hook mutation policy for 402 enrichment.
2//!
3//! Fail-closed: a policy violation is an error, not a silent ignore.
4
5use r402_protocol::payment::PaymentRequirements;
6use serde_json::{Map, Value};
7
8/// Protocol-reserved `extra` keys. Enrichment must not add or change them.
9pub const RESERVED_PAYMENT_FLOW_EXTRA_KEYS: [&str; 2] = ["paymentFlow", "assetTransferMethod"];
10
11/// Hook violated an enrichment mutation policy.
12#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
13#[error("{0}")]
14pub struct HookPolicyError(#[doc = "Policy violation message."] pub String);
15
16impl HookPolicyError {
17    fn accepts_extension(extension_key: &str, detail: &str) -> Self {
18        Self(format!(
19            "[x402] extension \"{extension_key}\" violated accepts mutation policy: {detail}"
20        ))
21    }
22
23    fn accepts_scheme(scheme: &str, detail: &str) -> Self {
24        Self(format!(
25            "[x402] scheme \"{scheme}\" violated accepts mutation policy: {detail}"
26        ))
27    }
28
29    pub(super) fn settle_extension(extension_key: &str, detail: &str) -> Self {
30        Self(format!(
31            "[x402] extension \"{extension_key}\" violated settlement mutation policy: {detail}"
32        ))
33    }
34}
35
36/// True when a string field is treated as unset and may be filled by enrichment.
37#[must_use]
38pub fn is_vacant_string_field(value: &str) -> bool {
39    value.trim().is_empty()
40}
41
42/// Extension 402 enrich: vacant `payTo` / `amount` / `asset` may be filled.
43///
44/// `extra` may gain keys. Reserved payment-flow keys are immutable.
45///
46/// # Errors
47///
48/// [`HookPolicyError`] when length, locked fields, extra values, or reserved
49/// keys change.
50pub fn assert_accepts_allowlisted_after_extension_enrich(
51    baseline: &[PaymentRequirements],
52    current: &[PaymentRequirements],
53    extension_key: &str,
54) -> Result<(), HookPolicyError> {
55    if baseline.len() != current.len() {
56        return Err(HookPolicyError::accepts_extension(
57            extension_key,
58            &format!(
59                "accepts length changed ({} → {})",
60                baseline.len(),
61                current.len()
62            ),
63        ));
64    }
65    for (index, (base, cur)) in baseline.iter().zip(current.iter()).enumerate() {
66        assert_extension_row(base, cur, index, extension_key)?;
67    }
68    Ok(())
69}
70
71fn assert_extension_row(
72    base: &PaymentRequirements,
73    cur: &PaymentRequirements,
74    index: usize,
75    extension_key: &str,
76) -> Result<(), HookPolicyError> {
77    if base.scheme != cur.scheme || base.network != cur.network {
78        return Err(HookPolicyError::accepts_extension(
79            extension_key,
80            &format!("scheme/network are immutable (index {index})"),
81        ));
82    }
83    if base.max_timeout_seconds != cur.max_timeout_seconds {
84        return Err(HookPolicyError::accepts_extension(
85            extension_key,
86            &format!("maxTimeoutSeconds is immutable (index {index})"),
87        ));
88    }
89    assert_vacant_or_unchanged(
90        "payTo",
91        base.pay_to.as_str(),
92        cur.pay_to.as_str(),
93        index,
94        extension_key,
95    )?;
96    assert_vacant_or_unchanged(
97        "amount",
98        base.amount.as_str(),
99        cur.amount.as_str(),
100        index,
101        extension_key,
102    )?;
103    assert_vacant_or_unchanged(
104        "asset",
105        base.asset.as_str(),
106        cur.asset.as_str(),
107        index,
108        extension_key,
109    )?;
110    assert_extra_keys_unchanged(base.extra.as_ref(), cur.extra.as_ref(), index, |detail| {
111        HookPolicyError::accepts_extension(extension_key, detail)
112    })?;
113    assert_reserved_keys_presence(base.extra.as_ref(), cur.extra.as_ref(), index, |detail| {
114        HookPolicyError::accepts_extension(extension_key, detail)
115    })
116}
117
118fn assert_vacant_or_unchanged(
119    field: &str,
120    baseline: &str,
121    current: &str,
122    index: usize,
123    extension_key: &str,
124) -> Result<(), HookPolicyError> {
125    if !is_vacant_string_field(baseline) && current != baseline {
126        return Err(HookPolicyError::accepts_extension(
127            extension_key,
128            &format!(
129                "\"{field}\" may only be set when the resource left it vacant (\"\"); non-vacant values are immutable (index {index})"
130            ),
131        ));
132    }
133    Ok(())
134}
135
136/// Scheme 402 enrich may only add `extra` keys on matching accepts.
137///
138/// Payment terms and reserved payment-flow keys are immutable.
139///
140/// # Errors
141///
142/// [`HookPolicyError`] when length, terms, extra values, non-matching extra,
143/// or reserved keys change.
144pub fn assert_accepts_additive_extra_after_scheme_enrich(
145    baseline: &[PaymentRequirements],
146    current: &[PaymentRequirements],
147    scheme: &str,
148    network: &str,
149) -> Result<(), HookPolicyError> {
150    if baseline.len() != current.len() {
151        return Err(HookPolicyError::accepts_scheme(
152            scheme,
153            &format!(
154                "accepts length changed ({} → {})",
155                baseline.len(),
156                current.len()
157            ),
158        ));
159    }
160    for (index, (base, cur)) in baseline.iter().zip(current.iter()).enumerate() {
161        assert_scheme_row(base, cur, index, scheme, network)?;
162    }
163    Ok(())
164}
165
166fn assert_scheme_row(
167    base: &PaymentRequirements,
168    cur: &PaymentRequirements,
169    index: usize,
170    scheme: &str,
171    network: &str,
172) -> Result<(), HookPolicyError> {
173    let is_matching = base.scheme == scheme && base.network.to_string() == network;
174    if base.scheme != cur.scheme || base.network != cur.network {
175        return Err(HookPolicyError::accepts_scheme(
176            scheme,
177            &format!("scheme/network are immutable (index {index})"),
178        ));
179    }
180    if base.max_timeout_seconds != cur.max_timeout_seconds
181        || base.pay_to != cur.pay_to
182        || base.amount != cur.amount
183        || base.asset != cur.asset
184    {
185        return Err(HookPolicyError::accepts_scheme(
186            scheme,
187            &format!("payment terms are immutable (index {index})"),
188        ));
189    }
190    assert_extra_keys_unchanged(base.extra.as_ref(), cur.extra.as_ref(), index, |detail| {
191        HookPolicyError::accepts_scheme(scheme, detail)
192    })?;
193    if !is_matching && extra_len(cur.extra.as_ref()) != extra_len(base.extra.as_ref()) {
194        return Err(HookPolicyError::accepts_scheme(
195            scheme,
196            &format!("only matching accepts may receive new extra fields (index {index})"),
197        ));
198    }
199    assert_reserved_keys_presence(base.extra.as_ref(), cur.extra.as_ref(), index, |detail| {
200        HookPolicyError::accepts_scheme(scheme, detail)
201    })
202}
203
204fn assert_extra_keys_unchanged(
205    baseline: Option<&Value>,
206    current: Option<&Value>,
207    index: usize,
208    error: impl Fn(&str) -> HookPolicyError,
209) -> Result<(), HookPolicyError> {
210    let Some(base_map) = extra_object(baseline) else {
211        return Ok(());
212    };
213    let current_map = extra_object(current);
214    for (key, base_value) in base_map {
215        let Some(current_value) = current_map.and_then(|map| map.get(key)) else {
216            return Err(error(&format!(
217                "extra[\"{key}\"] was removed (index {index})"
218            )));
219        };
220        if current_value != base_value {
221            return Err(error(&format!(
222                "extra[\"{key}\"] may not be changed (index {index})"
223            )));
224        }
225    }
226    Ok(())
227}
228
229fn assert_reserved_keys_presence(
230    baseline: Option<&Value>,
231    current: Option<&Value>,
232    index: usize,
233    error: impl Fn(&str) -> HookPolicyError,
234) -> Result<(), HookPolicyError> {
235    for key in RESERVED_PAYMENT_FLOW_EXTRA_KEYS {
236        if extra_has_key(baseline, key) != extra_has_key(current, key) {
237            return Err(error(&format!(
238                "extra[\"{key}\"] is protocol-reserved and immutable during enrichment (index {index})"
239            )));
240        }
241    }
242    Ok(())
243}
244
245fn extra_object(extra: Option<&Value>) -> Option<&Map<String, Value>> {
246    extra.and_then(Value::as_object)
247}
248
249fn extra_has_key(extra: Option<&Value>, key: &str) -> bool {
250    extra_object(extra).is_some_and(|map| map.contains_key(key))
251}
252
253fn extra_len(extra: Option<&Value>) -> usize {
254    extra_object(extra).map_or(0, Map::len)
255}
256
257/// Ensures scheme settlement-payload enrichment only adds new keys.
258///
259/// # Errors
260///
261/// [`HookPolicyError`] when `enrichment` collides with an existing payload key.
262pub fn assert_additive_payload_enrichment(
263    payload: &Map<String, Value>,
264    enrichment: &Map<String, Value>,
265    caller_label: &str,
266) -> Result<(), HookPolicyError> {
267    for key in enrichment.keys() {
268        if payload.contains_key(key) {
269            return Err(HookPolicyError(format!(
270                "[x402] {caller_label} violated settlement payload enrichment policy: \"{key}\" already exists on the client payload"
271            )));
272        }
273    }
274    Ok(())
275}
276
277/// Ensures scheme response enrichment only adds new `extra` fields, including nested objects.
278///
279/// # Errors
280///
281/// [`HookPolicyError`] when enrichment would overwrite an existing field.
282pub fn assert_additive_settlement_extra(
283    extra: &Map<String, Value>,
284    enrichment: &Map<String, Value>,
285    caller_label: &str,
286) -> Result<(), HookPolicyError> {
287    assert_additive_record(extra, enrichment, caller_label, "extra")
288}
289
290/// Merges server-owned settlement response fields after additive policy validation.
291#[must_use]
292pub fn merge_additive_settlement_extra(
293    extra: &Map<String, Value>,
294    enrichment: &Map<String, Value>,
295) -> Map<String, Value> {
296    merge_additive_record(extra, enrichment)
297}
298
299fn is_plain_record(value: &Value) -> Option<&Map<String, Value>> {
300    value.as_object()
301}
302
303fn assert_additive_record(
304    target: &Map<String, Value>,
305    enrichment: &Map<String, Value>,
306    caller_label: &str,
307    path: &str,
308) -> Result<(), HookPolicyError> {
309    for (key, enrichment_value) in enrichment {
310        let next_path = format!("{path}[\"{key}\"]");
311        let Some(target_value) = target.get(key) else {
312            continue;
313        };
314        if let (Some(target_map), Some(enrichment_map)) = (
315            is_plain_record(target_value),
316            is_plain_record(enrichment_value),
317        ) {
318            assert_additive_record(target_map, enrichment_map, caller_label, &next_path)?;
319            continue;
320        }
321        return Err(HookPolicyError(format!(
322            "[x402] {caller_label} violated settlement response enrichment policy: {next_path} already exists on the settlement result"
323        )));
324    }
325    Ok(())
326}
327
328fn merge_additive_record(
329    target: &Map<String, Value>,
330    enrichment: &Map<String, Value>,
331) -> Map<String, Value> {
332    let mut merged = target.clone();
333    for (key, enrichment_value) in enrichment {
334        match (
335            merged.get(key).and_then(is_plain_record),
336            is_plain_record(enrichment_value),
337        ) {
338            (Some(target_map), Some(enrichment_map)) => {
339                let nested = merge_additive_record(target_map, enrichment_map);
340                merged.insert(key.clone(), Value::Object(nested));
341            }
342            _ => {
343                merged.insert(key.clone(), enrichment_value.clone());
344            }
345        }
346    }
347    merged
348}
349
350#[cfg(test)]
351mod tests {
352    use serde_json::json;
353
354    use super::*;
355    use crate::hooks::snapshot::snapshot_payment_requirements_list;
356
357    fn sample_req() -> PaymentRequirements {
358        PaymentRequirements::new(
359            "test-scheme".into(),
360            "test:network".parse().unwrap(),
361            "1000000".into(),
362            "test_recipient".into(),
363            "TEST_ASSET".into(),
364            300,
365        )
366        .with_extra(json!({}))
367    }
368
369    #[test]
370    fn vacant_string_treats_empty_and_whitespace_as_vacant() {
371        assert!(is_vacant_string_field(""));
372        assert!(is_vacant_string_field("   "));
373        assert!(!is_vacant_string_field("0xabc"));
374    }
375
376    #[test]
377    fn extension_enrich_allows_filling_vacant_pay_to_amount_asset() {
378        let mut vacant = sample_req();
379        vacant.pay_to = "".into();
380        vacant.amount = "".into();
381        vacant.asset = "".into();
382        let baseline = snapshot_payment_requirements_list(&[vacant]);
383        let mut current = snapshot_payment_requirements_list(&baseline);
384        current[0].pay_to = "0xnew".into();
385        current[0].amount = "1".into();
386        current[0].asset = "USDC".into();
387        assert!(
388            assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext").is_ok()
389        );
390    }
391
392    #[test]
393    fn extension_enrich_rejects_scheme_change() {
394        let baseline = snapshot_payment_requirements_list(&[sample_req()]);
395        let mut current = snapshot_payment_requirements_list(&baseline);
396        current[0].scheme = "other".into();
397        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
398            .unwrap_err();
399        assert!(err.to_string().contains("scheme/network"), "{err}");
400    }
401
402    #[test]
403    fn extension_enrich_rejects_non_vacant_amount_change() {
404        let baseline = snapshot_payment_requirements_list(&[PaymentRequirements::new(
405            "test-scheme".into(),
406            "test:network".parse().unwrap(),
407            "1000".into(),
408            "test_recipient".into(),
409            "TEST_ASSET".into(),
410            300,
411        )
412        .with_extra(json!({}))]);
413        let mut current = snapshot_payment_requirements_list(&baseline);
414        current[0].amount = "999".into();
415        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
416            .unwrap_err();
417        assert!(err.to_string().contains("amount"), "{err}");
418        assert!(err.to_string().contains("vacant"), "{err}");
419    }
420
421    #[test]
422    fn extension_enrich_rejects_removed_extra_key() {
423        let baseline =
424            snapshot_payment_requirements_list(&[sample_req().with_extra(json!({"k": 1}))]);
425        let mut current = snapshot_payment_requirements_list(&baseline);
426        current[0].extra = Some(json!({}));
427        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
428            .unwrap_err();
429        assert!(err.to_string().contains("extra[\"k\"]"), "{err}");
430    }
431
432    #[test]
433    fn extension_enrich_rejects_changed_extra_value() {
434        let baseline =
435            snapshot_payment_requirements_list(&[sample_req().with_extra(json!({"k": 1}))]);
436        let mut current = snapshot_payment_requirements_list(&baseline);
437        current[0].extra = Some(json!({"k": 2}));
438        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
439            .unwrap_err();
440        assert!(err.to_string().contains("extra[\"k\"]"), "{err}");
441    }
442
443    #[test]
444    fn extension_enrich_allows_adding_extra_keys() {
445        let baseline =
446            snapshot_payment_requirements_list(&[sample_req().with_extra(json!({"k": 1}))]);
447        let mut current = snapshot_payment_requirements_list(&baseline);
448        current[0].extra = Some(json!({"k": 1, "newKey": true}));
449        assert!(
450            assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext").is_ok()
451        );
452    }
453
454    #[test]
455    fn extension_enrich_detects_nested_extra_mutation() {
456        let baseline = snapshot_payment_requirements_list(&[
457            sample_req().with_extra(json!({"nested": {"b": "c"}}))
458        ]);
459        let mut current = snapshot_payment_requirements_list(&baseline);
460        if let Some(Value::Object(map)) = current[0].extra.as_mut()
461            && let Some(Value::Object(nested)) = map.get_mut("nested")
462        {
463            nested.insert("b".into(), json!("mutated"));
464        }
465        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
466            .unwrap_err();
467        assert!(err.to_string().contains("extra[\"nested\"]"), "{err}");
468    }
469
470    #[test]
471    fn extension_enrich_rejects_injected_payment_flow() {
472        let baseline = snapshot_payment_requirements_list(&[
473            sample_req().with_extra(json!({"schemeField": "x"}))
474        ]);
475        let mut current = snapshot_payment_requirements_list(&baseline);
476        current[0].extra = Some(json!({"schemeField": "x", "paymentFlow": "upfront"}));
477        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
478            .unwrap_err();
479        let msg = err.to_string();
480        assert!(msg.contains("extra[\"paymentFlow\"]"), "{msg}");
481        assert!(msg.contains("protocol-reserved"), "{msg}");
482    }
483
484    #[test]
485    fn extension_enrich_rejects_injected_asset_transfer_method() {
486        let baseline = snapshot_payment_requirements_list(&[
487            sample_req().with_extra(json!({"schemeField": "x"}))
488        ]);
489        let mut current = snapshot_payment_requirements_list(&baseline);
490        current[0].extra = Some(json!({"schemeField": "x", "assetTransferMethod": "permit2"}));
491        let err = assert_accepts_allowlisted_after_extension_enrich(&baseline, &current, "ext")
492            .unwrap_err();
493        let msg = err.to_string();
494        assert!(msg.contains("extra[\"assetTransferMethod\"]"), "{msg}");
495        assert!(msg.contains("protocol-reserved"), "{msg}");
496    }
497
498    #[test]
499    fn scheme_enrich_rejects_new_extra_on_unbound_network() {
500        let main = PaymentRequirements::new(
501            "upto".into(),
502            "eip155:143".parse().unwrap(),
503            "1".into(),
504            "0xa".into(),
505            "0xb".into(),
506            300,
507        );
508        let testnet = PaymentRequirements::new(
509            "upto".into(),
510            "eip155:10143".parse().unwrap(),
511            "1".into(),
512            "0xa".into(),
513            "0xb".into(),
514            300,
515        );
516        let baseline = snapshot_payment_requirements_list(&[main, testnet]);
517        let mut current = snapshot_payment_requirements_list(&baseline);
518        current[0].extra = Some(json!({"facilitatorAddress": "0xfa"}));
519        current[1].extra = Some(json!({"facilitatorAddress": "0xfa"}));
520        let err = assert_accepts_additive_extra_after_scheme_enrich(
521            &baseline,
522            &current,
523            "upto",
524            "eip155:143",
525        )
526        .unwrap_err();
527        let msg = err.to_string();
528        assert!(msg.contains("index 1"), "{msg}");
529        assert!(msg.contains("only matching accepts"), "{msg}");
530    }
531
532    #[test]
533    fn scheme_enrich_allows_new_extra_only_on_bound_network() {
534        let main = PaymentRequirements::new(
535            "upto".into(),
536            "eip155:143".parse().unwrap(),
537            "1".into(),
538            "0xa".into(),
539            "0xb".into(),
540            300,
541        );
542        let testnet = PaymentRequirements::new(
543            "upto".into(),
544            "eip155:10143".parse().unwrap(),
545            "1".into(),
546            "0xa".into(),
547            "0xb".into(),
548            300,
549        );
550        let baseline = snapshot_payment_requirements_list(&[main, testnet]);
551        let mut current = snapshot_payment_requirements_list(&baseline);
552        current[0].extra = Some(json!({"facilitatorAddress": "0xfa"}));
553        assert!(
554            assert_accepts_additive_extra_after_scheme_enrich(
555                &baseline,
556                &current,
557                "upto",
558                "eip155:143",
559            )
560            .is_ok()
561        );
562    }
563
564    #[test]
565    fn scheme_enrich_rejects_injected_payment_flow_on_matching_accept() {
566        let baseline =
567            snapshot_payment_requirements_list(&[sample_req().with_extra(json!({"name": "USDC"}))]);
568        let mut current = snapshot_payment_requirements_list(&baseline);
569        current[0].extra = Some(json!({"name": "USDC", "paymentFlow": "upfront"}));
570        let err = assert_accepts_additive_extra_after_scheme_enrich(
571            &baseline,
572            &current,
573            baseline[0].scheme.as_str(),
574            &baseline[0].network.to_string(),
575        )
576        .unwrap_err();
577        let msg = err.to_string();
578        assert!(msg.contains("extra[\"paymentFlow\"]"), "{msg}");
579        assert!(msg.contains("protocol-reserved"), "{msg}");
580    }
581
582    #[test]
583    fn additive_payload_allows_new_fields() {
584        let payload = json_object(&json!({"clientField": "client"}));
585        let enrichment = json_object(&json!({"serverField": "server"}));
586        assert!(assert_additive_payload_enrichment(&payload, &enrichment, "scheme test").is_ok());
587    }
588
589    #[test]
590    fn additive_payload_rejects_overwrite() {
591        let payload = json_object(&json!({"clientField": "client"}));
592        let enrichment = json_object(&json!({"clientField": "server"}));
593        let err =
594            assert_additive_payload_enrichment(&payload, &enrichment, "scheme test").unwrap_err();
595        assert!(err.to_string().contains("clientField"), "{err}");
596    }
597
598    #[test]
599    fn additive_settlement_extra_allows_nested_fields() {
600        let extra = json_object(&json!({
601            "channelState": {
602                "channelId": "0xchannel",
603                "balance": "1000",
604            }
605        }));
606        let enrichment = json_object(&json!({
607            "channelState": {
608                "chargedCumulativeAmount": "200",
609            }
610        }));
611        assert!(assert_additive_settlement_extra(&extra, &enrichment, "scheme test").is_ok());
612    }
613
614    #[test]
615    fn additive_settlement_extra_rejects_nested_overwrite() {
616        let extra = json_object(&json!({"channelState": {"balance": "1000"}}));
617        let enrichment = json_object(&json!({"channelState": {"balance": "2000"}}));
618        let err = assert_additive_settlement_extra(&extra, &enrichment, "scheme test").unwrap_err();
619        let msg = err.to_string();
620        assert!(msg.contains("channelState"), "{msg}");
621        assert!(msg.contains("balance"), "{msg}");
622    }
623
624    #[test]
625    fn merge_additive_settlement_extra_merges_nested() {
626        let extra = json_object(&json!({
627            "channelState": {
628                "channelId": "0xchannel",
629                "balance": "1000",
630            }
631        }));
632        let enrichment = json_object(&json!({
633            "chargedAmount": "100",
634            "channelState": {
635                "chargedCumulativeAmount": "200",
636            }
637        }));
638        let merged = merge_additive_settlement_extra(&extra, &enrichment);
639        assert_eq!(
640            Value::Object(merged),
641            json!({
642                "chargedAmount": "100",
643                "channelState": {
644                    "channelId": "0xchannel",
645                    "balance": "1000",
646                    "chargedCumulativeAmount": "200",
647                }
648            })
649        );
650    }
651
652    fn json_object(value: &Value) -> Map<String, Value> {
653        value.as_object().cloned().expect("object")
654    }
655}