From 962e40d3f899bf442e9645aca756133b6ca8c66a Mon Sep 17 00:00:00 2001 From: Kieran Date: Sun, 22 Feb 2026 18:01:35 +0000 Subject: [PATCH] fix: round lightning invoice amounts to nearest satoshi Some Lightning wallets don't handle milli-sats amounts correctly. Round invoice amounts up to the nearest satoshi to ensure wallet compatibility while avoiding underpayment issues. The rounding is applied in two places: 1. In the pricing engine (get_amount_and_rate) for the base amount 2. At invoice creation for the final amount+tax sum Closes #61 --- lnvps_api/src/provisioner/lnvps.rs | 12 +++++---- lnvps_api_common/src/pricing.rs | 40 +++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/lnvps_api/src/provisioner/lnvps.rs b/lnvps_api/src/provisioner/lnvps.rs index c02373f5..142ea71f 100644 --- a/lnvps_api/src/provisioner/lnvps.rs +++ b/lnvps_api/src/provisioner/lnvps.rs @@ -10,7 +10,7 @@ use isocountry::CountryCode; use lnvps_api_common::retry::{OpResult, Pipeline, RetryPolicy}; use lnvps_api_common::{ AvailableIp, CostResult, HostCapacityService, NetworkProvisioner, NewPaymentInfo, - PricingEngine, UpgradeConfig, UpgradeCostQuote, + PricingEngine, UpgradeConfig, UpgradeCostQuote, round_msat_to_sat, }; use lnvps_api_common::{ExchangeRateService, op_fatal}; use lnvps_db::{ @@ -362,7 +362,8 @@ impl LNVpsProvisioner { "Lightning payment must be in BTC" ); const INVOICE_EXPIRE: u64 = 600; - let invoice_amount = converted.amount.value() + tax; + // Round to nearest satoshi for wallet compatibility + let invoice_amount = round_msat_to_sat(converted.amount.value() + tax); let desc = match payment_type { SubscriptionPaymentType::Purchase => { format!("Subscription purchase: {}", subscription.name) @@ -496,7 +497,8 @@ impl LNVpsProvisioner { "Cannot create invoices for non-BTC currency" ); const INVOICE_EXPIRE: u64 = 600; - let total_amount = p.amount + p.tax; + // Round to nearest satoshi for wallet compatibility + let total_amount = round_msat_to_sat(p.amount + p.tax); info!( "Creating invoice for {vm_id} for {} sats", total_amount / 1000 @@ -1163,10 +1165,10 @@ mod tests { assert_eq!(vm.id, payment.vm_id); assert_eq!(payment.tax, (payment.amount as f64 * 0.01).floor() as u64); - // check invoice amount matches amount+tax + // check invoice amount matches rounded amount+tax let inv = node.invoices.lock().await; if let Some(i) = inv.get(&hex::encode(payment.id)) { - assert_eq!(i.amount, payment.amount + payment.tax); + assert_eq!(i.amount, round_msat_to_sat(payment.amount + payment.tax)); } else { bail!("Invoice doesnt exist"); } diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index e2802155..26d2c5bf 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -13,6 +13,19 @@ use std::ops::{Add, Sub}; use std::str::FromStr; use std::sync::Arc; +/// Round milli-satoshi amount up to the nearest satoshi. +/// +/// Some Lightning wallets don't handle milli-sats correctly, so we round +/// amounts to whole satoshis. We round up to avoid underpayment. +pub fn round_msat_to_sat(msat: u64) -> u64 { + msat.div_ceil(1000) * 1000 +} + +fn round_to_sat(amount: CurrencyAmount) -> CurrencyAmount { + debug_assert_eq!(amount.currency(), Currency::BTC); + CurrencyAmount::from_u64(Currency::BTC, round_msat_to_sat(amount.value())) +} + /// Result of calculating upgrade costs including both immediate upgrade cost and new renewal cost #[derive(Debug, Clone)] pub struct UpgradeCostQuote { @@ -739,12 +752,15 @@ impl PricingEngine { (c, PaymentMethod::Lightning) if c != Currency::BTC => { // convert to BTC if price is not already in bitcoin let ticker = self.get_ticker(Currency::BTC, c).await?; - ticker.convert_with_rate(list_price)? + let mut converted = ticker.convert_with_rate(list_price)?; + // Round to nearest satoshi for wallet compatibility + converted.amount = round_to_sat(converted.amount); + converted } (c, PaymentMethod::Lightning) if c == Currency::BTC => { - // pass-through price as BTC + // pass-through price as BTC, rounded to nearest satoshi ConvertedCurrencyAmount { - amount: CurrencyAmount::from_u64(list_price.currency(), list_price.value()), + amount: round_to_sat(list_price), rate: TickerRate::passthrough(Currency::BTC), } } @@ -821,6 +837,24 @@ mod tests { VmCustomPricingDisk, VmCustomTemplate, }; + #[test] + fn test_round_msat_to_sat_exact() { + // Exact satoshi amounts should stay the same + assert_eq!(round_msat_to_sat(1000), 1000); + assert_eq!(round_msat_to_sat(5000), 5000); + assert_eq!(round_msat_to_sat(0), 0); + } + + #[test] + fn test_round_msat_to_sat_rounds_up() { + // Sub-satoshi amounts should round up to the next satoshi + assert_eq!(round_msat_to_sat(1), 1000); + assert_eq!(round_msat_to_sat(999), 1000); + assert_eq!(round_msat_to_sat(1001), 2000); + assert_eq!(round_msat_to_sat(1500), 2000); + assert_eq!(round_msat_to_sat(1999), 2000); + } + const MOCK_RATE: f32 = 100_000.0; const SECONDS_PER_MONTH: f64 = 30.0 * 24.0 * 3600.0; // 30 days * 24 hours * 3600 seconds