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 @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
3 changes: 3 additions & 0 deletions lnvps_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ default = [
"proxmox",
"linux-ssh",
"lnd",
"onchain",
"cloudflare",
"ovh",
"revolut",
Expand All @@ -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 = []
Expand Down
13 changes: 13 additions & 0 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand All @@ -494,6 +502,7 @@ pub enum ApiPaymentMethod {
Stripe,
NWC,
LNURL,
OnChain,
}

impl From<PaymentMethod> for ApiPaymentMethod {
Expand All @@ -503,6 +512,7 @@ impl From<PaymentMethod> for ApiPaymentMethod {
PaymentMethod::Revolut => ApiPaymentMethod::Revolut,
PaymentMethod::Paypal => ApiPaymentMethod::Paypal,
PaymentMethod::Stripe => ApiPaymentMethod::Stripe,
PaymentMethod::OnChain => ApiPaymentMethod::OnChain,
}
}
}
Expand Down Expand Up @@ -1082,6 +1092,9 @@ impl From<lnvps_db::SubscriptionPayment> 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 {
Expand Down
13 changes: 6 additions & 7 deletions lnvps_api/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,12 +808,7 @@ async fn v1_list_vm_images(State(this): State<RouterState>) -> ApiResult<Vec<Api
let images = this.db.list_os_image().await?;

// Compute popularity as the fraction of active VMs using each image
let counts: HashMap<u64, u64> = this
.db
.count_vms_by_os_image()
.await?
.into_iter()
.collect();
let counts: HashMap<u64, u64> = this.db.count_vms_by_os_image().await?.into_iter().collect();
let total: u64 = counts.values().sum();

let ret = images
Expand Down Expand Up @@ -1534,6 +1529,7 @@ fn parse_currencies(codes: &[String]) -> Vec<ApiCurrency> {
fn default_currencies_for_method(method: PaymentMethod) -> Vec<ApiCurrency> {
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],
Expand Down Expand Up @@ -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 => {
Expand Down
33 changes: 20 additions & 13 deletions lnvps_api/src/bin/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<dyn CountryResolver>> = match &settings.geoip_database {
Expand Down Expand Up @@ -223,6 +224,7 @@ async fn main() -> Result<(), Error> {
settings.clone(),
db.clone(),
node.clone(),
onchain.clone(),
exchange.clone(),
vat.clone(),
work_commander.clone(),
Expand Down Expand Up @@ -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?,
);
}

Expand Down Expand Up @@ -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
{
Expand Down
4 changes: 3 additions & 1 deletion lnvps_api/src/data_migration/orphaned_custom_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
))
})
}
}
25 changes: 23 additions & 2 deletions lnvps_api/src/data_migration/payment_method_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion lnvps_api/src/dvm/lnvps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
Expand Down
36 changes: 36 additions & 0 deletions lnvps_api/src/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Mutex<Vec<String>>>,
/// Scripted chain updates returned from `subscribe_payments`.
pub updates: Arc<Mutex<Vec<ChainPaymentUpdate>>>,
}

#[async_trait]
impl OnChainProvider for MockOnChainProvider {
async fn new_address(&self, req: NewAddressRequest) -> anyhow::Result<NewAddressResponse> {
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<PaymentCursor>,
) -> anyhow::Result<Pin<Box<dyn Stream<Item = ChainPaymentUpdate> + Send>>> {
let updates = self.updates.lock().await.clone();
Ok(Box::pin(futures::stream::iter(updates)))
}
}
Loading
Loading