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_injected_payment_flow_on_matching_accept() {
500 let baseline =
501 snapshot_payment_requirements_list(&[sample_req().with_extra(json!({"name": "USDC"}))]);
502 let mut current = snapshot_payment_requirements_list(&baseline);
503 current[0].extra = Some(json!({"name": "USDC", "paymentFlow": "upfront"}));
504 let err = assert_accepts_additive_extra_after_scheme_enrich(
505 &baseline,
506 ¤t,
507 baseline[0].scheme.as_str(),
508 &baseline[0].network.to_string(),
509 )
510 .unwrap_err();
511 let msg = err.to_string();
512 assert!(msg.contains("extra[\"paymentFlow\"]"), "{msg}");
513 assert!(msg.contains("protocol-reserved"), "{msg}");
514 }
515
516 #[test]
517 fn additive_payload_allows_new_fields() {
518 let payload = json_object(&json!({"clientField": "client"}));
519 let enrichment = json_object(&json!({"serverField": "server"}));
520 assert!(assert_additive_payload_enrichment(&payload, &enrichment, "scheme test").is_ok());
521 }
522
523 #[test]
524 fn additive_payload_rejects_overwrite() {
525 let payload = json_object(&json!({"clientField": "client"}));
526 let enrichment = json_object(&json!({"clientField": "server"}));
527 let err =
528 assert_additive_payload_enrichment(&payload, &enrichment, "scheme test").unwrap_err();
529 assert!(err.to_string().contains("clientField"), "{err}");
530 }
531
532 #[test]
533 fn additive_settlement_extra_allows_nested_fields() {
534 let extra = json_object(&json!({
535 "channelState": {
536 "channelId": "0xchannel",
537 "balance": "1000",
538 }
539 }));
540 let enrichment = json_object(&json!({
541 "channelState": {
542 "chargedCumulativeAmount": "200",
543 }
544 }));
545 assert!(assert_additive_settlement_extra(&extra, &enrichment, "scheme test").is_ok());
546 }
547
548 #[test]
549 fn additive_settlement_extra_rejects_nested_overwrite() {
550 let extra = json_object(&json!({"channelState": {"balance": "1000"}}));
551 let enrichment = json_object(&json!({"channelState": {"balance": "2000"}}));
552 let err = assert_additive_settlement_extra(&extra, &enrichment, "scheme test").unwrap_err();
553 let msg = err.to_string();
554 assert!(msg.contains("channelState"), "{msg}");
555 assert!(msg.contains("balance"), "{msg}");
556 }
557
558 #[test]
559 fn merge_additive_settlement_extra_merges_nested() {
560 let extra = json_object(&json!({
561 "channelState": {
562 "channelId": "0xchannel",
563 "balance": "1000",
564 }
565 }));
566 let enrichment = json_object(&json!({
567 "chargedAmount": "100",
568 "channelState": {
569 "chargedCumulativeAmount": "200",
570 }
571 }));
572 let merged = merge_additive_settlement_extra(&extra, &enrichment);
573 assert_eq!(
574 Value::Object(merged),
575 json!({
576 "chargedAmount": "100",
577 "channelState": {
578 "channelId": "0xchannel",
579 "balance": "1000",
580 "chargedCumulativeAmount": "200",
581 }
582 })
583 );
584 }
585
586 fn json_object(value: &Value) -> Map<String, Value> {
587 value.as_object().cloned().expect("object")
588 }
589}