1use std::{collections::HashMap, fmt};
2
3use serde::{de, Deserialize, Serialize};
4use tracing::{debug, instrument};
5
6use crate::{
7 error::map_error,
8 validation::{invalid_data, invalid_plaintext_json},
9 EncryptedLink, PaykitAppId, PaymentEndpointIdentifier, PaymentEndpointPayload,
10 PrivateMessageKind, Result,
11};
12
13#[derive(Clone, PartialEq, Eq)]
15pub struct PrivatePaymentList {
16 version: u8,
17 kind: PrivateMessageKind,
18 app_id: PaykitAppId,
19 pub payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
21}
22
23impl fmt::Debug for PrivatePaymentList {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 f.debug_struct("PrivatePaymentList")
26 .field("version", &self.version)
27 .field("kind", &self.kind)
28 .field("app_id", &self.app_id)
29 .field(
30 "payment_endpoints",
31 &format_args!("<redacted:{} endpoints>", self.payment_endpoints.len()),
32 )
33 .finish()
34 }
35}
36
37impl PrivatePaymentList {
38 pub fn new(
43 app_id: PaykitAppId,
44 payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
45 ) -> Self {
46 Self {
47 version: 1,
48 kind: PrivateMessageKind::PrivatePaymentList,
49 app_id,
50 payment_endpoints,
51 }
52 }
53
54 pub fn version(&self) -> u8 {
56 self.version
57 }
58
59 pub fn kind(&self) -> PrivateMessageKind {
61 self.kind
62 }
63
64 pub fn app_id(&self) -> &PaykitAppId {
66 &self.app_id
67 }
68
69 pub fn len(&self) -> usize {
71 self.payment_endpoints.len()
72 }
73
74 pub fn is_empty(&self) -> bool {
76 self.payment_endpoints.is_empty()
77 }
78
79 pub fn get(&self, identifier: &PaymentEndpointIdentifier) -> Option<&PaymentEndpointPayload> {
81 self.payment_endpoints.get(identifier)
82 }
83}
84
85#[derive(Deserialize)]
86#[serde(deny_unknown_fields)]
87struct PrivatePaymentListWire {
88 version: u8,
89 kind: String,
90 app_id: String,
91 #[serde(deserialize_with = "deserialize_payment_endpoints")]
92 payment_endpoints: HashMap<String, String>,
93}
94
95#[derive(Serialize)]
96struct PrivatePaymentListWireRef<'a> {
97 version: u8,
98 kind: &'static str,
99 app_id: &'a str,
100 payment_endpoints: HashMap<&'a str, &'a str>,
101}
102
103fn deserialize_payment_endpoints<'de, D>(
104 deserializer: D,
105) -> std::result::Result<HashMap<String, String>, D::Error>
106where
107 D: de::Deserializer<'de>,
108{
109 struct PaymentEndpointsVisitor;
110
111 impl<'de> de::Visitor<'de> for PaymentEndpointsVisitor {
112 type Value = HashMap<String, String>;
113
114 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115 formatter.write_str("a Payment Endpoint map without duplicate identifiers")
116 }
117
118 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
119 where
120 A: de::MapAccess<'de>,
121 {
122 let mut payment_endpoints = HashMap::new();
123
124 while let Some((key, value)) = map.next_entry::<String, String>()? {
125 if payment_endpoints.insert(key.clone(), value).is_some() {
126 return Err(de::Error::custom(format!(
127 "duplicate Payment Endpoint identifier '{key}'"
128 )));
129 }
130 }
131
132 Ok(payment_endpoints)
133 }
134 }
135
136 deserializer.deserialize_map(PaymentEndpointsVisitor)
137}
138
139pub fn parse_private_payment_list_json(json: &str) -> Result<PrivatePaymentList> {
141 let wire: PrivatePaymentListWire = serde_json::from_str(json)
145 .map_err(|_| invalid_plaintext_json("failed to parse Private Payment List JSON"))?;
146 if wire.version != 1 {
147 return Err(invalid_data(
148 "unsupported Private Payment List version",
149 None,
150 ));
151 }
152 if wire.kind != PrivateMessageKind::PrivatePaymentList.as_str() {
153 return Err(invalid_data("unsupported Private Payment List kind", None));
154 }
155 let app_id = PaykitAppId::new(&wire.app_id).map_err(|err| {
156 invalid_data(
157 "Private Payment List contains invalid App ID",
158 Some(err.into()),
159 )
160 })?;
161 let mut payment_endpoints = HashMap::new();
162 for (key, value) in wire.payment_endpoints {
163 let payment_endpoint_identifier = PaymentEndpointIdentifier::new(&key).map_err(|err| {
164 invalid_data(
167 "Private Payment List contains an invalid Payment Endpoint Identifier",
168 Some(err.into()),
169 )
170 })?;
171 payment_endpoints.insert(
172 payment_endpoint_identifier,
173 PaymentEndpointPayload::new(value),
174 );
175 }
176 Ok(PrivatePaymentList::new(app_id, payment_endpoints))
177}
178
179pub fn serialize_private_payment_list_json(list: &PrivatePaymentList) -> Result<String> {
181 let payment_endpoints = list
182 .payment_endpoints
183 .iter()
184 .map(|(k, v)| (k.as_str(), v.as_str()))
185 .collect();
186 let wire = PrivatePaymentListWireRef {
187 version: list.version,
188 kind: list.kind.as_str(),
189 app_id: list.app_id.as_str(),
190 payment_endpoints,
191 };
192 serde_json::to_string(&wire).map_err(|err| {
193 invalid_data(
194 format!("failed to serialize Private Payment List JSON: {err}"),
195 Some(err.into()),
196 )
197 })
198}
199
200#[instrument(skip(link, list), fields(count = list.payment_endpoints.len()))]
207pub async fn set_private_payment_list(
208 link: &mut EncryptedLink,
209 list: &PrivatePaymentList,
210) -> Result<()> {
211 debug!("sending Private Payment List");
212 let json = serialize_private_payment_list_json(list)
213 .map_err(|err| map_error("set_private_payment_list", err))?;
214 link.send_private_payment_list_message(json.as_bytes())
215 .await
216 .map_err(|err| map_error("set_private_payment_list", err))
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::{PaykitError, PaymentEndpointIdentifier, PaymentEndpointPayload};
223 use std::collections::HashMap;
224
225 fn app_id() -> PaykitAppId {
226 PaykitAppId::new("test-app").unwrap()
227 }
228
229 #[test]
230 fn test_serialize_private_payment_list_json_uses_versioned_message() {
231 let mut payment_endpoints = HashMap::new();
232 payment_endpoints.insert(
233 PaymentEndpointIdentifier::new("lightning").unwrap(),
234 PaymentEndpointPayload::new("ln..."),
235 );
236 let payload = PrivatePaymentList::new(app_id(), payment_endpoints);
237 let json = serialize_private_payment_list_json(&payload).unwrap();
238 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
239 assert_eq!(value["version"], 1);
240 assert_eq!(value["kind"], "paykit.private_payment_list");
241 assert_eq!(value["app_id"], "test-app");
242 assert!(value.get("reference").is_none());
243 assert_eq!(value["payment_endpoints"]["lightning"], "ln...");
244 }
245
246 #[test]
247 fn test_private_payment_list_debug_redacts_payloads() {
248 let mut payment_endpoints = HashMap::new();
249 payment_endpoints.insert(
250 PaymentEndpointIdentifier::new("lightning").unwrap(),
251 PaymentEndpointPayload::new("ln-secret"),
252 );
253 let list = PrivatePaymentList::new(app_id(), payment_endpoints);
254 let debug = format!("{list:?}");
255
256 assert!(!debug.contains("ln-secret"));
257 assert!(debug.contains("<redacted:"));
258 }
259
260 #[test]
261 fn test_parse_private_payment_list_json_requires_versioned_message() {
262 let err = parse_private_payment_list_json(r#"{"lightning": "ln..."}"#).unwrap_err();
263 assert!(
264 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("Private Payment List"))
265 );
266 }
267
268 #[test]
269 fn test_parse_private_payment_list_json_rejects_unsupported_version() {
270 let err = parse_private_payment_list_json(
271 r#"{"version":2,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{}}"#,
272 )
273 .unwrap_err();
274 assert!(
275 matches!(err, PaykitError::InvalidData { ref context, .. } if context == "unsupported Private Payment List version"),
276 "expected unsupported version error, got: {err}"
277 );
278 }
279
280 #[test]
281 fn test_parse_private_payment_list_json_rejects_unsupported_kind() {
282 let err = parse_private_payment_list_json(
283 r#"{"version":1,"kind":"paykit.receipt","app_id":"test-app","payment_endpoints":{}}"#,
284 )
285 .unwrap_err();
286 assert!(
287 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("unsupported Private Payment List kind")),
288 "expected unsupported kind error, got: {err}"
289 );
290 }
291
292 #[test]
293 fn test_parse_private_payment_list_json_rejects_reference_field() {
294 let err = parse_private_payment_list_json(
295 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","reference":"invoice-2026-0001","payment_endpoints":{}}"#,
296 )
297 .unwrap_err();
298 let context = match err {
299 PaykitError::InvalidData { context, .. } => context,
300 other => panic!("expected InvalidData for a reference field, got: {other:?}"),
301 };
302 assert_eq!(context, "failed to parse Private Payment List JSON");
303 assert!(!context.contains("reference"));
304 assert!(!context.contains("invoice-2026-0001"));
305 }
306
307 #[test]
310 fn test_parse_private_payment_list_json_empty_string() {
311 let err = parse_private_payment_list_json("").unwrap_err();
312 assert!(
313 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
314 "expected InvalidData parse error for empty string, got: {err}"
315 );
316 }
317
318 #[test]
321 fn test_parse_private_payment_list_json_truncated_object() {
322 let err = parse_private_payment_list_json("{").unwrap_err();
323 assert!(
324 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
325 "expected InvalidData for truncated JSON, got: {err}"
326 );
327 }
328
329 #[test]
330 fn test_parse_private_payment_list_json_array_instead_of_object() {
331 let err = parse_private_payment_list_json(r#"["lightning","onchain"]"#).unwrap_err();
332 assert!(
333 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
334 "expected InvalidData for JSON array, got: {err}"
335 );
336 }
337
338 #[test]
339 fn test_parse_private_payment_list_json_plain_string() {
340 let err = parse_private_payment_list_json(r#""just a string""#).unwrap_err();
341 assert!(
342 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
343 "expected InvalidData for plain JSON string, got: {err}"
344 );
345 }
346
347 #[test]
348 fn test_parse_private_payment_list_json_number() {
349 let err = parse_private_payment_list_json("42").unwrap_err();
350 assert!(
351 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
352 "expected InvalidData for JSON number, got: {err}"
353 );
354 }
355
356 #[test]
357 fn test_parse_private_payment_list_json_non_string_values() {
358 let err = parse_private_payment_list_json(
359 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":123,"onchain":true}}"#,
360 )
361 .unwrap_err();
362 assert!(
363 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
364 "expected InvalidData for non-string values, got: {err}"
365 );
366 }
367
368 #[test]
369 fn test_parse_private_payment_list_json_rejects_duplicate_identifiers() {
370 let err = parse_private_payment_list_json(
371 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln-one","lightning":"ln-two"}}"#,
372 )
373 .unwrap_err();
374 let context = match err {
375 PaykitError::InvalidData { context, .. } => context,
376 other => panic!("expected InvalidData for duplicate identifiers, got: {other:?}"),
377 };
378 assert_eq!(context, "failed to parse Private Payment List JSON");
379 assert!(!context.contains("lightning"));
380 assert!(!context.contains("ln-one"));
381 }
382
383 #[test]
384 fn test_parse_private_payment_list_json_error_does_not_embed_plaintext() {
385 let sentinel = "sentinel-private-payload";
386 let json = format!(
387 r#"{{"version":"{sentinel}","kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{{}}}}"#
388 );
389
390 let err = parse_private_payment_list_json(&json).unwrap_err();
391
392 let message = err.to_string();
393 assert!(
394 message.contains("failed to parse Private Payment List JSON"),
395 "expected static parse error, got: {message}"
396 );
397 assert!(
398 !message.contains(sentinel),
399 "decrypted plaintext must not appear in errors, got: {message}"
400 );
401 }
402
403 #[test]
404 fn test_private_payment_list_invalid_header_does_not_embed_plaintext() {
405 let sentinel = "SENTINEL_PRIVATE_HEADER";
406 for field in ["kind", "app_id"] {
407 let mut json = serde_json::json!({
408 "version": 1,
409 "kind": "paykit.private_payment_list",
410 "app_id": "test-app",
411 "payment_endpoints": {},
412 });
413 json[field] = sentinel.into();
414
415 let error = parse_private_payment_list_json(&json.to_string()).unwrap_err();
416
417 assert!(matches!(error, PaykitError::InvalidData { .. }));
418 assert!(!format!("{error:?}").contains(sentinel));
419 assert!(!error.to_string().contains(sentinel));
420 }
421 }
422
423 #[test]
424 fn test_parse_private_payment_list_json_trailing_comma() {
425 let err = parse_private_payment_list_json(r#"{"lightning": "ln...",}"#).unwrap_err();
426 assert!(
427 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
428 "expected InvalidData for trailing comma, got: {err}"
429 );
430 }
431
432 #[test]
433 fn test_parse_private_payment_list_json_null() {
434 let err = parse_private_payment_list_json("null").unwrap_err();
435 assert!(
436 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
437 "expected InvalidData for JSON null, got: {err}"
438 );
439 }
440
441 #[test]
444 fn test_parse_private_payment_list_json_empty_key() {
445 let err = parse_private_payment_list_json(
446 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"":"ln..."}}"#,
447 )
448 .unwrap_err();
449 assert!(
450 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
451 "expected InvalidData for empty key, got: {err}"
452 );
453 }
454
455 #[test]
456 fn test_parse_private_payment_list_json_path_traversal_key() {
457 let err = parse_private_payment_list_json(
458 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"..":"ln..."}}"#,
459 )
460 .unwrap_err();
461 assert!(
462 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
463 "expected InvalidData for path-traversal key, got: {err}"
464 );
465 }
466
467 #[test]
468 fn test_parse_private_payment_list_json_slash_in_key() {
469 let err = parse_private_payment_list_json(
470 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"foo/bar":"ln..."}}"#,
471 )
472 .unwrap_err();
473 assert!(
474 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
475 "expected InvalidData for key with slash, got: {err}"
476 );
477 }
478
479 #[test]
480 fn test_parse_private_payment_list_json_reserved_private_key() {
481 let err = parse_private_payment_list_json(
482 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"private":"secret..."}}"#,
483 )
484 .unwrap_err();
485 assert!(
486 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
487 "expected InvalidData for reserved 'private' key, got: {err}"
488 );
489 }
490
491 #[test]
492 fn test_parse_private_payment_list_json_oversized_key() {
493 let long_key = "a".repeat(65);
494 let json = format!(
495 r#"{{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{{"{long_key}":"ln..."}}}}"#
496 );
497 let err = parse_private_payment_list_json(&json).unwrap_err();
498 assert!(
499 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
500 "expected InvalidData for oversized key, got: {err}"
501 );
502 }
503
504 #[test]
505 fn test_parse_private_payment_list_json_one_valid_one_invalid_key() {
506 let err =
508 parse_private_payment_list_json(r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln...","":"bc1..."}}"#).unwrap_err();
509 assert!(
510 matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
511 "expected InvalidData when one key is invalid, got: {err}"
512 );
513 }
514
515 #[test]
518 fn test_parse_private_payment_list_json_valid_single_payment_endpoint() {
519 let result = parse_private_payment_list_json(
520 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln..."}}"#,
521 )
522 .unwrap();
523 assert_eq!(result.len(), 1);
524 assert_eq!(
525 result.get(&PaymentEndpointIdentifier::new("lightning").unwrap()),
526 Some(&PaymentEndpointPayload::new("ln..."))
527 );
528 }
529
530 #[test]
531 fn test_parse_private_payment_list_json_valid_multiple_payment_endpoints() {
532 let result =
533 parse_private_payment_list_json(r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln...","onchain":"bc1..."}}"#).unwrap();
534 assert_eq!(result.len(), 2);
535 assert_eq!(
536 result.get(&PaymentEndpointIdentifier::new("lightning").unwrap()),
537 Some(&PaymentEndpointPayload::new("ln..."))
538 );
539 assert_eq!(
540 result.get(&PaymentEndpointIdentifier::new("onchain").unwrap()),
541 Some(&PaymentEndpointPayload::new("bc1..."))
542 );
543 }
544
545 #[test]
546 fn test_parse_private_payment_list_json_empty_object() {
547 let result = parse_private_payment_list_json(
548 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{}}"#,
549 )
550 .unwrap();
551 assert!(result.is_empty());
552 }
553}