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
co-authored by Claude Opus 4.6
parent 90506ee52c
commit ffd57ad29d
25 changed files with 4740 additions and 480 deletions
@@ -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);
}
}
+281
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());
}
}
+235
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");
}
}
+39
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;
+362
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");
}
}
+436
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);
}
}