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::sync::Arc;

use agent_pay::{
    did_key_from_public_key, generate_key_pair, FetchOptions, MemoryLedger, MemoryNode, Paywall,
    PaywallOptions,
};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use harness::{echo_handler, make_fetch, make_raw_fetch, SECRET};

fn setup() -> (Arc<Paywall>, std::sync::Arc<MemoryLedger>, String) {
    let kp = generate_key_pair();
    let did = did_key_from_public_key(&kp.public_key).unwrap();
    let ledger = MemoryLedger::new();
    let lightning: Arc<dyn agent_pay::LightningNode> =
        Arc::new(MemoryNode::new(ledger.clone(), "server"));
    let opts = PaywallOptions::new(
        did.clone(),
        kp.private_key,
        1000,
        "/report",
        lightning,
        SECRET.to_vec(),
    );
    (Arc::new(Paywall::new(opts)), ledger, did)
}

#[tokio::test]
async fn first_request_returns_402_with_x_did_invoice() {
    let (paywall, _ledger, _did) = setup();
    let raw = make_raw_fetch(paywall.clone(), echo_handler());
    let res = (raw)("http://x/report".into(), HashMap::new())
        .await
        .unwrap();
    assert_eq!(res.status, 402);
    let auth = res.header("www-authenticate").unwrap();
    assert!(auth.starts_with("L402 "));
    assert!(auth.contains("macaroon=\""));
    assert!(auth.contains("invoice=\""));
    assert!(res.header("x-did-invoice").is_some());
}

#[tokio::test]
async fn each_402_carries_a_fresh_nonce() {
    let (paywall, _ledger, _did) = setup();
    let raw = make_raw_fetch(paywall.clone(), echo_handler());
    let nonce_from = || {
        let raw = raw.clone();
        async move {
            let res = (raw)("http://x/report".into(), HashMap::new())
                .await
                .unwrap();
            let jws = res.header("x-did-invoice").unwrap().to_string();
            let parts: Vec<&str> = jws.split('.').collect();
            let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
            let v: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
            v["nonce"].as_str().unwrap().to_string()
        }
    };
    let n1 = nonce_from().await;
    let n2 = nonce_from().await;
    assert_ne!(n1, n2);
}

#[tokio::test]
async fn replayed_preimage_returns_401() {
    let (paywall, ledger, _did) = setup();
    let wallet: Arc<dyn agent_pay::LightningNode> =
        Arc::new(MemoryNode::new(ledger.clone(), "wallet"));
    let captured = Arc::new(std::sync::Mutex::new(None::<String>));
    let captured_clone = captured.clone();
    let inner = make_fetch(paywall.clone(), echo_handler());
    let recorder: agent_pay::FetchFn =
        Arc::new(move |url: String, headers: HashMap<String, String>| {
            let captured_clone = captured_clone.clone();
            let inner = inner.clone();
            Box::pin(async move {
                if let Some(auth) = headers.get("authorization") {
                    if auth.starts_with("L402 ") {
                        *captured_clone.lock().unwrap() = Some(auth.clone());
                    }
                }
                (inner)(url, headers).await
            })
        });
    let mut opts = FetchOptions::new(wallet, 5000, recorder);
    opts.expected_did = None;
    let ok = agent_pay::fetch_with_l402("http://x/report", opts)
        .await
        .unwrap();
    assert_eq!(ok.status, 200);
    let auth = captured.lock().unwrap().clone().unwrap();
    let raw = make_raw_fetch(paywall, echo_handler());
    let mut headers = HashMap::new();
    headers.insert("authorization".into(), auth);
    let replay = (raw)("http://x/report".into(), headers).await.unwrap();
    assert_eq!(replay.status, 401);
    let body = replay.json.unwrap();
    assert!(body["error"].as_str().unwrap().contains("replay"));
}