429 lines
16 KiB
Rust
429 lines
16 KiB
Rust
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
|
|
use super::*;
|
|
use crate::wallet::{bdhke, cashu::Proof};
|
|
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
|
use hyper::{
|
|
service::{make_service_fn, service_fn},
|
|
Body, Request, Response, Server,
|
|
};
|
|
use serde_json::{json, Value};
|
|
use std::{
|
|
convert::Infallible,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
const ACTIVE: &str = "0011223344556677";
|
|
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
|
|
|
|
struct Mint {
|
|
url: String,
|
|
requests: Arc<Mutex<Vec<Value>>>,
|
|
task: tokio::task::JoinHandle<()>,
|
|
failure: Arc<std::sync::atomic::AtomicU16>,
|
|
}
|
|
impl Drop for Mint {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
|
|
fn signing_key() -> SecretKey {
|
|
SecretKey::from_slice(&[7; 32]).unwrap()
|
|
}
|
|
fn signed_point(point: PublicKey) -> String {
|
|
point
|
|
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
|
|
.unwrap()
|
|
.to_string()
|
|
}
|
|
fn proof(id: &str, amount: u64) -> Proof {
|
|
let secret = format!("test-{id}-{amount}");
|
|
Proof {
|
|
amount,
|
|
id: id.into(),
|
|
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
|
|
secret,
|
|
}
|
|
}
|
|
impl Mint {
|
|
async fn start(fee: u64, failure: Option<u16>) -> Self {
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
listener.set_nonblocking(true).unwrap();
|
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let seen = requests.clone();
|
|
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
|
|
let rejection = failure.clone();
|
|
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
|
|
let service = make_service_fn(move |_| {
|
|
let seen = seen.clone();
|
|
let rejection = rejection.clone();
|
|
let spent = spent.clone();
|
|
async move {
|
|
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
|
let seen = seen.clone();
|
|
let rejection = rejection.clone();
|
|
let spent = spent.clone();
|
|
async move {
|
|
let mut status = 200;
|
|
let body = match req.uri().path() {
|
|
"/v1/keysets" => json!({"keysets":[
|
|
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
|
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
|
]}),
|
|
"/v1/keys" => {
|
|
let public =
|
|
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
|
.to_string();
|
|
let keys: serde_json::Map<String, Value> = (0..16)
|
|
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
|
.collect();
|
|
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
|
}
|
|
"/v1/swap" => {
|
|
let body: Value = serde_json::from_slice(
|
|
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
|
)
|
|
.unwrap();
|
|
seen.lock().unwrap().push(body.clone());
|
|
let inputs = body["inputs"].as_array().unwrap();
|
|
let outputs = body["outputs"].as_array().unwrap();
|
|
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
|
|
if code != 0 {
|
|
status = code;
|
|
json!({"detail":"mock mint rejection"})
|
|
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
|
|
{
|
|
status = 422;
|
|
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
|
|
} else if inputs.iter().any(|p| {
|
|
spent
|
|
.lock()
|
|
.unwrap()
|
|
.contains(p["secret"].as_str().unwrap())
|
|
}) {
|
|
status = 400;
|
|
json!({"code":11001,"detail":"Token Already Spent"})
|
|
} else {
|
|
let total: u64 =
|
|
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
|
let out: u64 =
|
|
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
|
assert_eq!(
|
|
out,
|
|
total - (inputs.len() as u64 * fee).div_ceil(1000)
|
|
);
|
|
for p in inputs {
|
|
spent
|
|
.lock()
|
|
.unwrap()
|
|
.insert(p["secret"].as_str().unwrap().into());
|
|
}
|
|
json!({"signatures":outputs.iter().map(|o| json!({
|
|
"amount":o["amount"],"id":ACTIVE,
|
|
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
|
|
})).collect::<Vec<_>>()})
|
|
}
|
|
}
|
|
_ => {
|
|
status = 404;
|
|
json!({})
|
|
}
|
|
};
|
|
Ok::<_, Infallible>(
|
|
Response::builder()
|
|
.status(status)
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap(),
|
|
)
|
|
}
|
|
}))
|
|
}
|
|
});
|
|
let server = Server::from_tcp(listener).unwrap().serve(service);
|
|
let task = tokio::spawn(async move {
|
|
server.await.unwrap();
|
|
});
|
|
Self {
|
|
url,
|
|
requests,
|
|
task,
|
|
failure,
|
|
}
|
|
}
|
|
async fn wallet(&self) -> tempfile::TempDir {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
save_accepted_mints(
|
|
dir.path(),
|
|
&AcceptedMints {
|
|
mints: vec![format!("{}/", self.url)],
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
dir
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
|
|
let mint = Mint::start(0, None).await;
|
|
let dir = mint.wallet().await;
|
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
|
|
.serialize_v4()
|
|
.unwrap();
|
|
let decoded = CashuToken::deserialize(&token).unwrap();
|
|
assert_eq!(
|
|
decoded.token[0].proofs[0].id.len(),
|
|
16,
|
|
"reproduce the short V4 ID"
|
|
);
|
|
assert_eq!(
|
|
verify_and_receive_payment(dir.path(), &token, 100)
|
|
.await
|
|
.unwrap(),
|
|
100
|
|
);
|
|
let wallet = load_wallet(dir.path()).await.unwrap();
|
|
assert_eq!(wallet.balance(), 100);
|
|
for p in wallet.proofs {
|
|
assert_eq!(
|
|
p.proof.c,
|
|
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
|
|
);
|
|
}
|
|
assert!(mint.requests.lock().unwrap()[0]["inputs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|p| p["id"] == V2));
|
|
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
|
.await
|
|
.is_err());
|
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paid_v3_full_v2_and_v1_ids_work() {
|
|
for id in [V2, ACTIVE] {
|
|
let mint = Mint::start(0, None).await;
|
|
let dir = mint.wallet().await;
|
|
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
|
|
.serialize()
|
|
.unwrap();
|
|
assert_eq!(
|
|
verify_and_receive_payment(dir.path(), &token, 100)
|
|
.await
|
|
.unwrap(),
|
|
128
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
|
|
let mint = Mint::start(1000, None).await;
|
|
let dir = mint.wallet().await;
|
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
|
.serialize_v4()
|
|
.unwrap();
|
|
assert!(verify_and_receive_payment(dir.path(), &token, 128)
|
|
.await
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("after mint fees"));
|
|
assert!(mint.requests.lock().unwrap().is_empty());
|
|
assert_eq!(
|
|
verify_and_receive_payment(dir.path(), &token, 127)
|
|
.await
|
|
.unwrap(),
|
|
127
|
|
);
|
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejected_mint_response_does_not_credit_wallet() {
|
|
for status in [200, 400, 422, 500, 503] {
|
|
let mint = Mint::start(0, Some(status)).await;
|
|
let dir = mint.wallet().await;
|
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
|
.serialize_v4()
|
|
.unwrap();
|
|
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
|
.await
|
|
.is_err());
|
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
|
|
let mint = Mint::start(0, None).await;
|
|
let dir = mint.wallet().await;
|
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
|
|
let mut invalid = vec![
|
|
"cashuSend_500_abc_1700000000".into(),
|
|
"cashuBinvalid".into(),
|
|
];
|
|
let mut wrong_unit = token.clone();
|
|
wrong_unit.unit = Some("usd".into());
|
|
invalid.push(wrong_unit.serialize().unwrap());
|
|
let mut multi = token.clone();
|
|
multi.token.push(token.token[0].clone());
|
|
invalid.push(multi.serialize().unwrap());
|
|
let mut untrusted = token.clone();
|
|
untrusted.token[0].mint = "http://127.0.0.1:1".into();
|
|
invalid.push(untrusted.serialize().unwrap());
|
|
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
|
|
invalid.push(
|
|
CashuToken::new(&mint.url, vec![proof(id, 128)])
|
|
.serialize()
|
|
.unwrap(),
|
|
);
|
|
}
|
|
for value in invalid {
|
|
assert!(verify_and_receive_payment(dir.path(), &value, 100)
|
|
.await
|
|
.is_err());
|
|
}
|
|
assert!(
|
|
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert!(mint.requests.lock().unwrap().is_empty());
|
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
|
|
let mint = Mint::start(0, Some(422)).await;
|
|
let buyer = mint.wallet().await;
|
|
let seller = mint.wallet().await;
|
|
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
|
wallet.mint_url = mint.url.clone();
|
|
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
|
|
save_wallet(buyer.path(), &wallet).await.unwrap();
|
|
let token = send_token(buyer.path(), 100).await.unwrap();
|
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
|
assert!(verify_and_receive_payment(seller.path(), &token, 100)
|
|
.await
|
|
.is_err());
|
|
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
|
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
|
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
|
assert!(receive_token(buyer.path(), &token).await.is_err());
|
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unreachable_mint_does_not_credit_seller() {
|
|
let mint = Mint::start(0, None).await;
|
|
let dir = mint.wallet().await;
|
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
|
.serialize_v4()
|
|
.unwrap();
|
|
mint.task.abort();
|
|
tokio::task::yield_now().await;
|
|
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
|
.await
|
|
.is_err());
|
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
|
|
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
|
|
// which is needed for a 65-sat payment, after consuming the inputs.
|
|
let mint = Mint::start(1000, None).await;
|
|
let buyer = mint.wallet().await;
|
|
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
|
wallet.mint_url = mint.url.clone();
|
|
let first = proof(V2, 64);
|
|
let mut second = first.clone();
|
|
second.secret.push_str("-second");
|
|
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
|
|
wallet.add_proofs(&mint.url, vec![first, second]);
|
|
save_wallet(buyer.path(), &wallet).await.unwrap();
|
|
let encoded = send_token(buyer.path(), 65).await.unwrap();
|
|
assert_eq!(
|
|
CashuToken::deserialize(&encoded).unwrap().total_amount(),
|
|
65
|
|
);
|
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
|
|
use crate::content_server::{
|
|
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
|
|
};
|
|
for (exists, accepts_cashu, price) in [
|
|
(true, true, 100),
|
|
(true, false, 100),
|
|
(false, true, 100),
|
|
(true, true, 129),
|
|
] {
|
|
let mint = Mint::start(0, None).await;
|
|
let seller = mint.wallet().await;
|
|
let item = ContentItem {
|
|
id: "paid-test".into(),
|
|
filename: "test.txt".into(),
|
|
mime_type: "text/plain".into(),
|
|
size_bytes: 5,
|
|
description: String::new(),
|
|
added_at: String::new(),
|
|
availability: Availability::AllPeers,
|
|
access: AccessControl::Paid {
|
|
price_sats: price,
|
|
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
|
|
},
|
|
};
|
|
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
|
|
.await
|
|
.unwrap();
|
|
if exists {
|
|
tokio::fs::create_dir_all(seller.path().join("content/files"))
|
|
.await
|
|
.unwrap();
|
|
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
|
.serialize_v4()
|
|
.unwrap();
|
|
let result = content_server::serve_content(
|
|
seller.path(),
|
|
"paid-test",
|
|
Some(&token),
|
|
None,
|
|
None,
|
|
None,
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
if exists && accepts_cashu && price <= 128 {
|
|
match result {
|
|
ServeResult::Ok(bytes, mime) => {
|
|
assert_eq!(bytes, b"hello");
|
|
assert_eq!(mime, "text/plain");
|
|
}
|
|
_ => panic!("paid content was not delivered"),
|
|
}
|
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
|
} else {
|
|
assert!(matches!(
|
|
result,
|
|
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
|
));
|
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
|
assert!(mint.requests.lock().unwrap().is_empty());
|
|
}
|
|
}
|
|
}
|