agent-pay 0.1.0

L402 + DID-signed invoices: agent-to-agent Lightning payments (Rust port of @p-vbordei/agent-pay)
Documentation
//! L402-aware HTTP client. Mirrors fetchWithL402 from the TS reference.

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

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

use crate::bolt11::parse_invoice;
use crate::envelope::{verify_invoice_envelope, verify_receipt};
use crate::error::Error;
use crate::jws::ResolveKey;
use crate::keys::public_key_from_did_key;
use crate::lightning::LightningNode;

static CHALLENGE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"macaroon="([^"]+)",\s*invoice="([^"]+)""#).unwrap());

/// Minimal response carrier (mirrors PaywallResponse intentionally to allow
/// trivial adapter between server and client for tests).
#[derive(Debug, Clone, Default)]
pub struct FetchResponse {
    pub status: u16,
    pub headers: HashMap<String, String>,
    pub body: Option<Vec<u8>>,
    pub json: Option<serde_json::Value>,
}

impl FetchResponse {
    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())
    }
}

pub type FetchFn = Arc<
    dyn (Fn(
            String,
            HashMap<String, String>,
        ) -> Pin<Box<dyn Future<Output = Result<FetchResponse, Error>> + Send>>)
        + Send
        + Sync,
>;

pub struct FetchOptions {
    pub wallet: Arc<dyn LightningNode>,
    pub max_price_msat: u64,
    pub fetch: FetchFn,
    pub expected_did: Option<String>,
    pub verify_receipt_flag: bool,
    pub now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
    pub request_headers: HashMap<String, String>,
    pub method: String,
}

impl FetchOptions {
    pub fn new(wallet: Arc<dyn LightningNode>, max_price_msat: u64, fetch: FetchFn) -> Self {
        Self {
            wallet,
            max_price_msat,
            fetch,
            expected_did: None,
            verify_receipt_flag: true,
            now: Box::new(Utc::now),
            request_headers: HashMap::new(),
            method: "GET".into(),
        }
    }
}

pub async fn fetch_with_l402(url: &str, opts: FetchOptions) -> Result<FetchResponse, Error> {
    let first = (opts.fetch)(url.to_string(), opts.request_headers.clone()).await?;
    if first.status != 402 {
        return Ok(first);
    }
    let www_auth = first.header("www-authenticate").unwrap_or("").to_string();
    let captures = CHALLENGE_RE
        .captures(&www_auth)
        .ok_or_else(|| Error::fetch("no L402 challenge", "missing-challenge"))?;
    let token = captures.get(1).unwrap().as_str().to_string();
    let bolt11 = captures.get(2).unwrap().as_str().to_string();
    let envelope_jws = first
        .header("x-did-invoice")
        .ok_or_else(|| Error::fetch("missing X-Did-Invoice", "missing-x-did-invoice"))?
        .to_string();

    let resolver = make_did_key_resolver(opts.expected_did.clone());
    let env = verify_invoice_envelope(&envelope_jws, &bolt11, &resolver)
        .await
        .map_err(|e| {
            Error::fetch(
                format!("X-Did-Invoice verification failed: {e}"),
                "jws-invalid",
            )
        })?;

    let price: u64 = env
        .price_msat
        .parse()
        .map_err(|e| Error::fetch(format!("price_msat: {e}"), "jws-invalid"))?;
    if price > opts.max_price_msat {
        return Err(Error::fetch(
            format!("price {price} exceeds cap {}", opts.max_price_msat),
            "price-cap",
        ));
    }
    let expires_ms = parse_iso_ms(&env.expires_at)?;
    let now_ms = (opts.now)().timestamp_millis() as u64;
    if expires_ms <= now_ms {
        return Err(Error::fetch(
            format!("invoice expired ({})", env.expires_at),
            "expired",
        ));
    }
    let parsed = parse_invoice(&bolt11)
        .map_err(|e| Error::fetch(format!("bolt11 parse: {e}"), "jws-invalid"))?;
    if parsed.amount_msat != price {
        return Err(Error::fetch(
            format!(
                "BOLT11 amount {} mismatches envelope price {}",
                parsed.amount_msat, price
            ),
            "amount-mismatch",
        ));
    }

    let pay = opts.wallet.pay_invoice(&bolt11).await?;
    let preimage_hex = hex::encode(&pay.preimage);
    let mut second_headers = opts.request_headers.clone();
    second_headers.insert(
        "authorization".into(),
        format!("L402 {token}:{preimage_hex}"),
    );
    let second = (opts.fetch)(url.to_string(), second_headers).await?;
    if second.status != 200 {
        return Ok(second);
    }
    if opts.verify_receipt_flag {
        if let Some(receipt) = second.header("x-payment-receipt") {
            verify_receipt(receipt, &bolt11, &resolver)
                .await
                .map_err(|e| {
                    Error::fetch(
                        format!("receipt verification failed: {e}"),
                        "receipt-invalid",
                    )
                })?;
        }
    }
    Ok(second)
}

fn make_did_key_resolver(pinned: Option<String>) -> ResolveKey {
    Arc::new(move |kid: String| {
        let pinned = pinned.clone();
        Box::pin(async move {
            let did = kid.split('#').next().unwrap_or(&kid).to_string();
            if let Some(p) = pinned.as_ref() {
                if &did != p {
                    return Err(Error::fetch(format!("unexpected DID {did}"), "jws-invalid"));
                }
            }
            public_key_from_did_key(&did)
        })
    })
}

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