Replace fragmented random key generation with a single 24-word BIP-39 mnemonic that deterministically derives all node keys: Ed25519 (DID), secp256k1 (Nostr/Bitcoin), BIP-84 xprv (Bitcoin Core), and LND aezeed entropy. New onboarding flow: seed generate → word verification → identity naming. Restore path enabled via 24-word entry. Includes seed RPC handlers, mock backend support, LND/Bitcoin Core wallet-from-seed integration, and UI polish across settings and discover views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
277 lines
9.5 KiB
Rust
277 lines
9.5 KiB
Rust
//! Networking profit tracking.
|
|
//!
|
|
//! Aggregates earnings from content sales (ecash) and Lightning routing fees.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use tokio::fs;
|
|
use super::ecash;
|
|
|
|
const PROFITS_FILE: &str = "wallet/profits.json";
|
|
|
|
/// Earnings breakdown by source.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ProfitsSummary {
|
|
/// Total earnings in sats from all sources.
|
|
pub total_sats: u64,
|
|
/// Earnings from ecash content sales.
|
|
pub content_sales_sats: u64,
|
|
/// Earnings from Lightning routing fees.
|
|
pub routing_fees_sats: u64,
|
|
/// Recent earning entries (newest first).
|
|
pub recent: Vec<ProfitEntry>,
|
|
}
|
|
|
|
/// A single profit event.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProfitEntry {
|
|
pub source: ProfitSource,
|
|
pub amount_sats: u64,
|
|
pub timestamp: String,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ProfitSource {
|
|
ContentSale,
|
|
RoutingFee,
|
|
}
|
|
|
|
/// Load profits summary from disk.
|
|
pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
|
let path = data_dir.join(PROFITS_FILE);
|
|
if !path.exists() {
|
|
return Ok(ProfitsSummary::default());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.context("Failed to read profits file")?;
|
|
let summary: ProfitsSummary = serde_json::from_str(&content).unwrap_or_default();
|
|
Ok(summary)
|
|
}
|
|
|
|
/// Save profits summary to disk.
|
|
#[allow(dead_code)]
|
|
pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> {
|
|
let dir = data_dir.join("wallet");
|
|
fs::create_dir_all(&dir)
|
|
.await
|
|
.context("Failed to create wallet directory")?;
|
|
let path = data_dir.join(PROFITS_FILE);
|
|
let content = serde_json::to_string_pretty(summary)
|
|
.context("Failed to serialize profits")?;
|
|
fs::write(&path, content)
|
|
.await
|
|
.context("Failed to write profits file")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Record a single content sale, updating totals and the recent entries list.
|
|
#[allow(dead_code)]
|
|
pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description: &str) -> Result<()> {
|
|
let mut summary = load_profits(data_dir).await?;
|
|
let entry = ProfitEntry {
|
|
source: ProfitSource::ContentSale,
|
|
amount_sats,
|
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
description: description.to_string(),
|
|
};
|
|
summary.recent.insert(0, entry);
|
|
if summary.recent.len() > 100 {
|
|
summary.recent.truncate(100);
|
|
}
|
|
summary.content_sales_sats += amount_sats;
|
|
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
|
|
save_profits(data_dir, &summary).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Compute a full profits summary including ecash receive transactions.
|
|
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
|
let mut summary = load_profits(data_dir).await?;
|
|
|
|
// Also count ecash "receive" transactions as content sales revenue
|
|
let wallet = ecash::load_wallet(data_dir).await?;
|
|
let ecash_received: u64 = wallet
|
|
.transactions
|
|
.iter()
|
|
.filter(|tx| matches!(tx.tx_type, ecash::TransactionType::Receive))
|
|
.map(|tx| tx.amount_sats)
|
|
.sum();
|
|
|
|
// Use the higher of tracked profits or ecash receives as content sales
|
|
if ecash_received > summary.content_sales_sats {
|
|
summary.content_sales_sats = ecash_received;
|
|
}
|
|
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
|
|
|
|
Ok(summary)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_profits_summary_default() {
|
|
let summary = ProfitsSummary::default();
|
|
assert_eq!(summary.total_sats, 0);
|
|
assert_eq!(summary.content_sales_sats, 0);
|
|
assert_eq!(summary.routing_fees_sats, 0);
|
|
assert!(summary.recent.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_profits_returns_default_when_missing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let summary = load_profits(tmp.path()).await.unwrap();
|
|
assert_eq!(summary.total_sats, 0);
|
|
assert_eq!(summary.content_sales_sats, 0);
|
|
assert_eq!(summary.routing_fees_sats, 0);
|
|
assert!(summary.recent.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_and_load_profits_roundtrip() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let summary = ProfitsSummary {
|
|
total_sats: 5000,
|
|
content_sales_sats: 3000,
|
|
routing_fees_sats: 2000,
|
|
recent: vec![ProfitEntry {
|
|
source: ProfitSource::ContentSale,
|
|
amount_sats: 3000,
|
|
timestamp: "2025-06-01T00:00:00Z".to_string(),
|
|
description: "Test sale".to_string(),
|
|
}],
|
|
};
|
|
|
|
save_profits(tmp.path(), &summary).await.unwrap();
|
|
let loaded = load_profits(tmp.path()).await.unwrap();
|
|
assert_eq!(loaded.total_sats, 5000);
|
|
assert_eq!(loaded.content_sales_sats, 3000);
|
|
assert_eq!(loaded.routing_fees_sats, 2000);
|
|
assert_eq!(loaded.recent.len(), 1);
|
|
assert_eq!(loaded.recent[0].amount_sats, 3000);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_profits_creates_wallet_dir() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let wallet_dir = tmp.path().join("wallet");
|
|
assert!(!wallet_dir.exists());
|
|
|
|
save_profits(tmp.path(), &ProfitsSummary::default()).await.unwrap();
|
|
assert!(wallet_dir.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_record_content_sale() {
|
|
let tmp = TempDir::new().unwrap();
|
|
record_content_sale(tmp.path(), 500, "First sale").await.unwrap();
|
|
|
|
let summary = load_profits(tmp.path()).await.unwrap();
|
|
assert_eq!(summary.total_sats, 500);
|
|
assert_eq!(summary.content_sales_sats, 500);
|
|
assert_eq!(summary.routing_fees_sats, 0);
|
|
assert_eq!(summary.recent.len(), 1);
|
|
assert_eq!(summary.recent[0].amount_sats, 500);
|
|
assert_eq!(summary.recent[0].description, "First sale");
|
|
assert!(matches!(summary.recent[0].source, ProfitSource::ContentSale));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_record_multiple_content_sales() {
|
|
let tmp = TempDir::new().unwrap();
|
|
record_content_sale(tmp.path(), 100, "Sale 1").await.unwrap();
|
|
record_content_sale(tmp.path(), 200, "Sale 2").await.unwrap();
|
|
record_content_sale(tmp.path(), 300, "Sale 3").await.unwrap();
|
|
|
|
let summary = load_profits(tmp.path()).await.unwrap();
|
|
assert_eq!(summary.total_sats, 600);
|
|
assert_eq!(summary.content_sales_sats, 600);
|
|
assert_eq!(summary.recent.len(), 3);
|
|
// Newest first (inserted at index 0)
|
|
assert_eq!(summary.recent[0].description, "Sale 3");
|
|
assert_eq!(summary.recent[1].description, "Sale 2");
|
|
assert_eq!(summary.recent[2].description, "Sale 1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_record_content_sale_truncates_at_100() {
|
|
let tmp = TempDir::new().unwrap();
|
|
for i in 0..110 {
|
|
record_content_sale(tmp.path(), 1, &format!("Sale {}", i))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let summary = load_profits(tmp.path()).await.unwrap();
|
|
assert_eq!(summary.recent.len(), 100);
|
|
assert_eq!(summary.total_sats, 110);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_networking_profits_empty() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
|
assert_eq!(summary.total_sats, 0);
|
|
assert_eq!(summary.content_sales_sats, 0);
|
|
assert_eq!(summary.routing_fees_sats, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_networking_profits_includes_ecash_receives() {
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
// Simulate receiving ecash tokens
|
|
ecash::receive_token(tmp.path(), "cashuSend_500_uuid1_1700000000")
|
|
.await
|
|
.unwrap();
|
|
ecash::receive_token(tmp.path(), "cashuSend_300_uuid2_1700000001")
|
|
.await
|
|
.unwrap();
|
|
|
|
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
|
// ecash receives (800) should be reflected as content sales
|
|
assert_eq!(summary.content_sales_sats, 800);
|
|
assert_eq!(summary.total_sats, 800);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_networking_profits_uses_higher_of_tracked_or_ecash() {
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
// Record a larger tracked profit
|
|
record_content_sale(tmp.path(), 2000, "Big sale").await.unwrap();
|
|
// Receive a smaller ecash amount
|
|
ecash::receive_token(tmp.path(), "cashuSend_100_uuid_170")
|
|
.await
|
|
.unwrap();
|
|
|
|
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
|
// Should use tracked (2000) since it's larger than ecash receives (100)
|
|
assert_eq!(summary.content_sales_sats, 2000);
|
|
assert_eq!(summary.total_sats, 2000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_profit_source_serialization() {
|
|
let entry = ProfitEntry {
|
|
source: ProfitSource::RoutingFee,
|
|
amount_sats: 42,
|
|
timestamp: "2025-01-01T00:00:00Z".to_string(),
|
|
description: "routing".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
assert!(json.contains("\"routing_fee\""));
|
|
|
|
let parsed: ProfitEntry = serde_json::from_str(&json).unwrap();
|
|
assert!(matches!(parsed.source, ProfitSource::RoutingFee));
|
|
}
|
|
}
|