use std::{collections::HashMap, fmt};
use serde::{de, Deserialize, Serialize};
use tracing::{debug, instrument};
use crate::{
error::map_error,
validation::{invalid_data, invalid_plaintext_json},
EncryptedLink, PaykitAppId, PaymentEndpointIdentifier, PaymentEndpointPayload,
PrivateMessageKind, Result,
};
#[derive(Clone, PartialEq, Eq)]
pub struct PrivatePaymentList {
version: u8,
kind: PrivateMessageKind,
app_id: PaykitAppId,
pub payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
}
impl fmt::Debug for PrivatePaymentList {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PrivatePaymentList")
.field("version", &self.version)
.field("kind", &self.kind)
.field("app_id", &self.app_id)
.field(
"payment_endpoints",
&format_args!("<redacted:{} endpoints>", self.payment_endpoints.len()),
)
.finish()
}
}
impl PrivatePaymentList {
pub fn new(
app_id: PaykitAppId,
payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
) -> Self {
Self {
version: 1,
kind: PrivateMessageKind::PrivatePaymentList,
app_id,
payment_endpoints,
}
}
pub fn version(&self) -> u8 {
self.version
}
pub fn kind(&self) -> PrivateMessageKind {
self.kind
}
pub fn app_id(&self) -> &PaykitAppId {
&self.app_id
}
pub fn len(&self) -> usize {
self.payment_endpoints.len()
}
pub fn is_empty(&self) -> bool {
self.payment_endpoints.is_empty()
}
pub fn get(&self, identifier: &PaymentEndpointIdentifier) -> Option<&PaymentEndpointPayload> {
self.payment_endpoints.get(identifier)
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PrivatePaymentListWire {
version: u8,
kind: String,
app_id: String,
#[serde(deserialize_with = "deserialize_payment_endpoints")]
payment_endpoints: HashMap<String, String>,
}
#[derive(Serialize)]
struct PrivatePaymentListWireRef<'a> {
version: u8,
kind: &'static str,
app_id: &'a str,
payment_endpoints: HashMap<&'a str, &'a str>,
}
fn deserialize_payment_endpoints<'de, D>(
deserializer: D,
) -> std::result::Result<HashMap<String, String>, D::Error>
where
D: de::Deserializer<'de>,
{
struct PaymentEndpointsVisitor;
impl<'de> de::Visitor<'de> for PaymentEndpointsVisitor {
type Value = HashMap<String, String>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a Payment Endpoint map without duplicate identifiers")
}
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: de::MapAccess<'de>,
{
let mut payment_endpoints = HashMap::new();
while let Some((key, value)) = map.next_entry::<String, String>()? {
if payment_endpoints.insert(key.clone(), value).is_some() {
return Err(de::Error::custom(format!(
"duplicate Payment Endpoint identifier '{key}'"
)));
}
}
Ok(payment_endpoints)
}
}
deserializer.deserialize_map(PaymentEndpointsVisitor)
}
pub fn parse_private_payment_list_json(json: &str) -> Result<PrivatePaymentList> {
let wire: PrivatePaymentListWire = serde_json::from_str(json)
.map_err(|_| invalid_plaintext_json("failed to parse Private Payment List JSON"))?;
if wire.version != 1 {
return Err(invalid_data(
"unsupported Private Payment List version",
None,
));
}
if wire.kind != PrivateMessageKind::PrivatePaymentList.as_str() {
return Err(invalid_data("unsupported Private Payment List kind", None));
}
let app_id = PaykitAppId::new(&wire.app_id).map_err(|err| {
invalid_data(
"Private Payment List contains invalid App ID",
Some(err.into()),
)
})?;
let mut payment_endpoints = HashMap::new();
for (key, value) in wire.payment_endpoints {
let payment_endpoint_identifier = PaymentEndpointIdentifier::new(&key).map_err(|err| {
invalid_data(
"Private Payment List contains an invalid Payment Endpoint Identifier",
Some(err.into()),
)
})?;
payment_endpoints.insert(
payment_endpoint_identifier,
PaymentEndpointPayload::new(value),
);
}
Ok(PrivatePaymentList::new(app_id, payment_endpoints))
}
pub fn serialize_private_payment_list_json(list: &PrivatePaymentList) -> Result<String> {
let payment_endpoints = list
.payment_endpoints
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let wire = PrivatePaymentListWireRef {
version: list.version,
kind: list.kind.as_str(),
app_id: list.app_id.as_str(),
payment_endpoints,
};
serde_json::to_string(&wire).map_err(|err| {
invalid_data(
format!("failed to serialize Private Payment List JSON: {err}"),
Some(err.into()),
)
})
}
#[instrument(skip(link, list), fields(count = list.payment_endpoints.len()))]
pub async fn set_private_payment_list(
link: &mut EncryptedLink,
list: &PrivatePaymentList,
) -> Result<()> {
debug!("sending Private Payment List");
let json = serialize_private_payment_list_json(list)
.map_err(|err| map_error("set_private_payment_list", err))?;
link.send_private_payment_list_message(json.as_bytes())
.await
.map_err(|err| map_error("set_private_payment_list", err))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PaykitError, PaymentEndpointIdentifier, PaymentEndpointPayload};
use std::collections::HashMap;
fn app_id() -> PaykitAppId {
PaykitAppId::new("test-app").unwrap()
}
#[test]
fn test_serialize_private_payment_list_json_uses_versioned_message() {
let mut payment_endpoints = HashMap::new();
payment_endpoints.insert(
PaymentEndpointIdentifier::new("lightning").unwrap(),
PaymentEndpointPayload::new("ln..."),
);
let payload = PrivatePaymentList::new(app_id(), payment_endpoints);
let json = serialize_private_payment_list_json(&payload).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["version"], 1);
assert_eq!(value["kind"], "paykit.private_payment_list");
assert_eq!(value["app_id"], "test-app");
assert!(value.get("reference").is_none());
assert_eq!(value["payment_endpoints"]["lightning"], "ln...");
}
#[test]
fn test_private_payment_list_debug_redacts_payloads() {
let mut payment_endpoints = HashMap::new();
payment_endpoints.insert(
PaymentEndpointIdentifier::new("lightning").unwrap(),
PaymentEndpointPayload::new("ln-secret"),
);
let list = PrivatePaymentList::new(app_id(), payment_endpoints);
let debug = format!("{list:?}");
assert!(!debug.contains("ln-secret"));
assert!(debug.contains("<redacted:"));
}
#[test]
fn test_parse_private_payment_list_json_requires_versioned_message() {
let err = parse_private_payment_list_json(r#"{"lightning": "ln..."}"#).unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("Private Payment List"))
);
}
#[test]
fn test_parse_private_payment_list_json_rejects_unsupported_version() {
let err = parse_private_payment_list_json(
r#"{"version":2,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context == "unsupported Private Payment List version"),
"expected unsupported version error, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_rejects_unsupported_kind() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.receipt","app_id":"test-app","payment_endpoints":{}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("unsupported Private Payment List kind")),
"expected unsupported kind error, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_rejects_reference_field() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","reference":"invoice-2026-0001","payment_endpoints":{}}"#,
)
.unwrap_err();
let context = match err {
PaykitError::InvalidData { context, .. } => context,
other => panic!("expected InvalidData for a reference field, got: {other:?}"),
};
assert_eq!(context, "failed to parse Private Payment List JSON");
assert!(!context.contains("reference"));
assert!(!context.contains("invoice-2026-0001"));
}
#[test]
fn test_parse_private_payment_list_json_empty_string() {
let err = parse_private_payment_list_json("").unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData parse error for empty string, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_truncated_object() {
let err = parse_private_payment_list_json("{").unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for truncated JSON, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_array_instead_of_object() {
let err = parse_private_payment_list_json(r#"["lightning","onchain"]"#).unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for JSON array, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_plain_string() {
let err = parse_private_payment_list_json(r#""just a string""#).unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for plain JSON string, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_number() {
let err = parse_private_payment_list_json("42").unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for JSON number, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_non_string_values() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":123,"onchain":true}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for non-string values, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_rejects_duplicate_identifiers() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln-one","lightning":"ln-two"}}"#,
)
.unwrap_err();
let context = match err {
PaykitError::InvalidData { context, .. } => context,
other => panic!("expected InvalidData for duplicate identifiers, got: {other:?}"),
};
assert_eq!(context, "failed to parse Private Payment List JSON");
assert!(!context.contains("lightning"));
assert!(!context.contains("ln-one"));
}
#[test]
fn test_parse_private_payment_list_json_error_does_not_embed_plaintext() {
let sentinel = "sentinel-private-payload";
let json = format!(
r#"{{"version":"{sentinel}","kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{{}}}}"#
);
let err = parse_private_payment_list_json(&json).unwrap_err();
let message = err.to_string();
assert!(
message.contains("failed to parse Private Payment List JSON"),
"expected static parse error, got: {message}"
);
assert!(
!message.contains(sentinel),
"decrypted plaintext must not appear in errors, got: {message}"
);
}
#[test]
fn test_private_payment_list_invalid_header_does_not_embed_plaintext() {
let sentinel = "SENTINEL_PRIVATE_HEADER";
for field in ["kind", "app_id"] {
let mut json = serde_json::json!({
"version": 1,
"kind": "paykit.private_payment_list",
"app_id": "test-app",
"payment_endpoints": {},
});
json[field] = sentinel.into();
let error = parse_private_payment_list_json(&json.to_string()).unwrap_err();
assert!(matches!(error, PaykitError::InvalidData { .. }));
assert!(!format!("{error:?}").contains(sentinel));
assert!(!error.to_string().contains(sentinel));
}
}
#[test]
fn test_parse_private_payment_list_json_trailing_comma() {
let err = parse_private_payment_list_json(r#"{"lightning": "ln...",}"#).unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for trailing comma, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_null() {
let err = parse_private_payment_list_json("null").unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("failed to parse")),
"expected InvalidData for JSON null, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_empty_key() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"":"ln..."}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
"expected InvalidData for empty key, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_path_traversal_key() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"..":"ln..."}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
"expected InvalidData for path-traversal key, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_slash_in_key() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"foo/bar":"ln..."}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
"expected InvalidData for key with slash, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_reserved_private_key() {
let err = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"private":"secret..."}}"#,
)
.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
"expected InvalidData for reserved 'private' key, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_oversized_key() {
let long_key = "a".repeat(65);
let json = format!(
r#"{{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{{"{long_key}":"ln..."}}}}"#
);
let err = parse_private_payment_list_json(&json).unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
"expected InvalidData for oversized key, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_one_valid_one_invalid_key() {
let err =
parse_private_payment_list_json(r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln...","":"bc1..."}}"#).unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { ref context, .. } if context.contains("invalid Payment Endpoint Identifier")),
"expected InvalidData when one key is invalid, got: {err}"
);
}
#[test]
fn test_parse_private_payment_list_json_valid_single_payment_endpoint() {
let result = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln..."}}"#,
)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result.get(&PaymentEndpointIdentifier::new("lightning").unwrap()),
Some(&PaymentEndpointPayload::new("ln..."))
);
}
#[test]
fn test_parse_private_payment_list_json_valid_multiple_payment_endpoints() {
let result =
parse_private_payment_list_json(r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{"lightning":"ln...","onchain":"bc1..."}}"#).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(
result.get(&PaymentEndpointIdentifier::new("lightning").unwrap()),
Some(&PaymentEndpointPayload::new("ln..."))
);
assert_eq!(
result.get(&PaymentEndpointIdentifier::new("onchain").unwrap()),
Some(&PaymentEndpointPayload::new("bc1..."))
);
}
#[test]
fn test_parse_private_payment_list_json_empty_object() {
let result = parse_private_payment_list_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{}}"#,
)
.unwrap();
assert!(result.is_empty());
}
}