agent-pay 0.1.0

L402 + DID-signed invoices: agent-to-agent Lightning payments (Rust port of @p-vbordei/agent-pay)
Documentation
//! Paywall server: emits 402 challenges, validates L402 Authorization.

use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;

use chrono::{DateTime, SecondsFormat, Utc};
use once_cell::sync::Lazy;
use rand::RngCore;
use regex::Regex;

use crate::envelope::{sign_invoice_envelope, sign_receipt, SignInvoiceOpts, SignReceiptOpts};
use crate::error::Error;
use crate::lightning::{InvoiceCreateRequest, LightningNode};
use crate::replay::ReplayCache;
use crate::token::{issue_token, verify_token};

static AUTH_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"^L402\s+([^:\s]+):([0-9a-fA-F]+)$").unwrap());

/// Framework-agnostic response carried back through the middleware.
#[derive(Debug, Clone, Default)]
pub struct PaywallResponse {
    pub status: u16,
    pub headers: HashMap<String, String>,
    pub body: Option<Vec<u8>>,
    pub json: Option<serde_json::Value>,
}

impl PaywallResponse {
    pub fn header(&self, name: &str) -> Option<&str> {
        let lower = name.to_ascii_lowercase();
        self.headers
            .iter()
            .find(|(k, _)| k.to_ascii_lowercase() == lower)
            .map(|(_, v)| v.as_str())
    }
}

/// Async handler signature: receives (path, headers) and returns a response.
pub type InnerHandler = Arc<
    dyn (Fn(
            String,
            HashMap<String, String>,
        ) -> Pin<Box<dyn Future<Output = Result<PaywallResponse, Error>> + Send>>)
        + Send
        + Sync,
>;

pub struct PaywallOptions {
    pub server_did: String,
    pub server_private_key: [u8; 32],
    pub price_msat: u64,
    pub resource: String,
    pub lightning: Arc<dyn LightningNode>,
    pub token_secret: Vec<u8>,
    pub invoice_ttl_seconds: u64,
    pub now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
    pub replay: Option<Arc<ReplayCache>>,
}

impl PaywallOptions {
    pub fn new(
        server_did: impl Into<String>,
        server_private_key: [u8; 32],
        price_msat: u64,
        resource: impl Into<String>,
        lightning: Arc<dyn LightningNode>,
        token_secret: Vec<u8>,
    ) -> Self {
        Self {
            server_did: server_did.into(),
            server_private_key,
            price_msat,
            resource: resource.into(),
            lightning,
            token_secret,
            invoice_ttl_seconds: 300,
            now: Box::new(Utc::now),
            replay: None,
        }
    }
}

pub struct Paywall {
    opts: PaywallOptions,
    replay: Arc<ReplayCache>,
    issued: Mutex<HashMap<String, String>>, // payment_hash -> bolt11
}

impl Paywall {
    pub fn new(opts: PaywallOptions) -> Self {
        let replay = opts
            .replay
            .clone()
            .unwrap_or_else(|| Arc::new(ReplayCache::default()));
        Self {
            opts,
            replay,
            issued: Mutex::new(HashMap::new()),
        }
    }

    pub async fn process_request(
        &self,
        path: &str,
        headers: HashMap<String, String>,
        inner: Option<InnerHandler>,
    ) -> Result<PaywallResponse, Error> {
        let auth = headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
            .map(|(_, v)| v.clone());
        let Some(auth) = auth else {
            return self.challenge().await;
        };
        let Some(captures) = AUTH_RE.captures(&auth) else {
            return self.challenge().await;
        };
        let token = captures.get(1).unwrap().as_str();
        let preimage_hex = captures.get(2).unwrap().as_str();
        let payload = match verify_token(token, &self.opts.token_secret).await {
            Ok(p) => p,
            Err(_) => return self.challenge().await,
        };
        if self.replay.is_used(&payload.payment_hash) {
            return Ok(PaywallResponse {
                status: 401,
                json: Some(serde_json::json!({ "error": "preimage replayed" })),
                ..Default::default()
            });
        }
        let lookup = self
            .opts
            .lightning
            .lookup_invoice(&payload.payment_hash)
            .await?;
        if !lookup.settled || lookup.preimage.is_none() {
            return Ok(PaywallResponse {
                status: 401,
                json: Some(serde_json::json!({ "error": "invoice not settled" })),
                ..Default::default()
            });
        }
        let presented =
            hex::decode(preimage_hex).map_err(|e| Error::Paywall(format!("preimage hex: {e}")))?;
        let stored = lookup.preimage.unwrap();
        if !constant_time_eq(&presented, &stored) {
            return Ok(PaywallResponse {
                status: 401,
                json: Some(
                    serde_json::json!({ "error": "preimage does not match settled invoice" }),
                ),
                ..Default::default()
            });
        }
        let expires_ms = parse_iso_ms(&payload.expires_at)?;
        self.replay.mark_used(&payload.payment_hash, expires_ms);

        let mut inner_resp = if let Some(inner) = inner {
            (inner)(path.to_string(), headers).await?
        } else {
            PaywallResponse {
                status: 200,
                ..Default::default()
            }
        };

        let bolt11 = {
            let guard = self.issued.lock().unwrap();
            guard.get(&payload.payment_hash).cloned()
        };
        if let Some(bolt11) = bolt11 {
            let paid_at = iso((self.opts.now)());
            let receipt = sign_receipt(SignReceiptOpts {
                bolt11: &bolt11,
                did: &self.opts.server_did,
                private_key: &self.opts.server_private_key,
                preimage: &presented,
                resource: &self.opts.resource,
                paid_at: &paid_at,
            })
            .await?;
            inner_resp
                .headers
                .insert("x-payment-receipt".into(), receipt);
        }
        Ok(inner_resp)
    }

    async fn challenge(&self) -> Result<PaywallResponse, Error> {
        let ttl = self.opts.invoice_ttl_seconds;
        let invoice = self
            .opts
            .lightning
            .create_invoice(InvoiceCreateRequest {
                amount_msat: self.opts.price_msat,
                memo: None,
                expiry_seconds: Some(ttl),
            })
            .await?;
        self.issued
            .lock()
            .unwrap()
            .insert(invoice.payment_hash.clone(), invoice.bolt11.clone());
        let now = (self.opts.now)();
        let expires_at_dt = now + chrono::Duration::seconds(ttl as i64);
        let expires_at = iso(expires_at_dt);
        let mut nonce = [0u8; 16];
        rand::thread_rng().fill_bytes(&mut nonce);
        let envelope = sign_invoice_envelope(SignInvoiceOpts {
            bolt11: &invoice.bolt11,
            did: &self.opts.server_did,
            private_key: &self.opts.server_private_key,
            price_msat: self.opts.price_msat,
            resource: &self.opts.resource,
            expires_at: &expires_at,
            nonce: &nonce,
        })
        .await?;
        let token =
            issue_token(&invoice.payment_hash, &expires_at, &self.opts.token_secret).await?;
        let mut headers = HashMap::new();
        headers.insert(
            "www-authenticate".into(),
            format!("L402 macaroon=\"{token}\", invoice=\"{}\"", invoice.bolt11),
        );
        headers.insert("x-did-invoice".into(), envelope);
        Ok(PaywallResponse {
            status: 402,
            headers,
            body: None,
            json: None,
        })
    }
}

fn iso(dt: DateTime<Utc>) -> String {
    dt.to_rfc3339_opts(SecondsFormat::Millis, true)
}

fn parse_iso_ms(s: &str) -> Result<u64, Error> {
    let dt = DateTime::parse_from_rfc3339(s).map_err(|e| Error::Paywall(format!("iso: {e}")))?;
    Ok(dt.with_timezone(&Utc).timestamp_millis() as u64)
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut d = 0u8;
    for i in 0..a.len() {
        d |= a[i] ^ b[i];
    }
    d == 0
}