diff --git a/backend/spec/billing.md b/backend/spec/billing.md index e272a5e..199aa78 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -2,20 +2,20 @@ Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle. -It owns the domain logic only: every raw Stripe REST call goes through the `Stripe` wrapper (see `spec/stripe.md`), and every Lightning (NWC) wallet operation and the fiat-minor-units → millisatoshi conversion go through the helpers in `spec/bitcoin.md`. +It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`). Members: -- `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` -- `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`) +- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`) +- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`) - `query: Query` - `command: Command` - `robot: Robot` ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` -- Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()` -- Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional) +- Builds `stripe` via `Stripe::from_env()` and `wallet` from `NWC_URL` +- Panics if `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, or `NWC_URL` is missing or malformed ## `pub fn start(&self)` @@ -72,7 +72,7 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created, ## `pub async fn reconcile_manual_lightning_invoice(&self, invoice_id: &str, invoice: &Value) -> Result` -If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issued for it (`query.get_invoice_manual_lightning_bolt11`), check whether that bolt11 has settled (`bitcoin.system_wallet().invoice_settled(...)`). If it has, mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and return the refreshed invoice. On any lookup/settlement failure, log and return the invoice unchanged. +If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issued for it (`query.get_invoice_manual_lightning_bolt11`), check whether that bolt11 has settled (`self.wallet.is_settled(...)`). If it has, mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and return the refreshed invoice. On any lookup/settlement failure, log and return the invoice unchanged. ## `pub async fn get_or_create_manual_lightning_bolt11(&self, invoice_id: &str, tenant_pubkey: &str, amount_due_minor: i64, currency: &str) -> Result` @@ -81,8 +81,8 @@ If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issue ## `pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result` -- Converts the fiat amount to msats via `bitcoin.fiat_minor_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`) -- Issues a bolt11 invoice for that amount on the system NWC wallet (`bitcoin.system_wallet().make_invoice(...)`) +- Converts the fiat amount to msats via `bitcoin::fiat_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`) +- Issues a bolt11 invoice for that amount on the system NWC wallet (`self.wallet.make_invoice(...)`) - Returns the bolt11 invoice string ## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>` @@ -111,7 +111,7 @@ Attempts Stripe-side collection for open invoices when the tenant has a card on Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority: 1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first): - - The system wallet (`bitcoin.system_wallet()`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::parse` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries. + - The system wallet (`self.wallet`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::from_url` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries. - If payment succeeds: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and clear `nwc_error` via `command.clear_tenant_nwc_error`. Done. - If it fails before any charge could have gone out: set `nwc_error` on the tenant via `command.set_tenant_nwc_error`, and fall through to the next option (carrying a short summary of the error into the eventual DM). - If it fails after a charge may have gone out (needs reconciliation): set `nwc_error` and return the error without falling through — a human must reconcile before any retry. diff --git a/backend/spec/bitcoin.md b/backend/spec/bitcoin.md index 70f9cb3..5078145 100644 --- a/backend/spec/bitcoin.md +++ b/backend/spec/bitcoin.md @@ -1,62 +1,11 @@ -# `bitcoin` — Bitcoin / Lightning helpers +# `bitcoin` — fiat ↔ Bitcoin conversion -Small wrappers around the Bitcoin-facing services the app talks to: Nostr Wallet Connect (NWC) wallets for Lightning invoices/payments, and a fiat↔BTC spot price feed, plus the fiat-minor-units → millisatoshi conversion that ties them together. The billing-specific orchestration (which wallet pays which invoice, double-charge guards, DMs, etc.) lives in `spec/billing.md`. +Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`. -## `pub struct Bitcoin` +## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result` -Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the wallet used to *receive* payments — issue and look up bolt11 invoices) and the HTTP client used for the fiat↔BTC spot price feed. +Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3). -Members: +## `pub async fn get_bitcoin_price(currency: &str) -> Result` -- `system_nwc_url: String` - from `NWC_URL` -- `http: reqwest::Client` - -### `pub fn from_env() -> Self` - -Reads `NWC_URL` (the system / receiving wallet) and constructs a fresh `reqwest::Client`. Unlike the Stripe keys, `NWC_URL` is **optional**: if it's unset, Lightning operations fail at use time with a clear error rather than panicking at startup. This is what `Billing::new` calls. - -### `pub fn system_wallet(&self) -> Result` - -Returns the system `Wallet` (parsed from `system_nwc_url` with label `"system"`). Errors if `NWC_URL` is unset or malformed. - -### `pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result` - -Fetches the live BTC spot price (via `btc_spot_price_from_base` against Coinbase) for `currency` and converts `amount_due_minor` to millisatoshis via `fiat_minor_to_msats_from_quote`. `currency` is upper-cased before use. - -## `pub struct Wallet` - -A handle to a single NWC wallet. Each operation opens a fresh NWC connection and tears it down (`shutdown`) afterwards. - -Member: - -- `uri: NostrWalletConnectURI` - the parsed `nostr+walletconnect://…` URI - -### `pub fn parse(uri: &str, label: &str) -> Result` - -Parses an `nostr+walletconnect://` URI. `label` (e.g. `"system"` / `"tenant"`) only flavours the error message — `invalid {label} NWC URL` — so callers can tell which wallet was misconfigured. - -### `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result` - -Issues a bolt11 invoice for `amount_msats` with the given description. Returns the bolt11 string. - -### `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>` - -Pays a bolt11 invoice. - -### `pub async fn invoice_settled(&self, bolt11: &str) -> Result` - -Looks up a bolt11 invoice (previously issued by this wallet) and returns whether it has settled — true if the transaction state is `Settled` or `settled_at` is present. - -## `pub async fn btc_spot_price_from_base(http: &reqwest::Client, api_base: &str, currency: &str) -> Result` - -Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a Coinbase-shaped API at `api_base` (`{api_base}/BTC-{currency}/spot`); production callers reach it via `Bitcoin::fiat_minor_to_msats` with the real Coinbase base, while tests can point it at a stub. Errors if the quote is missing, unparseable, or non-positive. - -## `pub fn fiat_minor_to_msats_from_quote(amount_due_minor: i64, currency: &str, btc_price_in_fiat: f64) -> Result` - -Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis, given a BTC price quote in that currency. - -- Errors if `amount_due_minor <= 0` or `btc_price_in_fiat <= 0` -- Converts minor units to a major-unit amount using the currency's decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3 — following Stripe's currency conventions; an unrecognized or malformed code is an error) -- `msats = (amount_fiat / btc_price_in_fiat) * 100_000_000_000` -- Rounds **up** so we never under-charge, but snaps to the nearest integer when within `1e-6` of one to avoid floating-point artifacts at integer boundaries -- Errors if the result is non-finite, non-positive, or exceeds `u64::MAX` +Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint. diff --git a/backend/spec/wallet.md b/backend/spec/wallet.md new file mode 100644 index 0000000..a0bf36e --- /dev/null +++ b/backend/spec/wallet.md @@ -0,0 +1,23 @@ +# `pub struct Wallet` + +A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. + +Member: + +- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI + +## `pub fn from_url(url: &str) -> Result` + +Parses an `nostr+walletconnect://` URI. + +## `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result` + +Issues a bolt11 invoice for `amount_msats` and returns it. + +## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>` + +Pays a bolt11 invoice. + +## `pub async fn is_settled(&self, bolt11: &str) -> Result` + +Returns whether a bolt11 invoice (previously issued by this wallet) has settled. diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 7c168ae..25eb1de 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -1,12 +1,13 @@ use anyhow::{Result, anyhow}; use std::collections::BTreeMap; -use crate::bitcoin::{Bitcoin, Wallet}; +use crate::bitcoin; use crate::command::Command; use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay}; use crate::query::Query; use crate::robot::Robot; use crate::stripe::{InvoiceLookupError, Stripe}; +use crate::wallet::Wallet; const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment."; const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:"; @@ -22,7 +23,7 @@ enum NwcInvoicePaymentOutcome { #[derive(Clone)] pub struct Billing { stripe: Stripe, - bitcoin: Bitcoin, + wallet: Wallet, query: Query, command: Command, robot: Robot, @@ -32,7 +33,7 @@ impl Billing { pub fn new(query: Query, command: Command, robot: Robot) -> Self { Self { stripe: Stripe::from_env(), - bitcoin: Bitcoin::from_env(), + wallet: Wallet::from_url(&std::env::var("NWC_URL").unwrap_or_default()).expect("invalid NWC_URL"), query, command, robot, @@ -713,12 +714,8 @@ impl Billing { } pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result { - let amount_msats = self - .bitcoin - .fiat_minor_to_msats(amount_due_minor, currency) - .await?; - self.bitcoin - .system_wallet()? + let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?; + self.wallet .make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION) .await } @@ -891,7 +888,7 @@ impl Billing { } async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result { - self.bitcoin.system_wallet()?.invoice_settled(bolt11).await + self.wallet.is_settled(bolt11).await } /// Charges a Stripe invoice over Lightning: the system wallet issues a bolt11 @@ -912,20 +909,13 @@ impl Billing { return Ok(existing_outcome); } - let amount_msats = match self - .bitcoin - .fiat_minor_to_msats(amount_due_minor, currency) - .await - { - Ok(amount_msats) => amount_msats, + let amount_msats = match bitcoin::fiat_to_msats(amount_due_minor, currency).await { + Ok(msats) => msats, Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; - let system_wallet = match self.bitcoin.system_wallet() { - Ok(wallet) => wallet, - Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), - }; - let bolt11 = match system_wallet + let bolt11 = match self + .wallet .make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION) .await { @@ -933,7 +923,7 @@ impl Billing { Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; - let tenant_wallet = match Wallet::parse(tenant_nwc_url, "tenant") { + let tenant_wallet = match Wallet::from_url(tenant_nwc_url) { Ok(wallet) => wallet, Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; @@ -1078,6 +1068,18 @@ mod tests { } } + #[allow(unused_unsafe)] + fn set_nwc_url(value: Option<&str>) { + match value { + Some(v) => unsafe { std::env::set_var("NWC_URL", v) }, + None => unsafe { std::env::remove_var("NWC_URL") }, + } + } + + /// A syntactically valid NWC URI usable for `Wallet::from_env()` in tests. + /// The keys are random fixtures — the wallet is never actually contacted. + const TEST_NWC_URL: &str = "nostr+walletconnect://ef9824790df75f1f71d3fb9ffe9d8350f169df5cdd56a7a38592b407c61f4be7?relay=wss://relay.example.com&secret=baee312da88dcc52e9315e3962c2ea1bc8fdb5682a7fd6e6559084a41387e797"; + struct StripeSecretKeyGuard { previous: Option, } @@ -1114,6 +1116,24 @@ mod tests { } } + struct NwcUrlGuard { + previous: Option, + } + + impl NwcUrlGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("NWC_URL").ok(); + set_nwc_url(value); + Self { previous } + } + } + + impl Drop for NwcUrlGuard { + fn drop(&mut self) { + set_nwc_url(self.previous.as_deref()); + } + } + async fn test_pool() -> SqlitePool { let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") .expect("valid sqlite memory url") @@ -1138,6 +1158,7 @@ mod tests { let _lock = env_lock().lock().await; let _secret_env = StripeSecretKeyGuard::set(None); let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); + let _nwc_env = NwcUrlGuard::set(None); let pool = test_pool().await; let query = Query::new(pool.clone()); @@ -1171,6 +1192,7 @@ mod tests { let _lock = env_lock().lock().await; let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _webhook_env = StripeWebhookSecretGuard::set(None); + let _nwc_env = NwcUrlGuard::set(None); let pool = test_pool().await; let query = Query::new(pool.clone()); @@ -1204,6 +1226,7 @@ mod tests { let _lock = env_lock().lock().await; let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _webhook_env = StripeWebhookSecretGuard::set(Some(" ")); + let _nwc_env = NwcUrlGuard::set(None); let pool = test_pool().await; let query = Query::new(pool.clone()); @@ -1237,6 +1260,7 @@ mod tests { let _lock = env_lock().lock().await; let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); + let _nwc_env = NwcUrlGuard::set(Some(TEST_NWC_URL)); let pool = test_pool().await; let billing = Billing::new( diff --git a/backend/src/bitcoin.rs b/backend/src/bitcoin.rs index fbc5427..0f977e2 100644 --- a/backend/src/bitcoin.rs +++ b/backend/src/bitcoin.rs @@ -1,51 +1,11 @@ -//! Small wrappers around the Bitcoin-facing services this app talks to: Nostr -//! Wallet Connect wallets (for Lightning invoices/payments) and a fiat↔BTC spot -//! price feed, plus the fiat-minor-units → millisatoshi conversion that ties them -//! together. The billing-specific orchestration lives in [`crate::billing`]. - use anyhow::{Result, anyhow}; -use nwc::prelude::{ - LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI, - PayInvoiceRequest, TransactionState, -}; -const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices"; -/// Millisatoshis per bitcoin. -const MSATS_PER_BTC: f64 = 100_000_000_000.0; - -/// Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the -/// wallet used to *receive* payments — issue and look up bolt11 invoices) and the -/// HTTP client used for the fiat↔BTC spot price feed. -#[derive(Clone)] -pub struct Bitcoin { - system_nwc_url: String, - http: reqwest::Client, -} - -impl Bitcoin { - /// Reads `NWC_URL` (the system / receiving wallet). Unlike the Stripe keys this - /// is optional: if it's unset, Lightning operations fail at use time with a - /// clear error rather than at startup. - pub fn from_env() -> Self { - Self { - system_nwc_url: std::env::var("NWC_URL").unwrap_or_default(), - http: reqwest::Client::new(), - } - } - - /// The system wallet — issues and looks up the bolt11 invoices we want paid to - /// us. Errors if `NWC_URL` is unset or malformed. - pub fn system_wallet(&self) -> Result { - Wallet::parse(&self.system_nwc_url, "system") - } - - /// Fetches the live BTC spot price and converts a fiat amount in minor units - /// (cents, etc.) to millisatoshis. - pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result { - let currency = currency.to_uppercase(); - let btc_price = btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, ¤cy).await?; - fiat_minor_to_msats_from_quote(amount_due_minor, ¤cy, btc_price) - } +pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result { + let price = get_bitcoin_price(¤cy.to_uppercase()).await?; + let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32); + let amount_fiat = (amount_fiat_minor as f64) / divisor; + let amount_msats = (amount_fiat / price * 100_000_000_000.0).round(); + Ok(amount_msats as u64) } #[derive(serde::Deserialize)] @@ -58,123 +18,17 @@ struct CoinbaseSpotPriceData { amount: String, } -/// A handle to a single Nostr Wallet Connect wallet. Each operation opens a fresh -/// connection and tears it down afterwards. -pub struct Wallet { - uri: NostrWalletConnectURI, -} - -impl Wallet { - /// Parses an `nostr+walletconnect://` URI. `label` only flavours the error - /// message so callers can tell which wallet was misconfigured. - pub fn parse(uri: &str, label: &str) -> Result { - let uri = uri - .parse::() - .map_err(|_| anyhow!("invalid {label} NWC URL"))?; - Ok(Self { uri }) - } - - /// Issues a bolt11 invoice for `amount_msats`. - pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result { - let nwc = NWC::new(self.uri.clone()); - let result = nwc - .make_invoice(MakeInvoiceRequest { - amount: amount_msats, - description: Some(description.to_string()), - description_hash: None, - expiry: None, - }) - .await; - nwc.shutdown().await; - Ok(result - .map_err(|e| anyhow!("failed to create invoice: {e}"))? - .invoice) - } - - /// Pays a bolt11 invoice. - pub async fn pay_invoice(&self, bolt11: String) -> Result<()> { - let nwc = NWC::new(self.uri.clone()); - let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await; - nwc.shutdown().await; - result.map(|_| ()).map_err(|e| anyhow!("{e}")) - } - - /// Returns whether a bolt11 invoice (previously issued by this wallet) has been - /// settled. - pub async fn invoice_settled(&self, bolt11: &str) -> Result { - let nwc = NWC::new(self.uri.clone()); - let result = nwc - .lookup_invoice(LookupInvoiceRequest { - payment_hash: None, - invoice: Some(bolt11.to_string()), - }) - .await; - nwc.shutdown().await; - let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?; - Ok(lookup_invoice_response_is_settled(&response)) - } -} - -fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool { - response.state == Some(TransactionState::Settled) || response.settled_at.is_some() -} - -/// Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a -/// Coinbase-shaped API at `api_base`. Exposed so tests can stub the price feed; -/// production callers go through [`Bitcoin::fiat_minor_to_msats`]. -pub async fn btc_spot_price_from_base( - http: &reqwest::Client, - api_base: &str, - currency: &str, -) -> Result { - let pair = format!("BTC-{currency}"); - let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/')); +pub async fn get_bitcoin_price(currency: &str) -> Result { + let http = reqwest::Client::new(); + let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot"); let resp = http.get(url).send().await?; let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?; - let amount = body + Ok(body .data .amount .parse::() - .map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?; - if amount <= 0.0 { - return Err(anyhow!( - "invalid non-positive BTC spot quote for {currency}" - )); - } - Ok(amount) -} - -/// Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis, -/// given a BTC price quote in that currency. Rounds up so we never under-charge, -/// but snaps to the nearest integer when within a hair of one to avoid floating -/// point artifacts at integer boundaries. -pub fn fiat_minor_to_msats_from_quote( - amount_due_minor: i64, - currency: &str, - btc_price_in_fiat: f64, -) -> Result { - if amount_due_minor <= 0 { - return Err(anyhow!("amount_due must be positive")); - } - if btc_price_in_fiat <= 0.0 { - return Err(anyhow!("btc_price_in_fiat must be positive")); - } - - let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32); - let amount_fiat = (amount_due_minor as f64) / divisor; - let amount_btc = amount_fiat / btc_price_in_fiat; - let raw_msats = amount_btc * MSATS_PER_BTC; - let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 { - raw_msats.round() - } else { - raw_msats.ceil() - }; - - if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 { - return Err(anyhow!("calculated msat amount is out of bounds")); - } - Ok(amount_msats as u64) + .map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?) } /// Number of decimal places in `currency`'s minor unit, following Stripe's @@ -192,29 +46,3 @@ fn currency_minor_exponent(currency: &str) -> Result { }; Ok(exponent) } - -#[cfg(test)] -mod tests { - use super::fiat_minor_to_msats_from_quote; - - #[test] - fn converts_usd_minor_units_with_quote() { - let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0) - .expect("conversion should succeed"); - assert_eq!(msats, 1_000_000); - } - - #[test] - fn converts_zero_decimal_currency_with_quote() { - let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0) - .expect("conversion should succeed"); - assert_eq!(msats, 1_000_000); - } - - #[test] - fn rejects_malformed_currency_code() { - // Not three ASCII letters: rejected outright. - assert!(fiat_minor_to_msats_from_quote(100, "usdd", 100_000.0).is_err()); - assert!(fiat_minor_to_msats_from_quote(100, "us1", 100_000.0).is_err()); - } -} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 043746d..f94ba6d 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -9,3 +9,4 @@ pub mod pool; pub mod query; pub mod robot; pub mod stripe; +pub mod wallet; diff --git a/backend/src/main.rs b/backend/src/main.rs index b017f1c..db1bb4d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -9,6 +9,7 @@ mod pool; mod query; mod robot; mod stripe; +mod wallet; use anyhow::Result; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; diff --git a/backend/src/wallet.rs b/backend/src/wallet.rs new file mode 100644 index 0000000..8061d6b --- /dev/null +++ b/backend/src/wallet.rs @@ -0,0 +1,55 @@ +use anyhow::{Result, anyhow}; +use nwc::prelude::{ + LookupInvoiceRequest, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest, + TransactionState, +}; + +#[derive(Clone)] +pub struct Wallet { + url: NostrWalletConnectURI, +} + +impl Wallet { + pub fn from_url(url: &str) -> Result { + let url = url + .parse::() + .map_err(|_| anyhow!("invalid NWC URL"))?; + Ok(Self { url }) + } + + pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result { + let nwc = NWC::new(self.url.clone()); + let result = nwc + .make_invoice(MakeInvoiceRequest { + amount: amount_msats, + description: Some(description.to_string()), + description_hash: None, + expiry: None, + }) + .await; + nwc.shutdown().await; + Ok(result + .map_err(|e| anyhow!("failed to create invoice: {e}"))? + .invoice) + } + + pub async fn pay_invoice(&self, bolt11: String) -> Result<()> { + let nwc = NWC::new(self.url.clone()); + let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await; + nwc.shutdown().await; + result.map(|_| ()).map_err(|e| anyhow!("{e}")) + } + + pub async fn is_settled(&self, bolt11: &str) -> Result { + let nwc = NWC::new(self.url.clone()); + let result = nwc + .lookup_invoice(LookupInvoiceRequest { + payment_hash: None, + invoice: Some(bolt11.to_string()), + }) + .await; + nwc.shutdown().await; + let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?; + Ok(response.state == Some(TransactionState::Settled) || response.settled_at.is_some()) + } +}