diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index b5a4e164..fe1ef26f 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: 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? }`. - **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/Cargo.lock b/Cargo.lock index 7188d4af..4f067c38 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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c78ae2f0d557e77ffa28ca46e7e229189a3b50a1ead760664105f073b8858b" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 60b18243..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 = { git = "https://github.com/v0l/payments-rs.git", rev = "f4456beb68d800a2a0b386b74719a59087cb74cb", 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/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/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/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/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/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..24e4031e --- /dev/null +++ b/lnvps_api/src/payments/onchain.rs @@ -0,0 +1,928 @@ +//! 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 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. +//! +//! # Pricing (issue #109) +//! +//! 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`). +//! +//! - 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** 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, WorkJob}; +use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; +use log::{debug, error, info, warn}; +use payments_rs::currency::{Currency, CurrencyAmount}; +use payments_rs::onchain::{ChainPaymentUpdate, OnChainProvider}; +use std::str::FromStr; +use std::sync::Arc; + +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). +/// +/// 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 +} + +/// 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, + db: Arc, + sub_handler: SubscriptionHandler, + ) -> Self { + Self { + provider, + db, + sub_handler, + reported_deposits: Default::default(), + } + } + + /// 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)) + } + + /// 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))?; + Ok(self + .sub_handler + .pricing_engine() + .get_ticker(Currency::BTC, sub_currency) + .await? + .rate) + } + + /// 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(&key) + .await + .is_ok() + { + return Ok(()); + } + let Some(mut payment) = self.payment_for_address(address).await? else { + return Ok(()); + }; + // Address-reuse deposits are handled at confirmation; only a pending + // payment is re-priced here. + if payment.is_paid { + return Ok(()); + } + // 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 + { + 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(()) + } + + /// 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, + 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(()); + } + }, + }; + + // 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 + && 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 + // 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 {}: {} msat at rate {} ({}s)", + hex::encode(&payment.id), + amount_msat, + payment.rate, + payment.time_value.unwrap_or(0) + ); + 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 renewal payment, + // priced from the received amount (issue #109). + info!( + "New deposit {} of {} msat to settled address of payment {}, creating renewal", + txid, + amount_msat, + hex::encode(&payment.id) + ); + let new_id: [u8; 32] = rand::random(); + 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(), + // 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(key), + is_paid: false, + rate: payment.rate, + time_value: payment.time_value, + metadata: None, + tax: payment.tax, + processing_fee: payment.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.regenerate(&mut renewal, amount_msat).await?; + 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 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, 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, 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), + } + } + 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, ExchangeRateService, MockDb, MockExchangeRate, NewPaymentInfo, + Ticker, VmStateCache, + }; + use lnvps_db::{ + IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, SubscriptionType, Vm, + }; + use std::time::Duration; + + 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; + const RATE: f32 = 100_000.0; + + /// 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( + with_vm: bool, + quoted_rate: f32, + 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()); + rates.set_rate(Ticker::btc_rate("EUR")?, current_rate).await; + + 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: "EUR".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?; + + 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], + 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: quoted_rate, + 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(), + 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)) + } + + async fn get_payment(db: &MockDb, id: &[u8]) -> SubscriptionPayment { + db.subscription_payments + .lock() + .await + .iter() + .find(|p| p.id == id) + .cloned() + .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); + 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); + 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)); + } + + /// A confirmed deposit settles the pending payment with pricing + /// re-generated by the engine (quote discarded). + #[tokio::test] + async fn test_deposit_settles_with_engine_pricing() -> Result<()> { + let (db, _provider, handler, payment) = setup().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, 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(()) + } + + /// Half the deposit buys half the time (engine pricing is linear). + #[tokio::test] + async fn test_partial_deposit_buys_less_time() -> Result<()> { + let (db, _provider, handler, payment) = setup().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_close(p.time_value.unwrap(), expect_full.time_value / 2); + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED / 2); + Ok(()) + } + + /// A replayed deposit must not settle or create anything. + #[tokio::test] + async fn test_duplicate_deposit_skipped() -> Result<()> { + let (db, _provider, handler, _payment) = setup().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(()) + } + + /// 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", 0, 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 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", 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 == Some(deposit_key("tx2", 0))) + .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_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(()) + } + + /// One transaction paying two watched addresses settles both payments: + /// deposits are keyed by (txid, address), not txid alone. + #[tokio::test] + 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, + }, + ChainPaymentUpdate::Confirmed { + address: ADDRESS.to_string(), + txid: "tx1".to_string(), + vout: 0, + 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, Some(deposit_key("tx1", 0))); + 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<()> { + 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..2e702faa 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, } } } @@ -3547,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 { @@ -3596,6 +3612,7 @@ pub enum SanitizedProviderConfig { Revolut(SanitizedRevolutConfig), Stripe(SanitizedStripeConfig), Paypal(SanitizedPaypalConfig), + OnChain(SanitizedOnChainConfig), } impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { @@ -3640,6 +3657,16 @@ impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { has_client_secret: !cfg.client_secret.is_empty(), }) } + lnvps_db::ProviderConfig::OnChain(cfg) => { + 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_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 1c81caf7..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( @@ -2255,6 +2262,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 +2330,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 +3909,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 +4313,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 +4332,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..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, @@ -1214,7 +1215,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 +1223,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..78fa8da7 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,80 @@ 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(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); + 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"); + 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 @@ -2485,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 { @@ -2511,6 +2626,9 @@ pub enum ProviderConfig { Stripe(StripeProviderConfig), /// PayPal fiat payment configuration Paypal(PaypalProviderConfig), + /// On-chain Bitcoin payment configuration (LND wallet backend) + #[serde(rename = "onchain")] + OnChain(OnChainProviderConfig), } impl ProviderConfig { @@ -2522,6 +2640,7 @@ impl ProviderConfig { ProviderConfig::Revolut(_) => "revolut", ProviderConfig::Stripe(_) => "stripe", ProviderConfig::Paypal(_) => "paypal", + ProviderConfig::OnChain(_) => "onchain", } } @@ -2532,6 +2651,7 @@ impl ProviderConfig { ProviderConfig::Revolut(_) => PaymentMethod::Revolut, ProviderConfig::Stripe(_) => PaymentMethod::Stripe, ProviderConfig::Paypal(_) => PaymentMethod::Paypal, + ProviderConfig::OnChain(_) => PaymentMethod::OnChain, } } @@ -2574,6 +2694,14 @@ impl ProviderConfig { _ => None, } } + + /// Get on-chain config if this is an on-chain provider + pub fn as_onchain(&self) -> Option<&OnChainProviderConfig> { + 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/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/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/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.