1use std::collections::HashMap;
2
3use tracing::{debug, instrument};
4
5use crate::{error::map_error, pubky_routing, PaykitAppId, PaykitError, PublicKey, Result};
6
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
33pub struct PaymentEndpointIdentifier(String);
34
35const PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN: usize = 64;
37const PAYMENT_ENDPOINT_IDENTIFIER_RESERVED_PRIVATE: &str = "private";
39
40impl PaymentEndpointIdentifier {
41 pub fn new(id: impl Into<String>) -> Result<Self> {
47 let id = id.into();
48
49 if id.is_empty() {
50 return Err(PaykitError::Validation(
51 "PaymentEndpointIdentifier must not be empty".into(),
52 ));
53 }
54
55 if id.len() > PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN {
56 return Err(PaykitError::Validation(format!(
57 "PaymentEndpointIdentifier must not exceed {PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN} characters, got {}",
58 id.chars().count()
59 )));
60 }
61
62 if id == PAYMENT_ENDPOINT_IDENTIFIER_RESERVED_PRIVATE {
63 return Err(PaykitError::Validation(format!(
64 "PaymentEndpointIdentifier '{id}' is reserved for Paykit storage"
65 )));
66 }
67
68 if let Some((pos, ch)) = id
70 .char_indices()
71 .find(|&(_, ch)| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'))
72 {
73 return Err(PaykitError::Validation(format!(
77 "PaymentEndpointIdentifier contains forbidden character U+{:04X} at byte {pos}",
78 ch as u32
79 )));
80 }
81
82 if id.bytes().all(|b| b == b'.') {
84 return Err(PaykitError::Validation(format!(
85 "PaymentEndpointIdentifier must not be a path-traversal component: \"{id}\""
86 )));
87 }
88
89 Ok(Self(id))
90 }
91
92 pub fn as_str(&self) -> &str {
94 &self.0
95 }
96}
97
98impl std::fmt::Display for PaymentEndpointIdentifier {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str(&self.0)
101 }
102}
103
104impl AsRef<str> for PaymentEndpointIdentifier {
105 fn as_ref(&self) -> &str {
106 &self.0
107 }
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct PaymentEndpointPayload(String);
119
120pub const PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
122pub const PAYMENT_LIST_MAX_ENDPOINTS: usize = 256;
124
125impl PaymentEndpointPayload {
126 pub fn new(data: impl Into<String>) -> Self {
128 Self(data.into())
129 }
130
131 pub fn as_str(&self) -> &str {
133 &self.0
134 }
135
136 pub fn into_inner(self) -> String {
138 self.0
139 }
140}
141
142impl std::fmt::Display for PaymentEndpointPayload {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.write_str(&self.0)
145 }
146}
147
148impl AsRef<str> for PaymentEndpointPayload {
149 fn as_ref(&self) -> &str {
150 &self.0
151 }
152}
153
154#[derive(Clone, Debug, Default, PartialEq, Eq)]
156pub struct PaymentList {
157 pub payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
159}
160
161#[instrument(skip(session, payload), fields(identifier = %identifier))]
175pub async fn set_payment_endpoint(
176 session: &pubky::PubkySession,
177 app_id: &PaykitAppId,
178 identifier: PaymentEndpointIdentifier,
179 payload: PaymentEndpointPayload,
180) -> Result<()> {
181 debug!("storing payment endpoint");
182 pubky_routing::upsert_payment_endpoint(session, app_id, &identifier, &payload)
183 .await
184 .map_err(|err| map_error("set_payment_endpoint", err))
185}
186
187pub async fn create_payment_endpoint(
191 session: &pubky::PubkySession,
192 app_id: &PaykitAppId,
193 identifier: PaymentEndpointIdentifier,
194 payload: PaymentEndpointPayload,
195) -> Result<()> {
196 pubky_routing::create_payment_endpoint(session, app_id, &identifier, &payload)
197 .await
198 .map_err(|err| map_error("create_payment_endpoint", err))
199}
200
201pub async fn update_payment_endpoint(
205 session: &pubky::PubkySession,
206 app_id: &PaykitAppId,
207 identifier: PaymentEndpointIdentifier,
208 payload: PaymentEndpointPayload,
209 expected_revision: &str,
210) -> Result<()> {
211 pubky_routing::update_payment_endpoint(
212 session,
213 app_id,
214 &identifier,
215 &payload,
216 expected_revision,
217 )
218 .await
219 .map_err(|err| map_error("update_payment_endpoint", err))
220}
221
222#[instrument(skip(session), fields(identifier = %identifier))]
224pub async fn remove_payment_endpoint(
225 session: &pubky::PubkySession,
226 app_id: &PaykitAppId,
227 identifier: PaymentEndpointIdentifier,
228) -> Result<()> {
229 debug!("removing payment endpoint");
230 pubky_routing::delete_payment_endpoint(session, app_id, &identifier)
231 .await
232 .map_err(|err| map_error("remove_payment_endpoint", err))
233}
234
235pub async fn remove_payment_endpoint_if_revision(
239 session: &pubky::PubkySession,
240 app_id: &PaykitAppId,
241 identifier: PaymentEndpointIdentifier,
242 expected_revision: &str,
243) -> Result<()> {
244 pubky_routing::delete_payment_endpoint_if_revision(
245 session,
246 app_id,
247 &identifier,
248 expected_revision,
249 )
250 .await
251 .map_err(|err| map_error("remove_payment_endpoint_if_revision", err))
252}
253
254#[instrument(skip(storage))]
285pub async fn get_payment_list(
286 storage: &pubky::PublicStorage,
287 payee: &PublicKey,
288 app_id: &PaykitAppId,
289) -> Result<PaymentList> {
290 get_payment_list_with_limits(
291 storage,
292 payee,
293 app_id,
294 PAYMENT_LIST_MAX_ENDPOINTS,
295 PAYMENT_LIST_MAX_ENDPOINTS * PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES,
296 )
297 .await
298}
299
300#[instrument(skip(storage))]
305pub async fn get_payment_list_with_limits(
306 storage: &pubky::PublicStorage,
307 payee: &PublicKey,
308 app_id: &PaykitAppId,
309 max_endpoints: usize,
310 max_total_payload_bytes: usize,
311) -> Result<PaymentList> {
312 let mut remaining_requests = usize::MAX;
313 let mut remaining_endpoints = max_endpoints;
314 let mut remaining_payload_bytes = max_total_payload_bytes;
315 get_payment_list_with_budget(
316 storage,
317 payee,
318 app_id,
319 &mut remaining_endpoints,
320 &mut remaining_requests,
321 &mut remaining_payload_bytes,
322 )
323 .await
324}
325
326#[instrument(skip(
338 storage,
339 remaining_endpoints,
340 remaining_requests,
341 remaining_payload_bytes
342))]
343pub async fn get_payment_list_with_budget(
344 storage: &pubky::PublicStorage,
345 payee: &PublicKey,
346 app_id: &PaykitAppId,
347 remaining_endpoints: &mut usize,
348 remaining_requests: &mut usize,
349 remaining_payload_bytes: &mut usize,
350) -> Result<PaymentList> {
351 debug!("fetching Payment List");
352 let result = pubky_routing::fetch_payment_list_with_budget(
353 storage,
354 payee,
355 app_id,
356 remaining_endpoints,
357 remaining_requests,
358 remaining_payload_bytes,
359 )
360 .await
361 .map_err(|err| map_error("get_payment_list", err))?;
362 debug!(
363 count = result.payment_endpoints.len(),
364 "Payment List retrieved"
365 );
366 Ok(result)
367}
368
369pub fn is_payment_list_limit_exceeded(error: &PaykitError) -> bool {
371 pubky_routing::is_payment_list_limit_exceeded(error)
372}
373
374#[instrument(skip(storage), fields(identifier = %identifier))]
396pub async fn get_payment_endpoint(
397 storage: &pubky::PublicStorage,
398 payee: &PublicKey,
399 app_id: &PaykitAppId,
400 identifier: &PaymentEndpointIdentifier,
401) -> Result<Option<PaymentEndpointPayload>> {
402 debug!("fetching payment endpoint");
403 let result = pubky_routing::fetch_payment_endpoint(storage, payee, app_id, identifier)
404 .await
405 .map_err(|err| map_error("get_payment_endpoint", err))?;
406 debug!(found = result.is_some(), "payment endpoint lookup complete");
407 Ok(result)
408}
409
410pub async fn get_payment_endpoint_with_revision(
415 storage: &pubky::PublicStorage,
416 payee: &PublicKey,
417 app_id: &PaykitAppId,
418 identifier: &PaymentEndpointIdentifier,
419) -> Result<Option<(Option<PaymentEndpointPayload>, String)>> {
420 pubky_routing::fetch_payment_endpoint_with_revision(storage, payee, app_id, identifier)
421 .await
422 .map_err(|err| map_error("get_payment_endpoint_with_revision", err))
423}
424
425#[cfg(test)]
426mod validation_tests {
427 use super::*;
428
429 #[test]
432 fn test_payment_endpoint_identifier_valid_simple_names() {
433 for name in [
434 "btc-lightning-bolt11",
435 "btc-lightning-bolt12",
436 "btc-bitcoin-p2tr",
437 ] {
438 assert!(
439 PaymentEndpointIdentifier::new(name).is_ok(),
440 "expected '{name}' to be valid"
441 );
442 }
443 }
444
445 #[test]
446 fn test_payment_endpoint_identifier_valid_with_dots() {
447 let m = PaymentEndpointIdentifier::new("method.v2").unwrap();
448 assert_eq!(m.as_str(), "method.v2");
449 }
450
451 #[test]
452 fn test_payment_endpoint_identifier_valid_with_underscores() {
453 let m = PaymentEndpointIdentifier::new("my_method").unwrap();
454 assert_eq!(m.as_str(), "my_method");
455 }
456
457 #[test]
458 fn test_payment_endpoint_identifier_valid_mixed_case() {
459 let m = PaymentEndpointIdentifier::new("LnUrl-Pay").unwrap();
460 assert_eq!(m.as_str(), "LnUrl-Pay");
461 }
462
463 #[test]
464 fn test_payment_endpoint_identifier_valid_max_length() {
465 let name = "a".repeat(PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN);
466 assert!(PaymentEndpointIdentifier::new(&name).is_ok());
467 }
468
469 #[test]
470 fn test_payment_endpoint_identifier_valid_single_char() {
471 assert!(PaymentEndpointIdentifier::new("x").is_ok());
472 }
473
474 #[test]
475 fn test_payment_endpoint_identifier_display() {
476 let m = PaymentEndpointIdentifier::new("lightning").unwrap();
477 assert_eq!(format!("{m}"), "lightning");
478 }
479
480 #[test]
481 fn test_payment_endpoint_identifier_as_ref() {
482 let m = PaymentEndpointIdentifier::new("onchain").unwrap();
483 let s: &str = m.as_ref();
484 assert_eq!(s, "onchain");
485 }
486
487 #[test]
490 fn test_payment_endpoint_identifier_reject_empty() {
491 let err = PaymentEndpointIdentifier::new("").unwrap_err();
492 assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("empty")));
493 }
494
495 #[test]
496 fn test_payment_endpoint_identifier_reject_path_traversal_dotdot() {
497 assert!(PaymentEndpointIdentifier::new("..").is_err());
498 }
499
500 #[test]
501 fn test_payment_endpoint_identifier_reject_path_traversal_dot() {
502 assert!(PaymentEndpointIdentifier::new(".").is_err());
503 }
504
505 #[test]
506 fn test_payment_endpoint_identifier_reject_path_traversal_sequence() {
507 assert!(PaymentEndpointIdentifier::new("../etc/passwd").is_err());
510 assert!(PaymentEndpointIdentifier::new("../../foo").is_err());
511 }
512
513 #[test]
514 fn test_payment_endpoint_identifier_reject_forward_slash() {
515 assert!(PaymentEndpointIdentifier::new("foo/bar").is_err());
516 }
517
518 #[test]
519 fn test_payment_endpoint_identifier_reject_backslash() {
520 assert!(PaymentEndpointIdentifier::new("a\\b").is_err());
521 }
522
523 #[test]
524 fn test_payment_endpoint_identifier_reject_null_byte() {
525 assert!(PaymentEndpointIdentifier::new("foo\0bar").is_err());
526 }
527
528 #[test]
529 fn test_payment_endpoint_identifier_reject_too_long() {
530 let name = "a".repeat(PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN + 1);
531 let err = PaymentEndpointIdentifier::new(&name).unwrap_err();
532 assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("exceed")));
533 }
534
535 #[test]
536 fn test_payment_endpoint_identifier_reject_space() {
537 assert!(PaymentEndpointIdentifier::new("foo bar").is_err());
538 }
539
540 #[test]
541 fn test_payment_endpoint_identifier_reject_special_chars() {
542 for bad in ["foo@bar", "foo:bar", "foo?bar", "foo#bar", "foo=bar"] {
543 assert!(
544 PaymentEndpointIdentifier::new(bad).is_err(),
545 "expected '{bad}' to be rejected"
546 );
547 }
548 }
549
550 #[test]
551 fn test_payment_endpoint_identifier_reject_unicode() {
552 assert!(PaymentEndpointIdentifier::new("⚡lightning").is_err());
553 }
554
555 #[test]
556 fn test_payment_endpoint_identifier_reject_triple_dots() {
557 assert!(PaymentEndpointIdentifier::new("...").is_err());
558 }
559
560 #[test]
561 fn test_payment_endpoint_identifier_reject_reserved_values() {
562 let err = PaymentEndpointIdentifier::new("private").unwrap_err();
563 assert!(matches!(err, PaykitError::Validation(msg) if msg.contains("reserved")));
564 }
565
566 #[test]
567 fn test_payment_endpoint_identifier_forbidden_character_error_redacts_value() {
568 let err = PaymentEndpointIdentifier::new("evil_SENTINEL\tid").unwrap_err();
569 assert!(
570 matches!(&err, PaykitError::Validation(msg)
571 if msg.contains("U+0009 at byte 13")
572 && !msg.contains("SENTINEL")
573 && !msg.contains('\t')),
574 "{err:?}"
575 );
576 }
577
578 #[test]
581 fn test_payment_endpoint_payload_new_and_accessors() {
582 let d = PaymentEndpointPayload::new("ln...");
583 assert_eq!(d.as_str(), "ln...");
584 assert_eq!(format!("{d}"), "ln...");
585 }
586
587 #[test]
588 fn test_payment_endpoint_payload_into_inner() {
589 let d = PaymentEndpointPayload::new("payload");
590 assert_eq!(d.into_inner(), "payload");
591 }
592
593 #[test]
594 fn test_payment_endpoint_payload_as_ref() {
595 let d = PaymentEndpointPayload::new("data");
596 let s: &str = d.as_ref();
597 assert_eq!(s, "data");
598 }
599}