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());
#[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)
}