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:
co-authored by
Claude Opus 4.6
parent
90506ee52c
commit
ffd57ad29d
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user