use std::collections::HashMap;
use tracing::{debug, instrument};
use crate::{error::map_error, pubky_routing, PaykitAppId, PaykitError, PublicKey, Result};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PaymentEndpointIdentifier(String);
const PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN: usize = 64;
const PAYMENT_ENDPOINT_IDENTIFIER_RESERVED_PRIVATE: &str = "private";
impl PaymentEndpointIdentifier {
pub fn new(id: impl Into<String>) -> Result<Self> {
let id = id.into();
if id.is_empty() {
return Err(PaykitError::Validation(
"PaymentEndpointIdentifier must not be empty".into(),
));
}
if id.len() > PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN {
return Err(PaykitError::Validation(format!(
"PaymentEndpointIdentifier must not exceed {PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN} characters, got {}",
id.chars().count()
)));
}
if id == PAYMENT_ENDPOINT_IDENTIFIER_RESERVED_PRIVATE {
return Err(PaykitError::Validation(format!(
"PaymentEndpointIdentifier '{id}' is reserved for Paykit storage"
)));
}
if let Some((pos, ch)) = id
.char_indices()
.find(|&(_, ch)| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'))
{
return Err(PaykitError::Validation(format!(
"PaymentEndpointIdentifier contains forbidden character U+{:04X} at byte {pos}",
ch as u32
)));
}
if id.bytes().all(|b| b == b'.') {
return Err(PaykitError::Validation(format!(
"PaymentEndpointIdentifier must not be a path-traversal component: \"{id}\""
)));
}
Ok(Self(id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PaymentEndpointIdentifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for PaymentEndpointIdentifier {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PaymentEndpointPayload(String);
pub const PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
pub const PAYMENT_LIST_MAX_ENDPOINTS: usize = 256;
impl PaymentEndpointPayload {
pub fn new(data: impl Into<String>) -> Self {
Self(data.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for PaymentEndpointPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for PaymentEndpointPayload {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaymentList {
pub payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
}
#[instrument(skip(session, payload), fields(identifier = %identifier))]
pub async fn set_payment_endpoint(
session: &pubky::PubkySession,
app_id: &PaykitAppId,
identifier: PaymentEndpointIdentifier,
payload: PaymentEndpointPayload,
) -> Result<()> {
debug!("storing payment endpoint");
pubky_routing::upsert_payment_endpoint(session, app_id, &identifier, &payload)
.await
.map_err(|err| map_error("set_payment_endpoint", err))
}
pub async fn create_payment_endpoint(
session: &pubky::PubkySession,
app_id: &PaykitAppId,
identifier: PaymentEndpointIdentifier,
payload: PaymentEndpointPayload,
) -> Result<()> {
pubky_routing::create_payment_endpoint(session, app_id, &identifier, &payload)
.await
.map_err(|err| map_error("create_payment_endpoint", err))
}
pub async fn update_payment_endpoint(
session: &pubky::PubkySession,
app_id: &PaykitAppId,
identifier: PaymentEndpointIdentifier,
payload: PaymentEndpointPayload,
expected_revision: &str,
) -> Result<()> {
pubky_routing::update_payment_endpoint(
session,
app_id,
&identifier,
&payload,
expected_revision,
)
.await
.map_err(|err| map_error("update_payment_endpoint", err))
}
#[instrument(skip(session), fields(identifier = %identifier))]
pub async fn remove_payment_endpoint(
session: &pubky::PubkySession,
app_id: &PaykitAppId,
identifier: PaymentEndpointIdentifier,
) -> Result<()> {
debug!("removing payment endpoint");
pubky_routing::delete_payment_endpoint(session, app_id, &identifier)
.await
.map_err(|err| map_error("remove_payment_endpoint", err))
}
pub async fn remove_payment_endpoint_if_revision(
session: &pubky::PubkySession,
app_id: &PaykitAppId,
identifier: PaymentEndpointIdentifier,
expected_revision: &str,
) -> Result<()> {
pubky_routing::delete_payment_endpoint_if_revision(
session,
app_id,
&identifier,
expected_revision,
)
.await
.map_err(|err| map_error("remove_payment_endpoint_if_revision", err))
}
#[instrument(skip(storage))]
pub async fn get_payment_list(
storage: &pubky::PublicStorage,
payee: &PublicKey,
app_id: &PaykitAppId,
) -> Result<PaymentList> {
get_payment_list_with_limits(
storage,
payee,
app_id,
PAYMENT_LIST_MAX_ENDPOINTS,
PAYMENT_LIST_MAX_ENDPOINTS * PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES,
)
.await
}
#[instrument(skip(storage))]
pub async fn get_payment_list_with_limits(
storage: &pubky::PublicStorage,
payee: &PublicKey,
app_id: &PaykitAppId,
max_endpoints: usize,
max_total_payload_bytes: usize,
) -> Result<PaymentList> {
let mut remaining_requests = usize::MAX;
let mut remaining_endpoints = max_endpoints;
let mut remaining_payload_bytes = max_total_payload_bytes;
get_payment_list_with_budget(
storage,
payee,
app_id,
&mut remaining_endpoints,
&mut remaining_requests,
&mut remaining_payload_bytes,
)
.await
}
#[instrument(skip(
storage,
remaining_endpoints,
remaining_requests,
remaining_payload_bytes
))]
pub async fn get_payment_list_with_budget(
storage: &pubky::PublicStorage,
payee: &PublicKey,
app_id: &PaykitAppId,
remaining_endpoints: &mut usize,
remaining_requests: &mut usize,
remaining_payload_bytes: &mut usize,
) -> Result<PaymentList> {
debug!("fetching Payment List");
let result = pubky_routing::fetch_payment_list_with_budget(
storage,
payee,
app_id,
remaining_endpoints,
remaining_requests,
remaining_payload_bytes,
)
.await
.map_err(|err| map_error("get_payment_list", err))?;
debug!(
count = result.payment_endpoints.len(),
"Payment List retrieved"
);
Ok(result)
}
pub fn is_payment_list_limit_exceeded(error: &PaykitError) -> bool {
pubky_routing::is_payment_list_limit_exceeded(error)
}
#[instrument(skip(storage), fields(identifier = %identifier))]
pub async fn get_payment_endpoint(
storage: &pubky::PublicStorage,
payee: &PublicKey,
app_id: &PaykitAppId,
identifier: &PaymentEndpointIdentifier,
) -> Result<Option<PaymentEndpointPayload>> {
debug!("fetching payment endpoint");
let result = pubky_routing::fetch_payment_endpoint(storage, payee, app_id, identifier)
.await
.map_err(|err| map_error("get_payment_endpoint", err))?;
debug!(found = result.is_some(), "payment endpoint lookup complete");
Ok(result)
}
pub async fn get_payment_endpoint_with_revision(
storage: &pubky::PublicStorage,
payee: &PublicKey,
app_id: &PaykitAppId,
identifier: &PaymentEndpointIdentifier,
) -> Result<Option<(Option<PaymentEndpointPayload>, String)>> {
pubky_routing::fetch_payment_endpoint_with_revision(storage, payee, app_id, identifier)
.await
.map_err(|err| map_error("get_payment_endpoint_with_revision", err))
}
#[cfg(test)]
mod validation_tests {
use super::*;
#[test]
fn test_payment_endpoint_identifier_valid_simple_names() {
for name in [
"btc-lightning-bolt11",
"btc-lightning-bolt12",
"btc-bitcoin-p2tr",
] {
assert!(
PaymentEndpointIdentifier::new(name).is_ok(),
"expected '{name}' to be valid"
);
}
}
#[test]
fn test_payment_endpoint_identifier_valid_with_dots() {
let m = PaymentEndpointIdentifier::new("method.v2").unwrap();
assert_eq!(m.as_str(), "method.v2");
}
#[test]
fn test_payment_endpoint_identifier_valid_with_underscores() {
let m = PaymentEndpointIdentifier::new("my_method").unwrap();
assert_eq!(m.as_str(), "my_method");
}
#[test]
fn test_payment_endpoint_identifier_valid_mixed_case() {
let m = PaymentEndpointIdentifier::new("LnUrl-Pay").unwrap();
assert_eq!(m.as_str(), "LnUrl-Pay");
}
#[test]
fn test_payment_endpoint_identifier_valid_max_length() {
let name = "a".repeat(PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN);
assert!(PaymentEndpointIdentifier::new(&name).is_ok());
}
#[test]
fn test_payment_endpoint_identifier_valid_single_char() {
assert!(PaymentEndpointIdentifier::new("x").is_ok());
}
#[test]
fn test_payment_endpoint_identifier_display() {
let m = PaymentEndpointIdentifier::new("lightning").unwrap();
assert_eq!(format!("{m}"), "lightning");
}
#[test]
fn test_payment_endpoint_identifier_as_ref() {
let m = PaymentEndpointIdentifier::new("onchain").unwrap();
let s: &str = m.as_ref();
assert_eq!(s, "onchain");
}
#[test]
fn test_payment_endpoint_identifier_reject_empty() {
let err = PaymentEndpointIdentifier::new("").unwrap_err();
assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("empty")));
}
#[test]
fn test_payment_endpoint_identifier_reject_path_traversal_dotdot() {
assert!(PaymentEndpointIdentifier::new("..").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_path_traversal_dot() {
assert!(PaymentEndpointIdentifier::new(".").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_path_traversal_sequence() {
assert!(PaymentEndpointIdentifier::new("../etc/passwd").is_err());
assert!(PaymentEndpointIdentifier::new("../../foo").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_forward_slash() {
assert!(PaymentEndpointIdentifier::new("foo/bar").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_backslash() {
assert!(PaymentEndpointIdentifier::new("a\\b").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_null_byte() {
assert!(PaymentEndpointIdentifier::new("foo\0bar").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_too_long() {
let name = "a".repeat(PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN + 1);
let err = PaymentEndpointIdentifier::new(&name).unwrap_err();
assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("exceed")));
}
#[test]
fn test_payment_endpoint_identifier_reject_space() {
assert!(PaymentEndpointIdentifier::new("foo bar").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_special_chars() {
for bad in ["foo@bar", "foo:bar", "foo?bar", "foo#bar", "foo=bar"] {
assert!(
PaymentEndpointIdentifier::new(bad).is_err(),
"expected '{bad}' to be rejected"
);
}
}
#[test]
fn test_payment_endpoint_identifier_reject_unicode() {
assert!(PaymentEndpointIdentifier::new("⚡lightning").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_triple_dots() {
assert!(PaymentEndpointIdentifier::new("...").is_err());
}
#[test]
fn test_payment_endpoint_identifier_reject_reserved_values() {
let err = PaymentEndpointIdentifier::new("private").unwrap_err();
assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("reserved")));
}
#[test]
fn test_payment_endpoint_identifier_forbidden_character_error_redacts_value() {
let err = PaymentEndpointIdentifier::new("evil_SENTINEL\tid").unwrap_err();
assert!(
matches!(&err, PaykitError::Validation(msg)
if msg.contains("U+0009 at byte 13")
&& !msg.contains("SENTINEL")
&& !msg.contains('\t')),
"{err:?}"
);
}
#[test]
fn test_payment_endpoint_payload_new_and_accessors() {
let d = PaymentEndpointPayload::new("ln...");
assert_eq!(d.as_str(), "ln...");
assert_eq!(format!("{d}"), "ln...");
}
#[test]
fn test_payment_endpoint_payload_into_inner() {
let d = PaymentEndpointPayload::new("payload");
assert_eq!(d.into_inner(), "payload");
}
#[test]
fn test_payment_endpoint_payload_as_ref() {
let d = PaymentEndpointPayload::new("data");
let s: &str = d.as_ref();
assert_eq!(s, "data");
}
}