feat: fiat payments (revolut)

ref: #24
This commit is contained in:
2025-03-11 12:42:25 +00:00
parent 1c282e460f
commit 45dd0c4398
32 changed files with 822 additions and 151 deletions

2
Cargo.lock generated
View File

@ -2009,6 +2009,7 @@ dependencies = [
"fern", "fern",
"futures", "futures",
"hex", "hex",
"hmac",
"ipnetwork", "ipnetwork",
"lettre", "lettre",
"lnvps_db", "lnvps_db",
@ -2024,6 +2025,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"ssh-key", "ssh-key",
"ssh2", "ssh2",
"tokio", "tokio",

View File

@ -7,7 +7,7 @@ edition = "2021"
name = "api" name = "api"
[features] [features]
default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "cloudflare"] default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "cloudflare", "revolut"]
mikrotik = ["dep:reqwest"] mikrotik = ["dep:reqwest"]
nostr-dm = ["dep:nostr-sdk"] nostr-dm = ["dep:nostr-sdk"]
proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"] proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"]
@ -15,6 +15,7 @@ libvirt = ["dep:virt"]
lnd = ["dep:fedimint-tonic-lnd"] lnd = ["dep:fedimint-tonic-lnd"]
bitvora = ["dep:reqwest", "dep:tokio-stream"] bitvora = ["dep:reqwest", "dep:tokio-stream"]
cloudflare = ["dep:reqwest"] cloudflare = ["dep:reqwest"]
revolut = ["dep:reqwest", "dep:sha2", "dep:hmac"]
[dependencies] [dependencies]
lnvps_db = { path = "lnvps_db" } lnvps_db = { path = "lnvps_db" }
@ -57,4 +58,8 @@ virt = { version = "0.4.2", optional = true }
fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"], optional = true } fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"], optional = true }
#bitvora #bitvora
tokio-stream = { version = "0.1.17", features = ["sync"], optional = true } tokio-stream = { version = "0.1.17", features = ["sync"], optional = true }
#revolut
sha2 = { version = "0.10.8", optional = true }
hmac = { version = "0.12.1", optional = true }

View File

@ -5,6 +5,7 @@ lightning:
cert: "/home/kieran/.polar/networks/2/volumes/lnd/alice/tls.cert" cert: "/home/kieran/.polar/networks/2/volumes/lnd/alice/tls.cert"
macaroon: "/home/kieran/.polar/networks/2/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon" macaroon: "/home/kieran/.polar/networks/2/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon"
delete-after: 3 delete-after: 3
public-url: "https://api.lnvps.net"
provisioner: provisioner:
proxmox: proxmox:
read-only: false read-only: false

View File

@ -0,0 +1,6 @@
alter table vm_payment
add column currency varchar(5) not null default 'BTC',
add column payment_method smallint unsigned not null default 0,
add column external_id varchar(255),
change invoice external_data varchar (4096) NOT NULL,
drop column settle_index;

View File

@ -131,6 +131,9 @@ pub trait LNVpsDb: Sync + Send {
/// Get VM payment by payment id /// Get VM payment by payment id
async fn get_vm_payment(&self, id: &Vec<u8>) -> Result<VmPayment>; async fn get_vm_payment(&self, id: &Vec<u8>) -> Result<VmPayment>;
/// Get VM payment by payment id
async fn get_vm_payment_by_ext_id(&self, id: &str) -> Result<VmPayment>;
/// Update a VM payment record /// Update a VM payment record
async fn update_vm_payment(&self, vm_payment: &VmPayment) -> Result<()>; async fn update_vm_payment(&self, vm_payment: &VmPayment) -> Result<()>;

View File

@ -1,8 +1,9 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, bail, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::FromRow; use sqlx::{FromRow, Type};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use url::Url; use url::Url;
#[derive(FromRow, Clone, Debug)] #[derive(FromRow, Clone, Debug)]
@ -309,17 +310,53 @@ impl Display for VmIpAssignment {
#[derive(FromRow, Clone, Debug, Default)] #[derive(FromRow, Clone, Debug, Default)]
pub struct VmPayment { pub struct VmPayment {
/// Payment hash
pub id: Vec<u8>, pub id: Vec<u8>,
pub vm_id: u64, pub vm_id: u64,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
pub expires: DateTime<Utc>, pub expires: DateTime<Utc>,
pub amount: u64, pub amount: u64,
pub invoice: String, pub currency: String,
pub payment_method: PaymentMethod,
/// External data (invoice / json)
pub external_data: String,
/// External id on other system
pub external_id: Option<String>,
pub is_paid: bool, pub is_paid: bool,
/// Exchange rate /// TODO: handle other base currencies
/// Exchange rate back to base currency (EUR)
pub rate: f32, pub rate: f32,
/// Number of seconds this payment will add to vm expiry /// Number of seconds this payment will add to vm expiry
pub time_value: u64, pub time_value: u64,
pub settle_index: Option<u64>, }
#[derive(Type, Clone, Copy, Debug, Default, PartialEq)]
#[repr(u16)]
pub enum PaymentMethod {
#[default]
Lightning,
Revolut,
Paypal,
}
impl Display for PaymentMethod {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
PaymentMethod::Lightning => write!(f, "Lightning"),
PaymentMethod::Revolut => write!(f, "Revolut"),
PaymentMethod::Paypal => write!(f, "PayPal"),
}
}
}
impl FromStr for PaymentMethod {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"lightning" => Ok(PaymentMethod::Lightning),
"revolut" => Ok(PaymentMethod::Revolut),
"paypal" => Ok(PaymentMethod::Paypal),
_ => bail!("Unknown payment method: {}", s),
}
}
} }

View File

@ -387,16 +387,19 @@ impl LNVpsDb for LNVpsDbMysql {
} }
async fn insert_vm_payment(&self, vm_payment: &VmPayment) -> Result<()> { 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,rate) values(?,?,?,?,?,?,?,?,?)") sqlx::query("insert into vm_payment(id,vm_id,created,expires,amount,currency,payment_method,time_value,is_paid,rate,external_id,external_data) values(?,?,?,?,?,?,?,?,?,?,?,?)")
.bind(&vm_payment.id) .bind(&vm_payment.id)
.bind(vm_payment.vm_id) .bind(vm_payment.vm_id)
.bind(vm_payment.created) .bind(vm_payment.created)
.bind(vm_payment.expires) .bind(vm_payment.expires)
.bind(vm_payment.amount) .bind(vm_payment.amount)
.bind(&vm_payment.invoice) .bind(&vm_payment.currency)
.bind(&vm_payment.payment_method)
.bind(vm_payment.time_value) .bind(vm_payment.time_value)
.bind(vm_payment.is_paid) .bind(vm_payment.is_paid)
.bind(vm_payment.rate) .bind(vm_payment.rate)
.bind(&vm_payment.external_id)
.bind(&vm_payment.external_data)
.execute(&self.db) .execute(&self.db)
.await .await
.map_err(Error::new)?; .map_err(Error::new)?;
@ -411,6 +414,14 @@ impl LNVpsDb for LNVpsDbMysql {
.map_err(Error::new) .map_err(Error::new)
} }
async fn get_vm_payment_by_ext_id(&self, id: &str) -> Result<VmPayment> {
sqlx::query_as("select * from vm_payment where external_id=?")
.bind(id)
.fetch_one(&self.db)
.await
.map_err(Error::new)
}
async fn update_vm_payment(&self, vm_payment: &VmPayment) -> Result<()> { async fn update_vm_payment(&self, vm_payment: &VmPayment) -> Result<()> {
sqlx::query("update vm_payment set is_paid = ? where id = ?") sqlx::query("update vm_payment set is_paid = ? where id = ?")
.bind(vm_payment.is_paid) .bind(vm_payment.is_paid)
@ -428,8 +439,7 @@ impl LNVpsDb for LNVpsDbMysql {
let mut tx = self.db.begin().await?; let mut tx = self.db.begin().await?;
sqlx::query("update vm_payment set is_paid = true, settle_index = ? where id = ?") sqlx::query("update vm_payment set is_paid = true where id = ?")
.bind(vm_payment.settle_index)
.bind(&vm_payment.id) .bind(&vm_payment.id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@ -446,7 +456,7 @@ impl LNVpsDb for LNVpsDbMysql {
async fn last_paid_invoice(&self) -> Result<Option<VmPayment>> { async fn last_paid_invoice(&self) -> Result<Option<VmPayment>> {
sqlx::query_as( sqlx::query_as(
"select * from vm_payment where is_paid = true order by settle_index desc limit 1", "select * from vm_payment where is_paid = true order by created desc limit 1",
) )
.fetch_optional(&self.db) .fetch_optional(&self.db)
.await .await

View File

@ -10,4 +10,5 @@ pub fn routes() -> Vec<Route> {
r r
} }
pub use webhook::WebhookMessage;
pub use webhook::WEBHOOK_BRIDGE; pub use webhook::WEBHOOK_BRIDGE;

View File

@ -1,16 +1,16 @@
use crate::exchange::{alt_prices, Currency, CurrencyAmount, ExchangeRateService}; use crate::exchange::{alt_prices, Currency, CurrencyAmount, ExchangeRateService};
use crate::provisioner::{PricingData, PricingEngine}; use crate::provisioner::PricingEngine;
use crate::status::VmState; use crate::status::VmState;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use lnvps_db::{ use lnvps_db::{
LNVpsDb, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, LNVpsDb, PaymentMethod, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHostRegion, VmTemplate,
VmHostRegion, VmTemplate,
}; };
use nostr::util::hex; use nostr::util::hex;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@ -95,9 +95,9 @@ impl From<lnvps_db::DiskType> for DiskType {
} }
} }
impl Into<lnvps_db::DiskType> for DiskType { impl From<DiskType> for lnvps_db::DiskType {
fn into(self) -> lnvps_db::DiskType { fn from(val: DiskType) -> Self {
match self { match val {
DiskType::HDD => lnvps_db::DiskType::HDD, DiskType::HDD => lnvps_db::DiskType::HDD,
DiskType::SSD => lnvps_db::DiskType::SSD, DiskType::SSD => lnvps_db::DiskType::SSD,
} }
@ -143,7 +143,7 @@ impl ApiTemplatesResponse {
pub async fn expand_pricing(&mut self, rates: &Arc<dyn ExchangeRateService>) -> Result<()> { pub async fn expand_pricing(&mut self, rates: &Arc<dyn ExchangeRateService>) -> Result<()> {
let rates = rates.list_rates().await?; let rates = rates.list_rates().await?;
for mut template in &mut self.templates { for template in &mut self.templates {
let list_price = CurrencyAmount(template.cost_plan.currency, template.cost_plan.amount); let list_price = CurrencyAmount(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 {
@ -335,8 +335,8 @@ impl ApiVmTemplate {
cpu: template.cpu, cpu: template.cpu,
memory: template.memory, memory: template.memory,
disk_size: template.disk_size, disk_size: template.disk_size,
disk_type: template.disk_type.clone().into(), disk_type: template.disk_type.into(),
disk_interface: template.disk_interface.clone().into(), disk_interface: template.disk_interface.into(),
cost_plan: ApiVmCostPlan { cost_plan: ApiVmCostPlan {
id: cost_plan.id, id: cost_plan.id,
name: cost_plan.name.clone(), name: cost_plan.name.clone(),
@ -468,14 +468,14 @@ impl From<lnvps_db::VmOsImage> for ApiVmOsImage {
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmPayment { pub struct ApiVmPayment {
/// Payment hash hex
pub id: String, pub id: String,
pub vm_id: u64, pub vm_id: u64,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
pub expires: DateTime<Utc>, pub expires: DateTime<Utc>,
pub amount: u64, pub amount: u64,
pub invoice: String, pub currency: String,
pub is_paid: bool, pub is_paid: bool,
pub data: ApiPaymentData,
} }
impl From<lnvps_db::VmPayment> for ApiVmPayment { impl From<lnvps_db::VmPayment> for ApiVmPayment {
@ -486,8 +486,64 @@ impl From<lnvps_db::VmPayment> for ApiVmPayment {
created: value.created, created: value.created,
expires: value.expires, expires: value.expires,
amount: value.amount, amount: value.amount,
invoice: value.invoice, currency: value.currency,
is_paid: value.is_paid, is_paid: value.is_paid,
data: match &value.payment_method {
PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data),
PaymentMethod::Revolut => {
#[derive(Deserialize)]
struct RevolutData {
pub token: String,
}
let data: RevolutData = serde_json::from_str(&value.external_data).unwrap();
ApiPaymentData::Revolut { token: data.token }
}
PaymentMethod::Paypal => {
todo!()
}
},
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiPaymentInfo {
pub name: ApiPaymentMethod,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
pub currencies: Vec<Currency>,
}
/// Payment data related to the payment method
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ApiPaymentData {
/// Just an LN invoice
Lightning(String),
/// Revolut order data
Revolut {
/// Order token
token: String,
},
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ApiPaymentMethod {
#[default]
Lightning,
Revolut,
Paypal,
}
impl From<PaymentMethod> for ApiPaymentMethod {
fn from(value: PaymentMethod) -> Self {
match value {
PaymentMethod::Lightning => ApiPaymentMethod::Lightning,
PaymentMethod::Revolut => ApiPaymentMethod::Revolut,
PaymentMethod::Paypal => ApiPaymentMethod::Paypal,
} }
} }
} }

View File

@ -1,19 +1,21 @@
use crate::api::model::{ use crate::api::model::{
AccountPatchRequest, ApiCustomTemplateDiskParam, ApiCustomTemplateParams, ApiCustomVmOrder, AccountPatchRequest, ApiCustomTemplateParams, ApiCustomVmOrder,
ApiCustomVmRequest, ApiPrice, ApiTemplatesResponse, ApiUserSshKey, ApiVmHostRegion, ApiCustomVmRequest, ApiPaymentInfo, ApiPaymentMethod, ApiPrice, ApiTemplatesResponse,
ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, ApiUserSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus,
CreateVmRequest, VMPatchRequest, ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest,
}; };
use crate::exchange::ExchangeRateService; use crate::exchange::{Currency, 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};
use crate::settings::Settings; use crate::settings::Settings;
use crate::status::{VmState, VmStateCache}; use crate::status::{VmState, VmStateCache};
use crate::worker::WorkJob; use crate::worker::WorkJob;
use anyhow::{Context, Result}; use anyhow::Result;
use futures::future::join_all; use futures::future::join_all;
use lnvps_db::{IpRange, LNVpsDb, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate}; use lnvps_db::{
IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate,
};
use nostr::util::hex; use nostr::util::hex;
use rocket::futures::{SinkExt, StreamExt}; use rocket::futures::{SinkExt, StreamExt};
use rocket::serde::json::Json; use rocket::serde::json::Json;
@ -26,6 +28,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ssh_key::PublicKey; use ssh_key::PublicKey;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
@ -48,7 +51,8 @@ pub fn routes() -> Vec<Route> {
v1_patch_vm, v1_patch_vm,
v1_time_series, v1_time_series,
v1_custom_template_calc, v1_custom_template_calc,
v1_create_custom_vm_order v1_create_custom_vm_order,
v1_get_payment_methods
] ]
} }
@ -143,7 +147,7 @@ async fn vm_to_status(
.map(|i| (i.id, i)) .map(|i| (i.id, i))
.collect(); .collect();
let template = ApiVmTemplate::from_vm(&db, &vm).await?; let template = ApiVmTemplate::from_vm(db, &vm).await?;
Ok(ApiVmStatus { Ok(ApiVmStatus {
id: vm.id, id: vm.id,
created: vm.created, created: vm.created,
@ -309,7 +313,7 @@ async fn v1_list_vm_templates(
}) })
.collect(); .collect();
let custom_templates: Vec<VmCustomPricing> = let custom_templates: Vec<VmCustomPricing> =
join_all(regions.iter().map(|(k, _)| db.list_custom_pricing(*k))) join_all(regions.keys().map(|k| db.list_custom_pricing(*k)))
.await .await
.into_iter() .into_iter()
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
@ -344,8 +348,7 @@ async fn v1_list_vm_templates(
.into_iter() .into_iter()
.filter_map(|t| { .filter_map(|t| {
let region = regions.get(&t.region_id)?; let region = regions.get(&t.region_id)?;
Some( ApiCustomTemplateParams::from(
ApiCustomTemplateParams::from(
&t, &t,
&custom_template_disks, &custom_template_disks,
region, region,
@ -353,8 +356,7 @@ async fn v1_list_vm_templates(
max_memory, max_memory,
max_disk, max_disk,
) )
.ok()?, .ok()
)
}) })
.collect(), .collect(),
) )
@ -376,7 +378,7 @@ async fn v1_custom_template_calc(
let price = PricingEngine::get_custom_vm_cost_amount(db, 0, &template).await?; let price = PricingEngine::get_custom_vm_cost_amount(db, 0, &template).await?;
ApiData::ok(ApiPrice { ApiData::ok(ApiPrice {
currency: price.currency.clone(), currency: price.currency,
amount: price.total(), amount: price.total(),
}) })
} }
@ -484,12 +486,13 @@ async fn v1_create_vm_order(
/// Renew(Extend) a VM /// Renew(Extend) a VM
#[openapi(tag = "VM")] #[openapi(tag = "VM")]
#[get("/api/v1/vm/<id>/renew")] #[get("/api/v1/vm/<id>/renew?<method>")]
async fn v1_renew_vm( async fn v1_renew_vm(
auth: Nip98Auth, auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>, db: &State<Arc<dyn LNVpsDb>>,
provisioner: &State<Arc<LNVpsProvisioner>>, provisioner: &State<Arc<LNVpsProvisioner>>,
id: u64, id: u64,
method: Option<&str>,
) -> ApiResult<ApiVmPayment> { ) -> ApiResult<ApiVmPayment> {
let pubkey = auth.event.pubkey.to_bytes(); let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?; let uid = db.upsert_user(&pubkey).await?;
@ -498,7 +501,14 @@ async fn v1_renew_vm(
return ApiData::err("VM does not belong to you"); return ApiData::err("VM does not belong to you");
} }
let rsp = provisioner.renew(id).await?; let rsp = provisioner
.renew(
id,
method
.and_then(|m| PaymentMethod::from_str(m).ok())
.unwrap_or(PaymentMethod::Lightning),
)
.await?;
ApiData::ok(rsp.into()) ApiData::ok(rsp.into())
} }
@ -596,6 +606,26 @@ async fn v1_time_series(
ApiData::ok(client.get_time_series_data(&vm, TimeSeries::Hourly).await?) ApiData::ok(client.get_time_series_data(&vm, TimeSeries::Hourly).await?)
} }
#[openapi(tag = "Payment")]
#[get("/api/v1/payment/methods")]
async fn v1_get_payment_methods(settings: &State<Settings>) -> ApiResult<Vec<ApiPaymentInfo>> {
let mut ret = vec![ApiPaymentInfo {
name: ApiPaymentMethod::Lightning,
metadata: HashMap::new(),
currencies: vec![Currency::BTC],
}];
#[cfg(feature = "revolut")]
if let Some(r) = &settings.revolut {
ret.push(ApiPaymentInfo {
name: ApiPaymentMethod::Revolut,
metadata: HashMap::from([("pubkey".to_string(), r.public_key.to_string())]),
currencies: vec![Currency::EUR, Currency::USD],
})
}
ApiData::ok(ret)
}
/// Get payment status (for polling) /// Get payment status (for polling)
#[openapi(tag = "Payment")] #[openapi(tag = "Payment")]
#[get("/api/v1/payment/<id>")] #[get("/api/v1/payment/<id>")]

View File

@ -6,23 +6,35 @@ use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
use tokio::sync::broadcast; use tokio::sync::broadcast;
/// Messaging bridge for webhooks to other parts of the system (bitvora) /// Messaging bridge for webhooks to other parts of the system (bitvora/revout)
pub static WEBHOOK_BRIDGE: LazyLock<WebhookBridge> = LazyLock::new(WebhookBridge::new); pub static WEBHOOK_BRIDGE: LazyLock<WebhookBridge> = LazyLock::new(WebhookBridge::new);
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
if cfg!(feature = "bitvora") { let mut routes = vec![];
routes![bitvora_webhook]
} else { #[cfg(feature = "bitvora")]
routes![] routes.append(&mut routes![bitvora_webhook]);
}
#[cfg(feature = "revolut")]
routes.append(&mut routes![revolut_webhook]);
routes
} }
#[cfg(feature = "bitvora")]
#[post("/api/v1/webhook/bitvora", data = "<req>")] #[post("/api/v1/webhook/bitvora", data = "<req>")]
async fn bitvora_webhook(req: WebhookMessage) -> Status { async fn bitvora_webhook(req: WebhookMessage) -> Status {
WEBHOOK_BRIDGE.send(req); WEBHOOK_BRIDGE.send(req);
Status::Ok Status::Ok
} }
#[cfg(feature = "revolut")]
#[post("/api/v1/webhook/revolut", data = "<req>")]
async fn revolut_webhook(req: WebhookMessage) -> Status {
WEBHOOK_BRIDGE.send(req);
Status::Ok
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WebhookMessage { pub struct WebhookMessage {
pub body: Vec<u8>, pub body: Vec<u8>,

View File

@ -6,8 +6,8 @@ use lnvps::api;
use lnvps::cors::CORS; use lnvps::cors::CORS;
use lnvps::data_migration::run_data_migrations; use lnvps::data_migration::run_data_migrations;
use lnvps::exchange::{DefaultRateCache, ExchangeRateService}; use lnvps::exchange::{DefaultRateCache, ExchangeRateService};
use lnvps::invoice::InvoiceHandler;
use lnvps::lightning::get_node; use lnvps::lightning::get_node;
use lnvps::payments::listen_all_payments;
use lnvps::settings::Settings; use lnvps::settings::Settings;
use lnvps::status::VmStateCache; use lnvps::status::VmStateCache;
use lnvps::worker::{WorkJob, Worker}; use lnvps::worker::{WorkJob, Worker};
@ -20,7 +20,6 @@ use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep;
#[derive(Parser)] #[derive(Parser)]
#[clap(about, version, author)] #[clap(about, version, author)]
@ -37,7 +36,7 @@ struct Args {
#[rocket::main] #[rocket::main]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
let log_level = std::env::var("RUST_LOG") let log_level = std::env::var("RUST_LOG")
.unwrap_or_else(|_| "info".to_string()) // Default to "info" if not set .unwrap_or_else(|_| "info".to_string())
.to_lowercase(); .to_lowercase();
let max_level = match log_level.as_str() { let max_level = match log_level.as_str() {
@ -47,7 +46,7 @@ async fn main() -> Result<(), Error> {
"warn" => LevelFilter::Warn, "warn" => LevelFilter::Warn,
"error" => LevelFilter::Error, "error" => LevelFilter::Error,
"off" => LevelFilter::Off, "off" => LevelFilter::Off,
_ => LevelFilter::Info, _ => LevelFilter::Debug,
}; };
let args = Args::parse(); let args = Args::parse();
@ -121,15 +120,10 @@ async fn main() -> Result<(), Error> {
} }
} }
}); });
let mut handler = InvoiceHandler::new(node.clone(), db.clone(), sender.clone());
tokio::spawn(async move { // setup payment handlers
loop { listen_all_payments(&settings, node.clone(), db.clone(), sender.clone())?;
if let Err(e) = handler.listen().await {
error!("invoice-error: {}", e);
}
sleep(Duration::from_secs(5)).await;
}
});
// request work every 30s to check vm status // request work every 30s to check vm status
let sender_clone = sender.clone(); let sender_clone = sender.clone();
tokio::spawn(async move { tokio::spawn(async move {

View File

@ -28,17 +28,17 @@ impl DataMigration for DnsDataMigration {
for vm in vms { for vm in vms {
let mut ips = db.list_vm_ip_assignments(vm.id).await?; let mut ips = db.list_vm_ip_assignments(vm.id).await?;
for mut ip in &mut ips { for ip in &mut ips {
let mut did_change = false; let mut did_change = false;
if ip.dns_forward.is_none() { if ip.dns_forward.is_none() {
let rec = BasicRecord::forward(&ip)?; let rec = BasicRecord::forward(ip)?;
let r = dns.add_record(&rec).await?; let r = dns.add_record(&rec).await?;
ip.dns_forward = Some(r.name); ip.dns_forward = Some(r.name);
ip.dns_forward_ref = r.id; ip.dns_forward_ref = r.id;
did_change = true; did_change = true;
} }
if ip.dns_reverse.is_none() { if ip.dns_reverse.is_none() {
let rec = BasicRecord::reverse_to_fwd(&ip)?; let rec = BasicRecord::reverse_to_fwd(ip)?;
let r = dns.add_record(&rec).await?; let r = dns.add_record(&rec).await?;
ip.dns_reverse = Some(r.value); ip.dns_reverse = Some(r.value);
ip.dns_reverse_ref = r.id; ip.dns_reverse_ref = r.id;

View File

@ -14,8 +14,12 @@ pub struct Cloudflare {
impl Cloudflare { impl Cloudflare {
pub fn new(token: &str, reverse_zone_id: &str, forward_zone_id: &str) -> Cloudflare { pub fn new(token: &str, reverse_zone_id: &str, forward_zone_id: &str) -> Cloudflare {
Self { Self {
api: JsonApi::token("https://api.cloudflare.com", &format!("Bearer {}", token), false) api: JsonApi::token(
.unwrap(), "https://api.cloudflare.com",
&format!("Bearer {}", token),
false,
)
.unwrap(),
reverse_zone_id: reverse_zone_id.to_owned(), reverse_zone_id: reverse_zone_id.to_owned(),
forward_zone_id: forward_zone_id.to_owned(), forward_zone_id: forward_zone_id.to_owned(),
} }

View File

@ -1,10 +1,10 @@
use anyhow::{anyhow, ensure, Context, Error, Result}; use anyhow::{anyhow, ensure, Result};
use lnvps_db::async_trait; use lnvps_db::async_trait;
use log::info; use log::info;
use rocket::serde::Deserialize; use rocket::serde::Deserialize;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Serialize; use serde::Serialize;
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@ -62,6 +62,12 @@ pub struct TickerRate(pub Ticker, pub f32);
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct CurrencyAmount(pub Currency, pub f32); pub struct CurrencyAmount(pub Currency, pub f32);
impl CurrencyAmount {
pub fn from_u64(currency: Currency, amount: u64) -> Self {
CurrencyAmount(currency, amount as f32 / 100.0)
}
}
impl TickerRate { impl TickerRate {
pub fn can_convert(&self, currency: Currency) -> bool { pub fn can_convert(&self, currency: Currency) -> bool {
currency == self.0 .0 || currency == self.0 .1 currency == self.0 .0 || currency == self.0 .1
@ -99,7 +105,7 @@ pub fn alt_prices(rates: &Vec<TickerRate>, source: CurrencyAmount) -> Vec<Curren
let mut ret2 = vec![]; let mut ret2 = vec![];
for y in rates.iter() { for y in rates.iter() {
for x in ret.iter() { for x in ret.iter() {
if let Ok(r1) = y.convert(x.clone()) { if let Ok(r1) = y.convert(*x) {
if r1.0 != source.0 { if r1.0 != source.0 {
ret2.push(r1); ret2.push(r1);
} }

25
src/fiat/mod.rs Normal file
View File

@ -0,0 +1,25 @@
/// Fiat payment integrations
use crate::exchange::CurrencyAmount;
use anyhow::Result;
use rocket::serde::{Deserialize, Serialize};
use std::future::Future;
use std::pin::Pin;
#[cfg(feature = "revolut")]
mod revolut;
#[cfg(feature = "revolut")]
pub use revolut::*;
pub trait FiatPaymentService: Send + Sync {
fn create_order(
&self,
description: &str,
amount: CurrencyAmount,
) -> Pin<Box<dyn Future<Output = Result<FiatPaymentInfo>> + Send>>;
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FiatPaymentInfo {
pub external_id: String,
pub raw_data: String,
}

154
src/fiat/revolut.rs Normal file
View File

@ -0,0 +1,154 @@
use crate::exchange::{Currency, CurrencyAmount};
use crate::fiat::{FiatPaymentInfo, FiatPaymentService};
use crate::json_api::JsonApi;
use crate::settings::RevolutConfig;
use anyhow::{bail, Result};
use chrono::{DateTime, Utc};
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::{Client, Method};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::pin::Pin;
pub struct RevolutApi {
api: JsonApi,
}
impl RevolutApi {
pub fn new(config: RevolutConfig) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, format!("Bearer {}", config.token).parse()?);
headers.insert(ACCEPT, "application/json".parse()?);
headers.insert("Revolut-Api-Version", config.api_version.parse()?);
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
api: JsonApi {
client,
base: config
.url
.unwrap_or("https://merchant.revolut.com".to_string())
.parse()?,
},
})
}
pub async fn list_webhooks(&self) -> Result<Vec<RevolutWebhook>> {
self.api.get("/api/1.0/webhooks").await
}
pub async fn delete_webhook(&self, webhook_id: &str) -> Result<()> {
self.api
.req_status(
Method::DELETE,
&format!("/api/1.0/webhooks/{}", webhook_id),
(),
)
.await?;
Ok(())
}
pub async fn create_webhook(
&self,
url: &str,
events: Vec<RevolutWebhookEvent>,
) -> Result<RevolutWebhook> {
self.api
.post(
"/api/1.0/webhooks",
CreateWebhookRequest {
url: url.to_string(),
events,
},
)
.await
}
}
impl FiatPaymentService for RevolutApi {
fn create_order(
&self,
description: &str,
amount: CurrencyAmount,
) -> Pin<Box<dyn Future<Output = Result<FiatPaymentInfo>> + Send>> {
let api = self.api.clone();
let desc = description.to_string();
Box::pin(async move {
let rsp: CreateOrderResponse = api
.post(
"/api/orders",
CreateOrderRequest {
currency: amount.0.to_string(),
amount: match amount.0 {
Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"),
Currency::EUR => (amount.1 * 100.0).floor() as u64,
Currency::USD => (amount.1 * 100.0).floor() as u64,
},
description: Some(desc),
},
)
.await?;
Ok(FiatPaymentInfo {
raw_data: serde_json::to_string(&rsp)?,
external_id: rsp.id,
})
})
}
}
#[derive(Clone, Serialize)]
pub struct CreateOrderRequest {
pub amount: u64,
pub currency: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct CreateOrderResponse {
pub id: String,
pub token: String,
pub state: PaymentState,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub description: Option<String>,
pub amount: u64,
pub currency: String,
pub outstanding_amount: u64,
pub checkout_url: String,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PaymentState {
Pending,
Processing,
Authorised,
Completed,
Cancelled,
Failed,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct RevolutWebhook {
pub id: String,
pub url: String,
pub events: Vec<RevolutWebhookEvent>,
pub signing_secret: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RevolutWebhookEvent {
OrderAuthorised,
OrderCompleted,
OrderCancelled,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct CreateWebhookRequest {
pub url: String,
pub events: Vec<RevolutWebhookEvent>,
}

View File

@ -34,7 +34,7 @@ impl VmHostClient for LibVirt {
todo!() todo!()
} }
async fn configure_vm(&self, vm: &Vm) -> anyhow::Result<()> { async fn configure_vm(&self, vm: &FullVmInfo) -> anyhow::Result<()> {
todo!() todo!()
} }

View File

@ -1,10 +1,11 @@
use anyhow::bail; use anyhow::bail;
use log::debug; use log::debug;
use reqwest::header::{HeaderMap, AUTHORIZATION}; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::{Client, Method, Url}; use reqwest::{Client, Method, Url};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Serialize; use serde::Serialize;
#[derive(Clone)]
pub struct JsonApi { pub struct JsonApi {
pub client: Client, pub client: Client,
pub base: Url, pub base: Url,
@ -14,6 +15,7 @@ impl JsonApi {
pub fn token(base: &str, token: &str, allow_invalid_certs: bool) -> anyhow::Result<Self> { pub fn token(base: &str, token: &str, allow_invalid_certs: bool) -> anyhow::Result<Self> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, token.parse()?); headers.insert(AUTHORIZATION, token.parse()?);
headers.insert(ACCEPT, "application/json".parse()?);
let client = Client::builder() let client = Client::builder()
.danger_accept_invalid_certs(allow_invalid_certs) .danger_accept_invalid_certs(allow_invalid_certs)
@ -59,7 +61,6 @@ impl JsonApi {
.client .client
.request(method.clone(), self.base.join(path)?) .request(method.clone(), self.base.join(path)?)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(body) .body(body)
.send() .send()
.await?; .await?;
@ -73,4 +74,31 @@ impl JsonApi {
bail!("{} {}: {}: {}", method, path, status, &text); bail!("{} {}: {}: {}", method, path, status, &text);
} }
} }
/// Make a request and only return the status code
pub async fn req_status<R: Serialize>(
&self,
method: Method,
path: &str,
body: R,
) -> anyhow::Result<u16> {
let body = serde_json::to_string(&body)?;
debug!(">> {} {}: {}", method.clone(), path, &body);
let rsp = self
.client
.request(method.clone(), self.base.join(path)?)
.header("Content-Type", "application/json")
.body(body)
.send()
.await?;
let status = rsp.status();
let text = rsp.text().await?;
#[cfg(debug_assertions)]
debug!("<< {}", text);
if status.is_success() {
Ok(status.as_u16())
} else {
bail!("{} {}: {}: {}", method, path, status, &text);
}
}
} }

View File

@ -1,12 +1,14 @@
pub mod api; pub mod api;
pub mod cors; pub mod cors;
pub mod data_migration;
pub mod dns; pub mod dns;
pub mod exchange; pub mod exchange;
pub mod fiat;
pub mod host; pub mod host;
pub mod invoice;
pub mod json_api; pub mod json_api;
pub mod lightning; pub mod lightning;
pub mod nip98; pub mod nip98;
pub mod payments;
pub mod provisioner; pub mod provisioner;
pub mod router; pub mod router;
pub mod settings; pub mod settings;
@ -14,6 +16,6 @@ pub mod settings;
pub mod ssh_client; pub mod ssh_client;
pub mod status; pub mod status;
pub mod worker; pub mod worker;
pub mod data_migration;
#[cfg(test)] #[cfg(test)]
pub mod mocks; pub mod mocks;

View File

@ -17,7 +17,7 @@ impl BitvoraNode {
pub fn new(api_token: &str, webhook_secret: &str) -> Self { pub fn new(api_token: &str, webhook_secret: &str) -> Self {
let auth = format!("Bearer {}", api_token); let auth = format!("Bearer {}", api_token);
Self { Self {
api: JsonApi::token("https://api.bitvora.com/", &auth).unwrap(), api: JsonApi::token("https://api.bitvora.com/", &auth, false).unwrap(),
webhook_secret: webhook_secret.to_string(), webhook_secret: webhook_secret.to_string(),
} }
} }

View File

@ -78,7 +78,6 @@ impl LightningNode for LndNode {
Ok(m) => { Ok(m) => {
if m.state == InvoiceState::Settled as i32 { if m.state == InvoiceState::Settled as i32 {
InvoiceUpdate::Settled { InvoiceUpdate::Settled {
settle_index: m.settle_index,
payment_hash: hex::encode(m.r_hash), payment_hash: hex::encode(m.r_hash),
} }
} else { } else {

View File

@ -40,7 +40,6 @@ pub enum InvoiceUpdate {
Error(String), Error(String),
Settled { Settled {
payment_hash: String, payment_hash: String,
settle_index: u64,
}, },
} }

View File

@ -518,11 +518,18 @@ impl LNVpsDb for MockDb {
.clone()) .clone())
} }
async fn get_vm_payment_by_ext_id(&self, id: &str) -> anyhow::Result<VmPayment> {
let p = self.payments.lock().await;
Ok(p.iter()
.find(|p| p.external_id == Some(id.to_string()))
.context("no vm_payment")?
.clone())
}
async fn update_vm_payment(&self, vm_payment: &VmPayment) -> anyhow::Result<()> { async fn update_vm_payment(&self, vm_payment: &VmPayment) -> anyhow::Result<()> {
let mut p = self.payments.lock().await; let mut p = self.payments.lock().await;
if let Some(p) = p.iter_mut().find(|p| p.id == *vm_payment.id) { if let Some(p) = p.iter_mut().find(|p| p.id == *vm_payment.id) {
p.is_paid = vm_payment.is_paid.clone(); p.is_paid = vm_payment.is_paid.clone();
p.settle_index = vm_payment.settle_index.clone();
} }
Ok(()) Ok(())
} }
@ -539,7 +546,8 @@ impl LNVpsDb for MockDb {
async fn last_paid_invoice(&self) -> anyhow::Result<Option<VmPayment>> { async fn last_paid_invoice(&self) -> anyhow::Result<Option<VmPayment>> {
let p = self.payments.lock().await; let p = self.payments.lock().await;
Ok(p.iter() Ok(p.iter()
.max_by(|a, b| a.settle_index.cmp(&b.settle_index)) .filter(|p| p.is_paid)
.max_by(|a, b| a.created.cmp(&b.created))
.map(|v| v.clone())) .map(|v| v.clone()))
} }

View File

@ -98,7 +98,7 @@ impl<'r> FromRequest<'r> for Nip98Auth {
} }
let auth = Nip98Auth::from_base64(&auth[6..]).unwrap(); let auth = Nip98Auth::from_base64(&auth[6..]).unwrap();
match auth.check( match auth.check(
request.uri().to_string().as_str(), request.uri().path().to_string().as_str(),
request.method().as_str(), request.method().as_str(),
) { ) {
Ok(_) => Outcome::Success(auth), Ok(_) => Outcome::Success(auth),

View File

@ -8,13 +8,13 @@ use rocket::futures::StreamExt;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
pub struct InvoiceHandler { pub struct NodeInvoiceHandler {
node: Arc<dyn LightningNode>, node: Arc<dyn LightningNode>,
db: Arc<dyn LNVpsDb>, db: Arc<dyn LNVpsDb>,
tx: UnboundedSender<WorkJob>, tx: UnboundedSender<WorkJob>,
} }
impl InvoiceHandler { impl NodeInvoiceHandler {
pub fn new( pub fn new(
node: Arc<dyn LightningNode>, node: Arc<dyn LightningNode>,
db: Arc<dyn LNVpsDb>, db: Arc<dyn LNVpsDb>,
@ -23,9 +23,8 @@ impl InvoiceHandler {
Self { node, tx, db } Self { node, tx, db }
} }
async fn mark_paid(&self, settle_index: u64, id: &Vec<u8>) -> Result<()> { async fn mark_paid(&self, id: &Vec<u8>) -> Result<()> {
let mut p = self.db.get_vm_payment(id).await?; let p = self.db.get_vm_payment(id).await?;
p.settle_index = Some(settle_index);
self.db.vm_payment_paid(&p).await?; self.db.vm_payment_paid(&p).await?;
info!("VM payment {} for {}, paid", hex::encode(p.id), p.vm_id); info!("VM payment {} for {}, paid", hex::encode(p.id), p.vm_id);
@ -47,12 +46,9 @@ impl InvoiceHandler {
let mut handler = self.node.subscribe_invoices(from_ph).await?; let mut handler = self.node.subscribe_invoices(from_ph).await?;
while let Some(msg) = handler.next().await { while let Some(msg) = handler.next().await {
match msg { match msg {
InvoiceUpdate::Settled { InvoiceUpdate::Settled { payment_hash } => {
payment_hash,
settle_index,
} => {
let r_hash = hex::decode(payment_hash)?; let r_hash = hex::decode(payment_hash)?;
if let Err(e) = self.mark_paid(settle_index, &r_hash).await { if let Err(e) = self.mark_paid(&r_hash).await {
error!("{}", e); error!("{}", e);
} }
} }

56
src/payments/mod.rs Normal file
View File

@ -0,0 +1,56 @@
use crate::lightning::LightningNode;
use crate::payments::invoice::NodeInvoiceHandler;
use crate::settings::Settings;
use crate::worker::WorkJob;
use anyhow::Result;
use lnvps_db::LNVpsDb;
use log::error;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::UnboundedSender;
use tokio::time::sleep;
mod invoice;
#[cfg(feature = "revolut")]
mod revolut;
pub fn listen_all_payments(
settings: &Settings,
node: Arc<dyn LightningNode>,
db: Arc<dyn LNVpsDb>,
sender: UnboundedSender<WorkJob>,
) -> Result<()> {
let mut handler = NodeInvoiceHandler::new(node.clone(), db.clone(), sender.clone());
tokio::spawn(async move {
loop {
if let Err(e) = handler.listen().await {
error!("invoice-error: {}", e);
}
sleep(Duration::from_secs(30)).await;
}
});
#[cfg(feature = "revolut")]
{
use crate::payments::revolut::RevolutPaymentHandler;
if let Some(r) = &settings.revolut {
let mut handler = RevolutPaymentHandler::new(
r.clone(),
&settings.public_url,
db.clone(),
sender.clone(),
)?;
tokio::spawn(async move {
loop {
if let Err(e) = handler.listen().await {
error!("revolut-error: {}", e);
}
sleep(Duration::from_secs(30)).await;
}
});
}
}
Ok(())
}

129
src/payments/revolut.rs Normal file
View File

@ -0,0 +1,129 @@
use crate::api::{WebhookMessage, WEBHOOK_BRIDGE};
use crate::fiat::{RevolutApi, RevolutWebhookEvent};
use crate::settings::RevolutConfig;
use crate::worker::WorkJob;
use anyhow::{anyhow, bail, Context, Result};
use hmac::{Hmac, Mac};
use lnvps_db::LNVpsDb;
use log::{error, info, warn};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
pub struct RevolutPaymentHandler {
api: RevolutApi,
db: Arc<dyn LNVpsDb>,
sender: UnboundedSender<WorkJob>,
public_url: String,
}
impl RevolutPaymentHandler {
pub fn new(
settings: RevolutConfig,
public_url: &str,
db: Arc<dyn LNVpsDb>,
sender: UnboundedSender<WorkJob>,
) -> Result<Self> {
Ok(Self {
api: RevolutApi::new(settings)?,
public_url: public_url.to_string(),
db,
sender,
})
}
pub async fn listen(&mut self) -> Result<()> {
let this_webhook = Url::parse(&self.public_url)?.join("/api/v1/webhook/revolut")?;
let webhooks = self.api.list_webhooks().await?;
for wh in webhooks {
info!("Deleting old webhook: {} {}", wh.id, wh.url);
self.api.delete_webhook(&wh.id).await?
}
info!("Setting up webhook for '{}'", this_webhook);
let wh = self
.api
.create_webhook(
this_webhook.as_str(),
vec![
RevolutWebhookEvent::OrderCompleted,
RevolutWebhookEvent::OrderAuthorised,
],
)
.await?;
let secret = wh.signing_secret.context("Signing secret is missing")?;
// listen to events
let mut listenr = WEBHOOK_BRIDGE.listen();
while let Ok(m) = listenr.recv().await {
let body: RevolutWebhook = serde_json::from_slice(m.body.as_slice())?;
info!("Received webhook {:?}", body);
if let Err(e) = verify_webhook(&secret, &m) {
error!("Signature verification failed: {}", e);
continue;
}
if let RevolutWebhookEvent::OrderCompleted = body.event {
if let Err(e) = self.try_complete_payment(&body.order_id).await {
error!("Failed to complete order: {}", e);
}
}
}
Ok(())
}
async fn try_complete_payment(&self, ext_id: &str) -> Result<()> {
let p = self.db.get_vm_payment_by_ext_id(ext_id).await?;
self.db.vm_payment_paid(&p).await?;
self.sender.send(WorkJob::CheckVm { vm_id: p.vm_id })?;
info!("VM payment {} for {}, paid", hex::encode(p.id), p.vm_id);
Ok(())
}
}
type HmacSha256 = Hmac<sha2::Sha256>;
fn verify_webhook(secret: &str, msg: &WebhookMessage) -> Result<()> {
let sig = msg
.headers
.get("revolut-signature")
.ok_or_else(|| anyhow!("Missing Revolut-Signature header"))?;
let timestamp = msg
.headers
.get("revolut-request-timestamp")
.ok_or_else(|| anyhow!("Missing Revolut-Request-Timestamp header"))?;
// check if any signatures match
for sig in sig.split(",") {
let mut sig_split = sig.split("=");
let (version, code) = (
sig_split.next().context("Invalid signature format")?,
sig_split.next().context("Invalid signature format")?,
);
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?;
mac.update(version.as_bytes());
mac.update(b".");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(msg.body.as_slice());
let result = mac.finalize().into_bytes();
if hex::encode(result) == code {
return Ok(());
} else {
warn!(
"Invalid signature found {} != {}",
code,
hex::encode(result)
);
}
}
bail!("No valid signature found!");
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct RevolutWebhook {
pub event: RevolutWebhookEvent,
pub order_id: String,
pub merchant_order_ext_ref: Option<String>,
}

View File

@ -1,5 +1,6 @@
use crate::dns::{BasicRecord, DnsServer}; use crate::dns::{BasicRecord, DnsServer};
use crate::exchange::{ExchangeRateService, Ticker}; use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService};
use crate::fiat::FiatPaymentService;
use crate::host::{get_host_client, FullVmInfo}; use crate::host::{get_host_client, FullVmInfo};
use crate::lightning::{AddInvoiceRequest, LightningNode}; use crate::lightning::{AddInvoiceRequest, LightningNode};
use crate::provisioner::{ use crate::provisioner::{
@ -8,8 +9,11 @@ use crate::provisioner::{
use crate::router::{ArpEntry, Router}; use crate::router::{ArpEntry, Router};
use crate::settings::{NetworkAccessPolicy, NetworkPolicy, ProvisionerConfig, Settings}; use crate::settings::{NetworkAccessPolicy, NetworkPolicy, ProvisionerConfig, Settings};
use anyhow::{bail, ensure, Context, Result}; use anyhow::{bail, ensure, Context, Result};
use chrono::{Days, Months, Utc}; use chrono::Utc;
use lnvps_db::{DiskType, LNVpsDb, Vm, VmCostPlanIntervalType, VmCustomTemplate, VmIpAssignment, VmPayment}; use lnvps_db::{
LNVpsDb, PaymentMethod, Vm, VmCustomTemplate, VmIpAssignment,
VmPayment,
};
use log::{info, warn}; use log::{info, warn};
use nostr::util::hex; use nostr::util::hex;
use std::ops::Add; use std::ops::Add;
@ -28,6 +32,7 @@ pub struct LNVpsProvisioner {
router: Option<Arc<dyn Router>>, router: Option<Arc<dyn Router>>,
dns: Option<Arc<dyn DnsServer>>, dns: Option<Arc<dyn DnsServer>>,
revolut: Option<Arc<dyn FiatPaymentService>>,
network_policy: NetworkPolicy, network_policy: NetworkPolicy,
provisioner_config: ProvisionerConfig, provisioner_config: ProvisionerConfig,
@ -46,6 +51,7 @@ impl LNVpsProvisioner {
rates, rates,
router: settings.get_router().expect("router config"), router: settings.get_router().expect("router config"),
dns: settings.get_dns().expect("dns config"), dns: settings.get_dns().expect("dns config"),
revolut: settings.get_revolut().expect("revolut config"),
network_policy: settings.network_policy, network_policy: settings.network_policy,
provisioner_config: settings.provisioner, provisioner_config: settings.provisioner,
read_only: settings.read_only, read_only: settings.read_only,
@ -255,7 +261,9 @@ impl LNVpsProvisioner {
// TODO: cache capacity somewhere // TODO: cache capacity somewhere
let cap = HostCapacityService::new(self.db.clone()); let cap = HostCapacityService::new(self.db.clone());
let host = cap.get_host_for_template(template.region_id, &template).await?; let host = cap
.get_host_for_template(template.region_id, &template)
.await?;
let pick_disk = if let Some(hd) = host.disks.first() { let pick_disk = if let Some(hd) = host.disks.first() {
hd hd
@ -308,7 +316,9 @@ impl LNVpsProvisioner {
// TODO: cache capacity somewhere // TODO: cache capacity somewhere
let cap = HostCapacityService::new(self.db.clone()); let cap = HostCapacityService::new(self.db.clone());
let host = cap.get_host_for_template(pricing.region_id, &template).await?; let host = cap
.get_host_for_template(pricing.region_id, &template)
.await?;
let pick_disk = if let Some(hd) = host.disks.first() { let pick_disk = if let Some(hd) = host.disks.first() {
hd hd
@ -345,40 +355,83 @@ impl LNVpsProvisioner {
} }
/// Create a renewal payment /// Create a renewal payment
pub async fn renew(&self, vm_id: u64) -> Result<VmPayment> { pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result<VmPayment> {
let pe = PricingEngine::new(self.db.clone(), self.rates.clone()); let pe = PricingEngine::new(self.db.clone(), self.rates.clone());
let price = pe.get_vm_cost(vm_id).await?; let price = pe.get_vm_cost(vm_id, method).await?;
match price { match price {
CostResult::Existing(p) => Ok(p), CostResult::Existing(p) => Ok(p),
CostResult::New { CostResult::New {
msats, amount,
currency,
time_value, time_value,
new_expiry, new_expiry,
rate, rate,
} => { } => {
const INVOICE_EXPIRE: u64 = 600; let desc = format!("VM renewal {vm_id} to {new_expiry}");
info!("Creating invoice for {vm_id} for {} sats", msats / 1000); let vm_payment = match method {
let invoice = self PaymentMethod::Lightning => {
.node ensure!(
.add_invoice(AddInvoiceRequest { currency == Currency::BTC,
memo: Some(format!("VM renewal {vm_id} to {new_expiry}")), "Cannot create invoices for non-BTC currency"
amount: msats, );
expire: Some(INVOICE_EXPIRE as u32), const INVOICE_EXPIRE: u64 = 600;
}) info!("Creating invoice for {vm_id} for {} sats", amount / 1000);
.await?; let invoice = self
let vm_payment = VmPayment { .node
id: hex::decode(invoice.payment_hash)?, .add_invoice(AddInvoiceRequest {
vm_id, memo: Some(desc),
created: Utc::now(), amount,
expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), expire: Some(INVOICE_EXPIRE as u32),
amount: msats, })
invoice: invoice.pr, .await?;
time_value, VmPayment {
is_paid: false, id: hex::decode(invoice.payment_hash)?,
rate, vm_id,
settle_index: None, created: Utc::now(),
expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)),
amount,
currency: currency.to_string(),
payment_method: method,
time_value,
is_paid: false,
rate,
external_data: invoice.pr,
external_id: None,
}
}
PaymentMethod::Revolut => {
let rev = if let Some(r) = &self.revolut {
r
} else {
bail!("Revolut not configured")
};
ensure!(
currency != Currency::BTC,
"Cannot create revolut orders for BTC currency"
);
let order = rev
.create_order(&desc, CurrencyAmount::from_u64(currency, amount))
.await?;
let new_id: [u8; 32] = rand::random();
VmPayment {
id: new_id.to_vec(),
vm_id,
created: Utc::now(),
expires: Utc::now().add(Duration::from_secs(3600)),
amount,
currency: currency.to_string(),
payment_method: method,
time_value,
is_paid: false,
rate,
external_data: order.raw_data,
external_id: Some(order.external_id),
}
}
PaymentMethod::Paypal => todo!(),
}; };
self.db.insert_vm_payment(&vm_payment).await?; self.db.insert_vm_payment(&vm_payment).await?;
Ok(vm_payment) Ok(vm_payment)
@ -443,6 +496,7 @@ mod tests {
Settings { Settings {
listen: None, listen: None,
db: "".to_string(), db: "".to_string(),
public_url: "http://localhost:8000".to_string(),
lightning: LightningConfig::LND { lightning: LightningConfig::LND {
url: "".to_string(), url: "".to_string(),
cert: Default::default(), cert: Default::default(),
@ -480,6 +534,7 @@ mod tests {
reverse_zone_id: "456".to_string(), reverse_zone_id: "456".to_string(),
}), }),
nostr: None, nostr: None,
revolut: None,
} }
} }

View File

@ -59,4 +59,4 @@ impl Template for VmCustomTemplate {
fn disk_interface(&self) -> DiskInterface { fn disk_interface(&self) -> DiskInterface {
self.disk_interface self.disk_interface
} }
} }

View File

@ -1,8 +1,10 @@
use crate::exchange::{Currency, ExchangeRateService, Ticker}; use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService, Ticker, TickerRate};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Result};
use chrono::{DateTime, Days, Months, TimeDelta, Utc}; use chrono::{DateTime, Days, Months, TimeDelta, Utc};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use lnvps_db::{LNVpsDb, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomTemplate, VmPayment}; use lnvps_db::{
LNVpsDb, PaymentMethod, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomTemplate, VmPayment,
};
use log::info; use log::info;
use std::ops::Add; use std::ops::Add;
use std::str::FromStr; use std::str::FromStr;
@ -28,22 +30,22 @@ impl PricingEngine {
} }
/// Get VM cost (for renewal) /// Get VM cost (for renewal)
pub async fn get_vm_cost(&self, vm_id: u64) -> 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?;
// Reuse existing payment until expired // Reuse existing payment until expired
let payments = self.db.list_vm_payment(vm.id).await?; let payments = self.db.list_vm_payment(vm.id).await?;
if let Some(px) = payments if let Some(px) = payments
.into_iter() .into_iter()
.find(|p| p.expires > Utc::now() && !p.is_paid) .find(|p| p.expires > Utc::now() && !p.is_paid && p.payment_method == method)
{ {
return Ok(CostResult::Existing(px)); return Ok(CostResult::Existing(px));
} }
if vm.template_id.is_some() { if vm.template_id.is_some() {
Ok(self.get_template_vm_cost(&vm).await?) Ok(self.get_template_vm_cost(&vm, method).await?)
} else { } else {
Ok(self.get_custom_vm_cost(&vm).await?) Ok(self.get_custom_vm_cost(&vm, method).await?)
} }
} }
@ -101,7 +103,7 @@ impl PricingEngine {
}) })
} }
async fn get_custom_vm_cost(&self, vm: &Vm) -> Result<CostResult> { async fn get_custom_vm_cost(&self, vm: &Vm, method: PaymentMethod) -> Result<CostResult> {
let template_id = if let Some(i) = vm.custom_template_id { let template_id = if let Some(i) = vm.custom_template_id {
i i
} else { } else {
@ -114,26 +116,32 @@ impl PricingEngine {
// custom templates are always 1-month intervals // custom templates are always 1-month intervals
let time_value = (vm.expires.add(Months::new(1)) - vm.expires).num_seconds() as u64; let time_value = (vm.expires.add(Months::new(1)) - vm.expires).num_seconds() as u64;
let (cost_msats, rate) = self.get_msats_amount(price.currency, price.total()).await?; let (currency, amount, rate) = self
.get_amount_and_rate(CurrencyAmount(price.currency, price.total()), method)
.await?;
Ok(CostResult::New { Ok(CostResult::New {
msats: cost_msats, amount,
currency,
rate, rate,
time_value, time_value,
new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)), new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)),
}) })
} }
async fn get_msats_amount(&self, currency: Currency, amount: f32) -> Result<(u64, f32)> { async fn get_ticker(&self, currency: Currency) -> Result<TickerRate> {
let ticker = Ticker(Currency::BTC, currency); let ticker = Ticker(Currency::BTC, currency);
let rate = if let Some(r) = self.rates.get_rate(ticker).await { if let Some(r) = self.rates.get_rate(ticker).await {
r Ok(TickerRate(ticker, r))
} else { } else {
bail!("No exchange rate found") bail!("No exchange rate found")
}; }
}
let cost_btc = amount / rate; async fn get_msats_amount(&self, amount: CurrencyAmount) -> Result<(u64, f32)> {
let rate = self.get_ticker(amount.0).await?;
let cost_btc = amount.1 / rate.1;
let cost_msats = (cost_btc as f64 * Self::BTC_SATS) as u64 * 1000; let cost_msats = (cost_btc as f64 * Self::BTC_SATS) as u64 * 1000;
Ok((cost_msats, rate)) Ok((cost_msats, rate.1))
} }
fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 { fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 {
@ -150,7 +158,7 @@ impl PricingEngine {
(next_expire - vm.expires).num_seconds() as u64 (next_expire - vm.expires).num_seconds() as u64
} }
async fn get_template_vm_cost(&self, vm: &Vm) -> Result<CostResult> { async fn get_template_vm_cost(&self, vm: &Vm, method: PaymentMethod) -> Result<CostResult> {
let template_id = if let Some(i) = vm.template_id { let template_id = if let Some(i) = vm.template_id {
i i
} else { } else {
@ -159,20 +167,36 @@ impl PricingEngine {
let template = self.db.get_vm_template(template_id).await?; let template = self.db.get_vm_template(template_id).await?;
let cost_plan = self.db.get_cost_plan(template.cost_plan_id).await?; let cost_plan = self.db.get_cost_plan(template.cost_plan_id).await?;
let (cost_msats, rate) = self let currency = cost_plan.currency.parse().expect("Invalid currency");
.get_msats_amount( let (currency, amount, rate) = self
cost_plan.currency.parse().expect("Invalid currency"), .get_amount_and_rate(CurrencyAmount(currency, cost_plan.amount), method)
cost_plan.amount,
)
.await?; .await?;
let time_value = Self::next_template_expire(&vm, &cost_plan); let time_value = Self::next_template_expire(vm, &cost_plan);
Ok(CostResult::New { Ok(CostResult::New {
msats: cost_msats, amount,
currency,
rate, rate,
time_value, time_value,
new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)), new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)),
}) })
} }
async fn get_amount_and_rate(
&self,
list_price: CurrencyAmount,
method: PaymentMethod,
) -> Result<(Currency, u64, f32)> {
Ok(match (list_price.0, 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)
}
(cur, PaymentMethod::Revolut) if cur != Currency::BTC => {
(cur, (list_price.1 * 100.0).ceil() as u64, 1.0)
}
(c, m) => bail!("Cannot create payment for method {} and currency {}", m, c),
})
}
} }
#[derive(Clone)] #[derive(Clone)]
@ -181,8 +205,10 @@ pub enum CostResult {
Existing(VmPayment), Existing(VmPayment),
/// A new payment can be created with the specified amount /// A new payment can be created with the specified amount
New { New {
/// The cost in milli-sats /// The cost
msats: u64, amount: u64,
/// Currency
currency: Currency,
/// The exchange rate used to calculate the price /// The exchange rate used to calculate the price
rate: f32, rate: f32,
/// The time to extend the vm expiry in seconds /// The time to extend the vm expiry in seconds
@ -292,14 +318,14 @@ mod tests {
let db: Arc<dyn LNVpsDb> = Arc::new(db); let db: Arc<dyn LNVpsDb> = Arc::new(db);
let pe = PricingEngine::new(db.clone(), rates); let pe = PricingEngine::new(db.clone(), rates);
let price = pe.get_vm_cost(1).await?; let price = pe.get_vm_cost(1, PaymentMethod::Lightning).await?;
let plan = MockDb::mock_cost_plan(); let plan = MockDb::mock_cost_plan();
match price { match price {
CostResult::Existing(_) => bail!("??"), CostResult::New { amount, .. } => {
CostResult::New { msats, .. } => {
let expect_price = (plan.amount / MOCK_RATE * 1.0e11) as u64; let expect_price = (plan.amount / MOCK_RATE * 1.0e11) as u64;
assert_eq!(expect_price, msats); assert_eq!(expect_price, amount);
} }
_ => bail!("??"),
} }
Ok(()) Ok(())

View File

@ -1,5 +1,6 @@
use crate::dns::DnsServer; use crate::dns::DnsServer;
use crate::exchange::ExchangeRateService; use crate::exchange::ExchangeRateService;
use crate::fiat::FiatPaymentService;
use crate::lightning::LightningNode; use crate::lightning::LightningNode;
use crate::provisioner::LNVpsProvisioner; use crate::provisioner::LNVpsProvisioner;
use crate::router::Router; use crate::router::Router;
@ -12,9 +13,15 @@ use std::sync::Arc;
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct Settings { pub struct Settings {
/// Listen address for http server
pub listen: Option<String>, pub listen: Option<String>,
/// MYSQL connection string
pub db: String, pub db: String,
/// Public URL mapping to this service
pub public_url: String,
/// Lightning node config for creating LN payments /// Lightning node config for creating LN payments
pub lightning: LightningConfig, pub lightning: LightningConfig,
@ -42,6 +49,9 @@ pub struct Settings {
/// Nostr config for sending DMs /// Nostr config for sending DMs
pub nostr: Option<NostrConfig>, pub nostr: Option<NostrConfig>,
/// Config for accepting revolut payments
pub revolut: Option<RevolutConfig>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -168,6 +178,15 @@ pub struct QemuConfig {
pub kvm: bool, pub kvm: bool,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct RevolutConfig {
pub url: Option<String>,
pub api_version: String,
pub token: String,
pub public_key: String,
}
impl Settings { impl Settings {
pub fn get_provisioner( pub fn get_provisioner(
&self, &self,
@ -226,4 +245,12 @@ impl Settings {
} }
} }
} }
pub fn get_revolut(&self) -> Result<Option<Arc<dyn FiatPaymentService>>> {
match &self.revolut {
#[cfg(feature = "revolut")]
Some(c) => Ok(Some(Arc::new(crate::fiat::RevolutApi::new(c.clone())?))),
_ => Ok(None),
}
}
} }