feat: lnurl extend
All checks were successful
continuous-integration/drone/push Build is passing

closes https://github.com/LNVPS/api/issues/31
This commit is contained in:
2025-04-30 15:47:45 +01:00
parent 4db6aa1897
commit 7e10e0dd6e
8 changed files with 247 additions and 15 deletions

93
Cargo.lock generated
View File

@ -27,6 +27,17 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.11" version = "0.8.11"
@ -309,6 +320,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 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]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -350,25 +371,62 @@ dependencies = [
"unicode-normalization", "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]] [[package]]
name = "bitcoin-internals" name = "bitcoin-internals"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" 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]] [[package]]
name = "bitcoin-io" name = "bitcoin-io"
version = "0.1.3" version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" 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]] [[package]]
name = "bitcoin_hashes" name = "bitcoin_hashes"
version = "0.13.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [ dependencies = [
"bitcoin-internals", "bitcoin-internals 0.2.0",
"hex-conservative 0.1.2", "hex-conservative 0.1.2",
] ]
@ -920,9 +978,12 @@ dependencies = [
[[package]] [[package]]
name = "email_address" name = "email_address"
version = "0.2.9" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" checksum = "c1019fa28f600f5b581b7a603d515c3f1635da041ca211b5055804788673abfe"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
@ -1394,6 +1455,12 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "hex_lit"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.12.4" version = "0.12.4"
@ -2037,6 +2104,24 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 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]] [[package]]
name = "lnvps_api" name = "lnvps_api"
version = "0.1.0" version = "0.1.0"
@ -2054,6 +2139,7 @@ dependencies = [
"ipnetwork", "ipnetwork",
"isocountry", "isocountry",
"lettre", "lettre",
"lnurl-rs",
"lnvps_common", "lnvps_common",
"lnvps_db", "lnvps_db",
"log", "log",
@ -3491,6 +3577,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [ dependencies = [
"bitcoin_hashes 0.14.0",
"rand 0.8.5", "rand 0.8.5",
"secp256k1-sys", "secp256k1-sys",
"serde", "serde",

View File

@ -56,6 +56,7 @@ ssh-key = "0.6.7"
lettre = { version = "0.11.10", features = ["tokio1-native-tls"] } lettre = { version = "0.11.10", features = ["tokio1-native-tls"] }
ws = { package = "rocket_ws", version = "0.1.1" } ws = { package = "rocket_ws", version = "0.1.1" }
native-tls = "0.2.12" native-tls = "0.2.12"
lnurl-rs = { version = "0.9.0", default-features = false }
futures = "0.3.31" futures = "0.3.31"
isocountry = "0.3.2" isocountry = "0.3.2"

View File

@ -149,7 +149,7 @@ impl ApiTemplatesResponse {
CurrencyAmount::from_f32(template.cost_plan.currency, template.cost_plan.amount); CurrencyAmount::from_f32(template.cost_plan.currency, template.cost_plan.amount);
for alt_price in alt_prices(&rates, list_price) { for alt_price in alt_prices(&rates, list_price) {
template.cost_plan.other_price.push(ApiPrice { template.cost_plan.other_price.push(ApiPrice {
currency: alt_price.0, currency: alt_price.currency(),
amount: alt_price.value_f32(), amount: alt_price.value_f32(),
}); });
} }
@ -255,7 +255,7 @@ pub struct ApiPrice {
impl From<CurrencyAmount> for ApiPrice { impl From<CurrencyAmount> for ApiPrice {
fn from(value: CurrencyAmount) -> Self { fn from(value: CurrencyAmount) -> Self {
Self { Self {
currency: value.0, currency: value.currency(),
amount: value.value_f32(), amount: value.value_f32(),
} }
} }

View File

@ -4,7 +4,7 @@ use crate::api::model::{
ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey,
CreateVmRequest, VMPatchRequest, CreateVmRequest, VMPatchRequest,
}; };
use crate::exchange::{Currency, ExchangeRateService}; use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService};
use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData}; use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData};
use crate::nip98::Nip98Auth; use crate::nip98::Nip98Auth;
use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine}; use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine};
@ -15,11 +15,14 @@ use anyhow::{bail, Result};
use futures::future::join_all; use futures::future::join_all;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use isocountry::CountryCode; use isocountry::CountryCode;
use lnurl::pay::{LnURLPayInvoice, PayResponse};
use lnurl::Tag;
use lnvps_db::{ use lnvps_db::{
IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate,
}; };
use log::{error, info}; use log::{error, info};
use nostr::util::hex; use nostr::util::hex;
use nostr::Url;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::{get, patch, post, routes, Responder, Route, State}; use rocket::{get, patch, post, routes, Responder, Route, State};
use rocket_okapi::gen::OpenApiGenerator; use rocket_okapi::gen::OpenApiGenerator;
@ -64,7 +67,11 @@ pub fn routes() -> Vec<Route> {
api_routes.append(&mut super::nostr_domain::routes()); api_routes.append(&mut super::nostr_domain::routes());
routes.append(&mut api_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 routes
} }
@ -540,6 +547,57 @@ async fn v1_renew_vm(
ApiData::ok(rsp.into()) ApiData::ok(rsp.into())
} }
/// Extend a VM by LNURL payment
#[get("/api/v1/vm/<id>/renew-lnurlp?<amount>")]
async fn v1_renew_vm_lnurlp(
provisioner: &State<Arc<LNVpsProvisioner>>,
id: u64,
amount: u64,
) -> Result<Json<LnURLPayInvoice>, &'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/<id>")]
async fn v1_lnurlp(
db: &State<Arc<dyn LNVpsDb>>,
settings: &State<Settings>,
id: u64,
) -> Result<Json<PayResponse>, &'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 /// Start a VM
#[openapi(tag = "VM")] #[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>/start")] #[patch("/api/v1/vm/<id>/start")]

View File

@ -75,14 +75,19 @@ impl Display for Ticker {
pub struct TickerRate(pub Ticker, pub f32); pub struct TickerRate(pub Ticker, pub f32);
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct CurrencyAmount(pub Currency, u64); pub struct CurrencyAmount(Currency, u64);
impl CurrencyAmount { impl CurrencyAmount {
const MILLI_SATS: f64 = 1.0e11; 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 { pub fn from_u64(currency: Currency, amount: u64) -> Self {
CurrencyAmount(currency, amount) CurrencyAmount(currency, amount)
} }
pub fn from_f32(currency: Currency, amount: f32) -> Self { pub fn from_f32(currency: Currency, amount: f32) -> Self {
CurrencyAmount( CurrencyAmount(
currency, currency,
@ -103,6 +108,10 @@ impl CurrencyAmount {
_ => self.1 as f32 / 100.0, _ => self.1 as f32 / 100.0,
} }
} }
pub fn currency(&self) -> Currency {
self.0
}
} }
impl TickerRate { impl TickerRate {

View File

@ -89,8 +89,8 @@ impl RevolutApi {
.post( .post(
"/api/orders", "/api/orders",
CreateOrderRequest { CreateOrderRequest {
currency: amount.0.to_string(), currency: amount.currency().to_string(),
amount: match amount.0 { amount: match amount.currency() {
Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"), Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"),
_ => amount.value(), _ => amount.value(),
}, },

View File

@ -483,8 +483,23 @@ impl LNVpsProvisioner {
/// Create a renewal payment /// Create a renewal payment
pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result<VmPayment> { pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result<VmPayment> {
let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone()); let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone());
let price = pe.get_vm_cost(vm_id, method).await?; 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<VmPayment> {
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<VmPayment> {
match price { match price {
CostResult::Existing(p) => Ok(p), CostResult::Existing(p) => Ok(p),
CostResult::New { CostResult::New {

View File

@ -1,5 +1,5 @@
use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService, Ticker, TickerRate}; 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 chrono::{DateTime, Days, Months, TimeDelta, Utc};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use isocountry::CountryCode; 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<CostResult> {
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) /// Get VM cost (for renewal)
pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result<CostResult> { pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result<CostResult> {
let vm = self.db.get_vm(vm_id).await?; 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)> { 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_btc = amount.value_f32() / rate.1;
let cost_msats = (cost_btc as f64 * crate::BTC_SATS) as u64 * 1000; let cost_msats = (cost_btc as f64 * crate::BTC_SATS) as u64 * 1000;
Ok((cost_msats, rate.1)) 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 { let next_expire = match cost_plan.interval_type {
VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)), VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)),
VmCostPlanIntervalType::Month => vm VmCostPlanIntervalType::Month => vm
@ -209,7 +252,7 @@ impl PricingEngine {
list_price: CurrencyAmount, list_price: CurrencyAmount,
method: PaymentMethod, method: PaymentMethod,
) -> Result<(Currency, u64, f32)> { ) -> Result<(Currency, u64, f32)> {
Ok(match (list_price.0, method) { Ok(match (list_price.currency(), method) {
(c, PaymentMethod::Lightning) if c != Currency::BTC => { (c, PaymentMethod::Lightning) if c != Currency::BTC => {
let new_price = self.get_msats_amount(list_price).await?; let new_price = self.get_msats_amount(list_price).await?;
(Currency::BTC, new_price.0, new_price.1) (Currency::BTC, new_price.0, new_price.1)
@ -400,6 +443,25 @@ mod tests {
_ => bail!("??"), _ => 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(()) Ok(())
} }
} }