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}"));
}
}