Dorian 2c98bdd19d feat: streaming ecash payments + media playback overhaul
Cashu ecash protocol (BDHKE blind signatures, cashuA token format,
mint HTTP client) replacing the stub wallet. TollGate-inspired streaming
data payment system with step-based pricing (bytes/time/requests),
session management with incremental top-ups, usage metering, and
Nostr kind 10021 service advertisements.

13 new streaming.* RPC endpoints. Content server now verifies real
Cashu tokens. Profits tracking includes streaming revenue.

Frontend: GlobalAudioPlayer (persistent bottom bar across all pages),
video lightbox with full controls, audio in MediaLightbox, free file
previews (no blur), paid 10% audio/video previews, separated play
vs download buttons in PeerFiles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:31:28 -04:00

866 lines
27 KiB
Rust

//! Cashu ecash wallet for peer-to-peer micropayments and streaming data payments.
//!
//! Real Cashu protocol implementation using BDHKE blind signatures.
//! Connects to Cashu-compatible mints (local Fedimint or external) for
//! mint/melt/swap operations. Stores proofs locally in the data directory.
use super::cashu::{amount_to_denominations, CashuToken, Proof};
use super::mint_client::MintClient;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, warn};
const WALLET_FILE: &str = "wallet/ecash.json";
const MINTS_FILE: &str = "wallet/accepted_mints.json";
/// Transaction type for history tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransactionType {
Mint,
Melt,
Send,
Receive,
StreamingPayment,
StreamingRevenue,
}
/// Transaction history entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EcashTransaction {
pub id: String,
pub tx_type: TransactionType,
pub amount_sats: u64,
pub timestamp: String,
#[serde(default)]
pub description: String,
/// Mint URL involved in this transaction.
#[serde(default)]
pub mint_url: String,
/// Peer identifier (DID, pubkey, or onion) if applicable.
#[serde(default)]
pub peer: String,
}
/// A stored proof with metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProof {
/// The Cashu proof.
#[serde(flatten)]
pub proof: Proof,
/// Mint URL this proof is from.
pub mint_url: String,
/// Whether this proof has been spent (sent to someone or melted).
#[serde(default)]
pub spent: bool,
/// Whether this proof is reserved (allocated to an in-progress operation).
#[serde(default)]
pub reserved: bool,
/// Timestamp when received.
pub created_at: String,
}
/// Persistent wallet state.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct WalletState {
/// All proofs (spent and unspent).
pub proofs: Vec<StoredProof>,
/// Transaction history.
pub transactions: Vec<EcashTransaction>,
/// Primary mint URL.
#[serde(default)]
pub mint_url: String,
// ── Legacy compatibility ──
// Old wallet format had a `tokens` field. If present during deserialization,
// we migrate to proofs. This field is never written.
#[serde(default, skip_serializing)]
tokens: Vec<serde_json::Value>,
}
/// Accepted mints configuration.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AcceptedMints {
/// List of mint URLs we accept tokens from.
pub mints: Vec<String>,
}
impl WalletState {
/// Total balance of unspent, unreserved proofs.
pub fn balance(&self) -> u64 {
self.proofs
.iter()
.filter(|p| !p.spent && !p.reserved)
.map(|p| p.proof.amount)
.sum()
}
/// Balance from a specific mint.
pub fn balance_for_mint(&self, mint_url: &str) -> u64 {
self.proofs
.iter()
.filter(|p| !p.spent && !p.reserved && p.mint_url == mint_url)
.map(|p| p.proof.amount)
.sum()
}
/// Select unspent proofs that cover at least `amount` sats from a specific mint.
/// Returns selected proofs and any overpayment amount.
pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec<usize>, u64)> {
let mut candidates: Vec<(usize, u64)> = self
.proofs
.iter()
.enumerate()
.filter(|(_, p)| !p.spent && !p.reserved && p.mint_url == mint_url)
.map(|(i, p)| (i, p.proof.amount))
.collect();
// Sort by amount ascending for efficient selection
candidates.sort_by_key(|&(_, a)| a);
let mut selected = Vec::new();
let mut total = 0u64;
for (idx, amt) in &candidates {
if total >= amount {
break;
}
selected.push(*idx);
total += amt;
}
if total >= amount {
Some((selected, total - amount))
} else {
None
}
}
/// Mark proofs at given indices as spent.
pub fn mark_spent(&mut self, indices: &[usize]) {
for &i in indices {
if i < self.proofs.len() {
self.proofs[i].spent = true;
}
}
}
/// Add new proofs to the wallet.
pub fn add_proofs(&mut self, mint_url: &str, proofs: Vec<Proof>) {
let now = chrono::Utc::now().to_rfc3339();
for proof in proofs {
self.proofs.push(StoredProof {
proof,
mint_url: mint_url.to_string(),
spent: false,
reserved: false,
created_at: now.clone(),
});
}
}
/// Record a transaction.
pub fn record_tx(
&mut self,
tx_type: TransactionType,
amount_sats: u64,
description: &str,
mint_url: &str,
peer: &str,
) {
self.transactions.push(EcashTransaction {
id: uuid::Uuid::new_v4().to_string(),
tx_type,
amount_sats,
timestamp: chrono::Utc::now().to_rfc3339(),
description: description.to_string(),
mint_url: mint_url.to_string(),
peer: peer.to_string(),
});
}
/// Prune spent proofs older than 30 days to keep wallet file manageable.
pub fn prune_old_spent(&mut self) {
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
let cutoff_str = cutoff.to_rfc3339();
self.proofs
.retain(|p| !p.spent || p.created_at > cutoff_str);
}
}
/// Load wallet state from disk.
pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
let path = data_dir.join(WALLET_FILE);
if !path.exists() {
return Ok(WalletState {
mint_url: default_mint_url(),
..Default::default()
});
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read wallet file")?;
let mut wallet: WalletState = serde_json::from_str(&content).unwrap_or_default();
// Set default mint URL if empty
if wallet.mint_url.is_empty() {
wallet.mint_url = default_mint_url();
}
Ok(wallet)
}
/// Save wallet state to disk.
pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let path = data_dir.join(WALLET_FILE);
let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?;
fs::write(&path, content)
.await
.context("Failed to write wallet file")?;
Ok(())
}
/// Load accepted mints list.
pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
let path = data_dir.join(MINTS_FILE);
if !path.exists() {
return Ok(AcceptedMints {
mints: vec![default_mint_url()],
});
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read accepted mints")?;
let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints {
mints: vec![default_mint_url()],
});
Ok(mints)
}
/// Save accepted mints list.
pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let path = data_dir.join(MINTS_FILE);
let content =
serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?;
fs::write(&path, content)
.await
.context("Failed to write accepted mints")?;
Ok(())
}
/// Request a mint quote — returns a Lightning invoice to pay.
pub async fn mint_quote(data_dir: &Path, amount_sats: u64) -> Result<super::mint_client::MintQuote> {
let wallet = load_wallet(data_dir).await?;
let client = MintClient::new(&wallet.mint_url)?;
client.mint_quote(amount_sats).await
}
/// Mint new ecash tokens after a Lightning invoice has been paid.
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let client = MintClient::new(&mint_url)?;
let result = client.mint_tokens(quote_id, amount_sats).await?;
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
wallet.add_proofs(&mint_url, result.proofs);
wallet.record_tx(
TransactionType::Mint,
minted,
&format!("Minted {} sats from Lightning", minted),
&mint_url,
"",
);
save_wallet(data_dir, &wallet).await?;
debug!("Minted {} sats ecash", minted);
Ok(minted)
}
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
pub async fn melt_quote(
data_dir: &Path,
bolt11: &str,
) -> Result<super::mint_client::MeltQuote> {
let wallet = load_wallet(data_dir).await?;
let client = MintClient::new(&wallet.mint_url)?;
client.melt_quote(bolt11).await
}
/// Melt ecash tokens to pay a Lightning invoice.
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let client = MintClient::new(&mint_url)?;
// Get the melt quote to know the amount needed
let quote = client.melt_quote(bolt11).await?;
let total_needed = quote.amount + quote.fee_reserve;
// Select proofs to cover the amount
let (indices, _overpayment) = wallet
.select_proofs(&mint_url, total_needed)
.ok_or_else(|| {
anyhow::anyhow!(
"Insufficient balance: need {} sats, have {} sats",
total_needed,
wallet.balance_for_mint(&mint_url)
)
})?;
let proofs: Vec<Proof> = indices.iter().map(|&i| wallet.proofs[i].proof.clone()).collect();
let spent_amount: u64 = proofs.iter().map(|p| p.amount).sum();
// Execute melt
let result = client.melt_tokens(quote_id, &proofs).await?;
// Mark proofs as spent
wallet.mark_spent(&indices);
wallet.record_tx(
TransactionType::Melt,
quote.amount,
&format!("Melted {} sats to Lightning (fee: {})", quote.amount, quote.fee_reserve),
&mint_url,
"",
);
// If there was overpayment and the melt returned change, we could handle it here.
// For now, any overpayment beyond fee_reserve is lost (mints may return change in future NUTs).
let _ = result;
let _ = spent_amount;
save_wallet(data_dir, &wallet).await?;
debug!("Melted {} sats to Lightning", quote.amount);
Ok(quote.amount)
}
/// Create a cashuA token string to send to a peer.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
// Select proofs covering the amount
let (indices, overpayment) = wallet
.select_proofs(&mint_url, amount_sats)
.ok_or_else(|| {
anyhow::anyhow!(
"Insufficient balance: need {} sats, have {} sats",
amount_sats,
wallet.balance_for_mint(&mint_url)
)
})?;
let selected_proofs: Vec<Proof> = indices.iter().map(|&i| wallet.proofs[i].proof.clone()).collect();
// If there's overpayment, swap to get exact change
let send_proofs = if overpayment > 0 {
let client = MintClient::new(&mint_url)?;
let send_denoms = amount_to_denominations(amount_sats);
let change_denoms = amount_to_denominations(overpayment);
let mut all_target: Vec<u64> = send_denoms.clone();
all_target.extend(&change_denoms);
let swap_result = client.swap(&selected_proofs, &all_target).await?;
// Mark original proofs as spent
wallet.mark_spent(&indices);
// Separate send proofs from change proofs
let (send, change): (Vec<_>, Vec<_>) = swap_result
.new_proofs
.into_iter()
.partition(|p| send_denoms.contains(&p.amount));
// Add change proofs back to wallet
if !change.is_empty() {
wallet.add_proofs(&mint_url, change);
}
send
} else {
// Exact amount — just mark as spent and use directly
wallet.mark_spent(&indices);
selected_proofs
};
// Serialize as cashuA token
let token = CashuToken::new(&mint_url, send_proofs);
let token_str = token.serialize()?;
wallet.record_tx(
TransactionType::Send,
amount_sats,
&format!("Sent {} sats ecash", amount_sats),
&mint_url,
"",
);
save_wallet(data_dir, &wallet).await?;
debug!("Created send token for {} sats", amount_sats);
Ok(token_str)
}
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Handle legacy format for backwards compatibility
if token_str.starts_with("cashuSend_") {
return receive_legacy_token(data_dir, token_str).await;
}
let token = CashuToken::deserialize(token_str)?;
let total_amount = token.total_amount();
if total_amount == 0 {
anyhow::bail!("Token has zero value");
}
// Verify all mints in the token are accepted
let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() {
if !accepted.mints.iter().any(|m| m == mint_url) {
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
}
}
let mut wallet = load_wallet(data_dir).await?;
let mut received_total = 0u64;
// Swap proofs at each mint
for entry in &token.token {
let client = MintClient::new(&entry.mint)?;
match client.receive_token(&token).await {
Ok(new_proofs) => {
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
wallet.add_proofs(&entry.mint, new_proofs);
received_total += amount;
}
Err(e) => {
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
// Continue with other mints if any
}
}
}
if received_total == 0 {
anyhow::bail!("Failed to receive any proofs from token");
}
wallet.record_tx(
TransactionType::Receive,
received_total,
&format!("Received {} sats ecash", received_total),
&token.token.first().map(|e| e.mint.as_str()).unwrap_or(""),
"",
);
save_wallet(data_dir, &wallet).await?;
debug!("Received {} sats ecash token", received_total);
Ok(received_total)
}
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
/// For backwards compatibility during migration period.
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
let amount_sats = token_str
.split('_')
.nth(1)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if amount_sats == 0 {
anyhow::bail!("Invalid legacy ecash token");
}
let mut wallet = load_wallet(data_dir).await?;
// Store as a synthetic proof (legacy tokens can't be verified cryptographically)
let proof = Proof {
amount: amount_sats,
id: "legacy".to_string(),
secret: token_str.to_string(),
c: "0000000000000000000000000000000000000000000000000000000000000000ff".to_string(),
};
wallet.add_proofs(&wallet.mint_url.clone(), vec![proof]);
wallet.record_tx(
TransactionType::Receive,
amount_sats,
&format!("Received {} sats (legacy format)", amount_sats),
&wallet.mint_url.clone(),
"",
);
save_wallet(data_dir, &wallet).await?;
debug!("Received {} sats legacy ecash token", amount_sats);
Ok(amount_sats)
}
/// Verify a payment token and receive it into the wallet.
/// Returns the verified amount, or error if verification fails.
/// Used by the content server and streaming gate to verify incoming payments.
pub async fn verify_and_receive_payment(
data_dir: &Path,
token_str: &str,
required_sats: u64,
) -> Result<u64> {
// Handle legacy tokens
if token_str.starts_with("cashuSend_") {
let amount = token_str
.split('_')
.nth(1)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if amount < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
amount,
required_sats
);
}
let received = receive_legacy_token(data_dir, token_str).await?;
return Ok(received);
}
// Parse and validate cashuA token
let token = CashuToken::deserialize(token_str)?;
let total = token.total_amount();
if total < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
total,
required_sats
);
}
// Verify mints are accepted
let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() {
if !accepted.mints.iter().any(|m| m == mint_url) {
anyhow::bail!("Mint '{}' not accepted", mint_url);
}
}
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
let mut wallet = load_wallet(data_dir).await?;
let mut received_total = 0u64;
for entry in &token.token {
let client = MintClient::new(&entry.mint)?;
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(entry_total);
match client.swap(&entry.proofs, &target_amounts).await {
Ok(result) => {
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
wallet.add_proofs(&entry.mint, result.new_proofs);
received_total += amount;
}
Err(e) => {
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
}
}
}
if received_total < required_sats {
anyhow::bail!(
"Payment verification failed: only {} of {} sats verified",
received_total,
required_sats
);
}
wallet.record_tx(
TransactionType::Receive,
received_total,
&format!("Payment received: {} sats", received_total),
&token.token.first().map(|e| e.mint.as_str()).unwrap_or(""),
"",
);
save_wallet(data_dir, &wallet).await?;
Ok(received_total)
}
/// Check the wallet balance.
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
let wallet = load_wallet(data_dir).await?;
Ok(wallet.balance())
}
/// Default mint URL (local Fedimint).
fn default_mint_url() -> String {
"http://127.0.0.1:8175".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_wallet_state_balance_empty() {
let wallet = WalletState::default();
assert_eq!(wallet.balance(), 0);
}
#[test]
fn test_wallet_state_balance_with_proofs() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint",
vec![
Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into() },
Proof { amount: 200, id: "ks1".into(), secret: "s2".into(), c: "c2".into() },
],
);
assert_eq!(wallet.balance(), 300);
}
#[test]
fn test_wallet_state_balance_excludes_spent() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint",
vec![
Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into() },
Proof { amount: 200, id: "ks1".into(), secret: "s2".into(), c: "c2".into() },
],
);
wallet.proofs[0].spent = true;
assert_eq!(wallet.balance(), 200);
}
#[test]
fn test_wallet_state_balance_excludes_reserved() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint",
vec![
Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into() },
],
);
wallet.proofs[0].reserved = true;
assert_eq!(wallet.balance(), 0);
}
#[test]
fn test_select_proofs_sufficient() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint",
vec![
Proof { amount: 1, id: "ks1".into(), secret: "s1".into(), c: "c1".into() },
Proof { amount: 4, id: "ks1".into(), secret: "s2".into(), c: "c2".into() },
Proof { amount: 8, id: "ks1".into(), secret: "s3".into(), c: "c3".into() },
],
);
let (indices, overpayment) = wallet.select_proofs("http://mint", 5).unwrap();
let total: u64 = indices.iter().map(|&i| wallet.proofs[i].proof.amount).sum();
assert!(total >= 5);
assert_eq!(total - 5, overpayment);
}
#[test]
fn test_select_proofs_insufficient() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint",
vec![
Proof { amount: 1, id: "ks1".into(), secret: "s1".into(), c: "c1".into() },
],
);
assert!(wallet.select_proofs("http://mint", 100).is_none());
}
#[test]
fn test_select_proofs_wrong_mint() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint-a",
vec![
Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into() },
],
);
assert!(wallet.select_proofs("http://mint-b", 100).is_none());
}
#[test]
fn test_balance_for_mint() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint-a",
vec![Proof { amount: 100, id: "ks1".into(), secret: "s1".into(), c: "c1".into() }],
);
wallet.add_proofs(
"http://mint-b",
vec![Proof { amount: 200, id: "ks2".into(), secret: "s2".into(), c: "c2".into() }],
);
assert_eq!(wallet.balance_for_mint("http://mint-a"), 100);
assert_eq!(wallet.balance_for_mint("http://mint-b"), 200);
assert_eq!(wallet.balance(), 300);
}
#[tokio::test]
async fn test_load_wallet_creates_default_when_missing() {
let tmp = TempDir::new().unwrap();
let wallet = load_wallet(tmp.path()).await.unwrap();
assert_eq!(wallet.balance(), 0);
assert!(wallet.proofs.is_empty());
assert!(wallet.transactions.is_empty());
assert_eq!(wallet.mint_url, default_mint_url());
}
#[tokio::test]
async fn test_save_and_load_wallet_roundtrip() {
let tmp = TempDir::new().unwrap();
let mut wallet = WalletState {
mint_url: "http://127.0.0.1:8175".into(),
..Default::default()
};
wallet.add_proofs(
"http://127.0.0.1:8175",
vec![Proof {
amount: 42,
id: "ks1".into(),
secret: "test_secret".into(),
c: "test_c".into(),
}],
);
wallet.record_tx(
TransactionType::Mint,
42,
"Test mint",
"http://127.0.0.1:8175",
"",
);
save_wallet(tmp.path(), &wallet).await.unwrap();
let loaded = load_wallet(tmp.path()).await.unwrap();
assert_eq!(loaded.proofs.len(), 1);
assert_eq!(loaded.proofs[0].proof.amount, 42);
assert!(!loaded.proofs[0].spent);
assert_eq!(loaded.transactions.len(), 1);
assert_eq!(loaded.balance(), 42);
}
#[tokio::test]
async fn test_save_wallet_creates_directory() {
let tmp = TempDir::new().unwrap();
let wallet_dir = tmp.path().join("wallet");
assert!(!wallet_dir.exists());
let wallet = WalletState::default();
save_wallet(tmp.path(), &wallet).await.unwrap();
assert!(wallet_dir.exists());
}
#[tokio::test]
async fn test_receive_legacy_token() {
let tmp = TempDir::new().unwrap();
let amount = receive_token(tmp.path(), "cashuSend_500_abc123_1700000000")
.await
.unwrap();
assert_eq!(amount, 500);
let wallet = load_wallet(tmp.path()).await.unwrap();
assert_eq!(wallet.balance(), 500);
assert_eq!(wallet.proofs.len(), 1);
assert!(!wallet.proofs[0].spent);
}
#[tokio::test]
async fn test_receive_token_invalid_format() {
let tmp = TempDir::new().unwrap();
let result = receive_token(tmp.path(), "invalid_token_string").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_receive_legacy_token_zero_amount() {
let tmp = TempDir::new().unwrap();
let result = receive_token(tmp.path(), "cashuSend_0_abc_1700000000").await;
assert!(result.is_err());
}
#[test]
fn test_prune_old_spent() {
let mut wallet = WalletState::default();
// Add an old spent proof
wallet.proofs.push(StoredProof {
proof: Proof { amount: 100, id: "ks1".into(), secret: "old".into(), c: "c".into() },
mint_url: "http://mint".into(),
spent: true,
reserved: false,
created_at: "2020-01-01T00:00:00Z".into(),
});
// Add a recent unspent proof
wallet.proofs.push(StoredProof {
proof: Proof { amount: 200, id: "ks1".into(), secret: "new".into(), c: "c".into() },
mint_url: "http://mint".into(),
spent: false,
reserved: false,
created_at: chrono::Utc::now().to_rfc3339(),
});
wallet.prune_old_spent();
assert_eq!(wallet.proofs.len(), 1);
assert_eq!(wallet.proofs[0].proof.amount, 200);
}
#[tokio::test]
async fn test_accepted_mints_default() {
let tmp = TempDir::new().unwrap();
let mints = load_accepted_mints(tmp.path()).await.unwrap();
assert_eq!(mints.mints.len(), 1);
assert_eq!(mints.mints[0], default_mint_url());
}
#[tokio::test]
async fn test_accepted_mints_roundtrip() {
let tmp = TempDir::new().unwrap();
let mints = AcceptedMints {
mints: vec!["http://mint-a".into(), "http://mint-b".into()],
};
save_accepted_mints(tmp.path(), &mints).await.unwrap();
let loaded = load_accepted_mints(tmp.path()).await.unwrap();
assert_eq!(loaded.mints.len(), 2);
}
#[test]
fn test_transaction_type_serialization() {
let tx = EcashTransaction {
id: "tx-test".into(),
tx_type: TransactionType::StreamingPayment,
amount_sats: 100,
timestamp: "2025-01-01T00:00:00Z".into(),
description: "test".into(),
mint_url: String::new(),
peer: String::new(),
};
let json = serde_json::to_string(&tx).unwrap();
assert!(json.contains("\"streamingpayment\""));
}
#[test]
fn test_default_mint_url() {
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
}
}