Refactor bitcoin exchange rate fetching and wallet
This commit is contained in:
@@ -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<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>`
|
||||
|
||||
@@ -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>`
|
||||
|
||||
- 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.
|
||||
|
||||
+6
-57
@@ -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`
|
||||
- `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`
|
||||
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
|
||||
|
||||
@@ -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
@@ -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<String> {
|
||||
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<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
|
||||
@@ -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<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 {
|
||||
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(
|
||||
|
||||
+11
-183
@@ -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> {
|
||||
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, ¤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<u64> {
|
||||
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<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('/'));
|
||||
pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
|
||||
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::<f64>()
|
||||
.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)
|
||||
.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<u8> {
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod pool;
|
||||
pub mod query;
|
||||
pub mod robot;
|
||||
pub mod stripe;
|
||||
pub mod wallet;
|
||||
|
||||
@@ -9,6 +9,7 @@ mod pool;
|
||||
mod query;
|
||||
mod robot;
|
||||
mod stripe;
|
||||
mod wallet;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user