Refactor bitcoin exchange rate fetching and wallet

This commit is contained in:
Jon Staab
2026-05-14 12:26:43 -07:00
parent 3ed021214a
commit 066c91a4d1
8 changed files with 152 additions and 271 deletions
+9 -9
View File
@@ -2,20 +2,20 @@
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle. 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: Members:
- `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` - `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`)
- `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`) - `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`)
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
- `robot: Robot` - `robot: Robot`
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()` - Builds `stripe` via `Stripe::from_env()` and `wallet` from `NWC_URL`
- Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional) - Panics if `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, or `NWC_URL` is missing or malformed
## `pub fn start(&self)` ## `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<Value, InvoiceLookupError>` ## `pub async fn reconcile_manual_lightning_invoice(&self, invoice_id: &str, invoice: &Value) -> Result<Value, InvoiceLookupError>`
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<String>` ## `pub async fn get_or_create_manual_lightning_bolt11(&self, invoice_id: &str, tenant_pubkey: &str, amount_due_minor: i64, currency: &str) -> Result<String>`
@@ -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<String>` ## `pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String>`
- Converts the fiat amount to msats via `bitcoin.fiat_minor_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`) - 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 (`bitcoin.system_wallet().make_invoice(...)`) - Issues a bolt11 invoice for that amount on the system NWC wallet (`self.wallet.make_invoice(...)`)
- Returns the bolt11 invoice string - Returns the bolt11 invoice string
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>` ## `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: 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): 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 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 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. - 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.
+6 -57
View File
@@ -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<u64>`
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<f64>`
- `system_nwc_url: String` - from `NWC_URL` Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
- `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<Wallet>`
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<u64>`
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<Self>`
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<String>`
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<bool>`
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<f64>`
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<u64>`
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`
+23
View File
@@ -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<Self>`
Parses an `nostr+walletconnect://` URI.
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String>`
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<bool>`
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
+46 -22
View File
@@ -1,12 +1,13 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::bitcoin::{Bitcoin, Wallet}; use crate::bitcoin;
use crate::command::Command; use crate::command::Command;
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay}; use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
use crate::query::Query; use crate::query::Query;
use crate::robot::Robot; use crate::robot::Robot;
use crate::stripe::{InvoiceLookupError, Stripe}; 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 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:"; const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
@@ -22,7 +23,7 @@ enum NwcInvoicePaymentOutcome {
#[derive(Clone)] #[derive(Clone)]
pub struct Billing { pub struct Billing {
stripe: Stripe, stripe: Stripe,
bitcoin: Bitcoin, wallet: Wallet,
query: Query, query: Query,
command: Command, command: Command,
robot: Robot, robot: Robot,
@@ -32,7 +33,7 @@ impl Billing {
pub fn new(query: Query, command: Command, robot: Robot) -> Self { pub fn new(query: Query, command: Command, robot: Robot) -> Self {
Self { Self {
stripe: Stripe::from_env(), 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, query,
command, command,
robot, robot,
@@ -713,12 +714,8 @@ impl Billing {
} }
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> { pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
let amount_msats = self let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?;
.bitcoin self.wallet
.fiat_minor_to_msats(amount_due_minor, currency)
.await?;
self.bitcoin
.system_wallet()?
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION) .make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
.await .await
} }
@@ -891,7 +888,7 @@ impl Billing {
} }
async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> { async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> {
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 /// Charges a Stripe invoice over Lightning: the system wallet issues a bolt11
@@ -912,20 +909,13 @@ impl Billing {
return Ok(existing_outcome); return Ok(existing_outcome);
} }
let amount_msats = match self let amount_msats = match bitcoin::fiat_to_msats(amount_due_minor, currency).await {
.bitcoin Ok(msats) => msats,
.fiat_minor_to_msats(amount_due_minor, currency)
.await
{
Ok(amount_msats) => amount_msats,
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
}; };
let system_wallet = match self.bitcoin.system_wallet() { let bolt11 = match self
Ok(wallet) => wallet, .wallet
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
};
let bolt11 = match system_wallet
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION) .make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
.await .await
{ {
@@ -933,7 +923,7 @@ impl Billing {
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), 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, Ok(wallet) => wallet,
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), 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 { struct StripeSecretKeyGuard {
previous: Option<String>, previous: Option<String>,
} }
@@ -1114,6 +1116,24 @@ mod tests {
} }
} }
struct NwcUrlGuard {
previous: Option<String>,
}
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 { async fn test_pool() -> SqlitePool {
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
.expect("valid sqlite memory url") .expect("valid sqlite memory url")
@@ -1138,6 +1158,7 @@ mod tests {
let _lock = env_lock().lock().await; let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(None); let _secret_env = StripeSecretKeyGuard::set(None);
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
let _nwc_env = NwcUrlGuard::set(None);
let pool = test_pool().await; let pool = test_pool().await;
let query = Query::new(pool.clone()); let query = Query::new(pool.clone());
@@ -1171,6 +1192,7 @@ mod tests {
let _lock = env_lock().lock().await; let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(None); let _webhook_env = StripeWebhookSecretGuard::set(None);
let _nwc_env = NwcUrlGuard::set(None);
let pool = test_pool().await; let pool = test_pool().await;
let query = Query::new(pool.clone()); let query = Query::new(pool.clone());
@@ -1204,6 +1226,7 @@ mod tests {
let _lock = env_lock().lock().await; let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(Some(" ")); let _webhook_env = StripeWebhookSecretGuard::set(Some(" "));
let _nwc_env = NwcUrlGuard::set(None);
let pool = test_pool().await; let pool = test_pool().await;
let query = Query::new(pool.clone()); let query = Query::new(pool.clone());
@@ -1237,6 +1260,7 @@ mod tests {
let _lock = env_lock().lock().await; let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_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 pool = test_pool().await;
let billing = Billing::new( let billing = Billing::new(
+11 -183
View File
@@ -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 anyhow::{Result, anyhow};
use nwc::prelude::{
LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI,
PayInvoiceRequest, TransactionState,
};
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices"; pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64> {
/// Millisatoshis per bitcoin. let price = get_bitcoin_price(&currency.to_uppercase()).await?;
const MSATS_PER_BTC: f64 = 100_000_000_000.0; let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
let amount_fiat = (amount_fiat_minor as f64) / divisor;
/// Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the let amount_msats = (amount_fiat / price * 100_000_000_000.0).round();
/// wallet used to *receive* payments — issue and look up bolt11 invoices) and the Ok(amount_msats as u64)
/// 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> {
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<u64> {
let currency = currency.to_uppercase();
let btc_price = btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, &currency).await?;
fiat_minor_to_msats_from_quote(amount_due_minor, &currency, btc_price)
}
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -58,123 +18,17 @@ struct CoinbaseSpotPriceData {
amount: String, amount: String,
} }
/// A handle to a single Nostr Wallet Connect wallet. Each operation opens a fresh pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
/// connection and tears it down afterwards. let http = reqwest::Client::new();
pub struct Wallet { let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
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<Self> {
let uri = uri
.parse::<NostrWalletConnectURI>()
.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<String> {
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<bool> {
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<f64> {
let pair = format!("BTC-{currency}");
let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/'));
let resp = http.get(url).send().await?; let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?; let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
let amount = body Ok(body
.data .data
.amount .amount
.parse::<f64>() .parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?; .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<u64> {
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)
} }
/// Number of decimal places in `currency`'s minor unit, following Stripe's /// Number of decimal places in `currency`'s minor unit, following Stripe's
@@ -192,29 +46,3 @@ fn currency_minor_exponent(currency: &str) -> Result<u8> {
}; };
Ok(exponent) 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());
}
}
+1
View File
@@ -9,3 +9,4 @@ pub mod pool;
pub mod query; pub mod query;
pub mod robot; pub mod robot;
pub mod stripe; pub mod stripe;
pub mod wallet;
+1
View File
@@ -9,6 +9,7 @@ mod pool;
mod query; mod query;
mod robot; mod robot;
mod stripe; mod stripe;
mod wallet;
use anyhow::Result; use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
+55
View File
@@ -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<Self> {
let url = url
.parse::<NostrWalletConnectURI>()
.map_err(|_| anyhow!("invalid NWC URL"))?;
Ok(Self { url })
}
pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String> {
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<bool> {
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())
}
}