nvpn 4.1.13

CLI and daemon for Nostr VPN private mesh networks

async fn paid_exit_create_payment_command(args: PaidExitCreatePaymentArgs) -> Result<()> {
    let config_path = args.config.unwrap_or_else(default_config_path);
    let app = load_or_default_config(&config_path)?;
    let buyer_npub = app
        .nostr_keys()?
        .public_key()
        .to_bech32()
        .context("failed to encode buyer npub")?;
    let store_path = paid_route_store_file_path(&config_path);
    let now_unix = unix_timestamp();
    let (result, wallet_open_json, wallet_sign_json) = if args.sign_from_wallet {
        let signer = FileSpilmanPaymentSigner::load(&paid_exit_wallet_data_dir(&config_path))
            .map_err(|error| anyhow!("{error}"))?;
        let result = update_paid_route_store(&store_path, |store| {
            store.build_buyer_signed_payment_envelope(
                &signer,
                BuildPaidRouteBuyerSignedPaymentEnvelopeRequest {
                    session_id: args.session.clone(),
                    buyer_npub,
                    kind: args.kind.into(),
                    delivered_units: args.delivered_units,
                    paid_msat: args.paid_msat,
                    now_unix,
                },
            )
        })?;
        (
            result,
            None,
            Some(json!({
                "source": "spilman-client-store",
                "data_dir": paid_exit_wallet_data_dir(&config_path).display().to_string(),
            })),
        )
    } else if args.open_from_wallet {
        if args.kind != PaidExitCreatePaymentKind::ChannelOpen {
            return Err(anyhow!(
                "--open-from-wallet currently creates channel_open payments; pass --kind channel-open"
            ));
        }
        let keyset_info_json =
            read_optional_paid_exit_keyset_info(args.keyset_info, args.keyset_info_file)?;
        let open_request = {
            let store = load_paid_route_store(&store_path)?;
            let session_record = store.sessions.get(&args.session).cloned().ok_or_else(|| {
                anyhow!("paid exit buyer session {} does not exist", args.session)
            })?;
            let lease_record = store
                .leases
                .get(&session_record.session.lease_id)
                .cloned()
                .ok_or_else(|| {
                    anyhow!(
                        "paid exit lease {} does not exist",
                        session_record.session.lease_id
                    )
                })?;
            let channel_record = store
                .channels
                .get(&session_record.session.payment.channel_id)
                .cloned()
                .ok_or_else(|| {
                    anyhow!(
                        "paid exit channel {} does not exist",
                        session_record.session.payment.channel_id
                    )
                })?;
            let quote_record = store
                .quotes
                .get(&lease_record.lease.quote_id)
                .cloned()
                .ok_or_else(|| {
                    anyhow!(
                        "paid exit quote {} does not exist",
                        lease_record.lease.quote_id
                    )
                })?;
            let mint_url = args
                .mint
                .as_deref()
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
                .unwrap_or_else(|| channel_record.mint_url.clone());
            if mint_url.trim().is_empty() {
                return Err(anyhow!(
                    "paid exit session has no mint; pass --mint for --open-from-wallet"
                ));
            }
            let cashu_unit = if session_record.session.payment.cashu_unit.trim().is_empty() {
                "sat".to_string()
            } else {
                session_record.session.payment.cashu_unit.clone()
            };
            StreamingRouteOpenCashuSpilmanChannelFromWalletRequest {
                mint_url,
                receiver_pubkey_hex: quote_record.quote.receiver_pubkey_hex,
                capacity_sat: session_record.session.payment.capacity_sat,
                expiry_unix: channel_record.expires_at_unix,
                max_amount_per_output: args.max_amount_per_output,
                unit: cashu_unit,
                opening_paid_msat: args
                    .paid_msat
                    .unwrap_or(session_record.session.payment.paid_msat),
                keyset_id: args.keyset_id,
                keyset_info_json,
                client_request_id: Some(args.session.clone()),
                route_created_at_unix: Some(channel_record.created_at_unix),
            }
        };
        let opened: cashu_service::StreamingRouteOpenCashuSpilmanChannelFromWalletResult =
            daemon_cashu_wallet_request(
                &config_path,
                crate::cashu_wallet_daemon::DaemonCashuWalletCommand::OpenSpilmanChannel {
                    request: open_request,
                },
            )
            .await?;
        let (result, attach) = update_paid_route_store(&store_path, |store| {
            let attach = store.attach_buyer_spilman_channel(
                AttachPaidRouteBuyerSpilmanChannelRequest {
                    session_id: args.session.clone(),
                    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,
                },
            )?;
            let result = store.build_buyer_payment_envelope(
                BuildPaidRouteBuyerPaymentEnvelopeRequest {
                    session_id: args.session.clone(),
                    buyer_npub,
                    kind: args.kind.into(),
                    payment: opened.channel.payment.clone(),
                    delivered_units: args.delivered_units,
                    paid_msat: Some(opened.channel.opening_paid_msat),
                    now_unix,
                },
            )?;
            Ok((result, attach))
        })?;
        let wallet_open_json = json!({
            "channel": opened.channel,
            "wallet_send": {
                "mint_url": opened.wallet_send.mint_url,
                "unit": opened.wallet_send.unit,
                "amount_sat": opened.wallet_send.amount_sat,
                "send_fee_sat": opened.wallet_send.send_fee_sat,
                "operation_id": opened.wallet_send.operation_id,
            },
            "attached": attach,
        });
        (result, Some(wallet_open_json), None)
    } else {
        let payment_json = read_paid_exit_spilman_payment(args.payment, args.payment_stdin)?;
        let payment: CashuSpilmanPayment = serde_json::from_str(&payment_json)
            .context("failed to decode Cashu Spilman payment JSON")?;
        let result = update_paid_route_store(&store_path, |store| {
            store.build_buyer_payment_envelope(BuildPaidRouteBuyerPaymentEnvelopeRequest {
                session_id: args.session.clone(),
                buyer_npub,
                kind: args.kind.into(),
                payment,
                delivered_units: args.delivered_units,
                paid_msat: args.paid_msat,
                now_unix,
            })
        })?;
        (result, None, None)
    };

    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "store_path": store_path.display().to_string(),
                "payment": result,
                "wallet_open": wallet_open_json,
                "wallet_sign": wallet_sign_json,
            }))?
        );
    } else {
        if let Some(wallet_open) = wallet_open_json.as_ref()
            && let Some(wallet_send) = wallet_open.get("wallet_send")
        {
            let amount_sat = wallet_send["amount_sat"].as_u64().unwrap_or_default();
            let fee_sat = wallet_send["send_fee_sat"].as_u64().unwrap_or_default();
            println!(
                "wallet_funding: amount={} fee={} operation={}",
                paid_exit_sat_text(amount_sat),
                paid_exit_sat_text(fee_sat),
                wallet_send["operation_id"].as_str().unwrap_or_default()
            );
        }
        if let Some(wallet_sign) = wallet_sign_json.as_ref() {
            println!(
                "wallet_sign: {}",
                wallet_sign["source"].as_str().unwrap_or_default()
            );
        }
        println!("paid_exit_payment: {}", result.payload_type);
        println!("session: {}", result.session_id);
        println!("seller: {}", result.seller_npub);
        println!("offer: {}", result.offer_id);
        println!("channel: {}", result.channel_id);
        println!(
            "routing: state={} allow={} paid={} due={} unpaid={} usage={}",
            result.state.as_str(),
            result.allow_routing,
            paid_exit_msat_text(result.paid_msat),
            paid_exit_msat_text(result.amount_due_msat),
            paid_exit_msat_text(result.unpaid_msat),
            paid_exit_usage_text(result.delivered_units)
        );
        println!("store: {} changed={}", store_path.display(), result.changed);
        println!(
            "envelope: {}",
            serde_json::to_string(&result.envelope)
                .context("failed to encode paid route payment envelope")?
        );
    }

    Ok(())
}

fn paid_exit_create_token_lease_command(args: PaidExitCreateTokenLeaseArgs) -> Result<()> {
    let config_path = args.config.unwrap_or_else(default_config_path);
    let app = load_or_default_config(&config_path)?;
    let buyer_npub = app
        .nostr_keys()?
        .public_key()
        .to_bech32()
        .context("failed to encode buyer npub")?;
    let store_path = paid_route_store_file_path(&config_path);
    let token = read_paid_exit_wallet_token(args.token, args.token_stdin)?;
    let mint_url = args
        .mint
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(normalize_mint_url)
        .transpose()?
        .unwrap_or_default();
    let result = update_paid_route_store(&store_path, |store| {
        store.build_buyer_token_lease_envelope(BuildPaidRouteBuyerTokenLeaseEnvelopeRequest {
            session_id: args.session.clone(),
            buyer_npub,
            mint_url,
            cashu_unit: args.unit,
            amount: args.amount,
            paid_msat: args.paid_msat,
            token,
            expires_at_unix: args.expires_at_unix,
            now_unix: unix_timestamp(),
        })
    })?;

    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "store_path": store_path.display().to_string(),
                "payment": result,
            }))?
        );
    } else {
        println!("paid_exit_payment: {}", result.payload_type);
        println!("session: {}", result.session_id);
        println!("seller: {}", result.seller_npub);
        println!("offer: {}", result.offer_id);
        println!("channel: {}", result.channel_id);
        println!(
            "routing: state={} allow={} paid={} due={} unpaid={} usage={}",
            result.state.as_str(),
            result.allow_routing,
            paid_exit_msat_text(result.paid_msat),
            paid_exit_msat_text(result.amount_due_msat),
            paid_exit_msat_text(result.unpaid_msat),
            paid_exit_usage_text(result.delivered_units)
        );
        println!("store: {} changed={}", store_path.display(), result.changed);
        println!(
            "envelope: {}",
            serde_json::to_string(&result.envelope)
                .context("failed to encode paid route token lease envelope")?
        );
    }

    Ok(())
}