diff --git a/Cargo.lock b/Cargo.lock index 8254d45..68ade2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.11" @@ -309,6 +320,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.0", +] + [[package]] name = "base64" version = "0.21.7" @@ -350,25 +371,62 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bitcoin" +version = "0.32.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.0", + "hex-conservative 0.2.1", + "hex_lit", + "secp256k1", + "serde", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-io" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals 0.3.0", + "serde", +] + [[package]] name = "bitcoin_hashes" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.2.0", "hex-conservative 0.1.2", ] @@ -920,9 +978,12 @@ dependencies = [ [[package]] name = "email_address" -version = "0.2.9" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +checksum = "c1019fa28f600f5b581b7a603d515c3f1635da041ca211b5055804788673abfe" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -1394,6 +1455,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -2037,6 +2104,24 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +[[package]] +name = "lnurl-rs" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41eacdd87b675792f7752f3dd0937a00241a504c3956c47f72986490662e1db4" +dependencies = [ + "aes", + "anyhow", + "base64 0.22.1", + "bech32", + "bitcoin", + "cbc", + "email_address", + "serde", + "serde_json", + "url", +] + [[package]] name = "lnvps_api" version = "0.1.0" @@ -2054,6 +2139,7 @@ dependencies = [ "ipnetwork", "isocountry", "lettre", + "lnurl-rs", "lnvps_common", "lnvps_db", "log", @@ -3491,6 +3577,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ + "bitcoin_hashes 0.14.0", "rand 0.8.5", "secp256k1-sys", "serde", diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index a5fab0e..00b0210 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -56,6 +56,7 @@ ssh-key = "0.6.7" lettre = { version = "0.11.10", features = ["tokio1-native-tls"] } ws = { package = "rocket_ws", version = "0.1.1" } native-tls = "0.2.12" +lnurl-rs = { version = "0.9.0", default-features = false } futures = "0.3.31" isocountry = "0.3.2" diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index f860c68..e24a600 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -149,7 +149,7 @@ impl ApiTemplatesResponse { CurrencyAmount::from_f32(template.cost_plan.currency, template.cost_plan.amount); for alt_price in alt_prices(&rates, list_price) { template.cost_plan.other_price.push(ApiPrice { - currency: alt_price.0, + currency: alt_price.currency(), amount: alt_price.value_f32(), }); } @@ -255,7 +255,7 @@ pub struct ApiPrice { impl From for ApiPrice { fn from(value: CurrencyAmount) -> Self { Self { - currency: value.0, + currency: value.currency(), amount: value.value_f32(), } } diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index d786c71..069ba2d 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -4,7 +4,7 @@ use crate::api::model::{ ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest, }; -use crate::exchange::{Currency, ExchangeRateService}; +use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService}; use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData}; use crate::nip98::Nip98Auth; use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine}; @@ -15,11 +15,14 @@ use anyhow::{bail, Result}; use futures::future::join_all; use futures::{SinkExt, StreamExt}; use isocountry::CountryCode; +use lnurl::pay::{LnURLPayInvoice, PayResponse}; +use lnurl::Tag; use lnvps_db::{ IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, }; use log::{error, info}; use nostr::util::hex; +use nostr::Url; use rocket::serde::json::Json; use rocket::{get, patch, post, routes, Responder, Route, State}; use rocket_okapi::gen::OpenApiGenerator; @@ -64,7 +67,11 @@ pub fn routes() -> Vec { api_routes.append(&mut super::nostr_domain::routes()); routes.append(&mut api_routes); - routes.append(&mut routes![v1_terminal_proxy]); + routes.append(&mut routes![ + v1_terminal_proxy, + v1_lnurlp, + v1_renew_vm_lnurlp + ]); routes } @@ -540,6 +547,57 @@ async fn v1_renew_vm( ApiData::ok(rsp.into()) } +/// Extend a VM by LNURL payment +#[get("/api/v1/vm//renew-lnurlp?")] +async fn v1_renew_vm_lnurlp( + provisioner: &State>, + id: u64, + amount: u64, +) -> Result, &'static str> { + if amount < 1000 { + return Err("Amount must be greater than 1000"); + } + + let rsp = provisioner + .renew_amount( + id, + CurrencyAmount::millisats(amount), + PaymentMethod::Lightning, + ) + .await + .map_err(|_| "Error generating invoice")?; + + // external_data is pr for lightning payment method + Ok(Json(LnURLPayInvoice::new(rsp.external_data))) +} + +/// LNURL ad-hoc extend vm +#[get("/.well-known/lnurlp/")] +async fn v1_lnurlp( + db: &State>, + settings: &State, + id: u64, +) -> Result, &'static str> { + db.get_vm(id).await.map_err(|_e| "VM not found")?; + + let meta = vec![vec!["text/plain".to_string(), format!("Extend VM {}", id)]]; + let rsp = PayResponse { + callback: Url::parse(&settings.public_url) + .map_err(|_| "Invalid public url")? + .join(&format!("/api/v1/vm/{}/renew-lnurlp", id)) + .map_err(|_| "Could not get callback url")? + .to_string(), + max_sendable: 1_000_000_000, + min_sendable: 1_000, // TODO: calc min by using 1s extend time + tag: Tag::PayRequest, + metadata: serde_json::to_string(&meta).map_err(|_e| "Failed to serialize metadata")?, + comment_allowed: None, + allows_nostr: None, + nostr_pubkey: None, + }; + Ok(Json(rsp)) +} + /// Start a VM #[openapi(tag = "VM")] #[patch("/api/v1/vm//start")] diff --git a/lnvps_api/src/exchange.rs b/lnvps_api/src/exchange.rs index d896f58..14c7f55 100644 --- a/lnvps_api/src/exchange.rs +++ b/lnvps_api/src/exchange.rs @@ -75,14 +75,19 @@ impl Display for Ticker { pub struct TickerRate(pub Ticker, pub f32); #[derive(Clone, Copy, Debug, PartialEq)] -pub struct CurrencyAmount(pub Currency, u64); +pub struct CurrencyAmount(Currency, u64); impl CurrencyAmount { const MILLI_SATS: f64 = 1.0e11; + pub fn millisats(amount: u64) -> Self { + CurrencyAmount(Currency::BTC, amount) + } + pub fn from_u64(currency: Currency, amount: u64) -> Self { CurrencyAmount(currency, amount) } + pub fn from_f32(currency: Currency, amount: f32) -> Self { CurrencyAmount( currency, @@ -103,6 +108,10 @@ impl CurrencyAmount { _ => self.1 as f32 / 100.0, } } + + pub fn currency(&self) -> Currency { + self.0 + } } impl TickerRate { diff --git a/lnvps_api/src/fiat/revolut.rs b/lnvps_api/src/fiat/revolut.rs index 3354cd6..49d0622 100644 --- a/lnvps_api/src/fiat/revolut.rs +++ b/lnvps_api/src/fiat/revolut.rs @@ -89,8 +89,8 @@ impl RevolutApi { .post( "/api/orders", CreateOrderRequest { - currency: amount.0.to_string(), - amount: match amount.0 { + currency: amount.currency().to_string(), + amount: match amount.currency() { Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"), _ => amount.value(), }, diff --git a/lnvps_api/src/provisioner/lnvps.rs b/lnvps_api/src/provisioner/lnvps.rs index 9c2fc09..f7946f7 100644 --- a/lnvps_api/src/provisioner/lnvps.rs +++ b/lnvps_api/src/provisioner/lnvps.rs @@ -483,8 +483,23 @@ impl LNVpsProvisioner { /// Create a renewal payment pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result { let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone()); - let price = pe.get_vm_cost(vm_id, method).await?; + self.price_to_payment(vm_id, method, price).await + } + + /// Renew a VM using a specific amount + pub async fn renew_amount(&self, vm_id: u64, amount: CurrencyAmount, method: PaymentMethod) -> Result { + let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone()); + let price = pe.get_cost_by_amount(vm_id, amount, method).await?; + self.price_to_payment(vm_id, method, price).await + } + + async fn price_to_payment( + &self, + vm_id: u64, + method: PaymentMethod, + price: CostResult, + ) -> Result { match price { CostResult::Existing(p) => Ok(p), CostResult::New { diff --git a/lnvps_api/src/provisioner/pricing.rs b/lnvps_api/src/provisioner/pricing.rs index 57fada1..51316d3 100644 --- a/lnvps_api/src/provisioner/pricing.rs +++ b/lnvps_api/src/provisioner/pricing.rs @@ -1,5 +1,5 @@ use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService, Ticker, TickerRate}; -use anyhow::{bail, Result}; +use anyhow::{bail, ensure, Result}; use chrono::{DateTime, Days, Months, TimeDelta, Utc}; use ipnetwork::IpNetwork; use isocountry::CountryCode; @@ -34,6 +34,49 @@ impl PricingEngine { } } + /// Get amount of time a certain currency amount will extend a vm in seconds + pub async fn get_cost_by_amount( + &self, + vm_id: u64, + input: CurrencyAmount, + method: PaymentMethod, + ) -> Result { + let vm = self.db.get_vm(vm_id).await?; + + let cost = if vm.template_id.is_some() { + self.get_template_vm_cost(&vm, method).await? + } else { + self.get_custom_vm_cost(&vm, method).await? + }; + + match cost { + CostResult::Existing(_) => bail!("Invalid response"), + CostResult::New { + currency, + amount, + rate, + time_value, + .. + } => { + ensure!(currency == input.currency(), "Invalid currency"); + + // scale cost + let scale = input.value() as f64 / amount as f64; + let new_time = (time_value as f64 * scale).floor() as u64; + ensure!(new_time > 0, "Extend time is less than 1 second"); + + Ok(CostResult::New { + amount: input.value(), + currency, + time_value: new_time, + new_expiry: vm.expires.add(TimeDelta::seconds(new_time as i64)), + rate, + tax: self.get_tax_for_user(vm.user_id, input.value()).await?, + }) + } + } + } + /// Get VM cost (for renewal) pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result { let vm = self.db.get_vm(vm_id).await?; @@ -160,13 +203,13 @@ impl PricingEngine { } async fn get_msats_amount(&self, amount: CurrencyAmount) -> Result<(u64, f32)> { - let rate = self.get_ticker(amount.0).await?; + let rate = self.get_ticker(amount.currency()).await?; let cost_btc = amount.value_f32() / rate.1; let cost_msats = (cost_btc as f64 * crate::BTC_SATS) as u64 * 1000; Ok((cost_msats, rate.1)) } - fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 { + pub fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 { let next_expire = match cost_plan.interval_type { VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)), VmCostPlanIntervalType::Month => vm @@ -209,7 +252,7 @@ impl PricingEngine { list_price: CurrencyAmount, method: PaymentMethod, ) -> Result<(Currency, u64, f32)> { - Ok(match (list_price.0, method) { + Ok(match (list_price.currency(), method) { (c, PaymentMethod::Lightning) if c != Currency::BTC => { let new_price = self.get_msats_amount(list_price).await?; (Currency::BTC, new_price.0, new_price.1) @@ -400,6 +443,25 @@ mod tests { _ => bail!("??"), } + // from amount + let price = pe + .get_cost_by_amount(1, CurrencyAmount::millisats(1000), PaymentMethod::Lightning) + .await?; + // full month price in msats + let mo_price = (plan.amount / MOCK_RATE * 1.0e11) as u64; + let time_scale = 1000f64 / mo_price as f64; + let vm = db.get_vm(1).await?; + let next_expire = PricingEngine::next_template_expire(&vm, &plan); + match price { + CostResult::New { amount, time_value, tax, .. } => { + let expect_time = (next_expire as f64 * time_scale) as u64; + assert_eq!(expect_time, time_value); + assert_eq!(0, tax); + assert_eq!(amount, 1000); + } + _ => bail!("??"), + } + Ok(()) } }