Skip to main content

paykit_lib/
private_payment_list.rs

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/// Versioned Private Payment List sent over an established Encrypted Link.
14#[derive(Clone, PartialEq, Eq)]
15pub struct PrivatePaymentList {
16    version: u8,
17    kind: PrivateMessageKind,
18    app_id: PaykitAppId,
19    /// Complete Payment List carried by this Latest-State Message.
20    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    /// Construct a Private Payment List using protocol version 1 and the
39    /// `paykit.private_payment_list` message kind.
40    ///
41    /// `payment_endpoints` is the complete list to share, not a patch.
42    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    /// Protocol version used for this Private Application Message.
55    pub fn version(&self) -> u8 {
56        self.version
57    }
58
59    /// Private Message Kind used by this list message.
60    pub fn kind(&self) -> PrivateMessageKind {
61        self.kind
62    }
63
64    /// App that published this Private Payment List.
65    pub fn app_id(&self) -> &PaykitAppId {
66        &self.app_id
67    }
68
69    /// Number of Payment Endpoints in this private list message.
70    pub fn len(&self) -> usize {
71        self.payment_endpoints.len()
72    }
73
74    /// Returns true when this private list message contains no Payment Endpoints.
75    pub fn is_empty(&self) -> bool {
76        self.payment_endpoints.is_empty()
77    }
78
79    /// Look up a Payment Endpoint Payload by Payment Endpoint Identifier.
80    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
139/// Parse a versioned Private Payment List JSON message.
140pub fn parse_private_payment_list_json(json: &str) -> Result<PrivatePaymentList> {
141    // SECURITY / REDACTION: this parses decrypted private-message plaintext,
142    // and serde_json errors can embed document fragments. Keep the context
143    // static so field values never reach error chains, logs, or FFI text.
144    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            // SECURITY / REDACTION: `key` is decrypted private-message
165            // plaintext.
166            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
179/// Serialize a Private Payment List into its JSON wire representation.
180pub 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/// Encrypts and sends a complete Private Payment List via the established
201/// Encrypted Link.
202///
203/// The list must contain the full desired Payment List. Homeserver write
204/// failures are retried according to [`EncryptedLink::set_max_send_retries`].
205/// Oversized messages return [`crate::PaykitError::Validation`].
206#[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    // ── parse_private_payment_list_json tests ───────────────────────────────
308
309    #[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    // ── Malformed JSON ──────────────────────────────────────────────────
319
320    #[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    // ── Invalid Payment Endpoint Identifiers inside valid JSON ────────────────────────────
442
443    #[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        // The valid key should not mask the invalid one.
507        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    // ── Happy path ──────────────────────────────────────────────────────
516
517    #[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}