use async_trait::async_trait;
use std::sync::Arc;
use crate::configurable_host::{ConfigurableHost, KeysetCacheEntry};
use crate::SpilmanAsyncNetworking;
use cashu::nuts::{CurrencyUnit, Id};
#[derive(Debug, Clone)]
pub struct MintKeysetWithKeys {
pub id: Id,
pub unit: CurrencyUnit,
pub active: bool,
pub input_fee_ppk: u64,
pub keys: serde_json::Value,
}
#[derive(Debug, Clone)]
struct ListedMintKeyset {
id: Id,
unit: CurrencyUnit,
active: bool,
input_fee_ppk: u64,
}
fn parse_listed_keysets(response: &serde_json::Value) -> Result<Vec<ListedMintKeyset>, String> {
let keysets = response
.get("keysets")
.and_then(|keysets| keysets.as_array())
.ok_or("Invalid keysets response")?;
let mut result = Vec::new();
for keyset in keysets {
let Some(id_text) = keyset.get("id").and_then(|value| value.as_str()) else {
tracing::warn!("Ignoring mint keyset without an id");
continue;
};
let id = match id_text.parse::<Id>() {
Ok(id) => id,
Err(error) => {
tracing::warn!(id = id_text, %error, "Ignoring mint keyset with an invalid id");
continue;
}
};
let Some(unit_text) = keyset.get("unit").and_then(|value| value.as_str()) else {
tracing::warn!(id = id_text, "Ignoring mint keyset without a unit");
continue;
};
let unit = match unit_text.parse::<CurrencyUnit>() {
Ok(unit) => unit,
Err(error) => {
tracing::warn!(id = id_text, unit = unit_text, %error, "Ignoring mint keyset with an invalid unit");
continue;
}
};
result.push(ListedMintKeyset {
id,
unit,
active: keyset
.get("active")
.and_then(|value| value.as_bool())
.unwrap_or(false),
input_fee_ppk: keyset
.get("input_fee_ppk")
.and_then(|value| value.as_u64())
.unwrap_or(0),
});
}
if result.is_empty() {
return Err("Mint returned no valid keysets".to_owned());
}
Ok(result)
}
pub async fn fetch_all_keysets_from_mint(
mint_url: &str,
) -> Result<Vec<MintKeysetWithKeys>, String> {
let client = reqwest::Client::new();
let keysets_url = format!("{mint_url}/v1/keysets");
let keysets_resp: serde_json::Value = client
.get(&keysets_url)
.send()
.await
.map_err(|e| format!("Failed to fetch keysets: {e}"))?
.json()
.await
.map_err(|e| format!("Failed to parse keysets response: {e}"))?;
let keysets = parse_listed_keysets(&keysets_resp)?;
let mut result = Vec::new();
for keyset in keysets {
let keys_url = format!("{mint_url}/v1/keys/{}", keyset.id);
let keys_resp: serde_json::Value = client
.get(&keys_url)
.send()
.await
.map_err(|error| format!("Failed to fetch keys for {}: {error}", keyset.id))?
.json()
.await
.map_err(|error| format!("Failed to parse keys response for {}: {error}", keyset.id))?;
let keys = keys_resp
.get("keysets")
.and_then(|k| k.as_array())
.and_then(|arr| arr.first())
.and_then(|k| k.get("keys"))
.cloned()
.unwrap_or(serde_json::json!({}));
result.push(MintKeysetWithKeys {
id: keyset.id,
unit: keyset.unit,
active: keyset.active,
input_fee_ppk: keyset.input_fee_ppk,
keys,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::parse_listed_keysets;
#[test]
fn malformed_advertised_keysets_do_not_hide_a_valid_active_keyset() {
let response = serde_json::json!({
"keysets": [
{
"id": "ctv28hTYzQwr",
"unit": "sat",
"active": false,
"input_fee_ppk": 0
},
{
"id": "00107937db0cc865",
"unit": "sat",
"active": true,
"input_fee_ppk": 0
}
]
});
let keysets = parse_listed_keysets(&response).expect("valid keyset remains usable");
assert_eq!(keysets.len(), 1);
assert_eq!(keysets[0].id.to_string(), "00107937db0cc865");
assert!(keysets[0].active);
}
#[test]
fn an_entirely_malformed_keyset_list_is_rejected() {
let response = serde_json::json!({
"keysets": [{
"id": "ctv28hTYzQwr",
"unit": "sat",
"active": false
}]
});
let error = parse_listed_keysets(&response).expect_err("no usable keyset remains");
assert_eq!(error, "Mint returned no valid keysets");
}
}
pub fn build_keyset_info_json(
keyset_id: &Id,
unit: &CurrencyUnit,
keys: &serde_json::Value,
input_fee_ppk: u64,
) -> String {
serde_json::json!({
"keysetId": keyset_id.to_string(),
"unit": unit.to_string(),
"keys": keys,
"inputFeePpk": input_fee_ppk,
})
.to_string()
}
pub async fn fetch_and_cache_keysets(
host: &ConfigurableHost,
mint_url: &str,
) -> Result<(), String> {
let keysets = fetch_all_keysets_from_mint(mint_url).await?;
for ks in keysets {
let info_json = build_keyset_info_json(&ks.id, &ks.unit, &ks.keys, ks.input_fee_ppk);
host.set_keyset(
mint_url,
ks.id,
KeysetCacheEntry {
info_json,
active: ks.active,
unit: ks.unit,
},
)?;
}
Ok(())
}
#[derive(Debug)]
pub struct ReqwestNetworking {
host: Arc<ConfigurableHost>,
}
impl ReqwestNetworking {
pub fn new(host: Arc<ConfigurableHost>) -> Self {
Self { host }
}
}
#[async_trait]
impl SpilmanAsyncNetworking for ReqwestNetworking {
async fn call_mint_swap(
&self,
mint_url: &str,
swap_request_json: &str,
) -> Result<String, String> {
let client = reqwest::Client::new();
let url = format!("{mint_url}/v1/swap");
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.body(swap_request_json.to_string())
.send()
.await
.map_err(|e| format!("Swap request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body) {
let code = parsed.get("code").and_then(|v| v.as_u64());
let detail = parsed
.get("detail")
.and_then(|v| v.as_str())
.unwrap_or_default();
tracing::warn!(
%status,
nut00_code = ?code,
detail,
"Mint rejected swap (NUT-00 error)"
);
return Err(body);
}
return Err(format!("Swap failed: {status} - {body}"));
}
resp.text()
.await
.map_err(|e| format!("Failed to read swap response: {e}"))
}
async fn refresh_all_keysets(&self, mint: &str) -> Result<(), String> {
tracing::info!("Refreshing keysets from {mint}...");
fetch_and_cache_keysets(&self.host, mint).await?;
tracing::info!("Keyset refresh complete for {mint}");
Ok(())
}
}