use std::collections::BTreeMap;
use std::fmt;
#[cfg(feature = "npubcash")]
use std::str::FromStr;
use std::sync::Arc;
use cdk_common::database;
use cdk_common::database::WalletDatabase;
use cdk_common::wallet::WalletKey;
use tokio::sync::RwLock;
use tracing::instrument;
use zeroize::Zeroize;
use super::builder::WalletBuilder;
use super::{AuthMintConnector, Error, MintConnector, RateLimitConfig, RateLimiterManager};
use crate::mint_url::MintUrl;
use crate::nuts::CurrencyUnit;
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
use crate::wallet::mint_connector::transport::TorAsync;
use crate::{OidcClient, Wallet};
#[derive(Debug, Clone)]
pub struct TokenData {
pub mint_url: MintUrl,
pub proofs: cdk_common::Proofs,
pub memo: Option<String>,
pub value: cdk_common::Amount,
pub unit: CurrencyUnit,
pub redeem_fee: Option<cdk_common::Amount>,
}
#[derive(Clone, Default)]
pub struct WalletConfig {
pub mint_connector: Option<Arc<dyn super::MintConnector + Send + Sync>>,
pub auth_connector: Option<Arc<dyn super::auth::AuthMintConnector + Send + Sync>>,
pub target_proof_count: Option<usize>,
pub metadata_cache_ttl: Option<std::time::Duration>,
}
impl fmt::Debug for WalletConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WalletConfig")
.field(
"mint_connector",
&self.mint_connector.as_ref().map(|_| "[CONFIGURED]"),
)
.field(
"auth_connector",
&self.auth_connector.as_ref().map(|_| "[CONFIGURED]"),
)
.field("target_proof_count", &self.target_proof_count)
.field("metadata_cache_ttl", &self.metadata_cache_ttl)
.finish()
}
}
impl WalletConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_mint_connector(
mut self,
connector: Arc<dyn super::MintConnector + Send + Sync>,
) -> Self {
self.mint_connector = Some(connector);
self
}
pub fn with_auth_connector(
mut self,
connector: Arc<dyn super::auth::AuthMintConnector + Send + Sync>,
) -> Self {
self.auth_connector = Some(connector);
self
}
pub fn with_target_proof_count(mut self, count: usize) -> Self {
self.target_proof_count = Some(count);
self
}
pub fn with_metadata_cache_ttl(mut self, ttl: Option<std::time::Duration>) -> Self {
self.metadata_cache_ttl = ttl;
self
}
}
pub struct WalletRepositoryBuilder {
localstore: Option<Arc<dyn WalletDatabase<database::Error> + Send + Sync>>,
seed: Option<[u8; 64]>,
proxy_config: Option<url::Url>,
danger_accept_invalid_certs: bool,
rate_limit: Option<RateLimitConfig>,
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
use_tor: bool,
}
impl std::fmt::Debug for WalletRepositoryBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WalletRepositoryBuilder")
.field("localstore", &self.localstore.as_ref().map(|_| "..."))
.field("seed", &"[REDACTED]")
.field("proxy_config", &self.proxy_config)
.field(
"danger_accept_invalid_certs",
&self.danger_accept_invalid_certs,
)
.field("rate_limit", &self.rate_limit)
.finish()
}
}
impl Default for WalletRepositoryBuilder {
fn default() -> Self {
Self::new()
}
}
impl WalletRepositoryBuilder {
pub fn new() -> Self {
Self {
localstore: None,
seed: None,
proxy_config: None,
danger_accept_invalid_certs: false,
rate_limit: Some(RateLimitConfig::default()),
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
use_tor: false,
}
}
pub fn localstore(
mut self,
localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
) -> Self {
self.localstore = Some(localstore);
self
}
pub fn seed(mut self, seed: [u8; 64]) -> Self {
self.seed = Some(seed);
self
}
pub fn proxy_url(mut self, proxy_url: url::Url) -> Self {
self.proxy_config = Some(proxy_url);
self
}
pub fn danger_accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
self.danger_accept_invalid_certs = accept_invalid_certs;
self
}
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
pub fn tor(mut self) -> Self {
self.use_tor = true;
self
}
pub fn with_rate_limiting_config(mut self, config: RateLimitConfig) -> Self {
self.rate_limit = Some(config);
self
}
pub fn with_rate_limiting_disabled(mut self) -> Self {
self.rate_limit = None;
self
}
pub async fn build(self) -> Result<WalletRepository, Error> {
let localstore = self
.localstore
.ok_or(Error::Custom("localstore is required".into()))?;
let seed = self.seed.ok_or(Error::Custom("seed is required".into()))?;
let rate_limiter = RateLimiterManager::new(
self.rate_limit.unwrap_or_default(),
Some(localstore.clone()),
);
rate_limiter.set_enabled(self.rate_limit.is_some());
let wallet = WalletRepository {
rate_limiter,
localstore,
seed,
wallets: Arc::new(RwLock::new(BTreeMap::new())),
proxy_config: self.proxy_config,
danger_accept_invalid_certs: self.danger_accept_invalid_certs,
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
shared_tor_transport: if self.use_tor {
Some(TorAsync::new())
} else {
None
},
};
wallet.load_wallets().await?;
Ok(wallet)
}
}
fn proxy_http_client(
mint_url: MintUrl,
proxy_url: &url::Url,
accept_invalid_certs: bool,
) -> Result<crate::wallet::HttpClient, Error> {
validate_proxy_url(proxy_url)?;
crate::wallet::HttpClient::with_proxy(mint_url, proxy_url.clone(), None, accept_invalid_certs)
}
fn proxy_auth_http_client(
mint_url: MintUrl,
proxy_url: &url::Url,
accept_invalid_certs: bool,
) -> Result<crate::wallet::AuthHttpClient, Error> {
validate_proxy_url(proxy_url)?;
crate::wallet::AuthHttpClient::with_proxy(
mint_url,
proxy_url.clone(),
None,
accept_invalid_certs,
None,
)
}
fn validate_proxy_url(proxy_url: &url::Url) -> Result<(), Error> {
match proxy_url.scheme() {
"http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" => {}
scheme => {
return Err(Error::HttpError(
None,
format!("Unsupported proxy URL scheme: {scheme}"),
));
}
}
Ok(())
}
#[derive(Clone)]
pub struct WalletRepository {
localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
seed: [u8; 64],
wallets: Arc<RwLock<BTreeMap<WalletKey, Wallet>>>,
rate_limiter: RateLimiterManager,
proxy_config: Option<url::Url>,
danger_accept_invalid_certs: bool,
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
shared_tor_transport: Option<TorAsync>,
}
impl std::fmt::Debug for WalletRepository {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WalletRepository").finish_non_exhaustive()
}
}
impl WalletRepository {
pub fn seed(&self) -> &[u8; 64] {
&self.seed
}
#[instrument(skip(self))]
pub async fn get_wallet(
&self,
mint_url: &MintUrl,
unit: &CurrencyUnit,
) -> Result<Wallet, Error> {
let key = WalletKey::new(mint_url.clone(), unit.clone());
self.wallets
.read()
.await
.get(&key)
.cloned()
.ok_or_else(|| Error::UnknownWallet(key))
}
#[instrument(skip(self))]
pub async fn get_wallets_for_mint(&self, mint_url: &MintUrl) -> Vec<Wallet> {
self.wallets
.read()
.await
.iter()
.filter(|(key, _)| &key.mint_url == mint_url)
.map(|(_, wallet)| wallet.clone())
.collect()
}
#[instrument(skip(self))]
pub async fn oidc_client_for_mint(
&self,
mint_url: &MintUrl,
openid_discovery: String,
client_id: Option<String>,
) -> OidcClient {
match self.get_wallets_for_mint(mint_url).await.into_iter().next() {
Some(wallet) => wallet.oidc_client(openid_discovery, client_id),
None => OidcClient::new(openid_discovery, client_id),
}
}
#[instrument(skip(self))]
pub async fn has_wallet(&self, mint_url: &MintUrl, unit: &CurrencyUnit) -> bool {
let key = WalletKey::new(mint_url.clone(), unit.clone());
self.wallets.read().await.contains_key(&key)
}
#[instrument(skip(self))]
pub async fn add_wallet(&self, mint_url: MintUrl) -> Result<Vec<Wallet>, Error> {
self.add_wallet_with_config(mint_url, None).await
}
#[instrument(skip(self, config))]
pub async fn add_wallet_with_config(
&self,
mint_url: MintUrl,
config: Option<WalletConfig>,
) -> Result<Vec<Wallet>, Error> {
let mint_info = self.fetch_mint_info(&mint_url).await?;
let supported_units = mint_info.supported_units();
if supported_units.is_empty() {
return Err(Error::Custom(
"Mint does not support any currency units".into(),
));
}
let mut wallets = Vec::new();
for unit in supported_units {
let wallet = self
.get_or_create_wallet(mint_url.clone(), unit.clone(), config.clone())
.await?;
wallets.push(wallet);
}
Ok(wallets)
}
#[instrument(skip(self, config))]
pub async fn get_or_create_wallet(
&self,
mint_url: MintUrl,
unit: CurrencyUnit,
config: Option<WalletConfig>,
) -> Result<Wallet, Error> {
let key = WalletKey::new(mint_url.clone(), unit.clone());
let mut wallets = self.wallets.write().await;
if let Some(existing) = wallets.get(&key) {
return Ok(existing.clone());
}
let wallet = self
.create_wallet_internal(mint_url, unit, config.as_ref())
.await?;
wallets.insert(key, wallet.clone());
Ok(wallet)
}
#[instrument(skip(self, config))]
pub async fn set_mint_config(
&self,
mint_url: MintUrl,
unit: CurrencyUnit,
config: WalletConfig,
) -> Result<Wallet, Error> {
self.create_wallet(mint_url, unit, Some(config)).await
}
#[instrument(skip(self, config))]
pub async fn create_wallet(
&self,
mint_url: MintUrl,
unit: CurrencyUnit,
config: Option<WalletConfig>,
) -> Result<Wallet, Error> {
let wallet = self
.create_wallet_internal(mint_url.clone(), unit.clone(), config.as_ref())
.await?;
let key = WalletKey::new(mint_url, unit);
let mut wallets = self.wallets.write().await;
wallets.insert(key, wallet.clone());
Ok(wallet)
}
pub async fn flush_rate_limits(&self) {
self.rate_limiter.flush().await;
}
pub fn set_rate_limiting_config(&self, config: Option<RateLimitConfig>) {
match config {
Some(config) => self.rate_limiter.set_config(config),
None => self.rate_limiter.set_enabled(false),
}
}
pub fn is_rate_limited(&self) -> bool {
self.rate_limiter.is_enabled()
}
#[instrument(skip(self))]
pub async fn remove_wallet(
&self,
mint_url: MintUrl,
currency_unit: CurrencyUnit,
) -> Result<(), Error> {
let key = WalletKey::new(mint_url, currency_unit);
let mut wallets = self.wallets.write().await;
if !wallets.contains_key(&key) {
return Err(Error::UnknownWallet(key));
}
wallets.remove(&key);
Ok(())
}
#[instrument(skip(self))]
pub async fn get_wallets(&self) -> Vec<Wallet> {
self.wallets.read().await.values().cloned().collect()
}
#[instrument(skip(self))]
pub async fn has_mint(&self, mint_url: &MintUrl) -> bool {
self.wallets
.read()
.await
.keys()
.any(|key| &key.mint_url == mint_url)
}
#[instrument(skip(self))]
pub async fn get_balances(&self) -> Result<BTreeMap<WalletKey, cdk_common::Amount>, Error> {
let wallets = self.wallets.read().await;
let mut balances = BTreeMap::new();
for (key, wallet) in wallets.iter() {
let balance = wallet.total_balance().await?;
balances.insert(key.clone(), balance);
}
Ok(balances)
}
#[instrument(skip(self))]
pub async fn total_balance(&self) -> Result<BTreeMap<CurrencyUnit, cdk_common::Amount>, Error> {
let balances = self.get_balances().await?;
let mut by_unit: BTreeMap<CurrencyUnit, cdk_common::Amount> = BTreeMap::new();
for (key, amount) in balances {
let entry = by_unit.entry(key.unit).or_insert(cdk_common::Amount::ZERO);
*entry += amount;
}
Ok(by_unit)
}
pub async fn fetch_mint_info(
&self,
mint_url: &MintUrl,
) -> Result<crate::nuts::MintInfo, Error> {
let client: Arc<dyn MintConnector + Send + Sync> =
if let Some(proxy_url) = &self.proxy_config {
Arc::new(proxy_http_client(
mint_url.clone(),
proxy_url,
self.danger_accept_invalid_certs,
)?)
} else {
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
if let Some(tor) = &self.shared_tor_transport {
let transport = tor.clone();
Arc::new(crate::wallet::TorHttpClient::with_transport(
mint_url.clone(),
transport,
None,
))
} else {
Arc::new(crate::wallet::HttpClient::new(mint_url.clone(), None))
}
#[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))]
{
Arc::new(crate::wallet::HttpClient::new(mint_url.clone(), None))
}
};
client.get_mint_info().await
}
async fn create_wallet_internal(
&self,
mint_url: MintUrl,
unit: CurrencyUnit,
config: Option<&WalletConfig>,
) -> Result<Wallet, Error> {
let target_proof_count = config.and_then(|c| c.target_proof_count).unwrap_or(3);
let metadata_cache_ttl = config.and_then(|c| c.metadata_cache_ttl);
let configured_auth_connector = config.and_then(|c| c.auth_connector.clone());
if let Some(cfg) = config {
if let Some(custom_connector) = &cfg.mint_connector {
let mut builder = WalletBuilder::new()
.mint_url(mint_url.clone())
.unit(unit.clone())
.localstore(self.localstore.clone())
.seed(self.seed)
.target_proof_count(target_proof_count)
.with_rate_limiter(self.rate_limiter.clone())
.shared_client(custom_connector.clone());
if let Some(auth_connector) = configured_auth_connector.clone() {
builder = builder.auth_connector(auth_connector);
}
if let Some(ttl) = metadata_cache_ttl {
builder = builder.set_metadata_cache_ttl(Some(ttl));
}
return builder.build();
}
}
let wallet = if let Some(proxy_url) = &self.proxy_config {
let client = proxy_http_client(
mint_url.clone(),
proxy_url,
self.danger_accept_invalid_certs,
)?;
let auth_connector = match configured_auth_connector.clone() {
Some(auth_connector) => auth_connector,
None => Arc::new(proxy_auth_http_client(
mint_url.clone(),
proxy_url,
self.danger_accept_invalid_certs,
)?) as Arc<dyn AuthMintConnector + Send + Sync>,
};
let mut builder = WalletBuilder::new()
.mint_url(mint_url.clone())
.unit(unit.clone())
.localstore(self.localstore.clone())
.seed(self.seed)
.target_proof_count(target_proof_count)
.with_rate_limiter(self.rate_limiter.clone())
.client(client)
.auth_connector(auth_connector);
if let Some(ttl) = metadata_cache_ttl {
builder = builder.set_metadata_cache_ttl(Some(ttl));
}
builder.build()?
} else {
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
if let Some(tor) = &self.shared_tor_transport {
let client = crate::wallet::TorHttpClient::with_transport(
mint_url.clone(),
tor.clone(),
None,
);
let auth_connector = configured_auth_connector.clone().unwrap_or_else(|| {
Arc::new(crate::wallet::TorAuthHttpClient::with_transport(
mint_url.clone(),
tor.clone(),
None,
)) as Arc<dyn AuthMintConnector + Send + Sync>
});
let mut builder = WalletBuilder::new()
.mint_url(mint_url.clone())
.unit(unit.clone())
.localstore(self.localstore.clone())
.seed(self.seed)
.target_proof_count(target_proof_count)
.with_rate_limiter(self.rate_limiter.clone())
.client(client)
.auth_connector(auth_connector);
if let Some(ttl) = metadata_cache_ttl {
builder = builder.set_metadata_cache_ttl(Some(ttl));
}
builder.build()?
} else {
let mut builder = WalletBuilder::new()
.mint_url(mint_url.clone())
.unit(unit.clone())
.localstore(self.localstore.clone())
.seed(self.seed)
.target_proof_count(target_proof_count)
.with_rate_limiter(self.rate_limiter.clone());
if let Some(auth_connector) = configured_auth_connector.clone() {
builder = builder.auth_connector(auth_connector);
}
if let Some(ttl) = metadata_cache_ttl {
builder = builder.set_metadata_cache_ttl(Some(ttl));
}
builder.build()?
}
#[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))]
{
let mut builder = WalletBuilder::new()
.mint_url(mint_url.clone())
.unit(unit.clone())
.localstore(self.localstore.clone())
.seed(self.seed)
.target_proof_count(target_proof_count)
.with_rate_limiter(self.rate_limiter.clone());
if let Some(auth_connector) = configured_auth_connector.clone() {
builder = builder.auth_connector(auth_connector);
}
if let Some(ttl) = metadata_cache_ttl {
builder = builder.set_metadata_cache_ttl(Some(ttl));
}
builder.build()?
}
};
Ok(wallet)
}
#[instrument(skip(self))]
async fn load_wallets(&self) -> Result<(), Error> {
let mints = self.localstore.get_mints().await.map_err(Error::Database)?;
for (mint_url, mint_info) in mints {
let units = mint_info
.map(|info| {
let supported_units = info.supported_units();
if supported_units.is_empty() {
vec![CurrencyUnit::Sat]
} else {
supported_units.into_iter().cloned().collect()
}
})
.unwrap_or_else(|| vec![CurrencyUnit::Sat]);
for unit in units {
self.get_or_create_wallet(mint_url.clone(), unit, None)
.await?;
}
}
Ok(())
}
#[cfg(feature = "npubcash")]
pub async fn get_active_npubcash_mint(&self) -> Result<Option<MintUrl>, Error> {
use super::npubcash::{ACTIVE_MINT_KEY, NPUBCASH_KV_NAMESPACE};
let value = self
.localstore
.kv_read(NPUBCASH_KV_NAMESPACE, "", ACTIVE_MINT_KEY)
.await?;
match value {
Some(bytes) => {
let s = String::from_utf8(bytes)
.map_err(|_| Error::Custom("Invalid active mint URL".into()))?;
Ok(Some(MintUrl::from_str(&s)?))
}
None => Ok(None),
}
}
#[cfg(feature = "npubcash")]
pub async fn set_active_npubcash_mint(&self, mint_url: MintUrl) -> Result<(), Error> {
use super::npubcash::{ACTIVE_MINT_KEY, NPUBCASH_KV_NAMESPACE};
self.localstore
.kv_write(
NPUBCASH_KV_NAMESPACE,
"",
ACTIVE_MINT_KEY,
mint_url.to_string().as_bytes(),
)
.await?;
Ok(())
}
#[cfg(feature = "npubcash")]
pub async fn sync_npubcash_quotes(
&self,
) -> Result<Vec<crate::wallet::types::MintQuote>, Error> {
let active_mint = self.get_active_npubcash_mint().await?;
if let Some(mint_url) = active_mint {
let wallet = self.get_wallet(&mint_url, &CurrencyUnit::Sat).await?;
wallet.sync_npubcash_quotes().await
} else {
Err(Error::Custom("No active NpubCash mint set".into()))
}
}
#[instrument(skip(self, token))]
pub async fn get_token_data(
&self,
token: &crate::nuts::nut00::Token,
) -> Result<TokenData, Error> {
let mint_url = token.mint_url()?;
let unit = token.unit().unwrap_or_default();
let wallet = self.get_wallet(&mint_url, &unit).await?;
let proofs = wallet.token_proofs(token).await?;
let memo = token.memo().clone();
let redeem_fee = wallet.get_proofs_fee(&proofs).await?;
Ok(TokenData {
value: cdk_common::nuts::nut00::ProofsMethods::total_amount(&proofs)?,
mint_url,
proofs,
memo,
unit,
redeem_fee: Some(redeem_fee.total),
})
}
#[instrument(skip(self))]
pub async fn list_proofs(
&self,
) -> Result<std::collections::BTreeMap<WalletKey, Vec<cdk_common::Proof>>, Error> {
let mut mint_proofs = std::collections::BTreeMap::new();
for (key, wallet) in self.wallets.read().await.iter() {
let wallet_proofs = wallet.get_unspent_proofs().await?;
mint_proofs.insert(key.clone(), wallet_proofs);
}
Ok(mint_proofs)
}
#[instrument(skip(self))]
pub async fn list_transactions(
&self,
direction: Option<cdk_common::wallet::TransactionDirection>,
) -> Result<Vec<cdk_common::wallet::Transaction>, Error> {
let mut transactions = Vec::new();
for wallet in self.wallets.read().await.values() {
let wallet_transactions = wallet.list_transactions(direction).await?;
transactions.extend(wallet_transactions);
}
transactions.sort();
Ok(transactions)
}
#[instrument(skip(self))]
pub async fn check_all_mint_quotes(
&self,
mint_url: Option<MintUrl>,
) -> Result<cdk_common::Amount, Error> {
let mut total_minted = cdk_common::Amount::ZERO;
let wallets = self.wallets.read().await;
let wallets_to_check: Vec<_> = match &mint_url {
Some(url) => {
let filtered: Vec<_> = wallets
.iter()
.filter(|(key, _)| &key.mint_url == url)
.map(|(_, wallet)| wallet.clone())
.collect();
if filtered.is_empty() {
return Err(Error::UnknownMint {
mint_url: url.to_string(),
});
}
filtered
}
None => wallets.values().cloned().collect(),
};
drop(wallets);
for wallet in wallets_to_check {
let minted = wallet.mint_unissued_quotes().await?;
total_minted += minted;
}
Ok(total_minted)
}
}
impl Drop for WalletRepository {
fn drop(&mut self) {
self.seed.zeroize();
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use cdk_common::database::WalletDatabase;
use cdk_common::nut00::KnownMethod;
use cdk_common::nuts::{MintInfo, MintMethodSettings};
use tokio::net::TcpListener;
use super::*;
use crate::nuts::{NUT04Settings, Nuts, PaymentMethod};
async fn create_test_repository() -> WalletRepository {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let seed = [0u8; 64];
WalletRepositoryBuilder::new()
.localstore(localstore)
.seed(seed)
.build()
.await
.expect("Failed to create WalletRepository")
}
async fn create_test_repository_with_proxy(proxy_url: url::Url) -> WalletRepository {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let seed = [0u8; 64];
WalletRepositoryBuilder::new()
.localstore(localstore)
.seed(seed)
.proxy_url(proxy_url)
.build()
.await
.expect("Failed to create WalletRepository")
}
async fn local_mint_url_with_connection_counter(
) -> (MintUrl, Arc<AtomicUsize>, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind test mint listener");
let address = listener
.local_addr()
.expect("Failed to get test mint listener address");
let direct_connections = Arc::new(AtomicUsize::new(0));
let connection_count = Arc::clone(&direct_connections);
let handle = tokio::spawn(async move {
while let Ok((_stream, _address)) = listener.accept().await {
connection_count.fetch_add(1, Ordering::SeqCst);
}
});
(
format!("http://{address}")
.parse()
.expect("Failed to parse test mint URL"),
direct_connections,
handle,
)
}
fn unsupported_proxy_url() -> url::Url {
"gopher://127.0.0.1:1080"
.parse()
.expect("Failed to parse proxy URL")
}
fn mint_info_with_units(units: Vec<CurrencyUnit>) -> MintInfo {
MintInfo::new().nuts(
Nuts::new().nut04(NUT04Settings::new(
units
.into_iter()
.map(|unit| MintMethodSettings {
method: PaymentMethod::Known(KnownMethod::Bolt11),
unit,
method_name: None,
min_amount: None,
max_amount: None,
options: None,
})
.collect(),
false,
)),
)
}
#[test]
fn builder_verifies_proxy_tls_certificates_by_default() {
let builder = WalletRepositoryBuilder::new();
assert!(!builder.danger_accept_invalid_certs);
}
#[test]
fn builder_can_explicitly_accept_invalid_proxy_tls_certificates() {
let builder = WalletRepositoryBuilder::new().danger_accept_invalid_certs(true);
assert!(builder.danger_accept_invalid_certs);
}
#[tokio::test]
async fn test_wallet_repository_creation() {
let repo = create_test_repository().await;
assert!(repo.wallets.try_read().is_ok());
}
#[tokio::test]
async fn test_load_wallets_uses_persisted_metadata_without_network() {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let (mint_url, direct_connections, listener_handle) =
local_mint_url_with_connection_counter().await;
localstore
.add_mint(
mint_url.clone(),
Some(mint_info_with_units(vec![
CurrencyUnit::Sat,
CurrencyUnit::Usd,
])),
)
.await
.expect("Failed to add mint metadata");
let result = tokio::time::timeout(
Duration::from_secs(1),
WalletRepositoryBuilder::new()
.localstore(localstore)
.seed([0u8; 64])
.build(),
)
.await;
listener_handle.abort();
let repo = result
.expect("Repository startup should not wait for a mint request")
.expect("Repository startup should succeed");
assert_eq!(direct_connections.load(Ordering::SeqCst), 0);
assert!(repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
assert!(repo.has_wallet(&mint_url, &CurrencyUnit::Usd).await);
}
#[tokio::test]
async fn test_load_wallets_falls_back_to_sat_without_persisted_metadata() {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let (mint_url, direct_connections, listener_handle) =
local_mint_url_with_connection_counter().await;
localstore
.add_mint(mint_url.clone(), None)
.await
.expect("Failed to add legacy mint");
let result = tokio::time::timeout(
Duration::from_secs(1),
WalletRepositoryBuilder::new()
.localstore(localstore)
.seed([0u8; 64])
.build(),
)
.await;
listener_handle.abort();
let repo = result
.expect("Repository startup should not wait for a mint request")
.expect("Repository startup should succeed");
assert_eq!(direct_connections.load(Ordering::SeqCst), 0);
assert!(repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
}
#[tokio::test]
async fn test_has_mint_empty() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
assert!(!repo.has_mint(&mint_url).await);
}
#[tokio::test]
async fn test_create_and_get_wallet() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let wallet = repo
.create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("Failed to create wallet");
assert_eq!(wallet.mint_url, mint_url);
assert_eq!(wallet.unit, CurrencyUnit::Sat);
assert!(repo.has_mint(&mint_url).await);
assert!(repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
let retrieved = repo.get_wallet(&mint_url, &CurrencyUnit::Sat).await;
assert!(retrieved.is_ok());
}
#[tokio::test]
async fn test_get_or_create_wallet_keeps_the_existing_wallet() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
repo.create_wallet(
mint_url.clone(),
CurrencyUnit::Sat,
Some(WalletConfig::new().with_target_proof_count(5)),
)
.await
.expect("Failed to create wallet");
let wallet = repo
.get_or_create_wallet(
mint_url.clone(),
CurrencyUnit::Sat,
Some(WalletConfig::new().with_target_proof_count(99)),
)
.await
.expect("Failed to get wallet");
assert_eq!(wallet.target_proof_count, 5);
}
#[tokio::test]
async fn test_get_or_create_wallet_creates_a_missing_wallet() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let wallet = repo
.get_or_create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("Failed to create wallet");
assert_eq!(wallet.mint_url, mint_url);
assert_eq!(wallet.unit, CurrencyUnit::Sat);
assert!(repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
}
#[tokio::test]
async fn test_fetch_mint_info_returns_error_when_proxy_setup_fails() {
let repo = create_test_repository_with_proxy(unsupported_proxy_url()).await;
let (mint_url, direct_connections, listener_handle) =
local_mint_url_with_connection_counter().await;
let result = repo.fetch_mint_info(&mint_url).await;
listener_handle.abort();
assert!(result.is_err());
assert_eq!(direct_connections.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_create_wallet_returns_error_when_proxy_setup_fails() {
let repo = create_test_repository_with_proxy(unsupported_proxy_url()).await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let result = repo
.create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await;
assert!(result.is_err());
assert!(!repo.has_mint(&mint_url).await);
assert!(!repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
}
#[tokio::test]
async fn test_remove_wallet() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
repo.create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("Failed to create wallet");
assert!(repo.has_mint(&mint_url).await);
assert!(repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
let _ = repo
.remove_wallet(mint_url.clone(), CurrencyUnit::Sat)
.await;
assert!(!repo.has_mint(&mint_url).await);
assert!(!repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
}
#[tokio::test]
async fn test_get_wallets() {
let repo = create_test_repository().await;
let mint1: MintUrl = "https://mint1.example.com".parse().unwrap();
let mint2: MintUrl = "https://mint2.example.com".parse().unwrap();
repo.create_wallet(mint1, CurrencyUnit::Sat, None)
.await
.expect("Failed to create wallet 1");
repo.create_wallet(mint2, CurrencyUnit::Sat, None)
.await
.expect("Failed to create wallet 2");
let wallets = repo.get_wallets().await;
assert_eq!(wallets.len(), 2);
}
#[tokio::test]
async fn test_remove_wallet_does_not_touch_db() {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let seed = [0u8; 64];
let repo = WalletRepositoryBuilder::new()
.localstore(localstore.clone())
.seed(seed)
.build()
.await
.expect("Failed to create WalletRepository");
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
localstore.add_mint(mint_url.clone(), None).await.unwrap();
repo.create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("Failed to create wallet");
repo.remove_wallet(mint_url.clone(), CurrencyUnit::Sat)
.await
.expect("Failed to remove wallet");
assert!(!repo.has_wallet(&mint_url, &CurrencyUnit::Sat).await);
assert!(localstore
.get_mint(mint_url.clone())
.await
.unwrap()
.is_some());
}
const DEFAULT_BURST: usize = 20;
fn bucket_for(wallet: &Wallet, url: &str) -> crate::wallet::TokenBucket {
wallet
.rate_limiter
.clone()
.expect("default path retains its limiter")
.bucket_for(&url::Url::parse(url).expect("valid url"))
}
#[tokio::test]
async fn wallets_for_same_mint_share_one_rate_limit_budget() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let sat = repo
.create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("failed to create sat wallet");
let usd = repo
.create_wallet(mint_url.clone(), CurrencyUnit::Usd, None)
.await
.expect("failed to create usd wallet");
let sat_bucket = bucket_for(&sat, "https://mint.example.com/v1/mint");
let usd_bucket = bucket_for(&usd, "https://mint.example.com/v1/melt");
let mut admitted = 0;
for _ in 0..DEFAULT_BURST {
if sat_bucket.try_acquire().await {
admitted += 1;
}
if usd_bucket.try_acquire().await {
admitted += 1;
}
}
assert_eq!(
admitted, DEFAULT_BURST,
"combined burst is one capacity, not two"
);
assert!(
!sat_bucket.try_acquire().await,
"shared budget already spent"
);
assert!(
!usd_bucket.try_acquire().await,
"shared budget already spent"
);
}
#[tokio::test]
async fn wallets_for_different_mints_have_independent_budgets() {
let repo = create_test_repository().await;
let mint_a: MintUrl = "https://mint-a.example.com".parse().unwrap();
let mint_b: MintUrl = "https://mint-b.example.com".parse().unwrap();
let wallet_a = repo
.create_wallet(mint_a, CurrencyUnit::Sat, None)
.await
.expect("failed to create wallet a");
let wallet_b = repo
.create_wallet(mint_b, CurrencyUnit::Sat, None)
.await
.expect("failed to create wallet b");
let bucket_a = bucket_for(&wallet_a, "https://mint-a.example.com/v1/info");
let bucket_b = bucket_for(&wallet_b, "https://mint-b.example.com/v1/info");
for _ in 0..DEFAULT_BURST {
assert!(bucket_a.try_acquire().await);
}
assert!(
!bucket_a.try_acquire().await,
"mint A's own burst is drained"
);
assert!(
bucket_b.try_acquire().await,
"mint B has an untouched budget"
);
}
#[tokio::test]
async fn third_party_hosts_pace_against_their_own_budget() {
let repo = create_test_repository().await;
let wallet_a = repo
.create_wallet(
"https://mint-a.example.com".parse().unwrap(),
CurrencyUnit::Sat,
None,
)
.await
.expect("failed to create wallet a");
let wallet_b = repo
.create_wallet(
"https://mint-b.example.com".parse().unwrap(),
CurrencyUnit::Sat,
None,
)
.await
.expect("failed to create wallet b");
let mint_bucket = bucket_for(&wallet_a, "https://mint-a.example.com/v1/info");
let lnurl = "https://pay.example.org/.well-known/lnurlp/alice";
let lnurl_bucket = bucket_for(&wallet_a, lnurl);
for _ in 0..DEFAULT_BURST {
assert!(mint_bucket.try_acquire().await);
}
assert!(!mint_bucket.try_acquire().await);
assert!(
lnurl_bucket.try_acquire().await,
"the LNURL host keeps its own budget"
);
for _ in 0..(DEFAULT_BURST - 1) {
assert!(bucket_for(&wallet_b, lnurl).try_acquire().await);
}
assert!(
!bucket_for(&wallet_b, lnurl).try_acquire().await,
"the LNURL host's budget is shared across mints"
);
}
#[tokio::test]
async fn flushing_a_wallet_hands_its_budget_to_the_rebuilt_one() {
let cfg = RateLimitConfig::try_new(2, 300).expect("non-zero");
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let mint_endpoint = "https://mint.example.com/v1/mint";
let repo = WalletRepositoryBuilder::new()
.localstore(localstore.clone())
.seed([0u8; 64])
.build()
.await
.expect("Failed to create WalletRepository");
let wallet = repo
.create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("failed to create wallet");
wallet.set_rate_limiting_config(cfg);
let bucket = bucket_for(&wallet, mint_endpoint);
bucket.acquire(async {}).await;
bucket.acquire(async {}).await;
wallet.flush_rate_limits().await;
drop((bucket, wallet, repo));
let rebuilt_repo = WalletRepositoryBuilder::new()
.localstore(localstore)
.seed([0u8; 64])
.build()
.await
.expect("Failed to rebuild WalletRepository");
let rebuilt = rebuilt_repo
.get_or_create_wallet(mint_url, CurrencyUnit::Sat, None)
.await
.expect("failed to rebuild wallet");
rebuilt.set_rate_limiting_config(cfg);
let rebuilt_bucket = bucket_for(&rebuilt, mint_endpoint);
let start = Instant::now();
rebuilt_bucket.acquire(async {}).await;
rebuilt_bucket.acquire(async {}).await;
assert!(
start.elapsed() >= Duration::from_millis(150),
"rebuilt wallet should inherit the flushed budget, took {:?}",
start.elapsed()
);
let untouched = bucket_for(&rebuilt, "https://other.example.com/v1/info");
let start = Instant::now();
untouched.acquire(async {}).await;
untouched.acquire(async {}).await;
assert!(
start.elapsed() < Duration::from_millis(100),
"an origin the flushed wallet never touched still bursts"
);
}
#[tokio::test]
async fn flush_rate_limits_is_a_no_op_without_a_limiter() {
use crate::wallet::test_utils::MockMintConnector;
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let unlimited = crate::wallet::WalletBuilder::default()
.mint_url("https://mint.example.com".parse().unwrap())
.unit(CurrencyUnit::Sat)
.localstore(localstore)
.seed([0u8; 64])
.shared_client(Arc::new(MockMintConnector::new()))
.build()
.expect("failed to build wallet");
assert!(unlimited.rate_limiter.is_none());
unlimited.flush_rate_limits().await;
create_test_repository().await.flush_rate_limits().await;
}
async fn repository_with_rate_limit(rate_limit: Option<RateLimitConfig>) -> WalletRepository {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("Failed to create in-memory database"),
);
let builder = WalletRepositoryBuilder::new()
.localstore(localstore)
.seed([0u8; 64]);
let builder = match rate_limit {
Some(config) => builder.with_rate_limiting_config(config),
None => builder.with_rate_limiting_disabled(),
};
builder
.build()
.await
.expect("Failed to create WalletRepository")
}
#[tokio::test]
async fn repository_starts_with_the_configured_rate_limit() {
assert!(create_test_repository().await.is_rate_limited());
assert!(repository_with_rate_limit(RateLimitConfig::try_new(5, 30))
.await
.is_rate_limited());
assert!(!repository_with_rate_limit(None).await.is_rate_limited());
}
#[tokio::test]
async fn repository_rate_limit_reaches_wallets_it_already_handed_out() {
let repo = repository_with_rate_limit(None).await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let wallet = repo
.get_or_create_wallet(mint_url, CurrencyUnit::Sat, None)
.await
.expect("wallet should be created");
assert!(!wallet.is_rate_limited());
repo.set_rate_limiting_config(Some(RateLimitConfig::default()));
assert!(repo.is_rate_limited());
assert!(
wallet.is_rate_limited(),
"the wallet shares the repository's limiter"
);
repo.set_rate_limiting_config(None);
assert!(!wallet.is_rate_limited());
}
#[tokio::test]
async fn a_disabled_repository_admits_more_than_one_burst() {
let repo = repository_with_rate_limit(None).await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let wallet = repo
.get_or_create_wallet(mint_url, CurrencyUnit::Sat, None)
.await
.expect("wallet should be created");
let bucket = bucket_for(&wallet, "https://mint.example.com/v1/info");
for _ in 0..(DEFAULT_BURST + 5) {
assert!(bucket.try_acquire().await, "pacing is off");
}
}
#[tokio::test]
async fn get_or_create_wallet_second_unit_shares_the_budget() {
let repo = create_test_repository().await;
let mint_url: MintUrl = "https://mint.example.com".parse().unwrap();
let sat = repo
.get_or_create_wallet(mint_url.clone(), CurrencyUnit::Sat, None)
.await
.expect("failed to create sat wallet");
let usd = repo
.get_or_create_wallet(mint_url.clone(), CurrencyUnit::Usd, None)
.await
.expect("failed to create usd wallet");
let sat_bucket = bucket_for(&sat, "https://mint.example.com/v1/mint");
let usd_bucket = bucket_for(&usd, "https://mint.example.com/v1/melt");
for _ in 0..DEFAULT_BURST {
assert!(sat_bucket.try_acquire().await);
}
assert!(
!usd_bucket.try_acquire().await,
"shared budget already spent"
);
}
}