1use r402_protocol::payment::PaymentRequirements;
6use serde_json::{Map, Value};
7
8pub const RESERVED_PAYMENT_FLOW_EXTRA_KEYS: [&str; 2] = ["paymentFlow", "assetTransferMethod"];
10
11#[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#[must_use]
38pub fn is_vacant_string_field(value: &str) -> bool {
39 value.trim().is_empty()
40}
41
42pub 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
136pub 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
257pub 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
277pub 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#[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, ¤t, "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, ¤t, "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, ¤t, "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, ¤t, "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, ¤t, "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, ¤t, "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, ¤t, "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, ¤t, "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, ¤t, "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 ¤t,
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 ¤t,
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 ¤t,
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}