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

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

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

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

355 lines
12 KiB
Rust

//! 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);
}
}