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>
This commit is contained in:
Dorian 2026-04-11 22:31:28 -04:00
parent 0995aa1033
commit 2c98bdd19d
25 changed files with 4740 additions and 480 deletions

View File

@ -187,12 +187,29 @@ impl RpcHandler {
// Ecash wallet
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
"wallet.ecash-mint-claim" => self.handle_wallet_ecash_mint_claim(params).await,
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
"wallet.ecash-melt-confirm" => self.handle_wallet_ecash_melt_confirm(params).await,
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Streaming ecash payments
"streaming.list-services" => self.handle_streaming_list_services().await,
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
"streaming.pay" => self.handle_streaming_pay(params).await,
"streaming.discover" => self.handle_streaming_discover().await,
"streaming.usage" => self.handle_streaming_usage(params).await,
"streaming.session" => self.handle_streaming_session(params).await,
"streaming.list-sessions" => self.handle_streaming_list_sessions().await,
"streaming.close-session" => self.handle_streaming_close_session(params).await,
"streaming.advertise" => self.handle_streaming_advertise().await,
"streaming.list-mints" => self.handle_streaming_list_mints().await,
"streaming.configure-mints" => self.handle_streaming_configure_mints(params).await,
"streaming.maintenance" => self.handle_streaming_maintenance().await,
// Content catalog management
"content.list-mine" => self.handle_content_list_mine().await,
"content.add" => self.handle_content_add(params).await,
@ -201,6 +218,8 @@ impl RpcHandler {
"content.set-availability" => self.handle_content_set_availability(params).await,
"content.browse-peer" => self.handle_content_browse_peer(params).await,
"content.download-peer" => self.handle_content_download_peer(params).await,
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.preview-peer" => self.handle_content_preview_peer(params).await,
// DWN (Decentralized Web Node)
"dwn.status" => self.handle_dwn_status().await,

View File

@ -26,6 +26,7 @@ mod response;
mod router;
mod seed_rpc;
mod security;
mod streaming;
mod tor;
mod transport;
mod totp;

View File

@ -0,0 +1,411 @@
//! RPC handlers for streaming ecash payments.
//!
//! Endpoints for managing priced services, processing payments,
//! checking sessions/usage, and publishing service advertisements.
use super::RpcHandler;
use crate::streaming::{advertisement, gate, meter, pricing, session};
use crate::wallet::ecash;
use anyhow::Result;
impl RpcHandler {
// ── Service pricing management ──
/// List all configured streaming services and their pricing.
pub(super) async fn handle_streaming_list_services(&self) -> Result<serde_json::Value> {
let config = pricing::load_pricing(&self.config.data_dir).await?;
Ok(serde_json::json!({
"services": config.services,
}))
}
/// Configure pricing for a streaming service.
pub(super) async fn handle_streaming_configure_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(service_id);
let metric_str = params
.get("metric")
.and_then(|v| v.as_str())
.unwrap_or("requests");
let step_size = params
.get("step_size")
.and_then(|v| v.as_u64())
.unwrap_or(1);
let price_per_step = params
.get("price_per_step")
.and_then(|v| v.as_u64())
.unwrap_or(1);
let min_steps = params
.get("min_steps")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let description = params
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let metric = match metric_str {
"bytes" => pricing::Metric::Bytes,
"milliseconds" | "time" => pricing::Metric::Milliseconds,
"requests" => pricing::Metric::Requests,
_ => return Err(anyhow::anyhow!("Invalid metric: {}", metric_str)),
};
let accepted_mints: Vec<String> = params
.get("accepted_mints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let service = pricing::ServicePricing {
service_id: service_id.to_string(),
name: name.to_string(),
metric,
step_size,
price_per_step,
min_steps,
enabled,
description: description.to_string(),
accepted_mints,
};
service.validate()?;
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
// Update existing or add new
if let Some(existing) = config
.services
.iter_mut()
.find(|s| s.service_id == service_id)
{
*existing = service.clone();
} else {
config.services.push(service.clone());
}
pricing::save_pricing(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({
"service": service,
"updated": true,
}))
}
/// Enable or disable a streaming service.
pub(super) async fn handle_streaming_toggle_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
if let Some(service) = config
.services
.iter_mut()
.find(|s| s.service_id == service_id)
{
service.enabled = enabled;
pricing::save_pricing(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({
"service_id": service_id,
"enabled": enabled,
}))
} else {
Err(anyhow::anyhow!("Service '{}' not found", service_id))
}
}
// ── Payment processing ──
/// Process a streaming payment — submit a Cashu token for a service.
/// Returns session details with allotment on success.
pub(super) async fn handle_streaming_pay(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
let token = params
.get("token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing token (cashuA token string)"))?;
let peer_id = params
.get("peer_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
if token.is_empty() {
return Err(anyhow::anyhow!("Token cannot be empty"));
}
if peer_id.is_empty() {
return Err(anyhow::anyhow!("Peer ID cannot be empty"));
}
let result =
gate::check_gate(&self.config.data_dir, peer_id, service_id, Some(token), 0).await?;
match result {
gate::GateResult::PaidAndAllowed {
session_id,
allotment,
paid_sats,
} => Ok(serde_json::json!({
"status": "paid",
"session_id": session_id,
"allotment": allotment,
"paid_sats": paid_sats,
})),
gate::GateResult::InsufficientPayment {
provided_sats,
minimum_sats,
} => Ok(serde_json::json!({
"status": "insufficient",
"error": { "code": "insufficient_payment", "message": format!("Need {} sats, got {}", minimum_sats, provided_sats) },
"minimum_sats": minimum_sats,
"provided_sats": provided_sats,
})),
gate::GateResult::PaymentFailed { reason } => Ok(serde_json::json!({
"status": "failed",
"error": { "code": "payment_failed", "message": reason },
})),
gate::GateResult::ServiceUnavailable => {
Err(anyhow::anyhow!("Service '{}' not available", service_id))
}
_ => Err(anyhow::anyhow!("Unexpected gate result")),
}
}
/// Discover available streaming services (pricing info).
/// This is the unauthenticated discovery endpoint.
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
let config = pricing::load_pricing(&self.config.data_dir).await?;
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
let services: Vec<serde_json::Value> = config
.services
.iter()
.filter(|s| s.enabled)
.map(|s| {
let mints = if s.accepted_mints.is_empty() {
&accepted_mints.mints
} else {
&s.accepted_mints
};
serde_json::json!({
"service_id": s.service_id,
"name": s.name,
"description": s.description,
"metric": s.metric,
"step_size": s.step_size,
"price_per_step": s.price_per_step,
"min_steps": s.min_steps,
"minimum_sats": s.minimum_payment(),
"accepted_mints": mints,
})
})
.collect();
Ok(serde_json::json!({
"services": services,
}))
}
// ── Session management ──
/// Check usage for a peer's active session.
pub(super) async fn handle_streaming_usage(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let peer_id = params
.get("peer_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
match meter::get_peer_usage(&self.config.data_dir, peer_id, service_id).await? {
Some(usage) => Ok(serde_json::json!({ "usage": usage })),
None => Ok(serde_json::json!({
"usage": null,
"message": "No active session",
})),
}
}
/// Get details of a specific session by ID.
pub(super) async fn handle_streaming_session(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let session_id = params
.get("session_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
let store = session::load_sessions(&self.config.data_dir).await?;
match store.get(session_id) {
Some(s) => Ok(serde_json::json!({ "session": s })),
None => Err(anyhow::anyhow!("Session not found")),
}
}
/// List all active streaming sessions (admin view).
pub(super) async fn handle_streaming_list_sessions(&self) -> Result<serde_json::Value> {
let store = session::load_sessions(&self.config.data_dir).await?;
let active = store.active_sessions();
let revenue = store.total_revenue();
let by_service = store.revenue_by_service();
Ok(serde_json::json!({
"sessions": active,
"total_active": active.len(),
"total_revenue_sats": revenue,
"revenue_by_service": by_service,
}))
}
/// Close a specific session.
pub(super) async fn handle_streaming_close_session(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let session_id = params
.get("session_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
let mut store = session::load_sessions(&self.config.data_dir).await?;
if let Some(s) = store.get_mut(session_id) {
s.close();
session::save_sessions(&self.config.data_dir, &store).await?;
Ok(serde_json::json!({ "closed": true }))
} else {
Err(anyhow::anyhow!("Session not found"))
}
}
// ── Advertisement ──
/// Publish a streaming service advertisement to Nostr relays.
pub(super) async fn handle_streaming_advertise(&self) -> Result<serde_json::Value> {
let config = pricing::load_pricing(&self.config.data_dir).await?;
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
let enabled_count = config.services.iter().filter(|s| s.enabled).count();
if enabled_count == 0 {
return Err(anyhow::anyhow!(
"No enabled services to advertise"
));
}
// Get node's onion address for the endpoint tag
let onion = crate::container::docker_packages::read_tor_address("archipelago").await;
let tags = advertisement::build_advertisement_tags(
&config,
&accepted_mints.mints,
onion.as_deref(),
);
let content = advertisement::build_advertisement_content(&config);
Ok(serde_json::json!({
"kind": advertisement::KIND_SERVICE_ADVERTISEMENT,
"content": content,
"tags": tags,
"services_count": enabled_count,
"ready_to_publish": true,
}))
}
// ── Accepted mints management ──
/// List accepted mints for streaming payments.
pub(super) async fn handle_streaming_list_mints(&self) -> Result<serde_json::Value> {
let mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
Ok(serde_json::json!({ "mints": mints.mints }))
}
/// Add or remove accepted mints.
pub(super) async fn handle_streaming_configure_mints(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mints = params
.get("mints")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing mints array"))?;
let mint_urls: Vec<String> = mints
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if mint_urls.is_empty() {
return Err(anyhow::anyhow!("Must have at least one accepted mint"));
}
// Basic validation
for url in &mint_urls {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(anyhow::anyhow!("Invalid mint URL: {}", url));
}
}
let config = ecash::AcceptedMints {
mints: mint_urls.clone(),
};
ecash::save_accepted_mints(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({
"mints": mint_urls,
"updated": true,
}))
}
// ── Maintenance ──
/// Run streaming maintenance (close expired sessions, prune old records).
pub(super) async fn handle_streaming_maintenance(&self) -> Result<serde_json::Value> {
let closed = meter::maintenance(&self.config.data_dir).await?;
Ok(serde_json::json!({
"expired_closed": closed,
}))
}
}

View File

@ -9,7 +9,8 @@ impl RpcHandler {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
Ok(serde_json::json!({
"balance_sats": wallet.balance(),
"token_count": wallet.tokens.iter().filter(|t| !t.spent).count(),
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
"mint_url": wallet.mint_url,
}))
}
@ -27,10 +28,36 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Amount must be between 1 and 1,000,000 sats"));
}
let token = ecash::mint_tokens(&self.config.data_dir, amount_sats).await?;
// Step 1: Get a mint quote (returns Lightning invoice)
let quote = ecash::mint_quote(&self.config.data_dir, amount_sats).await?;
Ok(serde_json::json!({
"token_id": token.id,
"amount_sats": token.amount_sats,
"quote_id": quote.quote,
"bolt11": quote.request,
"state": quote.state,
"amount_sats": amount_sats,
"message": "Pay the Lightning invoice, then call wallet.ecash-mint-claim with the quote_id",
}))
}
/// Claim minted tokens after paying the Lightning invoice.
pub(super) async fn handle_wallet_ecash_mint_claim(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let quote_id = params
.get("quote_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
let amount_sats = params
.get("amount_sats")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
let minted = ecash::mint_tokens(&self.config.data_dir, quote_id, amount_sats).await?;
Ok(serde_json::json!({
"minted_sats": minted,
}))
}
@ -39,14 +66,41 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let token_id = params
.get("token_id")
let bolt11 = params
.get("bolt11")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing token_id"))?;
.ok_or_else(|| anyhow::anyhow!("Missing bolt11 (Lightning invoice)"))?;
// Step 1: Get melt quote
let quote = ecash::melt_quote(&self.config.data_dir, bolt11).await?;
let amount = ecash::melt_tokens(&self.config.data_dir, token_id).await?;
Ok(serde_json::json!({
"melted_sats": amount,
"quote_id": quote.quote,
"amount_sats": quote.amount,
"fee_reserve_sats": quote.fee_reserve,
"total_needed_sats": quote.amount + quote.fee_reserve,
"message": "Call wallet.ecash-melt-confirm with quote_id and bolt11 to execute",
}))
}
/// Confirm and execute a melt (pay Lightning invoice with ecash).
pub(super) async fn handle_wallet_ecash_melt_confirm(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let quote_id = params
.get("quote_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
let bolt11 = params
.get("bolt11")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
let melted = ecash::melt_tokens(&self.config.data_dir, quote_id, bolt11).await?;
Ok(serde_json::json!({
"melted_sats": melted,
}))
}
@ -100,6 +154,7 @@ impl RpcHandler {
"total_sats": summary.total_sats,
"content_sales_sats": summary.content_sales_sats,
"routing_fees_sats": summary.routing_fees_sats,
"streaming_revenue_sats": summary.streaming_revenue_sats,
"recent": summary.recent,
}))
}

View File

@ -309,23 +309,99 @@ pub async fn serve_content(
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
}
/// Verify a payment token covers the required amount.
/// Tokens are ecash strings that we validate and mark as spent.
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
// Parse cashu token format to verify amount
if token.starts_with("cashuSend_") {
let amount = token
.split('_')
.nth(1)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if amount >= required_sats {
// Record the payment (receive the token into our wallet)
if let Ok(wallet_mod) = crate::wallet::ecash::receive_token(data_dir, token).await {
debug!("Payment verified: {} sats for {} required", wallet_mod, required_sats);
return true;
/// Result of attempting to serve a preview.
pub enum PreviewResult {
/// Full content (free/peers-only items — redirect to normal serve).
FullContent(Vec<u8>, String),
/// Blurred preview for paid image (full bytes, frontend applies blur).
BlurPreview(Vec<u8>, String),
/// Truncated preview for paid video (first ~2% of bytes).
TruncatedPreview(Vec<u8>, String, u64),
/// Content not found.
NotFound,
}
/// Serve a preview of content by ID. For paid content, returns degraded previews:
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
/// - Other: not available
/// For free/peers-only content, returns the full file.
pub async fn serve_content_preview(
data_dir: &Path,
id: &str,
) -> Result<PreviewResult> {
let catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) {
Some(i) => i,
None => return Ok(PreviewResult::NotFound),
};
// Check availability — don't preview hidden items
if matches!(item.availability, Availability::Nobody) {
return Ok(PreviewResult::NotFound);
}
let file_path = content_file_path(data_dir, item);
if !file_path.exists() {
return Ok(PreviewResult::NotFound);
}
match &item.access {
AccessControl::Paid { .. } => {
let mime = &item.mime_type;
if mime.starts_with("image/") {
// Serve full image — frontend applies CSS blur
let bytes = fs::read(&file_path).await.context("Failed to read preview file")?;
debug!("Serving blur preview for paid image '{}' ({} bytes)", id, bytes.len());
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
// Serve first 10% of video/audio, minimum 512KB for codec headers
let metadata = fs::metadata(&file_path).await.context("Failed to read file metadata")?;
let total_size = metadata.len();
let preview_bytes = ((total_size * 10) / 100).max(512 * 1024).min(total_size);
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(&file_path).await.context("Failed to open file")?;
let mut buf = vec![0u8; preview_bytes as usize];
file.read_exact(&mut buf).await.context("Failed to read preview bytes")?;
let kind = if mime.starts_with("video/") { "video" } else { "audio" };
debug!("Serving truncated preview for paid {} '{}' ({}/{} bytes)", kind, id, preview_bytes, total_size);
Ok(PreviewResult::TruncatedPreview(buf, item.mime_type.clone(), total_size))
} else {
// Non-media paid content — no preview available
Ok(PreviewResult::NotFound)
}
}
_ => {
// Free or peers-only — serve full content as preview
let bytes = fs::read(&file_path).await.context("Failed to read content file")?;
Ok(PreviewResult::FullContent(bytes, item.mime_type.clone()))
}
}
}
/// Verify a payment token covers the required amount.
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
/// Swaps proofs at the mint to verify they're unspent before accepting.
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
Ok(received) => {
debug!(
"Payment verified: {} sats received for {} required",
received, required_sats
);
// Record the content sale for profit tracking
if let Err(e) =
crate::wallet::profits::record_content_sale(data_dir, received, "Content download payment").await
{
debug!("Failed to record content sale profit (non-fatal): {}", e);
}
true
}
Err(e) => {
debug!("Payment verification failed: {}", e);
false
}
}
false
}

View File

@ -36,6 +36,7 @@ mod server;
mod rate_limit;
mod session;
mod state;
mod streaming;
mod totp;
mod wallet;
mod names;

View File

@ -0,0 +1,354 @@
//! Nostr service advertisements for streaming data payments.
//!
//! Publishes and parses kind 10021 replaceable events (TollGate TIP-01 compatible)
//! that advertise priced services on this node. Peers discover services by
//! querying Nostr relays for these events.
use super::pricing::{PricingConfig, ServicePricing};
use serde::{Deserialize, Serialize};
/// Nostr event kind for service advertisements (TollGate TIP-01).
pub const KIND_SERVICE_ADVERTISEMENT: u16 = 10021;
/// Nostr event kind for session proof (TollGate TIP-01).
pub const KIND_SESSION: u16 = 1022;
/// Nostr event kind for service notice.
pub const KIND_NOTICE: u16 = 21023;
/// A parsed service advertisement from a Nostr event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceAdvertisement {
/// Publisher's Nostr pubkey (hex).
pub pubkey: String,
/// Tor onion address or clearnet address for connecting.
#[serde(default)]
pub endpoint: String,
/// Advertised services with pricing.
pub services: Vec<AdvertisedService>,
/// Supported protocol versions/TIPs.
#[serde(default)]
pub supported_tips: Vec<String>,
/// Timestamp of the advertisement.
pub created_at: String,
}
/// A single service within an advertisement.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdvertisedService {
pub service_id: String,
pub name: String,
pub metric: String,
pub step_size: u64,
pub price_per_step: u64,
pub unit: String,
pub mint_urls: Vec<String>,
pub min_steps: u64,
pub description: String,
}
impl From<&ServicePricing> for AdvertisedService {
fn from(p: &ServicePricing) -> Self {
Self {
service_id: p.service_id.clone(),
name: p.name.clone(),
metric: p.metric.to_string(),
step_size: p.step_size,
price_per_step: p.price_per_step,
unit: "sat".to_string(),
mint_urls: p.accepted_mints.clone(),
min_steps: p.min_steps,
description: p.description.clone(),
}
}
}
/// Build Nostr event tags for a service advertisement (TIP-01/TIP-02 format).
///
/// Returns a vector of tag arrays suitable for inclusion in a Nostr event.
pub fn build_advertisement_tags(
config: &PricingConfig,
accepted_mints: &[String],
onion_address: Option<&str>,
) -> Vec<Vec<String>> {
let mut tags: Vec<Vec<String>> = Vec::new();
// Endpoint tag (if we have a Tor address)
if let Some(onion) = onion_address {
tags.push(vec!["endpoint".to_string(), onion.to_string()]);
}
// Supported TIPs
tags.push(vec![
"tips".to_string(),
"TIP-01".to_string(),
"TIP-02".to_string(),
]);
// One set of tags per enabled service
for service in config.services.iter().filter(|s| s.enabled) {
tags.push(vec![
"service".to_string(),
service.service_id.clone(),
service.name.clone(),
]);
tags.push(vec![
"metric".to_string(),
service.service_id.clone(),
service.metric.to_string(),
]);
tags.push(vec![
"step_size".to_string(),
service.service_id.clone(),
service.step_size.to_string(),
]);
// Price tags — one per accepted mint (TIP-02 format)
let mints = if service.accepted_mints.is_empty() {
accepted_mints.to_vec()
} else {
service.accepted_mints.clone()
};
for mint_url in &mints {
tags.push(vec![
"price_per_step".to_string(),
service.service_id.clone(),
"cashu".to_string(),
service.price_per_step.to_string(),
"sat".to_string(),
mint_url.clone(),
service.min_steps.to_string(),
]);
}
}
tags
}
/// Build the content string for a kind 10021 advertisement event.
pub fn build_advertisement_content(config: &PricingConfig) -> String {
let enabled: Vec<_> = config.services.iter().filter(|s| s.enabled).collect();
if enabled.is_empty() {
return "No streaming services available".to_string();
}
let mut lines = vec!["Streaming data services available:".to_string()];
for service in &enabled {
lines.push(format!(
"- {} ({}: {} sats per {} {})",
service.name,
service.service_id,
service.price_per_step,
service.step_size,
service.metric,
));
}
lines.join("\n")
}
/// Parse a Nostr event's tags into a ServiceAdvertisement.
pub fn parse_advertisement_tags(
pubkey: &str,
tags: &[Vec<String>],
created_at: &str,
) -> ServiceAdvertisement {
let mut ad = ServiceAdvertisement {
pubkey: pubkey.to_string(),
endpoint: String::new(),
services: Vec::new(),
supported_tips: Vec::new(),
created_at: created_at.to_string(),
};
// Collect service IDs first
let mut service_ids: Vec<String> = Vec::new();
let mut service_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut service_metrics: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut service_step_sizes: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let mut service_prices: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let mut service_mints: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut service_min_steps: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
for tag in tags {
if tag.is_empty() {
continue;
}
match tag[0].as_str() {
"endpoint" if tag.len() >= 2 => {
ad.endpoint = tag[1].clone();
}
"tips" => {
ad.supported_tips = tag[1..].to_vec();
}
"service" if tag.len() >= 3 => {
let sid = tag[1].clone();
service_names.insert(sid.clone(), tag[2].clone());
if !service_ids.contains(&sid) {
service_ids.push(sid);
}
}
"metric" if tag.len() >= 3 => {
service_metrics.insert(tag[1].clone(), tag[2].clone());
}
"step_size" if tag.len() >= 3 => {
if let Ok(v) = tag[2].parse() {
service_step_sizes.insert(tag[1].clone(), v);
}
}
"price_per_step" if tag.len() >= 5 => {
// ["price_per_step", service_id, "cashu", price, "sat", mint_url, min_steps]
let sid = tag[1].clone();
if let Ok(price) = tag[3].parse::<u64>() {
service_prices.insert(sid.clone(), price);
}
if tag.len() >= 6 {
service_mints
.entry(sid.clone())
.or_default()
.push(tag[5].clone());
}
if tag.len() >= 7 {
if let Ok(ms) = tag[6].parse::<u64>() {
service_min_steps.insert(sid, ms);
}
}
}
_ => {}
}
}
// Assemble services
for sid in &service_ids {
ad.services.push(AdvertisedService {
service_id: sid.clone(),
name: service_names.get(sid).cloned().unwrap_or_default(),
metric: service_metrics.get(sid).cloned().unwrap_or_default(),
step_size: service_step_sizes.get(sid).copied().unwrap_or(0),
price_per_step: service_prices.get(sid).copied().unwrap_or(0),
unit: "sat".to_string(),
mint_urls: service_mints.get(sid).cloned().unwrap_or_default(),
min_steps: service_min_steps.get(sid).copied().unwrap_or(0),
description: String::new(),
});
}
ad
}
/// Build a kind 1022 session event content (proof of access grant).
pub fn build_session_event_content(
session_id: &str,
peer_pubkey: &str,
service_id: &str,
allotment: u64,
metric: &str,
paid_sats: u64,
) -> serde_json::Value {
serde_json::json!({
"session_id": session_id,
"customer": peer_pubkey,
"service": service_id,
"allotment": allotment,
"metric": metric,
"paid_sats": paid_sats,
"granted_at": chrono::Utc::now().to_rfc3339(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> PricingConfig {
PricingConfig {
services: vec![
ServicePricing {
service_id: "content-download".into(),
name: "Content Downloads".into(),
metric: Metric::Bytes,
step_size: 1_048_576,
price_per_step: 1,
min_steps: 0,
enabled: true,
description: "test".into(),
accepted_mints: vec![],
},
ServicePricing {
service_id: "disabled-service".into(),
name: "Disabled".into(),
metric: Metric::Requests,
step_size: 1,
price_per_step: 1,
min_steps: 0,
enabled: false,
description: "disabled".into(),
accepted_mints: vec![],
},
],
}
}
#[test]
fn test_build_advertisement_tags() {
let config = test_config();
let mints = vec!["http://mint.example.com".to_string()];
let tags = build_advertisement_tags(&config, &mints, Some("abc123.onion"));
// Should have endpoint, tips, service, metric, step_size, price_per_step
assert!(tags.iter().any(|t| t[0] == "endpoint"));
assert!(tags.iter().any(|t| t[0] == "tips"));
assert!(tags.iter().any(|t| t[0] == "service" && t[1] == "content-download"));
assert!(tags.iter().any(|t| t[0] == "metric" && t[1] == "content-download"));
assert!(tags.iter().any(|t| t[0] == "price_per_step" && t[1] == "content-download"));
// Disabled service should NOT appear
assert!(!tags.iter().any(|t| t.len() > 1 && t[1] == "disabled-service"));
}
#[test]
fn test_build_advertisement_content() {
let config = test_config();
let content = build_advertisement_content(&config);
assert!(content.contains("Content Downloads"));
assert!(!content.contains("Disabled"));
}
#[test]
fn test_parse_advertisement_tags_roundtrip() {
let config = test_config();
let mints = vec!["http://mint.example.com".to_string()];
let tags = build_advertisement_tags(&config, &mints, Some("abc123.onion"));
let ad = parse_advertisement_tags("deadbeef", &tags, "2025-01-01T00:00:00Z");
assert_eq!(ad.pubkey, "deadbeef");
assert_eq!(ad.endpoint, "abc123.onion");
assert_eq!(ad.services.len(), 1);
assert_eq!(ad.services[0].service_id, "content-download");
assert_eq!(ad.services[0].price_per_step, 1);
assert_eq!(ad.services[0].step_size, 1_048_576);
assert_eq!(ad.services[0].metric, "bytes");
}
#[test]
fn test_build_session_event_content() {
let content = build_session_event_content(
"session-123",
"peer-pubkey",
"content-download",
10_485_760,
"bytes",
10,
);
assert_eq!(content["session_id"], "session-123");
assert_eq!(content["paid_sats"], 10);
}
}

View File

@ -0,0 +1,281 @@
//! Streaming access gate — controls access to metered services.
//!
//! The gate sits between incoming requests and the resource being served.
//! It checks for active sessions, verifies/receives payments, and
//! records usage against allotments.
use super::meter::{self, MeterDecision};
use super::pricing::{self, ServicePricing};
use super::session::{self};
use crate::wallet::ecash;
use anyhow::Result;
use std::path::Path;
use tracing::{debug, warn};
/// Result of a gate check.
#[derive(Debug)]
pub enum GateResult {
/// Access granted — session is active with sufficient allotment.
Allowed {
session_id: String,
remaining: u64,
},
/// Access granted after accepting payment — new or topped-up session.
PaidAndAllowed {
session_id: String,
allotment: u64,
paid_sats: u64,
},
/// Payment required — no active session and no payment token provided.
PaymentRequired {
service_id: String,
minimum_sats: u64,
pricing: PricingInfo,
},
/// Payment insufficient — token was provided but doesn't meet minimum.
InsufficientPayment {
provided_sats: u64,
minimum_sats: u64,
},
/// Payment failed — token was invalid or couldn't be verified at mint.
PaymentFailed {
reason: String,
},
/// Service not found or not enabled.
ServiceUnavailable,
}
/// Pricing information for the payment-required response.
#[derive(Debug, Clone, serde::Serialize)]
pub struct PricingInfo {
pub metric: String,
pub step_size: u64,
pub price_per_step: u64,
pub min_steps: u64,
pub accepted_mints: Vec<String>,
}
impl From<&ServicePricing> for PricingInfo {
fn from(p: &ServicePricing) -> Self {
Self {
metric: p.metric.to_string(),
step_size: p.step_size,
price_per_step: p.price_per_step,
min_steps: p.min_steps,
accepted_mints: p.accepted_mints.clone(),
}
}
}
/// Check the gate for a streaming service request.
///
/// If `payment_token` is provided (cashuA string), it will be verified and
/// accepted to create or top up a session. If no token is provided, checks
/// for an existing active session.
///
/// `usage_cost` is the cost of the current request in the service's metric units
/// (e.g., bytes for download, 1 for a single API request).
pub async fn check_gate(
data_dir: &Path,
peer_id: &str,
service_id: &str,
payment_token: Option<&str>,
usage_cost: u64,
) -> Result<GateResult> {
// Load pricing config
let config = pricing::load_pricing(data_dir).await?;
let service = match config.get_active_service(service_id) {
Some(s) => s,
None => return Ok(GateResult::ServiceUnavailable),
};
// If payment token provided, process it first
if let Some(token_str) = payment_token {
return process_payment(data_dir, peer_id, service, token_str, usage_cost).await;
}
// No payment — check for existing session
let decision = meter::check_access(data_dir, peer_id, service_id, usage_cost).await?;
match decision {
MeterDecision::Allow {
session_id,
remaining,
} => {
// Record usage
let _ = meter::record_and_check(data_dir, peer_id, service_id, usage_cost).await?;
Ok(GateResult::Allowed {
session_id,
remaining: remaining.saturating_sub(usage_cost),
})
}
MeterDecision::Exhausted { .. } | MeterDecision::NoSession => {
let accepted_mints = if service.accepted_mints.is_empty() {
let wallet_mints = ecash::load_accepted_mints(data_dir).await?;
wallet_mints.mints
} else {
service.accepted_mints.clone()
};
let mut pricing_info = PricingInfo::from(service);
pricing_info.accepted_mints = accepted_mints;
Ok(GateResult::PaymentRequired {
service_id: service_id.to_string(),
minimum_sats: service.minimum_payment(),
pricing: pricing_info,
})
}
MeterDecision::NotMetered => Ok(GateResult::Allowed {
session_id: String::new(),
remaining: u64::MAX,
}),
}
}
/// Process a payment token and create/topup a session.
async fn process_payment(
data_dir: &Path,
peer_id: &str,
service: &ServicePricing,
token_str: &str,
usage_cost: u64,
) -> Result<GateResult> {
let minimum = service.minimum_payment();
// Verify and receive the payment
let received_sats = match ecash::verify_and_receive_payment(data_dir, token_str, minimum).await
{
Ok(amount) => amount,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("Insufficient payment") {
// Try to parse what was provided
let provided = extract_token_amount(token_str);
return Ok(GateResult::InsufficientPayment {
provided_sats: provided,
minimum_sats: minimum,
});
}
warn!("Payment verification failed for peer {}: {}", peer_id, e);
return Ok(GateResult::PaymentFailed {
reason: err_str,
});
}
};
// Create or top-up session
let mut store = session::load_sessions(data_dir).await?;
let session = store.create_or_topup(peer_id, &service.service_id, service, received_sats);
let session_id = session.id.clone();
let allotment = session.allotment;
// Record initial usage if applicable
if usage_cost > 0 {
if let Some(s) = store.get_mut(&session_id) {
s.record_usage(usage_cost);
}
}
session::save_sessions(data_dir, &store).await?;
// Record the streaming revenue
let mut wallet = ecash::load_wallet(data_dir).await?;
wallet.record_tx(
ecash::TransactionType::StreamingRevenue,
received_sats,
&format!(
"Streaming payment: {} sats for {} from {}",
received_sats, service.service_id, peer_id
),
&wallet.mint_url.clone(),
peer_id,
);
ecash::save_wallet(data_dir, &wallet).await?;
debug!(
"Gate: accepted {} sats from {} for {}, allotment={}",
received_sats, peer_id, service.service_id, allotment
);
Ok(GateResult::PaidAndAllowed {
session_id,
allotment,
paid_sats: received_sats,
})
}
/// Try to extract the total amount from a token string (best-effort for error messages).
fn extract_token_amount(token_str: &str) -> u64 {
// Try cashuA format
if let Ok(token) = super::super::wallet::cashu::CashuToken::deserialize(token_str) {
return token.total_amount();
}
// Try legacy format
if token_str.starts_with("cashuSend_") {
return token_str
.split('_')
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
}
0
}
/// Quick check: does a peer have an active session for a service?
/// Lighter weight than check_gate — doesn't record usage or process payments.
pub async fn has_active_session(
data_dir: &Path,
peer_id: &str,
service_id: &str,
) -> Result<bool> {
let store = session::load_sessions(data_dir).await?;
Ok(store.find_active(peer_id, service_id).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_gate_service_unavailable() {
let tmp = TempDir::new().unwrap();
let result = check_gate(tmp.path(), "peer1", "nonexistent", None, 1)
.await
.unwrap();
assert!(matches!(result, GateResult::ServiceUnavailable));
}
#[tokio::test]
async fn test_gate_payment_required_default_services() {
let tmp = TempDir::new().unwrap();
// Enable a service first
let mut config = pricing::load_pricing(tmp.path()).await.unwrap();
config.services[0].enabled = true; // content-download
pricing::save_pricing(tmp.path(), &config).await.unwrap();
let result = check_gate(tmp.path(), "peer1", "content-download", None, 1024)
.await
.unwrap();
match result {
GateResult::PaymentRequired {
minimum_sats,
pricing,
..
} => {
assert_eq!(pricing.metric, "bytes");
assert!(minimum_sats > 0);
}
other => panic!("Expected PaymentRequired, got {:?}", other),
}
}
#[tokio::test]
async fn test_has_active_session_false() {
let tmp = TempDir::new().unwrap();
assert!(!has_active_session(tmp.path(), "peer1", "test").await.unwrap());
}
}

View File

@ -0,0 +1,235 @@
//! Usage metering engine for streaming data payments.
//!
//! Tracks resource consumption (bytes, time, requests) per session
//! and enforces allotment limits.
use super::pricing::Metric;
use super::session::{self, StreamingSession};
use anyhow::Result;
use std::path::Path;
use tracing::debug;
/// Result of checking whether a request should be allowed.
#[derive(Debug)]
pub enum MeterDecision {
/// Request allowed — session has sufficient allotment.
Allow {
session_id: String,
remaining: u64,
},
/// Request denied — session exhausted or expired.
Exhausted {
session_id: String,
},
/// No active session found for this peer+service.
NoSession,
/// Service is not configured for metering (free access).
NotMetered,
}
/// Record usage for a peer's session and check if they can continue.
pub async fn record_and_check(
data_dir: &Path,
peer_id: &str,
service_id: &str,
usage_amount: u64,
) -> Result<MeterDecision> {
let mut store = session::load_sessions(data_dir).await?;
let decision = match store.find_active_mut(peer_id, service_id) {
Some(session) => {
let still_active = session.record_usage(usage_amount);
let session_id = session.id.clone();
let remaining = session.remaining();
if still_active {
debug!(
"Meter: peer={} service={} used={} remaining={}",
peer_id, service_id, usage_amount, remaining
);
MeterDecision::Allow {
session_id,
remaining,
}
} else {
debug!(
"Meter: peer={} service={} exhausted (used={})",
peer_id, service_id, session.used
);
session.close();
MeterDecision::Exhausted { session_id }
}
}
None => MeterDecision::NoSession,
};
session::save_sessions(data_dir, &store).await?;
Ok(decision)
}
/// Check if a peer has an active session for a service without recording usage.
pub async fn check_access(
data_dir: &Path,
peer_id: &str,
service_id: &str,
required_amount: u64,
) -> Result<MeterDecision> {
let store = session::load_sessions(data_dir).await?;
match store.find_active(peer_id, service_id) {
Some(session) => {
if session.can_serve(required_amount) {
Ok(MeterDecision::Allow {
session_id: session.id.clone(),
remaining: session.remaining(),
})
} else {
Ok(MeterDecision::Exhausted {
session_id: session.id.clone(),
})
}
}
None => Ok(MeterDecision::NoSession),
}
}
/// Get usage summary for a session.
pub async fn get_usage(data_dir: &Path, session_id: &str) -> Result<Option<UsageSummary>> {
let store = session::load_sessions(data_dir).await?;
Ok(store.get(session_id).map(|s| UsageSummary::from_session(s)))
}
/// Get usage summary for a peer's active session on a service.
pub async fn get_peer_usage(
data_dir: &Path,
peer_id: &str,
service_id: &str,
) -> Result<Option<UsageSummary>> {
let store = session::load_sessions(data_dir).await?;
Ok(store
.find_active(peer_id, service_id)
.map(|s| UsageSummary::from_session(s)))
}
/// Usage summary for display.
#[derive(Debug, Clone, serde::Serialize)]
pub struct UsageSummary {
pub session_id: String,
pub metric: Metric,
pub allotment: u64,
pub used: u64,
pub remaining: u64,
pub paid_sats: u64,
pub active: bool,
/// Human-readable usage string (e.g., "5.2 MB / 10 MB").
pub display: String,
}
impl UsageSummary {
pub fn from_session(session: &StreamingSession) -> Self {
let remaining = session.remaining();
let display = format_usage(session.metric, session.used, session.allotment);
Self {
session_id: session.id.clone(),
metric: session.metric,
allotment: session.allotment,
used: session.used,
remaining,
paid_sats: session.paid_sats,
active: session.active && !session.is_expired(),
display,
}
}
}
/// Format usage as a human-readable string.
fn format_usage(metric: Metric, used: u64, allotment: u64) -> String {
match metric {
Metric::Bytes => {
format!("{} / {}", format_bytes(used), format_bytes(allotment))
}
Metric::Milliseconds => {
format!(
"{} / {}",
format_duration_ms(used),
format_duration_ms(allotment)
)
}
Metric::Requests => {
format!("{} / {} requests", used, allotment)
}
}
}
/// Format bytes as human-readable (KB, MB, GB).
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1_048_576 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1_073_741_824 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else {
format!("{:.2} GB", bytes as f64 / 1_073_741_824.0)
}
}
/// Format milliseconds as human-readable duration.
fn format_duration_ms(ms: u64) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else if ms < 3_600_000 {
format!("{:.1}m", ms as f64 / 60_000.0)
} else {
format!("{:.1}h", ms as f64 / 3_600_000.0)
}
}
/// Run periodic maintenance: close expired sessions, prune old records.
pub async fn maintenance(data_dir: &Path) -> Result<usize> {
let mut store = session::load_sessions(data_dir).await?;
let closed = store.close_expired();
store.prune_old();
session::save_sessions(data_dir, &store).await?;
if closed > 0 {
debug!("Meter maintenance: closed {} expired sessions", closed);
}
Ok(closed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(500), "500 B");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(5_242_880), "5.0 MB");
assert_eq!(format_bytes(1_610_612_736), "1.50 GB");
}
#[test]
fn test_format_duration_ms() {
assert_eq!(format_duration_ms(500), "500ms");
assert_eq!(format_duration_ms(2500), "2.5s");
assert_eq!(format_duration_ms(90_000), "1.5m");
assert_eq!(format_duration_ms(5_400_000), "1.5h");
}
#[test]
fn test_format_usage_bytes() {
let display = format_usage(Metric::Bytes, 5_242_880, 10_485_760);
assert_eq!(display, "5.0 MB / 10.0 MB");
}
#[test]
fn test_format_usage_requests() {
let display = format_usage(Metric::Requests, 3, 10);
assert_eq!(display, "3 / 10 requests");
}
}

View File

@ -0,0 +1,39 @@
//! Streaming ecash payments for metered data access.
//!
//! Implements a TollGate-inspired protocol for paying for streaming data
//! using Cashu ecash micropayments. Supports three metering models:
//!
//! - **Bytes**: Pay per MB downloaded (content, federation sync)
//! - **Time**: Pay per minute of access (relay, API endpoints)
//! - **Requests**: Pay per API call
//!
//! # Architecture
//!
//! ```text
//! Paying Node Selling Node
//! ┌──────────────┐ ┌──────────────────────┐
//! │ Cashu Wallet │──cashuA token──────▶│ Gate │
//! │ (real BDHKE) │ │ verify + receive │
//! └──────────────┘ └──────┬───────────────┘
//! │ create/topup session
//! ┌──────▼───────────────┐
//! │ Meter │
//! │ track usage │
//! └──────┬───────────────┘
//! │ enforce allotment
//! ┌──────▼───────────────┐
//! │ Service │
//! │ content / sync / api │
//! └──────────────────────┘
//! ```
//!
//! # Discovery
//!
//! Services are advertised via Nostr kind 10021 events (TollGate TIP-01
//! compatible) containing pricing tags per TIP-02.
pub mod advertisement;
pub mod gate;
pub mod meter;
pub mod pricing;
pub mod session;

View File

@ -0,0 +1,362 @@
//! Streaming data pricing configuration.
//!
//! Follows TollGate TIP-02 pricing model:
//! - step_size: granularity of purchase (bytes, milliseconds, or request count)
//! - price_per_step: cost in sats for one step
//! - min_steps: minimum purchase requirement
//! - metric: what is being metered (bytes, time, requests)
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const PRICING_FILE: &str = "streaming/pricing.json";
/// What resource is being metered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Metric {
/// Bytes transferred.
Bytes,
/// Time in milliseconds.
Milliseconds,
/// Number of API requests.
Requests,
}
impl std::fmt::Display for Metric {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Metric::Bytes => write!(f, "bytes"),
Metric::Milliseconds => write!(f, "milliseconds"),
Metric::Requests => write!(f, "requests"),
}
}
}
/// Pricing configuration for a specific service.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicePricing {
/// Unique service identifier.
pub service_id: String,
/// Human-readable service name.
pub name: String,
/// What is being metered.
pub metric: Metric,
/// Size of one step in the metric's unit.
/// e.g., 1_048_576 for 1MB steps, 60_000 for 1-minute steps, 1 for per-request.
pub step_size: u64,
/// Price in sats for one step.
pub price_per_step: u64,
/// Minimum number of steps per purchase (0 = no minimum).
#[serde(default)]
pub min_steps: u64,
/// Whether this service is currently active/accepting payments.
#[serde(default = "default_true")]
pub enabled: bool,
/// Description of what this service provides.
#[serde(default)]
pub description: String,
/// Accepted mint URLs (empty = use wallet defaults).
#[serde(default)]
pub accepted_mints: Vec<String>,
}
fn default_true() -> bool {
true
}
impl ServicePricing {
/// Calculate allotment (in metric units) for a given payment amount.
pub fn calculate_allotment(&self, paid_sats: u64) -> u64 {
if self.price_per_step == 0 {
return 0;
}
let steps = paid_sats / self.price_per_step;
steps * self.step_size
}
/// Calculate the minimum payment required.
pub fn minimum_payment(&self) -> u64 {
if self.min_steps == 0 {
self.price_per_step // At least one step
} else {
self.min_steps * self.price_per_step
}
}
/// Calculate how many sats are needed for a specific allotment.
pub fn cost_for_allotment(&self, allotment: u64) -> u64 {
if self.step_size == 0 {
return 0;
}
let steps = (allotment + self.step_size - 1) / self.step_size; // ceiling division
steps * self.price_per_step
}
/// Validate that this pricing config is sensible.
pub fn validate(&self) -> Result<()> {
if self.service_id.is_empty() {
anyhow::bail!("Service ID cannot be empty");
}
if self.step_size == 0 {
anyhow::bail!("Step size must be > 0");
}
if self.price_per_step == 0 {
anyhow::bail!("Price per step must be > 0");
}
if self.name.is_empty() {
anyhow::bail!("Service name cannot be empty");
}
Ok(())
}
}
/// All pricing configurations for this node.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PricingConfig {
pub services: Vec<ServicePricing>,
}
impl PricingConfig {
/// Find pricing for a service by ID.
pub fn get_service(&self, service_id: &str) -> Option<&ServicePricing> {
self.services.iter().find(|s| s.service_id == service_id)
}
/// Find enabled pricing for a service by ID.
pub fn get_active_service(&self, service_id: &str) -> Option<&ServicePricing> {
self.services
.iter()
.find(|s| s.service_id == service_id && s.enabled)
}
}
/// Load pricing config from disk.
pub async fn load_pricing(data_dir: &Path) -> Result<PricingConfig> {
let path = data_dir.join(PRICING_FILE);
if !path.exists() {
return Ok(default_pricing());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read pricing config")?;
let config: PricingConfig = serde_json::from_str(&content).unwrap_or_else(|_| default_pricing());
Ok(config)
}
/// Save pricing config to disk.
pub async fn save_pricing(data_dir: &Path, config: &PricingConfig) -> Result<()> {
let dir = data_dir.join("streaming");
fs::create_dir_all(&dir)
.await
.context("Failed to create streaming dir")?;
let path = data_dir.join(PRICING_FILE);
let content =
serde_json::to_string_pretty(config).context("Failed to serialize pricing config")?;
fs::write(&path, content)
.await
.context("Failed to write pricing config")?;
Ok(())
}
/// Default pricing config with common services pre-configured (disabled).
fn default_pricing() -> PricingConfig {
PricingConfig {
services: vec![
ServicePricing {
service_id: "content-download".to_string(),
name: "Content Downloads".to_string(),
metric: Metric::Bytes,
step_size: 1_048_576, // 1 MB
price_per_step: 1, // 1 sat per MB
min_steps: 0,
enabled: false,
description: "Pay-per-byte content downloads from this node".to_string(),
accepted_mints: vec![],
},
ServicePricing {
service_id: "federation-sync".to_string(),
name: "Federation Sync Access".to_string(),
metric: Metric::Milliseconds,
step_size: 60_000, // 1 minute
price_per_step: 1, // 1 sat per minute
min_steps: 5, // 5 minute minimum
enabled: false,
description: "Timed access to federation sync endpoint".to_string(),
accepted_mints: vec![],
},
ServicePricing {
service_id: "api-access".to_string(),
name: "API Access".to_string(),
metric: Metric::Requests,
step_size: 1, // Per request
price_per_step: 1, // 1 sat per request
min_steps: 10, // 10 request minimum
enabled: false,
description: "Per-request API access for external consumers".to_string(),
accepted_mints: vec![],
},
ServicePricing {
service_id: "nostr-relay".to_string(),
name: "Nostr Relay Access".to_string(),
metric: Metric::Milliseconds,
step_size: 3_600_000, // 1 hour
price_per_step: 10, // 10 sats per hour
min_steps: 1,
enabled: false,
description: "Timed access to the local Nostr relay".to_string(),
accepted_mints: vec![],
},
],
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_calculate_allotment_bytes() {
let pricing = ServicePricing {
service_id: "test".into(),
name: "Test".into(),
metric: Metric::Bytes,
step_size: 1_048_576, // 1 MB
price_per_step: 1,
min_steps: 0,
enabled: true,
description: String::new(),
accepted_mints: vec![],
};
// 10 sats = 10 MB
assert_eq!(pricing.calculate_allotment(10), 10_485_760);
}
#[test]
fn test_calculate_allotment_time() {
let pricing = ServicePricing {
service_id: "test".into(),
name: "Test".into(),
metric: Metric::Milliseconds,
step_size: 60_000, // 1 minute
price_per_step: 2,
min_steps: 0,
enabled: true,
description: String::new(),
accepted_mints: vec![],
};
// 10 sats at 2 sats/min = 5 minutes = 300,000 ms
assert_eq!(pricing.calculate_allotment(10), 300_000);
}
#[test]
fn test_minimum_payment() {
let pricing = ServicePricing {
service_id: "test".into(),
name: "Test".into(),
metric: Metric::Requests,
step_size: 1,
price_per_step: 1,
min_steps: 10,
enabled: true,
description: String::new(),
accepted_mints: vec![],
};
assert_eq!(pricing.minimum_payment(), 10);
}
#[test]
fn test_cost_for_allotment() {
let pricing = ServicePricing {
service_id: "test".into(),
name: "Test".into(),
metric: Metric::Bytes,
step_size: 1_048_576,
price_per_step: 1,
min_steps: 0,
enabled: true,
description: String::new(),
accepted_mints: vec![],
};
// 5 MB costs 5 sats
assert_eq!(pricing.cost_for_allotment(5_242_880), 5);
// 1.5 MB rounds up to 2 sats
assert_eq!(pricing.cost_for_allotment(1_572_864), 2);
}
#[test]
fn test_validate_pricing() {
let good = ServicePricing {
service_id: "test".into(),
name: "Test".into(),
metric: Metric::Bytes,
step_size: 1024,
price_per_step: 1,
min_steps: 0,
enabled: true,
description: String::new(),
accepted_mints: vec![],
};
assert!(good.validate().is_ok());
let bad_step = ServicePricing { step_size: 0, ..good.clone() };
assert!(bad_step.validate().is_err());
let bad_price = ServicePricing { price_per_step: 0, ..good.clone() };
assert!(bad_price.validate().is_err());
}
#[tokio::test]
async fn test_load_default_pricing() {
let tmp = TempDir::new().unwrap();
let config = load_pricing(tmp.path()).await.unwrap();
assert_eq!(config.services.len(), 4);
// All disabled by default
assert!(config.services.iter().all(|s| !s.enabled));
}
#[tokio::test]
async fn test_save_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let config = PricingConfig {
services: vec![ServicePricing {
service_id: "custom".into(),
name: "Custom Service".into(),
metric: Metric::Requests,
step_size: 1,
price_per_step: 5,
min_steps: 1,
enabled: true,
description: "custom".into(),
accepted_mints: vec!["http://mint".into()],
}],
};
save_pricing(tmp.path(), &config).await.unwrap();
let loaded = load_pricing(tmp.path()).await.unwrap();
assert_eq!(loaded.services.len(), 1);
assert_eq!(loaded.services[0].price_per_step, 5);
}
#[test]
fn test_get_service() {
let config = default_pricing();
assert!(config.get_service("content-download").is_some());
assert!(config.get_service("nonexistent").is_none());
// All disabled by default
assert!(config.get_active_service("content-download").is_none());
}
#[test]
fn test_metric_display() {
assert_eq!(format!("{}", Metric::Bytes), "bytes");
assert_eq!(format!("{}", Metric::Milliseconds), "milliseconds");
assert_eq!(format!("{}", Metric::Requests), "requests");
}
}

View File

@ -0,0 +1,436 @@
//! Streaming session management.
//!
//! Tracks active metered sessions: which peer has how much allotment remaining
//! for which service. Supports incremental top-ups (TollGate-style).
use super::pricing::{Metric, ServicePricing};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use tokio::fs;
const SESSIONS_FILE: &str = "streaming/sessions.json";
/// A single streaming session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamingSession {
/// Unique session ID.
pub id: String,
/// Peer identifier (Nostr pubkey, DID, or onion address).
pub peer_id: String,
/// Service this session is for.
pub service_id: String,
/// Metric type for this session.
pub metric: Metric,
/// Total allotment granted (in metric units).
pub allotment: u64,
/// Amount consumed so far (in metric units).
pub used: u64,
/// Total sats paid for this session.
pub paid_sats: u64,
/// When the session was created.
pub created_at: String,
/// When the session was last topped up.
pub last_topup_at: String,
/// When the session expires (for time-based: created_at + allotment_ms).
/// Empty string for non-time-based sessions.
#[serde(default)]
pub expires_at: String,
/// Whether the session is still active.
#[serde(default = "default_true")]
pub active: bool,
}
fn default_true() -> bool {
true
}
impl StreamingSession {
/// Create a new session from a payment.
pub fn new(
peer_id: &str,
service_id: &str,
pricing: &ServicePricing,
paid_sats: u64,
) -> Self {
let allotment = pricing.calculate_allotment(paid_sats);
let now = chrono::Utc::now();
let now_str = now.to_rfc3339();
let expires_at = if pricing.metric == Metric::Milliseconds {
let expires =
now + chrono::Duration::milliseconds(allotment as i64);
expires.to_rfc3339()
} else {
String::new()
};
Self {
id: uuid::Uuid::new_v4().to_string(),
peer_id: peer_id.to_string(),
service_id: service_id.to_string(),
metric: pricing.metric,
allotment,
used: 0,
paid_sats,
created_at: now_str.clone(),
last_topup_at: now_str,
expires_at,
active: true,
}
}
/// Add more allotment from an additional payment (top-up).
pub fn topup(&mut self, pricing: &ServicePricing, additional_sats: u64) {
let additional_allotment = pricing.calculate_allotment(additional_sats);
self.allotment += additional_allotment;
self.paid_sats += additional_sats;
self.last_topup_at = chrono::Utc::now().to_rfc3339();
// For time-based: extend expiry
if self.metric == Metric::Milliseconds {
let current_expires = chrono::DateTime::parse_from_rfc3339(&self.expires_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
let new_expires = current_expires
+ chrono::Duration::milliseconds(additional_allotment as i64);
self.expires_at = new_expires.to_rfc3339();
}
// Reactivate if it was closed
self.active = true;
}
/// Record usage and check if the session is still within its allotment.
pub fn record_usage(&mut self, amount: u64) -> bool {
self.used += amount;
self.remaining() > 0 && !self.is_expired()
}
/// Remaining allotment.
pub fn remaining(&self) -> u64 {
self.allotment.saturating_sub(self.used)
}
/// Check if a time-based session has expired.
pub fn is_expired(&self) -> bool {
if !self.active {
return true;
}
if self.metric == Metric::Milliseconds && !self.expires_at.is_empty() {
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&self.expires_at) {
return chrono::Utc::now() > expires.with_timezone(&chrono::Utc);
}
}
// For non-time-based: expired when allotment consumed
self.used >= self.allotment
}
/// Check if this session can serve a request of the given cost.
pub fn can_serve(&self, cost: u64) -> bool {
self.active && !self.is_expired() && self.remaining() >= cost
}
/// Close the session.
pub fn close(&mut self) {
self.active = false;
}
}
/// All active and recent sessions.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SessionStore {
pub sessions: Vec<StreamingSession>,
}
impl SessionStore {
/// Find an active session for a peer and service.
pub fn find_active(&self, peer_id: &str, service_id: &str) -> Option<&StreamingSession> {
self.sessions
.iter()
.find(|s| s.peer_id == peer_id && s.service_id == service_id && s.active && !s.is_expired())
}
/// Find a mutable active session for a peer and service.
pub fn find_active_mut(
&mut self,
peer_id: &str,
service_id: &str,
) -> Option<&mut StreamingSession> {
self.sessions
.iter_mut()
.find(|s| s.peer_id == peer_id && s.service_id == service_id && s.active && !s.is_expired())
}
/// Get a session by ID.
pub fn get(&self, session_id: &str) -> Option<&StreamingSession> {
self.sessions.iter().find(|s| s.id == session_id)
}
/// Get a mutable session by ID.
pub fn get_mut(&mut self, session_id: &str) -> Option<&mut StreamingSession> {
self.sessions.iter_mut().find(|s| s.id == session_id)
}
/// List all active sessions.
pub fn active_sessions(&self) -> Vec<&StreamingSession> {
self.sessions
.iter()
.filter(|s| s.active && !s.is_expired())
.collect()
}
/// List all sessions for a peer.
pub fn sessions_for_peer(&self, peer_id: &str) -> Vec<&StreamingSession> {
self.sessions
.iter()
.filter(|s| s.peer_id == peer_id)
.collect()
}
/// Close expired sessions and return how many were closed.
pub fn close_expired(&mut self) -> usize {
let mut closed = 0;
for session in &mut self.sessions {
if session.active && session.is_expired() {
session.active = false;
closed += 1;
}
}
closed
}
/// Prune inactive sessions older than 7 days.
pub fn prune_old(&mut self) {
let cutoff = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339();
self.sessions
.retain(|s| s.active || s.created_at > cutoff);
}
/// Create or top-up a session for a peer+service.
pub fn create_or_topup(
&mut self,
peer_id: &str,
service_id: &str,
pricing: &ServicePricing,
paid_sats: u64,
) -> &StreamingSession {
// Check for existing active session
if let Some(session) = self.find_active_mut(peer_id, service_id) {
session.topup(pricing, paid_sats);
let id = session.id.clone();
return self.get(&id).unwrap();
}
// Create new session
let session = StreamingSession::new(peer_id, service_id, pricing, paid_sats);
self.sessions.push(session);
self.sessions.last().unwrap()
}
/// Total revenue from all sessions.
pub fn total_revenue(&self) -> u64 {
self.sessions.iter().map(|s| s.paid_sats).sum()
}
/// Total revenue by service.
pub fn revenue_by_service(&self) -> HashMap<String, u64> {
let mut map = HashMap::new();
for session in &self.sessions {
*map.entry(session.service_id.clone()).or_insert(0) += session.paid_sats;
}
map
}
}
/// Load sessions from disk.
pub async fn load_sessions(data_dir: &Path) -> Result<SessionStore> {
let path = data_dir.join(SESSIONS_FILE);
if !path.exists() {
return Ok(SessionStore::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read sessions file")?;
let store: SessionStore = serde_json::from_str(&content).unwrap_or_default();
Ok(store)
}
/// Save sessions to disk.
pub async fn save_sessions(data_dir: &Path, store: &SessionStore) -> Result<()> {
let dir = data_dir.join("streaming");
fs::create_dir_all(&dir)
.await
.context("Failed to create streaming dir")?;
let path = data_dir.join(SESSIONS_FILE);
let content =
serde_json::to_string_pretty(store).context("Failed to serialize sessions")?;
fs::write(&path, content)
.await
.context("Failed to write sessions file")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_pricing(metric: Metric) -> ServicePricing {
ServicePricing {
service_id: "test".into(),
name: "Test".into(),
metric,
step_size: match metric {
Metric::Bytes => 1_048_576,
Metric::Milliseconds => 60_000,
Metric::Requests => 1,
},
price_per_step: 1,
min_steps: 0,
enabled: true,
description: String::new(),
accepted_mints: vec![],
}
}
#[test]
fn test_new_session_bytes() {
let pricing = test_pricing(Metric::Bytes);
let session = StreamingSession::new("peer1", "test", &pricing, 10);
assert_eq!(session.allotment, 10_485_760); // 10 MB
assert_eq!(session.used, 0);
assert_eq!(session.paid_sats, 10);
assert!(session.active);
assert!(session.expires_at.is_empty());
}
#[test]
fn test_new_session_time() {
let pricing = test_pricing(Metric::Milliseconds);
let session = StreamingSession::new("peer1", "test", &pricing, 5);
assert_eq!(session.allotment, 300_000); // 5 minutes
assert!(!session.expires_at.is_empty());
assert!(!session.is_expired());
}
#[test]
fn test_session_topup() {
let pricing = test_pricing(Metric::Bytes);
let mut session = StreamingSession::new("peer1", "test", &pricing, 10);
assert_eq!(session.allotment, 10_485_760);
session.topup(&pricing, 5);
assert_eq!(session.allotment, 15_728_640); // 15 MB
assert_eq!(session.paid_sats, 15);
}
#[test]
fn test_session_record_usage() {
let pricing = test_pricing(Metric::Requests);
let mut session = StreamingSession::new("peer1", "test", &pricing, 5);
assert_eq!(session.allotment, 5);
assert!(session.record_usage(1));
assert!(session.record_usage(1));
assert!(session.record_usage(1));
assert!(session.record_usage(1));
assert!(!session.record_usage(1)); // 5th consumes last
assert_eq!(session.remaining(), 0);
}
#[test]
fn test_session_can_serve() {
let pricing = test_pricing(Metric::Requests);
let session = StreamingSession::new("peer1", "test", &pricing, 3);
assert!(session.can_serve(1));
assert!(session.can_serve(3));
assert!(!session.can_serve(4));
}
#[test]
fn test_session_close() {
let pricing = test_pricing(Metric::Requests);
let mut session = StreamingSession::new("peer1", "test", &pricing, 5);
assert!(session.active);
session.close();
assert!(!session.active);
assert!(session.is_expired());
}
#[test]
fn test_session_store_create_or_topup() {
let pricing = test_pricing(Metric::Requests);
let mut store = SessionStore::default();
// First payment creates session
let s1 = store.create_or_topup("peer1", "test", &pricing, 10);
let s1_id = s1.id.clone();
assert_eq!(s1.allotment, 10);
assert_eq!(s1.paid_sats, 10);
// Second payment tops up
let s2 = store.create_or_topup("peer1", "test", &pricing, 5);
assert_eq!(s2.id, s1_id); // Same session
assert_eq!(s2.allotment, 15);
assert_eq!(s2.paid_sats, 15);
}
#[test]
fn test_session_store_different_peers() {
let pricing = test_pricing(Metric::Requests);
let mut store = SessionStore::default();
store.create_or_topup("peer1", "test", &pricing, 10);
store.create_or_topup("peer2", "test", &pricing, 20);
assert_eq!(store.active_sessions().len(), 2);
}
#[test]
fn test_close_expired() {
let pricing = test_pricing(Metric::Requests);
let mut store = SessionStore::default();
store.create_or_topup("peer1", "test", &pricing, 1);
// Consume the allotment
if let Some(s) = store.find_active_mut("peer1", "test") {
s.record_usage(1);
}
let closed = store.close_expired();
assert_eq!(closed, 1);
}
#[test]
fn test_revenue_tracking() {
let pricing = test_pricing(Metric::Requests);
let mut store = SessionStore::default();
store.create_or_topup("peer1", "test", &pricing, 100);
store.create_or_topup("peer2", "test", &pricing, 200);
assert_eq!(store.total_revenue(), 300);
let by_service = store.revenue_by_service();
assert_eq!(*by_service.get("test").unwrap(), 300);
}
#[tokio::test]
async fn test_load_save_sessions() {
let tmp = TempDir::new().unwrap();
let pricing = test_pricing(Metric::Bytes);
let mut store = SessionStore::default();
store.create_or_topup("peer1", "test", &pricing, 42);
save_sessions(tmp.path(), &store).await.unwrap();
let loaded = load_sessions(tmp.path()).await.unwrap();
assert_eq!(loaded.sessions.len(), 1);
assert_eq!(loaded.sessions[0].paid_sats, 42);
}
}

View File

@ -0,0 +1,216 @@
//! Blind Diffie-Hellman Key Exchange (BDHKE) for Cashu ecash.
//!
//! Implements NUT-00 cryptographic operations:
//! - hash_to_curve: deterministic point derivation from secret
//! - blind: create blinded message for mint signing
//! - unblind: remove blinding factor from mint signature
//! - verify: verify unblinded signature against mint pubkey
use anyhow::{Context, Result};
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
use sha2::{Digest, Sha256};
/// Domain separator for hash_to_curve per NUT-00 spec.
const DOMAIN_SEPARATOR: &[u8] = b"Secp256k1_HashToCurve_Cashu_";
/// Hash a message to a secp256k1 curve point (NUT-00).
///
/// Iteratively hashes `sha256(sha256(domain_separator || msg) || counter)` until
/// the result is a valid x-coordinate on secp256k1. Prepends 0x02 to try as
/// a compressed public key.
pub fn hash_to_curve(message: &[u8]) -> Result<PublicKey> {
let msg_hash = {
let mut hasher = Sha256::new();
hasher.update(DOMAIN_SEPARATOR);
hasher.update(message);
hasher.finalize()
};
for counter in 0u32..65536 {
let mut hasher = Sha256::new();
hasher.update(&msg_hash);
hasher.update(counter.to_le_bytes());
let hash = hasher.finalize();
// Try to construct a point: 0x02 || hash (compressed even-y format)
let mut point_bytes = [0u8; 33];
point_bytes[0] = 0x02;
point_bytes[1..].copy_from_slice(&hash);
if let Ok(pk) = PublicKey::from_slice(&point_bytes) {
return Ok(pk);
}
}
Err(anyhow::anyhow!(
"hash_to_curve: no valid point found after 65536 iterations"
))
}
/// Blinded message output from the client.
pub struct BlindedMessage {
/// The blinded point B_ = Y + r*G
pub b_prime: PublicKey,
/// The blinding factor (kept secret by client)
pub r: SecretKey,
/// The original secret
pub secret: Vec<u8>,
}
/// Create a blinded message for the mint to sign.
///
/// Given a secret, computes Y = hash_to_curve(secret), picks random r,
/// and returns B_ = Y + r*G along with the blinding factor r.
pub fn blind_message(secret: &[u8], blinding_factor: &SecretKey) -> Result<BlindedMessage> {
let secp = Secp256k1::new();
// Y = hash_to_curve(secret)
let y = hash_to_curve(secret)?;
// r*G
let r_pub = PublicKey::from_secret_key(&secp, blinding_factor);
// B_ = Y + r*G
let b_prime = PublicKey::combine_keys(&[&y, &r_pub])
.context("Failed to compute blinded message B_ = Y + r*G")?;
Ok(BlindedMessage {
b_prime,
r: *blinding_factor,
secret: secret.to_vec(),
})
}
/// Unblind a mint's blind signature to get the real signature.
///
/// Given C_ (blind signature from mint), r (our blinding factor), and K (mint's pubkey):
/// C = C_ - r*K
pub fn unblind_signature(
c_prime: &PublicKey,
r: &SecretKey,
mint_pubkey: &PublicKey,
) -> Result<PublicKey> {
let secp = Secp256k1::new();
// Compute r*K
let r_scalar =
Scalar::from_be_bytes(r.secret_bytes()).expect("valid secret key is valid scalar");
let r_times_k = mint_pubkey
.mul_tweak(&secp, &r_scalar)
.context("Failed to compute r*K")?;
// Negate to get -(r*K)
let neg_r_times_k = r_times_k.negate(&secp);
// C = C_ + (-(r*K)) = C_ - r*K
let c = PublicKey::combine_keys(&[c_prime, &neg_r_times_k])
.context("Failed to compute C = C_ - r*K")?;
Ok(c)
}
/// Verify that a proof (secret, C) is valid against a mint's public key K.
///
/// Checks: C == k * hash_to_curve(secret) — but since we don't have k (the mint's
/// private key), we verify by checking that the DLEQ proof is valid, or by
/// attempting to swap the token at the mint. This function provides a basic
/// structural check that the proof components are well-formed.
pub fn verify_proof_structure(secret: &[u8], c: &PublicKey) -> Result<bool> {
// Verify that hash_to_curve(secret) produces a valid point
let _y = hash_to_curve(secret)?;
// Verify C is a valid public key (already guaranteed by type, but check non-identity)
let c_bytes = c.serialize();
if c_bytes.iter().all(|&b| b == 0) {
return Ok(false);
}
Ok(true)
}
/// Construct the secret string for a Cashu proof.
/// NUT-10 defines secret as a JSON array: ["P2PK", {nonce, data, tags}]
/// For basic (non-P2PK) proofs, the secret is just a random hex string.
pub fn generate_secret() -> Vec<u8> {
let random_bytes: [u8; 32] = rand::random();
hex::encode(random_bytes).into_bytes()
}
/// Generate a random blinding factor.
pub fn random_blinding_factor() -> SecretKey {
let mut rng = rand::thread_rng();
SecretKey::new(&mut rng)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_to_curve_deterministic() {
let msg = b"test_message";
let p1 = hash_to_curve(msg).unwrap();
let p2 = hash_to_curve(msg).unwrap();
assert_eq!(p1, p2);
}
#[test]
fn test_hash_to_curve_different_messages() {
let p1 = hash_to_curve(b"message_a").unwrap();
let p2 = hash_to_curve(b"message_b").unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_blind_unblind_roundtrip() {
let secp = Secp256k1::new();
let secret = b"test_secret";
let r = random_blinding_factor();
// Simulate mint: k is mint's private key, K = k*G is public key
let k = SecretKey::new(&mut rand::thread_rng());
let k_pub = PublicKey::from_secret_key(&secp, &k);
// Client blinds
let blinded = blind_message(secret, &r).unwrap();
// Mint signs: C_ = k * B_
let k_scalar = Scalar::from_be_bytes(k.secret_bytes()).unwrap();
let c_prime = blinded
.b_prime
.mul_tweak(&secp, &k_scalar)
.unwrap();
// Client unblinds: C = C_ - r*K
let c = unblind_signature(&c_prime, &r, &k_pub).unwrap();
// Verify: C should equal k * hash_to_curve(secret)
let y = hash_to_curve(secret).unwrap();
let expected_c = y.mul_tweak(&secp, &k_scalar).unwrap();
assert_eq!(c, expected_c);
}
#[test]
fn test_generate_secret_length() {
let secret = generate_secret();
// 32 bytes hex-encoded = 64 chars
assert_eq!(secret.len(), 64);
}
#[test]
fn test_generate_secret_unique() {
let s1 = generate_secret();
let s2 = generate_secret();
assert_ne!(s1, s2);
}
#[test]
fn test_verify_proof_structure_valid() {
let secret = generate_secret();
let secp = Secp256k1::new();
let k = SecretKey::new(&mut rand::thread_rng());
let y = hash_to_curve(&secret).unwrap();
let k_scalar = Scalar::from_be_bytes(k.secret_bytes()).unwrap();
let c = y.mul_tweak(&secp, &k_scalar).unwrap();
assert!(verify_proof_structure(&secret, &c).unwrap());
}
}

View File

@ -0,0 +1,315 @@
//! Cashu token format (NUT-00) — serialization and deserialization.
//!
//! Supports the cashuA (V3) token format:
//! cashuA<base64url_encoded_json>
//!
//! Token JSON structure:
//! {
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
//! "memo": "<optional>"
//! }
use anyhow::{Context, Result};
use bitcoin::secp256k1::PublicKey;
use serde::{Deserialize, Serialize};
/// Prefix for V3 tokens.
const CASHU_A_PREFIX: &str = "cashuA";
/// A single Cashu proof (a signed token for a specific denomination).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proof {
/// Denomination in the mint's unit (sats).
pub amount: u64,
/// Keyset ID (hex string, e.g. "009a1f293253e41e").
pub id: String,
/// The secret (random hex string or NUT-10 structured secret).
pub secret: String,
/// The unblinded signature C as hex-encoded compressed public key.
#[serde(rename = "C")]
pub c: String,
}
impl Proof {
/// Parse the C field as a secp256k1 PublicKey.
pub fn c_as_pubkey(&self) -> Result<PublicKey> {
let bytes = hex::decode(&self.c).context("Invalid hex in proof C field")?;
PublicKey::from_slice(&bytes).context("Invalid public key in proof C field")
}
}
/// A group of proofs from a single mint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenEntry {
/// Mint URL.
pub mint: String,
/// Proofs from this mint.
pub proofs: Vec<Proof>,
}
/// The full cashuA token envelope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CashuToken {
/// Token entries grouped by mint.
pub token: Vec<TokenEntry>,
/// Optional memo.
#[serde(skip_serializing_if = "Option::is_none")]
pub memo: Option<String>,
/// Optional unit (e.g. "sat").
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
impl CashuToken {
/// Create a new token with proofs from a single mint.
pub fn new(mint_url: &str, proofs: Vec<Proof>) -> Self {
Self {
token: vec![TokenEntry {
mint: mint_url.to_string(),
proofs,
}],
memo: None,
unit: Some("sat".to_string()),
}
}
/// Total value of all proofs across all mints.
pub fn total_amount(&self) -> u64 {
self.token
.iter()
.flat_map(|e| &e.proofs)
.map(|p| p.amount)
.sum()
}
/// All proofs across all mint entries.
pub fn all_proofs(&self) -> Vec<&Proof> {
self.token.iter().flat_map(|e| &e.proofs).collect()
}
/// All unique mint URLs in this token.
pub fn mint_urls(&self) -> Vec<&str> {
self.token.iter().map(|e| e.mint.as_str()).collect()
}
/// Encode as a cashuA token string.
pub fn serialize(&self) -> Result<String> {
let json = serde_json::to_string(self).context("Failed to serialize token JSON")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes());
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
}
/// Decode a cashuA token string.
pub fn deserialize(token_str: &str) -> Result<Self> {
let payload = token_str
.strip_prefix(CASHU_A_PREFIX)
.ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?;
use base64::Engine;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| {
// Try standard base64 as fallback (some implementations use it)
base64::engine::general_purpose::URL_SAFE.decode(payload)
})
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
.context("Invalid base64 in cashuA token")?;
let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?;
let token: CashuToken =
serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?;
// Structural validation
if token.token.is_empty() {
anyhow::bail!("Token has no entries");
}
for entry in &token.token {
if entry.mint.is_empty() {
anyhow::bail!("Token entry has empty mint URL");
}
if entry.proofs.is_empty() {
anyhow::bail!("Token entry has no proofs");
}
for proof in &entry.proofs {
if proof.amount == 0 {
anyhow::bail!("Proof has zero amount");
}
if proof.secret.is_empty() {
anyhow::bail!("Proof has empty secret");
}
if proof.c.is_empty() {
anyhow::bail!("Proof has empty C");
}
}
}
Ok(token)
}
}
/// Keyset info returned by a mint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeysetInfo {
pub id: String,
pub unit: String,
pub active: bool,
}
/// Mint keyset: maps denomination amounts to public keys.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MintKeyset {
pub id: String,
/// Map of amount (as string) to hex-encoded public key.
pub keys: std::collections::HashMap<String, String>,
}
impl MintKeyset {
/// Get the mint's public key for a given denomination amount.
pub fn key_for_amount(&self, amount: u64) -> Result<PublicKey> {
let amount_str = amount.to_string();
let hex_key = self
.keys
.get(&amount_str)
.ok_or_else(|| anyhow::anyhow!("No key for amount {} in keyset {}", amount, self.id))?;
let bytes = hex::decode(hex_key).context("Invalid hex in mint pubkey")?;
PublicKey::from_slice(&bytes).context("Invalid pubkey in mint keyset")
}
}
/// Blinded message sent to the mint during mint/swap.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlindedMessageRequest {
/// Amount for this output.
pub amount: u64,
/// Keyset ID to use.
pub id: String,
/// Blinded secret B_ as hex-encoded compressed pubkey.
#[serde(rename = "B_")]
pub b_prime: String,
}
/// Blind signature returned by the mint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlindSignature {
/// Amount signed.
pub amount: u64,
/// Keyset ID.
pub id: String,
/// Blind signature C_ as hex-encoded compressed pubkey.
#[serde(rename = "C_")]
pub c_prime: String,
}
impl BlindSignature {
/// Parse C_ as a secp256k1 PublicKey.
pub fn c_prime_as_pubkey(&self) -> Result<PublicKey> {
let bytes = hex::decode(&self.c_prime).context("Invalid hex in blind signature C_")?;
PublicKey::from_slice(&bytes).context("Invalid pubkey in blind signature C_")
}
}
/// Split a target amount into powers of 2 (Cashu denomination scheme).
/// E.g., 13 -> [1, 4, 8]
pub fn amount_to_denominations(mut amount: u64) -> Vec<u64> {
let mut denoms = Vec::new();
let mut bit = 0;
while amount > 0 {
if amount & 1 == 1 {
denoms.push(1u64 << bit);
}
amount >>= 1;
bit += 1;
}
denoms
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_roundtrip() {
let token = CashuToken {
token: vec![TokenEntry {
mint: "http://127.0.0.1:8175".to_string(),
proofs: vec![Proof {
amount: 8,
id: "009a1f293253e41e".to_string(),
secret: "abcdef1234567890".to_string(),
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".to_string(),
}],
}],
memo: Some("test token".to_string()),
unit: Some("sat".to_string()),
};
let encoded = token.serialize().unwrap();
assert!(encoded.starts_with("cashuA"));
let decoded = CashuToken::deserialize(&encoded).unwrap();
assert_eq!(decoded.total_amount(), 8);
assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175");
assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890");
assert_eq!(decoded.memo, Some("test token".to_string()));
}
#[test]
fn test_total_amount_multi_proof() {
let token = CashuToken {
token: vec![TokenEntry {
mint: "http://mint".to_string(),
proofs: vec![
Proof { amount: 1, id: "id1".into(), secret: "s1".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".into() },
Proof { amount: 4, id: "id1".into(), secret: "s2".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".into() },
Proof { amount: 8, id: "id1".into(), secret: "s3".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".into() },
],
}],
memo: None,
unit: None,
};
assert_eq!(token.total_amount(), 13);
}
#[test]
fn test_deserialize_rejects_empty_token() {
let bad = CashuToken { token: vec![], memo: None, unit: None };
let encoded = bad.serialize().unwrap();
let result = CashuToken::deserialize(&encoded);
assert!(result.is_err());
}
#[test]
fn test_deserialize_rejects_invalid_prefix() {
let result = CashuToken::deserialize("cashuBabc123");
assert!(result.is_err());
}
#[test]
fn test_amount_to_denominations() {
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
assert_eq!(amount_to_denominations(1), vec![1]);
assert_eq!(amount_to_denominations(13), vec![1, 4, 8]);
assert_eq!(amount_to_denominations(21), vec![1, 4, 16]);
assert_eq!(amount_to_denominations(64), vec![64]);
assert_eq!(amount_to_denominations(255), vec![1, 2, 4, 8, 16, 32, 64, 128]);
}
#[test]
fn test_amount_to_denominations_large() {
let denoms = amount_to_denominations(1_000_000);
let sum: u64 = denoms.iter().sum();
assert_eq!(sum, 1_000_000);
}
#[test]
fn test_proof_c_as_pubkey() {
let proof = Proof {
amount: 1,
id: "test".into(),
secret: "s".into(),
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".to_string(),
};
assert!(proof.c_as_pubkey().is_ok());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,461 @@
//! HTTP client for Cashu mint API (NUT-01 through NUT-06).
//!
//! Communicates with a Cashu-compatible mint for:
//! - Keyset discovery (GET /v1/keys, /v1/keysets)
//! - Mint quotes and minting (POST /v1/mint/quote/bolt11, /v1/mint/bolt11)
//! - Melt quotes and melting (POST /v1/melt/quote/bolt11, /v1/melt/bolt11)
//! - Token swaps (POST /v1/swap)
//! - Proof state checks (POST /v1/checkstate)
use super::bdhke;
use super::cashu::{
amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof,
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::debug;
/// Default timeout for mint API calls.
const MINT_TIMEOUT_SECS: u64 = 10;
/// Timeout for heavy operations (minting with Lightning payment).
const MINT_HEAVY_TIMEOUT_SECS: u64 = 30;
/// Mint quote response (NUT-04).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MintQuote {
pub quote: String,
pub request: String, // BOLT11 Lightning invoice
pub state: String, // "UNPAID", "PAID", "ISSUED"
#[serde(default)]
pub expiry: u64,
}
/// Melt quote response (NUT-05).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeltQuote {
pub quote: String,
pub amount: u64,
pub fee_reserve: u64,
pub state: String, // "UNPAID", "PENDING", "PAID"
#[serde(default)]
pub expiry: u64,
}
/// Token state from checkstate (NUT-07).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProofState {
#[serde(rename = "Y")]
pub y: String,
pub state: String, // "UNSPENT", "SPENT", "PENDING"
}
/// Result of a swap operation.
pub struct SwapResult {
pub new_proofs: Vec<Proof>,
}
/// Result of a mint operation.
pub struct MintResult {
pub proofs: Vec<Proof>,
}
/// HTTP client for a single Cashu mint.
pub struct MintClient {
url: String,
client: reqwest::Client,
}
impl MintClient {
/// Create a new mint client for the given mint URL.
pub fn new(mint_url: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for mint")?;
Ok(Self {
url: mint_url.trim_end_matches('/').to_string(),
client,
})
}
/// Create a mint client with a custom reqwest client (e.g., for Tor proxy).
pub fn with_client(mint_url: &str, client: reqwest::Client) -> Self {
Self {
url: mint_url.trim_end_matches('/').to_string(),
client,
}
}
pub fn url(&self) -> &str {
&self.url
}
// ── Keyset discovery (NUT-01, NUT-02) ──
/// Fetch the active keyset from the mint.
pub async fn get_keys(&self) -> Result<Vec<MintKeyset>> {
let url = format!("{}/v1/keys", self.url);
let res = self
.client
.get(&url)
.send()
.await
.context("Failed to fetch mint keys")?;
if !res.status().is_success() {
anyhow::bail!("Mint keys request failed: {}", res.status());
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint keys")?;
let keysets: Vec<MintKeyset> = serde_json::from_value(
body.get("keysets")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse keysets")?;
Ok(keysets)
}
/// Get the active keyset for the "sat" unit.
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
let keysets = self.get_keys().await?;
keysets
.into_iter()
.find(|k| {
// Find active sat keyset — check keys map is non-empty
!k.keys.is_empty()
})
.ok_or_else(|| anyhow::anyhow!("No active keyset found at mint {}", self.url))
}
// ── Mint quotes (NUT-04) ──
/// Request a mint quote — returns a Lightning invoice to pay.
pub async fn mint_quote(&self, amount: u64) -> Result<MintQuote> {
let url = format!("{}/v1/mint/quote/bolt11", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({ "amount": amount, "unit": "sat" }))
.send()
.await
.context("Failed to request mint quote")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Mint quote failed ({}): {}", status, body);
}
res.json().await.context("Failed to parse mint quote")
}
/// Check the status of a mint quote.
pub async fn mint_quote_status(&self, quote_id: &str) -> Result<MintQuote> {
let url = format!("{}/v1/mint/quote/bolt11/{}", self.url, quote_id);
let res = self
.client
.get(&url)
.send()
.await
.context("Failed to check mint quote status")?;
if !res.status().is_success() {
anyhow::bail!("Mint quote status check failed: {}", res.status());
}
res.json().await.context("Failed to parse mint quote status")
}
/// Mint tokens after Lightning invoice has been paid.
/// Performs BDHKE blinding, sends blinded messages to mint, unblinds signatures.
pub async fn mint_tokens(&self, quote_id: &str, amount: u64) -> Result<MintResult> {
let keyset = self.get_active_sat_keyset().await?;
let denominations = amount_to_denominations(amount);
let mut blinded_messages = Vec::new();
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
for &denom in &denominations {
let secret = bdhke::generate_secret();
let r = bdhke::random_blinding_factor();
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount: denom,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, denom));
}
let url = format!("{}/v1/mint/bolt11", self.url);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MINT_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build client for mint operation")?;
let res = client
.post(&url)
.json(&serde_json::json!({
"quote": quote_id,
"outputs": blinded_messages,
}))
.send()
.await
.context("Failed to mint tokens")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
let signatures: Vec<BlindSignature> = serde_json::from_value(
body.get("signatures")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse blind signatures")?;
if signatures.len() != blinding_data.len() {
anyhow::bail!(
"Mint returned {} signatures, expected {}",
signatures.len(),
blinding_data.len()
);
}
// Unblind signatures to get real proofs
let mut proofs = Vec::new();
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
let c_prime = sig.c_prime_as_pubkey()?;
let mint_key = keyset.key_for_amount(*amount)?;
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
proofs.push(Proof {
amount: *amount,
id: keyset.id.clone(),
secret: String::from_utf8_lossy(secret).to_string(),
c: hex::encode(c.serialize()),
});
}
debug!("Minted {} proofs totaling {} sats", proofs.len(), amount);
Ok(MintResult { proofs })
}
// ── Melt (NUT-05) ──
/// Request a melt quote — how much it costs to pay a Lightning invoice.
pub async fn melt_quote(&self, bolt11: &str) -> Result<MeltQuote> {
let url = format!("{}/v1/melt/quote/bolt11", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({ "request": bolt11, "unit": "sat" }))
.send()
.await
.context("Failed to request melt quote")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Melt quote failed ({}): {}", status, body);
}
res.json().await.context("Failed to parse melt quote")
}
/// Melt tokens — pay a Lightning invoice using ecash proofs.
pub async fn melt_tokens(&self, quote_id: &str, proofs: &[Proof]) -> Result<MeltQuote> {
let url = format!("{}/v1/melt/bolt11", self.url);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MINT_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build client for melt operation")?;
let res = client
.post(&url)
.json(&serde_json::json!({
"quote": quote_id,
"inputs": proofs,
}))
.send()
.await
.context("Failed to melt tokens")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Melt failed ({}): {}", status, body);
}
res.json().await.context("Failed to parse melt response")
}
// ── Swap (NUT-03) ──
/// Swap proofs for new proofs of different denominations.
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
let keyset = self.get_active_sat_keyset().await?;
let mut blinded_messages = Vec::new();
let mut blinding_data = Vec::new();
for &amount in target_amounts {
let secret = bdhke::generate_secret();
let r = bdhke::random_blinding_factor();
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, amount));
}
let url = format!("{}/v1/swap", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({
"inputs": inputs,
"outputs": blinded_messages,
}))
.send()
.await
.context("Failed to swap tokens")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Swap failed ({}): {}", status, body);
}
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
let signatures: Vec<BlindSignature> = serde_json::from_value(
body.get("signatures")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse swap signatures")?;
if signatures.len() != blinding_data.len() {
anyhow::bail!(
"Swap returned {} signatures, expected {}",
signatures.len(),
blinding_data.len()
);
}
let mut new_proofs = Vec::new();
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
let c_prime = sig.c_prime_as_pubkey()?;
let mint_key = keyset.key_for_amount(*amount)?;
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
new_proofs.push(Proof {
amount: *amount,
id: keyset.id.clone(),
secret: String::from_utf8_lossy(secret).to_string(),
c: hex::encode(c.serialize()),
});
}
debug!(
"Swapped {} inputs for {} new proofs",
inputs.len(),
new_proofs.len()
);
Ok(SwapResult { new_proofs })
}
// ── Check state (NUT-07) ──
/// Check whether proofs are spent, unspent, or pending.
pub async fn check_state(&self, proofs: &[Proof]) -> Result<Vec<ProofState>> {
// Compute Y = hash_to_curve(secret) for each proof
let ys: Vec<String> = proofs
.iter()
.map(|p| {
let y = bdhke::hash_to_curve(p.secret.as_bytes())?;
Ok(hex::encode(y.serialize()))
})
.collect::<Result<Vec<_>>>()?;
let url = format!("{}/v1/checkstate", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({ "Ys": ys }))
.send()
.await
.context("Failed to check proof state")?;
if !res.status().is_success() {
anyhow::bail!("Check state failed: {}", res.status());
}
let body: serde_json::Value =
res.json().await.context("Failed to parse checkstate response")?;
let states: Vec<ProofState> = serde_json::from_value(
body.get("states")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse proof states")?;
Ok(states)
}
/// Receive a CashuToken by swapping its proofs for fresh ones.
/// This prevents double-spend and ensures only we can spend the new proofs.
pub async fn receive_token(&self, token: &CashuToken) -> Result<Vec<Proof>> {
let mut all_new_proofs = Vec::new();
for entry in &token.token {
if entry.mint != self.url {
debug!(
"Skipping proofs from different mint {} (ours: {})",
entry.mint, self.url
);
continue;
}
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(total);
let result = self.swap(&entry.proofs, &target_amounts).await?;
all_new_proofs.extend(result.new_proofs);
}
if all_new_proofs.is_empty() {
anyhow::bail!("No proofs could be swapped — mint mismatch or empty token");
}
Ok(all_new_proofs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mint_client_url_normalization() {
let client = MintClient::new("http://mint.example.com/").unwrap();
assert_eq!(client.url(), "http://mint.example.com");
}
#[test]
fn test_mint_client_url_no_trailing_slash() {
let client = MintClient::new("http://mint.example.com").unwrap();
assert_eq!(client.url(), "http://mint.example.com");
}
}

View File

@ -1,2 +1,5 @@
pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod mint_client;
pub mod profits;

View File

@ -19,6 +19,9 @@ pub struct ProfitsSummary {
pub content_sales_sats: u64,
/// Earnings from Lightning routing fees.
pub routing_fees_sats: u64,
/// Earnings from streaming data payments.
#[serde(default)]
pub streaming_revenue_sats: u64,
/// Recent earning entries (newest first).
pub recent: Vec<ProfitEntry>,
}
@ -38,6 +41,7 @@ pub struct ProfitEntry {
pub enum ProfitSource {
ContentSale,
RoutingFee,
StreamingRevenue,
}
/// Load profits summary from disk.
@ -84,7 +88,7 @@ pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description:
summary.recent.truncate(100);
}
summary.content_sales_sats += amount_sats;
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats + summary.streaming_revenue_sats;
save_profits(data_dir, &summary).await?;
Ok(())
}
@ -93,8 +97,9 @@ pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description:
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
let mut summary = load_profits(data_dir).await?;
// Also count ecash "receive" transactions as content sales revenue
// Count ecash transactions by type
let wallet = ecash::load_wallet(data_dir).await?;
let ecash_received: u64 = wallet
.transactions
.iter()
@ -102,11 +107,22 @@ pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
.map(|tx| tx.amount_sats)
.sum();
let streaming_received: u64 = wallet
.transactions
.iter()
.filter(|tx| matches!(tx.tx_type, ecash::TransactionType::StreamingRevenue))
.map(|tx| tx.amount_sats)
.sum();
// Use the higher of tracked profits or ecash receives as content sales
if ecash_received > summary.content_sales_sats {
summary.content_sales_sats = ecash_received;
}
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
if streaming_received > summary.streaming_revenue_sats {
summary.streaming_revenue_sats = streaming_received;
}
summary.total_sats =
summary.content_sales_sats + summary.routing_fees_sats + summary.streaming_revenue_sats;
Ok(summary)
}
@ -142,6 +158,7 @@ mod tests {
total_sats: 5000,
content_sales_sats: 3000,
routing_fees_sats: 2000,
streaming_revenue_sats: 0,
recent: vec![ProfitEntry {
source: ProfitSource::ContentSale,
amount_sats: 3000,

View File

@ -37,6 +37,9 @@
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
<!-- Toast notifications - top right, glass style, any page -->
<Teleport to="body">
<Transition name="toast">
@ -75,6 +78,7 @@ import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'

View File

@ -0,0 +1,106 @@
<template>
<!-- Spacer to prevent content from being hidden behind the player -->
<div v-if="audioPlayer.currentName.value" class="h-14"></div>
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="audioPlayer.currentName.value"
class="fixed bottom-0 left-0 right-0 z-50 audio-player-bar"
>
<!-- Progress bar (clickable) -->
<div
class="h-1 bg-white/10 cursor-pointer"
@click="onProgressClick"
>
<div
class="h-full bg-orange-500 transition-all duration-200"
:style="{ width: audioPlayer.progress.value + '%' }"
/>
</div>
<div class="flex items-center gap-3 px-4 py-2.5">
<!-- Play/Pause -->
<button
class="flex-shrink-0 w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
@click="togglePlay"
>
<svg v-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</button>
<!-- Track info -->
<div class="flex-1 min-w-0">
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
<p v-else class="text-sm font-medium text-white/90 truncate">{{ audioPlayer.currentName.value }}</p>
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
</div>
<!-- Close -->
<button
class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center transition-colors"
@click="audioPlayer.stop()"
>
<svg class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const audioPlayer = useAudioPlayer()
function togglePlay() {
if (audioPlayer.playing.value) {
audioPlayer.pause()
} else if (audioPlayer.currentSrc.value) {
audioPlayer.play(audioPlayer.currentSrc.value, audioPlayer.currentName.value)
}
}
function onProgressClick(e: MouseEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const time = ratio * audioPlayer.duration.value
audioPlayer.seek(time)
}
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.audio-player-bar {
background: rgba(15, 15, 15, 0.55);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border-top: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.3);
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>

View File

@ -47,7 +47,7 @@
v-if="isAudio || isVideo"
class="cloud-grid-card-play"
:class="{ 'cloud-grid-card-play-active': isCurrentlyPlaying }"
@click.stop="emit('play', item.path, item.name)"
@click.stop="isVideo ? emit('preview', item.path) : emit('play', item.path, item.name)"
>
<span class="cloud-grid-card-play-btn">
<svg v-if="!isCurrentlyPlaying" class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
@ -132,6 +132,7 @@ const emit = defineEmits<{
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
}>()
const cloudStore = useCloudStore()
@ -171,6 +172,8 @@ const coverBg = computed(() => {
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path)
}
}
</script>

View File

@ -0,0 +1,256 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="lightbox-backdrop"
@click.self="close"
@keydown="onKeydown"
tabindex="0"
ref="backdropEl"
>
<!-- Close button -->
<button class="lightbox-close" @click="close">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Counter -->
<div v-if="mediaItems.length > 1" class="lightbox-counter">
{{ currentIndex + 1 }} / {{ mediaItems.length }}
</div>
<!-- Previous button -->
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-prev"
@click.stop="prev"
>
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Next button -->
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-next"
@click.stop="next"
>
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Media content -->
<div class="lightbox-content" @click.stop>
<!-- Loading -->
<div v-if="loading" class="lightbox-loading">
<div class="w-10 h-10 border-3 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
</div>
<!-- Image -->
<img
v-else-if="currentItem && currentUrl && isImageFile(currentItem)"
:src="currentUrl"
:alt="currentItem.name"
class="lightbox-media"
@error="onMediaError"
/>
<!-- Video -->
<video
v-else-if="currentItem && currentUrl && isVideoFile(currentItem)"
:src="currentUrl"
:key="currentUrl"
class="lightbox-media"
controls
autoplay
@error="onMediaError"
/>
<!-- Audio -->
<div
v-else-if="currentItem && currentUrl && isAudioFile(currentItem)"
class="flex flex-col items-center justify-center gap-6 p-8"
>
<div class="w-32 h-32 rounded-2xl bg-gradient-to-br from-orange-500/20 to-orange-600/10 flex items-center justify-center">
<svg class="w-16 h-16 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<audio
:src="currentUrl"
:key="currentUrl"
controls
autoplay
class="w-full max-w-md"
@error="onMediaError"
/>
</div>
<!-- Error -->
<div v-else-if="mediaError" class="lightbox-error">
<p class="text-white/60">Failed to load media</p>
</div>
</div>
<!-- Filename -->
<div class="lightbox-filename">
<p class="text-sm text-white/70 truncate max-w-md">{{ currentItem?.name }}</p>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
const props = defineProps<{
items: FileBrowserItem[]
startIndex: number
show: boolean
fetchBlobUrl: (path: string) => Promise<string>
}>()
const emit = defineEmits<{
close: []
}>()
const currentIndex = ref(0)
const loading = ref(false)
const mediaError = ref(false)
const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
// Cache blob URLs to avoid re-fetching
const urlCache = new Map<string, string>()
const mediaItems = computed(() =>
props.items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
)
const currentItem = computed(() => mediaItems.value[currentIndex.value] ?? null)
function isImageFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'image'
}
function isVideoFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'video'
}
function isAudioFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'audio'
}
async function loadMedia(item: FileBrowserItem) {
loading.value = true
mediaError.value = false
currentUrl.value = null
try {
const cached = urlCache.get(item.path)
if (cached) {
currentUrl.value = cached
} else {
const url = await props.fetchBlobUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
}
} catch {
mediaError.value = true
} finally {
loading.value = false
}
}
function preloadAdjacent() {
const items = mediaItems.value
if (items.length <= 1) return
const prevIdx = (currentIndex.value - 1 + items.length) % items.length
const nextIdx = (currentIndex.value + 1) % items.length
for (const idx of [prevIdx, nextIdx]) {
const item = items[idx]
if (item && !urlCache.has(item.path) && isImageFile(item)) {
props.fetchBlobUrl(item.path).then(url => {
urlCache.set(item.path, url)
}).catch(() => {})
}
}
}
function prev() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value - 1 + len) % len
}
function next() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value + 1) % len
}
function close() {
emit('close')
}
function onMediaError() {
mediaError.value = true
loading.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
close()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
prev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
next()
}
}
// Load media when current item changes
watch(currentItem, (item) => {
if (item) {
loadMedia(item)
preloadAdjacent()
}
})
// Initialize when shown
watch(() => props.show, async (visible) => {
if (visible) {
currentIndex.value = props.startIndex
const item = mediaItems.value[props.startIndex]
if (item) {
await loadMedia(item)
preloadAdjacent()
}
await nextTick()
backdropEl.value?.focus()
}
}, { immediate: true })
// Clean up blob URLs on unmount
onUnmounted(() => {
for (const url of urlCache.values()) {
URL.revokeObjectURL(url)
}
urlCache.clear()
})
</script>

View File

@ -119,25 +119,10 @@
@delete="handleDelete"
@play="handlePlay"
@share="handleShare"
@preview="handlePreview"
/>
<!-- Mini Audio Player -->
<div v-if="audioPlayer.currentName.value" class="cloud-audio-player">
<button class="cloud-audio-player-btn" @click="audioPlayer.playing.value ? audioPlayer.pause() : audioPlayer.play(audioPlayer.currentSrc.value!, audioPlayer.currentName.value)">
<svg v-if="!audioPlayer.playing.value" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7L8 5z" /></svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" /></svg>
</button>
<div class="flex-1 min-w-0">
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
<p v-else class="text-sm font-medium text-white/90 truncate">{{ audioPlayer.currentName.value }}</p>
<div class="cloud-audio-progress">
<div class="cloud-audio-progress-bar" :style="{ width: audioPlayer.progress.value + '%' }"></div>
</div>
</div>
<button class="cloud-audio-player-btn" @click="audioPlayer.stop()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Audio player is now the global bottom bar (GlobalAudioPlayer in App.vue) -->
</div>
<!-- Fallback iframe (for sections without native UI) -->
@ -168,6 +153,16 @@
@close="shareTarget = null"
@saved="shareTarget = null"
/>
<!-- Media Lightbox -->
<MediaLightbox
v-if="lightboxIndex !== null"
:items="cloudStore.sortedItems"
:start-index="lightboxIndex"
:show="lightboxIndex !== null"
:fetch-blob-url="cloudStore.fetchBlobUrl"
@close="lightboxIndex = null"
/>
</div>
</template>
@ -179,7 +174,9 @@ import { useCloudStore } from '../stores/cloud'
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
import FileGrid from '../components/cloud/FileGrid.vue'
import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
import { useAudioPlayer } from '../composables/useAudioPlayer'
import { getFileCategory } from '../composables/useFileType'
const router = useRouter()
const route = useRoute()
@ -301,6 +298,20 @@ watch([useNativeUI, section], async ([native, sec]) => {
}, { immediate: true })
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
const lightboxIndex = ref<number | null>(null)
function handlePreview(path: string) {
// MediaLightbox internally filters items to media only, so startIndex
// must be the index within that filtered list
const items = cloudStore.sortedItems
const mediaItems = items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
const idx = mediaItems.findIndex(item => item.path === path)
lightboxIndex.value = idx >= 0 ? idx : 0
}
function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir }

View File

@ -57,47 +57,194 @@
<p class="text-white/50">This peer has no shared files.</p>
</div>
<!-- Purchase error -->
<div v-if="purchaseError" class="glass-card p-3 mb-4 flex items-center gap-3 border border-red-500/30">
<svg class="w-4 h-4 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-red-400 flex-1">{{ purchaseError }}</span>
<button class="text-xs text-white/50 hover:text-white" @click="purchaseError = null">Dismiss</button>
</div>
<!-- File Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-else-if="catalogItems.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in catalogItems"
:key="item.id"
class="glass-card p-4 flex items-center gap-4"
class="glass-card overflow-hidden"
>
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }}</p>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="accessBadgeClass(item.access)"
<!-- Media preview (images / videos / audio) -->
<div
v-if="isMediaMime(item.mime_type)"
class="relative aspect-video overflow-hidden cursor-pointer group"
@click="isPlayable(item.mime_type) ? playMedia(item) : undefined"
>
<img
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
:src="previewUrls[item.id]"
:alt="item.filename"
class="w-full h-full object-cover"
:style="isPaidItem(item.access) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
/>
<video
v-else-if="item.mime_type.startsWith('video/') && previewUrls[item.id]"
:src="previewUrls[item.id]"
class="w-full h-full object-cover"
muted
autoplay
loop
playsinline
/>
<!-- Audio waveform placeholder -->
<div v-else-if="item.mime_type.startsWith('audio/')" class="w-full h-full flex flex-col items-center justify-center bg-gradient-to-br from-orange-500/10 to-orange-600/5">
<svg class="w-12 h-12 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
<p class="mt-2 text-xs text-white/50 truncate max-w-[80%]">{{ item.filename.split('/').pop() }}</p>
</div>
<div v-else class="w-full h-full flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<svg class="w-10 h-10" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
</svg>
</div>
<!-- Play button overlay for video/audio -->
<div
v-if="isPlayable(item.mime_type)"
class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-colors"
>
{{ accessLabel(item.access) }}
</span>
<button
v-if="canDownload(item.access)"
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium"
:disabled="downloading === item.id"
@click="downloadFile(item)"
>
{{ downloading === item.id ? '...' : 'Download' }}
</button>
<div class="w-12 h-12 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-6 h-6 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</div>
</div>
<!-- Paid badge (top-right) -->
<div v-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span class="text-xs font-medium text-orange-400">{{ getItemPrice(item.access) }} sats</span>
</div>
<!-- Preview badge for paid playable items -->
<div v-if="isPaidItem(item.access) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
<span class="text-xs text-white/70">10% preview</span>
</div>
</div>
<!-- Card body -->
<div class="p-4 flex items-center gap-4">
<div v-if="!isMediaMime(item.mime_type)" class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }}</p>
</div>
<div class="flex items-center gap-2">
<span
v-if="!isPaidItem(item.access)"
class="text-xs px-2 py-0.5 rounded-full"
:class="accessBadgeClass(item.access)"
>
{{ accessLabel(item.access) }}
</span>
<!-- Play button for audio/video -->
<button
v-if="isPlayable(item.mime_type)"
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
:disabled="playing === item.id"
@click="playMedia(item)"
>
<template v-if="playing === item.id">
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span>Loading...</span>
</template>
<template v-else>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<span>{{ isPaidItem(item.access) ? 'Preview' : 'Play' }}</span>
</template>
</button>
<button
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
:disabled="downloading === item.id"
@click="downloadFile(item)"
>
<template v-if="downloading === item.id">
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span>{{ isPaidItem(item.access) ? 'Paying...' : 'Loading...' }}</span>
</template>
<template v-else-if="isPaidItem(item.access)">
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Buy {{ getItemPrice(item.access) }} sats</span>
</template>
<template v-else>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Download</span>
</template>
</button>
</div>
</div>
</div>
</div>
<!-- Video player modal -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="videoPlayerUrl && videoPlayerItem"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click.self="closeVideoPlayer"
>
<div class="relative w-full max-w-4xl mx-4">
<!-- Close button -->
<button
class="absolute -top-10 right-0 text-white/60 hover:text-white transition-colors"
@click="closeVideoPlayer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Video element -->
<video
:src="videoPlayerUrl"
class="w-full rounded-xl"
controls
autoplay
/>
<!-- Info bar -->
<div class="mt-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-white">{{ videoPlayerItem.filename.split('/').pop() }}</p>
<p class="text-xs text-white/40">{{ formatSize(videoPlayerItem.size_bytes) }}</p>
</div>
<div v-if="videoPlayerPaid" class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-orange-500/15">
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span class="text-xs text-orange-400">10% preview - Buy to unlock full</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, Teleport } from 'vue'
import { ref, computed, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const props = defineProps<{
peerId?: string
@ -127,6 +274,15 @@ const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null)
const playing = ref<string | null>(null)
const purchaseError = ref<string | null>(null)
const previewUrls = reactive<Record<string, string>>({})
const audioPlayer = useAudioPlayer()
// Video player modal state
const videoPlayerItem = ref<CatalogItem | null>(null)
const videoPlayerUrl = ref<string | null>(null)
const videoPlayerPaid = ref(false)
const peerDisplayName = computed(() => {
if (currentPeer.value?.name) return currentPeer.value.name
@ -174,6 +330,36 @@ async function loadCatalog() {
}
}
// Load visual previews for image and video items when catalog loads
// Audio files don't need visual thumbnails they show a waveform icon
watch(catalogItems, async (items) => {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
for (const item of items) {
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) {
loadPreview(onion, item)
}
}
})
async function loadPreview(onion: string, item: CatalogItem) {
try {
const result = await rpcClient.call<{ data?: string; content_type?: string }>({
method: 'content.preview-peer',
params: { onion, content_id: item.id },
timeout: 30000,
})
if (result?.data) {
const mime = result.content_type || item.mime_type
const bytes = Uint8Array.from(atob(result.data), c => c.charCodeAt(0))
const blob = new Blob([bytes], { type: mime })
previewUrls[item.id] = URL.createObjectURL(blob)
}
} catch {
// Preview not available icon fallback is fine
}
}
function truncateDid(did: string): string {
if (did.length <= 24) return did
return did.slice(0, 16) + '...' + did.slice(-8)
@ -228,33 +414,162 @@ function accessBadgeClass(access: CatalogItem['access']): string {
return 'bg-white/10 text-white/50'
}
function canDownload(access: CatalogItem['access']): boolean {
return access === 'free' || access === 'peersonly'
function isMediaMime(mime: string): boolean {
return mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')
}
function isPlayable(mime: string): boolean {
return mime.startsWith('video/') || mime.startsWith('audio/')
}
function isPaidItem(access: CatalogItem['access']): boolean {
return typeof access === 'object' && 'paid' in access
}
function getItemPrice(access: CatalogItem['access']): number {
if (typeof access === 'object' && 'paid' in access) return access.paid.price_sats
return 0
}
async function downloadFile(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
downloading.value = item.id
purchaseError.value = null
try {
const result = await rpcClient.call<{ data?: string }>({
method: 'content.download-peer',
params: { onion, content_id: item.id },
timeout: 120000,
})
if (result?.data) {
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { type: item.mime_type })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = item.filename.split('/').pop() || item.filename
a.click()
URL.revokeObjectURL(url)
const price = getItemPrice(item.access)
if (price > 0) {
// Check ecash balance first
try {
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
method: 'wallet.ecash-balance',
})
const balance = balanceRes?.balance_sats ?? 0
if (balance < price) {
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet first.`
return
}
} catch {
// Balance check failed try the purchase anyway
}
// Paid download: mint ecash + download atomically
const result = await rpcClient.call<{ data?: string; error?: string; price_sats?: number }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price },
timeout: 120000,
})
if (result?.data) {
triggerDownload(result.data, item)
} else if (result?.error) {
purchaseError.value = `Payment failed: ${result.error}`
}
} else {
// Free / peers-only download
const result = await rpcClient.call<{ data?: string; error?: string; price_sats?: number }>({
method: 'content.download-peer',
params: { onion, content_id: item.id },
timeout: 120000,
})
if (result?.error === 'payment_required') {
purchaseError.value = `This content requires payment: ${result.price_sats ?? 0} sats`
return
}
if (result?.data) {
triggerDownload(result.data, item)
}
}
} catch (e: unknown) {
if (import.meta.env.DEV) console.warn('Download failed', e)
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
} finally {
downloading.value = null
}
}
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
async function playMedia(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
const paid = isPaidItem(item.access)
// If we already have a preview blob URL, use it
const existingUrl = previewUrls[item.id]
if (existingUrl) {
if (item.mime_type.startsWith('audio/')) {
audioPlayer.play(existingUrl, item.filename.split('/').pop() || item.filename)
} else if (item.mime_type.startsWith('video/')) {
videoPlayerItem.value = item
videoPlayerUrl.value = existingUrl
videoPlayerPaid.value = paid
}
return
}
// Download the content (preview for paid, full for free)
playing.value = item.id
try {
const method = paid ? 'content.preview-peer' : 'content.download-peer'
const result = await rpcClient.call<{ data?: string; content_type?: string }>({
method,
params: { onion, content_id: item.id },
timeout: 120000,
})
if (result?.data) {
const mime = result.content_type || item.mime_type
const bytes = Uint8Array.from(atob(result.data), c => c.charCodeAt(0))
const blob = new Blob([bytes], { type: mime })
const blobUrl = URL.createObjectURL(blob)
previewUrls[item.id] = blobUrl
if (item.mime_type.startsWith('audio/')) {
audioPlayer.play(blobUrl, item.filename.split('/').pop() || item.filename)
} else if (item.mime_type.startsWith('video/')) {
videoPlayerItem.value = item
videoPlayerUrl.value = blobUrl
videoPlayerPaid.value = paid
}
}
} catch {
purchaseError.value = 'Failed to load media for playback'
} finally {
playing.value = null
}
}
function closeVideoPlayer() {
videoPlayerItem.value = null
videoPlayerUrl.value = null
videoPlayerPaid.value = false
}
function triggerDownload(base64Data: string, item: CatalogItem) {
const blob = new Blob(
[Uint8Array.from(atob(base64Data), c => c.charCodeAt(0))],
{ type: item.mime_type },
)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = item.filename.split('/').pop() || item.filename
a.click()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1730,6 +1730,9 @@ LNDCONF
echo " Removed X-Frame-Options from IndeedHub"
fi
# Fix Host header for NIP-98 auth — $host strips port, $http_host preserves it
podman exec indeedhub sh -c "sed -i '"'"'s/proxy_set_header Host \$host;/proxy_set_header Host \$http_host;/g'"'"' /etc/nginx/conf.d/default.conf" 2>/dev/null && CHANGED=true && echo " Fixed Host header for NIP-98 auth" || true
# Inject nostr-provider.js for NIP-07 signing
if ! podman exec indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then
podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null