agent-pay 0.1.0

L402 + DID-signed invoices: agent-to-agent Lightning payments (Rust port of @p-vbordei/agent-pay)
Documentation
mod harness;

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;

use agent_pay::{
    did_key_from_public_key, fetch_with_l402, generate_key_pair, issue_token,
    sign_invoice_envelope, FetchOptions, FetchResponse, InvoiceCreateRequest, LightningNode,
    MemoryLedger, MemoryNode, Paywall, PaywallOptions, SignInvoiceOpts,
};
use harness::{make_fetch, make_raw_fetch, ok_handler, SECRET};

fn base_setup(
    price_msat: u64,
) -> (
    Arc<Paywall>,
    Arc<MemoryLedger>,
    Arc<dyn LightningNode>,
    String,
) {
    let kp = generate_key_pair();
    let did = did_key_from_public_key(&kp.public_key).unwrap();
    let ledger = MemoryLedger::new();
    let server: Arc<dyn LightningNode> = Arc::new(MemoryNode::new(ledger.clone(), "server"));
    let wallet: Arc<dyn LightningNode> = Arc::new(MemoryNode::new(ledger.clone(), "wallet"));
    let opts = PaywallOptions::new(
        did.clone(),
        kp.private_key,
        price_msat,
        "/r",
        server,
        SECRET.to_vec(),
    );
    (Arc::new(Paywall::new(opts)), ledger, wallet, did)
}

fn err_reason(err: &agent_pay::Error) -> Option<String> {
    match err {
        agent_pay::Error::FetchWithL402 { reason, .. } => Some(reason.clone()),
        _ => None,
    }
}

async fn run_vector(scenario: &str) -> Result<(), String> {
    match scenario {
        "C1-missing-x-did-invoice" => {
            let (paywall, _ledger, wallet, _did) = base_setup(1000);
            let inner = make_fetch(paywall, ok_handler());
            let strip: agent_pay::FetchFn = Arc::new(move |url, headers| {
                let inner = inner.clone();
                Box::pin(async move {
                    let res = (inner)(url, headers).await?;
                    let mut new_headers = res.headers.clone();
                    new_headers.retain(|k, _| !k.eq_ignore_ascii_case("x-did-invoice"));
                    Ok(FetchResponse {
                        status: res.status,
                        headers: new_headers,
                        body: res.body,
                        json: res.json,
                    })
                })
            });
            let opts = FetchOptions::new(wallet, 5000, strip);
            match fetch_with_l402("http://x/r", opts).await {
                Ok(_) => Err("expected error".into()),
                Err(e) => {
                    if err_reason(&e).as_deref() == Some("missing-x-did-invoice") {
                        Ok(())
                    } else {
                        Err(format!("unexpected: {e:?}"))
                    }
                }
            }
        }
        "C1-invalid-jws" => {
            let (paywall, _ledger, wallet, _did) = base_setup(1000);
            let inner = make_fetch(paywall, ok_handler());
            let tamper: agent_pay::FetchFn = Arc::new(move |url, headers| {
                let inner = inner.clone();
                Box::pin(async move {
                    let res = (inner)(url, headers).await?;
                    let jws = match res.header("x-did-invoice") {
                        Some(s) => s.to_string(),
                        None => return Ok(res),
                    };
                    let mut parts: Vec<String> = jws.split('.').map(String::from).collect();
                    if parts.len() == 3 {
                        let sig = parts[2].clone();
                        let first = sig.chars().next().unwrap_or('A');
                        let new_first = if first == 'A' { 'B' } else { 'A' };
                        parts[2] = format!("{}{}", new_first, &sig[1..]);
                    }
                    let mut new_headers = res.headers.clone();
                    new_headers.insert("x-did-invoice".into(), parts.join("."));
                    Ok(FetchResponse {
                        status: res.status,
                        headers: new_headers,
                        body: res.body,
                        json: res.json,
                    })
                })
            });
            let opts = FetchOptions::new(wallet, 5000, tamper);
            match fetch_with_l402("http://x/r", opts).await {
                Ok(_) => Err("expected error".into()),
                Err(e) => {
                    if err_reason(&e).as_deref() == Some("jws-invalid") {
                        Ok(())
                    } else {
                        Err(format!("unexpected: {e:?}"))
                    }
                }
            }
        }
        "C2-roundtrip" => {
            let (paywall, _ledger, wallet, did) = base_setup(1000);
            let fetch = make_fetch(paywall, ok_handler());
            let mut opts = FetchOptions::new(wallet, 5000, fetch);
            opts.expected_did = Some(did);
            let res = fetch_with_l402("http://x/r", opts)
                .await
                .map_err(|e| format!("{e}"))?;
            if res.status != 200 {
                return Err(format!("expected 200, got {}", res.status));
            }
            if res.header("x-payment-receipt").is_none() {
                return Err("missing x-payment-receipt".into());
            }
            Ok(())
        }
        "C3-replayed-preimage" => {
            let (paywall, _ledger, wallet, _did) = base_setup(1000);
            let captured = Arc::new(std::sync::Mutex::new(None::<String>));
            let captured_c = captured.clone();
            let inner = make_fetch(paywall.clone(), ok_handler());
            let recorder: agent_pay::FetchFn = Arc::new(move |url, headers| {
                let captured_c = captured_c.clone();
                let inner = inner.clone();
                Box::pin(async move {
                    if let Some(auth) = headers.get("authorization") {
                        if auth.starts_with("L402 ") {
                            *captured_c.lock().unwrap() = Some(auth.clone());
                        }
                    }
                    (inner)(url, headers).await
                })
            });
            let opts = FetchOptions::new(wallet, 5000, recorder);
            let ok = fetch_with_l402("http://x/r", opts)
                .await
                .map_err(|e| format!("{e}"))?;
            if ok.status != 200 {
                return Err("first request should succeed".into());
            }
            let auth = captured.lock().unwrap().clone().ok_or("missing auth")?;
            let raw = make_raw_fetch(paywall, ok_handler());
            let mut headers = HashMap::new();
            headers.insert("authorization".into(), auth);
            let replay = (raw)("http://x/r".into(), headers)
                .await
                .map_err(|e| format!("{e}"))?;
            if replay.status != 401 {
                return Err(format!("expected 401 on replay, got {}", replay.status));
            }
            Ok(())
        }
        "C4-bolt11-hash-mismatch" => {
            let kp = generate_key_pair();
            let did = did_key_from_public_key(&kp.public_key).unwrap();
            let ledger = MemoryLedger::new();
            let node: Arc<dyn LightningNode> = Arc::new(MemoryNode::new(ledger.clone(), "liar"));
            let wallet: Arc<dyn LightningNode> =
                Arc::new(MemoryNode::new(ledger.clone(), "wallet"));
            let did_for_fetch = did.clone();
            let private_key = kp.private_key;
            let liar: agent_pay::FetchFn = Arc::new(move |_url, _headers| {
                let node = node.clone();
                let did = did_for_fetch.clone();
                Box::pin(async move {
                    let real = node
                        .create_invoice(InvoiceCreateRequest {
                            amount_msat: 1000,
                            memo: None,
                            expiry_seconds: None,
                        })
                        .await
                        .unwrap();
                    let fake = node
                        .create_invoice(InvoiceCreateRequest {
                            amount_msat: 1000,
                            memo: None,
                            expiry_seconds: None,
                        })
                        .await
                        .unwrap();
                    let env = sign_invoice_envelope(SignInvoiceOpts {
                        bolt11: &fake.bolt11,
                        did: &did,
                        private_key: &private_key,
                        price_msat: 1000,
                        resource: "/r",
                        expires_at: "2030-01-01T00:00:00Z",
                        nonce: &[0u8; 16],
                    })
                    .await
                    .unwrap();
                    let tok = issue_token(&real.payment_hash, "2030-01-01T00:00:00Z", SECRET)
                        .await
                        .unwrap();
                    let mut headers = HashMap::new();
                    headers.insert(
                        "www-authenticate".into(),
                        format!("L402 macaroon=\"{tok}\", invoice=\"{}\"", real.bolt11),
                    );
                    headers.insert("x-did-invoice".into(), env);
                    Ok(FetchResponse {
                        status: 402,
                        headers,
                        body: None,
                        json: None,
                    })
                })
            });
            let opts = FetchOptions::new(wallet, 5000, liar);
            match fetch_with_l402("http://x/r", opts).await {
                Ok(_) => Err("expected error".into()),
                Err(e) => {
                    if err_reason(&e).as_deref() == Some("jws-invalid") {
                        Ok(())
                    } else {
                        Err(format!("unexpected: {e:?}"))
                    }
                }
            }
        }
        _ => Err(format!("unknown scenario {scenario}")),
    }
}

#[tokio::test]
async fn all_vectors_pass() {
    let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("vectors");
    let mut entries: Vec<_> = fs::read_dir(&dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map(|s| s == "json").unwrap_or(false))
        .collect();
    entries.sort_by_key(|e| e.path());
    assert!(!entries.is_empty(), "no vectors found in {dir:?}");
    for entry in entries {
        let v: serde_json::Value =
            serde_json::from_slice(&fs::read(entry.path()).unwrap()).unwrap();
        let scenario = v["scenario"].as_str().unwrap();
        let id = v["id"].as_str().unwrap();
        run_vector(scenario)
            .await
            .unwrap_or_else(|e| panic!("vector {id} ({scenario}) failed: {e}"));
    }
}