Skip to main content

paykit_lib/
payment_endpoint.rs

1use std::collections::HashMap;
2
3use tracing::{debug, instrument};
4
5use crate::{error::map_error, pubky_routing, PaykitAppId, PaykitError, PublicKey, Result};
6
7/// Machine-readable identifier for a Payment Endpoint.
8///
9/// A `PaymentEndpointIdentifier` is a single, safe path segment stored in an
10/// app-owned endpoint directory. It is validated at construction time to
11/// prevent path injection attacks.
12///
13/// # Allowed characters
14/// ASCII alphanumeric (`a-z`, `A-Z`, `0-9`), hyphens (`-`), underscores (`_`),
15/// and dots (`.`) — but the value must not consist solely of dots (i.e. `"."` and
16/// `".."` are rejected).
17///
18/// # Limits
19/// - Must not be empty.
20/// - Must not exceed 64 characters.
21/// - Must not be a reserved Paykit storage value.
22///
23/// # Examples
24/// ```
25/// # use paykit_lib::PaymentEndpointIdentifier;
26/// let id = PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap();
27/// assert_eq!(id.as_str(), "btc-lightning-bolt11");
28///
29/// // Path traversal is rejected:
30/// assert!(PaymentEndpointIdentifier::new("../etc/passwd").is_err());
31/// ```
32#[derive(Clone, Debug, PartialEq, Eq, Hash)]
33pub struct PaymentEndpointIdentifier(String);
34
35/// Maximum length (in bytes) of a [`PaymentEndpointIdentifier`] value.
36const PAYMENT_ENDPOINT_IDENTIFIER_MAX_LEN: usize = 64;
37/// Reserved [`PaymentEndpointIdentifier`] value used by private Paykit storage.
38const PAYMENT_ENDPOINT_IDENTIFIER_RESERVED_PRIVATE: &str = "private";
39
40impl PaymentEndpointIdentifier {
41    /// Create a new `PaymentEndpointIdentifier` after validating the identifier.
42    ///
43    /// Returns `Err(PaykitError::Validation)` if the value is empty, too long,
44    /// contains forbidden characters, resembles a path-traversal component,
45    /// or collides with a reserved identifier.
46    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        // Every character must be ASCII alphanumeric, hyphen, underscore, or dot.
69        if let Some((pos, ch)) = id
70            .char_indices()
71            .find(|&(_, ch)| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'))
72        {
73            // SECURITY / REDACTION: the identifier can come from decrypted
74            // private-message plaintext or remote public data. Report only the
75            // code point and byte offset, as `PaymentReference::new` does.
76            return Err(PaykitError::Validation(format!(
77                "PaymentEndpointIdentifier contains forbidden character U+{:04X} at byte {pos}",
78                ch as u32
79            )));
80        }
81
82        // Reject pure-dot names that are path-traversal components.
83        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    /// Access the inner identifier string.
93    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/// Serialized Payment Endpoint Payload served by a Payment Endpoint.
111///
112/// The payload is UTF-8 text such as JSON, lnurl, or another payment-specific
113/// descriptor. If you need to transmit binary payloads, encode them (for
114/// example base64) before wrapping in `PaymentEndpointPayload`.
115/// Public storage helpers reject payloads larger than
116/// [`PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES`].
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct PaymentEndpointPayload(String);
119
120/// Maximum encoded size of one public Payment Endpoint payload.
121pub const PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
122/// Maximum number of public Payment Endpoints fetched for one Paykit App.
123pub const PAYMENT_LIST_MAX_ENDPOINTS: usize = 256;
124
125impl PaymentEndpointPayload {
126    /// Wrap a UTF-8 string as a Payment Endpoint Payload.
127    pub fn new(data: impl Into<String>) -> Self {
128        Self(data.into())
129    }
130
131    /// Access the inner payload string.
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135
136    /// Consume the wrapper and return the inner string.
137    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/// Collection of Payment Endpoints keyed by Payment Endpoint Identifiers.
155#[derive(Clone, Debug, Default, PartialEq, Eq)]
156pub struct PaymentList {
157    /// Map of Payment Endpoint Identifier to Payment Endpoint Payload.
158    pub payment_endpoints: HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>,
159}
160
161/// Stores or updates a public payment endpoint in the authenticated Pubky session.
162///
163/// # Examples
164/// ```
165/// # use paykit_lib::{set_payment_endpoint, PaykitAppId, PaymentEndpointIdentifier, PaymentEndpointPayload};
166/// # async fn demo(session: &pubky::PubkySession) -> paykit_lib::Result<()> {
167/// let app_id = PaykitAppId::new("bitkit")?;
168/// let identifier = PaymentEndpointIdentifier::new("btc-lightning-bolt11")?;
169/// let payload = PaymentEndpointPayload::new("ln...");
170/// set_payment_endpoint(session, &app_id, identifier, payload).await?;
171/// # Ok(())
172/// # }
173/// ```
174#[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
187/// Creates a public Payment Endpoint only when it is currently absent.
188///
189/// Session creation, capability scope, and key rotation remain the caller's responsibility.
190pub 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
201/// Replaces a public Payment Endpoint only at the supplied strong revision.
202///
203/// Session creation, capability scope, and key rotation remain the caller's responsibility.
204pub 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/// Removes a public payment endpoint from the authenticated Pubky session.
223#[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
235/// Removes a public Payment Endpoint only at the supplied strong revision.
236///
237/// Session creation, capability scope, and key rotation remain the caller's responsibility.
238pub 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/// Retrieves the public Payment List for the given payee.
255///
256/// # Semantics
257/// - Returns an empty map when the payee has not published any endpoints or their
258///   storage directory is missing.
259/// - Returns `Err(PaykitError::InvalidData)` when a resource path is unparseable or
260///   an endpoint payload contains invalid UTF-8.
261/// - Returns `Err(PaykitError::Transport)` for network or transport-layer failures.
262///
263/// # Examples
264/// ```
265/// # #![recursion_limit = "256"]
266/// # use paykit_lib::{get_payment_list, PaykitAppId};
267/// # async fn demo(storage: &pubky::PublicStorage, pk: &paykit_lib::PublicKey) -> paykit_lib::Result<()> {
268/// let app_id = PaykitAppId::new("bitkit")?;
269/// let payments = get_payment_list(storage, pk, &app_id).await?;
270/// if payments.payment_endpoints.is_empty() {
271///     println!("payee published no endpoints yet");
272/// } else {
273///     for (identifier, payload) in &payments.payment_endpoints {
274///         println!(
275///             "identifier={} payload={}",
276///             identifier.as_str(),
277///             payload.as_str()
278///         );
279///     }
280/// }
281/// # Ok(())
282/// # }
283/// ```
284#[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/// Fetch an app's public Payment Endpoints within caller-supplied aggregate limits.
301///
302/// The fetch fails if the app publishes more endpoints or total payload bytes
303/// than the supplied limits. Per-endpoint protocol limits still apply.
304#[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/// Fetch an app's public Payment Endpoints using a budget shared across app loads.
327///
328/// Each directory-page or endpoint request consumes one request before it starts.
329/// Each endpoint fetch also consumes one endpoint attempt, regardless of its result.
330/// Endpoint body bytes consume the byte budget as they are read, even if the list
331/// later fails validation. Missing and empty resources still consume requests.
332/// Counters are retained on failure; exhausting the aggregate byte budget saturates
333/// that counter at zero. Per-endpoint and per-list protocol limits still apply.
334///
335/// The caller owns client timeouts, session creation, capability scope, and key
336/// rotation. This read only requires public Pubky storage.
337#[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
369/// Return whether a Payment List fetch failed because it exceeded caller-supplied limits.
370pub fn is_payment_list_limit_exceeded(error: &PaykitError) -> bool {
371    pubky_routing::is_payment_list_limit_exceeded(error)
372}
373
374/// Retrieves a specific Payment Endpoint for `payee` and `identifier`.
375///
376/// # Semantics
377/// - Returns `Ok(None)` when the endpoint file is missing or empty.
378/// - Returns `Err(PaykitError::InvalidData)` when the endpoint payload contains invalid UTF-8.
379/// - Returns `Err(PaykitError::Transport)` for network or transport-layer failures.
380///
381/// # Examples
382/// ```
383/// # use paykit_lib::{get_payment_endpoint, PaykitAppId, PaymentEndpointIdentifier, PublicKey};
384/// # async fn inspect(storage: &pubky::PublicStorage, pk: &PublicKey) -> paykit_lib::Result<()> {
385/// let app_id = PaykitAppId::new("bitkit")?;
386/// let lightning = PaymentEndpointIdentifier::new("btc-lightning-bolt11")?;
387/// if let Some(endpoint) = get_payment_endpoint(storage, pk, &app_id, &lightning).await? {
388///     println!("lightning endpoint: {}", endpoint.as_str());
389/// } else {
390///     println!("no lightning endpoint published");
391/// }
392/// # Ok(())
393/// # }
394/// ```
395#[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
410/// Retrieves a specific public Payment Endpoint and its strong revision.
411///
412/// The outer `Option` is `None` when the resource is absent. The inner payload
413/// is `None` when the resource exists but is empty.
414pub 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    // ── PaymentEndpointIdentifier: valid inputs ──────────────────────────────────────────
430
431    #[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    // ── PaymentEndpointIdentifier: invalid inputs ────────────────────────────────────────
488
489    #[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        // Slashes are rejected by the character allowlist, but verify the
508        // specific traversal pattern is caught.
509        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    // ── PaymentEndpointPayload: basic accessors ───────────────────────────────────
579
580    #[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}