paykit-lib 0.1.0

Paykit library implementation
Documentation

Paykit Library

Stateless Rust crate that implements Paykit Library functionality on Pubky. It provides helpers for public Payment Endpoints (stored as plaintext files on the homeserver), Private Payment Lists, Payment Requests, Allowances, Encrypted Links, and Receipts, while delegating Pubky authentication and session management to callers.

Pubky is the only supported network/storage backend for this crate, and Pubky dependencies are unconditional.

Quick Start

Add paykit-lib to your Cargo.toml.

[dependencies]
paykit-lib = "0.1.0"
pubky = "0.16.2"

The caller supplies an authenticated pubky::PubkySession, a pubky::PublicStorage handle, and the payee's public key. Session creation, capability scope, and key rotation remain the caller's responsibility.

Minimal example: store and retrieve a public Payment Endpoint.

use paykit_lib::{
    set_payment_endpoint, get_payment_endpoint, get_payment_list,
    PaykitAppId, PaymentEndpointIdentifier, PaymentEndpointPayload,
};

# async fn example(
#     session: pubky::PubkySession,
#     public_storage: pubky::PublicStorage,
#     payee_pubkey: pubky::PublicKey,
# ) -> paykit_lib::Result<()> {
// Create validated types.
let app_id = PaykitAppId::new("bitkit")?;
let identifier = PaymentEndpointIdentifier::new("btc-lightning-bolt11")?;
let payload = PaymentEndpointPayload::new("lnbc1...");

// Store a Payment Endpoint using an authenticated PubkySession.
set_payment_endpoint(&session, &app_id, identifier.clone(), payload).await?;

// Read it back using pubky::PublicStorage.
if let Some(endpoint) = get_payment_endpoint(&public_storage, &payee_pubkey, &app_id, &identifier).await? {
    println!("{}", endpoint.as_str());
}

// List all Payment Endpoints published by this app for a payee.
let payment_list = get_payment_list(&public_storage, &payee_pubkey, &app_id).await?;
for (identifier, payload) in &payment_list.payment_endpoints {
    println!("{}: {}", identifier.as_str(), payload.as_str());
}
# Ok(())
# }

Core Types

Payment Request domain values use validated construction and read-only accessors. See the Rust migration guide for replacing struct literals and field mutation.

PaymentEndpointIdentifier

Machine-readable identifier for a Payment Endpoint type (for example "btc-bitcoin-p2tr" or "btc-lightning-bolt11"). PaymentEndpointIdentifier is validated at construction time to prevent path injection attacks.

use paykit_lib::PaymentEndpointIdentifier;

// Construction is fallible:
let identifier = PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap();
assert_eq!(identifier.as_str(), "btc-lightning-bolt11");

// Path traversal is rejected:
assert!(PaymentEndpointIdentifier::new("../etc/passwd").is_err());

Allowed values: 1-64 ASCII characters from the set [a-zA-Z0-9_-.]. The value must not consist solely of dots (. and .. are rejected as path traversal components), and private is reserved for Paykit storage. Slashes, null bytes, spaces, and other special characters are rejected.

PaymentEndpointIdentifier::new() returns Err(PaykitError::Validation(...)) for invalid input.

Read access is available via as_str(), Display, and AsRef<str>.

Naming convention: PaymentEndpointIdentifier values are opaque to the library, but counterparties need to agree on them to interoperate. A recommended (non-obligatory) convention for the shape of these identifiers is described in ../specs/payment-endpoint-identifier.md.

PaymentEndpointPayload

Serialized Payment Endpoint Payload (UTF-8 text such as JSON, lnurl, etc.).

use paykit_lib::PaymentEndpointPayload;

let payload = PaymentEndpointPayload::new("ln...");
let payload_str: &str = payload.as_str();
let owned: String = payload.into_inner();

PaymentList

Collection of Payment Endpoints keyed by Payment Endpoint Identifiers. Returned by get_payment_list.

use paykit_lib::{PaykitAppId, PaymentList};

let app_id = PaykitAppId::new("bitkit")?;
let payment_list: PaymentList = get_payment_list(&public_storage, &payee, &app_id).await?;

// Access the underlying map:
for (identifier, payload) in &payment_list.payment_endpoints {
    println!(
        "identifier={} payload={}",
        identifier.as_str(),
        payload.as_str()
    );
}

// Check if empty:
if payment_list.payment_endpoints.is_empty() {
    println!("no Payment Endpoints published");
}

Private Payment Lists use a versioned PrivatePaymentList value. The Payment Endpoints still use the same HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload> layout. Payment-specific correlation belongs in Payment Requests, Payment Proofs, and Receipts, not in endpoint publication:

use paykit_lib::{parse_private_payment_list_json, PrivateMessageKind};

let messages = link.receive_private_application_messages().await?;
for message in messages {
    if message.known_kind() == Some(PrivateMessageKind::PrivatePaymentList) {
        let list = parse_private_payment_list_json(&message.raw_json)?;
        for (identifier, payload) in &list.payment_endpoints {
            println!(
                "identifier={} payload={}",
                identifier.as_str(),
                payload.as_str()
            );
        }
    }
}

Private Payment Lists use Latest-State Message semantics per Paykit App at the SDK/runtime layer: if one app queues several list messages, its latest valid list supersedes its older list messages without replacing lists from other apps. Malformed newer list messages do not supersede the latest valid state. Receipt Access, Allowance, and Payment Request messages use Event Message semantics, so SDK/runtime code must preserve every valid message in send order.

The payment_endpoints field is a HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>.

Paykit Apps And App Registry

PaykitAppId identifies the app that owns a public Payment Endpoint or sent a private message. PaykitAppRegistry is one public identity-wide record at /pub/paykit/v0/app-registry.json; it lists participating apps, their coarse capabilities, the current identity-wide Noise public key and key generation, and optional default-app preferences. It is not private SDK state.

The registry does not authorize its Noise key. The identity signs a PaykitNoiseKeyAuthorization at /pub/paykit-authority/v0/current-key.json, outside /pub/paykit/:rw. get_paykit_noise_key_authorization verifies the signature and expected owner. Low-level callers must retain peer pins and match routing keys and remote_static_public_key() to the signed record before using new or restored links; follow the SDK's checks and trust model. Publishing requires separate authority-path write access.

Registries are bounded to 64 KiB, 64 applications, and 256 endpoint-specific defaults so remote data cannot trigger unbounded parsing or endpoint lookups.

Public Payment Endpoints are stored under /pub/paykit/v0/apps/{app_id}/endpoints/. Private Paykit messages share one identity-wide Encrypted Link per counterparty and include app_id in their versioned JSON.

PaykitError

Domain error enum with the following variants:

Variant Meaning
Transport { context, source } Network or Pubky SDK failure
NotFound(String) Requested resource does not exist (404/GONE)
InvalidData { context, source } Fetched data is corrupt or structurally invalid
Validation(String) Caller-supplied input failed validation (e.g. invalid PaymentEndpointIdentifier)

The source field on Transport and InvalidData is backed by [anyhow::Error] so callers can downcast via anyhow::Error::downcast_ref when they need the original typed error.

Result<T>

All public APIs return paykit_lib::Result<T>, which is an alias for std::result::Result<T, PaykitError>.

Pubky Sessions and I/O

  • Public Payment Endpoints use concrete Pubky SDK handles. Writes take &pubky::PubkySession; reads take &pubky::PublicStorage.
  • Private Payment Lists use pubky-noise's PubkyNoiseEncryptor for Noise-encrypted messaging, which handles both encryption and homeserver I/O through Pubky. Private Payment List functions accept an EncryptedLink established via an Encrypted Link Handshake.
  • Paykit stays stateless. Session creation, capability scoping, key rotation, account recovery, and client timeout configuration remain caller responsibilities.
  • Public payment paths are centralized by PAYKIT_PATH_PREFIX (/pub/paykit/v0/, including the trailing slash). Private Paykit message paths use PAYKIT_PRIVATE_PATH_PREFIX (/pub/paykit/v0/private) as the base for pubky-noise path derivation.

Timeout Handling

Paykit does not wrap Pubky SDK calls with its own deadline. A slow or unresponsive homeserver can block the caller unless the Pubky client is configured with appropriate timeouts. The Pubky SDK exposes PubkyHttpClientBuilder::request_timeout:

use std::time::Duration;
let client = PubkyHttpClient::builder()
    .request_timeout(Duration::from_secs(10))
    .build()?;

API Surface

Public Payment Endpoints

These functions operate on concrete Pubky SDK handles.

set_payment_endpoint

Store or update a payee-owned Payment Endpoint using the caller's authenticated pubky::PubkySession.

use paykit_lib::{set_payment_endpoint, PaykitAppId, PaymentEndpointIdentifier, PaymentEndpointPayload};

async fn demo(session: &pubky::PubkySession) -> paykit_lib::Result<()> {
    // NOTE: parties need to agree on Payment Endpoint Identifiers to interoperate.

    let app_id = PaykitAppId::new("bitkit")?;
    let identifier = PaymentEndpointIdentifier::new("btc-lightning-bolt11")?;
    let payload = PaymentEndpointPayload::new("ln...");
    set_payment_endpoint(session, &app_id, identifier, payload).await?;

    let identifier = PaymentEndpointIdentifier::new("btc-bitcoin-p2wpkh")?;
    let payload = PaymentEndpointPayload::new("bc1...");
    set_payment_endpoint(session, &app_id, identifier, payload).await?;
    // or
    let identifier = PaymentEndpointIdentifier::new("btc-bitcoin-p2tr")?;
    let payload = PaymentEndpointPayload::new("bc1...");
    set_payment_endpoint(session, &app_id, identifier, payload).await?;

    Ok(())
}

remove_payment_endpoint

Remove a previously published Payment Endpoint Payload for a given Payment Endpoint Identifier.

use paykit_lib::{remove_payment_endpoint, PaykitAppId, PaymentEndpointIdentifier};

async fn demo(session: &pubky::PubkySession) -> paykit_lib::Result<()> {
    let app_id = PaykitAppId::new("bitkit")?;
    let identifier = PaymentEndpointIdentifier::new("btc-lightning-bolt11")?;
    remove_payment_endpoint(session, &app_id, identifier).await?;
    Ok(())
}

Note: Removing a non-existent Payment Endpoint succeeds, so cleanup can be retried safely.

get_payment_list

Fetch the public Payment List for a public key. The result is empty when no Payment Endpoints are published.

use paykit_lib::{get_payment_list, PaykitAppId, PublicKey};

async fn demo(public_storage: &pubky::PublicStorage, pk: &PublicKey) -> paykit_lib::Result<()> {
    let app_id = PaykitAppId::new("bitkit")?;
    let payment_list = get_payment_list(public_storage, pk, &app_id).await?;
    if payment_list.payment_endpoints.is_empty() {
        println!("payee published no Payment Endpoints yet");
    } else {
        for (identifier, payload) in &payment_list.payment_endpoints {
            println!(
                "identifier={} payload={}",
                identifier.as_str(),
                payload.as_str()
            );
        }
    }
    Ok(())
}

get_payment_endpoint

Convenience resolver for a single Payment Endpoint Identifier. Returns Ok(None) when the Payment Endpoint Payload is missing or empty.

use paykit_lib::{get_payment_endpoint, PaykitAppId, PaymentEndpointIdentifier, PublicKey};

async fn inspect(public_storage: &pubky::PublicStorage, pk: &PublicKey) -> paykit_lib::Result<()> {
    let app_id = PaykitAppId::new("bitkit")?;
    let bolt11 = PaymentEndpointIdentifier::new("btc-lightning-bolt11")?;
    if let Some(endpoint) = get_payment_endpoint(public_storage, pk, &app_id, &bolt11).await? {
        println!("bolt11 payload: {}", endpoint.as_str());
    } else {
        println!("no bolt11 Payment Endpoint published");
    }
    Ok(())
}

Consistency Note

get_payment_list first lists available Payment Endpoints and then fetches each one individually. Because Pubky public storage does not support atomic reads, a race condition exists: between the directory listing and the individual fetches, endpoints may be added, removed, or modified by the payee. The returned PaymentList is therefore a best-effort snapshot.

If a payment execution fails with an error suggesting the endpoint has been consumed or is no longer valid, callers should:

  1. Re-fetch the specific app-owned endpoint via get_payment_endpoint.
  2. Compare the newly retrieved PaymentEndpointPayload with the value used in the failed attempt.
  3. If the Payment Endpoint Payload differs, retry the payment with the updated value.

Private Payment Lists

Private Payment Lists are end-to-end encrypted via a Noise protocol handshake managed by pubky-noise. PubkyNoiseEncryptor handles encryption, counter-based file naming, and prepared send/receive state transitions.

Storage paths for private Paykit data are derived per-counterparty pair using pubky_noise::path_derivation::derive_asymmetric_paths. Each party writes to a different path than they read from (write_path vs read_path), preventing third parties from enumerating communication relationships. The base prefix is /pub/paykit/v0/private; the derived hex component is appended as a child segment. Within each derived folder, pubky-noise manages individual file slots using a counter-based scheme — Paykit does not control file names or locations for private Paykit data.

The path domain is paykit-path-v0 followed by the two 32-byte Pubky identity keys in lexicographic byte order. The Noise DH secret remains part of the path hash. Copying another identity's advertised Noise key therefore does not share its stream, while apps under the same identity still share one path pair. Session-based helpers obtain the local identity from the Pubky session.

Handshake Initiation

  • initiate_encrypted_link(session, sender_noise_secret_key, receiver_identity_public_key, receiver_noise_public_key, recovery_context, outbox_client) -> Result<EncryptedLinkHandshake> Initializes a Noise XX handshake as the initiator. Returns a handshake handle to be driven forward with advance_handshake.
  • accept_encrypted_link(session, receiver_noise_secret_key, sender_identity_public_key, sender_noise_public_key, recovery_context, outbox_client) -> Result<EncryptedLinkHandshake> Initializes a Noise XX handshake as the responder. Returns a handshake handle to be driven forward with advance_handshake.

EncryptedLinkRecoveryContext contains the local and remote recovery attempt IDs. Both peers use the same pair in opposite order. Replacement attempts derive separate stream paths; snapshots preserve the context for restart. The SDK manages persistent recovery markers and chooses the initiator by comparing Pubky public keys. Low-level callers must coordinate these roles and IDs themselves.

Handshake advancing

  • advance_handshake(handshake: EncryptedLinkHandshake) -> std::result::Result<HandshakeProgress, HandshakeAdvanceError> Advances the handshake by one step. Returns HandshakeProgress::Pending(handle) when waiting for the counterparty, or HandshakeProgress::Complete(EncryptedLink) when finished. Polling-safe — the caller controls retry timing and timeouts. If a homeserver write fails during the handshake (HomeserverWriteError), the function automatically recovers from a pre-mutation snapshot and returns Pending so the caller's polling loop retries transparently. The maximum number of consecutive recovery attempts is configurable via EncryptedLinkHandshake::set_max_recovery_attempts (default: DEFAULT_MAX_RECOVERY_ATTEMPTS, 3). The recovery-attempt counter resets to zero after every successful step.

    HTTP/response-body transport read failures remain real errors. HandshakeAdvanceError::error() borrows the underlying PaykitError; into_parts() returns that error and an optional boxed handshake. Only an unchanged transport read failure retains the handle, which can be passed back as *handshake after the caller decides to retry. Retained state does not imply a transient failure or authorize automatic retries. Malformed packets and other errors carry no retry handle, and Debug never prints retained handshake state.

Handshake checkpointing / resumption

  • EncryptedLinkHandshake::snapshot() -> Result<EncryptedLinkHandshakeSnapshot> Captures the current in-progress handshake state. Fails while a prepared transport operation is unacknowledged.
  • EncryptedLinkHandshake::serialize() -> Result<Vec<u8>> Convenience method equivalent to self.snapshot()?.serialize().
  • EncryptedLinkHandshake::config() -> &Arc<PubkyNoiseConfig> Access the shared Noise configuration for in-process handshake restore.
  • EncryptedLinkHandshakeSnapshot::serialize() -> Vec<u8> / EncryptedLinkHandshakeSnapshot::deserialize(bytes: &[u8]) -> Result<EncryptedLinkHandshakeSnapshot> / EncryptedLinkHandshakeSnapshot::recipient() -> &PublicKey / EncryptedLinkHandshakeSnapshot::remote_noise_public_key() -> &PublicKey Snapshot wire format helpers. The versioned handshake envelope retains the Noise session state, counterparty Noise key, recovery context, and at most one bounded pending-publication packet with its exact destination. Persist pending output before publishing it; acknowledge the exact packet before restoring the checkpoint or promoting the link to transport mode.
  • restore_encrypted_link_handshake(session, secret_key, remote_identity_public_key, outbox_client, snapshot) -> Result<EncryptedLinkHandshake> Cross-restart restore for an in-progress handshake.
  • restore_encrypted_link_handshake_from_config(config, remote_identity_public_key, snapshot) -> Result<EncryptedLinkHandshake> In-process restore for an in-progress handshake.

After handshake restore, recovery tuning resets to defaults: recovery_attempts = 0 and max_recovery_attempts = DEFAULT_MAX_RECOVERY_ATTEMPTS.

Snapshot bytes include sensitive key material and must be treated as secrets (store encrypted at rest; never log or expose them).

Private Payment List exchange

  • set_private_payment_list(link: &mut EncryptedLink, list: &PrivatePaymentList) -> Result<()> Serializes the complete Private Payment List to JSON, encrypts it, and sends it via the Encrypted Link. The caller is responsible for managing the Payment Endpoints in the Payment List and passing the full map each time in list.payment_endpoints. The serialized JSON must fit within PUBKY_NOISE_MSG_LEN (1000 bytes). Transient homeserver write failures are retried automatically up to EncryptedLink::set_max_send_retries times (default: DEFAULT_MAX_SEND_RETRIES, 3). Transport-phase homeserver write failures do not corrupt the Noise state, so retries are safe without snapshot-based recovery. Deterministic state, counter, nonce, or encryption errors fail immediately.
  • parse_private_payment_list_json(json: &str) -> Result<PrivatePaymentList> Stateless parser for callers or SDK layers that route messages from EncryptedLink::receive_private_application_messages.

Durable runtimes should use EncryptedLink::prepare_private_application_message_json, persist the exact ciphertext and resulting snapshot together, acknowledge the prepared send, and then call publish_prepared_private_application_message. On receive, use prepare_next_private_application_message, persist the plaintext application result and resulting snapshot together, and only then call acknowledge_persisted_private_receive. The SDK runtime implements this staged flow; the direct stateless send helpers remain convenience APIs.

All Private Application Messages share one ordered encrypted stream and carry a source app_id. Use EncryptedLink::receive_private_application_messages when a caller or SDK must route private message kinds durably. The raw JSON is preserved even when parsed version/kind/app_id header fields are missing or malformed. Callers that perform irreversible side effects after receiving Event Messages should persist their own app-level handled/unhandled event state before persisting a snapshot whose read counter has advanced past those messages.

Private Paykit wire messages are closed-world JSON objects: unknown fields are rejected unless a field is explicitly defined as an open JSON object, such as Payment Request metadata, Payment Proof proof, or Receipt Metadata.

Payment Request exchange

Payment Requests are payee-initiated private protocol objects exchanged over an Encrypted Link. Their lifecycle messages (paykit.payment_request, acceptance, rejection, cancellation, and proof) use Event Message semantics. Paykit serializes, encrypts, sends, receives, and structurally validates these messages, but does not execute payments, schedule recurring jobs, manage wallet state, validate sender-role intent, or validate payment-method-specific proofs.

An accepted Recurring Payment Request is the protocol object behind a product Subscription; Paykit does not define a separate Subscription protocol or execute its periodic payments. See Recurring Payment Requests And Subscriptions for the complete responsibility split and lifecycle.

  • send_payment_request(link, app_id, event: &PaymentRequest) -> Result<()> Sends a paykit.payment_request proposal with immutable terms. Terms include PaymentAmount, a payee-provided PaymentReference, required nullable proposal_expires_at (None/null means no protocol-level proposal expiry before acceptance), optional Recurrence, accepted Payment Endpoint Identifiers, and optional metadata.
  • serialize_payment_request_event(app_id, event: &PaymentRequestEvent) -> Result<String> Serializes a Payment Request event to the exact JSON payload callers can persist before sending for crash-safe retry/idempotency.
  • parse_payment_request_event_message(message: &PrivateApplicationMessage) -> Option<PaymentRequestEventMessage> Stateless parser for Payment Request events from the raw Private Application Message stream. Recognized but malformed events are returned with a validation error so callers can persist the raw payload before advancing their durable checkpoint.
  • send_payment_request_acceptance(link, app_id, event: &PaymentRequestAcceptance) -> Result<()> Sends an explicit payer acceptance for a proposed Payment Request.
  • send_payment_request_rejection(link, app_id, event: &PaymentRequestRejection) -> Result<()> Sends a payer rejection with an optional reason.
  • send_payment_request_cancellation(link, app_id, event: &PaymentRequestCancellation) -> Result<()> Sends a unilateral cancellation with an optional reason.
  • send_payment_proof(link, app_id, event: &PaymentProof) -> Result<()> Sends method-specific proof data for one concrete payment execution. The requested amount is inherited from immutable Payment Request terms and is not repeated in the generic Payment Proof. The opaque proof object is method-specific and has no required generic discriminator. The payment_reference must be copied from the accepted Payment Request; recurring proofs include a BillingPeriod.
  • PaymentProof::validate_for_request(&request) -> Result<()> Validates stateless proof/request correlation fields: matching Payment Request ID and Payment Reference, one-time vs recurring Billing Period presence, Billing Period shape when present, and accepted Payment Endpoint Identifier. Caller-managed application or wallet state must still decide whether the request is known, accepted, proposal-expired before acceptance, rejected, cancelled, already processed or settled, whether the sender role is allowed for the message kind, or whether a recurring Billing Period is eligible under the request recurrence.
  • PaymentProof::validate_conversion_quote(&request, quote) -> Result<()> Also validates a selected quote's request, Billing Period and accepted payment asset. Wallets still verify the actual payment amount and time against the accepted terms and quote.

Payment Request, Allowance, and Receipt Access messages use Event Message semantics. Use EncryptedLink::receive_private_application_messages, persist the returned raw stream messages, then parse/reroute them with stateless parsers such as parse_payment_request_event_message, parse_allowance_event_message, parse_receipt_access_event_message, and parse_private_payment_list_json.

Paykit Library is stateless and does not persist Event Message history. Callers that derive state or trigger side effects must persist the raw JSON payload, parsed kind when available, parse result, and parsed IDs when available from event wrappers such as PaymentRequestEventMessage or ReceiptAccessEventMessage, detect conflicting reused Event IDs or conflicting repeated payment_request_id terms, and apply lifecycle rules such as proof-after-rejection/cancellation before acting.

Callers that persist Encrypted Link snapshots should treat the snapshot as the local read checkpoint. Persist received Event Messages and dedupe state before replacing the stored snapshot with a snapshot whose read counter has advanced past those messages. If events are persisted but the snapshot is not, replay is expected; Event Messages should be deduped by event_id, while Receipt Access can also be reconciled by Receipt ID and caller receipt state.

Allowance exchange

An Allowance is private, scoped authority from an Allower for automatic handling of compatible Payment Requests sent by an Allowee. The four V1 kinds are proposal, acceptance, rejection, and End Event Messages. AllowanceTermsBuilder constructs immutable terms, while AllowanceAcceptance::validate_for_proposal, AllowanceRejection::validate_for_proposal, and the two AllowanceEnd validation helpers check authenticated party roles and causal references without storing lifecycle state.

  • serialize_allowance_event(event: &AllowanceEvent) -> Result<String> serializes closed V1 JSON and enforces the 1000-byte whole-message limit.
  • parse_allowance_event_message(message: &PrivateApplicationMessage) -> Option<AllowanceEventMessage> parses recognized events while preserving raw JSON and returning redacted structural failures.
  • send_allowance_proposal, send_allowance_acceptance, send_allowance_rejection, and send_allowance_end send typed events over an established Encrypted Link.

match_allowance_request evaluates exact asset, inclusive amount range, and the allowed Payment Endpoint Identifier intersection. check_allowance_time checks the active window and watermark without evaluating capacity, for example before recurring Acceptance. evaluate_allowance additionally checks the active window, a caller-supplied trusted time and durable watermark, and every period/lifetime limit against complete committed automatic usage and unresolved reservations. allowance_period_window exposes UTC anchored or rolling boundaries, and compare_decimal_amounts / add_decimal_amounts provide exact decimal arithmetic without an asset precision limit. Inputs retain their original wire spelling; arithmetic results are numerically normalized.

These functions are pure checks, not payment authorization. AllowanceUsageEntry represents one already-admitted automatic occurrence; each evaluation adds one candidate count and its complete requested amount. The SDK must establish that the history and watermark are current and complete, exclude released reservations and manual payments from usage, check lifecycle and exact party scope, deduplicate all payment paths, and atomically reserve capacity before execution. An empty usage slice is not evidence of an unused Allowance. Restoring an old snapshot does not establish accounting freshness. A successful evaluation changes no state.

Paykit Library does not derive Allowance lifecycle state, persist usage, reserve capacity, select an Allowance or Payment Endpoint, or execute payment. The caller/SDK must durably preserve both directions of authenticated history and enforce cross-kind Event ID dedupe. Session creation, capability scope, key rotation, Pubky client timeouts, and wallet payment policy remain caller duties.

Payment receipts

Receipts involve three related objects:

  1. The plaintext Receipt is created and decrypted locally. It is not stored directly on the homeserver.
  2. The Encrypted Receipt is stored on the issuer's homeserver under /pub/paykit/v0/private/receipts/{ReceiptId}.
  3. A ReceiptAccess descriptor is sent to the counterparty over the existing Encrypted Link. It contains the Event ID, ReceiptId, payment_reference, optional payment_request_id and billing_period, Receipt Location, and symmetric Receipt Decryption Key.

Receipt Location is a path on the issuer's homeserver, not a complete Pubky resource by itself. SDK/runtime code pairs it with the Receipt Access sender/issuer context when retrieving the Encrypted Receipt. The Receipt is encrypted with XChaCha20Poly1305; the storage location path is used as authenticated associated data, so fetching the right ciphertext but decrypting it against a different location fails.

  • prepare_receipt(link, draft: ReceiptDraft) -> Result<PreparedReceipt> Builds a canonical local Receipt from the caller's ReceiptDraft, fills in the recipient public key from link, generates a fresh ReceiptId when the draft does not provide one, copies optional Payment Request ID and Billing Period correlation into the Receipt and Receipt Access descriptor, generates a fresh ReceiptDecryptionKey, encrypts the Receipt into an Encrypted Receipt, and returns the matching ReceiptAccess descriptor without storing or sending anything. Receipt Metadata is a caller-defined JSON object.
  • prepare_receipt_for_recipient(recipient_public_key, draft: ReceiptDraft) -> Result<PreparedReceipt> Same as prepare_receipt, but takes the recipient public key directly so stateful runtimes can prepare receipt issuance before restoring or sending over an Encrypted Link.
  • store_prepared_receipt(session, prepared: &PreparedReceipt) -> Result<()> Stores the Encrypted Receipt from a prepared issuance at its Receipt Location.
  • send_receipt_access(link, app_id, access: &ReceiptAccess) -> Result<()> Sends a prepared Receipt Access descriptor over Noise. This can be retried with the same descriptor if storage already succeeded.
  • serialize_receipt_access_json(app_id, access: &ReceiptAccess) -> Result<String> Returns the canonical Receipt Access Event Message JSON for durable outbound queues.
  • parse_receipt_access_event_message(message: &PrivateApplicationMessage) -> Option<ReceiptAccessEventMessage> Stateless parser for Receipt Access events from the raw Private Application Message stream. Recognized but malformed Receipt Access events are returned with a validation error so callers can persist the raw payload before advancing their durable checkpoint.
  • parse_receipt_access_json(json: &str) -> Result<ReceiptAccess> Stateless parser for a known Receipt Access JSON payload.
  • ReceiptAccess::location_for(receipt_id) -> String Returns Paykit's canonical Receipt Location for an Encrypted Receipt.
  • Receipt::encrypt(&self, key) -> Result<String> / Receipt::decrypt(encrypted_json, key, location) -> Result<Receipt> Encrypts local plaintext Receipts into Encrypted Receipts, or decrypts Encrypted Receipts back into local plaintext Receipts, using XChaCha20Poly1305. Encryption derives the canonical Receipt Location path from the Receipt's ReceiptId and authenticates that path as AAD. Pass the exact location from the access descriptor when decrypting; it is authenticated as AAD. Decryption also rejects plaintext whose internal Receipt ID does not match the authenticated location.
  • decrypt_receipt(encrypted_json, key, location) -> Result<Receipt> Convenience wrapper around Receipt::decrypt. This decrypts with the supplied location as authenticated data and rejects plaintext whose ReceiptId does not map to that location. Receipt Access descriptor validation happens when parsing or sending Receipt Access.

Receipt Decryption Keys are sensitive. ReceiptDecryptionKey and ReceiptAccess redact key material from formatted output through the key's custom Debug/Display, but callers must still avoid logging or persisting the raw ReceiptDecryptionKey::as_str() value outside secure storage.

Apps should call prepare_receipt, persist the returned PreparedReceipt or equivalent issuance state, then call store_prepared_receipt and send_receipt_access in retryable steps. If storage succeeds but sending Receipt Access fails, callers can retry sending the same PreparedReceipt::access.

Termination

  • close_encrypted_link(link: EncryptedLink) -> Result<()> Closes the Noise session and releases resources.

Handshake Polling Patterns

The caller controls the polling strategy for advance_handshake. Two common patterns:

Fixed interval:

use std::time::Duration;
use paykit_lib::{advance_handshake, HandshakeAdvanceError, HandshakeProgress, EncryptedLinkHandshake};

async fn poll_fixed(mut handshake: EncryptedLinkHandshake) -> Result<paykit_lib::EncryptedLink, HandshakeAdvanceError> {
    loop {
        match advance_handshake(handshake).await? {
            HandshakeProgress::Pending(h) => {
                handshake = h;
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
            HandshakeProgress::Complete(link) => return Ok(link),
        }
    }
}

With timeout:

use std::time::{Duration, Instant};
use paykit_lib::{advance_handshake, HandshakeAdvanceError, HandshakeProgress, EncryptedLinkHandshake};

async fn poll_with_timeout(mut handshake: EncryptedLinkHandshake) -> Result<paykit_lib::EncryptedLink, HandshakeAdvanceError> {
    let deadline = Instant::now() + Duration::from_secs(60);
    loop {
        if Instant::now() > deadline {
            return Err(paykit_lib::PaykitError::Transport {
                context: "handshake timed out".into(),
                source: anyhow::anyhow!("deadline exceeded"),
            }
            .into());
        }
        match advance_handshake(handshake).await? {
            HandshakeProgress::Pending(h) => {
                handshake = h;
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
            HandshakeProgress::Complete(link) => return Ok(link),
        }
    }
}

Both polling examples propagate the complete advance error, including any retry handle, to their caller. The timeout generated by the second example has no retry handle. Session creation, capability scope, and key rotation remain the caller's responsibility.

Session Resumption

The handshake checkpointing API above covers in-progress handshakes.

An established EncryptedLink can be snapshotted, serialized to bytes, persisted to durable storage, and later restored without re-doing the Noise handshake. This enables session resumption after app restarts or in-process recovery.

Snapshot and serialize:

  • EncryptedLink::snapshot() -> Result<EncryptedLinkSnapshot> Captures the current Encrypted Link state (transport keys, nonce counters, and counterparty identity) as a serializable snapshot. Fails while a prepared send is unacknowledged; see retry_pending_send.
  • EncryptedLink::serialize() -> Result<Vec<u8>> Convenience method equivalent to self.snapshot()?.serialize().
  • EncryptedLink::retry_pending_send() -> Result<()> Republish the prepared packet retained after a send exhausted its retry budget. Until it is republished, sends, receives, and snapshots on this link fail. Restoring an older snapshot is not a safe recovery: the failed write may have reached the homeserver, so rolling back the sending nonce and slot can reuse the transport key and nonce. Only republish the retained packet or establish a fresh link.
  • EncryptedLink::config() -> &Arc<PubkyNoiseConfig> Access the shared Noise configuration for in-process restore via restore_encrypted_link_from_config.

Snapshot type:

  • EncryptedLinkSnapshot::serialize() -> Vec<u8> Serializes the pubky-noise session state followed by the counterparty's 32-byte Noise public key and 72-byte recovery context. The counterparty Pubky identity key remains embedded in the Noise state.
  • EncryptedLinkSnapshot::deserialize(bytes: &[u8]) -> Result<EncryptedLinkSnapshot> Reconstructs a snapshot from bytes, including the embedded recipient public key.
  • EncryptedLinkSnapshot::recipient() -> &PublicKey Access the counterparty's public key embedded in the snapshot.
  • EncryptedLinkSnapshot::remote_noise_public_key() -> &PublicKey Access the counterparty's identity-wide Noise public key embedded in the snapshot.

Restore:

  • restore_encrypted_link(session, secret_key, remote_identity_public_key, outbox_client, snapshot) -> Result<EncryptedLink> Cross-restart restore. Accepts a fresh PubkySession and the same secret key used in the original initiate_encrypted_link or accept_encrypted_link call. Internally builds a new PubkyNoiseConfig and restores the transport-phase Noise state and counters directly from the serialized snapshot.
  • restore_encrypted_link_from_config(config, remote_identity_public_key, snapshot) -> Result<EncryptedLink> In-process restore. Reuses an existing Arc<PubkyNoiseConfig> (obtainable via EncryptedLink::config()) when the link needs rebuilding without an app restart.

Both handshake and established-link restore validate that supplied config paths match the Pubky identity pair and Noise keys before restoring Noise state.

After link restore, max_send_retries resets to DEFAULT_MAX_SEND_RETRIES. Call EncryptedLink::set_max_send_retries after restore if you need a non-default value.

Recovery markers:

EncryptedLinkRecoveryMarker is a minimal public Pubky marker for relink coordination when an Encrypted Link can no longer be trusted. paykit-lib provides stateless marker parsing, serialization, path derivation, and publish/fetch/remove helpers. SDKs decide when to publish or act on markers. Marker derivation uses the same sorted Pubky identity pair under the distinct paykit-link-recovery-v0 domain, retaining the Noise DH secret. Pure path and unauthenticated fetch helpers require both identities explicitly; publish, remove, and outbox cleanup helpers take the remote identity and obtain the local identity from the supplied session.

When to snapshot:

Take a snapshot after the Encrypted Link is established and periodically after exchanging messages. The snapshot includes nonce counters that must stay in sync with the counterparty. Restoring a snapshot taken before a later send rolls back the sending nonce and outbox slot, so the next send reuses the transport key and nonce; establish a fresh Encrypted Link instead. Never send different plaintext from a snapshot taken before a failed send: if the earlier publish reached the homeserver, that reuses the transport key and nonce. Persist any returned Event Messages and caller dedupe state before replacing the stored snapshot with one whose read counter has advanced past those messages.

Snapshot bytes include sensitive key material, so they must be treated as secrets (store encrypted at rest; never log or expose them).

// After establishing the link:
let link: EncryptedLink = /* ... handshake complete ... */;
let bytes = link.serialize()?;
save_to_disk(&bytes);

// After app restart:
let bytes = load_from_disk();
let snapshot = EncryptedLinkSnapshot::deserialize(&bytes)?;
let remote_pubkey = snapshot.recipient().clone();
let mut link = restore_encrypted_link(
    fresh_session, secret_key, &remote_pubkey, outbox_client, snapshot,
).await?;
// Continue using set_private_payment_list and raw stream receive/parsing

Exports

Key exports include:

  • PaykitAppId, PaykitApp, PaykitAppRegistry, and its read/publish helpers for identity-wide app discovery and payment preferences.
  • with_write_lock for Pubky read-modify-write operations. Encrypted shared SDK state and rotatable Paykit key material are provided by paykit-sdk, not this stateless crate.
  • PAYKIT_PATH_PREFIX (/pub/paykit/v0/) and PAYKIT_PRIVATE_PATH_PREFIX (/pub/paykit/v0/private) to standardize Pubky path construction.
  • set_payment_endpoint, remove_payment_endpoint, get_payment_list, and get_payment_endpoint for Public Payment Endpoint operations over pubky::PubkySession and pubky::PublicStorage.
  • EncryptedLink, EncryptedLinkHandshake, HandshakeProgress, EncryptedLinkSnapshot, EncryptedLinkHandshakeSnapshot, PrivateApplicationMessage, and PrivateMessageKind for Encrypted Link types.
  • initiate_encrypted_link, accept_encrypted_link, advance_handshake, close_encrypted_link, EncryptedLink::receive_private_application_messages, set_private_payment_list, and parse_private_payment_list_json for Encrypted Link and Private Payment List operations.
  • PaymentAmount, PaymentRequest, PaymentRequestEvent, PaymentRequestEventMessage, PaymentRequestAcceptance, PaymentRequestRejection, PaymentRequestCancellation, PaymentProof, send_payment_request, serialize_payment_request_event, parse_payment_request_event_message, and proof validation helpers for Payment Request exchange.
  • AllowanceId, AllowanceTerms, AllowanceEvent, AllowanceEventMessage, the four typed Allowance lifecycle events, serialize_allowance_event, parse_allowance_event_message, and typed send helpers for stateless Allowance exchange.
  • ReceiptId, ReceiptDraft, Receipt, PreparedReceipt, ReceiptAccess, ReceiptAccessEventMessage, ReceiptDecryptionKey, prepare_receipt, store_prepared_receipt, send_receipt_access, parse_receipt_access_event_message, parse_receipt_access_json, decrypt_receipt, and ENCRYPTED_RECEIPT_MAX_BYTES for Encrypted Receipt issuance, access delivery, decryption, and encrypted-document sizing.
  • restore_encrypted_link, restore_encrypted_link_from_config, restore_encrypted_link_handshake, restore_encrypted_link_handshake_from_config for session resumption after app restart or in-process recovery.
  • DEFAULT_MAX_RECOVERY_ATTEMPTS, DEFAULT_MAX_SEND_RETRIES for configurable retry/recovery limits.
  • pubky_noise re-export for advanced callers that need direct access to the encryption layer.