nvpn 4.1.13

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

pub(super) async fn fund_automatic_paid_exit(
    app: &AppConfig,
    config_path: &Path,
    session_id: &str,
    now_unix: u64,
) -> Result<StreamingRoutePaymentEnvelope> {
    if !PaidExitAutomaticBuyer::enabled(app) {
        return Err(anyhow!(
            "automatic paid exit funding cancelled by internet mode"
        ));
    }
    fund_paid_exit_session(app, config_path, session_id, now_unix).await
}

pub(crate) async fn fund_paid_exit_session(
    app: &AppConfig,
    config_path: &Path,
    session_id: &str,
    now_unix: u64,
) -> Result<StreamingRoutePaymentEnvelope> {
    let store_path = paid_route_store_file_path(config_path);
    let store = load_paid_route_store(&store_path)?;
    let session = store
        .sessions
        .get(session_id)
        .cloned()
        .ok_or_else(|| anyhow!("automatic paid exit session {session_id} does not exist"))?;
    let lease = store
        .leases
        .get(&session.session.lease_id)
        .cloned()
        .ok_or_else(|| anyhow!("automatic paid exit session has no lease"))?;
    let channel = store
        .channels
        .get(&session.session.payment.channel_id)
        .cloned()
        .ok_or_else(|| anyhow!("automatic paid exit session has no channel"))?;
    let quote = store
        .quotes
        .get(&lease.lease.quote_id)
        .cloned()
        .ok_or_else(|| anyhow!("automatic paid exit session has no quote"))?;
    let retry_at = store.buyer_session_funding_retry_at(session_id);
    if retry_at == u64::MAX {
        return Err(anyhow!(
            "More wallet funds are needed to cover channel credit and mint fees"
        ));
    }
    if now_unix < retry_at {
        return Err(
            anyhow::Error::new(cashu_service::MintRetryAfter(retry_at - now_unix))
                .context("Cashu mint recovery is waiting before another funding attempt"),
        );
    }
    let mint_url = channel.mint_url.clone();
    let result = crate::cashu_wallet_daemon::request_daemon_cashu_wallet_worker(
        config_path,
        crate::cashu_wallet_daemon::DaemonCashuWalletCommand::OpenSpilmanChannel {
            request: StreamingRouteOpenCashuSpilmanChannelFromWalletRequest {
                mint_url: channel.mint_url,
                receiver_pubkey_hex: quote.quote.receiver_pubkey_hex,
                capacity_sat: session.session.payment.capacity_sat,
                expiry_unix: channel.expires_at_unix,
                max_amount_per_output: 0,
                unit: "sat".to_string(),
                // Credit the first sat toward traffic. A refundable
                // zero-payment deposit is not payment for a new probe.
                opening_paid_msat: 1_000,
                keyset_id: None,
                keyset_info_json: None,
                client_request_id: Some(session_id.to_string()),
                route_created_at_unix: Some(channel.created_at_unix),
            },
        },
    )
    .await;
    let result = match result {
        Ok(value) => value,
        Err(error) => {
            update_paid_route_store(&store_path, |store| {
                if let Some(shortfall) =
                    error.downcast_ref::<cashu_service::CashuInsufficientFunds>()
                {
                    return store.record_buyer_session_funding_shortfall(
                        session_id,
                        shortfall.required_sat,
                    );
                }
                store.defer_buyer_mint_retry(
                    &mint_url,
                    unix_timestamp(),
                    true,
                    error
                        .downcast_ref::<cashu_service::MintRetryAfter>()
                        .map(|delay| delay.0),
                )
            })?;
            return Err(error);
        }
    };
    let opened: cashu_service::StreamingRouteOpenCashuSpilmanChannelFromWalletResult =
        serde_json::from_value(result)
            .context("daemon wallet returned an invalid opened Cashu channel")?;

    let now_unix = unix_timestamp().max(now_unix);
    let buyer_npub = app
        .nostr_keys()?
        .public_key()
        .to_bech32()
        .context("failed to encode automatic paid exit buyer npub")?;
    let payment = update_paid_route_store(&store_path, |store| {
        store.clear_buyer_mint_retry(&opened.channel.mint_url, now_unix)?;
        store.attach_buyer_spilman_channel(AttachPaidRouteBuyerSpilmanChannelRequest {
            session_id: session_id.to_string(),
            channel_id: opened.channel.channel_id.clone(),
            cashu_unit: opened.channel.unit.clone(),
            capacity_sat: opened.channel.capacity_sat,
            paid_msat: Some(opened.channel.opening_paid_msat),
            payment: opened.channel.payment.clone(),
            now_unix,
        })?;
        store.build_buyer_payment_envelope(BuildPaidRouteBuyerPaymentEnvelopeRequest {
            session_id: session_id.to_string(),
            buyer_npub,
            kind: BuildPaidRouteBuyerPaymentEnvelopeKind::ChannelOpen,
            payment: opened.channel.payment,
            delivered_units: None,
            paid_msat: Some(opened.channel.opening_paid_msat),
            now_unix,
        })
    })?;
    Ok(payment.envelope)
}

pub(crate) async fn finalize_automatic_paid_exit(
    automatic: &PaidExitAutomaticBuyer,
    runtime: &crate::fips_private_mesh::FipsPrivateTunnelRuntime,
    app: &AppConfig,
    config_path: &Path,
    now_unix: u64,
) -> Result<()> {
    let Some(candidate) = automatic.candidate.as_ref() else {
        return Ok(());
    };
    drain_paid_exit_buyer_usage(runtime, config_path, &candidate.seller_pubkey, now_unix)?;
    if candidate.funded {
        let wallet_data_dir = paid_exit_wallet_data_dir(config_path);
        let signer = FileSpilmanPaymentSigner::try_load(&wallet_data_dir)
            .map_err(|error| anyhow!("{error}"))?
            .ok_or_else(|| anyhow!("Cashu channel storage is busy; retry finalization"))?;
        let store_path = paid_route_store_file_path(config_path);
        update_paid_route_store(&store_path, |store| {
            paid_exit_settle_with_signer(PaidExitSettleRequest {
                app,
                config_path,
                store,
                signer: &signer,
                session_id: &candidate.session_id,
                dry_run: false,
                wallet_data_dir: &wallet_data_dir,
                now_unix,
            })
        })?;
        let flushed = flush_paid_exit_payment_outbox(runtime, config_path).await;
        if flushed.errors > 0 {
            eprintln!(
                "paid-exit: automatic seller finalization queued with {} send error(s)",
                flushed.errors
            );
        }
    }
    Ok(())
}

pub(super) fn suspend_automatic_paid_exit(
    automatic: &PaidExitAutomaticBuyer,
    runtime: &crate::fips_private_mesh::FipsPrivateTunnelRuntime,
    config_path: &Path,
    now_unix: u64,
) -> Result<()> {
    let Some(candidate) = automatic.candidate.as_ref() else {
        return Ok(());
    };
    drain_paid_exit_buyer_usage(runtime, config_path, &candidate.seller_pubkey, now_unix)?;
    Ok(())
}

pub(crate) fn queue_recovered_paid_exit_channel_open(
    app: &AppConfig,
    config_path: &Path,
    session_id: &str,
    now_unix: u64,
) -> Result<()> {
    let signer = FileSpilmanPaymentSigner::try_load(&paid_exit_wallet_data_dir(config_path))
        .map_err(|error| anyhow!("{error}"))?
        .ok_or_else(|| anyhow!("Cashu channel storage is busy; retry recovery"))?;
    let buyer_npub = app
        .nostr_keys()?
        .public_key()
        .to_bech32()
        .context("failed to encode automatic paid exit buyer npub")?;
    let payment = update_paid_route_store(&paid_route_store_file_path(config_path), |store| {
        store.build_buyer_signed_payment_envelope(
            &signer,
            BuildPaidRouteBuyerSignedPaymentEnvelopeRequest {
                session_id: session_id.to_string(),
                buyer_npub,
                kind: BuildPaidRouteBuyerPaymentEnvelopeKind::ChannelOpen,
                delivered_units: None,
                paid_msat: None,
                now_unix,
            },
        )
    })?;
    queue_paid_exit_payment(app, config_path, &payment.envelope)?;
    Ok(())
}

pub(super) fn drain_paid_exit_buyer_usage(
    runtime: &crate::fips_private_mesh::FipsPrivateTunnelRuntime,
    config_path: &Path,
    seller_pubkey: &str,
    now_unix: u64,
) -> Result<PaidRouteUsage> {
    let delta = runtime.drain_paid_route_usage(seller_pubkey)?;
    if delta.is_empty() {
        return Ok(delta);
    }
    let store_path = paid_route_store_file_path(config_path);
    update_paid_route_store(&store_path, |store| {
        store.record_buyer_usage(RecordPaidRouteBuyerUsageRequest {
            seller_pubkey: seller_pubkey.to_string(),
            usage_delta: delta.clone(),
            now_unix,
        })?;
        Ok(())
    })?;
    Ok(delta)
}