From 256883d62149f66b48df3525d6b2c7555ff2ad6d Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 17:03:36 +0100 Subject: [PATCH 1/8] chore: use published payments-rs 0.4.1 from crates.io --- Cargo.lock | 5 +++-- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7188d4af..78a1150b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3661,8 +3661,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payments-rs" -version = "0.2.0" -source = "git+https://github.com/v0l/payments-rs.git?rev=f4456beb68d800a2a0b386b74719a59087cb74cb#f4456beb68d800a2a0b386b74719a59087cb74cb" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ca26435e721f18457df89331976b1fd37e34232c94f68575c19ccd4e942a1d" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 60b18243..dcf040bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ tower-http = { version = "0.6", features = ["cors"] } config = { version = "0.15", features = ["yaml"] } hex = "0.4" ipnetwork = "0.21" -payments-rs = { git = "https://github.com/v0l/payments-rs.git", rev = "f4456beb68d800a2a0b386b74719a59087cb74cb", default-features = false } +payments-rs = { version = "0.4.1", default-features = false } async-trait = "0.1" futures = "0.3" reqwest = { version = "0.13", features = ["json"] } From f518b6ac6667539c766ce57aa69a24d139c9439e Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 18:04:44 +0100 Subject: [PATCH 2/8] feat: on-chain bitcoin payments Support on-chain bitcoin payments (issue #109) using the payments-rs OnChainProvider backed by the same LND wallet as the Lightning node. - lnvps_db: PaymentMethod::OnChain, ProviderConfig::OnChain(LndConfig), list_subscription_payments_by_method, unique index on subscription_payment.external_id (txid de-dupe) - Settings::get_onchain() mirrors get_node(); provider is required and injected into SubscriptionHandler like the Lightning node - Payment creation derives a fresh receive address (external_data); external_id stays empty until the deposit txid is known - OnChainPaymentHandler watcher: correlates deposits by address, de-dupes by txid (at-least-once stream -> exactly-once), pro-rates partial/late/over-payments and inserts new pro-rated renewal payments for deposits to already-settled addresses - API: onchain payment method, ApiPaymentData::OnChain { address }, admin payment method config support - MockDb::update_subscription_payment now mirrors all MySQL columns --- API_CHANGELOG.md | 2 + lnvps_api/Cargo.toml | 3 + lnvps_api/src/api/model.rs | 13 + lnvps_api/src/api/routes.rs | 13 +- lnvps_api/src/bin/api.rs | 33 +- .../orphaned_custom_templates.rs | 4 +- lnvps_api/src/dvm/lnvps.rs | 3 +- lnvps_api/src/mocks.rs | 36 ++ lnvps_api/src/payments/invoice.rs | 5 +- lnvps_api/src/payments/mod.rs | 14 + lnvps_api/src/payments/onchain.rs | 483 ++++++++++++++++++ lnvps_api/src/provisioner/vm.rs | 5 +- lnvps_api/src/settings.rs | 33 ++ lnvps_api/src/subscription/mod.rs | 202 +++++++- lnvps_api/src/worker.rs | 7 +- lnvps_api_admin/src/admin/model.rs | 13 + lnvps_api_common/src/mock.rs | 51 +- lnvps_api_common/src/pricing.rs | 4 +- .../20260720172401_onchain_payments.sql | 9 + lnvps_db/src/lib.rs | 9 + lnvps_db/src/model.rs | 67 +++ lnvps_db/src/mysql.rs | 12 + work/onchain-payments.md | 129 +++++ 23 files changed, 1113 insertions(+), 37 deletions(-) create mode 100644 lnvps_api/src/payments/onchain.rs create mode 100644 lnvps_db/migrations/20260720172401_onchain_payments.sql create mode 100644 work/onchain-payments.md diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index b5a4e164..24c4f7b3 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **On-chain Bitcoin payments** (issue #109) — VMs and subscriptions can now be paid on-chain. A new `onchain` payment method is accepted wherever a payment method is selected (e.g. `GET /api/v1/vm/{id}/renew?method=onchain`); the payment's `data` field returns `{ "onchain": { "address": "bc1…" } }` with a freshly derived receive address (BTC only). The txid is recorded on the payment (`external_id`) once the deposit confirms. Deposits are never rejected: partial, late and over-payments are pro-rated at the quoted rate, and any further deposit to an already-settled address automatically creates a new pro-rated renewal payment. Admin payment-method configs accept a new `onchain` provider type (reuses the LND wallet of the Lightning backend). + - **Transfer a VM to another user (admin)** — new `POST /api/admin/v1/vms/{id}/transfer` (`virtual_machines::update`) reassigns a VM to another user account, e.g. for account recovery (issue #178). It atomically moves the VM and its billing subscription to the target `user_id` and clears the old owner's SSH key from the VM. The change is recorded in VM history as a new `transferred` action. Rejects deleted VMs, self-transfers (`409`) and unknown target users (`404`). Body: `{ user_id, reason? }`. - **Change OS during re-install** — `PATCH /api/v1/vm/{id}/re-install` now accepts an optional `{ image_id }` body to switch the VM to a different OS image as part of the re-install (issue #177). When omitted, the VM is reinstalled with its current image (unchanged behaviour). The chosen image must exist and be enabled, and the change is recorded in VM history. diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index 64b906eb..cfa99744 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -26,6 +26,7 @@ default = [ "proxmox", "linux-ssh", "lnd", + "onchain", "cloudflare", "ovh", "revolut", @@ -44,6 +45,8 @@ nostr-nwc = ["dep:nostr-sdk", "dep:nwc", "nostr-sdk/nip47"] proxmox = ["dep:ssh2", "dep:tokio-tungstenite"] libvirt = ["dep:virt", "dep:uuid", "dep:quick-xml"] lnd = ["payments-rs/method-lnd"] +# On-chain Bitcoin payments (LND wallet backend) +onchain = ["payments-rs/method-lnd-onchain"] # bitvora is disabled - service has been shut down bitvora = ["payments-rs/method-bitvora"] cloudflare = [] diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index c707a528..f35ab512 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -403,6 +403,9 @@ impl ApiVmPayment { let is_upgrade = value.payment_type == lnvps_db::SubscriptionPaymentType::Upgrade; let data = match &value.payment_method { PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data.into()), + PaymentMethod::OnChain => ApiPaymentData::OnChain { + address: value.external_data.into(), + }, PaymentMethod::Revolut => { #[derive(Deserialize)] struct RevolutData { @@ -482,6 +485,11 @@ pub enum ApiPaymentData { /// Stripe checkout session ID session_id: String, }, + /// On-chain bitcoin payment + OnChain { + /// Bitcoin receive address + address: String, + }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -494,6 +502,7 @@ pub enum ApiPaymentMethod { Stripe, NWC, LNURL, + OnChain, } impl From for ApiPaymentMethod { @@ -503,6 +512,7 @@ impl From for ApiPaymentMethod { PaymentMethod::Revolut => ApiPaymentMethod::Revolut, PaymentMethod::Paypal => ApiPaymentMethod::Paypal, PaymentMethod::Stripe => ApiPaymentMethod::Stripe, + PaymentMethod::OnChain => ApiPaymentMethod::OnChain, } } } @@ -1082,6 +1092,9 @@ impl From for ApiSubscriptionPayment { PaymentMethod::Lightning => { ApiPaymentData::Lightning(payment.external_data.clone().into()) } + PaymentMethod::OnChain => ApiPaymentData::OnChain { + address: payment.external_data.clone().into(), + }, PaymentMethod::Revolut => { #[derive(Deserialize)] struct RevolutData { diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 1bc3b01e..81992845 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -808,12 +808,7 @@ async fn v1_list_vm_images(State(this): State) -> ApiResult = this - .db - .count_vms_by_os_image() - .await? - .into_iter() - .collect(); + let counts: HashMap = this.db.count_vms_by_os_image().await?.into_iter().collect(); let total: u64 = counts.values().sum(); let ret = images @@ -1534,6 +1529,7 @@ fn parse_currencies(codes: &[String]) -> Vec { fn default_currencies_for_method(method: PaymentMethod) -> Vec { match method { PaymentMethod::Lightning => vec![ApiCurrency::BTC], + PaymentMethod::OnChain => vec![ApiCurrency::BTC], PaymentMethod::Revolut => vec![ApiCurrency::EUR, ApiCurrency::USD], PaymentMethod::Paypal => vec![ApiCurrency::EUR, ApiCurrency::USD], PaymentMethod::Stripe => vec![ApiCurrency::EUR, ApiCurrency::USD], @@ -2385,7 +2381,10 @@ mod tests { for info in &result { match info.name { - ApiPaymentMethod::Lightning | ApiPaymentMethod::NWC | ApiPaymentMethod::LNURL => { + ApiPaymentMethod::Lightning + | ApiPaymentMethod::NWC + | ApiPaymentMethod::LNURL + | ApiPaymentMethod::OnChain => { assert_eq!(info.currencies, vec![ApiCurrency::BTC]); } ApiPaymentMethod::Revolut | ApiPaymentMethod::Paypal | ApiPaymentMethod::Stripe => { diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 0516e42d..86930f9c 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -163,6 +163,7 @@ async fn main() -> Result<(), Error> { let exchange = make_exchange_service(&settings.redis); let node = settings.get_node().await?; + let onchain = settings.get_onchain().await?; // Optional IP -> country geolocation for VAT place-of-supply evidence. let geoip: Option> = match &settings.geoip_database { @@ -223,6 +224,7 @@ async fn main() -> Result<(), Error> { settings.clone(), db.clone(), node.clone(), + onchain.clone(), exchange.clone(), vat.clone(), work_commander.clone(), @@ -288,7 +290,14 @@ async fn main() -> Result<(), Error> { // API replica (or move LND settlement to the worker) to avoid double work. if mode.contains(&ExecMode::Api) { tasks.extend( - listen_all_payments(&settings, node.clone(), db.clone(), sub_handler.clone()).await?, + listen_all_payments( + &settings, + node.clone(), + onchain.clone(), + db.clone(), + sub_handler.clone(), + ) + .await?, ); } @@ -392,18 +401,16 @@ async fn main() -> Result<(), Error> { tasks.push(tokio::spawn(async move { if let Err(e) = axum::serve( listener, - router - .layer(cors_layer()) - .with_state(RouterState { - db, - state: status, - sub_handler, - history: vm_history, - settings, - rates: exchange, - work_sender: worker.commander(), - geoip: geoip.clone(), - }), + router.layer(cors_layer()).with_state(RouterState { + db, + state: status, + sub_handler, + history: vm_history, + settings, + rates: exchange, + work_sender: worker.commander(), + geoip: geoip.clone(), + }), ) .await { diff --git a/lnvps_api/src/data_migration/orphaned_custom_templates.rs b/lnvps_api/src/data_migration/orphaned_custom_templates.rs index db05f108..f2190f87 100644 --- a/lnvps_api/src/data_migration/orphaned_custom_templates.rs +++ b/lnvps_api/src/data_migration/orphaned_custom_templates.rs @@ -30,7 +30,9 @@ impl DataMigration for OrphanedCustomTemplatesMigration { let db = self.db.clone(); Box::pin(async move { let deleted = db.delete_orphaned_custom_vm_templates().await?; - Ok(format!("deleted {deleted} orphaned vm_custom_template row(s)")) + Ok(format!( + "deleted {deleted} orphaned vm_custom_template row(s)" + )) }) } } diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs index fb91cb14..4e1af81d 100644 --- a/lnvps_api/src/dvm/lnvps.rs +++ b/lnvps_api/src/dvm/lnvps.rs @@ -164,7 +164,7 @@ impl DVMHandler for LnvpsDvm { mod tests { use super::*; use crate::dvm::parse_job_request; - use crate::mocks::MockNode; + use crate::mocks::{MockNode, MockOnChainProvider}; use crate::settings::mock_settings; use lnvps_api_common::{ ChannelWorkCommander, ExchangeRateService, MockDb, MockExchangeRate, Ticker, @@ -227,6 +227,7 @@ mod tests { settings, db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), exch.clone(), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index de4431ae..431c87c5 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -35,6 +35,9 @@ use payments_rs::lightning::{ AddInvoiceRequest, AddInvoiceResponse, InvoiceUpdate, LightningNode, PayInvoiceRequest, PayInvoiceResponse, }; +use payments_rs::onchain::{ + ChainPaymentUpdate, NewAddressRequest, NewAddressResponse, OnChainProvider, PaymentCursor, +}; use ssh2::HashType::Sha256; use std::collections::HashMap; use std::ops::Add; @@ -409,3 +412,36 @@ impl LightningNode for MockNode { todo!() } } + +/// Mock on-chain provider, mirrors [`MockNode`] for on-chain payments. +/// +/// Derives unique fake bech32-style addresses and records every request. +/// Chain updates can be pushed by tests via [`MockOnChainProvider::updates`]. +#[derive(Clone, Debug, Default)] +pub struct MockOnChainProvider { + /// Addresses handed out so far, in order. + pub addresses: Arc>>, + /// Scripted chain updates returned from `subscribe_payments`. + pub updates: Arc>>, +} + +#[async_trait] +impl OnChainProvider for MockOnChainProvider { + async fn new_address(&self, req: NewAddressRequest) -> anyhow::Result { + let mut addresses = self.addresses.lock().await; + let address = format!("bcrt1qmock{:08}", addresses.len()); + addresses.push(address.clone()); + Ok(NewAddressResponse { + address, + label: req.label, + }) + } + + async fn subscribe_payments( + &self, + _from: Option, + ) -> anyhow::Result + Send>>> { + let updates = self.updates.lock().await.clone(); + Ok(Box::pin(futures::stream::iter(updates))) + } +} diff --git a/lnvps_api/src/payments/invoice.rs b/lnvps_api/src/payments/invoice.rs index 625c2ae9..36c5813f 100644 --- a/lnvps_api/src/payments/invoice.rs +++ b/lnvps_api/src/payments/invoice.rs @@ -96,7 +96,7 @@ impl NodeInvoiceHandler { #[cfg(test)] mod tests { use super::*; - use crate::mocks::MockNode; + use crate::mocks::{MockNode, MockOnChainProvider}; use crate::provisioner::VmProvisioner; use crate::settings::mock_settings; use crate::subscription::SubscriptionHandler; @@ -218,6 +218,7 @@ mod tests { mock_settings(), db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), Arc::new(MockExchangeRate::default()), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -371,6 +372,7 @@ mod tests { mock_settings(), db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), Arc::new(MockExchangeRate::default()), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -546,6 +548,7 @@ mod tests { mock_settings(), db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), rates, lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), diff --git a/lnvps_api/src/payments/mod.rs b/lnvps_api/src/payments/mod.rs index f34ac60d..db8ff4d4 100644 --- a/lnvps_api/src/payments/mod.rs +++ b/lnvps_api/src/payments/mod.rs @@ -1,10 +1,12 @@ use crate::payments::invoice::NodeInvoiceHandler; +use crate::payments::onchain::OnChainPaymentHandler; use crate::settings::Settings; use crate::subscription::SubscriptionHandler; use anyhow::Result; use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; use log::{error, info, warn}; use payments_rs::lightning::LightningNode; +use payments_rs::onchain::OnChainProvider; use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -12,6 +14,7 @@ use tokio::task::JoinHandle; use tokio::time::sleep; mod invoice; +mod onchain; #[cfg(feature = "revolut")] mod revolut; #[cfg(feature = "stripe")] @@ -24,6 +27,7 @@ mod stripe; pub async fn listen_all_payments( settings: &Settings, node: Arc, + onchain: Arc, db: Arc, sub_handler: SubscriptionHandler, ) -> Result>> { @@ -38,6 +42,16 @@ pub async fn listen_all_payments( } })); + let mut onchain_handler = OnChainPaymentHandler::new(onchain, db.clone(), sub_handler.clone()); + ret.push(tokio::spawn(async move { + loop { + if let Err(e) = onchain_handler.listen().await { + error!("onchain-error: {}", e); + } + sleep(Duration::from_secs(10)).await; + } + })); + #[cfg(feature = "revolut")] { use crate::payments::revolut::RevolutPaymentHandler; diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs new file mode 100644 index 00000000..8f77221d --- /dev/null +++ b/lnvps_api/src/payments/onchain.rs @@ -0,0 +1,483 @@ +//! On-chain Bitcoin payment watcher. +//! +//! Subscribes to chain events from the [`OnChainProvider`] (the LND wallet) +//! and settles [`SubscriptionPayment`]s when deposits confirm. +//! +//! # Correlation +//! +//! LND cannot label on-chain outputs, so deposits are correlated back to +//! payments by **receive address**: each on-chain payment stores its derived +//! address encrypted in `external_data`, and the watcher matches updates +//! against those in memory. +//! +//! # Delivery / de-duplication +//! +//! The provider stream is at-least-once and replayable; exactly-once +//! accounting is achieved by storing the **txid** in `external_id` (unique +//! index) and skipping any update whose txid is already recorded. +//! +//! # Pro-rating (issue #109) +//! +//! On-chain funds can arrive at any time and for any amount. Deposits are +//! never rejected: +//! +//! - A deposit for a pending payment settles it, with `amount`, `tax`, +//! `processing_fee` and `time_value` scaled by `received / expected` when +//! the amount differs from what was quoted (partial, late and over-payments +//! are all pro-rated at the quoted rate). +//! - A deposit to an address whose payment already settled (address reuse) +//! automatically inserts a **new** pro-rated renewal payment based on the +//! original quote. + +use crate::subscription::SubscriptionHandler; +use anyhow::{Result, bail}; +use chrono::Utc; +use futures::StreamExt; +use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; +use log::{debug, error, info, warn}; +use payments_rs::onchain::{ChainPaymentUpdate, OnChainProvider}; +use std::sync::Arc; + +pub struct OnChainPaymentHandler { + provider: Arc, + db: Arc, + sub_handler: SubscriptionHandler, +} + +/// Scale `value` by `received / expected` (u128 intermediate, no overflow). +fn pro_rate(value: u64, received: u64, expected: u64) -> u64 { + debug_assert!(expected > 0); + (value as u128 * received as u128 / expected as u128) as u64 +} + +impl OnChainPaymentHandler { + pub fn new( + provider: Arc, + db: Arc, + sub_handler: SubscriptionHandler, + ) -> Self { + Self { + provider, + db, + sub_handler, + } + } + + /// Find the payment a deposit belongs to: the most recent on-chain payment + /// whose stored receive address matches. + async fn payment_for_address(&self, address: &str) -> Result> { + Ok(self + .db + .list_subscription_payments_by_method(PaymentMethod::OnChain) + .await? + .into_iter() + .filter(|p| p.external_data.as_str() == address) + .max_by_key(|p| p.created)) + } + + /// Handle a confirmed deposit of `amount_msat` to `address` in tx `txid`. + async fn handle_deposit(&self, address: &str, txid: &str, amount_msat: u64) -> Result<()> { + // De-dupe: the stream is at-least-once, a known txid was already handled. + if self + .db + .get_subscription_payment_by_ext_id(txid) + .await + .is_ok() + { + debug!("Skipping already processed deposit {}", txid); + return Ok(()); + } + + let Some(payment) = self.payment_for_address(address).await? else { + debug!("Deposit {} to unknown address {}, ignoring", txid, address); + return Ok(()); + }; + + let expected = payment.amount + payment.tax + payment.processing_fee; + if expected == 0 { + bail!( + "Payment {} has zero expected amount, cannot pro-rate deposit {}", + hex::encode(&payment.id), + txid + ); + } + + if !payment.is_paid { + // Settle the pending payment, pro-rating if the received amount + // differs from the quote (partial / late / over-payment). + let mut payment = payment; + if amount_msat != expected { + info!( + "Pro-rating on-chain payment {}: received {} msat, expected {} msat", + hex::encode(&payment.id), + amount_msat, + expected + ); + payment.tax = pro_rate(payment.tax, amount_msat, expected); + payment.processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); + payment.amount = amount_msat - payment.tax - payment.processing_fee; + payment.time_value = payment + .time_value + .map(|tv| pro_rate(tv, amount_msat, expected)); + } + payment.external_id = Some(txid.to_string()); + self.db.update_subscription_payment(&payment).await?; + self.sub_handler.complete_payment(&payment).await?; + } else { + // Address reuse after settlement: insert a new pro-rated renewal + // payment based on the original quote (issue #109). + let Some(time_value) = payment.time_value else { + warn!( + "Deposit {} to settled payment {} without time_value, cannot pro-rate", + txid, + hex::encode(&payment.id) + ); + return Ok(()); + }; + info!( + "New deposit {} of {} msat to settled address of payment {}, creating pro-rated renewal", + txid, + amount_msat, + hex::encode(&payment.id) + ); + let tax = pro_rate(payment.tax, amount_msat, expected); + let processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); + let new_id: [u8; 32] = rand::random(); + let renewal = SubscriptionPayment { + id: new_id.to_vec(), + subscription_id: payment.subscription_id, + user_id: payment.user_id, + created: Utc::now(), + expires: Utc::now(), + amount: amount_msat - tax - processing_fee, + currency: payment.currency.clone(), + payment_method: PaymentMethod::OnChain, + payment_type: SubscriptionPaymentType::Renewal, + external_data: address.into(), + external_id: Some(txid.to_string()), + is_paid: false, + rate: payment.rate, + time_value: Some(pro_rate(time_value, amount_msat, expected)), + metadata: None, + tax, + processing_fee, + paid_at: None, + tax_rate: payment.tax_rate, + tax_country_code: payment.tax_country_code.clone(), + tax_treatment: payment.tax_treatment.clone(), + tax_evidence: payment.tax_evidence.clone(), + tax_breakdown: payment.tax_breakdown.clone(), + }; + self.db.insert_subscription_payment(&renewal).await?; + self.sub_handler.complete_payment(&renewal).await?; + } + Ok(()) + } + + pub async fn listen(&mut self) -> Result<()> { + info!("Listening for on-chain deposits"); + // Subscribe from the start; the txid de-dupe above makes replaying + // history harmless (at-least-once -> exactly-once). + let mut stream = self.provider.subscribe_payments(None).await?; + while let Some(update) = stream.next().await { + match update { + ChainPaymentUpdate::Confirmed { + address, + txid, + amount_msat, + .. + } => { + if let Err(e) = self.handle_deposit(&address, &txid, amount_msat).await { + error!("onchain deposit error for {}: {}", txid, e); + } + } + ChainPaymentUpdate::Detected { + address, + txid, + amount_msat, + confirmations, + .. + } => { + debug!( + "Detected deposit {} of {} msat to {} ({} confs)", + txid, amount_msat, address, confirmations + ); + } + ChainPaymentUpdate::Error(e) => bail!("onchain stream error: {}", e), + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mocks::{MockNode, MockOnChainProvider}; + use crate::settings::mock_settings; + use anyhow::Result; + use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; + use lnvps_db::{ + IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, SubscriptionType, Vm, + }; + + const ADDRESS: &str = "bcrt1qtestaddr0"; + const AMOUNT: u64 = 1_000_000; + const TAX: u64 = 100_000; + const EXPECTED: u64 = AMOUNT + TAX; + const TIME_VALUE: u64 = 86_400; + + /// DB with a VM + subscription and one unpaid on-chain payment for [`ADDRESS`]. + async fn setup() -> Result<( + Arc, + Arc, + OnChainPaymentHandler, + SubscriptionPayment, + )> { + let db = Arc::new(MockDb::default()); + let node = Arc::new(MockNode::default()); + let provider = Arc::new(MockOnChainProvider::default()); + + let pubkey: [u8; 32] = [1u8; 32]; + let user_id = db.upsert_user(&pubkey).await?; + let ssh_key_id = db + .insert_user_ssh_key(&lnvps_db::UserSshKey { + id: 0, + name: "test".to_string(), + user_id, + created: Utc::now(), + key_data: "ssh-rsa AAA==".into(), + }) + .await?; + + let (sub_id, line_item_ids) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "test".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: "BTC".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::Vps, + name: "vm renewal".to_string(), + description: None, + amount: 1000, + setup_amount: 0, + configuration: None, + }], + ) + .await?; + + db.insert_vm(&Vm { + id: 0, + host_id: 1, + user_id, + image_id: 1, + template_id: Some(1), + custom_template_id: None, + subscription_line_item_id: line_item_ids[0], + ssh_key_id: Some(ssh_key_id), + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + deleted: false, + ..Default::default() + }) + .await?; + + let payment = SubscriptionPayment { + id: vec![42u8; 32], + subscription_id: sub_id, + user_id, + created: Utc::now(), + expires: Utc::now() + chrono::Duration::hours(1), + amount: AMOUNT, + currency: "BTC".to_string(), + payment_method: PaymentMethod::OnChain, + payment_type: SubscriptionPaymentType::Renewal, + external_data: ADDRESS.into(), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: Some(TIME_VALUE), + metadata: None, + tax: TAX, + processing_fee: 0, + paid_at: None, + tax_rate: None, + tax_country_code: None, + tax_treatment: None, + tax_evidence: None, + tax_breakdown: None, + }; + db.insert_subscription_payment(&payment).await?; + + let sub = SubscriptionHandler::new( + mock_settings(), + db.clone(), + node, + provider.clone(), + Arc::new(MockExchangeRate::default()), + lnvps_api_common::VatClient::new(), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + )?; + let handler = OnChainPaymentHandler::new(provider.clone(), db.clone(), sub); + + Ok((db, provider, handler, payment)) + } + + async fn get_payment(db: &MockDb, id: &[u8]) -> SubscriptionPayment { + db.subscription_payments + .lock() + .await + .iter() + .find(|p| p.id == id) + .cloned() + .expect("payment") + } + + #[test] + fn test_pro_rate() { + assert_eq!(pro_rate(TIME_VALUE, EXPECTED, EXPECTED), TIME_VALUE); + assert_eq!(pro_rate(TIME_VALUE, EXPECTED / 2, EXPECTED), TIME_VALUE / 2); + assert_eq!(pro_rate(TIME_VALUE, EXPECTED * 2, EXPECTED), TIME_VALUE * 2); + assert_eq!(pro_rate(0, EXPECTED, EXPECTED), 0); + // u128 intermediate: no overflow on large values + assert_eq!(pro_rate(u64::MAX, 1000, 1000), u64::MAX); + } + + /// Exact deposit settles the pending payment untouched. + #[tokio::test] + async fn test_exact_deposit_settles_payment() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.external_id.as_deref(), Some("tx1")); + assert_eq!(p.amount, AMOUNT); + assert_eq!(p.tax, TAX); + assert_eq!(p.time_value, Some(TIME_VALUE)); + Ok(()) + } + + /// Partial deposit is pro-rated: amount/tax/time all scale by received/expected. + #[tokio::test] + async fn test_partial_deposit_pro_rates() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + handler.handle_deposit(ADDRESS, "tx1", EXPECTED / 2).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.tax, TAX / 2); + assert_eq!(p.amount, EXPECTED / 2 - TAX / 2); + assert_eq!(p.time_value, Some(TIME_VALUE / 2)); + Ok(()) + } + + /// A replayed txid must not settle or create anything (at-least-once stream). + #[tokio::test] + async fn test_duplicate_txid_skipped() -> Result<()> { + let (db, _provider, handler, _payment) = setup().await?; + handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + + assert_eq!(db.subscription_payments.lock().await.len(), 1); + Ok(()) + } + + /// Deposits to unknown addresses are ignored. + #[tokio::test] + async fn test_unknown_address_ignored() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + handler + .handle_deposit("bcrt1qunknown", "tx1", EXPECTED) + .await?; + + let p = get_payment(&db, &payment.id).await; + assert!(!p.is_paid); + assert_eq!(db.subscription_payments.lock().await.len(), 1); + Ok(()) + } + + /// A deposit to an already-settled address inserts a new pro-rated renewal. + #[tokio::test] + async fn test_address_reuse_inserts_renewal() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + // half the original amount arrives later at the same address + handler.handle_deposit(ADDRESS, "tx2", EXPECTED / 2).await?; + + let payments = db.subscription_payments.lock().await.clone(); + assert_eq!(payments.len(), 2); + let renewal = payments + .iter() + .find(|p| p.external_id.as_deref() == Some("tx2")) + .expect("renewal payment"); + assert!(renewal.is_paid); + assert_ne!(renewal.id, payment.id); + assert_eq!(renewal.payment_type, SubscriptionPaymentType::Renewal); + assert_eq!(renewal.payment_method, PaymentMethod::OnChain); + assert_eq!(renewal.subscription_id, payment.subscription_id); + assert_eq!(renewal.external_data.as_str(), ADDRESS); + assert_eq!(renewal.tax, TAX / 2); + assert_eq!(renewal.amount, EXPECTED / 2 - TAX / 2); + assert_eq!(renewal.time_value, Some(TIME_VALUE / 2)); + Ok(()) + } + + /// listen() drains scripted updates: Confirmed settles, Detected is ignored. + #[tokio::test] + async fn test_listen_processes_confirmed_updates() -> Result<()> { + let (db, provider, mut handler, payment) = setup().await?; + provider.updates.lock().await.extend([ + ChainPaymentUpdate::Detected { + address: ADDRESS.to_string(), + txid: "tx1".to_string(), + amount_msat: EXPECTED, + confirmations: 0, + label: None, + }, + ChainPaymentUpdate::Confirmed { + address: ADDRESS.to_string(), + txid: "tx1".to_string(), + amount_msat: EXPECTED, + confirmations: 1, + label: None, + }, + ]); + handler.listen().await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.external_id.as_deref(), Some("tx1")); + Ok(()) + } + + /// A stream error bubbles up so the outer loop reconnects. + #[tokio::test] + async fn test_listen_stream_error_bails() -> Result<()> { + let (_db, provider, mut handler, _payment) = setup().await?; + provider + .updates + .lock() + .await + .push(ChainPaymentUpdate::Error("rpc down".to_string())); + assert!(handler.listen().await.is_err()); + Ok(()) + } +} diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs index 9e498a3b..e01c39e7 100644 --- a/lnvps_api/src/provisioner/vm.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -1049,7 +1049,7 @@ impl SpawnVmContext { #[cfg(test)] mod tests { use super::*; - use crate::mocks::{MockDnsServer, MockNode, MockRouter}; + use crate::mocks::{MockDnsServer, MockNode, MockOnChainProvider, MockRouter}; use crate::settings::mock_settings; use crate::subscription::{ SubscriptionHandler, SubscriptionLineItemHandler, VmLineItemHandler, @@ -1140,6 +1140,7 @@ mod tests { settings, db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), rates.clone(), lnvps_api_common::VatClient::new(), wrk.clone(), @@ -1365,6 +1366,7 @@ mod tests { mock_settings(), db.clone(), node, + Arc::new(MockOnChainProvider::default()), rates, lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -2650,6 +2652,7 @@ mod tests { mock_settings(), db.clone(), node, + Arc::new(MockOnChainProvider::default()), rates, lnvps_api_common::VatClient::new(), wrk, diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index 49fd9c3c..6ebfe180 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -2,6 +2,7 @@ use anyhow::Result; use lnvps_api_common::RedisConfig; use payments_rs::fiat::FiatPaymentService; use payments_rs::lightning::LightningNode; +use payments_rs::onchain::OnChainProvider; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; @@ -592,6 +593,38 @@ impl Settings { _ => anyhow::bail!("Unsupported lightning config!"), } } + + /// Build the on-chain payment provider from the configured LND node. + /// + /// On-chain payments reuse the same LND wallet as the Lightning node, so + /// this is required in the same way [`Self::get_node`] is: any backend + /// that cannot receive on-chain payments is an error. + pub async fn get_onchain(&self) -> Result> { + match &self.lightning { + #[cfg(feature = "onchain")] + LightningConfig::LND { + url, + cert, + macaroon, + } => { + use payments_rs::onchain::{LndAddressType, LndOnChainConfig, LndOnChainProvider}; + Ok(Arc::new( + LndOnChainProvider::new( + url, + cert, + macaroon, + LndOnChainConfig { + address_type: LndAddressType::WitnessPubkeyHash, + account: None, + min_confirmations: 1, + }, + ) + .await?, + )) + } + _ => anyhow::bail!("Unsupported lightning config for on-chain payments!"), + } + } } #[cfg(test)] diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index c653e41a..88e5b1a2 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -28,6 +28,7 @@ use log::{debug, info, warn}; use payments_rs::currency::{Currency, CurrencyAmount}; use payments_rs::fiat::FiatPaymentService; use payments_rs::lightning::{AddInvoiceRequest, LightningNode}; +use payments_rs::onchain::{NewAddressRequest, OnChainProvider}; use std::ops::Add; use std::str::FromStr; use std::sync::Arc; @@ -106,6 +107,7 @@ pub struct SubscriptionHandler { tx: Arc, node: Arc, + onchain: Arc, revolut: Option>, pe: PricingEngine, @@ -120,6 +122,7 @@ impl SubscriptionHandler { settings: Settings, db: Arc, node: Arc, + onchain: Arc, rates: Arc, vat: VatClient, tx: Arc, @@ -132,6 +135,7 @@ impl SubscriptionHandler { db, tx, node, + onchain, vm_state_cache, saved_payment_settle_timeout: SAVED_PAYMENT_SETTLE_TIMEOUT, }) @@ -153,6 +157,26 @@ impl SubscriptionHandler { self.db.clone() } + /// Derive a fresh on-chain receive address for a new payment. + /// + /// Returns the address string to store in `external_data`. + async fn new_onchain_address( + &self, + amount_msat: u64, + memo: String, + label: String, + ) -> Result { + let resp = self + .onchain + .new_address(NewAddressRequest { + amount: CurrencyAmount::millisats(amount_msat), + memo: Some(memo), + label: Some(label), + }) + .await?; + Ok(resp.address) + } + #[cfg(test)] pub(crate) fn set_revolut_for_test(&mut self, r: Arc) { self.revolut = Some(r); @@ -714,6 +738,64 @@ impl SubscriptionHandler { tax_breakdown: tax_breakdown.clone(), } } + PaymentMethod::OnChain => { + ensure!( + converted_currency == Currency::BTC, + "On-chain payment must be in BTC" + ); + // On-chain payments need time for the tx to be broadcast and + // confirmed; hold the quoted rate for longer than Lightning. + const ONCHAIN_EXPIRE: u64 = 3600; + let invoice_amount = round_msat_to_sat(converted_amount + tax); + let desc = match payment_type { + SubscriptionPaymentType::Purchase => { + format!("Subscription purchase: {}", subscription.name) + } + SubscriptionPaymentType::Renewal => { + format!("Subscription renewal: {}", subscription.name) + } + SubscriptionPaymentType::Upgrade => { + format!("Subscription upgrade: {}", subscription.name) + } + }; + + let new_id: [u8; 32] = rand::random(); + let address = self + .new_onchain_address(invoice_amount, desc, hex::encode(new_id)) + .await?; + + SubscriptionPayment { + id: new_id.to_vec(), + subscription_id, + user_id: subscription.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(ONCHAIN_EXPIRE)), + amount: converted_amount, + currency: converted_currency.to_string(), + payment_method: method, + payment_type, + external_data: address.into(), + // txid is not known until the payment arrives; the chain + // watcher fills this in when the deposit is seen. + external_id: None, + is_paid: false, + rate, + time_value: if time_value > 0 { + Some(time_value) + } else { + None + }, + metadata: None, + tax, + processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + } + } PaymentMethod::Paypal => bail!("PayPal not implemented"), PaymentMethod::Stripe => bail!("Stripe not implemented"), }; @@ -903,6 +985,49 @@ impl SubscriptionHandler { tax_breakdown: tax_breakdown.clone(), } } + PaymentMethod::OnChain => { + ensure!( + p.currency == Currency::BTC, + "Cannot create on-chain payments for non-BTC currency" + ); + const ONCHAIN_EXPIRE: u64 = 3600; + let total_amount = round_msat_to_sat(p.amount + p.tax); + info!( + "Creating on-chain address for vm {vm_id} for {} sats", + total_amount / 1000 + ); + let new_id: [u8; 32] = rand::random(); + let address = self + .new_onchain_address(total_amount, desc, hex::encode(new_id)) + .await?; + SubscriptionPayment { + id: new_id.to_vec(), + subscription_id, + user_id: vm.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(ONCHAIN_EXPIRE)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: address.into(), + // txid is filled in by the chain watcher when the + // deposit is seen. + external_id: None, + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + } + } PaymentMethod::Paypal => bail!("PayPal not implemented"), PaymentMethod::Stripe => { bail!("Stripe payment creation not yet implemented") @@ -1128,7 +1253,7 @@ impl SubscriptionHandler { #[cfg(all(test, feature = "revolut"))] mod revolut_autorenew_tests { use super::*; - use crate::mocks::MockNode; + use crate::mocks::{MockNode, MockOnChainProvider}; use crate::settings::mock_settings; use config::{Config, File}; use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; @@ -1242,6 +1367,7 @@ mod revolut_autorenew_tests { settings, db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), Arc::new(MockExchangeRate::default()), VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -1266,7 +1392,7 @@ mod revolut_autorenew_tests { #[cfg(test)] mod revolut_offline_tests { use super::*; - use crate::mocks::MockNode; + use crate::mocks::{MockNode, MockOnChainProvider}; use crate::settings::mock_settings; use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; use lnvps_db::{ @@ -1415,6 +1541,7 @@ mod revolut_offline_tests { mock_settings(), db.clone(), node, + Arc::new(MockOnChainProvider::default()), rates, VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -1484,6 +1611,7 @@ mod revolut_offline_tests { mock_settings(), db.clone(), node, + Arc::new(MockOnChainProvider::default()), Arc::new(MockExchangeRate::default()), VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -1511,6 +1639,76 @@ mod revolut_offline_tests { assert_eq!(ev["declared_country"], "DEU"); } + /// An on-chain renewal derives a fresh receive address from the provider and + /// stores it in `external_data`; the txid (`external_id`) stays empty until + /// the chain watcher sees the deposit. + #[tokio::test] + async fn renew_subscription_onchain_derives_address() { + let db = Arc::new(MockDb::default()); + let node = Arc::new(MockNode::default()); + let onchain = Arc::new(MockOnChainProvider::default()); + let user_id = db.upsert_user(&[22u8; 32]).await.unwrap(); + + // BTC-denominated subscription so an on-chain renewal needs no FX. + let (sub_id, _items) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "s".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: true, + is_setup: true, + currency: "BTC".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::IpRange, + name: "hosting".to_string(), + description: None, + amount: 100_000, + setup_amount: 0, + configuration: None, + }], + ) + .await + .unwrap(); + + let sub = SubscriptionHandler::new( + mock_settings(), + db.clone(), + node, + onchain.clone(), + Arc::new(MockExchangeRate::default()), + VatClient::new(), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + ) + .unwrap(); + + let payment = sub + .renew_subscription(sub_id, PaymentMethod::OnChain, 1) + .await + .unwrap(); + + assert_eq!(payment.payment_method, PaymentMethod::OnChain); + assert!(!payment.is_paid); + assert_eq!(payment.external_id, None, "txid unknown until deposit"); + // The stored external_data is the address derived from the provider + let addresses = onchain.addresses.lock().await; + assert_eq!(addresses.len(), 1); + assert_eq!(payment.external_data.as_str(), addresses[0]); + } + #[tokio::test] async fn test_default_revolut_payment_method_selection() { let (db, sub, user_id, _sub_id) = setup(true).await; diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 06f8bd99..95e93035 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -895,9 +895,7 @@ impl Worker { Err(e) => warn!("[dns-reconcile] reverse failed for {}: {}", a.ip, e), } } - if changed - && let Err(e) = self.db.update_vm_ip_assignment(a).await - { + if changed && let Err(e) = self.db.update_vm_ip_assignment(a).await { warn!( "[dns-reconcile] failed to persist dns refs for {}: {}", a.ip, e @@ -3136,7 +3134,7 @@ impl Worker { #[cfg(test)] mod tests { use super::*; - use crate::mocks::MockNode; + use crate::mocks::{MockNode, MockOnChainProvider}; use crate::settings::mock_settings; use crate::subscription::SubscriptionHandler; use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate}; @@ -3160,6 +3158,7 @@ mod tests { settings.clone(), db.clone(), node.clone(), + Arc::new(MockOnChainProvider::default()), rates, lnvps_api_common::VatClient::new(), work_commander.clone(), diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index b5d4b4c1..e7e37a36 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -2670,6 +2670,7 @@ pub enum AdminPaymentMethod { Revolut, Paypal, Stripe, + OnChain, } impl From for AdminPaymentMethod { @@ -2679,6 +2680,7 @@ impl From for AdminPaymentMethod { PaymentMethod::Revolut => AdminPaymentMethod::Revolut, PaymentMethod::Paypal => AdminPaymentMethod::Paypal, PaymentMethod::Stripe => AdminPaymentMethod::Stripe, + PaymentMethod::OnChain => AdminPaymentMethod::OnChain, } } } @@ -3513,6 +3515,7 @@ pub enum AdminPaymentMethodType { Revolut, Paypal, Stripe, + OnChain, } impl From for AdminPaymentMethodType { @@ -3522,6 +3525,7 @@ impl From for AdminPaymentMethodType { lnvps_db::PaymentMethod::Revolut => AdminPaymentMethodType::Revolut, lnvps_db::PaymentMethod::Paypal => AdminPaymentMethodType::Paypal, lnvps_db::PaymentMethod::Stripe => AdminPaymentMethodType::Stripe, + lnvps_db::PaymentMethod::OnChain => AdminPaymentMethodType::OnChain, } } } @@ -3533,6 +3537,7 @@ impl From for lnvps_db::PaymentMethod { AdminPaymentMethodType::Revolut => lnvps_db::PaymentMethod::Revolut, AdminPaymentMethodType::Paypal => lnvps_db::PaymentMethod::Paypal, AdminPaymentMethodType::Stripe => lnvps_db::PaymentMethod::Stripe, + AdminPaymentMethodType::OnChain => lnvps_db::PaymentMethod::OnChain, } } } @@ -3596,6 +3601,7 @@ pub enum SanitizedProviderConfig { Revolut(SanitizedRevolutConfig), Stripe(SanitizedStripeConfig), Paypal(SanitizedPaypalConfig), + OnChain(SanitizedLndConfig), } impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { @@ -3640,6 +3646,13 @@ impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { has_client_secret: !cfg.client_secret.is_empty(), }) } + lnvps_db::ProviderConfig::OnChain(cfg) => { + SanitizedProviderConfig::OnChain(SanitizedLndConfig { + url: cfg.url.clone(), + cert_path: cfg.cert_path.display().to_string(), + macaroon_path: cfg.macaroon_path.display().to_string(), + }) + } } } } diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 1c81caf7..5df23c64 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -2255,6 +2255,18 @@ impl LNVpsDbBase for MockDb { .context("Subscription payment not found")?) } + async fn list_subscription_payments_by_method( + &self, + method: lnvps_db::PaymentMethod, + ) -> DbResult> { + let payments = self.subscription_payments.lock().await; + Ok(payments + .iter() + .filter(|p| p.payment_method == method) + .cloned() + .collect()) + } + async fn get_subscription_payment_with_company( &self, id: &Vec, @@ -2311,8 +2323,23 @@ impl LNVpsDbBase for MockDb { async fn update_subscription_payment(&self, payment: &SubscriptionPayment) -> DbResult<()> { let mut payments = self.subscription_payments.lock().await; if let Some(p) = payments.iter_mut().find(|p| p.id == payment.id) { + // Mirror the MySQL impl: update every column that query writes + p.subscription_id = payment.subscription_id; + p.user_id = payment.user_id; + p.created = payment.created; + p.expires = payment.expires; + p.amount = payment.amount; + p.currency = payment.currency.clone(); + p.payment_method = payment.payment_method; + p.payment_type = payment.payment_type; + p.external_data = payment.external_data.clone(); + p.external_id = payment.external_id.clone(); p.is_paid = payment.is_paid; - p.paid_at = payment.paid_at; + p.rate = payment.rate; + p.tax = payment.tax; + p.processing_fee = payment.processing_fee; + p.time_value = payment.time_value; + p.metadata = payment.metadata.clone(); Ok(()) } else { Err(anyhow!("Subscription payment not found").into()) @@ -3875,8 +3902,12 @@ mod tests { ); } - let counts: HashMap = - db.count_vms_by_os_image().await.unwrap().into_iter().collect(); + let counts: HashMap = db + .count_vms_by_os_image() + .await + .unwrap() + .into_iter() + .collect(); assert_eq!(counts.get(&1), Some(&2)); assert_eq!(counts.get(&2), Some(&1)); // deleted VM excluded } @@ -4275,7 +4306,12 @@ mod tests { .unwrap(); // Live (non-deleted) never-paid VM is not returned. - assert!(db.list_deleted_never_paid_vm_ids().await.unwrap().is_empty()); + assert!( + db.list_deleted_never_paid_vm_ids() + .await + .unwrap() + .is_empty() + ); // Soft-delete it -> now eligible for purge. db.delete_vm(vm_id).await.unwrap(); @@ -4289,7 +4325,12 @@ mod tests { let mut subs = db.subscriptions.lock().await; subs.get_mut(&1).unwrap().is_setup = true; } - assert!(db.list_deleted_never_paid_vm_ids().await.unwrap().is_empty()); + assert!( + db.list_deleted_never_paid_vm_ids() + .await + .unwrap() + .is_empty() + ); } /// Firewall rule CRUD via the mock DB. diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 9e9e5f01..71f1c9c6 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -1214,7 +1214,7 @@ impl PricingEngine { method: PaymentMethod, ) -> Result { Ok(match (list_price.currency(), method) { - (c, PaymentMethod::Lightning) if c != Currency::BTC => { + (c, PaymentMethod::Lightning | PaymentMethod::OnChain) if c != Currency::BTC => { // convert to BTC if price is not already in bitcoin let ticker = self.get_ticker(Currency::BTC, c).await?; let mut converted = ticker.convert_with_rate(list_price)?; @@ -1222,7 +1222,7 @@ impl PricingEngine { converted.amount = round_to_sat(converted.amount); converted } - (c, PaymentMethod::Lightning) if c == Currency::BTC => { + (c, PaymentMethod::Lightning | PaymentMethod::OnChain) if c == Currency::BTC => { // pass-through price as BTC, rounded to nearest satoshi ConvertedCurrencyAmount { amount: round_to_sat(list_price), diff --git a/lnvps_db/migrations/20260720172401_onchain_payments.sql b/lnvps_db/migrations/20260720172401_onchain_payments.sql new file mode 100644 index 00000000..4b208cd2 --- /dev/null +++ b/lnvps_db/migrations/20260720172401_onchain_payments.sql @@ -0,0 +1,9 @@ +-- On-chain payments (issue #109) +-- +-- Every payment must have a unique external_id; the on-chain watcher stores +-- the bitcoin txid here and relies on uniqueness for at-least-once stream +-- de-duplication (a replayed deposit must not create or settle a second +-- payment). NULLs are exempt from MySQL unique indexes, so pending payments +-- (txid not yet known) are unaffected. +ALTER TABLE subscription_payment + ADD CONSTRAINT uq_subscription_payment_external_id UNIQUE (external_id); diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index b105e534..81b62a9b 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -757,6 +757,15 @@ pub trait LNVpsDbBase: Send + Sync { &self, external_id: &str, ) -> DbResult; + /// List all payments made with a given payment method. + /// + /// Used by the on-chain watcher to correlate incoming deposits back to + /// payments by receive address (stored encrypted in `external_data`, so + /// the match must happen in memory). + async fn list_subscription_payments_by_method( + &self, + method: PaymentMethod, + ) -> DbResult>; async fn get_subscription_payment_with_company( &self, id: &Vec, diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 38ed308a..6de42be7 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1474,6 +1474,8 @@ pub enum PaymentMethod { Revolut, Paypal, Stripe, + /// On-chain Bitcoin payments + OnChain, } #[derive(Type, Clone, Copy, Debug, Default, PartialEq)] @@ -1491,6 +1493,7 @@ impl Display for PaymentMethod { PaymentMethod::Revolut => write!(f, "Revolut"), PaymentMethod::Paypal => write!(f, "PayPal"), PaymentMethod::Stripe => write!(f, "Stripe"), + PaymentMethod::OnChain => write!(f, "OnChain"), } } } @@ -1504,6 +1507,7 @@ impl FromStr for PaymentMethod { "revolut" => Ok(PaymentMethod::Revolut), "paypal" => Ok(PaymentMethod::Paypal), "stripe" => Ok(PaymentMethod::Stripe), + "onchain" => Ok(PaymentMethod::OnChain), _ => bail!("Unknown payment method: {}", s), } } @@ -2391,6 +2395,57 @@ mod tests { assert_eq!(parsed, arch); } } + + #[test] + fn test_payment_method_roundtrip() { + for (s, m) in [ + ("lightning", PaymentMethod::Lightning), + ("revolut", PaymentMethod::Revolut), + ("paypal", PaymentMethod::Paypal), + ("stripe", PaymentMethod::Stripe), + ("onchain", PaymentMethod::OnChain), + ] { + assert_eq!(PaymentMethod::from_str(s).unwrap(), m); + } + assert_eq!(PaymentMethod::OnChain.to_string(), "OnChain"); + assert!(PaymentMethod::from_str("bogus").is_err()); + } + + #[test] + fn test_provider_config_onchain() { + let config = ProviderConfig::OnChain(LndConfig { + url: "https://localhost:10009".to_string(), + cert_path: "/tls.cert".into(), + macaroon_path: "/admin.macaroon".into(), + }); + assert_eq!(config.provider_type(), "onchain"); + assert_eq!(config.payment_method(), PaymentMethod::OnChain); + assert!(config.as_onchain().is_some()); + assert!(config.as_lnd().is_none()); + assert!( + ProviderConfig::Lnd(LndConfig { + url: "".to_string(), + cert_path: "".into(), + macaroon_path: "".into(), + }) + .as_onchain() + .is_none() + ); + + // serde round-trip via PaymentMethodConfig helpers + let mut pmc = PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::OnChain, + "onchain".to_string(), + true, + config, + ); + assert_eq!(pmc.provider_type, "onchain"); + let parsed = pmc.get_provider_config().expect("config round-trips"); + assert_eq!(parsed.as_onchain().unwrap().url, "https://localhost:10009"); + pmc.set_provider_config(parsed); + assert_eq!(pmc.provider_type, "onchain"); + } } /// Available IP Space - Inventory of IP ranges available for sale @@ -2511,6 +2566,8 @@ pub enum ProviderConfig { Stripe(StripeProviderConfig), /// PayPal fiat payment configuration Paypal(PaypalProviderConfig), + /// On-chain Bitcoin payment configuration (reuses the LND wallet backend) + OnChain(LndConfig), } impl ProviderConfig { @@ -2522,6 +2579,7 @@ impl ProviderConfig { ProviderConfig::Revolut(_) => "revolut", ProviderConfig::Stripe(_) => "stripe", ProviderConfig::Paypal(_) => "paypal", + ProviderConfig::OnChain(_) => "onchain", } } @@ -2532,6 +2590,7 @@ impl ProviderConfig { ProviderConfig::Revolut(_) => PaymentMethod::Revolut, ProviderConfig::Stripe(_) => PaymentMethod::Stripe, ProviderConfig::Paypal(_) => PaymentMethod::Paypal, + ProviderConfig::OnChain(_) => PaymentMethod::OnChain, } } @@ -2574,6 +2633,14 @@ impl ProviderConfig { _ => None, } } + + /// Get on-chain config if this is an on-chain provider + pub fn as_onchain(&self) -> Option<&LndConfig> { + match self { + ProviderConfig::OnChain(cfg) => Some(cfg), + _ => None, + } + } } /// Payment method configuration stored in database diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 41b9d29f..6482c417 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -2496,6 +2496,18 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn list_subscription_payments_by_method( + &self, + method: PaymentMethod, + ) -> DbResult> { + Ok( + sqlx::query_as("SELECT * FROM subscription_payment WHERE payment_method = ?") + .bind(method as u16) + .fetch_all(&self.db) + .await?, + ) + } + async fn get_subscription_payment_with_company( &self, id: &Vec, diff --git a/work/onchain-payments.md b/work/onchain-payments.md new file mode 100644 index 00000000..257f7c3d --- /dev/null +++ b/work/onchain-payments.md @@ -0,0 +1,129 @@ +# On-chain Bitcoin Payments (issue #109) + +**Status:** in-progress +**Started:** 2026-07-20 +**Last updated:** 2026-07-20 + +**Design decisions (from review):** +- No new provider config struct — `ProviderConfig::OnChain(LndConfig)` reuses `LndConfig`. +- On-chain provider is **required**, injected into `SubscriptionHandler::new` as + `Arc` exactly like `node: Arc`; built via + `Settings::get_onchain()` which mirrors `get_node()` (LND arm gated by the existing + `onchain` cargo feature, other backends bail). No Option, no type alias. +- `payments-rs` stays a plain workspace dep (`0.4.1` from crates.io); the trait is + available through the default `onchain` feature, same as `lightning` via `lnd`. + +## Goal + +Support on-chain Bitcoin payments. A customer requesting an on-chain payment gets +a freshly derived receive address; a background watcher streams chain updates and +records each confirmed deposit as a `subscription_payment` row. Because on-chain +funds can arrive at any time (including after expiry), late payments are pro-rated +and new `subscription_payment` entries are inserted automatically. + +Backend: `payments-rs` `OnChainProvider` (LND on-chain, `method-lnd-onchain` +feature), now available on crates.io as `payments-rs = 0.4.1`. + +## Findings + +### payments-rs API (crates.io 0.4.1, module `payments_rs::onchain`) +- `OnChainProvider` trait: + - `new_address(NewAddressRequest) -> NewAddressResponse` — derive receive address. + `NewAddressRequest { amount: CurrencyAmount, memo, label }`, + `NewAddressResponse { address, label }`. + - `subscribe_payments(from: Option) -> Stream` + — resumable, at-least-once. **De-dupe on `txid`** for exactly-once accounting. +- `LndOnChainProvider::new(url, tls_cert, macaroon, LndOnChainConfig { address_type, + account, min_confirmations })`. +- `ChainPaymentUpdate::{Detected, Confirmed, Error}`; each payment variant carries + `address`, `txid`, `amount_msat` (real amount received), `confirmations`, `label` + (LND leaves `label` = None → correlate by `address`). +- Amounts in **millisats**; `sats_to_msat` / `msat_to_sats` helpers. +- `PaymentCursor { block_height, block_hash }` — persist to resume without missing/ + double-counting deposits across restarts. +- Feature flags to enable in `lnvps_api/Cargo.toml`: `method-lnd-onchain`. + +### Existing LNVPS structures to extend +- `lnvps_db::PaymentMethod` enum (`model.rs:1471`) — add `OnChain`. Update `Display` + (1487) and `FromStr` (1498) arms. +- `lnvps_db::ProviderConfig` enum (`model.rs:2503`) — add `OnChain(LndOnChainConfig)` + variant + a new config struct near `LndConfig` (2443). Serde-tagged like siblings. +- `SubscriptionPayment` (`model.rs:2023`): `external_id: Option` (unique) = + **txid**; `external_data: EncryptedString` = **bitcoin address**. `SubscriptionPaymentType` + needs an on-chain-relevant handling; reuse existing types. +- Payment factory `lnvps_api/src/payment_factory.rs` — currently splits Lightning + node vs fiat service. Add `create_onchain_provider(&config) -> Arc` + and a `get_onchain_provider_for_company`. +- Listener wiring `lnvps_api/src/payments/mod.rs::listen_all_payments` — add an + on-chain handler task loop (like Revolut/Stripe), gated behind an `onchain` feature. +- New module `lnvps_api/src/payments/onchain.rs` — the watcher: subscribe, de-dupe + txids, resolve address→(subscription/order), insert `subscription_payment`, + pro-rate late payments, persist `PaymentCursor`. +- Pricing/quote surface `lnvps_api_common/src/pricing.rs` — on-chain amount is BTC + like Lightning; expose as a payable method. +- Migration under `lnvps_db/migrations/` — persist cursor + any address→order table, + and confirm `subscription_payment.external_id` uniqueness/index. + +### Amount / currency +- On-chain BTC amounts are millisats — align with `CurrencyAmount::millisats` and the + existing BTC handling in `docs/agents/currency.md`. + +## Tasks + +- [x] Switch `payments-rs` from the git rev to the published crates.io `0.4.1` + (`Cargo.toml`, `cargo update`, verified payment crates compile). + +### Increment 1 — DB layer (S/M) +- [x] Add `PaymentMethod::OnChain` (+ `Display`/`FromStr`) in `lnvps_db/src/model.rs`. +- [x] `ProviderConfig::OnChain(LndConfig)` variant (reuses `LndConfig`) + `as_onchain()`. +- [x] Migration `20260720172401_onchain_payments.sql`: unique index on + `subscription_payment.external_id` (txid de-dupe). No cursor persistence + needed — watcher resubscribes from genesis and de-dupes by txid. +- [x] Unit tests for enum round-trips (Display/FromStr/serde) + `as_onchain`. +- [x] New DB method `list_subscription_payments_by_method` (trait + mysql + mock); + mock `update_subscription_payment` fixed to mirror all MySQL columns. + +### Increment 2 — provider wiring (S) +- [x] `onchain` feature flag in `lnvps_api/Cargo.toml` (`payments-rs/method-lnd-onchain`, + in defaults). +- [x] `Settings::get_onchain()` mirroring `get_node()`; required provider injected into + `SubscriptionHandler::new` after `node` (all call sites updated). +- [x] `MockOnChainProvider` in `lnvps_api/src/mocks.rs` (mirrors `MockNode`; scripted + updates via `updates`, records derived `addresses`). + +### Increment 3 — address derivation / create-payment flow (M) +- [x] `SubscriptionHandler::new_onchain_address` helper (`new_address` on provider). +- [x] `PaymentMethod::OnChain` arms in both payment-creation matches + (`subscription/mod.rs`): BTC-only, 3600s expiry, address in `external_data`, + `external_id = None` until the watcher sees the txid. +- [x] API surface: `ApiPaymentData::OnChain { address }`, `ApiPaymentMethod::OnChain`, + BTC default currency (`routes.rs`); admin `AdminPaymentMethod(Type)::OnChain` + + `SanitizedProviderConfig::OnChain`. +- [x] Test `renew_subscription_onchain_derives_address` (payment-creation arm). +- [x] `get_amount_and_rate` in `pricing.rs` treats OnChain like Lightning (BTC + conversion); processing fee is config-driven (0 without config). + +### Increment 4 — chain watcher + pro-rating (M/L) +- [x] `lnvps_api/src/payments/onchain.rs`: `OnChainPaymentHandler` — subscribe loop, + txid de-dupe, address→payment correlation (decrypt in memory), settle on + `Confirmed`, pro-rate partial/late/over-payments, insert new pro-rated renewal + on address reuse. +- [x] Registered in `listen_all_payments` (provider passed from `bin/api.rs`). +- [x] 8 watcher tests: exact/partial deposits, replay de-dupe, unknown address, + address-reuse renewal, listen loop, stream error. + +### Increment 5 — API surface + docs (S) +- [x] `ApiPaymentMethod::OnChain` / `ApiPaymentData::OnChain { address }` exposed. +- [x] `API_CHANGELOG.md` updated (Unreleased). +- [ ] Label issue #109 and prep PR referencing `Fixes #109`. + +## Notes + +- Decisions: store **txid in `external_id`** (unique) and **address in `external_data`** + (encrypted), per issue #109. +- LND cannot label on-chain outputs, so the watcher must correlate by **address**, not + `label`. +- De-dup on `txid` — the stream is at-least-once and replays across restarts. +- Load `docs/agents/migrations.md`, `docs/agents/currency.md`, + `docs/agents/api-guidelines.md`, `docs/agents/code-style.md`, and coverage docs when + implementing each increment. From db086b901eb6c126fbf53614897d17d07c77558c Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 18:39:27 +0100 Subject: [PATCH 3/8] fix(onchain): re-calculate exchange rate at tx discovery The quote only fixes the price in the subscription's currency, never the BTC rate. When a deposit is discovered, time credited is pro-rated by the value received at the current rate: time_value * received_msat * rate_now / (expected_msat * rate_quoted) For BTC-denominated subscriptions this reduces to the plain msat ratio. The current rate is recorded on the settled payment, so address-reuse renewals also price against the latest settle. PricingEngine::get_ticker is now public for the watcher. --- API_CHANGELOG.md | 2 +- lnvps_api/src/payments/onchain.rs | 164 ++++++++++++++++++++++++------ lnvps_api_common/src/pricing.rs | 3 +- work/onchain-payments.md | 5 + 4 files changed, 140 insertions(+), 34 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 24c4f7b3..fe1ef26f 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -22,7 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- **On-chain Bitcoin payments** (issue #109) — VMs and subscriptions can now be paid on-chain. A new `onchain` payment method is accepted wherever a payment method is selected (e.g. `GET /api/v1/vm/{id}/renew?method=onchain`); the payment's `data` field returns `{ "onchain": { "address": "bc1…" } }` with a freshly derived receive address (BTC only). The txid is recorded on the payment (`external_id`) once the deposit confirms. Deposits are never rejected: partial, late and over-payments are pro-rated at the quoted rate, and any further deposit to an already-settled address automatically creates a new pro-rated renewal payment. Admin payment-method configs accept a new `onchain` provider type (reuses the LND wallet of the Lightning backend). +- **On-chain Bitcoin payments** (issue #109) — VMs and subscriptions can now be paid on-chain. A new `onchain` payment method is accepted wherever a payment method is selected (e.g. `GET /api/v1/vm/{id}/renew?method=onchain`); the payment's `data` field returns `{ "onchain": { "address": "bc1…" } }` with a freshly derived receive address (BTC only). The txid is recorded on the payment (`external_id`) once the deposit confirms. Deposits are never rejected: the exchange rate is re-calculated when the transaction is discovered, and the time credited is pro-rated by the value received at that rate (partial, late and over-payments included). Any further deposit to an already-settled address automatically creates a new pro-rated renewal payment. Admin payment-method configs accept a new `onchain` provider type (reuses the LND wallet of the Lightning backend). - **Transfer a VM to another user (admin)** — new `POST /api/admin/v1/vms/{id}/transfer` (`virtual_machines::update`) reassigns a VM to another user account, e.g. for account recovery (issue #178). It atomically moves the VM and its billing subscription to the target `user_id` and clears the old owner's SSH key from the VM. The change is recorded in VM history as a new `transferred` action. Rejects deleted VMs, self-transfers (`409`) and unknown target users (`404`). Body: `{ user_id, reason? }`. diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index 8f77221d..e9d46eea 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -19,23 +19,27 @@ //! # Pro-rating (issue #109) //! //! On-chain funds can arrive at any time and for any amount. Deposits are -//! never rejected: +//! never rejected, and the exchange rate is **always re-calculated when the +//! transaction is discovered** — the original quote only fixes the price in +//! the subscription's currency, never the BTC rate: //! -//! - A deposit for a pending payment settles it, with `amount`, `tax`, -//! `processing_fee` and `time_value` scaled by `received / expected` when -//! the amount differs from what was quoted (partial, late and over-payments -//! are all pro-rated at the quoted rate). +//! - A deposit for a pending payment settles it. `time_value` is scaled by +//! the *value* that arrived, measured at the current rate: +//! `received_msat * rate_now / (expected_msat * rate_quoted)`. For +//! BTC-denominated subscriptions this reduces to `received / expected`. //! - A deposit to an address whose payment already settled (address reuse) -//! automatically inserts a **new** pro-rated renewal payment based on the -//! original quote. +//! automatically inserts a **new** pro-rated renewal payment, priced the +//! same way at the current rate. use crate::subscription::SubscriptionHandler; -use anyhow::{Result, bail}; +use anyhow::{Result, bail, ensure}; use chrono::Utc; use futures::StreamExt; use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; use log::{debug, error, info, warn}; +use payments_rs::currency::Currency; use payments_rs::onchain::{ChainPaymentUpdate, OnChainProvider}; +use std::str::FromStr; use std::sync::Arc; pub struct OnChainPaymentHandler { @@ -50,6 +54,11 @@ fn pro_rate(value: u64, received: u64, expected: u64) -> u64 { (value as u128 * received as u128 / expected as u128) as u64 } +/// Scale `value` by an arbitrary ratio, flooring to whole units. +fn pro_rate_f64(value: u64, ratio: f64) -> u64 { + (value as f64 * ratio).floor() as u64 +} + impl OnChainPaymentHandler { pub fn new( provider: Arc, @@ -75,6 +84,38 @@ impl OnChainPaymentHandler { .max_by_key(|p| p.created)) } + /// How much of the quoted *value* arrived, measured at the **current** + /// exchange rate, plus that rate. + /// + /// The quote fixed a price in the subscription's currency; the BTC rate is + /// never locked in. `received_msat * rate_now / (expected_msat * + /// rate_quoted)` — for BTC-denominated subscriptions (rate 1:1) this is + /// just `received / expected`. + async fn value_ratio( + &self, + payment: &SubscriptionPayment, + amount_msat: u64, + expected: u64, + ) -> Result<(f64, f32)> { + let sub = self.db.get_subscription(payment.subscription_id).await?; + let sub_currency = Currency::from_str(&sub.currency) + .map_err(|e| anyhow::anyhow!("Invalid subscription currency: {}", e))?; + let rate_now = self + .sub_handler + .pricing_engine() + .get_ticker(Currency::BTC, sub_currency) + .await? + .rate; + ensure!( + payment.rate > 0.0, + "Payment {} has invalid quoted rate", + hex::encode(&payment.id) + ); + let ratio = + (amount_msat as f64 * rate_now as f64) / (expected as f64 * payment.rate as f64); + Ok((ratio, rate_now)) + } + /// Handle a confirmed deposit of `amount_msat` to `address` in tx `txid`. async fn handle_deposit(&self, address: &str, txid: &str, amount_msat: u64) -> Result<()> { // De-dupe: the stream is at-least-once, a known txid was already handled. @@ -102,24 +143,28 @@ impl OnChainPaymentHandler { ); } + // The BTC rate is always re-calculated when the tx is discovered; the + // quote only fixed the price in the subscription's currency. + let (ratio, rate_now) = self.value_ratio(&payment, amount_msat, expected).await?; + if !payment.is_paid { - // Settle the pending payment, pro-rating if the received amount - // differs from the quote (partial / late / over-payment). + // Settle the pending payment, crediting the time the received + // value actually buys at the current rate. let mut payment = payment; - if amount_msat != expected { - info!( - "Pro-rating on-chain payment {}: received {} msat, expected {} msat", - hex::encode(&payment.id), - amount_msat, - expected - ); - payment.tax = pro_rate(payment.tax, amount_msat, expected); - payment.processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); - payment.amount = amount_msat - payment.tax - payment.processing_fee; - payment.time_value = payment - .time_value - .map(|tv| pro_rate(tv, amount_msat, expected)); - } + info!( + "Settling on-chain payment {}: received {} msat (expected {} msat), value ratio {:.4}", + hex::encode(&payment.id), + amount_msat, + expected, + ratio + ); + // Split what arrived proportionally between net/tax/fee so the + // components always sum to the received amount. + payment.tax = pro_rate(payment.tax, amount_msat, expected); + payment.processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); + payment.amount = amount_msat - payment.tax - payment.processing_fee; + payment.time_value = payment.time_value.map(|tv| pro_rate_f64(tv, ratio)); + payment.rate = rate_now; payment.external_id = Some(txid.to_string()); self.db.update_subscription_payment(&payment).await?; self.sub_handler.complete_payment(&payment).await?; @@ -135,10 +180,11 @@ impl OnChainPaymentHandler { return Ok(()); }; info!( - "New deposit {} of {} msat to settled address of payment {}, creating pro-rated renewal", + "New deposit {} of {} msat to settled address of payment {}, creating pro-rated renewal (value ratio {:.4})", txid, amount_msat, - hex::encode(&payment.id) + hex::encode(&payment.id), + ratio ); let tax = pro_rate(payment.tax, amount_msat, expected); let processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); @@ -156,8 +202,8 @@ impl OnChainPaymentHandler { external_data: address.into(), external_id: Some(txid.to_string()), is_paid: false, - rate: payment.rate, - time_value: Some(pro_rate(time_value, amount_msat, expected)), + rate: rate_now, + time_value: Some(pro_rate_f64(time_value, ratio)), metadata: None, tax, processing_fee, @@ -216,7 +262,9 @@ mod tests { use crate::mocks::{MockNode, MockOnChainProvider}; use crate::settings::mock_settings; use anyhow::Result; - use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; + use lnvps_api_common::{ + ChannelWorkCommander, ExchangeRateService, MockDb, MockExchangeRate, Ticker, VmStateCache, + }; use lnvps_db::{ IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, SubscriptionType, Vm, }; @@ -233,10 +281,30 @@ mod tests { Arc, OnChainPaymentHandler, SubscriptionPayment, + )> { + // BTC-denominated: value ratio is the plain msat ratio + setup_with("BTC", 1.0, None).await + } + + /// Like [`setup`] but with an explicit subscription currency, quoted rate + /// on the pending payment, and current exchange rate. + async fn setup_with( + currency: &str, + quoted_rate: f32, + current_rate: Option, + ) -> Result<( + Arc, + Arc, + OnChainPaymentHandler, + SubscriptionPayment, )> { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let provider = Arc::new(MockOnChainProvider::default()); + let rates = Arc::new(MockExchangeRate::default()); + if let Some(r) = current_rate { + rates.set_rate(Ticker::btc_rate(currency)?, r).await; + } let pubkey: [u8; 32] = [1u8; 32]; let user_id = db.upsert_user(&pubkey).await?; @@ -262,7 +330,7 @@ mod tests { expires: None, is_active: false, is_setup: false, - currency: "BTC".to_string(), + currency: currency.to_string(), interval_amount: 1, interval_type: IntervalType::Month, setup_fee: 0, @@ -311,7 +379,7 @@ mod tests { external_data: ADDRESS.into(), external_id: None, is_paid: false, - rate: 1.0, + rate: quoted_rate, time_value: Some(TIME_VALUE), metadata: None, tax: TAX, @@ -330,7 +398,7 @@ mod tests { db.clone(), node, provider.clone(), - Arc::new(MockExchangeRate::default()), + rates, lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), VmStateCache::new(), @@ -468,6 +536,38 @@ mod tests { Ok(()) } + /// The BTC rate is re-calculated when the tx is discovered: a fiat quote + /// paid in full in msat terms credits time by *value* at the current rate. + #[tokio::test] + async fn test_fiat_quote_repriced_at_current_rate() -> Result<()> { + // Quoted at 100k EUR/BTC; by discovery the rate doubled to 200k, so + // the same msats are worth twice the quoted value -> twice the time. + let (db, _provider, handler, payment) = + setup_with("EUR", 100_000.0, Some(200_000.0)).await?; + handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.time_value, Some(TIME_VALUE * 2)); + assert_eq!(p.rate, 200_000.0, "current rate recorded on the payment"); + // msat components are unchanged (full msat amount arrived) + assert_eq!(p.amount, AMOUNT); + assert_eq!(p.tax, TAX); + + // Address reuse is also priced at the (new) current rate + handler.handle_deposit(ADDRESS, "tx2", EXPECTED / 2).await?; + let payments = db.subscription_payments.lock().await.clone(); + let renewal = payments + .iter() + .find(|p| p.external_id.as_deref() == Some("tx2")) + .expect("renewal payment"); + // reference payment now has rate 200k and time 2*TIME_VALUE; half the + // msats at the same rate -> half its time + assert_eq!(renewal.time_value, Some(TIME_VALUE)); + assert_eq!(renewal.rate, 200_000.0); + Ok(()) + } + /// A stream error bubbles up so the outer loop reconnects. #[tokio::test] async fn test_listen_stream_error_bails() -> Result<()> { diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 71f1c9c6..83440579 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -820,7 +820,8 @@ impl PricingEngine { } } - async fn get_ticker( + /// Current exchange rate between two currencies (passthrough when equal). + pub async fn get_ticker( &self, base_currency: Currency, target_currency: Currency, diff --git a/work/onchain-payments.md b/work/onchain-payments.md index 257f7c3d..90ee3e9f 100644 --- a/work/onchain-payments.md +++ b/work/onchain-payments.md @@ -111,6 +111,11 @@ feature), now available on crates.io as `payments-rs = 0.4.1`. - [x] Registered in `listen_all_payments` (provider passed from `bin/api.rs`). - [x] 8 watcher tests: exact/partial deposits, replay de-dupe, unknown address, address-reuse renewal, listen loop, stream error. +- [x] Rate is **re-calculated at tx discovery** (review feedback): time credited = + `time_value × received_msat × rate_now / (expected_msat × rate_quoted)`; + the quote only fixes the price in the subscription currency, never the BTC + rate. Current rate is recorded on the settled payment. BTC-denominated subs + reduce to the plain msat ratio. `PricingEngine::get_ticker` made pub. ### Increment 5 — API surface + docs (S) - [x] `ApiPaymentMethod::OnChain` / `ApiPaymentData::OnChain { address }` exposed. From b8d2beebb1bb06bffd9dda452572793b1320e6f3 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 19:04:14 +0100 Subject: [PATCH 4/8] refactor(onchain): regenerate pricing at discovery; key deposits by outpoint Per review: - Discard the quote when a deposit is discovered (Detected, 0-conf) and re-generate time/tax/fee/rate from the received amount through PricingEngine::get_cost_by_amount - the same path as LNURL top-ups. Confirmed then just settles, so time-to-confirm never matters. Subscriptions without a VM (no amount->cost API) fall back to scaling the original quote by value at the current rate. - Key deposits by the standard outpoint {txid}:{vout} instead of txid alone: one tx can pay several watched addresses. Requires payments-rs 0.5.0 which exposes vout on ChainPaymentUpdate. - PricingEngine::get_ticker made public for the fallback path. --- Cargo.lock | 4 +- Cargo.toml | 2 +- lnvps_api/src/payments/onchain.rs | 599 +++++++++++++++++++---------- work/onchain-payments.md | 16 +- work/payments-rs-onchain-prompt.md | 136 +++++++ 5 files changed, 555 insertions(+), 202 deletions(-) create mode 100644 work/payments-rs-onchain-prompt.md diff --git a/Cargo.lock b/Cargo.lock index 78a1150b..4f067c38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3661,9 +3661,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payments-rs" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ca26435e721f18457df89331976b1fd37e34232c94f68575c19ccd4e942a1d" +checksum = "59c78ae2f0d557e77ffa28ca46e7e229189a3b50a1ead760664105f073b8858b" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index dcf040bd..5e2d28cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ tower-http = { version = "0.6", features = ["cors"] } config = { version = "0.15", features = ["yaml"] } hex = "0.4" ipnetwork = "0.21" -payments-rs = { version = "0.4.1", default-features = false } +payments-rs = { version = "0.5.0", default-features = false } async-trait = "0.1" futures = "0.3" reqwest = { version = "0.13", features = ["json"] } diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index e9d46eea..7d4d73fa 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -13,31 +13,38 @@ //! # Delivery / de-duplication //! //! The provider stream is at-least-once and replayable; exactly-once -//! accounting is achieved by storing the **txid** in `external_id` (unique -//! index) and skipping any update whose txid is already recorded. +//! accounting is achieved by storing the deposit's **outpoint** +//! (`{txid}:{vout}`) in `external_id` (unique index) and skipping any update +//! whose outpoint is already settled. The txid alone is not enough: one +//! transaction can pay several watched addresses at once — each output is a +//! distinct deposit that must settle its own payment. //! -//! # Pro-rating (issue #109) +//! # Pricing (issue #109) //! -//! On-chain funds can arrive at any time and for any amount. Deposits are -//! never rejected, and the exchange rate is **always re-calculated when the -//! transaction is discovered** — the original quote only fixes the price in -//! the subscription's currency, never the BTC rate: +//! On-chain funds can arrive at any time and for any amount; deposits are +//! never rejected. The quote on the pending payment is **discarded** when the +//! deposit is discovered: pricing (`time_value`, `tax`, `processing_fee`, +//! `rate`) is re-generated from the received amount at the rates current at +//! discovery, exactly like an LNURL top-up (`PricingEngine::get_cost_by_amount`). //! -//! - A deposit for a pending payment settles it. `time_value` is scaled by -//! the *value* that arrived, measured at the current rate: -//! `received_msat * rate_now / (expected_msat * rate_quoted)`. For -//! BTC-denominated subscriptions this reduces to `received / expected`. +//! - The re-generation happens at `Detected` (0-conf, first sight of the tx), +//! so time-to-confirm never matters to the customer. `Confirmed` then just +//! settles. If the `Detected` event was never seen (e.g. the tx confirmed +//! while the watcher was down), pricing is generated at confirmation +//! instead — the moment we discover it. //! - A deposit to an address whose payment already settled (address reuse) -//! automatically inserts a **new** pro-rated renewal payment, priced the -//! same way at the current rate. +//! automatically inserts a **new** renewal payment, priced the same way. +//! - Subscriptions without a VM have no amount→cost pricing; they fall back +//! to scaling the original quote by the value received at the current rate. use crate::subscription::SubscriptionHandler; use anyhow::{Result, bail, ensure}; use chrono::Utc; use futures::StreamExt; +use lnvps_api_common::CostResult; use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; -use log::{debug, error, info, warn}; -use payments_rs::currency::Currency; +use log::{debug, error, info}; +use payments_rs::currency::{Currency, CurrencyAmount}; use payments_rs::onchain::{ChainPaymentUpdate, OnChainProvider}; use std::str::FromStr; use std::sync::Arc; @@ -59,6 +66,14 @@ fn pro_rate_f64(value: u64, ratio: f64) -> u64 { (value as f64 * ratio).floor() as u64 } +/// Unique key for one deposit: the standard outpoint notation `{txid}:{vout}`. +/// +/// One transaction can pay multiple watched addresses (one output each), so +/// the txid alone does not identify a deposit. +fn deposit_key(txid: &str, vout: u32) -> String { + format!("{}:{}", txid, vout) +} + impl OnChainPaymentHandler { pub fn new( provider: Arc, @@ -84,129 +99,210 @@ impl OnChainPaymentHandler { .max_by_key(|p| p.created)) } - /// How much of the quoted *value* arrived, measured at the **current** - /// exchange rate, plus that rate. - /// - /// The quote fixed a price in the subscription's currency; the BTC rate is - /// never locked in. `received_msat * rate_now / (expected_msat * - /// rate_quoted)` — for BTC-denominated subscriptions (rate 1:1) this is - /// just `received / expected`. - async fn value_ratio( - &self, - payment: &SubscriptionPayment, - amount_msat: u64, - expected: u64, - ) -> Result<(f64, f32)> { + /// Current BTC exchange rate for the payment's subscription currency. + async fn current_rate(&self, payment: &SubscriptionPayment) -> Result { let sub = self.db.get_subscription(payment.subscription_id).await?; let sub_currency = Currency::from_str(&sub.currency) .map_err(|e| anyhow::anyhow!("Invalid subscription currency: {}", e))?; - let rate_now = self + Ok(self .sub_handler .pricing_engine() .get_ticker(Currency::BTC, sub_currency) .await? - .rate; - ensure!( - payment.rate > 0.0, - "Payment {} has invalid quoted rate", - hex::encode(&payment.id) - ); - let ratio = - (amount_msat as f64 * rate_now as f64) / (expected as f64 * payment.rate as f64); - Ok((ratio, rate_now)) + .rate) } - /// Handle a confirmed deposit of `amount_msat` to `address` in tx `txid`. - async fn handle_deposit(&self, address: &str, txid: &str, amount_msat: u64) -> Result<()> { - // De-dupe: the stream is at-least-once, a known txid was already handled. + /// Re-generate a payment's pricing from the gross msats received, at the + /// rates current right now. The original quote is discarded. + /// + /// VM-backed subscriptions price through the pricing engine exactly like + /// LNURL top-ups. Subscriptions without a VM have no amount→cost pricing + /// and fall back to scaling the original quote by the value received. + async fn regenerate(&self, payment: &mut SubscriptionPayment, gross_msat: u64) -> Result<()> { + match self + .db + .get_vm_by_subscription(payment.subscription_id) + .await + { + Ok(vm) => { + // The engine prices from the net amount and adds tax on top; + // split the gross deposit using the frozen tax rate. + let tax_pct = payment.tax_rate.unwrap_or(0.0) as f64; + let net = (gross_msat as f64 / (1.0 + tax_pct / 100.0)).floor() as u64; + let cost = self + .sub_handler + .pricing_engine() + .get_cost_by_amount( + vm.id, + CurrencyAmount::millisats(net), + PaymentMethod::OnChain, + ) + .await?; + let p = match cost { + CostResult::New(p) => p, + CostResult::Existing(_) => bail!("Unexpected existing cost result"), + }; + payment.time_value = Some(p.time_value); + payment.rate = p.rate.rate; + // Components always sum to exactly what arrived + payment.tax = p.tax.min(gross_msat); + payment.processing_fee = p.processing_fee.min(gross_msat - payment.tax); + payment.amount = gross_msat - payment.tax - payment.processing_fee; + } + Err(_) => { + // No VM: scale the original quote by the value received at + // the current rate. + let expected = payment.amount + payment.tax + payment.processing_fee; + ensure!( + expected > 0, + "Payment {} has zero expected amount", + hex::encode(&payment.id) + ); + ensure!( + payment.rate > 0.0, + "Payment {} has invalid quoted rate", + hex::encode(&payment.id) + ); + let rate_now = self.current_rate(payment).await?; + let ratio = + (gross_msat as f64 * rate_now as f64) / (expected as f64 * payment.rate as f64); + payment.tax = pro_rate(payment.tax, gross_msat, expected); + payment.processing_fee = pro_rate(payment.processing_fee, gross_msat, expected); + payment.amount = gross_msat - payment.tax - payment.processing_fee; + payment.time_value = payment.time_value.map(|tv| pro_rate_f64(tv, ratio)); + payment.rate = rate_now; + } + } + Ok(()) + } + + /// Handle a deposit **first seen** in the mempool (0-conf `Detected`). + /// + /// Re-generates the pending payment's pricing from the received amount at + /// the current rate and tags it with the deposit key (`external_id`), so + /// settlement at `Confirmed` needs no further pricing — time to confirm + /// does not matter to the customer. + async fn handle_detected( + &self, + address: &str, + txid: &str, + vout: u32, + amount_msat: u64, + ) -> Result<()> { + let key = deposit_key(txid, vout); + // Already priced or settled (replayed event) if self .db - .get_subscription_payment_by_ext_id(txid) + .get_subscription_payment_by_ext_id(&key) .await .is_ok() { - debug!("Skipping already processed deposit {}", txid); return Ok(()); } - - let Some(payment) = self.payment_for_address(address).await? else { - debug!("Deposit {} to unknown address {}, ignoring", txid, address); + let Some(mut payment) = self.payment_for_address(address).await? else { return Ok(()); }; - - let expected = payment.amount + payment.tax + payment.processing_fee; - if expected == 0 { - bail!( - "Payment {} has zero expected amount, cannot pro-rate deposit {}", - hex::encode(&payment.id), - txid - ); + // Address-reuse deposits are handled at confirmation; only a pending + // payment is re-priced here. + if payment.is_paid { + return Ok(()); } + self.regenerate(&mut payment, amount_msat).await?; + info!( + "Deposit {} detected: priced payment {} at rate {} for {} msat ({}s)", + key, + hex::encode(&payment.id), + payment.rate, + amount_msat, + payment.time_value.unwrap_or(0) + ); + payment.external_id = Some(key); + self.db.update_subscription_payment(&payment).await?; + Ok(()) + } - // The BTC rate is always re-calculated when the tx is discovered; the - // quote only fixed the price in the subscription's currency. - let (ratio, rate_now) = self.value_ratio(&payment, amount_msat, expected).await?; + /// Handle a confirmed deposit of `amount_msat` to `address` in tx `txid`. + async fn handle_deposit( + &self, + address: &str, + txid: &str, + vout: u32, + amount_msat: u64, + ) -> Result<()> { + // De-dupe: the stream is at-least-once, a settled (txid, address) + // deposit was already handled. An *unpaid* match is the pending + // payment priced at Detected. + let key = deposit_key(txid, vout); + let priced = match self.db.get_subscription_payment_by_ext_id(&key).await { + Ok(p) if p.is_paid => { + debug!("Skipping already processed deposit {}", key); + return Ok(()); + } + Ok(p) => Some(p), + Err(_) => None, + }; + + let already_priced = priced.is_some(); + let payment = match priced { + Some(p) => p, + None => match self.payment_for_address(address).await? { + Some(p) => p, + None => { + debug!("Deposit {} to unknown address {}, ignoring", txid, address); + return Ok(()); + } + }, + }; if !payment.is_paid { - // Settle the pending payment, crediting the time the received - // value actually buys at the current rate. let mut payment = payment; + // Price now unless Detected already did (and for the same gross + // amount — a replaced tx could change the output value). + let priced_gross = payment.amount + payment.tax + payment.processing_fee; + if !already_priced || priced_gross != amount_msat { + self.regenerate(&mut payment, amount_msat).await?; + } info!( - "Settling on-chain payment {}: received {} msat (expected {} msat), value ratio {:.4}", + "Settling on-chain payment {}: {} msat at rate {} ({}s)", hex::encode(&payment.id), amount_msat, - expected, - ratio + payment.rate, + payment.time_value.unwrap_or(0) ); - // Split what arrived proportionally between net/tax/fee so the - // components always sum to the received amount. - payment.tax = pro_rate(payment.tax, amount_msat, expected); - payment.processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); - payment.amount = amount_msat - payment.tax - payment.processing_fee; - payment.time_value = payment.time_value.map(|tv| pro_rate_f64(tv, ratio)); - payment.rate = rate_now; - payment.external_id = Some(txid.to_string()); + payment.external_id = Some(key); self.db.update_subscription_payment(&payment).await?; self.sub_handler.complete_payment(&payment).await?; } else { - // Address reuse after settlement: insert a new pro-rated renewal - // payment based on the original quote (issue #109). - let Some(time_value) = payment.time_value else { - warn!( - "Deposit {} to settled payment {} without time_value, cannot pro-rate", - txid, - hex::encode(&payment.id) - ); - return Ok(()); - }; + // Address reuse after settlement: insert a new renewal payment, + // priced from the received amount (issue #109). info!( - "New deposit {} of {} msat to settled address of payment {}, creating pro-rated renewal (value ratio {:.4})", + "New deposit {} of {} msat to settled address of payment {}, creating renewal", txid, amount_msat, - hex::encode(&payment.id), - ratio + hex::encode(&payment.id) ); - let tax = pro_rate(payment.tax, amount_msat, expected); - let processing_fee = pro_rate(payment.processing_fee, amount_msat, expected); let new_id: [u8; 32] = rand::random(); - let renewal = SubscriptionPayment { + let mut renewal = SubscriptionPayment { id: new_id.to_vec(), subscription_id: payment.subscription_id, user_id: payment.user_id, created: Utc::now(), expires: Utc::now(), - amount: amount_msat - tax - processing_fee, + // Seed pricing from the settled payment; regenerate below + // overwrites it (and uses it as the quote reference for the + // no-VM fallback). + amount: payment.amount, currency: payment.currency.clone(), payment_method: PaymentMethod::OnChain, payment_type: SubscriptionPaymentType::Renewal, external_data: address.into(), - external_id: Some(txid.to_string()), + external_id: Some(key), is_paid: false, - rate: rate_now, - time_value: Some(pro_rate_f64(time_value, ratio)), + rate: payment.rate, + time_value: payment.time_value, metadata: None, - tax, - processing_fee, + tax: payment.tax, + processing_fee: payment.processing_fee, paid_at: None, tax_rate: payment.tax_rate, tax_country_code: payment.tax_country_code.clone(), @@ -214,6 +310,7 @@ impl OnChainPaymentHandler { tax_evidence: payment.tax_evidence.clone(), tax_breakdown: payment.tax_breakdown.clone(), }; + self.regenerate(&mut renewal, amount_msat).await?; self.db.insert_subscription_payment(&renewal).await?; self.sub_handler.complete_payment(&renewal).await?; } @@ -222,32 +319,44 @@ impl OnChainPaymentHandler { pub async fn listen(&mut self) -> Result<()> { info!("Listening for on-chain deposits"); - // Subscribe from the start; the txid de-dupe above makes replaying - // history harmless (at-least-once -> exactly-once). + // Subscribe from the start; the deposit-key de-dupe above makes + // replaying history harmless (at-least-once -> exactly-once). let mut stream = self.provider.subscribe_payments(None).await?; while let Some(update) = stream.next().await { match update { ChainPaymentUpdate::Confirmed { address, txid, + vout, amount_msat, .. } => { - if let Err(e) = self.handle_deposit(&address, &txid, amount_msat).await { + if let Err(e) = self + .handle_deposit(&address, &txid, vout, amount_msat) + .await + { error!("onchain deposit error for {}: {}", txid, e); } } ChainPaymentUpdate::Detected { address, txid, + vout, amount_msat, confirmations, .. } => { debug!( - "Detected deposit {} of {} msat to {} ({} confs)", - txid, amount_msat, address, confirmations + "Detected deposit {}:{} of {} msat to {} ({} confs)", + txid, vout, amount_msat, address, confirmations ); + // Price the payment at first sight of the tx + if let Err(e) = self + .handle_detected(&address, &txid, vout, amount_msat) + .await + { + error!("onchain detect error for {}: {}", txid, e); + } } ChainPaymentUpdate::Error(e) => bail!("onchain stream error: {}", e), } @@ -263,7 +372,8 @@ mod tests { use crate::settings::mock_settings; use anyhow::Result; use lnvps_api_common::{ - ChannelWorkCommander, ExchangeRateService, MockDb, MockExchangeRate, Ticker, VmStateCache, + ChannelWorkCommander, ExchangeRateService, MockDb, MockExchangeRate, NewPaymentInfo, + Ticker, VmStateCache, }; use lnvps_db::{ IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, SubscriptionType, Vm, @@ -274,37 +384,28 @@ mod tests { const TAX: u64 = 100_000; const EXPECTED: u64 = AMOUNT + TAX; const TIME_VALUE: u64 = 86_400; + const RATE: f32 = 100_000.0; - /// DB with a VM + subscription and one unpaid on-chain payment for [`ADDRESS`]. - async fn setup() -> Result<( - Arc, - Arc, - OnChainPaymentHandler, - SubscriptionPayment, - )> { - // BTC-denominated: value ratio is the plain msat ratio - setup_with("BTC", 1.0, None).await - } - - /// Like [`setup`] but with an explicit subscription currency, quoted rate - /// on the pending payment, and current exchange rate. + /// Build DB + handler with a subscription and one unpaid on-chain payment + /// for [`ADDRESS`]. `with_vm` links a VM (template 1 / cost plan 1 from + /// MockDb) so pricing regenerates through the engine; without it the + /// watcher uses the quote-scaling fallback. async fn setup_with( - currency: &str, + with_vm: bool, quoted_rate: f32, - current_rate: Option, + current_rate: f32, ) -> Result<( Arc, Arc, OnChainPaymentHandler, SubscriptionPayment, + Arc, )> { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let provider = Arc::new(MockOnChainProvider::default()); let rates = Arc::new(MockExchangeRate::default()); - if let Some(r) = current_rate { - rates.set_rate(Ticker::btc_rate(currency)?, r).await; - } + rates.set_rate(Ticker::btc_rate("EUR")?, current_rate).await; let pubkey: [u8; 32] = [1u8; 32]; let user_id = db.upsert_user(&pubkey).await?; @@ -330,7 +431,7 @@ mod tests { expires: None, is_active: false, is_setup: false, - currency: currency.to_string(), + currency: "EUR".to_string(), interval_amount: 1, interval_type: IntervalType::Month, setup_fee: 0, @@ -350,21 +451,23 @@ mod tests { ) .await?; - db.insert_vm(&Vm { - id: 0, - host_id: 1, - user_id, - image_id: 1, - template_id: Some(1), - custom_template_id: None, - subscription_line_item_id: line_item_ids[0], - ssh_key_id: Some(ssh_key_id), - disk_id: 1, - mac_address: "aa:bb:cc:dd:ee:ff".to_string(), - deleted: false, - ..Default::default() - }) - .await?; + if with_vm { + db.insert_vm(&Vm { + id: 0, + host_id: 1, + user_id, + image_id: 1, + template_id: Some(1), + custom_template_id: None, + subscription_line_item_id: line_item_ids[0], + ssh_key_id: Some(ssh_key_id), + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + deleted: false, + ..Default::default() + }) + .await?; + } let payment = SubscriptionPayment { id: vec![42u8; 32], @@ -398,13 +501,24 @@ mod tests { db.clone(), node, provider.clone(), - rates, + rates.clone(), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), VmStateCache::new(), )?; let handler = OnChainPaymentHandler::new(provider.clone(), db.clone(), sub); + Ok((db, provider, handler, payment, rates)) + } + + /// VM-backed setup at [`RATE`]. + async fn setup() -> Result<( + Arc, + Arc, + OnChainPaymentHandler, + SubscriptionPayment, + )> { + let (db, provider, handler, payment, _rates) = setup_with(true, RATE, RATE).await?; Ok((db, provider, handler, payment)) } @@ -418,6 +532,40 @@ mod tests { .expect("payment") } + /// What the pricing engine would generate for `gross` msats right now. + async fn engine_price( + handler: &OnChainPaymentHandler, + sub_id: u64, + gross: u64, + ) -> Result { + let vm = handler.db.get_vm_by_subscription(sub_id).await?; + match handler + .sub_handler + .pricing_engine() + .get_cost_by_amount( + vm.id, + CurrencyAmount::millisats(gross), + PaymentMethod::OnChain, + ) + .await? + { + CostResult::New(p) => Ok(p), + CostResult::Existing(_) => bail!("unexpected existing"), + } + } + + /// The engine's time_value depends on Utc::now(); allow a small drift + /// between the expectation call and the watcher's own call. + fn assert_close(a: u64, b: u64) { + assert!( + a.abs_diff(b) <= 5, + "expected {} ≈ {} (diff {})", + a, + b, + a.abs_diff(b) + ); + } + #[test] fn test_pro_rate() { assert_eq!(pro_rate(TIME_VALUE, EXPECTED, EXPECTED), TIME_VALUE); @@ -426,43 +574,59 @@ mod tests { assert_eq!(pro_rate(0, EXPECTED, EXPECTED), 0); // u128 intermediate: no overflow on large values assert_eq!(pro_rate(u64::MAX, 1000, 1000), u64::MAX); + assert_eq!(pro_rate_f64(100, 1.5), 150); + assert_eq!(pro_rate_f64(100, 0.333), 33); + } + + #[test] + fn test_deposit_key() { + // standard outpoint notation; txid alone is not the key + assert_eq!(deposit_key("tx1", 0), "tx1:0"); + assert_ne!(deposit_key("tx1", 0), deposit_key("tx1", 1)); } - /// Exact deposit settles the pending payment untouched. + /// A confirmed deposit settles the pending payment with pricing + /// re-generated by the engine (quote discarded). #[tokio::test] - async fn test_exact_deposit_settles_payment() -> Result<()> { + async fn test_deposit_settles_with_engine_pricing() -> Result<()> { let (db, _provider, handler, payment) = setup().await?; - handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + let expect = engine_price(&handler, payment.subscription_id, EXPECTED).await?; + + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; let p = get_payment(&db, &payment.id).await; assert!(p.is_paid); - assert_eq!(p.external_id.as_deref(), Some("tx1")); - assert_eq!(p.amount, AMOUNT); - assert_eq!(p.tax, TAX); - assert_eq!(p.time_value, Some(TIME_VALUE)); + assert_eq!(p.external_id, Some(deposit_key("tx1", 0))); + assert_eq!(p.rate, expect.rate.rate); + assert_close(p.time_value.unwrap(), expect.time_value); + // components sum to exactly what arrived + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED); Ok(()) } - /// Partial deposit is pro-rated: amount/tax/time all scale by received/expected. + /// Half the deposit buys half the time (engine pricing is linear). #[tokio::test] - async fn test_partial_deposit_pro_rates() -> Result<()> { + async fn test_partial_deposit_buys_less_time() -> Result<()> { let (db, _provider, handler, payment) = setup().await?; - handler.handle_deposit(ADDRESS, "tx1", EXPECTED / 2).await?; + let expect_full = engine_price(&handler, payment.subscription_id, EXPECTED).await?; + + handler + .handle_deposit(ADDRESS, "tx1", 0, EXPECTED / 2) + .await?; let p = get_payment(&db, &payment.id).await; assert!(p.is_paid); - assert_eq!(p.tax, TAX / 2); - assert_eq!(p.amount, EXPECTED / 2 - TAX / 2); - assert_eq!(p.time_value, Some(TIME_VALUE / 2)); + assert_close(p.time_value.unwrap(), expect_full.time_value / 2); + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED / 2); Ok(()) } - /// A replayed txid must not settle or create anything (at-least-once stream). + /// A replayed deposit must not settle or create anything. #[tokio::test] - async fn test_duplicate_txid_skipped() -> Result<()> { + async fn test_duplicate_deposit_skipped() -> Result<()> { let (db, _provider, handler, _payment) = setup().await?; - handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; - handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; assert_eq!(db.subscription_payments.lock().await.len(), 1); Ok(()) @@ -473,7 +637,7 @@ mod tests { async fn test_unknown_address_ignored() -> Result<()> { let (db, _provider, handler, payment) = setup().await?; handler - .handle_deposit("bcrt1qunknown", "tx1", EXPECTED) + .handle_deposit("bcrt1qunknown", "tx1", 0, EXPECTED) .await?; let p = get_payment(&db, &payment.id).await; @@ -482,19 +646,23 @@ mod tests { Ok(()) } - /// A deposit to an already-settled address inserts a new pro-rated renewal. + /// A deposit to an already-settled address inserts a new renewal payment + /// priced from the received amount. #[tokio::test] async fn test_address_reuse_inserts_renewal() -> Result<()> { let (db, _provider, handler, payment) = setup().await?; - handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; - // half the original amount arrives later at the same address - handler.handle_deposit(ADDRESS, "tx2", EXPECTED / 2).await?; + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + + let expect = engine_price(&handler, payment.subscription_id, EXPECTED / 2).await?; + handler + .handle_deposit(ADDRESS, "tx2", 0, EXPECTED / 2) + .await?; let payments = db.subscription_payments.lock().await.clone(); assert_eq!(payments.len(), 2); let renewal = payments .iter() - .find(|p| p.external_id.as_deref() == Some("tx2")) + .find(|p| p.external_id == Some(deposit_key("tx2", 0))) .expect("renewal payment"); assert!(renewal.is_paid); assert_ne!(renewal.id, payment.id); @@ -502,20 +670,94 @@ mod tests { assert_eq!(renewal.payment_method, PaymentMethod::OnChain); assert_eq!(renewal.subscription_id, payment.subscription_id); assert_eq!(renewal.external_data.as_str(), ADDRESS); - assert_eq!(renewal.tax, TAX / 2); - assert_eq!(renewal.amount, EXPECTED / 2 - TAX / 2); - assert_eq!(renewal.time_value, Some(TIME_VALUE / 2)); + assert_close(renewal.time_value.unwrap(), expect.time_value); + assert_eq!( + renewal.amount + renewal.tax + renewal.processing_fee, + EXPECTED / 2 + ); + Ok(()) + } + + /// Pricing is generated when the tx is first seen (`Detected`): later + /// rate moves before confirmation do not change the credited time. + #[tokio::test] + async fn test_detected_locks_pricing() -> Result<()> { + let (db, _provider, handler, payment, rates) = setup_with(true, RATE, RATE).await?; + let expect = engine_price(&handler, payment.subscription_id, EXPECTED).await?; + + // Tx first seen: payment re-priced in place and tagged + handler.handle_detected(ADDRESS, "tx1", 0, EXPECTED).await?; + let p = get_payment(&db, &payment.id).await; + assert!(!p.is_paid); + assert_eq!(p.external_id, Some(deposit_key("tx1", 0))); + assert_eq!(p.rate, expect.rate.rate); + assert_close(p.time_value.unwrap(), expect.time_value); + let locked_time = p.time_value.unwrap(); + + // Rate halves before the tx confirms -> the locked pricing stays + rates.set_rate(Ticker::btc_rate("EUR")?, RATE / 2.0).await; + handler.handle_detected(ADDRESS, "tx1", 0, EXPECTED).await?; // replay, no-op + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.rate, expect.rate.rate); + assert_eq!(p.time_value, Some(locked_time)); Ok(()) } - /// listen() drains scripted updates: Confirmed settles, Detected is ignored. + /// One transaction paying two watched addresses settles both payments: + /// deposits are keyed by (txid, address), not txid alone. #[tokio::test] - async fn test_listen_processes_confirmed_updates() -> Result<()> { + async fn test_same_txid_two_addresses() -> Result<()> { + const ADDRESS2: &str = "bcrt1qtestaddr1"; + let (db, _provider, handler, payment) = setup().await?; + // Second pending payment for another address + let mut payment2 = payment.clone(); + payment2.id = vec![43u8; 32]; + payment2.external_data = ADDRESS2.into(); + db.insert_subscription_payment(&payment2).await?; + + // Both outputs of the same tx + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + handler.handle_deposit(ADDRESS2, "tx1", 1, EXPECTED).await?; + + let p1 = get_payment(&db, &payment.id).await; + let p2 = get_payment(&db, &payment2.id).await; + assert!(p1.is_paid); + assert!(p2.is_paid); + assert_eq!(p1.external_id, Some(deposit_key("tx1", 0))); + assert_eq!(p2.external_id, Some(deposit_key("tx1", 1))); + assert_ne!(p1.external_id, p2.external_id); + Ok(()) + } + + /// Subscriptions without a VM fall back to scaling the original quote by + /// value at the current rate. + #[tokio::test] + async fn test_no_vm_fallback_scales_quote() -> Result<()> { + // Quoted at 100k EUR/BTC; rate doubled by discovery -> same msats are + // worth twice the quoted value -> twice the time. + let (db, _provider, handler, payment, _rates) = setup_with(false, RATE, RATE * 2.0).await?; + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.time_value, Some(TIME_VALUE * 2)); + assert_eq!(p.rate, RATE * 2.0); + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED); + Ok(()) + } + + /// listen() drains scripted updates: Detected prices, Confirmed settles. + #[tokio::test] + async fn test_listen_processes_updates() -> Result<()> { let (db, provider, mut handler, payment) = setup().await?; provider.updates.lock().await.extend([ ChainPaymentUpdate::Detected { address: ADDRESS.to_string(), txid: "tx1".to_string(), + vout: 0, amount_msat: EXPECTED, confirmations: 0, label: None, @@ -523,6 +765,7 @@ mod tests { ChainPaymentUpdate::Confirmed { address: ADDRESS.to_string(), txid: "tx1".to_string(), + vout: 0, amount_msat: EXPECTED, confirmations: 1, label: None, @@ -532,39 +775,7 @@ mod tests { let p = get_payment(&db, &payment.id).await; assert!(p.is_paid); - assert_eq!(p.external_id.as_deref(), Some("tx1")); - Ok(()) - } - - /// The BTC rate is re-calculated when the tx is discovered: a fiat quote - /// paid in full in msat terms credits time by *value* at the current rate. - #[tokio::test] - async fn test_fiat_quote_repriced_at_current_rate() -> Result<()> { - // Quoted at 100k EUR/BTC; by discovery the rate doubled to 200k, so - // the same msats are worth twice the quoted value -> twice the time. - let (db, _provider, handler, payment) = - setup_with("EUR", 100_000.0, Some(200_000.0)).await?; - handler.handle_deposit(ADDRESS, "tx1", EXPECTED).await?; - - let p = get_payment(&db, &payment.id).await; - assert!(p.is_paid); - assert_eq!(p.time_value, Some(TIME_VALUE * 2)); - assert_eq!(p.rate, 200_000.0, "current rate recorded on the payment"); - // msat components are unchanged (full msat amount arrived) - assert_eq!(p.amount, AMOUNT); - assert_eq!(p.tax, TAX); - - // Address reuse is also priced at the (new) current rate - handler.handle_deposit(ADDRESS, "tx2", EXPECTED / 2).await?; - let payments = db.subscription_payments.lock().await.clone(); - let renewal = payments - .iter() - .find(|p| p.external_id.as_deref() == Some("tx2")) - .expect("renewal payment"); - // reference payment now has rate 200k and time 2*TIME_VALUE; half the - // msats at the same rate -> half its time - assert_eq!(renewal.time_value, Some(TIME_VALUE)); - assert_eq!(renewal.rate, 200_000.0); + assert_eq!(p.external_id, Some(deposit_key("tx1", 0))); Ok(()) } diff --git a/work/onchain-payments.md b/work/onchain-payments.md index 90ee3e9f..fb9eca8c 100644 --- a/work/onchain-payments.md +++ b/work/onchain-payments.md @@ -111,11 +111,17 @@ feature), now available on crates.io as `payments-rs = 0.4.1`. - [x] Registered in `listen_all_payments` (provider passed from `bin/api.rs`). - [x] 8 watcher tests: exact/partial deposits, replay de-dupe, unknown address, address-reuse renewal, listen loop, stream error. -- [x] Rate is **re-calculated at tx discovery** (review feedback): time credited = - `time_value × received_msat × rate_now / (expected_msat × rate_quoted)`; - the quote only fixes the price in the subscription currency, never the BTC - rate. Current rate is recorded on the settled payment. BTC-denominated subs - reduce to the plain msat ratio. `PricingEngine::get_ticker` made pub. +- [x] Pricing is **re-generated at tx discovery** (review feedback, superseding the + earlier scaling approach): `Detected` (0-conf) discards the quote and re-prices + the pending payment from the received amount via + `PricingEngine::get_cost_by_amount` — the same path as LNURL top-ups — then + tags it with the deposit key; `Confirmed` just settles. Fallback for + subscriptions without a VM (no amount→cost API): scale the quote by value at + the current rate. `PricingEngine::get_ticker` made pub. +- [x] Deposits keyed by the standard outpoint `{txid}:{vout}`. Required exposing + the output index in payments-rs: `ChainPaymentUpdate::{Detected,Confirmed}` + now carry `vout` (payments-rs commit f107f04, published as **0.5.0** on + crates.io); workspace dep bumped to 0.5.0. ### Increment 5 — API surface + docs (S) - [x] `ApiPaymentMethod::OnChain` / `ApiPaymentData::OnChain { address }` exposed. diff --git a/work/payments-rs-onchain-prompt.md b/work/payments-rs-onchain-prompt.md new file mode 100644 index 00000000..e518fba3 --- /dev/null +++ b/work/payments-rs-onchain-prompt.md @@ -0,0 +1,136 @@ +# Prompt: Add on-chain Bitcoin payment support to `payments-rs` + +You are working in the **`payments-rs`** crate (https://github.com/v0l/payments-rs), a Rust +library that abstracts multiple payment providers behind trait objects. It is consumed by +`lnvps-api`, which needs on-chain Bitcoin receive support to satisfy +[LNVPS/api#109](https://github.com/LNVPS/api/issues/109). + +## Goal + +Add a new **on-chain Bitcoin** payment method to `payments-rs`, following the exact structural +and stylistic conventions already used for the Lightning and fiat providers. The consumer only +needs to **receive** payments: derive a fresh receive address per order, and be notified when +funds arrive (including confirmations and the txid). + +## Study the existing patterns first + +Mirror these — do not invent a new project layout: + +- `src/lightning/mod.rs` — defines the `LightningNode` trait plus shared request/response structs + (`AddInvoiceRequest`, `AddInvoiceResponse`, `InvoiceUpdate` enum with `Created`/`Settled`/…), + gated provider modules (`#[cfg(feature = "method-lnd")] mod lnd;`), and a `subscribe_invoices` + method returning `Pin + Send>>`. +- `src/fiat/mod.rs` — `FiatPaymentService` trait, `LineItem`, boxed-future method signatures. +- `src/currency.rs` — `Currency` (already has a `BTC` variant stored as **milli-satoshis**) and + `CurrencyAmount`. Reuse these; do **not** add a new money type. +- `src/lib.rs` — module gating via feature flags; add `#[cfg(feature = "onchain")] pub mod onchain;`. +- `Cargo.toml` — feature-flag layout (`method-lnd`, `method-bitvora`, …). Add analogous flags. + +## Deliverables + +### 1. New module `src/onchain/` + +Create `src/onchain/mod.rs` defining a provider-agnostic trait, plus at least one concrete backend +module gated behind a feature flag (see below). Suggested trait shape (adapt names to match +existing conventions, e.g. async_trait usage like `LightningNode`): + +```rust +#[async_trait] +pub trait OnChainProvider: Send + Sync { + /// Derive/allocate a fresh receive address for a new order. + /// `external_id` is the caller's order reference (stored so incoming + /// txs can be correlated back). Returns the address + any provider id. + async fn new_address(&self, req: NewAddressRequest) -> Result; + + /// Stream chain events (payment detected / confirmed) for watched + /// addresses, resumable from a cursor (block height or provider marker). + async fn subscribe_payments( + &self, + from: Option, + ) -> Result + Send>>>; +} +``` + +Shared types to define in `mod.rs` (model them on `AddInvoiceRequest`/`InvoiceUpdate`): + +- `NewAddressRequest { amount: CurrencyAmount, memo: Option, external_id: Option }` +- `NewAddressResponse { address: String, external_id: Option }` +- `ChainPaymentUpdate` enum, e.g.: + - `Detected { address, txid, amount_msat: u64, confirmations: u32, external_id: Option }` + - `Confirmed { address, txid, amount_msat: u64, confirmations: u32, external_id: Option }` + - `Error(String)` + +Design notes to honour the consuming issue (#109): +- Amounts must round-trip cleanly to **milli-satoshis** so callers can compare against + `CurrencyAmount::millisats(..)`. Convert on-chain sats → msat at the boundary. +- The **txid** must be surfaced on every update (the consumer stores it as a unique `external_id`). +- Partial / late / over-payments must still be reported — do **not** filter by "exact amount" + inside the library; report the actual `amount_msat` received and let the caller pro-rate. +- `subscribe_payments` must be **resumable** (a cursor param) so a restarted consumer does not miss + or double-count deposits. Prefer block-height + txid dedup semantics; document exactly-once vs + at-least-once guarantees. + +### 2. Concrete backend + +Implement one backend behind a feature flag. Recommended: **Bitcoin Core RPC** (`method-bitcoind`) +using a watch-only descriptor/xpub wallet: +- Config struct `BitcoindConfig { url, auth (cookie or user/pass), xpub or wallet name, network, + min_confirmations }` following the style of `LndConfig` / `BitvoraConfig`. +- `new_address` derives the next unused address from the xpub descriptor (or `getnewaddress` on a + named watch-only wallet) and registers it for watching. +- `subscribe_payments` polls (e.g. `listsinceblock` / `listtransactions` / `getaddressinfo`) on an + interval and emits `ChainPaymentUpdate`s, tracking a block-height cursor. +- Keep all bitcoind-specific deps `optional = true` and only pulled in by the feature. + +If a full bitcoind client is out of scope for a first pass, still land the trait + types + a +`MockOnChainProvider` (test-only) so `lnvps-api` can integrate against a stable interface, and +leave a clearly-marked `TODO` module for the real backend. + +### 3. Feature flags (`Cargo.toml`) + +- Add `onchain = [...]` (analogous to `lightning`/`fiat`) enabling the shared module. +- Add `method-bitcoind = ["onchain", "dep:...", ...]`. +- Add `method-bitcoind` to the `default` feature list **only if** the other methods are enabled by + default (match current convention — currently all methods are default). +- Wire `tls-ring`/`tls-aws` if the RPC client needs TLS, matching existing optional dep patterns. + +### 4. Exports & docs + +- `src/lib.rs`: `#[cfg(feature = "onchain")] pub mod onchain;` with a module-level doc comment in + the same style as the lightning/fiat modules (overview + `rust,ignore` example). +- Re-export concrete types from `onchain/mod.rs` (`#[cfg(feature = "method-bitcoind")] pub use bitcoind::*;`). +- Add a usage example under `examples/` gated with `required-features = ["method-bitcoind"]`, + matching the `revolut`/`stripe` example entries. + +### 5. Tests + +- Follow the in-file `#[cfg(test)] mod tests` convention (see the extensive unit tests at the + bottom of `src/lightning/mod.rs`): cover clone/debug/round-trip for every new struct and enum + variant, sats↔msat conversion edge cases, and cursor resume logic. +- Provide a `MockOnChainProvider` (behind a `test`/`mock` cfg) that emits scripted + `ChainPaymentUpdate`s so downstream (`lnvps-api`) integration tests need no real node. + +## Constraints / house style + +- Reuse `anyhow::Result`, `async_trait`, `futures::Stream`, `Pin>` exactly as the + existing modules do. Match their import ordering and doc-comment density. +- No breaking changes to existing `LightningNode` / `FiatPaymentService` APIs. +- Keep every new external dependency `optional = true` and feature-gated. +- `cargo build`, `cargo test`, `cargo clippy --all-features` and `cargo fmt --check` must all pass. +- Bump the crate version and note the addition in the changelog if the repo keeps one. + +## Definition of done + +- A downstream crate can, with only `payments-rs` + `method-bitcoind` (or the mock): + 1. call `new_address(...)` to get a receive address tied to an order id, + 2. `subscribe_payments(cursor)` and receive `Detected`/`Confirmed` updates carrying the real + txid and actual received `amount_msat`, + 3. resume the subscription after a restart without missing deposits. +- All new public items are documented; all tests pass; clippy/fmt clean. + +## After merging here + +Once released, update the `payments-rs` git `rev` in `lnvps-api/Cargo.toml` and implement the +`lnvps-api` side of #109 (new `PaymentMethod::OnChain` + `ProviderConfig`, DB migration, a +monitoring loop that inserts pro-rated `subscription_payment` rows using the txid as `external_id` +and the address as `external_data`). That work is tracked separately from this crate. From d6f5d1f1c64697a288bd81b4f6a0ee2bf54c4689 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 19:21:47 +0100 Subject: [PATCH 5/8] feat(onchain): notify admins when a deposit arrives for a deleted VM Deposits to addresses of deleted VMs cannot be credited automatically. Database-wise they are ignored - nothing is settled or recorded; a SendAdminNotification job is queued (VM id, user, outpoint, amount, receive address) and the sender is expected to contact support so it is resolved out of band. An in-memory seen-set stops stream replays from re-notifying within one process. Also fixes MockDb::get_vm_by_subscription to match the MySQL impl, which does not filter deleted VMs. --- lnvps_api/src/payments/onchain.rs | 135 +++++++++++++++++++++++++++++- lnvps_api_common/src/mock.rs | 9 +- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index 7d4d73fa..566125c0 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -41,9 +41,9 @@ use crate::subscription::SubscriptionHandler; use anyhow::{Result, bail, ensure}; use chrono::Utc; use futures::StreamExt; -use lnvps_api_common::CostResult; +use lnvps_api_common::{CostResult, WorkJob}; use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use payments_rs::currency::{Currency, CurrencyAmount}; use payments_rs::onchain::{ChainPaymentUpdate, OnChainProvider}; use std::str::FromStr; @@ -53,6 +53,10 @@ pub struct OnChainPaymentHandler { provider: Arc, db: Arc, sub_handler: SubscriptionHandler, + /// Deposits to deleted VMs already reported to admins (in-memory only: + /// these deposits are ignored database-wise and handled out of band, this + /// set just stops stream replays from re-notifying within one process). + reported_deposits: tokio::sync::Mutex>, } /// Scale `value` by `received / expected` (u128 intermediate, no overflow). @@ -84,6 +88,7 @@ impl OnChainPaymentHandler { provider, db, sub_handler, + reported_deposits: Default::default(), } } @@ -207,6 +212,16 @@ impl OnChainPaymentHandler { if payment.is_paid { return Ok(()); } + // Deposits to deleted VMs are held; Confirmed sends the admin alert. + if let Ok(vm) = self + .db + .get_vm_by_subscription(payment.subscription_id) + .await + { + if vm.deleted { + return Ok(()); + } + } self.regenerate(&mut payment, amount_msat).await?; info!( "Deposit {} detected: priced payment {} at rate {} for {} msat ({}s)", @@ -221,6 +236,54 @@ impl OnChainPaymentHandler { Ok(()) } + /// Notify the admins about a deposit that arrived for a deleted VM. + /// + /// Database-wise the deposit is ignored — nothing is settled or recorded. + /// The sender is expected to contact support so it can be resolved out of + /// band. + async fn notify_deleted_vm_deposit( + &self, + payment: SubscriptionPayment, + vm_id: u64, + address: &str, + key: &str, + amount_msat: u64, + ) -> Result<()> { + if !self.reported_deposits.lock().await.insert(key.to_string()) { + debug!("Deposit {} for deleted VM {} already reported", key, vm_id); + return Ok(()); + } + warn!( + "Deposit {} of {} msat arrived for deleted VM {} (payment {}), notifying admins", + key, + amount_msat, + vm_id, + hex::encode(&payment.id) + ); + if let Err(e) = self + .sub_handler + .work_commander() + .send(WorkJob::SendAdminNotification { + title: Some(format!("[VM{}] On-chain deposit to deleted VM", vm_id)), + message: format!( + "An on-chain deposit of {} sats (outpoint {}) arrived for deleted VM #{} (user {}).\n\ + Receive address: {}\n\ + The deposit has NOT been credited. The sender is expected to \ + contact support so it can be resolved out of band.", + amount_msat / 1000, + key, + vm_id, + payment.user_id, + address + ), + }) + .await + { + warn!("Failed to queue admin notification for deposit {}: {}", key, e); + } + Ok(()) + } + /// Handle a confirmed deposit of `amount_msat` to `address` in tx `txid`. async fn handle_deposit( &self, @@ -254,6 +317,21 @@ impl OnChainPaymentHandler { }, }; + // A deposit for a deleted VM is ignored database-wise: just alert the + // admins — the sender is expected to contact support so it can be + // resolved out of band. + if let Ok(vm) = self + .db + .get_vm_by_subscription(payment.subscription_id) + .await + { + if vm.deleted { + return self + .notify_deleted_vm_deposit(payment, vm.id, address, &key, amount_msat) + .await; + } + } + if !payment.is_paid { let mut payment = payment; // Price now unless Detected already did (and for the same gross @@ -378,6 +456,7 @@ mod tests { use lnvps_db::{ IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, SubscriptionType, Vm, }; + use std::time::Duration; const ADDRESS: &str = "bcrt1qtestaddr0"; const AMOUNT: u64 = 1_000_000; @@ -779,6 +858,58 @@ mod tests { Ok(()) } + /// A deposit for a deleted VM is ignored database-wise; the admins are + /// notified (once per deposit within a process) — the sender is expected + /// to contact support so it can be resolved out of band. + #[tokio::test] + async fn test_deleted_vm_deposit_notifies_admin() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + // Delete the VM + for vm in db.vms.lock().await.values_mut() { + vm.deleted = true; + } + + // Detected: silently ignored (no pricing/tagging), no notification + handler.handle_detected(ADDRESS, "tx1", 0, EXPECTED).await?; + let p = get_payment(&db, &payment.id).await; + assert_eq!(p.external_id, None); + + // Confirmed: nothing touched in the database, one admin notification + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + let p = get_payment(&db, &payment.id).await; + assert!(!p.is_paid, "payment must be untouched"); + assert_eq!(p.external_id, None); + assert_eq!(p.metadata, None); + assert_eq!(p.time_value, payment.time_value); + assert_eq!(p.rate, payment.rate); + + let jobs = handler.sub_handler.work_commander().recv().await?; + assert_eq!(jobs.len(), 1); + match &jobs[0].job { + WorkJob::SendAdminNotification { title, message } => { + assert!(title.as_deref().unwrap_or("").contains("deleted VM")); + assert!(message.contains("tx1:0")); + assert!(message.contains(ADDRESS)); + } + other => panic!("expected SendAdminNotification, got {:?}", other), + } + + // Replayed Confirmed: no second notification + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + let no_job = tokio::time::timeout( + Duration::from_millis(50), + handler.sub_handler.work_commander().recv(), + ) + .await; + assert!(no_job.is_err(), "replay must not re-notify admins"); + + // A second distinct deposit to the same address notifies again + handler.handle_deposit(ADDRESS, "tx2", 0, EXPECTED).await?; + let jobs = handler.sub_handler.work_commander().recv().await?; + assert_eq!(jobs.len(), 1); + Ok(()) + } + /// A stream error bubbles up so the outer loop reconnects. #[tokio::test] async fn test_listen_stream_error_bails() -> Result<()> { diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 5df23c64..71992930 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -1213,7 +1213,14 @@ impl LNVpsDbBase for MockDb { )) })?; drop(items); - self.get_vm_by_line_item(line_item_id).await + // Mirror the MySQL impl: unlike get_vm_by_line_item, this does NOT + // filter deleted VMs (callers such as the on-chain watcher need to + // see deleted VMs to hold deposits for manual resolution). + let vms = self.vms.lock().await; + vms.values() + .find(|v| v.subscription_line_item_id == line_item_id) + .cloned() + .ok_or_else(|| anyhow!("VM not found for line item {}", line_item_id).into()) } async fn list_vm_subscription_payments( From 3b6d412b3db7f6035b3eeecbdda761870bfa063a Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 19:34:43 +0100 Subject: [PATCH 6/8] feat(onchain): dedicated DB provider config with min_confirmations - New OnChainProviderConfig (url/cert/macaroon + address_type, account, min_confirmations with serde defaults) replaces the reused LndConfig in ProviderConfig::OnChain; serde tag fixed to 'onchain' to match provider_type() (rename_all would have produced 'on_chain') - PaymentMethodFactory: create_onchain_provider + get_onchain_provider_for_company, mapping OnChainAddressType to LndAddressType and passing account/min_confirmations through - Startup data migration seeds an OnChain config from the YAML LND section so it is manageable via the admin API - Admin API: SanitizedOnChainConfig exposes the new fields Note: runtime still builds the provider from YAML settings (like the Lightning node); completing the factory->runtime integration is #182. Cleanup: collapse nested ifs (let-chains), document that the pro_rate helpers only serve the no-VM fallback pending #181. --- .../data_migration/payment_method_config.rs | 25 ++- lnvps_api/src/payment_factory.rs | 184 ++++++++++++++++++ lnvps_api/src/payments/onchain.rs | 21 +- lnvps_api_admin/src/admin/model.rs | 18 +- lnvps_db/src/model.rs | 71 ++++++- work/onchain-payments.md | 13 ++ 6 files changed, 314 insertions(+), 18 deletions(-) diff --git a/lnvps_api/src/data_migration/payment_method_config.rs b/lnvps_api/src/data_migration/payment_method_config.rs index f01194bb..78e9418f 100644 --- a/lnvps_api/src/data_migration/payment_method_config.rs +++ b/lnvps_api/src/data_migration/payment_method_config.rs @@ -2,8 +2,8 @@ use crate::data_migration::DataMigration; use crate::settings::{LightningConfig, Settings}; use anyhow::Result; use lnvps_db::{ - BitvoraConfig, LNVpsDb, LndConfig, PaymentMethod, PaymentMethodConfig, ProviderConfig, - RevolutProviderConfig, + BitvoraConfig, LNVpsDb, LndConfig, OnChainAddressType, OnChainProviderConfig, PaymentMethod, + PaymentMethodConfig, ProviderConfig, RevolutProviderConfig, }; use log::info; use std::future::Future; @@ -81,6 +81,27 @@ impl DataMigration for PaymentMethodConfigMigration { db.insert_payment_method_config(&payment_config).await?; info!("Migrated LND Lightning config for company {}", company_id); migrated_count += 1; + + // On-chain payments reuse the same LND wallet; seed a + // config with defaults so it is manageable in the admin UI + let onchain_config = ProviderConfig::OnChain(OnChainProviderConfig { + url: url.clone(), + cert_path: PathBuf::from(cert), + macaroon_path: PathBuf::from(macaroon), + address_type: OnChainAddressType::default(), + account: None, + min_confirmations: 1, + }); + let payment_config = PaymentMethodConfig::new_with_config( + company_id, + PaymentMethod::OnChain, + "LND On-chain".to_string(), + true, + onchain_config, + ); + db.insert_payment_method_config(&payment_config).await?; + info!("Migrated LND on-chain config for company {}", company_id); + migrated_count += 1; } LightningConfig::Bitvora { token, diff --git a/lnvps_api/src/payment_factory.rs b/lnvps_api/src/payment_factory.rs index 98b99995..d931096b 100644 --- a/lnvps_api/src/payment_factory.rs +++ b/lnvps_api/src/payment_factory.rs @@ -154,6 +154,90 @@ impl PaymentMethodFactory { } } + /// Create an on-chain payment provider from a PaymentMethodConfig + /// + /// Returns an error if the config is not for the OnChain payment method, + /// is disabled, or the provider type is not supported. + #[cfg(feature = "onchain")] + pub async fn create_onchain_provider( + &self, + config: &PaymentMethodConfig, + ) -> Result> { + use lnvps_db::OnChainAddressType; + use payments_rs::onchain::{LndAddressType, LndOnChainConfig, LndOnChainProvider}; + + if config.payment_method != PaymentMethod::OnChain { + bail!( + "Cannot create on-chain provider from {:?} config", + config.payment_method + ); + } + if !config.enabled { + bail!("Payment method config '{}' is disabled", config.name); + } + let provider_config = config + .get_provider_config() + .context("Failed to parse provider config")?; + match provider_config { + ProviderConfig::OnChain(cfg) => { + let address_type = match cfg.address_type { + OnChainAddressType::WitnessPubkeyHash => LndAddressType::WitnessPubkeyHash, + OnChainAddressType::NestedPubkeyHash => LndAddressType::NestedPubkeyHash, + OnChainAddressType::TaprootPubkey => LndAddressType::TaprootPubkey, + }; + let provider = LndOnChainProvider::new( + &cfg.url, + &cfg.cert_path, + &cfg.macaroon_path, + LndOnChainConfig { + address_type, + account: cfg.account.clone(), + min_confirmations: cfg.min_confirmations, + }, + ) + .await + .context("Failed to create LND on-chain provider")?; + Ok(Arc::new(provider)) + } + other => bail!( + "Unsupported on-chain provider type: {}", + other.provider_type() + ), + } + } + + /// Get the on-chain payment provider for a company + #[cfg(feature = "onchain")] + pub async fn get_onchain_provider_for_company( + &self, + company_id: u64, + ) -> Result>> { + match self + .db + .get_payment_method_config_for_company(company_id, PaymentMethod::OnChain) + .await + { + Ok(config) => { + if config.enabled { + match self.create_onchain_provider(&config).await { + Ok(provider) => Ok(Some(provider)), + Err(e) => { + log::warn!( + "Failed to create on-chain provider from config '{}': {}", + config.name, + e + ); + Ok(None) + } + } + } else { + Ok(None) + } + } + Err(_) => Ok(None), // No config found for company + } + } + /// Get the Lightning node configuration for a company pub async fn get_lightning_node_for_company( &self, @@ -346,6 +430,106 @@ mod tests { Ok(()) } + #[cfg(feature = "onchain")] + fn make_onchain_config(enabled: bool, company_id: u64) -> PaymentMethodConfig { + use lnvps_db::{OnChainAddressType, OnChainProviderConfig}; + PaymentMethodConfig::new_with_config( + company_id, + PaymentMethod::OnChain, + "Test OnChain".to_string(), + enabled, + ProviderConfig::OnChain(OnChainProviderConfig { + url: "https://localhost:10009".to_string(), + cert_path: PathBuf::from("/path/to/cert"), + macaroon_path: PathBuf::from("/path/to/macaroon"), + address_type: OnChainAddressType::WitnessPubkeyHash, + account: None, + min_confirmations: 3, + }), + ) + } + + #[cfg(feature = "onchain")] + #[tokio::test] + async fn test_create_onchain_provider_wrong_method_fails() -> Result<()> { + let db = Arc::new(MockDb::default()); + let factory = PaymentMethodFactory::new(db); + + // Wrong method + let config = make_revolut_config(true, 1); + let result = factory.create_onchain_provider(&config).await; + assert!( + result + .as_ref() + .err() + .map(|e| e.to_string().contains("Cannot create on-chain provider")) + .unwrap_or(false), + "expected wrong-method error, got {:?}", + result.err() + ); + + // Disabled config + let config = make_onchain_config(false, 1); + let result = factory.create_onchain_provider(&config).await; + assert!( + result + .as_ref() + .err() + .map(|e| e.to_string().contains("disabled")) + .unwrap_or(false), + "expected disabled error, got {:?}", + result.err() + ); + + // Wrong provider type inside an OnChain method config + let mut config = make_onchain_config(true, 1); + config.set_provider_config(ProviderConfig::Lnd(LndConfig { + url: "https://localhost:8080".to_string(), + cert_path: PathBuf::from("/path/to/cert"), + macaroon_path: PathBuf::from("/path/to/macaroon"), + })); + let result = factory.create_onchain_provider(&config).await; + assert!( + result + .as_ref() + .err() + .map(|e| e.to_string().contains("Unsupported on-chain provider")) + .unwrap_or(false), + "expected unsupported-provider error, got {:?}", + result.err() + ); + + Ok(()) + } + + #[cfg(feature = "onchain")] + #[tokio::test] + async fn test_get_onchain_provider_for_company() -> Result<()> { + // LndOnChainProvider::new requires a process-level rustls provider + payments_rs::lightning::setup_crypto_provider(); + let db = Arc::new(MockDb::default()); + + // No config -> None + let factory = PaymentMethodFactory::new(db.clone()); + assert!(factory.get_onchain_provider_for_company(1).await?.is_none()); + + // Disabled config -> None + { + let mut configs = db.payment_method_configs.lock().await; + configs.insert(1, make_onchain_config(false, 1)); + } + assert!(factory.get_onchain_provider_for_company(1).await?.is_none()); + + // Enabled config with unreachable node -> None (create fails, logged) + { + let mut configs = db.payment_method_configs.lock().await; + configs.insert(1, make_onchain_config(true, 1)); + } + assert!(factory.get_onchain_provider_for_company(1).await?.is_none()); + + Ok(()) + } + #[tokio::test] async fn test_get_fiat_service_for_lightning_fails() -> Result<()> { let db = Arc::new(MockDb::default()); diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index 566125c0..24e4031e 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -60,12 +60,17 @@ pub struct OnChainPaymentHandler { } /// Scale `value` by `received / expected` (u128 intermediate, no overflow). +/// +/// Only used by the no-VM quote-scaling fallback in [`OnChainPaymentHandler::regenerate`]; +/// remove once subscription-level amount→cost pricing exists (issue #181). fn pro_rate(value: u64, received: u64, expected: u64) -> u64 { debug_assert!(expected > 0); (value as u128 * received as u128 / expected as u128) as u64 } /// Scale `value` by an arbitrary ratio, flooring to whole units. +/// +/// Only used by the no-VM quote-scaling fallback; see [`pro_rate`]. fn pro_rate_f64(value: u64, ratio: f64) -> u64 { (value as f64 * ratio).floor() as u64 } @@ -212,15 +217,14 @@ impl OnChainPaymentHandler { if payment.is_paid { return Ok(()); } - // Deposits to deleted VMs are held; Confirmed sends the admin alert. + // Deposits to deleted VMs are ignored; Confirmed sends the admin alert. if let Ok(vm) = self .db .get_vm_by_subscription(payment.subscription_id) .await + && vm.deleted { - if vm.deleted { - return Ok(()); - } + return Ok(()); } self.regenerate(&mut payment, amount_msat).await?; info!( @@ -324,12 +328,11 @@ impl OnChainPaymentHandler { .db .get_vm_by_subscription(payment.subscription_id) .await + && vm.deleted { - if vm.deleted { - return self - .notify_deleted_vm_deposit(payment, vm.id, address, &key, amount_msat) - .await; - } + return self + .notify_deleted_vm_deposit(payment, vm.id, address, &key, amount_msat) + .await; } if !payment.is_paid { diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index e7e37a36..2e702faa 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -3552,6 +3552,17 @@ pub struct SanitizedLndConfig { pub macaroon_path: String, } +/// Sanitized on-chain config (nothing secret, mirrors OnChainProviderConfig) +#[derive(Serialize)] +pub struct SanitizedOnChainConfig { + pub url: String, + pub cert_path: String, + pub macaroon_path: String, + pub address_type: String, + pub account: Option, + pub min_confirmations: u32, +} + /// Sanitized Bitvora config (hides token and webhook_secret) #[derive(Serialize)] pub struct SanitizedBitvoraConfig { @@ -3601,7 +3612,7 @@ pub enum SanitizedProviderConfig { Revolut(SanitizedRevolutConfig), Stripe(SanitizedStripeConfig), Paypal(SanitizedPaypalConfig), - OnChain(SanitizedLndConfig), + OnChain(SanitizedOnChainConfig), } impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { @@ -3647,10 +3658,13 @@ impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { }) } lnvps_db::ProviderConfig::OnChain(cfg) => { - SanitizedProviderConfig::OnChain(SanitizedLndConfig { + SanitizedProviderConfig::OnChain(SanitizedOnChainConfig { url: cfg.url.clone(), cert_path: cfg.cert_path.display().to_string(), macaroon_path: cfg.macaroon_path.display().to_string(), + address_type: format!("{:?}", cfg.address_type), + account: cfg.account.clone(), + min_confirmations: cfg.min_confirmations, }) } } diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 6de42be7..78fa8da7 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -2413,10 +2413,13 @@ mod tests { #[test] fn test_provider_config_onchain() { - let config = ProviderConfig::OnChain(LndConfig { + let config = ProviderConfig::OnChain(OnChainProviderConfig { url: "https://localhost:10009".to_string(), cert_path: "/tls.cert".into(), macaroon_path: "/admin.macaroon".into(), + address_type: OnChainAddressType::TaprootPubkey, + account: Some("deposits".to_string()), + min_confirmations: 3, }); assert_eq!(config.provider_type(), "onchain"); assert_eq!(config.payment_method(), PaymentMethod::OnChain); @@ -2442,10 +2445,30 @@ mod tests { ); assert_eq!(pmc.provider_type, "onchain"); let parsed = pmc.get_provider_config().expect("config round-trips"); - assert_eq!(parsed.as_onchain().unwrap().url, "https://localhost:10009"); + let oc = parsed.as_onchain().unwrap(); + assert_eq!(oc.url, "https://localhost:10009"); + assert_eq!(oc.address_type, OnChainAddressType::TaprootPubkey); + assert_eq!(oc.account.as_deref(), Some("deposits")); + assert_eq!(oc.min_confirmations, 3); pmc.set_provider_config(parsed); assert_eq!(pmc.provider_type, "onchain"); } + + #[test] + fn test_onchain_provider_config_defaults() { + // Old configs without the new fields deserialize with defaults + let json = serde_json::json!({ + "type": "onchain", + "url": "https://localhost:10009", + "cert_path": "/tls.cert", + "macaroon_path": "/admin.macaroon" + }); + let cfg: ProviderConfig = serde_json::from_value(json).unwrap(); + let oc = cfg.as_onchain().unwrap(); + assert_eq!(oc.address_type, OnChainAddressType::WitnessPubkeyHash); + assert_eq!(oc.account, None); + assert_eq!(oc.min_confirmations, 1); + } } /// Available IP Space - Inventory of IP ranges available for sale @@ -2540,6 +2563,43 @@ pub struct StripeProviderConfig { pub webhook_secret: String, } +/// On-chain receive-address type (mirrors LND's supported families) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnChainAddressType { + /// Pay-to-witness-public-key-hash (`p2wkh`, bech32) + #[default] + WitnessPubkeyHash, + /// Nested pay-to-witness-public-key-hash (`np2wkh`, base58) + NestedPubkeyHash, + /// Pay-to-taproot (`p2tr`, bech32m) + TaprootPubkey, +} + +/// On-chain Bitcoin provider configuration (LND wallet backend) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OnChainProviderConfig { + /// LND gRPC API URL + pub url: String, + /// Path to TLS certificate + pub cert_path: PathBuf, + /// Path to macaroon file + pub macaroon_path: PathBuf, + /// Type of receive address to derive + #[serde(default)] + pub address_type: OnChainAddressType, + /// Optional wallet account name (`None` uses the default account) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, + /// Confirmations required before a deposit is settled + #[serde(default = "default_min_confirmations")] + pub min_confirmations: u32, +} + +fn default_min_confirmations() -> u32 { + 1 +} + /// PayPal provider configuration #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PaypalProviderConfig { @@ -2566,8 +2626,9 @@ pub enum ProviderConfig { Stripe(StripeProviderConfig), /// PayPal fiat payment configuration Paypal(PaypalProviderConfig), - /// On-chain Bitcoin payment configuration (reuses the LND wallet backend) - OnChain(LndConfig), + /// On-chain Bitcoin payment configuration (LND wallet backend) + #[serde(rename = "onchain")] + OnChain(OnChainProviderConfig), } impl ProviderConfig { @@ -2635,7 +2696,7 @@ impl ProviderConfig { } /// Get on-chain config if this is an on-chain provider - pub fn as_onchain(&self) -> Option<&LndConfig> { + pub fn as_onchain(&self) -> Option<&OnChainProviderConfig> { match self { ProviderConfig::OnChain(cfg) => Some(cfg), _ => None, diff --git a/work/onchain-payments.md b/work/onchain-payments.md index fb9eca8c..4aa2700e 100644 --- a/work/onchain-payments.md +++ b/work/onchain-payments.md @@ -123,6 +123,19 @@ feature), now available on crates.io as `payments-rs = 0.4.1`. now carry `vout` (payments-rs commit f107f04, published as **0.5.0** on crates.io); workspace dep bumped to 0.5.0. +### Increment 6 — dedicated DB config + factory (review feedback) +- [x] `OnChainProviderConfig` (url/cert/macaroon + `address_type`, `account`, + `min_confirmations` with serde defaults) replaces the reused `LndConfig` in + `ProviderConfig::OnChain`; serde tag fixed to `onchain` (was snake_cased to + `on_chain`, inconsistent with `provider_type()`). +- [x] Factory: `create_onchain_provider` + `get_onchain_provider_for_company` + (feature-gated `onchain`), mapping `OnChainAddressType` → `LndAddressType`. +- [x] Data migration seeds an `OnChain` config from the YAML LND section. +- [x] Admin `SanitizedOnChainConfig` exposes the new fields. +- [ ] Runtime still builds the provider from YAML settings (hardcoded 1-conf/p2wkh) + — completing the factory→runtime integration is issue #182 (Lightning has the + same gap; the factory is currently dead code at runtime). + ### Increment 5 — API surface + docs (S) - [x] `ApiPaymentMethod::OnChain` / `ApiPaymentData::OnChain { address }` exposed. - [x] `API_CHANGELOG.md` updated (Unreleased). From 5976d35dad2dcd45d41fafab6de71359bd283644 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 20:26:58 +0100 Subject: [PATCH 7/8] test(e2e): on-chain payments against real regtest bitcoind/LND Lifecycle test now exercises the full on-chain flow with real nodes: - 14d happy path: renew?method=onchain derives a real address from LND, lnd-payer sends coins, a block is mined and the API's chain watcher settles the payment; expiry advances and the settled payment records the real txid:vout outpoint - 14e partial payment: half the quoted sats settles with exactly the received msats recorded and ~half an interval credited - 14f address reuse: a further deposit to the settled address auto-creates a new paid renewal payment - Falls back to admin-complete when the docker stack is unavailable Fixes a latent bug: the compose-file path in the docker helpers was relative but cargo runs tests with the crate dir as CWD, so pay_invoice had been silently falling back to admin-complete and lightning e2e never actually paid through LND. Both lightning and on-chain now settle via the real payer node (path resolved from CARGO_MANIFEST_DIR). --- lnvps_e2e/src/lib.rs | 1 + lnvps_e2e/src/lifecycle.rs | 271 +++++++++++++++++++++++++++++++++++-- lnvps_e2e/src/lightning.rs | 5 +- lnvps_e2e/src/onchain.rs | 125 +++++++++++++++++ work/onchain-payments.md | 19 +++ 5 files changed, 411 insertions(+), 10 deletions(-) create mode 100644 lnvps_e2e/src/onchain.rs diff --git a/lnvps_e2e/src/lib.rs b/lnvps_e2e/src/lib.rs index 7621b9bc..78e413cf 100644 --- a/lnvps_e2e/src/lib.rs +++ b/lnvps_e2e/src/lib.rs @@ -18,6 +18,7 @@ pub mod db; mod lifecycle; pub mod lightning; pub mod nip98; +pub mod onchain; mod rbac; pub mod soft_authenticator; mod user_api; diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index 7ead992d..781abf8a 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -675,6 +675,209 @@ mod tests { ); } + // ---------------------------------------------------------------- + // 14d. On-chain renewal: request an on-chain payment, send real + // coins from lnd-payer, mine a block and wait for the API's + // chain watcher to settle it. + // ---------------------------------------------------------------- + let resp = user + .get_auth(&format!("/api/v1/vm/{vm_id}/renew?method=onchain")) + .await + .unwrap(); + if resp.status() == StatusCode::OK { + let oc_renew = serde_json::from_str::(&resp.text().await.unwrap()).unwrap(); + let oc_payment_id = oc_renew["data"]["id"].as_str().unwrap().to_string(); + let oc_address = crate::onchain::extract_onchain_address(&oc_renew).unwrap(); + // amount + tax are msats; send the exact gross in whole sats + let gross_msat = oc_renew["data"]["amount"].as_u64().unwrap() + + oc_renew["data"]["tax"].as_u64().unwrap_or(0); + let gross_sats = gross_msat.div_ceil(1000); + eprintln!( + "Created on-chain payment {oc_payment_id}: {gross_sats} sats to {oc_address}" + ); + + let expiry_before_onchain = + json_ok(user.get_auth(&format!("/api/v1/vm/{vm_id}")).await.unwrap()).await["data"] + ["expires"] + .as_str() + .unwrap() + .to_string(); + + pay_onchain_and_wait( + &admin, + &format!("/api/admin/v1/vms/{vm_id}/payments/{oc_payment_id}"), + &oc_address, + gross_sats, + ) + .await; + eprintln!("On-chain payment {oc_payment_id} settled ✓"); + + // Expiry must advance again + let expiry_after_onchain = + json_ok(user.get_auth(&format!("/api/v1/vm/{vm_id}")).await.unwrap()).await["data"] + ["expires"] + .as_str() + .unwrap() + .to_string(); + assert_ne!( + expiry_after_onchain, expiry_before_onchain, + "VM expiry should advance after on-chain renewal" + ); + eprintln!( + "VM {vm_id} expiry advanced {expiry_before_onchain} → {expiry_after_onchain} after on-chain payment ✓" + ); + + // The settled payment records the txid:vout outpoint + let oc_paid = json_ok( + admin + .get_auth(&format!( + "/api/admin/v1/vms/{vm_id}/payments/{oc_payment_id}" + )) + .await + .unwrap(), + ) + .await; + if let Some(ext) = oc_paid["data"]["external_id"].as_str() { + assert!( + ext.contains(':'), + "external_id should be a txid:vout outpoint, got {ext}" + ); + eprintln!("On-chain payment outpoint: {ext} ✓"); + } + + // ------------------------------------------------------------ + // 14e. On-chain edge case: PARTIAL payment. Deposits are never + // rejected — half the quoted amount settles the payment + // with pro-rated (roughly half) time credited. + // ------------------------------------------------------------ + let oc2 = json_ok( + user.get_auth(&format!("/api/v1/vm/{vm_id}/renew?method=onchain")) + .await + .unwrap(), + ) + .await; + let oc2_payment_id = oc2["data"]["id"].as_str().unwrap().to_string(); + let oc2_address = crate::onchain::extract_onchain_address(&oc2).unwrap(); + let oc2_gross_msat = + oc2["data"]["amount"].as_u64().unwrap() + oc2["data"]["tax"].as_u64().unwrap_or(0); + let half_sats = (oc2_gross_msat / 2).div_ceil(1000); + eprintln!( + "Created on-chain payment {oc2_payment_id}, paying only {half_sats} sats (half)" + ); + + let expiry_before_partial = + json_ok(user.get_auth(&format!("/api/v1/vm/{vm_id}")).await.unwrap()).await["data"] + ["expires"] + .as_str() + .unwrap() + .to_string(); + + pay_onchain_and_wait( + &admin, + &format!("/api/admin/v1/vms/{vm_id}/payments/{oc2_payment_id}"), + &oc2_address, + half_sats, + ) + .await; + + let oc2_paid = json_ok( + admin + .get_auth(&format!( + "/api/admin/v1/vms/{vm_id}/payments/{oc2_payment_id}" + )) + .await + .unwrap(), + ) + .await; + // Components are re-generated from what actually arrived + let recorded = oc2_paid["data"]["amount"].as_u64().unwrap() + + oc2_paid["data"]["tax"].as_u64().unwrap_or(0); + assert_eq!( + recorded, + half_sats * 1000, + "settled partial payment should record exactly the received msats" + ); + // Time credited should be roughly half an interval (month): + // parse expiries and compare the delta against 10-20 days. + let expiry_after_partial = + json_ok(user.get_auth(&format!("/api/v1/vm/{vm_id}")).await.unwrap()).await["data"] + ["expires"] + .as_str() + .unwrap() + .to_string(); + let before = chrono::DateTime::parse_from_rfc3339(&expiry_before_partial).unwrap(); + let after = chrono::DateTime::parse_from_rfc3339(&expiry_after_partial).unwrap(); + let credited_days = (after - before).num_days(); + assert!( + (10..=20).contains(&credited_days), + "half payment should credit roughly half a month, got {credited_days} days" + ); + eprintln!("Partial on-chain payment credited {credited_days} days (≈ half interval) ✓"); + + // ------------------------------------------------------------ + // 14f. On-chain edge case: ADDRESS REUSE. A further deposit to + // the already-settled first address automatically inserts + // a new paid renewal payment. + // ------------------------------------------------------------ + let paid_count_before = json_ok( + user.get_auth(&format!("/api/v1/subscriptions/{sub_id}/payments")) + .await + .unwrap(), + ) + .await["data"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["is_paid"].as_bool().unwrap_or(false)) + .count(); + + match crate::onchain::send_onchain(&oc_address, gross_sats).await { + Ok(reuse_txid) => { + crate::onchain::mine_blocks(1).await.unwrap(); + eprintln!( + "Sent {gross_sats} sats to already-settled address {oc_address} (tx {reuse_txid})" + ); + // A new paid renewal payment should appear automatically + let appeared = poll_until(60, 500, || { + let user = user.clone(); + let path = format!("/api/v1/subscriptions/{sub_id}/payments"); + async move { + if let Ok(r) = user.get_auth(&path).await { + if let Ok(body) = serde_json::from_str::( + &r.text().await.unwrap_or_default(), + ) { + let paid = body["data"] + .as_array() + .map(|a| { + a.iter() + .filter(|p| p["is_paid"].as_bool().unwrap_or(false)) + .count() + }) + .unwrap_or(0); + return paid > paid_count_before; + } + } + false + } + }) + .await; + assert!( + appeared, + "deposit to a settled address should auto-create a new paid renewal payment" + ); + eprintln!("Address-reuse deposit auto-created a paid renewal payment ✓"); + } + Err(e) => { + eprintln!("on-chain payer not available ({e}), skipping address-reuse case"); + } + } + } else { + eprintln!( + "On-chain renew returned {} — skipping on-chain payment flow", + resp.status() + ); + } + // ---------------------------------------------------------------- // 14. Verify referral earnings after payment // ---------------------------------------------------------------- @@ -1815,19 +2018,69 @@ mod tests { // lnd-payer unavailable — fall back to admin complete so the // test suite still passes when running without the full stack. eprintln!("lnd-payer not available ({e}), falling back to admin complete"); - let complete_path = format!("{status_path}/complete"); - let p = json_ok( - admin - .post_auth(&complete_path, &serde_json::json!({})) - .await - .unwrap(), - ) + admin_complete(admin, status_path).await; + } + } + } + + /// Send an on-chain payment to `address`, mine a confirmation block and + /// poll `status_path` until the API's chain watcher settles the payment. + /// Falls back to admin-complete when the docker stack is unavailable. + async fn pay_onchain_and_wait( + admin: &crate::client::TestClient, + status_path: &str, + address: &str, + amount_sats: u64, + ) { + match crate::onchain::send_onchain(address, amount_sats).await { + Ok(txid) => { + eprintln!("On-chain tx {txid} broadcast, mining 1 block..."); + crate::onchain::mine_blocks(1) + .await + .expect("mining a block should succeed when the stack is up"); + // Poll up to 60 s for the chain watcher to settle the deposit. + let paid = poll_until(60, 500, || { + let admin = admin.clone(); + let path = status_path.to_string(); + async move { + if let Ok(r) = admin.get_auth(&path).await { + if let Ok(body) = serde_json::from_str::( + &r.text().await.unwrap_or_default(), + ) { + return body["data"]["is_paid"].as_bool().unwrap_or(false); + } + } + false + } + }) .await; assert!( - p["data"]["is_paid"].as_bool().unwrap_or(false), - "Admin complete at {complete_path} did not mark payment as paid" + paid, + "Payment at {status_path} was not marked paid within 60 s after on-chain confirmation" ); } + Err(e) => { + // Docker stack unavailable — fall back to admin complete so + // the test suite still passes without the full stack. + eprintln!("on-chain payer not available ({e}), falling back to admin complete"); + admin_complete(admin, status_path).await; + } } } + + /// Mark a payment as paid via the admin complete endpoint. + async fn admin_complete(admin: &crate::client::TestClient, status_path: &str) { + let complete_path = format!("{status_path}/complete"); + let p = json_ok( + admin + .post_auth(&complete_path, &serde_json::json!({})) + .await + .unwrap(), + ) + .await; + assert!( + p["data"]["is_paid"].as_bool().unwrap_or(false), + "Admin complete at {complete_path} did not mark payment as paid" + ); + } } diff --git a/lnvps_e2e/src/lightning.rs b/lnvps_e2e/src/lightning.rs index 17be360f..21884ebc 100644 --- a/lnvps_e2e/src/lightning.rs +++ b/lnvps_e2e/src/lightning.rs @@ -9,7 +9,10 @@ const PAYER_SERVICE: &str = "lnd-payer"; /// Docker compose file used by the E2E environment. -const COMPOSE_FILE: &str = "docker-compose.e2e.yaml"; +/// +/// Resolved relative to the workspace root: cargo runs tests with the crate +/// directory as CWD, where a relative path would not resolve. +const COMPOSE_FILE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../docker-compose.e2e.yaml"); /// Pay a BOLT11 invoice using the `lnd-payer` node. /// diff --git a/lnvps_e2e/src/onchain.rs b/lnvps_e2e/src/onchain.rs new file mode 100644 index 00000000..672d33c1 --- /dev/null +++ b/lnvps_e2e/src/onchain.rs @@ -0,0 +1,125 @@ +//! Helpers for paying on-chain (regtest) from E2E tests. +//! +//! The `lnd-payer` docker service has a funded on-chain wallet (101 blocks +//! mined to it by `wait-for-lnd.sh`). Tests call [`send_onchain`] to send +//! coins to a receive address derived by the API's `lnd` node, then +//! [`mine_blocks`] so the deposit confirms and the API's on-chain watcher +//! settles the payment. + +use anyhow::{Context, ensure}; +use serde_json::Value; + +/// Name of the payer LND docker-compose service (funded on-chain wallet). +const PAYER_SERVICE: &str = "lnd-payer"; + +/// Name of the bitcoind docker-compose service (regtest miner). +const BITCOIND_SERVICE: &str = "bitcoind"; + +/// Docker compose file used by the E2E environment. +/// +/// Resolved relative to the workspace root: cargo runs tests with the crate +/// directory as CWD, where a relative path would not resolve. +const COMPOSE_FILE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../docker-compose.e2e.yaml"); + +/// Run a command inside a docker-compose service container. +async fn exec_in_service(service: &str, args: &[&str]) -> anyhow::Result { + let id_out = tokio::process::Command::new("docker") + .args(["compose", "-f", COMPOSE_FILE, "ps", "-q", service]) + .output() + .await?; + let container_id = String::from_utf8(id_out.stdout)?.trim().to_string(); + ensure!( + !container_id.is_empty(), + "Could not find running container for service '{service}'. \ + Is docker-compose.e2e.yaml up?" + ); + + let mut cmd_args = vec!["exec", container_id.as_str()]; + cmd_args.extend_from_slice(args); + let out = tokio::process::Command::new("docker") + .args(&cmd_args) + .output() + .await?; + ensure!( + out.status.success(), + "docker exec in '{service}' failed (exit {})\nstdout: {}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Ok(out) +} + +/// Send `amount_sats` on-chain to `address` from the `lnd-payer` wallet. +/// +/// Returns the txid. The transaction is only broadcast — call +/// [`mine_blocks`] afterwards so it confirms. +pub async fn send_onchain(address: &str, amount_sats: u64) -> anyhow::Result { + let amt = amount_sats.to_string(); + let out = exec_in_service( + PAYER_SERVICE, + &[ + "lncli", + "--network=regtest", + "sendcoins", + "--addr", + address, + "--amt", + &amt, + ], + ) + .await?; + let v: Value = serde_json::from_slice(&out.stdout).context("parsing sendcoins output")?; + v["txid"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("no txid in sendcoins output: {v}")) +} + +/// Mine `n` regtest blocks (to a throwaway payer address). +pub async fn mine_blocks(n: u32) -> anyhow::Result<()> { + let out = exec_in_service( + PAYER_SERVICE, + &["lncli", "--network=regtest", "newaddress", "p2wkh"], + ) + .await?; + let v: Value = serde_json::from_slice(&out.stdout).context("parsing newaddress output")?; + let addr = v["address"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("no address in newaddress output: {v}"))? + .to_string(); + + exec_in_service( + BITCOIND_SERVICE, + &[ + "bitcoin-cli", + "-regtest", + "-rpcuser=polaruser", + "-rpcpassword=polarpass", + "generatetoaddress", + &n.to_string(), + &addr, + ], + ) + .await?; + Ok(()) +} + +/// Extract the on-chain receive address from a VM renew API response. +/// +/// The response shape is: +/// ```json +/// { "data": { "data": { "onchain": { "address": "bcrt1..." } } } } +/// ``` +pub fn extract_onchain_address(renew_response: &Value) -> anyhow::Result { + renew_response["data"]["data"]["onchain"]["address"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + anyhow::anyhow!( + "No on-chain address found in renew response. \ + Expected data.data.onchain.address to be a string. \ + Response: {renew_response}" + ) + }) +} diff --git a/work/onchain-payments.md b/work/onchain-payments.md index 4aa2700e..ae6899c8 100644 --- a/work/onchain-payments.md +++ b/work/onchain-payments.md @@ -136,6 +136,25 @@ feature), now available on crates.io as `payments-rs = 0.4.1`. — completing the factory→runtime integration is issue #182 (Lightning has the same gap; the factory is currently dead code at runtime). +### Increment 7 — real e2e tests (regtest bitcoind + LND) +- [x] `lnvps_e2e/src/onchain.rs`: `send_onchain` (lnd-payer `sendcoins`), + `mine_blocks` (bitcoind `generatetoaddress`), `extract_onchain_address`. +- [x] Lifecycle step 14d: `renew?method=onchain` → real address from LND → real + coins sent + 1 block mined → chain watcher settles → expiry advances → + settled payment records the real `{txid}:{vout}` outpoint. Falls back to + admin-complete when the docker stack is down. +- [x] Edge cases e2e (14e/14f): **partial payment** — half the quote settles with + ≈half a month credited (15 days measured) and records exactly the received + msats; **address reuse** — a further deposit to the settled address + auto-creates a new paid renewal payment. Replay/de-dupe and multi-output + stay unit-tested (need watcher restart / sendmany); deleted-VM deposit not + e2e-testable without breaking later lifecycle steps. +- [x] **Bug found**: docker-compose helpers used a relative compose path but cargo + runs tests with CWD = `lnvps_e2e/`, so `pay_invoice` had been silently + falling back to admin-complete — lightning e2e never actually paid via LND. + Fixed with `CARGO_MANIFEST_DIR`-relative path; lightning payments now settle + through the real payer node too. Full suite: 123 e2e tests green locally. + ### Increment 5 — API surface + docs (S) - [x] `ApiPaymentMethod::OnChain` / `ApiPaymentData::OnChain { address }` exposed. - [x] `API_CHANGELOG.md` updated (Unreleased). From 5a9bcd0e1d0a62b41f23c690d46f8d49e799779b Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 20 Jul 2026 20:28:08 +0100 Subject: [PATCH 8/8] chore: remove completed work files Drop the finished onchain-payments work file and all other work files marked complete (basic-firewall, dns-db-refactor, e2e-integration-tests, fw-inkernel-src-rate, referral-program-apis, vat-snapshot-and-vm-payment-retirement); in-progress files remain. --- work/basic-firewall.md | 99 ---------- work/dns-db-refactor.md | 115 ------------ work/e2e-integration-tests.md | 40 ---- work/fw-inkernel-src-rate.md | 98 ---------- work/onchain-payments.md | 172 ------------------ work/payments-rs-onchain-prompt.md | 136 -------------- work/referral-program-apis.md | 126 ------------- .../vat-snapshot-and-vm-payment-retirement.md | 93 ---------- 8 files changed, 879 deletions(-) delete mode 100644 work/basic-firewall.md delete mode 100644 work/dns-db-refactor.md delete mode 100644 work/e2e-integration-tests.md delete mode 100644 work/fw-inkernel-src-rate.md delete mode 100644 work/onchain-payments.md delete mode 100644 work/payments-rs-onchain-prompt.md delete mode 100644 work/referral-program-apis.md delete mode 100644 work/vat-snapshot-and-vm-payment-retirement.md diff --git a/work/basic-firewall.md b/work/basic-firewall.md deleted file mode 100644 index 12cddb2f..00000000 --- a/work/basic-firewall.md +++ /dev/null @@ -1,99 +0,0 @@ -# Basic Firewall Support (#36) - -**Status:** complete -**Started:** 2026-06-24 -**Last updated:** 2026-06-24 (All increments complete) - -## Goal - -Implement basic user-configurable per-VM firewall rules (issue #36): a data -model for rules, user API endpoints to CRUD them, and a Proxmox PVE firewall -backend that applies the user rules on top of the always-enforced ipfilter -(anti-spoof) rules. Default policy stays allow-all (no regression). - -nftables backend (#33) and the `use_nftables` host flag (#34) are **out of -scope** for this initial work — tracked separately. - -## Findings - -### Existing firewall infrastructure -- `Host::patch_firewall(cfg: &FullVmInfo)` trait method: `lnvps_api/src/host/mod.rs:83` - - Proxmox impl: `lnvps_api/src/host/proxmox.rs:1514` — enables PVE fw on NIC, - manages `ipfilter-net0` IPset (anti-spoof), adds an ACCEPT rule for that set. - - libvirt impl: stub returning Ok (`lnvps_api/src/host/libvirt.rs:230`) - - dummy impl: stub (`lnvps_api/src/host/dummy_host.rs:271`) -- Proxmox API client already has rule helpers: `list_vm_firewall_rules`, - `add_vm_firewall_rule` (proxmox.rs:835+). May need a delete helper. -- `VmFirewallRule` (proxmox API type), `VmFirewallAction`, `VmFirewallRuleType` - already exist for the proxmox client — distinct from our new DB model type. -- patch_firewall is invoked from worker: `lnvps_api/src/worker.rs:1255`. -- NIC always has `firewall=1` (proxmox.rs:928). - -### DB patterns -- Model structs in `lnvps_db/src/model.rs`; enums use - `#[derive(sqlx::Type)] #[repr(u16)]`. Trait in `lnvps_db/src/lib.rs`, - mysql impl in `lnvps_db/src/mysql.rs`. Mock in db trait area too. -- Migrations: `lnvps_db/migrations/` timestamped `.sql`. -- Template limit columns added via ALTER (see template_limits migration); - `VmTemplate` at model.rs:837. - -### API patterns -- Routes registered in `lnvps_api/src/api/routes.rs` (axum `.route(...)`). -- API DTO types in `lnvps_api/src/api/model.rs`. -- VM ownership check pattern used across `v1_*` handlers. -- `API_CHANGELOG.md` must be updated for user-facing API changes. - -## Tasks - -### Increment 1 — Data model + DB layer (M) ✅ DONE -- [x] Migration: create `vm_firewall_rule` table - (`20260624123544_vm_firewall_rule.sql`) -- [x] Migration: add `firewall_rule_limit` to `vm_template` + `vm_custom_template` -- [x] Model: `VmFirewallRule` struct + `VmFirewallDirection`, - `VmFirewallProtocol`, `VmFirewallRuleAction` enums (model.rs) -- [x] Add `firewall_rule_limit` field to `VmTemplate` / `VmCustomTemplate` -- [x] DB trait methods (LNVpsDbBase): insert / get / list_by_vm / update / delete -- [x] mysql impl of the above -- [x] Update mock DB impl (`firewall_rules` map + 5 methods) -- [x] cargo build --workspace + lnvps_api_common tests green - -### Increment 2 — User API (M) ✅ DONE -- [x] API DTOs: `ApiVmFirewallRule` + `ApiFirewall{Direction,Protocol,Action}`, - `CreateVmFirewallRule`, `PatchVmFirewallRule` (api/model.rs) -- [x] `GET /api/v1/vm/{id}/firewall` -- [x] `POST /api/v1/vm/{id}/firewall` -- [x] `PATCH /api/v1/vm/{id}/firewall/{rule_id}` -- [x] `DELETE /api/v1/vm/{id}/firewall/{rule_id}` -- [x] Validation: ownership (`get_user_vm`), max-rule limit - (`vm_firewall_rule_limit`, default 20), CIDR + port-range parsing -- [x] Trigger firewall re-apply: new `WorkJob::ApplyVmFirewall { vm_id }` + - worker `apply_vm_firewall` handler; queued on every change -- [x] `FullVmInfo` now loads `firewall_rules` (for Increment 3 backend) -- [x] API_CHANGELOG.md + API_DOCUMENTATION.md entries -- [x] OpenAPI auto-generated from handlers (`cargo build --features openapi` ok) -- [x] Unit tests: validators, enum round-trips, ApiVmFirewallRule::from, - mock DB firewall CRUD - -### Increment 3 — Proxmox backend (M-L) ✅ DONE -- [x] Translate DB rules → Proxmox PVE firewall rules - (`ProxmoxClient::to_pve_firewall_rule`, tagged with `lnvps-fw:{id}` comment) -- [x] Sync semantics in `patch_firewall`: delete stale user rules (by pos, desc) - then re-add current set in reverse priority order (PVE inserts at top); - ipfilter anti-spoof ACCEPT rule always preserved -- [x] Added `ProxmoxClient::delete_vm_firewall_rule` (DELETE by pos) -- [x] Unit tests: inbound tcp port-range + outbound any single-port disabled -- [x] Full workspace tests green (`--test-threads=1`) - -## Summary - -All three increments complete. nftables backend (#33) + `use_nftables` host -flag (#34) remain out of scope (separate issues); libvirt `patch_firewall` -stays a stub until then. Default policy unchanged (allow-all); anti-spoof always -enforced. - -## Notes - -- Default policy: inbound allow-all, outbound allow-all (current behaviour). -- Anti-spoof ipfilter rules always enforced regardless of user rules. -- Protocols: TCP/UDP/ICMP/Any. Port range optional (start/end). -- Direction: inbound / outbound. diff --git a/work/dns-db-refactor.md b/work/dns-db-refactor.md deleted file mode 100644 index 4608def5..00000000 --- a/work/dns-db-refactor.md +++ /dev/null @@ -1,115 +0,0 @@ -# DNS Forward/Reverse DB-Driven Refactor + OVH Reverse DNS - -**Status:** complete -**Started:** 2026-07-10 -**Last updated:** 2026-07-10 - -## Summary (complete) - -Done in a single PR. DNS providers are now DB rows (`dns_server` table, encrypted token), -referenced per IP range via `forward_dns_server_id` / `reverse_dns_server_id` + -`forward_zone_id` (reverse zone already existed). The `DnsServer` trait was generalized -(zone/ip folded onto `BasicRecord`), a `dns::get_dns_server(db,id)` factory dispatches on -kind, and a new `ovh` provider implements reverse DNS via `POST/DELETE /ip/{ip}/reverse` -(reuses shared `crate::ovh::OvhTokenGen`, extracted from `router/ovh.rs`). `vm_network.rs` -resolves DNS servers per range from the DB; the static `Settings.dns` is now legacy, -consumed once by `DnsDataMigration` to bootstrap the DB rows. Admin CRUD at -`/api/admin/v1/dns_servers` (+ new `dns_server` permission, `AdminResource::DnsServer = 22`). -All unit tests pass; clippy clean on new files; API_CHANGELOG + ADMIN_API_ENDPOINTS updated. -Closes #78 (and finishes #16). Not done: authoritative DNS server crate (#110, out of scope). - -## Goal - -Refactor the DNS subsystem so forward and reverse DNS are configured entirely in the -database (mirroring the existing `router` pattern) instead of the global `settings.yaml`, -and add an OVH reverse-DNS provider. Closes GitHub issue #78 ("OVH Reverse"). - -Done looks like: -- A `dns_server` DB table (`id, name, enabled, kind, url, token`) with an encrypted token, - a Rust model, DB trait CRUD methods, and admin API — mirroring `router`. -- IP ranges reference a **forward** and a **reverse** DNS server (per-range, per user's choice) - plus provider-specific zone fields; the static `Settings.dns` config is removed. -- A generalized `DnsServer` trait + factory `dns::get_dns_server(db, id)` dispatching on kind. -- A new `dns/ovh.rs` provider implementing reverse DNS via `POST /ip/{ip}/reverse` and - `DELETE /ip/{ip}/reverse/{ip}` (forward DNS unsupported by OVH — reverse only). -- `vm_network.rs` resolves DNS servers per IP-range from the DB. -- Data migration moving existing config (`Settings.dns` + `IpRange.reverse_zone_id`) into the - new tables, and updated tests + `MockDnsServer` + `API_CHANGELOG.md`. - -## Findings - -### Current architecture (config split, needs unifying) -- Provider + creds: `settings.yaml` → `Settings.dns` = `DnsServerConfig { forward_zone_id, api: DnsServerApi::Cloudflare { token } }` (single global). `lnvps_api/src/settings.rs:138`, factory `get_dns()` at `settings.rs:256`. -- Forward zone: global `DnsServerConfig.forward_zone_id`. -- Reverse zone: DB `IpRange.reverse_zone_id: Option` (`lnvps_db/src/model.rs:791`). Column added in `lnvps_db/migrations/20250325113115_extend_ip_range.sql`. -- Record refs: DB `VmIpAssignment.dns_forward/dns_forward_ref/dns_reverse/dns_reverse_ref` (`model.rs:1061-1068`). - -### Key files -- `lnvps_api/src/dns/mod.rs` — `DnsServer` trait (`add_record`/`update_record`/`delete_record` all take `zone_id: &str`), `BasicRecord`, `RecordType`, `is_valid_fqdn`. -- `lnvps_api/src/dns/cloudflare.rs` — only provider; zone+record-id based. -- `lnvps_api/src/provisioner/vm_network.rs:119-200` — `remove_ip_dns` / `update_forward_ip_dns` / `update_reverse_ip_dns`. Currently uses `self.dns` (single) + `self.forward_zone_id` + `range.reverse_zone_id`. -- `lnvps_api/src/data_migration/dns.rs` — existing forward/reverse backfill using global settings; model the new data migration on this. -- `lnvps_api/src/mocks.rs` — `MockDnsServer`. - -### Blueprint to mirror: the `router` subsystem -- DB table `router (id, name, enabled bit(1), kind smallint, url varchar(255), token varchar(128))` — migration `20250325113115_extend_ip_range.sql`. -- Model `Router { id, name, enabled, kind: RouterKind, url, token: EncryptedString }` (`model.rs:686`). -- `RouterKind` enum: Mikrotik=0, OvhAdditionalIp=1, LinuxSsh=2, MockRouter=u16::MAX (`model.rs:697`). -- Factory `router::get_router(db, router_id) -> OpResult>` dispatches on kind (`router/mod.rs:370`). -- DB trait CRUD: `get_router`/`list_routers`/... (`lnvps_db/src/lib.rs:411`). -- Admin API: `lnvps_api_admin/src/admin/routers.rs` (CRUD at `/api/admin/v1/routers`). -- **OVH auth already implemented**: `lnvps_api/src/router/ovh.rs` has `OvhTokenGen` (app_key:app_secret:consumer_key token, SHA1 signature, `X-Ovh-*` headers) + time-delta bootstrap via `v1/auth/time`. Extract this into a shared module so both router and DNS OVH clients reuse it. - -### OVH reverse DNS API (issue #78) -- `POST /ip/{ip}/reverse` body `{ ipReverse: "", reverse: "host.example.com." }` — sets PTR. -- `GET /ip/{ip}/reverse/{ipReverse}` — read; `DELETE /ip/{ip}/reverse/{ipReverse}` — remove. -- **No zones, no record IDs.** The "ref" is just the IP. This is why the zone-based `DnsServer` trait must be generalized (make zone optional/opaque; treat the returned ref opaquely). OVH provider supports **reverse only** — return an error/no-op for forward records. - -### Migrations note -- New migration timestamp must be unique 14-digit; generate with `date +%Y%m%d%H%M%S`. Latest existing is `20260624140000`. Use `NOT NULL DEFAULT`/nullable columns so existing rows survive. -- `EncryptedString` type is used for `router.token`; reuse for `dns_server.token`. - -### Related issues -- **#16 "Cloudflare zone ids" (CLOSED, completed 2025-03-25)** — predecessor. Commit `c570222 "feat: move zone-id configuration"` moved only the **reverse** zone id onto `ip_range.reverse_zone_id`; the **forward** zone id + provider token stayed global in `settings.yaml`. This refactor finishes that job (moves forward zone onto `ip_range`, removes `Settings.dns`). Reference #16 in the PR. -- **#110 "DNS server" (OPEN)** — distinct, larger: build an authoritative `lnvps_dns` crate on `trust-dns-server` to host PTR/A/AAAA ourselves. Out of scope here, but it becomes another `DnsServerKind` (self-hosted) that plugs into this DB-driven abstraction. This refactor is an enabler for #110. - -## Design decisions (confirmed with user) -- **Full DB-driven refactor** (not minimal OVH-only). -- Forward DNS configured **per IP range** (store both `forward_dns_server_id` and `reverse_dns_server_id` on `ip_range`, alongside zone fields). - -## Tasks - -### Increment 1 — DB schema + model + trait CRUD (size: M) -- [ ] Extract OVH token/signature helper from `router/ovh.rs` into a shared module (e.g. `lnvps_api/src/ovh/mod.rs` or `json_api`), reused by router + dns. (Can be deferred to Increment 3 if cleaner.) -- [ ] Migration: `create table dns_server (id, name, enabled bit(1), kind smallint, url varchar(255), token varchar(128))`. Add columns to `ip_range`: `forward_dns_server_id`, `reverse_dns_server_id` (both `integer unsigned` nullable, FKs to `dns_server`), and `forward_zone_id varchar(255)` (reverse already exists as `reverse_zone_id`). Keep `reverse_zone_id`. -- [ ] Model: `DnsServer` DB struct + `DnsServerKind` enum (Cloudflare=0, Ovh=1, MockDns=u16::MAX). Extend `IpRange` with the new fields. -- [ ] DB trait + mysql impl: `get_dns_server`/`list_dns_servers`/`insert_dns_server`/`update_dns_server`/`delete_dns_server`. Update `ip_range` queries (get/list/insert/update) for new columns. -- [ ] Note DB model type name clash: DB `DnsServer` struct vs api `DnsServer` trait — rename one (e.g. DB `DnsServerConfig`/`DnsServerRow`, or trait stays `DnsServer` and DB row is `DnsServer` in `lnvps_db` namespace — pick and document). - -### Increment 2 — Generalize DnsServer trait + factory (size: M) -- [ ] Change trait signatures so zone is optional context (e.g. carry an optional zone on `BasicRecord` or pass `&IpRange`), and record ref is opaque. Keep Cloudflare working. -- [ ] Add `dns::get_dns_server(db, dns_server_id) -> OpResult>` factory dispatching on `DnsServerKind` (Cloudflare/Ovh/Mock), decrypting token. -- [ ] Update `MockDnsServer` and any callers to the new trait shape. - -### Increment 3 — OVH reverse DNS provider (size: S/M) -- [ ] `lnvps_api/src/dns/ovh.rs`: implement `DnsServer` for reverse via `POST/GET/DELETE /ip/{ip}/reverse`. Reuse shared OVH token gen. Forward = unsupported error. Feature-flag `ovh-dns` (or reuse existing `ovh`/router feature). -- [ ] Wire into factory + Cargo features. - -### Increment 4 — vm_network + provisioner wiring (size: M) -- [ ] `vm_network.rs`: resolve forward+reverse DNS servers from the IP range via factory instead of `self.dns`/`self.forward_zone_id`. Update `remove_ip_dns`/`update_forward_ip_dns`/`update_reverse_ip_dns`. -- [ ] Remove `forward_zone_id`/`dns` constructor params where they came from settings; source from DB per range. -- [ ] Remove `Settings.dns` / `DnsServerConfig` / `DnsServerApi` from `settings.rs` and `get_dns()`. Update all constructors/tests. - -### Increment 5 — Admin API for dns_server CRUD (size: M) -- [ ] `lnvps_api_admin/src/admin/dns_servers.rs` mirroring `routers.rs` (list/create/get/patch/delete) with `AdminResource` permission. Add route wiring + admin model types. Expose ip_range forward/reverse dns server + zone fields in the ip_range admin API (`lnvps_api_admin/src/admin/ip_ranges.rs`). -- [ ] Update `ADMIN_API_ENDPOINTS.md`. - -### Increment 6 — Data migration + docs + tests (size: M) -- [ ] Data migration: create a `dns_server` row from the old `Settings.dns` Cloudflare token, point existing IP ranges' `forward_dns_server_id` at it + set `forward_zone_id` from old global, and `reverse_dns_server_id` at it where `reverse_zone_id` is set. Model on `data_migration/dns.rs` (which then becomes/needs updating). -- [ ] Update/extend E2E + unit tests, mocks, and demo-data generator (`generate_demo_data.rs`). -- [ ] `API_CHANGELOG.md` under `## [Unreleased]`. Label issue #78 (`enhancement`, `api`, `database`) and open PR `Fixes #78`. - -## Notes -- Coverage rule: 100% function coverage required for added/modified functions — add tests per increment. -- One PR per increment (each L-or-smaller). Ask user before committing/pushing. -- Watch the `DnsServer` name collision between the api trait and a DB model struct. diff --git a/work/e2e-integration-tests.md b/work/e2e-integration-tests.md deleted file mode 100644 index 272a2a6a..00000000 --- a/work/e2e-integration-tests.md +++ /dev/null @@ -1,40 +0,0 @@ -# E2E Integration Tests - -**Status:** complete -**Started:** 2026-02-23 -**Last updated:** 2026-02-23 - -## Goal - -Create a comprehensive E2E integration test suite that tests all user API and admin API endpoints against localhost. Tests use NIP-98 Nostr auth with auto-generated keys. Includes CRUD lifecycle tests and full VM order flow. - -## Findings - -- API uses NIP-98 (Nostr HTTP Auth) for authenticated endpoints -- User API defaults to `http://localhost:8000`, Admin API defaults to `http://localhost:8001` -- Response types wrap in `{"data": ...}` or `{"data": [...], "total": N, "limit": N, "offset": N}` -- Errors return `{"error": "message"}` -- Auth keys are auto-generated when env vars not set, so all tests run unconditionally -- CRUD lifecycle tests for regions, roles, cost plans, OS images -- SSH key CRUD lifecycle and VM order creation flow - -## Tasks - -- [x] Create test crate structure (Cargo.toml, lib crate) -- [x] Implement NIP-98 auth helper -- [x] Implement common test client with base URL and auth support -- [x] Test unauthenticated user API endpoints (docs, templates, images, payment methods, ip_space) -- [x] Test authenticated user API endpoints (account, VMs, SSH keys, payments, subscriptions, referral) -- [x] Test admin API endpoints (users, VMs, hosts, regions, roles, templates, etc.) -- [x] Change defaults to localhost:8000/8001 -- [x] Add admin CRUD lifecycle tests (region, role, cost plan, OS image) -- [x] Add SSH key CRUD lifecycle test -- [x] Add VM order creation test with payment verification -- [x] Add VM extend admin test -- [x] Verify compilation and test execution - -## Notes - -- Environment variables: `LNVPS_API_URL`, `NOSTR_SECRET_KEY`, `LNVPS_ADMIN_API_URL`, `ADMIN_NOSTR_SECRET_KEY` -- Auth keys auto-generated when env vars not set (random Nostr keys) -- 93 total tests, all compiling cleanly with no clippy warnings diff --git a/work/fw-inkernel-src-rate.md b/work/fw-inkernel-src-rate.md deleted file mode 100644 index b5f10f9e..00000000 --- a/work/fw-inkernel-src-rate.md +++ /dev/null @@ -1,98 +0,0 @@ -# In-kernel per-source rate limiting (XDP rewrite) - -**Status:** complete -**Started:** 2026-07-11 -**Last updated:** 2026-07-11 - -## Goal - -Move the per-source pps calculation and blocking decision from the userspace -control loop into the XDP datapath. Userspace stops scanning the (up to 256k -entry) source counter maps every 500ms tick and only reads state for display. -The class of bugs this kills structurally: cumulative-counter seeding, hidden -threshold drift between the two rate systems, and control-loop CPU that scales -with flood history instead of live traffic. - -## Design - -- `V4/V6_SRC_COUNTERS` (per-CPU cumulative u64) are **replaced** by - `V4/V6_SRC_STATE: LruHashMap` where - `SrcState { window_start_ns, count, blocked_until_ns }`. -- Fixed-window rate machine, in-kernel, per packet under mitigation: - - blocked? (`now < blocked_until_ns`) → count, and on window roll while - still over-rate extend the block (re-trip); XDP_DROP. - - window rolled (`now - window_start >= window_ns`) → reset window. - - `count++` (atomic); `count > max_per_window` → `blocked_until = now + - cooldown_ns`, XDP_DROP. -- Config via `SRC_RATE_CFG: Array` (1 entry): - `{ max_per_window, window_ns, cooldown_ns, enforce }` — written by userspace - at startup and on `PUT /limits`. `max_per_window` is precomputed - (`rate_pps × window_secs`) so the datapath never divides. -- Counting happens under any mitigation flags (same as today); **dropping** is - gated on the dest's `SOURCE_BLOCK` flag (escalation ladder unchanged: - userspace still decides *when* a dest escalates via `escalate_pass_pps`). -- Auto CIDR aggregation, spoof gate (`max_real_sources`), `plan_blocks`, - `V4/V6_CIDR_SRC` auto entries: **deleted**. The per-source state map IS the - block list; a spoofed flood of unique IPs never trips per-source limits and - simply churns the LRU (port-filter layer remains the defense, as today). - Manual blocks keep the separate `MANUAL_BLOCK_V4/V6` tries (unchanged). -- Userspace `/sources` + `/blocks` views: batched on-demand read of the state - maps (still via `batch.rs`), `state = blocked_until > now ? dropping : - normal` (the `cooling` display state disappears), `pps` approximated from - the current window. `src_exit_pct` is retired from `Limits`; - `src_rate_pps`/`src_cooldown_secs` remain and now write `SRC_RATE_CFG`. - -## Findings - -- Prior perf work (uncommitted, kept): `batch.rs` raw `BPF_MAP_LOOKUP_BATCH` - reader + batched dest/tx/GC scans + `block_pps` de-quadratic + idle purge. - The purge/tracker machinery (`detect.rs` SourceTracker, `step_sources`) gets - deleted by increment 2 of this rewrite. -- `mitigate_v4` at `lnvps_ebpf/src/main.rs:260` (`count_src_v4` + - `cidr_blocked_v4`) and v6 at `:315` are the integration points. -- Harness tests that break: `tests/escalation.rs` - (`cidr_escalation_blocks_offending_v24` — tests deleted aggregation), - `tests/mitigation.rs` (`source_block_only_when_flag_set` — same semantics, - new map). Harness accessors live in `tests/harness/mod.rs`. -- eBPF atomics: `count` increments use BPF atomic add; per-entry races on - window reset are benign (approximate counting is acceptable). - -## Tasks - -- [x] Pre-work: batch reader + GC batching compiles green (63 lib tests) -- [x] Increment 1: `SrcState`/`SrcRateConfig` in `lnvps_fw_common`; eBPF - `V4/V6_SRC_STATE` maps + `SRC_RATE_CFG`; rate machine in - `mitigate_v4/v6`; deleted `V4/V6_SRC_COUNTERS` + `V4/V6_CIDR_SRC` -- [x] Increment 2: stripped userspace source polling (SourceTracker, - step_sources, plan_blocks/aggregation, spoof gate, block trie - reconciliation); `write_src_rate_cfg` at startup + on `PUT /limits`; - `gc_src_states` on the GC timer (60s idle TTL); views from batched - state-map snapshots; deprecated config knobs kept parseable (regression - test `parses_legacy_escalation_keys`); `src_exit_pct` removed from - `Limits` -- [x] Increment 3: harness accessors rewritten (`src_state_v4`, - `src_blocked_v4`, `set_src_rate`); `escalation.rs` rewritten as two - in-kernel scenarios (kernel blocks over-rate source + releases after - cooldown with **zero** control ticks; drops gated on SOURCE_BLOCK); - superseded `source_block_only_when_flag_set` removed; full e2e suite - green (21 root tests: smoke 4, learning 3, mitigation 4, escalation 2, - carpet_bomb 1, syn_proxy 3, gre_decap 2, scoping 2); docs + example - config + dashboard updated, dist rebuilt - -## Outcome - -43 lib + 2 bin + 15 API tests and the full netns e2e suite pass. The control -loop's per-tick source work went from "iterate every counted source × 2 -syscalls" to one batched display read; blocking latency went from up to one -500ms tick to the packet that crosses the limit; seeding/counter-lifecycle -bugs are structurally gone. Deployment note: on upgrade the old -`V4/V6_CIDR_SRC`-based auto blocks disappear (kernel re-blocks offenders -within one window); old config files parse unchanged. - -## Notes - -- Do NOT release mid-rewrite; single release once increment 3 is green. -- Keep `GET /blocks` API shape backward-compatible (manual + dropping /32s). -- `escalate_pass_pps` and the dest-level detection ladder are intentionally - untouched — dest detection stays in userspace (16k entries, cheap, complex - policy). diff --git a/work/onchain-payments.md b/work/onchain-payments.md deleted file mode 100644 index ae6899c8..00000000 --- a/work/onchain-payments.md +++ /dev/null @@ -1,172 +0,0 @@ -# On-chain Bitcoin Payments (issue #109) - -**Status:** in-progress -**Started:** 2026-07-20 -**Last updated:** 2026-07-20 - -**Design decisions (from review):** -- No new provider config struct — `ProviderConfig::OnChain(LndConfig)` reuses `LndConfig`. -- On-chain provider is **required**, injected into `SubscriptionHandler::new` as - `Arc` exactly like `node: Arc`; built via - `Settings::get_onchain()` which mirrors `get_node()` (LND arm gated by the existing - `onchain` cargo feature, other backends bail). No Option, no type alias. -- `payments-rs` stays a plain workspace dep (`0.4.1` from crates.io); the trait is - available through the default `onchain` feature, same as `lightning` via `lnd`. - -## Goal - -Support on-chain Bitcoin payments. A customer requesting an on-chain payment gets -a freshly derived receive address; a background watcher streams chain updates and -records each confirmed deposit as a `subscription_payment` row. Because on-chain -funds can arrive at any time (including after expiry), late payments are pro-rated -and new `subscription_payment` entries are inserted automatically. - -Backend: `payments-rs` `OnChainProvider` (LND on-chain, `method-lnd-onchain` -feature), now available on crates.io as `payments-rs = 0.4.1`. - -## Findings - -### payments-rs API (crates.io 0.4.1, module `payments_rs::onchain`) -- `OnChainProvider` trait: - - `new_address(NewAddressRequest) -> NewAddressResponse` — derive receive address. - `NewAddressRequest { amount: CurrencyAmount, memo, label }`, - `NewAddressResponse { address, label }`. - - `subscribe_payments(from: Option) -> Stream` - — resumable, at-least-once. **De-dupe on `txid`** for exactly-once accounting. -- `LndOnChainProvider::new(url, tls_cert, macaroon, LndOnChainConfig { address_type, - account, min_confirmations })`. -- `ChainPaymentUpdate::{Detected, Confirmed, Error}`; each payment variant carries - `address`, `txid`, `amount_msat` (real amount received), `confirmations`, `label` - (LND leaves `label` = None → correlate by `address`). -- Amounts in **millisats**; `sats_to_msat` / `msat_to_sats` helpers. -- `PaymentCursor { block_height, block_hash }` — persist to resume without missing/ - double-counting deposits across restarts. -- Feature flags to enable in `lnvps_api/Cargo.toml`: `method-lnd-onchain`. - -### Existing LNVPS structures to extend -- `lnvps_db::PaymentMethod` enum (`model.rs:1471`) — add `OnChain`. Update `Display` - (1487) and `FromStr` (1498) arms. -- `lnvps_db::ProviderConfig` enum (`model.rs:2503`) — add `OnChain(LndOnChainConfig)` - variant + a new config struct near `LndConfig` (2443). Serde-tagged like siblings. -- `SubscriptionPayment` (`model.rs:2023`): `external_id: Option` (unique) = - **txid**; `external_data: EncryptedString` = **bitcoin address**. `SubscriptionPaymentType` - needs an on-chain-relevant handling; reuse existing types. -- Payment factory `lnvps_api/src/payment_factory.rs` — currently splits Lightning - node vs fiat service. Add `create_onchain_provider(&config) -> Arc` - and a `get_onchain_provider_for_company`. -- Listener wiring `lnvps_api/src/payments/mod.rs::listen_all_payments` — add an - on-chain handler task loop (like Revolut/Stripe), gated behind an `onchain` feature. -- New module `lnvps_api/src/payments/onchain.rs` — the watcher: subscribe, de-dupe - txids, resolve address→(subscription/order), insert `subscription_payment`, - pro-rate late payments, persist `PaymentCursor`. -- Pricing/quote surface `lnvps_api_common/src/pricing.rs` — on-chain amount is BTC - like Lightning; expose as a payable method. -- Migration under `lnvps_db/migrations/` — persist cursor + any address→order table, - and confirm `subscription_payment.external_id` uniqueness/index. - -### Amount / currency -- On-chain BTC amounts are millisats — align with `CurrencyAmount::millisats` and the - existing BTC handling in `docs/agents/currency.md`. - -## Tasks - -- [x] Switch `payments-rs` from the git rev to the published crates.io `0.4.1` - (`Cargo.toml`, `cargo update`, verified payment crates compile). - -### Increment 1 — DB layer (S/M) -- [x] Add `PaymentMethod::OnChain` (+ `Display`/`FromStr`) in `lnvps_db/src/model.rs`. -- [x] `ProviderConfig::OnChain(LndConfig)` variant (reuses `LndConfig`) + `as_onchain()`. -- [x] Migration `20260720172401_onchain_payments.sql`: unique index on - `subscription_payment.external_id` (txid de-dupe). No cursor persistence - needed — watcher resubscribes from genesis and de-dupes by txid. -- [x] Unit tests for enum round-trips (Display/FromStr/serde) + `as_onchain`. -- [x] New DB method `list_subscription_payments_by_method` (trait + mysql + mock); - mock `update_subscription_payment` fixed to mirror all MySQL columns. - -### Increment 2 — provider wiring (S) -- [x] `onchain` feature flag in `lnvps_api/Cargo.toml` (`payments-rs/method-lnd-onchain`, - in defaults). -- [x] `Settings::get_onchain()` mirroring `get_node()`; required provider injected into - `SubscriptionHandler::new` after `node` (all call sites updated). -- [x] `MockOnChainProvider` in `lnvps_api/src/mocks.rs` (mirrors `MockNode`; scripted - updates via `updates`, records derived `addresses`). - -### Increment 3 — address derivation / create-payment flow (M) -- [x] `SubscriptionHandler::new_onchain_address` helper (`new_address` on provider). -- [x] `PaymentMethod::OnChain` arms in both payment-creation matches - (`subscription/mod.rs`): BTC-only, 3600s expiry, address in `external_data`, - `external_id = None` until the watcher sees the txid. -- [x] API surface: `ApiPaymentData::OnChain { address }`, `ApiPaymentMethod::OnChain`, - BTC default currency (`routes.rs`); admin `AdminPaymentMethod(Type)::OnChain` + - `SanitizedProviderConfig::OnChain`. -- [x] Test `renew_subscription_onchain_derives_address` (payment-creation arm). -- [x] `get_amount_and_rate` in `pricing.rs` treats OnChain like Lightning (BTC - conversion); processing fee is config-driven (0 without config). - -### Increment 4 — chain watcher + pro-rating (M/L) -- [x] `lnvps_api/src/payments/onchain.rs`: `OnChainPaymentHandler` — subscribe loop, - txid de-dupe, address→payment correlation (decrypt in memory), settle on - `Confirmed`, pro-rate partial/late/over-payments, insert new pro-rated renewal - on address reuse. -- [x] Registered in `listen_all_payments` (provider passed from `bin/api.rs`). -- [x] 8 watcher tests: exact/partial deposits, replay de-dupe, unknown address, - address-reuse renewal, listen loop, stream error. -- [x] Pricing is **re-generated at tx discovery** (review feedback, superseding the - earlier scaling approach): `Detected` (0-conf) discards the quote and re-prices - the pending payment from the received amount via - `PricingEngine::get_cost_by_amount` — the same path as LNURL top-ups — then - tags it with the deposit key; `Confirmed` just settles. Fallback for - subscriptions without a VM (no amount→cost API): scale the quote by value at - the current rate. `PricingEngine::get_ticker` made pub. -- [x] Deposits keyed by the standard outpoint `{txid}:{vout}`. Required exposing - the output index in payments-rs: `ChainPaymentUpdate::{Detected,Confirmed}` - now carry `vout` (payments-rs commit f107f04, published as **0.5.0** on - crates.io); workspace dep bumped to 0.5.0. - -### Increment 6 — dedicated DB config + factory (review feedback) -- [x] `OnChainProviderConfig` (url/cert/macaroon + `address_type`, `account`, - `min_confirmations` with serde defaults) replaces the reused `LndConfig` in - `ProviderConfig::OnChain`; serde tag fixed to `onchain` (was snake_cased to - `on_chain`, inconsistent with `provider_type()`). -- [x] Factory: `create_onchain_provider` + `get_onchain_provider_for_company` - (feature-gated `onchain`), mapping `OnChainAddressType` → `LndAddressType`. -- [x] Data migration seeds an `OnChain` config from the YAML LND section. -- [x] Admin `SanitizedOnChainConfig` exposes the new fields. -- [ ] Runtime still builds the provider from YAML settings (hardcoded 1-conf/p2wkh) - — completing the factory→runtime integration is issue #182 (Lightning has the - same gap; the factory is currently dead code at runtime). - -### Increment 7 — real e2e tests (regtest bitcoind + LND) -- [x] `lnvps_e2e/src/onchain.rs`: `send_onchain` (lnd-payer `sendcoins`), - `mine_blocks` (bitcoind `generatetoaddress`), `extract_onchain_address`. -- [x] Lifecycle step 14d: `renew?method=onchain` → real address from LND → real - coins sent + 1 block mined → chain watcher settles → expiry advances → - settled payment records the real `{txid}:{vout}` outpoint. Falls back to - admin-complete when the docker stack is down. -- [x] Edge cases e2e (14e/14f): **partial payment** — half the quote settles with - ≈half a month credited (15 days measured) and records exactly the received - msats; **address reuse** — a further deposit to the settled address - auto-creates a new paid renewal payment. Replay/de-dupe and multi-output - stay unit-tested (need watcher restart / sendmany); deleted-VM deposit not - e2e-testable without breaking later lifecycle steps. -- [x] **Bug found**: docker-compose helpers used a relative compose path but cargo - runs tests with CWD = `lnvps_e2e/`, so `pay_invoice` had been silently - falling back to admin-complete — lightning e2e never actually paid via LND. - Fixed with `CARGO_MANIFEST_DIR`-relative path; lightning payments now settle - through the real payer node too. Full suite: 123 e2e tests green locally. - -### Increment 5 — API surface + docs (S) -- [x] `ApiPaymentMethod::OnChain` / `ApiPaymentData::OnChain { address }` exposed. -- [x] `API_CHANGELOG.md` updated (Unreleased). -- [ ] Label issue #109 and prep PR referencing `Fixes #109`. - -## Notes - -- Decisions: store **txid in `external_id`** (unique) and **address in `external_data`** - (encrypted), per issue #109. -- LND cannot label on-chain outputs, so the watcher must correlate by **address**, not - `label`. -- De-dup on `txid` — the stream is at-least-once and replays across restarts. -- Load `docs/agents/migrations.md`, `docs/agents/currency.md`, - `docs/agents/api-guidelines.md`, `docs/agents/code-style.md`, and coverage docs when - implementing each increment. diff --git a/work/payments-rs-onchain-prompt.md b/work/payments-rs-onchain-prompt.md deleted file mode 100644 index e518fba3..00000000 --- a/work/payments-rs-onchain-prompt.md +++ /dev/null @@ -1,136 +0,0 @@ -# Prompt: Add on-chain Bitcoin payment support to `payments-rs` - -You are working in the **`payments-rs`** crate (https://github.com/v0l/payments-rs), a Rust -library that abstracts multiple payment providers behind trait objects. It is consumed by -`lnvps-api`, which needs on-chain Bitcoin receive support to satisfy -[LNVPS/api#109](https://github.com/LNVPS/api/issues/109). - -## Goal - -Add a new **on-chain Bitcoin** payment method to `payments-rs`, following the exact structural -and stylistic conventions already used for the Lightning and fiat providers. The consumer only -needs to **receive** payments: derive a fresh receive address per order, and be notified when -funds arrive (including confirmations and the txid). - -## Study the existing patterns first - -Mirror these — do not invent a new project layout: - -- `src/lightning/mod.rs` — defines the `LightningNode` trait plus shared request/response structs - (`AddInvoiceRequest`, `AddInvoiceResponse`, `InvoiceUpdate` enum with `Created`/`Settled`/…), - gated provider modules (`#[cfg(feature = "method-lnd")] mod lnd;`), and a `subscribe_invoices` - method returning `Pin + Send>>`. -- `src/fiat/mod.rs` — `FiatPaymentService` trait, `LineItem`, boxed-future method signatures. -- `src/currency.rs` — `Currency` (already has a `BTC` variant stored as **milli-satoshis**) and - `CurrencyAmount`. Reuse these; do **not** add a new money type. -- `src/lib.rs` — module gating via feature flags; add `#[cfg(feature = "onchain")] pub mod onchain;`. -- `Cargo.toml` — feature-flag layout (`method-lnd`, `method-bitvora`, …). Add analogous flags. - -## Deliverables - -### 1. New module `src/onchain/` - -Create `src/onchain/mod.rs` defining a provider-agnostic trait, plus at least one concrete backend -module gated behind a feature flag (see below). Suggested trait shape (adapt names to match -existing conventions, e.g. async_trait usage like `LightningNode`): - -```rust -#[async_trait] -pub trait OnChainProvider: Send + Sync { - /// Derive/allocate a fresh receive address for a new order. - /// `external_id` is the caller's order reference (stored so incoming - /// txs can be correlated back). Returns the address + any provider id. - async fn new_address(&self, req: NewAddressRequest) -> Result; - - /// Stream chain events (payment detected / confirmed) for watched - /// addresses, resumable from a cursor (block height or provider marker). - async fn subscribe_payments( - &self, - from: Option, - ) -> Result + Send>>>; -} -``` - -Shared types to define in `mod.rs` (model them on `AddInvoiceRequest`/`InvoiceUpdate`): - -- `NewAddressRequest { amount: CurrencyAmount, memo: Option, external_id: Option }` -- `NewAddressResponse { address: String, external_id: Option }` -- `ChainPaymentUpdate` enum, e.g.: - - `Detected { address, txid, amount_msat: u64, confirmations: u32, external_id: Option }` - - `Confirmed { address, txid, amount_msat: u64, confirmations: u32, external_id: Option }` - - `Error(String)` - -Design notes to honour the consuming issue (#109): -- Amounts must round-trip cleanly to **milli-satoshis** so callers can compare against - `CurrencyAmount::millisats(..)`. Convert on-chain sats → msat at the boundary. -- The **txid** must be surfaced on every update (the consumer stores it as a unique `external_id`). -- Partial / late / over-payments must still be reported — do **not** filter by "exact amount" - inside the library; report the actual `amount_msat` received and let the caller pro-rate. -- `subscribe_payments` must be **resumable** (a cursor param) so a restarted consumer does not miss - or double-count deposits. Prefer block-height + txid dedup semantics; document exactly-once vs - at-least-once guarantees. - -### 2. Concrete backend - -Implement one backend behind a feature flag. Recommended: **Bitcoin Core RPC** (`method-bitcoind`) -using a watch-only descriptor/xpub wallet: -- Config struct `BitcoindConfig { url, auth (cookie or user/pass), xpub or wallet name, network, - min_confirmations }` following the style of `LndConfig` / `BitvoraConfig`. -- `new_address` derives the next unused address from the xpub descriptor (or `getnewaddress` on a - named watch-only wallet) and registers it for watching. -- `subscribe_payments` polls (e.g. `listsinceblock` / `listtransactions` / `getaddressinfo`) on an - interval and emits `ChainPaymentUpdate`s, tracking a block-height cursor. -- Keep all bitcoind-specific deps `optional = true` and only pulled in by the feature. - -If a full bitcoind client is out of scope for a first pass, still land the trait + types + a -`MockOnChainProvider` (test-only) so `lnvps-api` can integrate against a stable interface, and -leave a clearly-marked `TODO` module for the real backend. - -### 3. Feature flags (`Cargo.toml`) - -- Add `onchain = [...]` (analogous to `lightning`/`fiat`) enabling the shared module. -- Add `method-bitcoind = ["onchain", "dep:...", ...]`. -- Add `method-bitcoind` to the `default` feature list **only if** the other methods are enabled by - default (match current convention — currently all methods are default). -- Wire `tls-ring`/`tls-aws` if the RPC client needs TLS, matching existing optional dep patterns. - -### 4. Exports & docs - -- `src/lib.rs`: `#[cfg(feature = "onchain")] pub mod onchain;` with a module-level doc comment in - the same style as the lightning/fiat modules (overview + `rust,ignore` example). -- Re-export concrete types from `onchain/mod.rs` (`#[cfg(feature = "method-bitcoind")] pub use bitcoind::*;`). -- Add a usage example under `examples/` gated with `required-features = ["method-bitcoind"]`, - matching the `revolut`/`stripe` example entries. - -### 5. Tests - -- Follow the in-file `#[cfg(test)] mod tests` convention (see the extensive unit tests at the - bottom of `src/lightning/mod.rs`): cover clone/debug/round-trip for every new struct and enum - variant, sats↔msat conversion edge cases, and cursor resume logic. -- Provide a `MockOnChainProvider` (behind a `test`/`mock` cfg) that emits scripted - `ChainPaymentUpdate`s so downstream (`lnvps-api`) integration tests need no real node. - -## Constraints / house style - -- Reuse `anyhow::Result`, `async_trait`, `futures::Stream`, `Pin>` exactly as the - existing modules do. Match their import ordering and doc-comment density. -- No breaking changes to existing `LightningNode` / `FiatPaymentService` APIs. -- Keep every new external dependency `optional = true` and feature-gated. -- `cargo build`, `cargo test`, `cargo clippy --all-features` and `cargo fmt --check` must all pass. -- Bump the crate version and note the addition in the changelog if the repo keeps one. - -## Definition of done - -- A downstream crate can, with only `payments-rs` + `method-bitcoind` (or the mock): - 1. call `new_address(...)` to get a receive address tied to an order id, - 2. `subscribe_payments(cursor)` and receive `Detected`/`Confirmed` updates carrying the real - txid and actual received `amount_msat`, - 3. resume the subscription after a restart without missing deposits. -- All new public items are documented; all tests pass; clippy/fmt clean. - -## After merging here - -Once released, update the `payments-rs` git `rev` in `lnvps-api/Cargo.toml` and implement the -`lnvps-api` side of #109 (new `PaymentMethod::OnChain` + `ProviderConfig`, DB migration, a -monitoring loop that inserts pro-rated `subscription_payment` rows using the txid as `external_id` -and the address as `external_data`). That work is tracked separately from this crate. diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md deleted file mode 100644 index 1fd2b20c..00000000 --- a/work/referral-program-apis.md +++ /dev/null @@ -1,126 +0,0 @@ -# Referral Program API Completion - -**Status:** complete -**Started:** 2026-07-18 -**Last updated:** 2026-07-18 (all 4 PRs complete) - -## Goal - -Complete the referral program so it is fully operable end-to-end: a configurable -commission (percentage of the referred VM's first payment), admin management APIs -(list/inspect referrers, earnings, payouts; manual payout + reconcile), automated -payout processing (pay referrers via Lightning address / NWC, capture preimage), -and the missing user-facing endpoints. Delivered as a sequence of L-or-smaller -PRs, one increment at a time. - -## Findings - -Current state (as of investigation): - -- **User API** `lnvps_api/src/api/referral.rs`: `POST` enroll, `GET` state, - `PATCH` payout prefs. State exposes per-currency `earned`, `payouts`, and - success/failed counts. No `DELETE`, no per-referral VM detail list. -- **Attribution**: `vm.ref_code` set at order time (`provisioner/vm.rs`). - Earnings basis = first paid subscription payment per referred VM - (`list_referral_usage` in `lnvps_db/src/mysql.rs:2980`). -- **DB layer** (`lnvps_db/src/lib.rs:790+`, `model.rs:1310+`): `Referral`, - `ReferralPayout` (has `is_paid`, `invoice`, `pre_image`), `ReferralCostUsage`, - and `insert/update_referral_payout`. -- **DEAD CODE**: `insert_referral_payout` / `update_referral_payout` are never - called in production — no accrual, no worker payout job, `pre_image` unused, - `payouts` is always empty in practice. -- **Admin**: only `GET /api/admin/v1/reports/referral-usage/time-series` - (`admin/reports.rs`). No referral CRUD, no payout management. No dedicated - RBAC resource (report reuses `analytics::view`). -- **Config**: no commission rate / cap / threshold anywhere. `earned` currently - sums the *full* first payment. - -Decisions: - -- **Commission model**: configurable **percentage of the first payment** per - referred VM. The effective rate is **per-referrer with a company default**: - `referral.referral_rate` (nullable override on the referrer's entry) takes - precedence, else `company.referral_rate` (the referred VM's company). Payments - are company-scoped, so a referrer with no override uses each referred VM's - company default; an override applies to all of that referrer's referrals. -- Follow the project's explicit per-feature RBAC grant convention (grant new - `Referral` resource to `super_admin`; extend `read_only`/`admin` only if we - add it deliberately — mirror how existing resources were introduced). - -## Tasks - -### PR 1 — Commission config + flexible payout mode [M] -- [x] Replace `referral.use_nwc` boolean with a `ReferralPayoutMode` enum column - (`lightning_address` | `nwc` | `account_credit`, extensible). Migration - migrates use_nwc=1 -> nwc. API `mode` field replaces `use_nwc`; - `account_credit` reserved/rejected until implemented. -- [x] Migration: add `referral_rate FLOAT NOT NULL DEFAULT 0` to `company` - (default) and `referral_rate FLOAT NULL` to `referral` (per-user override). -- [x] `Company` + `Referral` models + all inserts/updates/selects (`lnvps_db`). -- [x] Admin company GET/PATCH expose + edit `referral_rate` (`admin/companies.rs`). -- [x] User `GET /api/v1/referral` exposes the per-referrer override (read-only; - admin-controlled — users cannot set their own commission). -- [x] Compute reward = first_payment * effective_rate% where - effective_rate = referral.referral_rate ?? company.referral_rate: surfaced - in `list_referral_usage` / `ReferralCostUsage.effective_rate`, applied in - `ApiReferralState` `earned` and the admin report (`effective_rate`,`commission`). -- [x] Tests (commission floor/0%, mode roundtrip, parse_payout_mode), docs + changelog. - -**PR1 committed:** `4048398`. Note: per-referrer override is set by admins (PR2), -not users. `referral.referral_rate` NULL = use company default. - -### PR 2 — Admin referral management APIs + RBAC [L] -- [x] Add `AdminResource::Referral = 25` (Display/FromStr/TryFrom/all + roundtrip - test); explicit permission migration `20260718002000` (grants super_admin). -- [x] DB: `admin_list_referrals` (paginated + search by code substring or 64-char - hex pubkey via SQL HEX()), `admin_get_referral`; extended - `update_referral_payout` to also set invoice; mock impls + test. -- [x] Endpoints (`admin/referrals.rs`): list, detail (earnings + payouts + - counts), PATCH commission override, list/create payout, PATCH reconcile - (is_paid/invoice/pre_image). -- [x] Sanitized (no NWC secrets). Tests, docs, changelog. - -**PR2 committed:** `4d19600`. Per-referrer override is set here via -`PATCH /api/admin/v1/referrals/{id}`. - -### PR 3 — Automated payout processing (worker) [L] -- [x] Accrual: owed BTC = sum(commission on first payments) − sum(existing BTC - payouts, paid + reserved). Only BTC is auto-paid (Lightning settles sats); - fiat commission left for manual admin payout. -- [x] Config: opt-in `referral` settings section (`min-payout-sats`, default - 1000). Absent = automated payouts disabled. Job scheduled hourly, gated on config. -- [x] Dedicated `ReferralPayoutHandler` (`lnvps_api/src/referral/mod.rs`) — - **not** in SubscriptionHandler. Reserve-then-pay (delete reservation on - failure) so no double-pay; LNURL-pay + NWC make_invoice; captures pre_image; - notifies referrer. New DB base methods `list_all_referrals` / - `delete_referral_payout`; `update_referral_payout` also persists invoice. -- [x] Expose `pre_image` (hex) in `ApiReferralPayout`. Tests (payable math), - docs, changelog. - -**PR3 committed:** `8980e48`. Note: enabled lnurl-rs `async-https-native` -feature; new `WorkJob::ProcessReferralPayouts`; `Worker::new` gained a `node` param. - -### PR 4 — User API extras [M] -- [x] `DELETE /api/v1/referral` (leave program). Blocks on pending payout (409) - and on paid payout history (retained for accounting). New base DB method - `delete_referral` + mock test. -- [x] `GET /api/v1/referral/usage` — per-referred-VM breakdown (vm_id, first - payment, currency, effective_rate, commission). -- [x] `pre_image` already surfaced in payout records (PR3). Tests, docs, changelog. - -**PR4 committed:** `b5dac33`. - -## Summary - -All four increments are merged: PR1 `4048398` (commission rate + payout mode), -PR2 `4d19600` (admin management + RBAC), PR3 `8980e48` (automated payout worker), -PR4 `b5dac33` (leave program + per-VM usage). The referral program is now -complete end-to-end: configurable per-referrer/company commission, admin -management, automated BTC Lightning payouts (opt-in), and full user self-service. - -## Notes - -- Currency: earnings/payouts are per-currency (referred VMs may span companies / - currencies). Payout via Lightning implies BTC; conversion policy for non-BTC - earned balances must be decided in PR 3. -- Keep one writer per PR; do not start PR N+1 until PR N is committed. diff --git a/work/vat-snapshot-and-vm-payment-retirement.md b/work/vat-snapshot-and-vm-payment-retirement.md deleted file mode 100644 index 610fcf4f..00000000 --- a/work/vat-snapshot-and-vm-payment-retirement.md +++ /dev/null @@ -1,93 +0,0 @@ -# VAT snapshot on payments + retire defunct vm_payment - -**Status:** complete -**Started:** 2026-07-16 -**Last updated:** 2026-07-16 - -## Goal - -1. Freeze a full VAT determination snapshot (rate, place-of-supply country, - treatment, and the evidence used) on every `subscription_payment` for OSS - filing / audit defensibility. -2. Finish retiring the defunct `vm_payment` table: drop the table and remove the - dead model/queries/backfill-phase-2/demo-data that still reference it. - -## Findings - -- **Live payment path is `subscription_payment`.** The public API - (`/api/v1/vm/{id}/payments`, renew, etc.) already builds `ApiVmPayment` via - `ApiVmPayment::from_subscription_payment(...)`. `ApiVmPayment` is only a - response DTO name — not backed by the `vm_payment` table. -- **`vm_payment` is only referenced by:** - - `lnvps_api/src/data_migration/vm_subscription_backfill.rs` Phase 2 - (`list_vm_payments_for_migration`) — copies old rows into - `subscription_payment`. Phase 1 (VM→subscription linking) does NOT touch - vm_payment and must be kept. - - `lnvps_api_admin/src/bin/generate_demo_data.rs` (`insert_vm_payment`). - - DB trait methods in `lnvps_db/src/lib.rs` (~12) + mysql impls (~53 refs) + - mock impls + models `VmPayment`/`VmPaymentRaw`/`VmPaymentWithCompany`. -- **Payment tax is computed uniformly per payment** (one user + one - `subscription.company_id`), so a single `TaxDetermination` per payment is - correct. 4 `SubscriptionPayment {}` construction sites in - `subscription/mod.rs`: lines ~550, 639 (aggregated renew) and ~736, 784 - (single-item). Upgrade path ~934 sets `tax: 0`. -- Migration ordering: the startup backfill runs AFTER schema migrations, so the - `DROP TABLE vm_payment` migration and the removal of backfill Phase 2 must land - together (otherwise Phase 2 queries a dropped table). Operator confirms prod is - already migrated. -- Snapshot column design (chosen): `tax_rate double`, `tax_country_code - varchar(3)`, `tax_treatment varchar(32)`, `tax_evidence json` (declared - country / geo country / vat number). Discrete columns for reporting GROUP BY; - JSON blob for rarely-queried evidence. - -## Tasks - -### Increment 1 — VAT snapshot on subscription_payment -- [x] Extend `TaxDetermination` with evidence (`declared_country`, - `geo_country`); populate in `determine_tax`. -- [x] Per-line-item design: added `TaxLine`, `summarize_tax_lines`, `TaxSummary`, - `NewPaymentInfo.tax_details`; determination now threaded per line item so a - payment mixing sellers/treatments is recorded losslessly. -- [x] Migration: add `tax_rate`(nullable), `tax_country_code`, `tax_treatment`, - `tax_evidence`, `tax_breakdown`(json) to `subscription_payment`. -- [x] Add fields to `SubscriptionPayment` + `SubscriptionPaymentWithCompany`. -- [x] `insert_subscription_payment` (WithCompany selects use `sp.*`) + mock. -- [x] Fill snapshot at the 4 construction sites (aggregated + single) from the - per-line breakdown; upgrade path → `TaxDetermination::untaxed()`. -- [x] Removed now-unused `get_tax_for_user`. -- [ ] Surface in admin reports where subscription payments are exposed. -- [x] Tests (summarize uniform/mixed, to_line/evidence, determination branches); - fixed provisioner test that asserted the old hardcoded 1%. - -Note: a payment maps to one subscription (per-VM, single company+user) today, so -breakdowns are single-line in practice; the array design future-proofs multi- -seller subscriptions without a schema change. - -### Increment 2 — Retire vm_payment -- [x] Migration `20260716130000_drop_vm_payment.sql`: `DROP TABLE IF EXISTS vm_payment`. -- [x] Removed backfill Phase 2 (kept Phase 1) + `migrate_vm_payments` + - `list_vm_payments_for_migration` / `list_vm_ids_with_uncopied_payments` / - `insert_subscription_payment_raw` / `list_subscription_payment_ids_for_subscription`. -- [x] Converted generate_demo_data to insert `SubscriptionPayment`. -- [x] Removed 12 trait methods (lib.rs), mysql impls, mock impls + mock - `payments` field. -- [x] Removed models `VmPayment` / `VmPaymentRaw` / `VmPaymentWithCompany`, - `ApiInvoiceItem::from_vm_payment`, `From for ApiVmPayment`, - `AdminVmPaymentInfo::from_vm_payment` (kept `ApiVmPayment` DTO name and the - `AdminResource::VmPayment` RBAC resource). -- [x] Build/test/clippy clean across the workspace (e2e excluded — needs live stack). - -## Notes - -- No commits yet this session; user reviews before commit. -- `AdminResource::VmPayment` (RBAC resource id 15) intentionally kept — it gates - the payment endpoints, which now read `subscription_payment`. -- Remaining `vm_payment`-ish identifiers are safe: `ApiVmPayment` DTO, - `vm_payment_infos` locals, `log_vm_payment_received`, admin route names. - -## Summary - -Both increments complete. VAT determinations are now frozen per-payment as a -per-line-item breakdown (future-proof for multi-seller payments) plus a uniform -summary + customer evidence, surfaced in admin time-series reports. The defunct -`vm_payment` table and all its code are removed and the table is dropped.