diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 272ec0f6..9c077091 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -6370,7 +6370,7 @@ The RBAC system uses the following permission format: `resource::action` "company_base_currency": "string", // Base currency of the company that owns this VM (e.g., "EUR") "payment_method": "lightning", - // AdminPaymentMethod enum: "lightning", "revolut", "paypal", "stripe" + // AdminPaymentMethod enum: "lightning", "revolut", "paypal", "stripe", "onchain", "fixedfloat" "external_id": "string | null", // External payment provider ID "is_paid": boolean, @@ -6379,9 +6379,28 @@ The RBAC system uses the following permission format: `resource::action` // When payment was completed (null if unpaid) "rate": number, // Exchange rate to company_base_currency - "discount": AdminPaymentDiscountInfo + "discount": AdminPaymentDiscountInfo, // The discount applied to this payment; omitted when there was none. // `amount` is already net of it. + "swap": AdminSwapInfo + // The swap order behind a `fixedfloat` payment; omitted for every other method. +} +``` + +### AdminSwapInfo + +```json +{ + "coin": "string", + // XMR, LTC, ETH or USDTTRC + "order_id": "string", + // FixedFloat order id, as shown on ff.io/order/{order_id} + "deposit_address": "string", + // Where the customer sends `coin` + "deposit_amount": "string", + // Amount of `coin` the customer must send + "receive_address": "string" + // Our on-chain address FixedFloat pays the Bitcoin to } ``` @@ -6514,9 +6533,11 @@ The RBAC system uses the following permission format: `resource::action` // Tax amount in cents/millisats "processing_fee": number, // Processing fee in cents/millisats - "discount": AdminPaymentDiscountInfo + "discount": AdminPaymentDiscountInfo, // The discount applied to this payment; omitted when there was none. // `amount` is already net of it. + "swap": AdminSwapInfo + // The swap order behind a `fixedfloat` payment; omitted for every other method. } ``` @@ -8013,7 +8034,7 @@ Only the `type` field is required; all other fields are optional and will keep t - `stripe` - Stripe Payments API - `paypal` - PayPal REST API - `onchain` - On-chain Bitcoin payments (LND wallet backend). `address_type` is one of `witness_pubkey_hash`, `nested_pubkey_hash`, `taproot_pubkey`. -- `fixedfloat` - Cryptocurrency payments through the FixedFloat exchange API (payment method `fixedfloat`). The customer picks a coin per payment (`coin` query parameter). The coins a company accepts are the config's `supported_currencies`, which for `fixedfloat` must be coins from `XMR`, `LTC`, `ETH` and `USDTTRC`; an empty list accepts all four. Coins are rejected on every other payment method, and a coin cannot be a company or plan currency. Each payment creates a FixedFloat order into that coin whose BTC destination is a fresh address from the company's `onchain` config, so an enabled `onchain` payment method is required as well. The BTC that arrives is settled by the on-chain watcher at the rate current on arrival. The response hides `api_key` and `api_secret` and returns `has_api_key` and `has_api_secret`. `afftax` is only sent together with `refcode`. +- `fixedfloat` - Cryptocurrency payments through the FixedFloat exchange API (payment method `fixedfloat`). The customer picks a coin per payment (`coin` query parameter). The coins a company accepts are the config's `supported_currencies`, which for `fixedfloat` must be coins from `XMR`, `LTC`, `ETH` and `USDTTRC`; an empty list accepts all four. Coins are rejected on every other payment method, and a coin cannot be a company or plan currency. Each payment creates a FixedFloat order into that coin whose BTC destination is a fresh address from the company's `onchain` config, so an enabled `onchain` payment method is required as well: creating or enabling a `fixedfloat` config is rejected without one. Changes to the config apply to new payments without a restart. The BTC that arrives is settled by the on-chain watcher at the rate current on arrival. The response hides `api_key` and `api_secret` and returns `has_api_key` and `has_api_secret`. `afftax` is only sent together with `refcode`. **Multiple Configurations:** A company can have multiple payment method configurations for the same payment method type. This allows keeping disabled diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index c0dae56f..9ab0c438 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - **Cryptocurrency payments**: `fixedfloat` payment method that takes a `coin` (`XMR`, `LTC`, `ETH` or `USDTTRC`) on `method=fixedfloat&coin=...`. `PaymentData` gains `{ fixedfloat: { coin, address, amount, outpoint? } }`, the deposit address and amount of a swap into our Bitcoin wallet. Time is priced from the Bitcoin that arrives. `ApiCurrency` gains `XMR`, `LTC`, `ETH` and `USDTTRC`, which appear only in a `fixedfloat` method's `currencies`. Admins configure it with a `fixedfloat` provider config (`api_key`, `api_secret`, optional `url`, `refcode`, `afftax`) on `/api/admin/v1/payment_methods`, alongside the company's `onchain` config, and list the accepted coins in `supported_currencies`. +- **FixedFloat in the admin API**: payment rows from `GET /api/admin/v1/vms/{id}/payments`, `/subscriptions/{id}/payments` and the single-payment endpoints gain `swap` (`coin`, `order_id`, `deposit_address`, `deposit_amount`, `receive_address`) on `fixedfloat` payments. Creating or enabling a `fixedfloat` payment method is rejected unless the company has an enabled `onchain` method. Edits to the FixedFloat config take effect on new payments without restarting the API. ### Security diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 8bebce06..1636c5bd 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -280,9 +280,6 @@ async fn main() -> Result<(), Error> { } })); - let swap = factory - .get_swap_provider_for_company(default_company_id) - .await?; let sub_handler = SubscriptionHandler::new( settings.clone(), db.clone(), @@ -293,8 +290,7 @@ async fn main() -> Result<(), Error> { vat.clone(), work_commander.clone(), status.clone(), - )? - .with_swap(swap); + )?; sub_handler.vm_provisioner().init().await?; // On-chain provider for the worker's automated on-chain referral payouts. diff --git a/lnvps_api/src/payment_factory.rs b/lnvps_api/src/payment_factory.rs index a63e0634..8093a3a6 100644 --- a/lnvps_api/src/payment_factory.rs +++ b/lnvps_api/src/payment_factory.rs @@ -195,29 +195,6 @@ impl PaymentMethodFactory { } } - pub async fn get_swap_provider_for_company( - &self, - company_id: u64, - ) -> Result>> { - let config = match self - .db - .get_payment_method_config_for_company(company_id, PaymentMethod::FixedFloat) - .await - { - Ok(config) if config.enabled => config, - _ => return Ok(None), - }; - match config - .get_provider_config() - .context("Failed to parse provider config")? - { - ProviderConfig::FixedFloat(cfg) => { - Ok(Some(Arc::new(crate::swap::FixedFloat::new(&cfg)))) - } - other => bail!("Unsupported swap provider type: {}", other.provider_type()), - } - } - /// Get the on-chain payment provider for a company #[cfg(feature = "onchain")] pub async fn get_onchain_provider_for_company( diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index f5ef7435..7a6bcb9f 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -273,7 +273,8 @@ impl SubscriptionHandler { }) } - pub fn with_swap(mut self, swap: Option>) -> Self { + #[cfg(test)] + pub(crate) fn with_swap(mut self, swap: Option>) -> Self { self.swap = swap; self } @@ -345,10 +346,10 @@ impl SubscriptionHandler { "{} is not accepted for FixedFloat payments", coin ); - let swap = self - .swap - .clone() - .context("FixedFloat payments are not configured")?; + let swap = match &self.swap { + Some(swap) => swap.clone(), + None => crate::swap::provider_for_config(&config)?, + }; let receive_address = self.new_onchain_address(amount_msat, memo, label).await?; swap.create_order(SwapOrderRequest { coin: coin.to_string(), @@ -3129,7 +3130,7 @@ pub(crate) mod revolut_offline_tests { ProviderConfig::FixedFloat(FixedFloatProviderConfig { api_key: "k".to_string(), api_secret: "s".to_string(), - url: None, + url: Some("http://127.0.0.1:1".to_string()), refcode: None, afftax: None, }), @@ -3246,11 +3247,15 @@ pub(crate) mod revolut_offline_tests { } #[tokio::test] - async fn renew_subscription_fixedfloat_requires_swap_provider() { - let (sub, onchain, _swap, sub_id) = fixedfloat_setup("BTC", false, &[], true).await; + async fn renew_subscription_fixedfloat_builds_the_provider_from_the_config() { + let (sub, _onchain, swap, sub_id) = fixedfloat_setup("BTC", false, &[], true).await; - assert!(renew_with_coin(&sub, sub_id, Currency::XMR).await.is_err()); - assert!(onchain.addresses.lock().await.is_empty()); + let err = renew_with_coin(&sub, sub_id, Currency::XMR) + .await + .unwrap_err(); + + assert!(!err.to_string().contains("not configured"), "{err}"); + assert!(swap.requests.lock().await.is_empty()); } #[tokio::test] diff --git a/lnvps_api/src/swap/mod.rs b/lnvps_api/src/swap/mod.rs index c66a3d1a..83706722 100644 --- a/lnvps_api/src/swap/mod.rs +++ b/lnvps_api/src/swap/mod.rs @@ -1,11 +1,13 @@ -use anyhow::Result; +use anyhow::{Result, bail}; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use lnvps_db::{PaymentMethodConfig, ProviderConfig}; +use std::sync::Arc; mod fixedfloat; pub use fixedfloat::FixedFloat; +pub use lnvps_api_common::SwapData; #[derive(Clone, Debug, PartialEq, Eq)] pub struct SwapOrderRequest { @@ -20,27 +22,69 @@ pub struct SwapOrder { pub expires: DateTime, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SwapData { - pub coin: String, - pub order_id: String, - pub order_token: String, - pub deposit_address: String, - pub deposit_amount: String, - pub receive_address: String, +#[async_trait] +pub trait SwapProvider: Send + Sync { + async fn create_order(&self, req: SwapOrderRequest) -> Result; } -impl SwapData { - pub fn parse(raw: &str) -> Result { - Ok(serde_json::from_str(raw)?) +pub fn provider_for_config(config: &PaymentMethodConfig) -> Result> { + match config.get_provider_config() { + Some(ProviderConfig::FixedFloat(cfg)) => Ok(Arc::new(FixedFloat::new(&cfg))), + Some(other) => bail!("Unsupported swap provider type: {}", other.provider_type()), + None => bail!("Failed to parse swap provider config"), } +} - pub fn to_json(&self) -> Result { - Ok(serde_json::to_string(self)?) +#[cfg(test)] +mod tests { + use super::*; + use lnvps_db::{FixedFloatProviderConfig, PaymentMethod, RevolutProviderConfig}; + + fn config(provider: ProviderConfig) -> PaymentMethodConfig { + PaymentMethodConfig::new_with_config( + 1, + provider.payment_method(), + "x".to_string(), + true, + provider, + ) } -} -#[async_trait] -pub trait SwapProvider: Send + Sync { - async fn create_order(&self, req: SwapOrderRequest) -> Result; + #[test] + fn provider_for_config_builds_fixedfloat() { + let cfg = config(ProviderConfig::FixedFloat(FixedFloatProviderConfig { + api_key: "k".to_string(), + api_secret: "s".to_string(), + url: None, + refcode: None, + afftax: None, + })); + assert_eq!(cfg.payment_method, PaymentMethod::FixedFloat); + assert!(provider_for_config(&cfg).is_ok()); + } + + #[test] + fn provider_for_config_rejects_other_providers() { + let cfg = config(ProviderConfig::Revolut(RevolutProviderConfig { + url: "https://x".to_string(), + token: "t".to_string(), + api_version: "v".to_string(), + public_key: "p".to_string(), + webhook_secret: None, + })); + assert!(provider_for_config(&cfg).is_err()); + } + + #[test] + fn provider_for_config_rejects_unparsable_config() { + let mut cfg = config(ProviderConfig::FixedFloat(FixedFloatProviderConfig { + api_key: "k".to_string(), + api_secret: "s".to_string(), + url: None, + refcode: None, + afftax: None, + })); + cfg.config = None; + assert!(provider_for_config(&cfg).is_err()); + } } diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index f671392a..fe3ef1cd 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -3557,7 +3557,10 @@ pub struct AdminVmPaymentInfo { /// it — this only says which code was used and how much came off. #[serde(skip_serializing_if = "Option::is_none")] pub discount: Option, - // Note: external_data is omitted as it may contain sensitive payment provider data + /// The swap order behind a `fixedfloat` payment. + #[serde(skip_serializing_if = "Option::is_none")] + pub swap: Option, + // Note: external_data is otherwise omitted as it may contain sensitive payment provider data } /// Refunds recorded against one payment, with what is still refundable on it. @@ -3601,6 +3604,7 @@ impl AdminVmPaymentInfo { payment_type: ApiSubscriptionPaymentType::from(payment.payment_type), refunded_payment_id: payment.refunded_payment_id.as_ref().map(hex::encode), discount: None, + swap: AdminSwapInfo::from_payment(payment.payment_method, &payment.external_data), } } @@ -4099,6 +4103,36 @@ impl AdminCreateSubscriptionLineItemRequest { } } +/// The swap behind a `fixedfloat` payment. The order token is left out: it +/// authorises actions on the FixedFloat order. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct AdminSwapInfo { + pub coin: String, + pub order_id: String, + pub deposit_address: String, + pub deposit_amount: String, + pub receive_address: String, +} + +impl AdminSwapInfo { + pub fn from_payment( + method: lnvps_db::PaymentMethod, + external_data: &lnvps_db::EncryptedString, + ) -> Option { + if method != lnvps_db::PaymentMethod::FixedFloat { + return None; + } + let data = lnvps_api_common::SwapData::parse(external_data.as_str()).ok()?; + Some(Self { + coin: data.coin, + order_id: data.order_id, + deposit_address: data.deposit_address, + deposit_amount: data.deposit_amount, + receive_address: data.receive_address, + }) + } +} + // Subscription Payment Models #[derive(Serialize)] pub struct AdminSubscriptionPaymentInfo { @@ -4127,6 +4161,9 @@ pub struct AdminSubscriptionPaymentInfo { /// it — this only says which code was used and how much came off. #[serde(skip_serializing_if = "Option::is_none")] pub discount: Option, + /// The swap order behind a `fixedfloat` payment. + #[serde(skip_serializing_if = "Option::is_none")] + pub swap: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -4163,6 +4200,7 @@ impl From for lnvps_db::SubscriptionPaymentType { impl AdminSubscriptionPaymentInfo { pub fn new(payment: lnvps_db::SubscriptionPayment, company_base_currency: String) -> Self { + let swap = AdminSwapInfo::from_payment(payment.payment_method, &payment.external_data); Self { id: hex::encode(&payment.id), subscription_id: payment.subscription_id, @@ -4183,6 +4221,7 @@ impl AdminSubscriptionPaymentInfo { tax: payment.tax, processing_fee: payment.processing_fee, discount: None, + swap, } } @@ -4196,6 +4235,7 @@ impl AdminSubscriptionPaymentInfo { } pub fn from_with_company(payment: lnvps_db::SubscriptionPaymentWithCompany) -> Self { + let swap = AdminSwapInfo::from_payment(payment.payment_method, &payment.external_data); Self { id: hex::encode(&payment.id), subscription_id: payment.subscription_id, @@ -4216,6 +4256,7 @@ impl AdminSubscriptionPaymentInfo { tax: payment.tax, processing_fee: payment.processing_fee, discount: None, + swap, } } } @@ -5155,6 +5196,152 @@ mod tests { c.iter().map(|s| s.to_string()).collect() } + fn swap_payment( + method: lnvps_db::PaymentMethod, + external_data: String, + ) -> lnvps_db::SubscriptionPayment { + lnvps_db::SubscriptionPayment { + id: vec![3; 32], + subscription_id: 1, + user_id: 1, + created: Utc::now(), + expires: Utc::now(), + amount: 1000, + currency: "BTC".to_string(), + payment_method: method, + payment_type: lnvps_db::SubscriptionPaymentType::Renewal, + external_data: lnvps_db::EncryptedString::new(external_data), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: Some(2_592_000), + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + tax_rate: None, + tax_country_code: None, + tax_treatment: None, + tax_evidence: None, + tax_breakdown: None, + refunded_payment_id: None, + renewal_source: None, + } + } + + fn swap_json() -> String { + lnvps_api_common::SwapData { + coin: "XMR".to_string(), + order_id: "ABC123".to_string(), + order_token: "secret-token".to_string(), + deposit_address: "4deposit".to_string(), + deposit_amount: "0.5".to_string(), + receive_address: "bc1qours".to_string(), + } + .to_json() + .unwrap() + } + + #[test] + fn fixedfloat_payment_shows_the_swap_without_the_token() { + let payment = swap_payment(lnvps_db::PaymentMethod::FixedFloat, swap_json()); + + let sub = AdminSubscriptionPaymentInfo::new(payment.clone(), "EUR".to_string()); + let vm = AdminVmPaymentInfo::from_subscription_payment(&payment, 7, "EUR".to_string()); + + for json in [ + serde_json::to_value(&sub).unwrap(), + serde_json::to_value(&vm).unwrap(), + ] { + assert_eq!(json["swap"]["coin"], "XMR"); + assert_eq!(json["swap"]["order_id"], "ABC123"); + assert_eq!(json["swap"]["deposit_address"], "4deposit"); + assert_eq!(json["swap"]["deposit_amount"], "0.5"); + assert_eq!(json["swap"]["receive_address"], "bc1qours"); + assert!(!json.to_string().contains("secret-token")); + } + } + + #[test] + fn fixedfloat_payment_with_company_shows_the_swap() { + let p = swap_payment(lnvps_db::PaymentMethod::FixedFloat, swap_json()); + let with_company = lnvps_db::SubscriptionPaymentWithCompany { + id: p.id, + subscription_id: p.subscription_id, + user_id: p.user_id, + created: p.created, + expires: p.expires, + amount: p.amount, + currency: p.currency, + payment_method: p.payment_method, + payment_type: p.payment_type, + external_data: p.external_data, + external_id: p.external_id, + is_paid: p.is_paid, + rate: p.rate, + time_value: p.time_value, + metadata: p.metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: p.paid_at, + tax_rate: p.tax_rate, + tax_country_code: p.tax_country_code, + tax_treatment: p.tax_treatment, + tax_evidence: p.tax_evidence, + tax_breakdown: p.tax_breakdown, + refunded_payment_id: p.refunded_payment_id, + renewal_source: p.renewal_source, + company_id: 1, + company_name: "Co".to_string(), + company_base_currency: "EUR".to_string(), + vm_id: None, + host_id: None, + host_name: None, + region_id: None, + region_name: None, + user_country_code: None, + user_geo_country_code: None, + user_billing_tax_id: None, + }; + + let json = serde_json::to_value(AdminSubscriptionPaymentInfo::from_with_company( + with_company, + )) + .unwrap(); + + assert_eq!(json["swap"]["order_id"], "ABC123"); + assert!(!json.to_string().contains("secret-token")); + } + + #[test] + fn other_payments_have_no_swap() { + let payment = swap_payment(lnvps_db::PaymentMethod::OnChain, swap_json()); + let json = serde_json::to_value(AdminSubscriptionPaymentInfo::new( + payment.clone(), + "EUR".to_string(), + )) + .unwrap(); + assert!(json.get("swap").is_none()); + let json = serde_json::to_value(AdminVmPaymentInfo::from_subscription_payment( + &payment, + 1, + "EUR".to_string(), + )) + .unwrap(); + assert!(json.get("swap").is_none()); + } + + #[test] + fn fixedfloat_payment_with_unreadable_data_has_no_swap() { + let payment = swap_payment(lnvps_db::PaymentMethod::FixedFloat, "not json".to_string()); + let json = serde_json::to_value(AdminSubscriptionPaymentInfo::new( + payment, + "EUR".to_string(), + )) + .unwrap(); + assert!(json.get("swap").is_none()); + } + #[test] fn fixedfloat_method_wire_names() { assert_eq!( diff --git a/lnvps_api_admin/src/admin/payment_methods.rs b/lnvps_api_admin/src/admin/payment_methods.rs index 8b61ad8c..000e2a2b 100644 --- a/lnvps_api_admin/src/admin/payment_methods.rs +++ b/lnvps_api_admin/src/admin/payment_methods.rs @@ -72,6 +72,7 @@ async fn admin_create_payment_method( auth.require_permission(AdminResource::PaymentMethodConfig, AdminAction::Create)?; let config = request.to_payment_method_config()?; + require_onchain_for_fixedfloat(&this, &config).await?; let config_id = this.db.insert_payment_method_config(&config).await?; @@ -178,6 +179,8 @@ async fn admin_update_payment_method( ); } + require_onchain_for_fixedfloat(&this, &config).await?; + this.db.update_payment_method_config(&config).await?; let updated_config = this.db.get_payment_method_config(id).await?; @@ -203,3 +206,234 @@ async fn admin_delete_payment_method( "message": "Payment method configuration deleted successfully" })) } + +async fn require_onchain_for_fixedfloat( + this: &RouterState, + config: &lnvps_db::PaymentMethodConfig, +) -> anyhow::Result<()> { + if config.payment_method != lnvps_db::PaymentMethod::FixedFloat || !config.enabled { + return Ok(()); + } + let onchain = this + .db + .get_payment_method_config_for_company(config.company_id, lnvps_db::PaymentMethod::OnChain) + .await + .ok() + .filter(|c| c.enabled); + anyhow::ensure!( + onchain.is_some(), + "FixedFloat pays out to an on-chain address, so the company needs an enabled onchain payment method first" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admin::model::Permission; + use lnvps_api_common::{ + ChannelWorkCommander, MockDb, MockExchangeRate, VatClient, VmStateCache, + }; + use lnvps_db::{ + FixedFloatProviderConfig, LNVpsDb, OnChainAddressType, OnChainProviderConfig, + PaymentMethod, PaymentMethodConfig, ProviderConfig, + }; + use std::sync::Arc; + + fn state(db: &Arc) -> RouterState { + RouterState { + node_control: None, + db: db.clone(), + work_commander: Arc::new(ChannelWorkCommander::new()), + feedback: None, + vm_state_cache: VmStateCache::new(), + exchange: Arc::new(MockExchangeRate::default()), + vat: VatClient::new(), + } + } + + fn auth() -> AdminAuth { + AdminAuth { + user_id: 1, + pubkey: vec![1u8; 32], + permissions: [AdminAction::View, AdminAction::Create, AdminAction::Update] + .into_iter() + .map(|action| Permission { + resource: AdminResource::PaymentMethodConfig, + action, + }) + .collect(), + nip98_auth: None, + } + } + + fn fixedfloat(enabled: bool) -> PaymentMethodConfig { + PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::FixedFloat, + "FF".to_string(), + enabled, + ProviderConfig::FixedFloat(FixedFloatProviderConfig { + api_key: "k".to_string(), + api_secret: "s".to_string(), + url: None, + refcode: None, + afftax: None, + }), + ) + } + + fn onchain(enabled: bool) -> PaymentMethodConfig { + PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::OnChain, + "OC".to_string(), + enabled, + ProviderConfig::OnChain(OnChainProviderConfig { + url: "https://x:10009".to_string(), + cert_path: "c".into(), + macaroon_path: "m".into(), + address_type: OnChainAddressType::WitnessPubkeyHash, + account: None, + min_confirmations: 1, + }), + ) + } + + fn create_request(enabled: Option) -> CreatePaymentMethodConfigRequest { + CreatePaymentMethodConfigRequest { + company_id: 1, + name: "FF".to_string(), + enabled, + config: fixedfloat(true).get_provider_config().unwrap(), + processing_fee_rate: None, + processing_fee_base: None, + processing_fee_currency: None, + min_amount: None, + min_amount_currency: None, + supported_currencies: None, + } + } + + #[tokio::test] + async fn guard_ignores_other_methods_and_disabled_fixedfloat() { + let db: Arc = Arc::new(MockDb::default()); + let this = state(&db); + + assert!( + require_onchain_for_fixedfloat(&this, &onchain(true)) + .await + .is_ok() + ); + assert!( + require_onchain_for_fixedfloat(&this, &fixedfloat(false)) + .await + .is_ok() + ); + assert!( + require_onchain_for_fixedfloat(&this, &fixedfloat(true)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn guard_needs_an_enabled_onchain_config() { + let db: Arc = Arc::new(MockDb::default()); + let this = state(&db); + + db.insert_payment_method_config(&onchain(false)) + .await + .unwrap(); + assert!( + require_onchain_for_fixedfloat(&this, &fixedfloat(true)) + .await + .is_err() + ); + + let mut enabled = db + .get_payment_method_config_for_company(1, PaymentMethod::OnChain) + .await + .unwrap(); + enabled.enabled = true; + db.update_payment_method_config(&enabled).await.unwrap(); + assert!( + require_onchain_for_fixedfloat(&this, &fixedfloat(true)) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn create_fixedfloat_requires_onchain() { + let db: Arc = Arc::new(MockDb::default()); + let this = state(&db); + + let res = + admin_create_payment_method(auth(), State(this.clone()), Json(create_request(None))) + .await; + assert!(res.is_err()); + assert!( + db.get_payment_method_config_for_company(1, PaymentMethod::FixedFloat) + .await + .is_err() + ); + + db.insert_payment_method_config(&onchain(true)) + .await + .unwrap(); + let res = + admin_create_payment_method(auth(), State(this), Json(create_request(None))).await; + assert!(res.is_ok()); + assert!( + db.get_payment_method_config_for_company(1, PaymentMethod::FixedFloat) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn create_disabled_fixedfloat_does_not_need_onchain() { + let db: Arc = Arc::new(MockDb::default()); + let res = admin_create_payment_method( + auth(), + State(state(&db)), + Json(create_request(Some(false))), + ) + .await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn enabling_fixedfloat_requires_onchain() { + let db: Arc = Arc::new(MockDb::default()); + let id = db + .insert_payment_method_config(&fixedfloat(false)) + .await + .unwrap(); + let enable = || UpdatePaymentMethodConfigRequest { + name: None, + enabled: Some(true), + config: None, + processing_fee_rate: None, + processing_fee_base: None, + processing_fee_currency: None, + min_amount: None, + min_amount_currency: None, + supported_currencies: None, + }; + + let res = + admin_update_payment_method(auth(), State(state(&db)), Path(id), Json(enable())).await; + assert!(res.is_err()); + assert!(!db.get_payment_method_config(id).await.unwrap().enabled); + + db.insert_payment_method_config(&onchain(true)) + .await + .unwrap(); + let res = + admin_update_payment_method(auth(), State(state(&db)), Path(id), Json(enable())).await; + assert!(res.is_ok()); + assert!(db.get_payment_method_config(id).await.unwrap().enabled); + } +} diff --git a/lnvps_api_common/src/lib.rs b/lnvps_api_common/src/lib.rs index 3598df06..b15d5f04 100644 --- a/lnvps_api_common/src/lib.rs +++ b/lnvps_api_common/src/lib.rs @@ -33,6 +33,7 @@ mod single_use; pub mod ssh_client; mod ssh_host_key; mod status; +mod swap; mod traffic; mod vat; mod vm_history; @@ -68,6 +69,7 @@ pub use session::*; pub use single_use::*; pub use ssh_host_key::*; pub use status::*; +pub use swap::*; pub use traffic::*; pub use vat::*; pub use vm_history::*; diff --git a/lnvps_api_common/src/swap.rs b/lnvps_api_common/src/swap.rs new file mode 100644 index 00000000..79c67af7 --- /dev/null +++ b/lnvps_api_common/src/swap.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SwapData { + pub coin: String, + pub order_id: String, + pub order_token: String, + pub deposit_address: String, + pub deposit_amount: String, + pub receive_address: String, +} + +impl SwapData { + pub fn parse(raw: &str) -> Result { + Ok(serde_json::from_str(raw)?) + } + + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn data() -> SwapData { + SwapData { + coin: "XMR".to_string(), + order_id: "ABC".to_string(), + order_token: "tok".to_string(), + deposit_address: "4deposit".to_string(), + deposit_amount: "0.5".to_string(), + receive_address: "bc1qours".to_string(), + } + } + + #[test] + fn swap_data_round_trips_through_json() { + let d = data(); + assert_eq!(SwapData::parse(&d.to_json().unwrap()).unwrap(), d); + } + + #[test] + fn swap_data_rejects_garbage() { + assert!(SwapData::parse("not json").is_err()); + assert!(SwapData::parse("{}").is_err()); + } +}