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

View File

@ -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"

View File

@ -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<CurrencyAmount> for ApiPrice {
fn from(value: CurrencyAmount) -> Self {
Self {
currency: value.0,
currency: value.currency(),
amount: value.value_f32(),
}
}

View File

@ -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<Route> {
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/<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
#[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>/start")]

View File

@ -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 {

View File

@ -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(),
},

View File

@ -483,8 +483,23 @@ impl LNVpsProvisioner {
/// Create a renewal payment
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 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 {
CostResult::Existing(p) => Ok(p),
CostResult::New {

View File

@ -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<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)
pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result<CostResult> {
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(())
}
}