diff --git a/lnvps_db/Cargo.lock b/lnvps_db/Cargo.lock index c09645a..5ac9848 100644 --- a/lnvps_db/Cargo.lock +++ b/lnvps_db/Cargo.lock @@ -245,6 +245,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.9" @@ -256,6 +291,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "digest" version = "0.10.7" @@ -349,6 +394,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -457,6 +508,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -668,6 +725,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -689,6 +752,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.6.0" @@ -697,6 +771,7 @@ checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown 0.15.1", + "serde", ] [[package]] @@ -766,6 +841,7 @@ dependencies = [ "async-trait", "chrono", "serde", + "serde_with", "sqlx", ] @@ -855,6 +931,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -989,6 +1071,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1150,6 +1238,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.6.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1280,7 +1398,7 @@ dependencies = [ "hashbrown 0.14.5", "hashlink", "hex", - "indexmap", + "indexmap 2.6.0", "log", "memchr", "once_cell", @@ -1460,6 +1578,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1521,6 +1645,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" diff --git a/lnvps_db/build.rs b/lnvps_db/build.rs new file mode 100644 index 0000000..d506869 --- /dev/null +++ b/lnvps_db/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/lnvps_db/migrations/20241126205723_rates.sql b/lnvps_db/migrations/20241126205723_rates.sql new file mode 100644 index 0000000..0b4d67d --- /dev/null +++ b/lnvps_db/migrations/20241126205723_rates.sql @@ -0,0 +1,5 @@ +alter table vm_payment + add column rate float; +update vm_payment set rate = 92000; +alter table vm_payment + modify column rate float not null; \ No newline at end of file diff --git a/lnvps_db/src/hydrate.rs b/lnvps_db/src/hydrate.rs index fa47626..62e87cc 100644 --- a/lnvps_db/src/hydrate.rs +++ b/lnvps_db/src/hydrate.rs @@ -47,4 +47,4 @@ impl Hydrate for VmTemplate { async fn hydrate_down(&mut self, db: &Box) -> Result<()> { todo!() } -} \ No newline at end of file +} diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 474ab95..d0ffb1e 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -1,10 +1,10 @@ use anyhow::Result; use async_trait::async_trait; +pub mod hydrate; mod model; #[cfg(feature = "mysql")] mod mysql; -pub mod hydrate; pub use model::*; #[cfg(feature = "mysql")] @@ -110,4 +110,4 @@ pub trait LNVpsDb: Sync + Send { /// Return the most recently settled invoice async fn last_paid_invoice(&self) -> Result>; -} \ No newline at end of file +} diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 3f4fb93..cc20ef7 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -246,6 +246,8 @@ pub struct VmPayment { pub amount: u64, pub invoice: String, pub is_paid: bool, + /// Exchange rate + pub rate: f32, /// Number of seconds this payment will add to vm expiry #[serde(skip_serializing)] @@ -253,4 +255,4 @@ pub struct VmPayment { #[serde(skip_serializing)] pub settle_index: Option, -} \ No newline at end of file +} diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index baac1f6..ab923d5 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,4 +1,7 @@ -use crate::{IpRange, LNVpsDb, User, UserSshKey, Vm, VmCostPlan, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate}; +use crate::{ + IpRange, LNVpsDb, User, UserSshKey, Vm, VmCostPlan, VmHost, VmHostDisk, VmHostRegion, + VmIpAssignment, VmOsImage, VmPayment, VmTemplate, +}; use anyhow::{bail, Error, Result}; use async_trait::async_trait; use sqlx::{Executor, MySqlPool, Row}; @@ -11,9 +14,7 @@ pub struct LNVpsDbMysql { impl LNVpsDbMysql { pub async fn new(conn: &str) -> Result { let db = MySqlPool::connect(conn).await?; - Ok(Self { - db - }) + Ok(Self { db }) } #[cfg(debug_assertions)] @@ -62,14 +63,16 @@ impl LNVpsDb for LNVpsDbMysql { } async fn insert_user_ssh_key(&self, new_key: &UserSshKey) -> Result { - Ok(sqlx::query("insert into user_ssh_key(name,user_id,key_data) values(?, ?, ?) returning id") - .bind(&new_key.name) - .bind(&new_key.user_id) - .bind(&new_key.key_data) - .fetch_one(&self.db) - .await - .map_err(Error::new)? - .try_get(0)?) + Ok(sqlx::query( + "insert into user_ssh_key(name,user_id,key_data) values(?, ?, ?) returning id", + ) + .bind(&new_key.name) + .bind(new_key.user_id) + .bind(&new_key.key_data) + .fetch_one(&self.db) + .await + .map_err(Error::new)? + .try_get(0)?) } async fn get_user_ssh_key(&self, id: u64) -> Result { @@ -109,7 +112,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn get_host(&self, id: u64) -> Result { sqlx::query_as("select * from vm_host where id = ?") - .bind(&id) + .bind(id) .fetch_one(&self.db) .await .map_err(Error::new) @@ -118,9 +121,9 @@ impl LNVpsDb for LNVpsDbMysql { async fn update_host(&self, host: &VmHost) -> Result<()> { sqlx::query("update vm_host set name = ?, cpu = ?, memory = ? where id = ?") .bind(&host.name) - .bind(&host.cpu) - .bind(&host.memory) - .bind(&host.id) + .bind(host.cpu) + .bind(host.memory) + .bind(host.id) .execute(&self.db) .await?; Ok(()) @@ -128,7 +131,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn list_host_disks(&self, host_id: u64) -> Result> { sqlx::query_as("select * from vm_host_disk where host_id = ?") - .bind(&host_id) + .bind(host_id) .fetch_all(&self.db) .await .map_err(Error::new) @@ -188,7 +191,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn list_user_vms(&self, id: u64) -> Result> { sqlx::query_as("select * from vm where user_id = ?") - .bind(&id) + .bind(id) .fetch_all(&self.db) .await .map_err(Error::new) @@ -196,7 +199,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn get_vm(&self, vm_id: u64) -> Result { sqlx::query_as("select * from vm where id = ?") - .bind(&vm_id) + .bind(vm_id) .fetch_one(&self.db) .await .map_err(Error::new) @@ -204,17 +207,17 @@ impl LNVpsDb for LNVpsDbMysql { async fn insert_vm(&self, vm: &Vm) -> Result { Ok(sqlx::query("insert into vm(host_id,user_id,image_id,template_id,ssh_key_id,created,expires,cpu,memory,disk_size,disk_id) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id") - .bind(&vm.host_id) - .bind(&vm.user_id) - .bind(&vm.image_id) - .bind(&vm.template_id) - .bind(&vm.ssh_key_id) - .bind(&vm.created) - .bind(&vm.expires) - .bind(&vm.cpu) - .bind(&vm.memory) - .bind(&vm.disk_size) - .bind(&vm.disk_id) + .bind(vm.host_id) + .bind(vm.user_id) + .bind(vm.image_id) + .bind(vm.template_id) + .bind(vm.ssh_key_id) + .bind(vm.created) + .bind(vm.expires) + .bind(vm.cpu) + .bind(vm.memory) + .bind(vm.disk_size) + .bind(vm.disk_id) .fetch_one(&self.db) .await .map_err(Error::new)? @@ -222,14 +225,16 @@ impl LNVpsDb for LNVpsDbMysql { } async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result { - Ok(sqlx::query("insert into vm_ip_assignment(vm_id,ip_range_id,ip) values(?, ?, ?) returning id") - .bind(&ip_assignment.vm_id) - .bind(&ip_assignment.ip_range_id) - .bind(&ip_assignment.ip) - .fetch_one(&self.db) - .await - .map_err(Error::new)? - .try_get(0)?) + Ok(sqlx::query( + "insert into vm_ip_assignment(vm_id,ip_range_id,ip) values(?, ?, ?) returning id", + ) + .bind(ip_assignment.vm_id) + .bind(ip_assignment.ip_range_id) + .bind(&ip_assignment.ip) + .fetch_one(&self.db) + .await + .map_err(Error::new)? + .try_get(0)?) } async fn list_vm_ip_assignments(&self, vm_id: u64) -> Result> { @@ -257,15 +262,16 @@ impl LNVpsDb for LNVpsDbMysql { } async fn insert_vm_payment(&self, vm_payment: &VmPayment) -> Result<()> { - sqlx::query("insert into vm_payment(id,vm_id,created,expires,amount,invoice,time_value,is_paid) values(?,?,?,?,?,?,?,?)") + sqlx::query("insert into vm_payment(id,vm_id,created,expires,amount,invoice,time_value,is_paid,rate) values(?,?,?,?,?,?,?,?,?)") .bind(&vm_payment.id) - .bind(&vm_payment.vm_id) - .bind(&vm_payment.created) - .bind(&vm_payment.expires) - .bind(&vm_payment.amount) + .bind(vm_payment.vm_id) + .bind(vm_payment.created) + .bind(vm_payment.expires) + .bind(vm_payment.amount) .bind(&vm_payment.invoice) - .bind(&vm_payment.time_value) - .bind(&vm_payment.is_paid) + .bind(vm_payment.time_value) + .bind(vm_payment.is_paid) + .bind(vm_payment.rate) .execute(&self.db) .await .map_err(Error::new)?; @@ -274,7 +280,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn get_vm_payment(&self, id: &Vec) -> Result { sqlx::query_as("select * from vm_payment where id=?") - .bind(&id) + .bind(id) .fetch_one(&self.db) .await .map_err(Error::new) @@ -282,7 +288,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn update_vm_payment(&self, vm_payment: &VmPayment) -> Result<()> { sqlx::query("update vm_payment set is_paid = ? where id = ?") - .bind(&vm_payment.is_paid) + .bind(vm_payment.is_paid) .bind(&vm_payment.id) .execute(&self.db) .await @@ -298,14 +304,14 @@ impl LNVpsDb for LNVpsDbMysql { let mut tx = self.db.begin().await?; sqlx::query("update vm_payment set is_paid = true, settle_index = ? where id = ?") - .bind(&vm_payment.settle_index) + .bind(vm_payment.settle_index) .bind(&vm_payment.id) .execute(&mut *tx) .await?; sqlx::query("update vm set expires = TIMESTAMPADD(SECOND, ?, expires) where id = ?") - .bind(&vm_payment.time_value) - .bind(&vm_payment.vm_id) + .bind(vm_payment.time_value) + .bind(vm_payment.vm_id) .execute(&mut *tx) .await?; @@ -314,9 +320,11 @@ impl LNVpsDb for LNVpsDbMysql { } async fn last_paid_invoice(&self) -> Result> { - sqlx::query_as("select * from vm_payment where is_paid = true order by settle_index desc limit 1") - .fetch_optional(&self.db) - .await - .map_err(Error::new) + sqlx::query_as( + "select * from vm_payment where is_paid = true order by settle_index desc limit 1", + ) + .fetch_optional(&self.db) + .await + .map_err(Error::new) } -} \ No newline at end of file +} diff --git a/src/bin/api.rs b/src/bin/api.rs index a285a94..162b8a2 100644 --- a/src/bin/api.rs +++ b/src/bin/api.rs @@ -3,6 +3,7 @@ use config::{Config, File}; use fedimint_tonic_lnd::connect; use lnvps::api; use lnvps::cors::CORS; +use lnvps::exchange::ExchangeRateCache; use lnvps::invoice::InvoiceHandler; use lnvps::provisioner::lnvps::LNVpsProvisioner; use lnvps::provisioner::Provisioner; @@ -42,8 +43,10 @@ async fn main() -> Result<(), Error> { let db = LNVpsDbMysql::new(&settings.db).await?; db.migrate().await?; + + let exchange = ExchangeRateCache::new(); let lnd = connect(settings.lnd.url, settings.lnd.cert, settings.lnd.macaroon).await?; - let provisioner = LNVpsProvisioner::new(db.clone(), lnd.clone()); + let provisioner = LNVpsProvisioner::new(db.clone(), lnd.clone(), exchange.clone()); #[cfg(debug_assertions)] { let setup_script = include_str!("../../dev_setup.sql"); @@ -52,7 +55,7 @@ async fn main() -> Result<(), Error> { } let status = VmStateCache::new(); - let mut worker = Worker::new(settings.read_only, db.clone(), lnd.clone(), status.clone()); + let mut worker = Worker::new(settings.read_only, db.clone(), lnd.clone(), status.clone(), exchange.clone()); let sender = worker.sender(); tokio::spawn(async move { loop { @@ -84,6 +87,21 @@ async fn main() -> Result<(), Error> { tokio::time::sleep(Duration::from_secs(30)).await; } }); + // refresh rates every 1min + let rates = exchange.clone(); + tokio::spawn(async move { + loop { + match rates.fetch_rates().await { + Ok(z) => { + for r in z { + rates.set_rate(r.0, r.1).await; + } + } + Err(e) => error!("Failed to fetch rates: {}", e) + } + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); let db: Box = Box::new(db.clone()); let pv: Box = Box::new(provisioner); @@ -101,6 +119,7 @@ async fn main() -> Result<(), Error> { .manage(db) .manage(pv) .manage(status) + .manage(exchange) .mount("/", api::routes()) .launch() .await diff --git a/src/exchange.rs b/src/exchange.rs new file mode 100644 index 0000000..99dbe89 --- /dev/null +++ b/src/exchange.rs @@ -0,0 +1,105 @@ +use anyhow::{Error, Result}; +use log::info; +use rocket::serde::Deserialize; +use std::collections::HashMap; +use std::fmt::{write, Display, Formatter}; +use std::str::FromStr; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum Currency { + EUR, + BTC, + USD, +} + +impl Display for Currency { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Currency::EUR => write!(f, "EUR"), + Currency::BTC => write!(f, "BTC"), + Currency::USD => write!(f, "USD"), + } + } +} + +impl FromStr for Currency { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "eur" => Ok(Currency::EUR), + "usd" => Ok(Currency::USD), + "btc" => Ok(Currency::BTC), + _ => Err(()), + } + } +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct Ticker(Currency, Currency); + +impl Ticker { + pub fn btc_rate(cur: &str) -> Result { + let to_cur: Currency = cur.parse().map_err(|_| Error::msg(""))?; + Ok(Ticker(Currency::BTC, to_cur)) + } +} + +impl Display for Ticker { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.0, self.1) + } +} + +#[derive(Debug, PartialEq)] +pub struct TickerRate(pub Ticker, pub f32); + +#[derive(Clone)] +pub struct ExchangeRateCache { + cache: Arc>>, +} + +#[derive(Deserialize)] +struct MempoolRates { + pub time: u64, + #[serde(rename = "USD")] + pub usd: Option, + #[serde(rename = "EUR")] + pub eur: Option, +} + +impl ExchangeRateCache { + pub fn new() -> Self { + Self { cache: Arc::new(RwLock::new(HashMap::new())) } + } + + pub async fn fetch_rates(&self) -> Result> { + let rsp = reqwest::get("https://mempool.space/api/v1/prices") + .await? + .text().await?; + let rates: MempoolRates = serde_json::from_str(&rsp)?; + + let mut ret = vec![]; + if let Some(usd) = rates.usd { + ret.push(TickerRate(Ticker(Currency::BTC, Currency::USD), usd)); + } + if let Some(eur) = rates.eur { + ret.push(TickerRate(Ticker(Currency::BTC, Currency::EUR), eur)); + } + + Ok(ret) + } + + pub async fn set_rate(&self, ticker: Ticker, amount: f32) { + let mut cache = self.cache.write().await; + info!("{}: {}", &ticker, amount); + cache.insert(ticker, amount); + } + + pub async fn get_rate(&self, ticker: Ticker) -> Option { + let cache = self.cache.read().await; + cache.get(&ticker).cloned() + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index ad2b008..b730c6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,4 @@ pub mod nip98; pub mod provisioner; pub mod status; pub mod worker; +pub mod exchange; diff --git a/src/provisioner/lnvps.rs b/src/provisioner/lnvps.rs index e034a63..548d277 100644 --- a/src/provisioner/lnvps.rs +++ b/src/provisioner/lnvps.rs @@ -1,3 +1,4 @@ +use crate::exchange::{Currency, ExchangeRateCache, Ticker}; use crate::host::proxmox::ProxmoxClient; use crate::provisioner::Provisioner; use anyhow::{bail, Result}; @@ -21,13 +22,15 @@ use std::time::Duration; pub struct LNVpsProvisioner { db: Box, lnd: Client, + rates: ExchangeRateCache, } impl LNVpsProvisioner { - pub fn new(db: D, lnd: Client) -> Self { + pub fn new(db: D, lnd: Client, rates: ExchangeRateCache) -> Self { Self { db: Box::new(db), lnd, + rates, } } @@ -155,22 +158,25 @@ impl Provisioner for LNVpsProvisioner { .add(Months::new((12 * cost_plan.interval_amount) as u32)), }; - const BTC_MILLI_SATS: u64 = 100_000_000_000; + const BTC_SATS: f64 = 100_000_000.0; const INVOICE_EXPIRE: i64 = 3600; - let cost = cost_plan.amount - * match cost_plan.currency.as_str() { - "EUR" => 1_100_000, //TODO: rates - "BTC" => 1, // BTC amounts are always millisats - c => bail!("Unknown currency {c}"), - }; - info!("Creating invoice for {vm_id} for {cost} mSats"); + let ticker = Ticker::btc_rate(cost_plan.currency.as_str())?; + let rate = if let Some(r) = self.rates.get_rate(ticker).await { + r + } else { + bail!("No exchange rate found") + }; + + let cost_btc = cost_plan.amount as f32 / rate; + let cost_msat = (cost_btc as f64 * BTC_SATS) as i64 * 1000; + info!("Creating invoice for {vm_id} for {} sats", cost_msat / 1000); let mut lnd = self.lnd.clone(); let invoice = lnd .lightning() .add_invoice(Invoice { memo: format!("VM renewal {vm_id} to {new_expire}"), - value_msat: cost as i64, + value_msat: cost_msat, expiry: INVOICE_EXPIRE, ..Default::default() }) @@ -182,10 +188,11 @@ impl Provisioner for LNVpsProvisioner { vm_id, created: Utc::now(), expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE as u64)), - amount: cost, + amount: cost_msat as u64, invoice: invoice.payment_request.clone(), time_value: (new_expire - vm.expires).num_seconds() as u64, is_paid: false, + rate, ..Default::default() }; self.db.insert_vm_payment(&vm_payment).await?; diff --git a/src/worker.rs b/src/worker.rs index 0c564d6..c6e1fa1 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,3 +1,4 @@ +use crate::exchange::ExchangeRateCache; use crate::host::proxmox::{CreateVm, ProxmoxClient, VmBios, VmStatus}; use crate::provisioner::lnvps::LNVpsProvisioner; use crate::provisioner::Provisioner; @@ -35,9 +36,10 @@ impl Worker { db: D, lnd: Client, vm_state_cache: VmStateCache, + rates: ExchangeRateCache, ) -> Self { let (tx, rx) = unbounded_channel(); - let p = LNVpsProvisioner::new(db.clone(), lnd.clone()); + let p = LNVpsProvisioner::new(db.clone(), lnd.clone(), rates); Self { read_only, db: Box::new(db),