Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Changed

- **On-chain deposits to non-VM subscriptions are now priced from live line-item pricing** (issue #181) — a new `PricingEngine::get_subscription_cost_by_amount` turns an arbitrary paid amount into time for any subscription, priced from its line-item amounts and billing interval (`interval_amount`/`interval_type`) with the same tax + processing-fee handling as the VM path. The on-chain payment watcher now uses it for subscriptions without a VM (IP range, DNS, ASN, …), replacing the previous fallback that scaled the payment's original (stale) quote by the value received. All on-chain deposits — VM and non-VM — are now re-generated uniformly from current pricing. No API surface change.
- **VM reinstall now runs on the worker instead of inline in the API request**`PATCH /api/v1/vm/{id}/re-install` previously executed the stop → wipe → re-import → start pipeline directly in the HTTP handler, which could race a still-in-flight provision (spawn) of a freshly-paid VM and surface confusing errors. The reinstall is now dispatched as a worker job (serialised with spawn on the same worker), and the API waits on a pub/sub job-feedback reply before responding. On success it returns `200`; on a worker-reported failure it returns an error with the reason; if the reinstall is still running after 180s (large disk imports) it returns `200` and the job continues (VM state is refreshed by the periodic check). Deployments without a Redis feedback service fall back to running the pipeline inline as before. Up-front validation (ownership, expired-VM guard, image switch) is unchanged. No request/response schema change.
- **Payment providers are now configured exclusively in the database** (issue #182) — the Lightning node, on-chain provider and Revolut service used at startup are now constructed from the per-company `payment_method_config` rows (via `PaymentMethodFactory`). The YAML `lightning:` / `revolut:` config sections have been **removed**, along with the one-time YAML→DB seed migration and the `Settings::get_node`/`get_onchain`/`get_revolut` helpers. Providers must be configured via the admin API (`POST/PATCH /api/admin/v1/payment_method_configs`); startup now fails with a clear error if no enabled Lightning/on-chain config exists for the default company. The on-chain provider now honours the DB `min_confirmations`, `address_type` and `account` fields (previously hardcoded to 1 / p2wkh / default account). The runtime still resolves a single global provider set from the default (first) company; true per-company provider routing remains future work. **Operators must ensure their `payment_method_config` rows are seeded before upgrading** (deployments that ran the previous seed migration already are). No API surface change.
- **Custom VM price quote now returns converted prices**`POST /api/v1/vm/custom-template/price` now returns `{ currency, amount, other_price }`, where `other_price` lists the same quote converted into the other supported currencies (matching the template listing's `cost_plan.other_price`). Previously it returned only the single base-currency `{ currency, amount }`; existing clients reading those two fields are unaffected.
Expand All @@ -31,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Fixed

- **Amount→time pricing now treats the paid amount as gross (tax + fee taken out, not added on)** — the amount-based pricing (`get_cost_by_amount` for LNURL/on-chain VM top-ups, and the new `get_subscription_cost_by_amount`) previously treated the input as the **net** price and computed tax/fee *on top* of it. But the amount a customer actually pays is the **gross** total — the sats a wallet sends for an LNURL top-up, or an on-chain deposit — so tax (and any processing fee) must be taken **out** of it to get the net that buys time. Both methods now back the net out of the gross (`net = (gross·(1 − fee_rate) − fee_base) / (1 + tax)`, via a new `PricingEngine::net_from_gross`) and the returned `amount`/`tax`/`processing_fee` sum to exactly the paid amount. Practical effects: an LNURL top-up for a taxed customer now buys time worth the net (previously the full amount was treated as net, over-crediting time and generating an invoice larger than requested); on-chain deposits now also remove the processing fee, not just the tax. No effect for untaxed, no-fee payments (net already equalled the paid amount).
- **LNURL-pay callback no longer emits a non-spec `hodl_invoice` field** (issue #197) — `GET /api/v1/vm/{id}/renew-lnurlp` previously returned `{"pr": "...", "hodl_invoice": null}` (the extra field came from the `lnurl-rs` response type). `hodl_invoice` is not part of any LUD, and strict wallets such as LNbits (1.5.5) refused to pay the invoice because of the unexpected field. The endpoint now returns a spec-compliant LUD-06 response containing only `pr` (and an empty `routes` array); the `hodl_invoice` key is gone.
- **Reinstall no longer blocked on a VM whose expiry hasn't been set yet**`PATCH /api/v1/vm/{id}/re-install` rejected the request with `402 "Cannot re-install an expired VM, please renew it first"` whenever the VM's subscription had no `expires` value. A null expiry is not an expired VM — it's a subscription that hasn't been given an expiry yet (e.g. a freshly provisioned VM whose payment expiry hasn't propagated), which the client already renders as *new*, not *expired*. Reinstall now only rejects a **concrete** expiry at/before now, and a missing/failed subscription lookup now surfaces as an error rather than being silently treated as expired.
- **Compressed OS images (`.xz`, `.zst`, `.gz`, `.bz2`, `.lzo`) now usable on Proxmox** — image URLs pointing at a compressed disk image previously failed: Proxmox's `download-url` API rejects compressed filenames (`.qcow2.xz` → "wrong file extension") and its own decompression is unreliable, so no VM could be provisioned from them. Proxmox OS image downloads now bypass the `download-url` API entirely and fetch the file directly on the host over SSH (`wget`/`curl`, which follow redirects natively and no longer need Proxmox's flaky redirect/checksum handling). Compressed images are then verified against their `SHASUMS` entry and decompressed on the host into the final `.img` (via `xz`/`zstd`/`gzip`/`bzip2`/`lzop`, using temp-file + atomic move so a partial download/decompression never leaves a truncated image). Supported extensions: `.xz`/`.lzma`, `.zst`/`.zstd`, `.gz`, `.bz2`, `.lzo`. Affects the admin `POST /api/admin/v1/vm_os_images/{id}/download` endpoint and the periodic image check.
Expand Down
178 changes: 77 additions & 101 deletions lnvps_api/src/payments/onchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,19 @@
//! 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.
//! - Subscriptions without a VM are priced from their line-item amounts and
//! billing interval (`PricingEngine::get_subscription_cost_by_amount`), so
//! every payment is re-generated uniformly from current line-item pricing.

use crate::subscription::SubscriptionHandler;
use anyhow::{Result, bail, ensure};
use anyhow::{Result, bail};
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::currency::CurrencyAmount;
use payments_rs::onchain::{ChainPaymentUpdate, OnChainProvider};
use std::str::FromStr;
use std::sync::Arc;

pub struct OnChainPaymentHandler {
Expand All @@ -59,22 +59,6 @@ pub struct OnChainPaymentHandler {
reported_deposits: tokio::sync::Mutex<std::collections::HashSet<String>>,
}

/// 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
Expand Down Expand Up @@ -109,80 +93,57 @@ impl OnChainPaymentHandler {
.max_by_key(|p| p.created))
}

/// Current BTC exchange rate for the payment's subscription currency.
async fn current_rate(&self, payment: &SubscriptionPayment) -> Result<f32> {
let sub = self.db.get_subscription(payment.subscription_id).await?;
let sub_currency = Currency::from_str(&sub.currency)
.map_err(|e| anyhow::anyhow!("Invalid subscription currency: {}", e))?;
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.
/// Both VM-backed and non-VM subscriptions price through the pricing engine
/// exactly like an LNURL top-up: VMs via [`PricingEngine::get_cost_by_amount`],
/// other subscriptions via [`PricingEngine::get_subscription_cost_by_amount`]
/// (from their line-item amounts and billing interval).
///
/// [`PricingEngine::get_cost_by_amount`]: lnvps_api_common::PricingEngine::get_cost_by_amount
/// [`PricingEngine::get_subscription_cost_by_amount`]: lnvps_api_common::PricingEngine::get_subscription_cost_by_amount
async fn regenerate(&self, payment: &mut SubscriptionPayment, gross_msat: u64) -> Result<()> {
match self
let engine = self.sub_handler.pricing_engine();

// The received on-chain amount is always the **gross** deposit
// (net + tax + processing fee). The pricing engine takes the gross paid
// amount directly and backs out the net (removing tax + fee); the
// returned components sum to exactly what arrived.
//
// VM-backed subscriptions price from the VM's template/custom pricing;
// everything else prices from the subscription's line items.
let gross = CurrencyAmount::millisats(gross_msat);
let cost = 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;
engine
.get_cost_by_amount(vm.id, gross, PaymentMethod::OnChain)
.await?
}
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;
engine
.get_subscription_cost_by_amount(
payment.subscription_id,
gross,
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 sum to exactly what arrived (fee is the remainder).
payment.amount = p.amount;
payment.tax = p.tax;
payment.processing_fee = p.processing_fee;
Ok(())
}

Expand Down Expand Up @@ -637,6 +598,28 @@ mod tests {
}
}

/// Price a net amount through the subscription (non-VM) pricing path, for
/// asserting the watcher's re-generated pricing.
async fn engine_price_sub(
handler: &OnChainPaymentHandler,
sub_id: u64,
net: u64,
) -> Result<NewPaymentInfo> {
match handler
.sub_handler
.pricing_engine()
.get_subscription_cost_by_amount(
sub_id,
CurrencyAmount::millisats(net),
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) {
Expand All @@ -649,18 +632,6 @@ mod tests {
);
}

#[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
Expand Down Expand Up @@ -818,16 +789,21 @@ mod tests {
/// 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?;
async fn test_no_vm_prices_from_line_items() -> Result<()> {
// No VM: the quote is discarded and pricing is re-generated from the
// subscription's line-item amounts + billing interval, not from a
// quote-scaling fallback.
let (db, _provider, handler, payment, _rates) = setup_with(false, RATE, RATE).await?;
// tax_rate is None -> net == gross.
let expect = engine_price_sub(&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.time_value, Some(TIME_VALUE * 2));
assert_eq!(p.rate, RATE * 2.0);
assert_eq!(p.rate, expect.rate.rate);
assert_close(p.time_value.unwrap(), expect.time_value);
// Components always sum to exactly what arrived.
assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED);
Ok(())
}
Expand Down
Loading
Loading