nvpn 4.1.9

CLI and daemon for Nostr VPN private mesh networks
Documentation
    use super::*;

    fn pending_send(
        operation_id: &str,
        amount_sat: u64,
        created_at_unix: u64,
    ) -> cashu_service::CashuWalletActivityEntry {
        cashu_service::CashuWalletActivityEntry {
            id: format!("activity-{operation_id}"),
            kind: cashu_service::CashuWalletActivityKind::TokenSend,
            status: cashu_service::CashuWalletActivityStatus::Pending,
            mint_url: "https://mint.example/Bitcoin".to_string(),
            unit: "sat".to_string(),
            amount_sat,
            fee_sat: Some(0),
            created_at_unix,
            expires_at_unix: None,
            quote_id: None,
            operation_id: Some(operation_id.to_string()),
            payment_request: None,
            token: Some("cashu-token-redacted-in-test".to_string()),
        }
    }

    fn expired_unfunded_buyer_channel(
        created_at_unix: u64,
    ) -> nostr_vpn_core::paid_route_store::PaidRouteChannelRecord {
        nostr_vpn_core::paid_route_store::PaidRouteChannelRecord {
            channel_id: "route-channel".to_string(),
            offer_id: "offer".to_string(),
            role: nostr_vpn_core::paid_route_store::PaidRouteChannelRole::Buyer,
            status: nostr_vpn_core::paid_route_store::PaidRouteLifecycleStatus::Probing,
            payment: nostr_vpn_core::paid_routes::PaidRoutePaymentState {
                capacity_sat: 20,
                cashu_unit: "sat".to_string(),
                ..Default::default()
            },
            accepted_terms: None,
            mint_url: "https://mint.example/Bitcoin/".to_string(),
            counterparty_npub: "npub-test".to_string(),
            created_at_unix,
            expires_at_unix: created_at_unix + 60,
            updated_at_unix: created_at_unix,
            error: String::new(),
        }
    }

    fn recoverable_unfunded_route_store(
        expires_at_unix: u64,
    ) -> nostr_vpn_core::paid_route_store::PaidRouteStore {
        use nostr_vpn_core::paid_route_store::{
            PaidRouteLeaseRecord, PaidRouteQuoteRecord, PaidRouteSessionRecord,
        };
        use nostr_vpn_core::paid_routes::{
            PaidRouteLease, PaidRoutePaymentMode, PaidRoutePaymentState, PaidRouteQuote,
            PaidRouteSession, PaidRouteUsage,
        };

        let mut store = nostr_vpn_core::paid_route_store::PaidRouteStore::default();
        let mut channel = expired_unfunded_buyer_channel(1_000);
        channel.expires_at_unix = expires_at_unix;
        channel.status = nostr_vpn_core::paid_route_store::PaidRouteLifecycleStatus::Failed;
        channel.payment.channel_id = "route-channel".to_string();
        channel.counterparty_npub = "npub-seller".to_string();
        store.channels.insert("route-channel".to_string(), channel);
        store.quotes.insert(
            "quote-1".to_string(),
            PaidRouteQuoteRecord {
                quote: PaidRouteQuote {
                    quote_id: "quote-1".to_string(),
                    offer_id: "offer".to_string(),
                    payment_mode: PaidRoutePaymentMode::CashuSpilman,
                    channel_capacity_sat: 20,
                    expires_at_unix,
                    receiver_pubkey_hex: "22".repeat(32),
                },
                created_at_unix: 1_000,
                updated_at_unix: 1_000,
            },
        );
        store.leases.insert(
            "lease-1".to_string(),
            PaidRouteLeaseRecord {
                lease: PaidRouteLease {
                    lease_id: "lease-1".to_string(),
                    offer_id: "offer".to_string(),
                    quote_id: "quote-1".to_string(),
                    buyer_npub: "npub-buyer".to_string(),
                    starts_at_unix: 1_000,
                    expires_at_unix,
                },
                status: nostr_vpn_core::paid_route_store::PaidRouteLifecycleStatus::Failed,
                created_at_unix: 1_000,
                updated_at_unix: 1_000,
            },
        );
        store.sessions.insert(
            "session-1".to_string(),
            PaidRouteSessionRecord {
                session: PaidRouteSession {
                    session_id: "session-1".to_string(),
                    lease_id: "lease-1".to_string(),
                    usage: PaidRouteUsage::default(),
                    payment: PaidRoutePaymentState {
                        mode: PaidRoutePaymentMode::CashuSpilman,
                        channel_id: "route-channel".to_string(),
                        cashu_unit: "sat".to_string(),
                        capacity_sat: 20,
                        ..Default::default()
                    },
                    realized_exit_ip: None,
                    observed_country_code: None,
                    observed_asn: None,
                    quality: None,
                },
                created_at_unix: 1_000,
                updated_at_unix: 1_000,
            },
        );
        store
    }

    #[test]
    fn startup_recovery_selects_unexpired_unattached_buyer_channels() {
        let store = recoverable_unfunded_route_store(10_000);

        let requests = legacy_route_open_recovery_requests(&store, 2_000);

        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].0, "session-1");
        assert_eq!(
            requests[0].1.client_request_id.as_deref(),
            Some("session-1")
        );
        assert_eq!(requests[0].1.route_created_at_unix, Some(1_000));
        assert_eq!(requests[0].1.capacity_sat, 20);
        assert_eq!(requests[0].1.mint_url, "https://mint.example/Bitcoin/");
    }

    #[test]
    fn startup_recovery_ignores_expired_channels() {
        let store = recoverable_unfunded_route_store(1_500);

        assert!(legacy_route_open_recovery_requests(&store, 2_000).is_empty());
    }

    #[test]
    fn startup_recovery_retries_signature_only_channel_state() {
        let mut store = recoverable_unfunded_route_store(10_000);
        let payment = cashu_service::CashuSpilmanPayment {
            channel_id: "route-channel".to_string(),
            balance: 1,
            signature: "signed-update".to_string(),
            params: None,
            funding_proofs: None,
        };
        let mut session_payment = store.sessions["session-1"].session.payment.clone();
        session_payment.paid_msat = 1_000;
        session_payment.cashu_spilman_payment = Some(payment.clone());
        store
            .sessions
            .get_mut("session-1")
            .expect("buyer session")
            .session
            .payment = session_payment;
        let channel = store
            .channels
            .get_mut("route-channel")
            .expect("buyer channel");
        channel.payment.paid_msat = 1_000;
        channel.payment.cashu_spilman_payment = Some(payment);

        let requests = legacy_route_open_recovery_requests(&store, 2_000);

        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].0, "session-1");
        assert_eq!(requests[0].1.opening_paid_msat, 1_000);
    }

    #[test]
    fn upgrade_reclaims_only_pending_send_matching_expired_unfunded_channel() {
        let mut store = nostr_vpn_core::paid_route_store::PaidRouteStore::default();
        store.channels.insert(
            "route-channel".to_string(),
            expired_unfunded_buyer_channel(1_000),
        );
        let activity = vec![
            pending_send("matching", 20, 1_002),
            pending_send("manual-send", 20, 900),
            pending_send("wrong-amount", 21, 1_002),
        ];

        let candidates = legacy_orphaned_channel_sends(&store, &activity, 2_000);

        assert_eq!(
            candidates
                .iter()
                .map(|candidate| candidate.operation_id.as_str())
                .collect::<Vec<_>>(),
            vec!["matching"]
        );
    }

    #[test]
    fn failed_channel_cleanup_selects_only_sends_created_by_that_attempt() {
        let pending_before = BTreeSet::from(["already-pending".to_string()]);
        let mut completed = pending_send("completed", 20, 1_003);
        completed.status = cashu_service::CashuWalletActivityStatus::Complete;
        let activity_after = vec![
            pending_send("already-pending", 20, 900),
            pending_send("new-channel-send", 20, 1_002),
            completed,
        ];

        let created = newly_pending_cashu_sends(&pending_before, &activity_after);

        assert_eq!(created.len(), 1);
        assert_eq!(created[0].operation_id, "new-channel-send");
    }

    #[derive(Debug)]
    struct FixedSeedStore;

    impl cashu_service::CashuWalletSeedStore for FixedSeedStore {
        fn load_seed(&self) -> Result<Option<[u8; 64]>> {
            Ok(Some([42; 64]))
        }

        fn store_seed(&self, seed: &[u8; 64]) -> Result<()> {
            if *seed != [42; 64] {
                return Err(anyhow!("test wallet attempted to replace its seed"));
            }
            Ok(())
        }
    }

    struct TestDirectory(PathBuf);

    impl TestDirectory {
        fn new() -> Self {
            let nonce = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos();
            let path = std::env::temp_dir().join(format!(
                "nvpn-daemon-cashu-wallet-{}-{nonce}",
                std::process::id()
            ));
            fs::create_dir_all(&path).expect("create daemon wallet test directory");
            Self(path)
        }
    }

    impl Drop for TestDirectory {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[tokio::test]
    async fn daemon_is_the_single_cdk_sqlite_owner_and_handles_private_requests() {
        let directory = TestDirectory::new();
        let config_path = directory.0.join("config.toml");
        fs::write(&config_path, "").expect("create config ownership anchor");
        let worker = DaemonCashuWalletWorker::start(config_path.clone())
            .expect("start daemon CDK SQLite wallet worker");
        let second_owner =
            CashuWalletService::open_with_seed_store(&directory.0, Arc::new(FixedSeedStore))
                .await
                .expect_err("a second wallet owner must be rejected");
        assert!(second_owner.to_string().contains("already in use"));

        let in_process_overview = decode_daemon_cashu_wallet_overview(
            request_daemon_cashu_wallet_worker(
                &config_path,
                DaemonCashuWalletCommand::Overview {
                    refresh_quotes: false,
                },
            )
            .await
            .expect("daemon process can request its wallet worker"),
        )
        .expect("decode in-process wallet overview");
        assert!(in_process_overview.entries.is_empty());

        let request = DaemonCashuWalletRequest {
            id: "00000000000000000000000000000042".to_string(),
            command: DaemonCashuWalletCommand::Overview {
                refresh_quotes: false,
            },
        };
        let request_path =
            cashu_wallet_request_dir(&config_path).join("00000000000000000000000000000042.json");
        nostr_vpn_core::config::write_private_file_preserving_user_owner(
            &request_path,
            &serde_json::to_vec(&request).expect("encode wallet request"),
        )
        .expect("write wallet request");

        let response_path =
            cashu_wallet_response_dir(&config_path).join("00000000000000000000000000000042.json");
        let deadline = Instant::now() + Duration::from_secs(2);
        while (!response_path.exists() || daemon_cashu_wallet_requests_pending(&config_path))
            && Instant::now() < deadline
        {
            tokio::time::sleep(Duration::from_millis(10)).await;
        }
        assert!(response_path.exists(), "daemon wallet worker did not reply");
        assert!(!daemon_cashu_wallet_requests_pending(&config_path));
        let response: DaemonCashuWalletResponse =
            serde_json::from_slice(&fs::read(&response_path).expect("read daemon wallet response"))
                .expect("decode daemon wallet response");
        assert!(response.error.is_none());
        let overview =
            decode_daemon_cashu_wallet_overview(response.result.expect("wallet overview response"))
                .expect("decode wallet overview");
        assert!(overview.entries.is_empty());

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            assert_eq!(
                fs::metadata(response_path)
                    .expect("wallet response metadata")
                    .permissions()
                    .mode()
                    & 0o777,
                0o600
            );
        }
        worker.stop();
    }