nvpn 4.1.6

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

#[path = "paid_exit/automatic.rs"]
mod automatic;
pub(crate) use automatic::*;
#[path = "paid_exit/refunds.rs"]
mod refunds;
pub(in crate::session_runtime) use refunds::PaidExitBuyerRefundRuntime;

pub(super) const PAID_EXIT_DAEMON_STREAM_PAYMENT_MIN_INCREMENT_MSAT: u64 = 1;
pub(super) const PAID_EXIT_DAEMON_STREAM_PAYMENT_LIMIT: usize = 4;
pub(super) const PAID_EXIT_SESSION_OPEN_RETRY_SECS: u64 = 5;

#[derive(Debug, Default)]
pub(super) struct PaidExitApplySessionOpensResult {
    pub(super) received_count: usize,
    pub(super) applied_count: usize,
    pub(super) error_count: usize,
    pub(super) changed: bool,
    pub(super) acknowledgments: Vec<(String, String)>,
}

pub(super) async fn send_selected_paid_exit_session_open(
    runtime: &crate::fips_private_mesh::FipsPrivateTunnelRuntime,
    app: &AppConfig,
    config_path: &Path,
    now_unix: u64,
) -> Result<bool> {
    let Some(seller_pubkey) = app.public_paid_exit_node_pubkey_hex() else {
        return Ok(false);
    };
    let buyer_npub = app
        .nostr_keys()?
        .public_key()
        .to_bech32()
        .context("failed to encode paid route buyer npub")?;
    let buyer_tunnel_ip = derive_mesh_tunnel_ip(
        &app.effective_network_id(),
        &app.nostr_keys()?.public_key().to_hex(),
    )
    .ok_or_else(|| anyhow!("failed to derive paid route buyer tunnel IP"))?;
    let store = load_paid_route_store(&paid_route_store_file_path(config_path))?;
    let Some(open) = store.buyer_session_open_for_seller(
        &seller_pubkey,
        &buyer_npub,
        &buyer_tunnel_ip,
        now_unix,
    )?
    else {
        return Ok(false);
    };
    runtime
        .send_paid_route_session_open(&seller_pubkey, open)
        .await?;
    Ok(true)
}

pub(super) fn apply_paid_exit_session_opens(
    app: &AppConfig,
    config_path: &Path,
    opens: Vec<(String, PaidRouteSessionOpen)>,
) -> Result<PaidExitApplySessionOpensResult> {
    if opens.is_empty() {
        return Ok(PaidExitApplySessionOpensResult::default());
    }
    if !app.paid_exit.enabled {
        return Err(anyhow!("paid exit selling is disabled"));
    }
    let seller_npub = app
        .nostr_keys()?
        .public_key()
        .to_bech32()
        .context("failed to encode paid route seller npub")?;
    let store_path = paid_route_store_file_path(config_path);
    let mut store = load_paid_route_store(&store_path)?;
    let mut result = PaidExitApplySessionOpensResult {
        received_count: opens.len(),
        ..PaidExitApplySessionOpensResult::default()
    };
    for (buyer_pubkey, open) in opens {
        match store.apply_seller_session_open(ApplyPaidRouteSellerSessionOpenRequest {
            open,
            authenticated_buyer_pubkey: buyer_pubkey.clone(),
            seller_npub: seller_npub.clone(),
            config: app.paid_exit.clone(),
            now_unix: unix_timestamp(),
        }) {
            Ok(applied) => {
                result.applied_count += 1;
                result.changed |= applied.changed;
                result
                    .acknowledgments
                    .push((buyer_pubkey, applied.lease_id));
            }
            Err(error) => {
                result.error_count += 1;
                eprintln!(
                    "paid-exit: rejected authenticated free-probe open from {buyer_pubkey}: {error}"
                );
            }
        }
    }
    if result.changed {
        write_paid_route_store(&store_path, &store)?;
    }
    Ok(result)
}

pub(super) struct PaidExitMeshEventContext<'a> {
    pub(super) runtime: &'a mut crate::fips_private_mesh::FipsPrivateTunnelRuntime,
    pub(super) app: &'a AppConfig,
    pub(super) config_path: &'a Path,
    pub(super) network_id: &'a str,
    pub(super) underlay_interface: Option<&'a str>,
    pub(super) underlay_interface_mtu: Option<u32>,
    pub(super) own_pubkey: Option<&'a str>,
    pub(super) vpn_status: &'a mut String,
    pub(super) spilman_receiver: Option<&'a FileSpilmanPaymentReceiver>,
    pub(super) spilman_receiver_error: Option<&'a str>,
}

pub(super) async fn handle_paid_exit_mesh_events(
    context: PaidExitMeshEventContext<'_>,
    drained: &mut DrainedFipsMeshEvents,
) {
    let PaidExitMeshEventContext {
        runtime,
        app,
        config_path,
        network_id,
        underlay_interface,
        underlay_interface_mtu,
        own_pubkey,
        vpn_status,
        spilman_receiver,
        spilman_receiver_error,
    } = context;

    let session_opens = std::mem::take(&mut drained.paid_route_session_opens);
    if !session_opens.is_empty() {
        match apply_paid_exit_session_opens(app, config_path, session_opens) {
            Ok(result) => {
                eprintln!(
                    "paid-exit: authenticated session opens received={} applied={} errors={} changed={}",
                    result.received_count, result.applied_count, result.error_count, result.changed
                );
                if result.changed
                    && let Err(error) = refresh_fips_tunnel_config(
                        runtime,
                        app,
                        config_path,
                        network_id,
                        underlay_interface,
                        underlay_interface_mtu,
                        own_pubkey,
                    )
                    .await
                {
                    *vpn_status =
                        format!("paid-exit free-probe admission refresh failed ({error})");
                }
                for (buyer_pubkey, lease_id) in result.acknowledgments {
                    if let Err(error) = runtime
                        .send_paid_route_session_open_ack(&buyer_pubkey, lease_id.clone())
                        .await
                    {
                        eprintln!(
                            "paid-exit: failed to acknowledge session open {lease_id}: {error}"
                        );
                    }
                }
            }
            Err(error) => {
                eprintln!("paid-exit: failed to apply authenticated session open: {error}")
            }
        }
    }

    for (seller_pubkey, lease_id) in std::mem::take(&mut drained.paid_route_session_open_acks) {
        match acknowledge_paid_exit_session_open(config_path, &seller_pubkey, &lease_id) {
            Ok(true) => {
                eprintln!("paid-exit: seller admitted session {lease_id}");
                if let Err(error) = refresh_fips_tunnel_config(
                    runtime,
                    app,
                    config_path,
                    network_id,
                    underlay_interface,
                    underlay_interface_mtu,
                    own_pubkey,
                )
                .await
                {
                    *vpn_status =
                        format!("paid-exit admission acknowledgment refresh failed ({error})");
                }
            }
            Ok(false) => {}
            Err(error) => eprintln!(
                "paid-exit: rejected session acknowledgment from {seller_pubkey}: {error}"
            ),
        }
    }

    for (seller_pubkey, id) in std::mem::take(&mut drained.paid_route_payment_acks) {
        match acknowledge_paid_exit_payment(config_path, &seller_pubkey, &id) {
            Ok(true) => {
                eprintln!("paid-exit: seller acknowledged direct FIPS payment {id}");
                if let Err(error) = refresh_fips_tunnel_config(
                    runtime,
                    app,
                    config_path,
                    network_id,
                    underlay_interface,
                    underlay_interface_mtu,
                    own_pubkey,
                )
                .await
                {
                    *vpn_status =
                        format!("paid-exit payment acknowledgment refresh failed ({error})");
                }
            }
            Ok(false) => {}
            Err(error) => {
                eprintln!("paid-exit: rejected direct FIPS payment acknowledgment: {error}")
            }
        }
    }

    let payments = std::mem::take(&mut drained.paid_route_payments);
    if payments.is_empty() {
        return;
    }
    match paid_exit_apply_fips_payments(
        app,
        config_path,
        payments,
        spilman_receiver,
        spilman_receiver_error,
    ) {
        Ok(result) => {
            eprintln!(
                "paid-exit: direct FIPS payments received={} applied={} errors={} changed={} receiver={}",
                result.received_count,
                result.applied_count,
                result.error_count,
                result.changed,
                result.spilman_receiver_processing
            );
            if result.changed
                && let Err(error) = refresh_fips_tunnel_config(
                    runtime,
                    app,
                    config_path,
                    network_id,
                    underlay_interface,
                    underlay_interface_mtu,
                    own_pubkey,
                )
                .await
            {
                *vpn_status = format!("paid-exit payment refresh failed ({error})");
            }
            for (buyer_pubkey, id) in result.acknowledgments {
                if let Err(error) = runtime
                    .send_paid_route_payment_ack(&buyer_pubkey, id.clone())
                    .await
                {
                    eprintln!("paid-exit: failed to acknowledge direct FIPS payment {id}: {error}");
                }
            }
        }
        Err(error) => eprintln!("paid-exit: failed to apply direct FIPS payment: {error}"),
    }
}

pub(super) fn acknowledge_paid_exit_session_open(
    config_path: &Path,
    seller_pubkey: &str,
    lease_id: &str,
) -> Result<bool> {
    let store_path = paid_route_store_file_path(config_path);
    let mut store = load_paid_route_store(&store_path)?;
    let changed =
        store.acknowledge_buyer_session_open(seller_pubkey, lease_id, unix_timestamp())?;
    if changed {
        write_paid_route_store(&store_path, &store)?;
    }
    Ok(changed)
}

pub(super) fn flush_fips_paid_route_usage(
    runtime: &crate::fips_private_mesh::FipsPrivateTunnelRuntime,
    app: &AppConfig,
    config_path: &Path,
    now_unix: u64,
    active_millis_delta: u64,
) -> Result<PaidExitUsageFlush> {
    let store_path = paid_route_store_file_path(config_path);
    let mut store = load_paid_route_store(&store_path)?;
    let mut changed = false;
    let seller_admission_routing_before = if app.paid_exit.enabled {
        paid_route_seller_admission_routing_signature(
            &store.seller_admissions(&app.paid_exit, now_unix),
        )
    } else {
        Vec::new()
    };

    let mut buyer_delta = PaidRouteUsage::default();
    if let Some(seller_pubkey) = app.public_paid_exit_node_pubkey_hex() {
        let mut usage_delta = runtime.drain_paid_route_usage(&seller_pubkey)?;
        usage_delta.active_millis = usage_delta
            .active_millis
            .saturating_add(active_millis_delta);
        buyer_delta = usage_delta.clone();
        if !usage_delta.is_empty() {
            changed |= store
                .record_buyer_usage(RecordPaidRouteBuyerUsageRequest {
                    seller_pubkey,
                    usage_delta,
                    now_unix,
                })?
                .is_some_and(|result| result.changed);
        }
    }

    if app.paid_exit.enabled {
        for admission in store.seller_admissions(&app.paid_exit, now_unix) {
            let mut usage_delta = runtime.drain_paid_route_usage(&admission.buyer_pubkey)?;
            if admission.allow_routing {
                usage_delta.active_millis = usage_delta
                    .active_millis
                    .saturating_add(active_millis_delta);
            }
            if usage_delta.is_empty() {
                continue;
            }
            changed |= store
                .record_seller_usage(RecordPaidRouteSellerUsageRequest {
                    buyer_pubkey: admission.buyer_pubkey,
                    config: app.paid_exit.clone(),
                    usage_delta,
                    now_unix,
                })?
                .is_some_and(|result| result.changed);
        }
    }

    if changed {
        write_paid_route_store(&store_path, &store)?;
    }
    let seller_admission_routing_after = if changed && app.paid_exit.enabled {
        paid_route_seller_admission_routing_signature(
            &store.seller_admissions(&app.paid_exit, now_unix),
        )
    } else {
        seller_admission_routing_before.clone()
    };
    Ok(PaidExitUsageFlush {
        seller_admission_changed: seller_admission_routing_after != seller_admission_routing_before,
        buyer_delta,
    })
}

fn paid_route_seller_admission_routing_signature(
    admissions: &[nostr_vpn_core::paid_route_store::PaidRouteSellerAdmission],
) -> Vec<(String, String, bool)> {
    admissions
        .iter()
        .map(|admission| {
            (
                admission.buyer_pubkey.clone(),
                admission.session_id.clone(),
                admission.allow_routing,
            )
        })
        .collect()
}