diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 30c8eda1..272ec0f6 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -7988,6 +7988,7 @@ All fields except `webhook_secret` are required when creating: | `stripe` | stripe | `secret_key`, `publishable_key`, `webhook_secret` | | `paypal` | paypal | `client_id`, `client_secret`, `mode` | | `onchain` | onchain | `url`, `cert_path`, `macaroon_path` (`address_type`, `account`, `min_confirmations` optional) | +| `fixedfloat` | fixedfloat | `api_key`, `api_secret` (`url`, `refcode`, `afftax` optional) | ### PartialProviderConfig (Tagged Union) - For Updates @@ -8001,6 +8002,7 @@ Only the `type` field is required; all other fields are optional and will keep t | `stripe` | `secret_key`, `publishable_key`, `webhook_secret` | | `paypal` | `client_id`, `client_secret`, `mode` | | `onchain` | `url`, `cert_path`, `macaroon_path`, `address_type`, `account` (null clears), `min_confirmations` | +| `fixedfloat` | `api_key`, `api_secret`, `url` (null clears), `refcode` (null clears), `afftax` (null clears) | ### Notes @@ -8011,6 +8013,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`. **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 c5a40512..c0dae56f 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### 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`. + ### Security - **Referral self-dealing**: a VM ordered with the buyer's own referral code no longer earns commission, including VMs already ordered that way, so commission already paid on them nets against future earnings. Commission is reduced by refunds against the referred VM's first payment, and automated payouts wait until that payment is `commission-hold-days` old (default 30). `GET /api/v1/referral/usage` rows gain `payable_after`. Affects `GET /api/v1/referral`, `GET /api/v1/referral/usage`, `GET /api/admin/v1/referrals/*` and the referral usage reports. diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 2456acda..2383858b 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -527,6 +527,7 @@ interface Discount { type PaymentData = | { lightning: string } // Lightning Network invoice | { onchain: { address: string; outpoint?: string } } // On-chain Bitcoin receive address (BTC only). `outpoint` ("{txid}:{vout}") is set as soon as a deposit is seen in the mempool (0-conf), before it confirms, so the UI can show "received, waiting for confirmation"; absent until a deposit is detected. Confirmed once is_paid is true + | { fixedfloat: { coin: string; address: string; amount: string; outpoint?: string } } // A cryptocurrency swapped to Bitcoin. Send exactly `amount` of `coin` (XMR, LTC, ETH or USDTTRC) to `address` before `expires`. A third-party exchange converts it and pays our Bitcoin wallet; `outpoint` ("{txid}:{vout}") appears when that Bitcoin is seen in the mempool. The credited time is priced from the Bitcoin actually received, so a short or late deposit buys proportionally less time. Confirmed once is_paid is true | { revolut: { token: string } } // Revolut payment token | { stripe: { session_id: string } }; // Stripe checkout session @@ -535,9 +536,9 @@ interface PaymentType { } interface PaymentMethod { - name: 'lightning' | 'onchain' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl'; + name: 'lightning' | 'onchain' | 'fixedfloat' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl'; metadata: Record; - currencies: ('BTC' | 'EUR' | 'USD')[]; + currencies: ('BTC' | 'EUR' | 'USD' | 'GBP' | 'CAD' | 'CHF' | 'AUD' | 'JPY' | 'XMR' | 'LTC' | 'ETH' | 'USDTTRC')[]; // For `fixedfloat` these are the coins the customer can pay with, never billing currencies processing_fee_rate?: number; // Percentage rate (e.g., 1.0 for 1%) processing_fee_base?: number; // Base amount in smallest currency units (cents for fiat, millisats for BTC) processing_fee_currency?: string; // Currency for the base fee (e.g., "EUR") @@ -585,7 +586,7 @@ interface SubscriptionPayment { created: string; // ISO 8601 datetime expires: string; // ISO 8601 datetime amount: Price; // Total payment amount - payment_method: 'lightning' | 'onchain' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl'; + payment_method: 'lightning' | 'onchain' | 'fixedfloat' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl'; payment_type: 'Purchase' | 'Renewal' | 'Upgrade'; is_paid: boolean; paid_at?: string; // ISO 8601 datetime when payment was completed (only present when is_paid is true) @@ -1328,7 +1329,8 @@ interface ExchangeRates { - **GET** `/api/v1/vm/{id}/renew?method={payment_method}&intervals={count}` - **Auth**: Required - **Query Params**: - - `method`: Optional payment method ('lightning' | 'onchain' | 'revolut' | 'paypal' | 'nwc') + - `method`: Optional payment method ('lightning' | 'onchain' | 'fixedfloat' | 'revolut' | 'paypal' | 'nwc') + - `coin`: Required when `method=fixedfloat`: one of the `currencies` the `fixedfloat` payment method lists (`XMR`, `LTC`, `ETH`, `USDTTRC`, case-insensitive). The payment is priced in BTC and the response `data` is `{ fixedfloat: { coin, address, amount } }`. A coin that is missing, not a coin, or not listed for the company is rejected - `intervals`: Optional number of billing intervals to renew (default: 1). For example, if the VM has a monthly billing cycle, `intervals=3` would generate a payment for 3 months. - `code`: Optional discount code. A code that cannot be used (unknown, expired, exhausted, or not applicable to this order) **fails the request** rather than quietly invoicing full price, so the customer is told it did not work. The returned `amount` and `tax` are already net of any discount, and `discount` describes what was taken off. - **Response**: `VmPayment` diff --git a/Cargo.lock b/Cargo.lock index 34e53e44..ce50fde7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3578,6 +3578,7 @@ dependencies = [ "futures", "hex", "hickory-resolver", + "hmac 0.12.1", "humantime", "ipnetwork", "isocountry", @@ -3603,6 +3604,7 @@ dependencies = [ "russh", "serde", "serde_json", + "sha2 0.10.9", "ssh-key 0.6.7", "tempfile", "tokio", @@ -4769,9 +4771,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payments-rs" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c670330eb4478b9dda4490b184660550b604918dbea2cae5cb6e48bddb228022" +checksum = "04b077b5f500ddbe0d027dbeae2dae9d404bd28fdddc1f0e38343d83c9ad4b17" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 01e51af6..5306420c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ config = { version = "0.15", features = ["yaml"] } hex = "0.4" nftables = "0.6" ipnetwork = "0.21" -payments-rs = { version = "0.7.1", default-features = false } +payments-rs = { version = "0.8.0", default-features = false } async-trait = "0.1" futures = "0.3" reqwest = { version = "0.13", features = ["json", "rustls-no-provider"] } diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index 37018081..e5d42bae 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -92,6 +92,8 @@ base64 = { version = "0.22", features = ["alloc"] } # On-chain Bitcoin address validation for referral payouts. bitcoin = "0.32" urlencoding = "2.1" +hmac = "0.12" +sha2 = "0.10" rand = "0.9" # ES256 signing for the "Sign in with Apple" client-secret JWT (.p8 key). p256 = { version = "0.13", features = ["ecdsa", "pkcs8", "pem"] } diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index 1c510b73..7a38ebb3 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -70,6 +70,9 @@ pub(crate) struct PaymentMethodQuery { /// Discount code to apply to this order. A code that cannot be used fails /// the request rather than quietly charging full price. pub code: Option, + /// For `method=fixedfloat`: the coin the customer pays with, one of the + /// codes in the method's `coins` metadata (e.g. `XMR`). + pub coin: Option, } impl PaymentMethodQuery { @@ -234,6 +237,28 @@ pub(crate) fn resolve_quote_method(q: &PaymentMethodQuery) -> lnvps_db::PaymentM } } +pub(crate) fn parse_fixedfloat_coin( + coin: Option<&str>, +) -> Result { + use payments_rs::currency::Currency; + use std::str::FromStr; + + let coin = coin + .map(str::trim) + .filter(|c| !c.is_empty()) + .ok_or_else(|| { + lnvps_api_common::ApiError::from(anyhow::anyhow!( + "coin is required for fixedfloat payments" + )) + })?; + Currency::from_str(coin) + .ok() + .filter(|c| c.is_crypto() && *c != Currency::BTC) + .ok_or_else(|| { + lnvps_api_common::ApiError::from(anyhow::anyhow!("Unsupported coin: {}", coin)) + }) +} + pub(crate) async fn resolve_payment_mode( this: &RouterState, uid: u64, @@ -267,6 +292,12 @@ pub(crate) async fn resolve_payment_mode( method_id: q.payment_method_id, }, )), + Some("fixedfloat") => Ok(( + PaymentMethod::FixedFloat, + RenewMode::FixedFloat { + coin: parse_fixedfloat_coin(q.coin.as_deref())?, + }, + )), other => Ok(( other .and_then(|m| PaymentMethod::from_str(m).ok()) @@ -292,6 +323,7 @@ mod tests { save_card: None, payment_method_id: None, code: None, + coin: None, } } @@ -433,6 +465,29 @@ mod tests { ); } + #[test] + fn parse_fixedfloat_coin_accepts_coins_in_any_case() { + use payments_rs::currency::Currency; + + assert_eq!(parse_fixedfloat_coin(Some("XMR")).unwrap(), Currency::XMR); + assert_eq!(parse_fixedfloat_coin(Some(" ltc ")).unwrap(), Currency::LTC); + assert_eq!(parse_fixedfloat_coin(Some("eth")).unwrap(), Currency::ETH); + assert_eq!( + parse_fixedfloat_coin(Some("usdttrc")).unwrap(), + Currency::USDTTRC + ); + } + + #[test] + fn parse_fixedfloat_coin_rejects_missing_and_non_coins() { + assert!(parse_fixedfloat_coin(None).is_err()); + assert!(parse_fixedfloat_coin(Some("")).is_err()); + assert!(parse_fixedfloat_coin(Some(" ")).is_err()); + assert!(parse_fixedfloat_coin(Some("BTC")).is_err()); + assert!(parse_fixedfloat_coin(Some("EUR")).is_err()); + assert!(parse_fixedfloat_coin(Some("DOGE")).is_err()); + } + /// The wire shape mirrors the payment: amounts already net of the discount, /// with the discount reported as a line to show rather than to re-apply. #[test] diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 8bae7dad..38b46495 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -601,6 +601,17 @@ impl ApiVmPayment { address: value.external_data.into(), outpoint: value.external_id.clone(), }, + PaymentMethod::FixedFloat => { + let data = crate::swap::SwapData::parse(value.external_data.as_str()).map_err( + |e| anyhow::anyhow!("Failed to parse FixedFloat payment data: {}", e), + )?; + ApiPaymentData::FixedFloat { + coin: data.coin, + address: data.deposit_address, + amount: data.deposit_amount, + outpoint: value.external_id.clone(), + } + } PaymentMethod::Revolut => { #[derive(Deserialize)] struct RevolutData { @@ -710,6 +721,19 @@ pub enum ApiPaymentData { #[serde(skip_serializing_if = "Option::is_none")] outpoint: Option, }, + /// A cryptocurrency paid through a swap that settles to our Bitcoin wallet + FixedFloat { + /// Coin to send, e.g. `XMR` + coin: String, + /// Deposit address for `coin` + address: String, + /// Amount of `coin` to send, as a decimal string + amount: String, + /// Outpoint (`"{txid}:{vout}"`) of the Bitcoin the swap delivered, set + /// once it is seen in the mempool. `None` until the swap pays out. + #[serde(skip_serializing_if = "Option::is_none")] + outpoint: Option, + }, /// Paid in full from account credit; nothing to pay externally. Credit, } @@ -725,6 +749,7 @@ pub enum ApiPaymentMethod { NWC, LNURL, OnChain, + FixedFloat, } impl From for ApiPaymentMethod { @@ -735,6 +760,7 @@ impl From for ApiPaymentMethod { PaymentMethod::Paypal => ApiPaymentMethod::Paypal, PaymentMethod::Stripe => ApiPaymentMethod::Stripe, PaymentMethod::OnChain => ApiPaymentMethod::OnChain, + PaymentMethod::FixedFloat => ApiPaymentMethod::FixedFloat, } } } @@ -1374,6 +1400,16 @@ impl From for ApiSubscriptionPayment { address: payment.external_data.clone().into(), outpoint: payment.external_id.clone(), }, + PaymentMethod::FixedFloat => { + crate::swap::SwapData::parse(payment.external_data.as_str()) + .map(|d| ApiPaymentData::FixedFloat { + coin: d.coin, + address: d.deposit_address, + amount: d.deposit_amount, + outpoint: payment.external_id.clone(), + }) + .unwrap_or_else(|_| ApiPaymentData::Lightning(String::new())) + } PaymentMethod::Revolut => { #[derive(Deserialize)] struct RevolutData { @@ -1796,6 +1832,136 @@ mod tests { assert!(matches!(sub.data, ApiPaymentData::Credit)); } + fn fixedfloat_payment(external_data: &str) -> lnvps_db::SubscriptionPayment { + lnvps_db::SubscriptionPayment { + id: vec![2u8; 16], + subscription_id: 1, + user_id: 1, + created: Utc::now(), + expires: Utc::now(), + amount: 1000, + currency: "BTC".to_string(), + payment_method: PaymentMethod::FixedFloat, + payment_type: lnvps_db::SubscriptionPaymentType::Renewal, + external_data: external_data.to_string().into(), + 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, + } + } + + #[test] + fn test_fixedfloat_payment_exposes_coin_address_and_amount() { + let data = crate::swap::SwapData { + coin: "LTC".to_string(), + order_id: "ABC".to_string(), + order_token: "secret-token".to_string(), + deposit_address: "ltc1qdeposit".to_string(), + deposit_amount: "0.42".to_string(), + receive_address: "bc1qours".to_string(), + } + .to_json() + .unwrap(); + let mut payment = fixedfloat_payment(&data); + + let waiting = ApiVmPayment::from_subscription_payment(payment.clone(), 1).unwrap(); + match &waiting.data { + ApiPaymentData::FixedFloat { + coin, + address, + amount, + outpoint, + } => { + assert_eq!(coin, "LTC"); + assert_eq!(address, "ltc1qdeposit"); + assert_eq!(amount, "0.42"); + assert!(outpoint.is_none()); + } + _ => panic!("expected FixedFloat data"), + } + let json = serde_json::to_value(&waiting).unwrap(); + assert_eq!(json["data"]["fixedfloat"]["coin"], "LTC"); + assert!(json["data"]["fixedfloat"].get("outpoint").is_none()); + let text = json.to_string(); + assert!(!text.contains("secret-token"), "order token must not leak"); + assert!( + !text.contains("bc1qours"), + "our receive address must not leak" + ); + + payment.external_id = Some("txid:1".to_string()); + let detected = ApiVmPayment::from_subscription_payment(payment, 1).unwrap(); + match detected.data { + ApiPaymentData::FixedFloat { outpoint, .. } => { + assert_eq!(outpoint.as_deref(), Some("txid:1")) + } + _ => panic!("expected FixedFloat data"), + } + } + + #[test] + fn test_subscription_fixedfloat_payment_exposes_coin() { + let data = crate::swap::SwapData { + coin: "USDTTRC".to_string(), + order_id: "ABC".to_string(), + order_token: "secret-token".to_string(), + deposit_address: "Tdeposit".to_string(), + deposit_amount: "55.1".to_string(), + receive_address: "bc1qours".to_string(), + } + .to_json() + .unwrap(); + let sub = ApiSubscriptionPayment::from(fixedfloat_payment(&data)); + match sub.data { + ApiPaymentData::FixedFloat { + coin, + address, + amount, + .. + } => { + assert_eq!( + (coin.as_str(), address.as_str(), amount.as_str()), + ("USDTTRC", "Tdeposit", "55.1") + ); + } + _ => panic!("expected FixedFloat data"), + } + } + + #[test] + fn test_fixedfloat_method_wire_name() { + assert_eq!( + serde_json::to_string(&ApiPaymentMethod::FixedFloat).unwrap(), + "\"fixedfloat\"" + ); + assert_eq!( + ApiPaymentMethod::from(PaymentMethod::FixedFloat), + ApiPaymentMethod::FixedFloat + ); + assert_eq!( + serde_json::from_str::("\"fixedfloat\"").unwrap(), + ApiPaymentMethod::FixedFloat + ); + } + + #[test] + fn test_fixedfloat_payment_with_bad_data_is_an_error() { + let payment = fixedfloat_payment("not json"); + assert!(ApiVmPayment::from_subscription_payment(payment, 1).is_err()); + } + /// On-chain payment data exposes the deposit outpoint (`{txid}:{vout}`) once /// a deposit is seen (external_id set), before confirmation, and omits it /// while no deposit has arrived. diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 1aeb6b0f..e8523bd0 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -1994,6 +1994,12 @@ fn default_currencies_for_method(method: PaymentMethod) -> Vec { match method { PaymentMethod::Lightning => vec![ApiCurrency::BTC], PaymentMethod::OnChain => vec![ApiCurrency::BTC], + PaymentMethod::FixedFloat => vec![ + ApiCurrency::XMR, + ApiCurrency::LTC, + ApiCurrency::ETH, + ApiCurrency::USDTTRC, + ], PaymentMethod::Revolut => vec![ApiCurrency::EUR, ApiCurrency::USD], PaymentMethod::Paypal => vec![ApiCurrency::EUR, ApiCurrency::USD], PaymentMethod::Stripe => vec![ApiCurrency::EUR, ApiCurrency::USD], @@ -3110,6 +3116,17 @@ mod tests { | ApiPaymentMethod::OnChain => { assert_eq!(info.currencies, vec![ApiCurrency::BTC]); } + ApiPaymentMethod::FixedFloat => { + assert_eq!( + info.currencies, + vec![ + ApiCurrency::XMR, + ApiCurrency::LTC, + ApiCurrency::ETH, + ApiCurrency::USDTTRC + ] + ); + } ApiPaymentMethod::Revolut | ApiPaymentMethod::Paypal | ApiPaymentMethod::Stripe => { assert_eq!(info.currencies, vec![ApiCurrency::EUR, ApiCurrency::USD]); } diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 1636c5bd..8bebce06 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -280,6 +280,9 @@ 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(), @@ -290,7 +293,8 @@ 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/lib.rs b/lnvps_api/src/lib.rs index 65247fdc..2773fd18 100644 --- a/lnvps_api/src/lib.rs +++ b/lnvps_api/src/lib.rs @@ -11,6 +11,7 @@ pub mod refund; pub mod router; pub mod settings; pub mod subscription; +pub mod swap; pub mod worker; #[cfg(test)] diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index 16344f30..b510ba62 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -647,3 +647,31 @@ impl OnChainProvider for MockOnChainProvider { }) } } + +#[derive(Clone, Default)] +pub struct MockSwapProvider { + pub requests: Arc>>, +} + +#[async_trait] +impl crate::swap::SwapProvider for MockSwapProvider { + async fn create_order( + &self, + req: crate::swap::SwapOrderRequest, + ) -> anyhow::Result { + let mut requests = self.requests.lock().await; + let n = requests.len(); + requests.push(req.clone()); + Ok(crate::swap::SwapOrder { + data: crate::swap::SwapData { + coin: req.coin.clone(), + order_id: format!("MOCK{n}"), + order_token: format!("token{n}"), + deposit_address: format!("4mockxmr{n:08}"), + deposit_amount: "1.5".to_string(), + receive_address: req.receive_address, + }, + expires: Utc::now() + chrono::Duration::minutes(30), + }) + } +} diff --git a/lnvps_api/src/payment_factory.rs b/lnvps_api/src/payment_factory.rs index 8093a3a6..a63e0634 100644 --- a/lnvps_api/src/payment_factory.rs +++ b/lnvps_api/src/payment_factory.rs @@ -195,6 +195,29 @@ 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/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index e91f2261..2a5265f0 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -39,6 +39,7 @@ //! every payment is re-generated uniformly from current line-item pricing. use crate::subscription::SubscriptionHandler; +use crate::swap::SwapData; use anyhow::{Result, bail}; use chrono::Utc; use futures::StreamExt; @@ -67,6 +68,16 @@ fn deposit_key(txid: &str, vout: u32) -> String { format!("{}:{}", txid, vout) } +fn receive_address(payment: &SubscriptionPayment) -> Option { + match payment.payment_method { + PaymentMethod::OnChain => Some(payment.external_data.as_str().to_string()), + PaymentMethod::FixedFloat => SwapData::parse(payment.external_data.as_str()) + .ok() + .map(|d| d.receive_address), + _ => None, + } +} + impl OnChainPaymentHandler { pub fn new( provider: Arc, @@ -98,12 +109,18 @@ impl OnChainPaymentHandler { } async fn payment_for_address(&self, address: &str) -> Result> { - Ok(self + let mut payments = self .db .list_subscription_payments_by_method(PaymentMethod::OnChain) - .await? + .await?; + payments.extend( + self.db + .list_subscription_payments_by_method(PaymentMethod::FixedFloat) + .await?, + ); + Ok(payments .into_iter() - .filter(|p| p.external_data.as_str() == address) + .filter(|p| receive_address(p).as_deref() == Some(address)) .max_by_key(|p| p.created)) } @@ -135,7 +152,7 @@ impl OnChainPaymentHandler { { Ok(vm) => { engine - .get_cost_by_amount(vm.id, gross, PaymentMethod::OnChain) + .get_cost_by_amount(vm.id, gross, payment.payment_method) .await? } Err(_) => { @@ -143,7 +160,7 @@ impl OnChainPaymentHandler { .get_subscription_cost_by_amount( payment.subscription_id, gross, - PaymentMethod::OnChain, + payment.payment_method, ) .await? } @@ -336,6 +353,12 @@ impl OnChainPaymentHandler { payment.external_id = Some(key); self.db.update_subscription_payment(&payment).await?; self.sub_handler.complete_payment(&payment).await?; + } else if payment.payment_method != PaymentMethod::OnChain { + debug!( + "Deposit {} to settled swap address of payment {}, ignoring", + key, + hex::encode(&payment.id) + ); } else { // Address reuse after settlement: insert a new renewal payment, // priced from the received amount (issue #109). @@ -788,6 +811,93 @@ mod tests { Ok(()) } + async fn make_fixedfloat(db: &MockDb, payment: &SubscriptionPayment) -> Result<()> { + let mut p = payment.clone(); + p.payment_method = PaymentMethod::FixedFloat; + p.external_data = SwapData { + coin: "XMR".to_string(), + order_id: "ABC".to_string(), + order_token: "tok".to_string(), + deposit_address: "4Axmr".to_string(), + deposit_amount: "1.5".to_string(), + receive_address: ADDRESS.to_string(), + } + .to_json()? + .into(); + db.update_subscription_payment(&p).await?; + Ok(()) + } + + #[tokio::test] + async fn test_fixedfloat_swap_deposit_settles() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + make_fixedfloat(&db, &payment).await?; + let expect = engine_price(&handler, payment.subscription_id, EXPECTED).await?; + + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.payment_method, PaymentMethod::FixedFloat); + assert_eq!(p.external_id.as_deref(), Some("tx1:0")); + assert_close(p.time_value.unwrap(), expect.time_value); + assert_eq!(db.subscription_payments.lock().await.len(), 1); + Ok(()) + } + + #[tokio::test] + async fn test_fixedfloat_swap_short_deposit_buys_less_time() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + make_fixedfloat(&db, &payment).await?; + let expect_full = engine_price(&handler, payment.subscription_id, EXPECTED).await?; + + handler + .handle_deposit(ADDRESS, "tx1", 0, EXPECTED / 2) + .await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_close(p.time_value.unwrap(), expect_full.time_value / 2); + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED / 2); + Ok(()) + } + + #[tokio::test] + async fn test_fixedfloat_swap_detected_locks_pricing() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + make_fixedfloat(&db, &payment).await?; + + handler.handle_detected(ADDRESS, "tx1", 0, EXPECTED).await?; + + let p = get_payment(&db, &payment.id).await; + assert!(!p.is_paid); + assert_eq!(p.external_id.as_deref(), Some("tx1:0")); + Ok(()) + } + + #[tokio::test] + async fn test_fixedfloat_swap_second_deposit_to_settled_address_ignored() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + make_fixedfloat(&db, &payment).await?; + + handler.handle_deposit(ADDRESS, "tx1", 0, EXPECTED).await?; + handler.handle_deposit(ADDRESS, "tx2", 0, EXPECTED).await?; + + assert_eq!(db.subscription_payments.lock().await.len(), 1); + Ok(()) + } + + #[tokio::test] + async fn test_receive_address() -> Result<()> { + let (_db, _provider, _handler, mut p) = setup().await?; + assert_eq!(receive_address(&p).as_deref(), Some(ADDRESS)); + p.payment_method = PaymentMethod::Lightning; + assert_eq!(receive_address(&p), None); + p.payment_method = PaymentMethod::FixedFloat; + assert_eq!(receive_address(&p), None); + Ok(()) + } + /// A replayed deposit must not settle or create anything. #[tokio::test] async fn test_duplicate_deposit_skipped() -> Result<()> { diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index 6759cf6a..f5ef7435 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -34,6 +34,8 @@ use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; +use crate::swap::{SwapOrder, SwapOrderRequest, SwapProvider}; + mod app; mod ip_range; mod marketplace_node_fee; @@ -92,6 +94,9 @@ pub enum RenewMode { /// NWC (Lightning) wallet pays the invoice. `method_id` selects a specific /// saved method; `None` uses the user's default for the chosen provider. Saved { method_id: Option }, + /// Pay with a cryptocurrency swapped to Bitcoin; `coin` is the exchange's + /// code for what the customer sends (e.g. `XMR`). + FixedFloat { coin: Currency }, } impl Default for RenewMode { @@ -100,6 +105,24 @@ impl Default for RenewMode { } } +/// Whether `coin` may be paid on a FixedFloat payment method config. An empty +/// `supported_currencies` accepts every coin the swap provider supports. +pub(crate) fn fixedfloat_coin_accepted( + config: &lnvps_db::PaymentMethodConfig, + coin: Currency, +) -> bool { + if !coin.is_crypto() || coin == Currency::BTC { + return false; + } + if config.supported_currencies.is_empty() { + return true; + } + config + .supported_currencies + .iter() + .any(|c| c.eq_ignore_ascii_case(&coin.to_string())) +} + /// A priced renewal order: exactly what the renewal would charge, with nothing /// created. Produced by [`SubscriptionHandler::quote_renewal`] and consumed /// internally by the renewal path itself, so the two can never disagree. @@ -207,6 +230,7 @@ pub struct SubscriptionHandler { node: Arc, onchain: Arc, revolut: Option>, + swap: Option>, pe: PricingEngine, vm_provisioner: VmProvisioner, @@ -235,6 +259,7 @@ impl SubscriptionHandler { let max_prepay_days = settings.max_prepay_days; Ok(Self { revolut, + swap: None, pe: PricingEngine::new(db.clone(), rates, vat), vm_provisioner: VmProvisioner::new(settings, db.clone()), ip_range_provisioner: IpRangeProvisioner::new(db.clone(), tx.clone()), @@ -248,6 +273,11 @@ impl SubscriptionHandler { }) } + pub fn with_swap(mut self, swap: Option>) -> Self { + self.swap = swap; + self + } + pub fn work_commander(&self) -> Arc { self.tx.clone() } @@ -288,6 +318,46 @@ impl SubscriptionHandler { Ok(resp.address) } + async fn new_fixedfloat_swap( + &self, + company_id: u64, + mode: &RenewMode, + currency: Currency, + amount_msat: u64, + memo: String, + label: String, + ) -> Result { + ensure!( + currency == Currency::BTC, + "Cannot create FixedFloat payments for non-BTC currency" + ); + let RenewMode::FixedFloat { coin } = mode else { + bail!("A coin is required for fixedfloat payments"); + }; + let config = self + .db + .get_payment_method_config_for_company(company_id, PaymentMethod::FixedFloat) + .await + .context("FixedFloat payments are not configured")?; + ensure!(config.enabled, "FixedFloat payments are not enabled"); + ensure!( + fixedfloat_coin_accepted(&config, *coin), + "{} is not accepted for FixedFloat payments", + coin + ); + let swap = self + .swap + .clone() + .context("FixedFloat payments are not configured")?; + let receive_address = self.new_onchain_address(amount_msat, memo, label).await?; + swap.create_order(SwapOrderRequest { + coin: coin.to_string(), + receive_sats: amount_msat / 1000, + receive_address, + }) + .await + } + #[cfg(test)] pub(crate) fn set_revolut_for_test(&mut self, r: Arc) { self.revolut = Some(r); @@ -1346,6 +1416,9 @@ impl SubscriptionHandler { .await?; (info.external_id, info.raw_data) } + RenewMode::FixedFloat { .. } => { + bail!("A coin cannot be used with this payment method") + } RenewMode::Interactive { save_card } => { self.create_revolut_order( rev, @@ -1460,6 +1533,67 @@ impl SubscriptionHandler { refunded_payment_id: None, } } + PaymentMethod::FixedFloat => { + let desc = match payment_type { + SubscriptionPaymentType::Purchase => { + format!("Subscription purchase: {}", subscription.name) + } + SubscriptionPaymentType::Renewal => { + format!("Subscription renewal: {}", subscription.name) + } + SubscriptionPaymentType::Upgrade => { + format!("Subscription upgrade: {}", subscription.name) + } + SubscriptionPaymentType::Refund => { + bail!("Cannot create an invoice for a refund") + } + }; + let receive_amount = + round_msat_to_sat(converted_amount + tax + processing_fee - credit_covered); + let new_id: [u8; 32] = rand::random(); + let order = self + .new_fixedfloat_swap( + subscription.company_id, + mode, + converted_currency, + receive_amount, + desc, + hex::encode(new_id), + ) + .await?; + + SubscriptionPayment { + renewal_source: Some(source), + id: new_id.to_vec(), + subscription_id, + user_id: subscription.user_id, + created: Utc::now(), + expires: order.expires, + amount: converted_amount, + currency: converted_currency.to_string(), + payment_method: method, + payment_type, + external_data: order.data.to_json()?.into(), + external_id: None, + is_paid: false, + rate, + time_value: if time_value > 0 { + Some(time_value) + } else { + None + }, + metadata: None, + tax, + processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + refunded_payment_id: None, + } + } PaymentMethod::Paypal => bail!("PayPal not implemented"), PaymentMethod::Stripe => bail!("Stripe not implemented"), }) @@ -1967,6 +2101,9 @@ impl SubscriptionHandler { .await?; (info.external_id, info.raw_data) } + RenewMode::FixedFloat { .. } => { + bail!("A coin cannot be used with this payment method") + } RenewMode::Interactive { save_card } => { self.create_revolut_order( rev, @@ -2059,6 +2196,49 @@ impl SubscriptionHandler { refunded_payment_id: None, } } + PaymentMethod::FixedFloat => { + let receive_amount = + round_msat_to_sat(p.amount + p.tax + p.processing_fee - credit_covered); + let new_id: [u8; 32] = rand::random(); + let company_id = self.db.get_subscription(subscription_id).await?.company_id; + let order = self + .new_fixedfloat_swap( + company_id, + mode, + p.currency, + receive_amount, + desc, + hex::encode(new_id), + ) + .await?; + SubscriptionPayment { + renewal_source: Some(RenewalSource::Manual), + id: new_id.to_vec(), + subscription_id, + user_id, + created: Utc::now(), + expires: order.expires, + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: order.data.to_json()?.into(), + external_id: None, + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + refunded_payment_id: None, + } + } PaymentMethod::Paypal => bail!("PayPal not implemented"), PaymentMethod::Stripe => { bail!("Stripe payment creation not yet implemented") @@ -2925,6 +3105,236 @@ pub(crate) mod revolut_offline_tests { assert_eq!(payment.external_data.as_str(), addresses[0]); } + async fn fixedfloat_setup( + currency: &str, + with_swap: bool, + coins: &[&str], + enabled: bool, + ) -> ( + SubscriptionHandler, + Arc, + Arc, + u64, + ) { + use lnvps_db::{FixedFloatProviderConfig, PaymentMethodConfig, ProviderConfig}; + let db = Arc::new(MockDb::default()); + let onchain = Arc::new(MockOnChainProvider::default()); + let swap = Arc::new(crate::mocks::MockSwapProvider::default()); + let user_id = db.upsert_user(&[23u8; 32]).await.unwrap(); + let mut config = PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::FixedFloat, + "FixedFloat".to_string(), + enabled, + ProviderConfig::FixedFloat(FixedFloatProviderConfig { + api_key: "k".to_string(), + api_secret: "s".to_string(), + url: None, + refcode: None, + afftax: None, + }), + ); + config.supported_currencies = + lnvps_db::CommaSeparated::new(coins.iter().map(|c| c.to_string()).collect()); + db.insert_payment_method_config(&config).await.unwrap(); + let (sub_id, _items) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "s".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: true, + is_setup: true, + currency: currency.to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: LineItemType::IpRange, + name: "hosting".to_string(), + description: None, + amount: 100_000, + setup_amount: 0, + configuration: None, + }], + ) + .await + .unwrap(); + let rates = Arc::new(MockExchangeRate::default()); + rates + .set_rate( + lnvps_api_common::Ticker::btc_rate("EUR").unwrap(), + 100_000.0, + ) + .await; + let handler = SubscriptionHandler::new( + mock_settings(), + db, + Arc::new(MockNode::default()), + onchain.clone(), + None, + rates, + VatClient::new(), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + ) + .unwrap() + .with_swap(if with_swap { + Some(swap.clone() as Arc) + } else { + None + }); + (handler, onchain, swap, sub_id) + } + + async fn renew_with_coin( + sub: &SubscriptionHandler, + sub_id: u64, + coin: Currency, + ) -> Result { + sub.renew_subscription_with_mode( + sub_id, + PaymentMethod::FixedFloat, + 1, + RenewMode::FixedFloat { coin }, + ) + .await + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_creates_swap_to_onchain_address() { + let (sub, onchain, swap, sub_id) = fixedfloat_setup("BTC", true, &[], true).await; + + let payment = renew_with_coin(&sub, sub_id, Currency::XMR).await.unwrap(); + + assert_eq!(payment.payment_method, PaymentMethod::FixedFloat); + assert!(!payment.is_paid); + assert_eq!(payment.external_id, None); + let data = crate::swap::SwapData::parse(payment.external_data.as_str()).unwrap(); + let addresses = onchain.addresses.lock().await; + assert_eq!(addresses.len(), 1); + assert_eq!(data.coin, "XMR"); + assert_eq!(data.receive_address, addresses[0]); + assert_eq!(data.deposit_address, "4mockxmr00000000"); + let requests = swap.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].coin, "XMR"); + assert_eq!(requests[0].receive_address, addresses[0]); + assert_eq!( + requests[0].receive_sats * 1000, + payment.amount + payment.tax + payment.processing_fee + ); + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_orders_the_requested_coin() { + let (sub, _onchain, swap, sub_id) = + fixedfloat_setup("BTC", true, &["XMR", "LTC"], true).await; + + renew_with_coin(&sub, sub_id, Currency::LTC).await.unwrap(); + + assert_eq!(swap.requests.lock().await[0].coin, "LTC"); + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_requires_swap_provider() { + 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()); + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_rejects_a_coin_the_config_does_not_list() { + let (sub, onchain, swap, sub_id) = fixedfloat_setup("BTC", true, &["XMR"], true).await; + + let err = renew_with_coin(&sub, sub_id, Currency::ETH) + .await + .unwrap_err(); + + assert!(err.to_string().contains("ETH is not accepted"), "{err}"); + assert!(onchain.addresses.lock().await.is_empty()); + assert!(swap.requests.lock().await.is_empty()); + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_rejects_a_disabled_config() { + let (sub, onchain, _swap, sub_id) = fixedfloat_setup("BTC", true, &[], false).await; + + assert!(renew_with_coin(&sub, sub_id, Currency::XMR).await.is_err()); + assert!(onchain.addresses.lock().await.is_empty()); + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_requires_a_coin() { + let (sub, onchain, _swap, sub_id) = fixedfloat_setup("BTC", true, &[], true).await; + + let err = sub + .renew_subscription(sub_id, PaymentMethod::FixedFloat, 1) + .await + .unwrap_err(); + + assert!(err.to_string().contains("coin is required"), "{err}"); + assert!(onchain.addresses.lock().await.is_empty()); + } + + #[tokio::test] + async fn renew_subscription_fixedfloat_for_fiat_subscription_is_btc() { + let (sub, _onchain, swap, sub_id) = fixedfloat_setup("EUR", true, &[], true).await; + + let payment = renew_with_coin(&sub, sub_id, Currency::XMR).await.unwrap(); + + assert_eq!(payment.currency, "BTC"); + assert_eq!(swap.requests.lock().await.len(), 1); + } + + #[test] + fn fixedfloat_coin_accepted_follows_the_config_list() { + use lnvps_db::{FixedFloatProviderConfig, PaymentMethodConfig, ProviderConfig}; + let mut config = PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::FixedFloat, + "FixedFloat".to_string(), + true, + ProviderConfig::FixedFloat(FixedFloatProviderConfig { + api_key: String::new(), + api_secret: String::new(), + url: None, + refcode: None, + afftax: None, + }), + ); + for coin in [ + Currency::XMR, + Currency::LTC, + Currency::ETH, + Currency::USDTTRC, + ] { + assert!( + fixedfloat_coin_accepted(&config, coin), + "empty list accepts {coin}" + ); + } + assert!(!fixedfloat_coin_accepted(&config, Currency::BTC)); + assert!(!fixedfloat_coin_accepted(&config, Currency::EUR)); + + config.supported_currencies = + lnvps_db::CommaSeparated::new(vec!["xmr".to_string(), "USDTTRC".to_string()]); + assert!(fixedfloat_coin_accepted(&config, Currency::XMR)); + assert!(fixedfloat_coin_accepted(&config, Currency::USDTTRC)); + assert!(!fixedfloat_coin_accepted(&config, Currency::LTC)); + assert!(!fixedfloat_coin_accepted(&config, Currency::BTC)); + } + #[tokio::test] async fn test_default_revolut_payment_method_selection() { let (db, sub, user_id, _sub_id) = setup(true).await; diff --git a/lnvps_api/src/swap/fixedfloat.rs b/lnvps_api/src/swap/fixedfloat.rs new file mode 100644 index 00000000..d7752580 --- /dev/null +++ b/lnvps_api/src/swap/fixedfloat.rs @@ -0,0 +1,316 @@ +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use lnvps_db::FixedFloatProviderConfig; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::Sha256; + +use crate::swap::{SwapData, SwapOrder, SwapOrderRequest, SwapProvider}; + +const DEFAULT_URL: &str = "https://ff.io/api/v2"; + +pub struct FixedFloat { + client: reqwest::Client, + url: String, + api_key: String, + api_secret: String, + refcode: Option, + afftax: Option, +} + +#[derive(Deserialize)] +struct Envelope { + code: i64, + #[serde(default)] + msg: String, + data: Option, +} + +#[derive(Deserialize)] +struct CreatedOrder { + id: String, + token: String, + time: OrderTime, + from: OrderLeg, +} + +#[derive(Deserialize)] +struct OrderTime { + expiration: i64, +} + +#[derive(Deserialize)] +struct OrderLeg { + address: String, + amount: String, +} + +impl FixedFloat { + pub fn new(config: &FixedFloatProviderConfig) -> Self { + Self { + client: reqwest::Client::new(), + url: config + .url + .clone() + .unwrap_or_else(|| DEFAULT_URL.to_string()), + api_key: config.api_key.clone(), + api_secret: config.api_secret.clone(), + refcode: config.refcode.clone(), + afftax: config.afftax, + } + } + + fn sign(&self, body: &str) -> Result { + let mut mac = Hmac::::new_from_slice(self.api_secret.as_bytes())?; + mac.update(body.as_bytes()); + Ok(hex::encode(mac.finalize().into_bytes())) + } + + async fn call Deserialize<'de>>(&self, method: &str, params: Value) -> Result { + let body = serde_json::to_string(¶ms)?; + let res = self + .client + .post(format!("{}/{}", self.url.trim_end_matches('/'), method)) + .header("Accept", "application/json") + .header("Content-Type", "application/json; charset=UTF-8") + .header("X-API-KEY", &self.api_key) + .header("X-API-SIGN", self.sign(&body)?) + .body(body) + .send() + .await? + .error_for_status()?; + let envelope: Envelope = res.json().await.context("parsing FixedFloat response")?; + if envelope.code != 0 { + bail!("FixedFloat {}: {}", envelope.code, envelope.msg); + } + let data = envelope.data.context("FixedFloat response had no data")?; + Ok(serde_json::from_value(data)?) + } + + fn create_params(&self, req: &SwapOrderRequest) -> Value { + let mut params = json!({ + "type": "float", + "fromCcy": req.coin, + "toCcy": "BTC", + "direction": "to", + "amount": req.receive_sats as f64 / 100_000_000.0, + "toAddress": req.receive_address, + }); + if let (Some(refcode), Some(afftax)) = (&self.refcode, self.afftax) { + params["refcode"] = json!(refcode); + params["afftax"] = json!(afftax); + } else if let Some(refcode) = &self.refcode { + params["refcode"] = json!(refcode); + } + params + } +} + +#[async_trait] +impl SwapProvider for FixedFloat { + async fn create_order(&self, req: SwapOrderRequest) -> Result { + let order: CreatedOrder = self.call("create", self.create_params(&req)).await?; + let expires = DateTime::::from_timestamp(order.time.expiration, 0) + .context("FixedFloat order has an invalid expiration")?; + Ok(SwapOrder { + data: SwapData { + coin: req.coin, + order_id: order.id, + order_token: order.token, + deposit_address: order.from.address, + deposit_amount: order.from.amount, + receive_address: req.receive_address, + }, + expires, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn client(url: String, refcode: Option<&str>, afftax: Option) -> FixedFloat { + FixedFloat::new(&FixedFloatProviderConfig { + api_key: "key".to_string(), + api_secret: "secret".to_string(), + url: Some(url), + refcode: refcode.map(str::to_string), + afftax, + }) + } + + fn request() -> SwapOrderRequest { + SwapOrderRequest { + coin: "XMR".to_string(), + receive_sats: 150_000, + receive_address: "bc1qdest".to_string(), + } + } + + fn created() -> Value { + json!({ + "code": 0, + "msg": "OK", + "data": { + "id": "ABC123", + "token": "tok", + "time": {"expiration": 1_900_000_000}, + "from": {"address": "4Axmr", "amount": "0.0123"}, + "to": {"address": "bc1qdest", "amount": "0.0015"} + } + }) + } + + #[test] + fn default_url_is_the_public_api() { + let ff = FixedFloat::new(&FixedFloatProviderConfig { + api_key: String::new(), + api_secret: String::new(), + url: None, + refcode: None, + afftax: None, + }); + assert_eq!(ff.url, DEFAULT_URL); + } + + #[test] + fn signature_is_hmac_sha256_hex() { + let ff = client("http://x".to_string(), None, None); + let sig = ff.sign("{}").unwrap(); + assert_eq!(sig.len(), 64); + assert_eq!(sig, expected_sign("secret", "{}")); + assert_ne!(sig, expected_sign("other", "{}")); + } + + fn expected_sign(secret: &str, body: &str) -> String { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + } + + #[test] + fn params_ask_for_btc_out_to_our_address() { + let ff = client("http://x".to_string(), None, None); + let params = ff.create_params(&request()); + assert_eq!(params["fromCcy"], "XMR"); + assert_eq!(params["toCcy"], "BTC"); + assert_eq!(params["direction"], "to"); + assert_eq!(params["type"], "float"); + assert_eq!(params["amount"], 0.0015); + assert_eq!(params["toAddress"], "bc1qdest"); + assert!(params.get("refcode").is_none()); + assert!(params.get("afftax").is_none()); + } + + #[test] + fn params_carry_affiliate_fields_when_configured() { + let ff = client("http://x".to_string(), Some("code"), Some(0.2)); + let params = ff.create_params(&request()); + assert_eq!(params["refcode"], "code"); + assert!(params["afftax"].as_f64().unwrap() - 0.2 < 1e-6); + + let ff = client("http://x".to_string(), Some("code"), None); + let params = ff.create_params(&request()); + assert_eq!(params["refcode"], "code"); + assert!(params.get("afftax").is_none()); + } + + #[tokio::test] + async fn create_order_signs_and_parses() -> Result<()> { + let server = MockServer::start().await; + let ff = client(server.uri(), None, None); + let body = ff.create_params(&request()); + let sign = expected_sign("secret", &serde_json::to_string(&body)?); + Mock::given(method("POST")) + .and(path("/create")) + .and(header("X-API-KEY", "key")) + .and(header("X-API-SIGN", sign.as_str())) + .and(body_json(&body)) + .respond_with(ResponseTemplate::new(200).set_body_json(created())) + .expect(1) + .mount(&server) + .await; + + let order = ff.create_order(request()).await?; + assert_eq!(order.data.order_id, "ABC123"); + assert_eq!(order.data.order_token, "tok"); + assert_eq!(order.data.deposit_address, "4Axmr"); + assert_eq!(order.data.deposit_amount, "0.0123"); + assert_eq!(order.data.receive_address, "bc1qdest"); + assert_eq!(order.expires.timestamp(), 1_900_000_000); + Ok(()) + } + + #[tokio::test] + async fn api_error_code_is_surfaced() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json( + json!({"code": 301, "msg": "Amount less than limit", "data": null}), + ), + ) + .mount(&server) + .await; + let err = client(server.uri(), None, None) + .create_order(request()) + .await + .unwrap_err(); + assert!(err.to_string().contains("Amount less than limit"), "{err}"); + } + + #[tokio::test] + async fn http_error_is_surfaced() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + assert!( + client(server.uri(), None, None) + .create_order(request()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn missing_data_is_an_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"code": 0, "msg": "", "data": null})), + ) + .mount(&server) + .await; + assert!( + client(server.uri(), None, None) + .create_order(request()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn invalid_expiration_is_an_error() { + let server = MockServer::start().await; + let mut body = created(); + body["data"]["time"]["expiration"] = json!(i64::MAX); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + assert!( + client(server.uri(), None, None) + .create_order(request()) + .await + .is_err() + ); + } +} diff --git a/lnvps_api/src/swap/mod.rs b/lnvps_api/src/swap/mod.rs new file mode 100644 index 00000000..c66a3d1a --- /dev/null +++ b/lnvps_api/src/swap/mod.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +mod fixedfloat; + +pub use fixedfloat::FixedFloat; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SwapOrderRequest { + pub coin: String, + pub receive_sats: u64, + pub receive_address: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SwapOrder { + pub data: SwapData, + 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, +} + +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)?) + } +} + +#[async_trait] +pub trait SwapProvider: Send + Sync { + async fn create_order(&self, req: SwapOrderRequest) -> Result; +} diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 23a66dd2..cb1fc83f 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -71,7 +71,10 @@ fn get_host_info_path_for_arch(arch: CpuArch) -> Option { pub(crate) fn payment_blocks_unpaid_deletion(p: &SubscriptionPayment, now: DateTime) -> bool { !p.is_paid && (p.expires > now - || (p.payment_method == PaymentMethod::OnChain && p.external_id.is_some())) + || (matches!( + p.payment_method, + PaymentMethod::OnChain | PaymentMethod::FixedFloat + ) && p.external_id.is_some())) } /// Why a detected deposit can no longer confirm. diff --git a/lnvps_api_admin/src/admin/companies.rs b/lnvps_api_admin/src/admin/companies.rs index 7f24c6d9..23037ef1 100644 --- a/lnvps_api_admin/src/admin/companies.rs +++ b/lnvps_api_admin/src/admin/companies.rs @@ -92,8 +92,8 @@ async fn admin_create_company( } else { // Validate currency by parsing it with the Currency enum match currency.parse::() { - Ok(_) => {} // Valid currency - Err(_) => { + Ok(c) if !c.is_crypto() || c == payments_rs::currency::Currency::BTC => {} + _ => { return ApiData::err( "Invalid currency code. Supported currencies: EUR, USD, GBP, CAD, CHF, AUD, JPY, BTC", ); @@ -264,8 +264,8 @@ async fn admin_update_company( } else { // Validate currency by parsing it with the Currency enum match currency.parse::() { - Ok(_) => {} // Valid currency - Err(_) => { + Ok(c) if !c.is_crypto() || c == payments_rs::currency::Currency::BTC => {} + _ => { return ApiData::err( "Invalid currency code. Supported currencies: EUR, USD, GBP, CAD, CHF, AUD, JPY, BTC", ); diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index e5120221..f671392a 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -3469,6 +3469,8 @@ pub enum AdminPaymentMethod { Paypal, Stripe, OnChain, + #[serde(rename = "fixedfloat")] + FixedFloat, } impl From for AdminPaymentMethod { @@ -3479,6 +3481,7 @@ impl From for AdminPaymentMethod { PaymentMethod::Paypal => AdminPaymentMethod::Paypal, PaymentMethod::Stripe => AdminPaymentMethod::Stripe, PaymentMethod::OnChain => AdminPaymentMethod::OnChain, + PaymentMethod::FixedFloat => AdminPaymentMethod::FixedFloat, } } } @@ -4521,6 +4524,7 @@ pub enum AdminPaymentMethodType { Paypal, Stripe, OnChain, + FixedFloat, } impl From for AdminPaymentMethodType { @@ -4531,6 +4535,7 @@ impl From for AdminPaymentMethodType { lnvps_db::PaymentMethod::Paypal => AdminPaymentMethodType::Paypal, lnvps_db::PaymentMethod::Stripe => AdminPaymentMethodType::Stripe, lnvps_db::PaymentMethod::OnChain => AdminPaymentMethodType::OnChain, + lnvps_db::PaymentMethod::FixedFloat => AdminPaymentMethodType::FixedFloat, } } } @@ -4543,6 +4548,7 @@ impl From for lnvps_db::PaymentMethod { AdminPaymentMethodType::Paypal => lnvps_db::PaymentMethod::Paypal, AdminPaymentMethodType::Stripe => lnvps_db::PaymentMethod::Stripe, AdminPaymentMethodType::OnChain => lnvps_db::PaymentMethod::OnChain, + AdminPaymentMethodType::FixedFloat => lnvps_db::PaymentMethod::FixedFloat, } } } @@ -4568,6 +4574,16 @@ pub struct SanitizedOnChainConfig { pub min_confirmations: u32, } +/// Sanitized FixedFloat config (hides api_key and api_secret) +#[derive(Serialize)] +pub struct SanitizedFixedFloatConfig { + pub url: Option, + pub refcode: Option, + pub afftax: Option, + pub has_api_key: bool, + pub has_api_secret: bool, +} + /// Sanitized Revolut config (hides token and webhook_secret) #[derive(Serialize)] pub struct SanitizedRevolutConfig { @@ -4608,6 +4624,8 @@ pub enum SanitizedProviderConfig { Stripe(SanitizedStripeConfig), Paypal(SanitizedPaypalConfig), OnChain(SanitizedOnChainConfig), + #[serde(rename = "fixedfloat")] + FixedFloat(SanitizedFixedFloatConfig), } impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { @@ -4656,6 +4674,15 @@ impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { min_confirmations: cfg.min_confirmations, }) } + lnvps_db::ProviderConfig::FixedFloat(cfg) => { + SanitizedProviderConfig::FixedFloat(SanitizedFixedFloatConfig { + url: cfg.url.clone(), + refcode: cfg.refcode.clone(), + afftax: cfg.afftax, + has_api_key: !cfg.api_key.is_empty(), + has_api_secret: !cfg.api_secret.is_empty(), + }) + } } } } @@ -4736,6 +4763,39 @@ pub struct CreatePaymentMethodConfigRequest { pub supported_currencies: Option>, } +/// Validate the currency codes an admin sets on a payment method config. +/// +/// The `fixedfloat` method lists the coins a customer can pay with, which must be +/// cryptocurrencies other than BTC. Every other method lists billing currencies, +/// which cannot be a coin because coins have no exchange rate to price against. +pub fn validate_supported_currencies( + method: lnvps_db::PaymentMethod, + currencies: &[String], +) -> anyhow::Result<()> { + use payments_rs::currency::Currency; + for code in currencies { + let currency = code + .trim() + .parse::() + .map_err(|_| anyhow!("Unknown currency code: {}", code))?; + let is_coin = currency.is_crypto() && currency != Currency::BTC; + if method == lnvps_db::PaymentMethod::FixedFloat { + if !is_coin { + return Err(anyhow!( + "{} is not a coin. The FixedFloat payment method accepts XMR, LTC, ETH and USDTTRC", + code + )); + } + } else if is_coin { + return Err(anyhow!( + "{} can only be listed on the FixedFloat payment method", + code + )); + } + } + Ok(()) +} + impl CreatePaymentMethodConfigRequest { pub fn to_payment_method_config(&self) -> anyhow::Result { if self.name.trim().is_empty() { @@ -4775,6 +4835,7 @@ impl CreatePaymentMethodConfigRequest { .as_ref() .map(|s| s.trim().to_uppercase()); if let Some(currencies) = &self.supported_currencies { + validate_supported_currencies(payment_config.payment_method, currencies)?; payment_config.supported_currencies = lnvps_db::CommaSeparated::new( currencies.iter().map(|s| s.trim().to_uppercase()).collect(), ); @@ -4880,6 +4941,28 @@ pub struct PartialOnChainConfig { pub min_confirmations: Option, } +/// Partial FixedFloat config for updates +#[derive(Deserialize)] +pub struct PartialFixedFloatConfig { + pub api_key: Option, + pub api_secret: Option, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub url: Option>, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub refcode: Option>, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub afftax: Option>, +} + /// Partial provider configuration for updates - only provided fields will be updated #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -4890,6 +4973,8 @@ pub enum PartialProviderConfig { Paypal(PartialPaypalConfig), #[serde(rename = "onchain")] OnChain(PartialOnChainConfig), + #[serde(rename = "fixedfloat")] + FixedFloat(PartialFixedFloatConfig), } impl PartialProviderConfig { @@ -4973,6 +5058,19 @@ impl PartialProviderConfig { .unwrap_or(existing.min_confirmations), })) } + (PartialProviderConfig::FixedFloat(partial), ProviderConfig::FixedFloat(existing)) => { + Ok(ProviderConfig::FixedFloat( + lnvps_db::FixedFloatProviderConfig { + api_key: partial.api_key.unwrap_or_else(|| existing.api_key.clone()), + api_secret: partial + .api_secret + .unwrap_or_else(|| existing.api_secret.clone()), + url: partial.url.unwrap_or_else(|| existing.url.clone()), + refcode: partial.refcode.unwrap_or_else(|| existing.refcode.clone()), + afftax: partial.afftax.unwrap_or(existing.afftax), + }, + )) + } _ => Err(anyhow!( "Cannot change provider type during update. Create a new config instead." )), @@ -4987,6 +5085,7 @@ impl PartialProviderConfig { PartialProviderConfig::Stripe(_) => "stripe", PartialProviderConfig::Paypal(_) => "paypal", PartialProviderConfig::OnChain(_) => "onchain", + PartialProviderConfig::FixedFloat(_) => "fixedfloat", } } } @@ -5050,6 +5149,69 @@ pub struct AdminUpdateUserPaymentMethodRequest { #[cfg(test)] mod tests { + use lnvps_db::PaymentMethod; + + fn codes(c: &[&str]) -> Vec { + c.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn fixedfloat_method_wire_names() { + assert_eq!( + serde_json::to_string(&AdminPaymentMethod::FixedFloat).unwrap(), + "\"fixedfloat\"" + ); + assert_eq!( + serde_json::to_string(&AdminPaymentMethodType::FixedFloat).unwrap(), + "\"fixedfloat\"" + ); + assert_eq!( + lnvps_db::PaymentMethod::from(AdminPaymentMethodType::FixedFloat), + lnvps_db::PaymentMethod::FixedFloat + ); + assert_eq!( + AdminPaymentMethod::from(lnvps_db::PaymentMethod::FixedFloat), + AdminPaymentMethod::FixedFloat + ); + } + + #[test] + fn fixedfloat_method_lists_only_coins() { + assert!( + validate_supported_currencies( + PaymentMethod::FixedFloat, + &codes(&["XMR", "ltc", "ETH", "USDTTRC"]) + ) + .is_ok() + ); + assert!(validate_supported_currencies(PaymentMethod::FixedFloat, &[]).is_ok()); + for bad in ["BTC", "EUR", "USD", "DOGE"] { + assert!( + validate_supported_currencies(PaymentMethod::FixedFloat, &codes(&[bad])).is_err(), + "{bad}" + ); + } + } + + #[test] + fn other_methods_never_list_coins() { + for method in [ + PaymentMethod::Lightning, + PaymentMethod::OnChain, + PaymentMethod::Revolut, + PaymentMethod::Stripe, + PaymentMethod::Paypal, + ] { + assert!(validate_supported_currencies(method, &codes(&["EUR", "BTC", "usd"])).is_ok()); + for coin in ["XMR", "LTC", "ETH", "USDTTRC"] { + assert!( + validate_supported_currencies(method, &codes(&[coin])).is_err(), + "{method} {coin}" + ); + } + assert!(validate_supported_currencies(method, &codes(&["DOGE"])).is_err()); + } + } use super::*; /// Per-disk usage is attached to each configured disk, and a disk the diff --git a/lnvps_api_admin/src/admin/payment_methods.rs b/lnvps_api_admin/src/admin/payment_methods.rs index a5d6af56..8b61ad8c 100644 --- a/lnvps_api_admin/src/admin/payment_methods.rs +++ b/lnvps_api_admin/src/admin/payment_methods.rs @@ -172,6 +172,7 @@ async fn admin_update_payment_method( // Update supported currencies if provided if let Some(currencies) = &request.supported_currencies { + crate::admin::model::validate_supported_currencies(config.payment_method, currencies)?; config.supported_currencies = lnvps_db::CommaSeparated::new( currencies.iter().map(|s| s.trim().to_uppercase()).collect(), ); diff --git a/lnvps_api_common/src/exchange.rs b/lnvps_api_common/src/exchange.rs index 5fc46274..dfcd07ad 100644 --- a/lnvps_api_common/src/exchange.rs +++ b/lnvps_api_common/src/exchange.rs @@ -106,10 +106,7 @@ impl TickerRate { /// Number of smallest units per standard unit for a currency, matching /// `payments_rs` (BTC = 1e11 milli-sats, all fiat = 100 cents). fn scale(currency: Currency) -> f64 { - match currency { - Currency::BTC => 1.0e11, - _ => 100.0, - } + currency.scale() } pub fn passthrough(currency: Currency) -> Self { @@ -525,6 +522,19 @@ struct MempoolRates { #[cfg(test)] mod tests { + #[test] + fn scale_matches_payments_rs_for_every_currency() { + for c in Currency::ALL { + let expected = if c.is_crypto() { 1.0e11 } else { 100.0 }; + assert_eq!(TickerRate::scale(c), expected, "{c}"); + assert_eq!( + CurrencyAmount::from_f32(c, 1.0).value() as f64, + expected, + "{c}" + ); + } + } + use super::*; const RATE: f32 = 95_000.0; diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index de140409..4d0599bc 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -733,6 +733,10 @@ pub enum ApiCurrency { CHF, AUD, JPY, + XMR, + LTC, + ETH, + USDTTRC, } impl From for ApiCurrency { @@ -746,6 +750,10 @@ impl From for ApiCurrency { Currency::CHF => ApiCurrency::CHF, Currency::AUD => ApiCurrency::AUD, Currency::JPY => ApiCurrency::JPY, + Currency::XMR => ApiCurrency::XMR, + Currency::LTC => ApiCurrency::LTC, + Currency::ETH => ApiCurrency::ETH, + Currency::USDTTRC => ApiCurrency::USDTTRC, } } } @@ -761,6 +769,10 @@ impl From for Currency { ApiCurrency::CHF => Currency::CHF, ApiCurrency::AUD => Currency::AUD, ApiCurrency::JPY => Currency::JPY, + ApiCurrency::XMR => Currency::XMR, + ApiCurrency::LTC => Currency::LTC, + ApiCurrency::ETH => Currency::ETH, + ApiCurrency::USDTTRC => Currency::USDTTRC, } } } @@ -1340,6 +1352,15 @@ impl ApiSubscriptionLineItemResource { #[cfg(test)] mod tests { + #[test] + fn api_currency_round_trips_every_currency() { + for c in Currency::ALL { + let api = ApiCurrency::from(c); + assert_eq!(Currency::from(api), c); + assert_eq!(serde_json::to_string(&api).unwrap(), format!("\"{}\"", c)); + } + } + use super::*; #[test] diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 3248c776..0f7e6c20 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -1771,7 +1771,9 @@ impl PricingEngine { method: PaymentMethod, ) -> Result { Ok(match (list_price.currency(), method) { - (c, PaymentMethod::Lightning | PaymentMethod::OnChain) if c != Currency::BTC => { + (c, PaymentMethod::Lightning | PaymentMethod::OnChain | PaymentMethod::FixedFloat) + if c != Currency::BTC => + { // convert to BTC if price is not already in bitcoin let ticker = self.get_ticker(Currency::BTC, c).await?; let mut converted = ticker.convert_with_rate(list_price)?; @@ -1779,7 +1781,9 @@ impl PricingEngine { converted.amount = round_to_sat(converted.amount); converted } - (c, PaymentMethod::Lightning | PaymentMethod::OnChain) if c == Currency::BTC => { + (c, PaymentMethod::Lightning | PaymentMethod::OnChain | PaymentMethod::FixedFloat) + if c == Currency::BTC => + { // pass-through price as BTC, rounded to nearest satoshi ConvertedCurrencyAmount { amount: round_to_sat(list_price), diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 64dc5169..6e19ef59 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -2563,6 +2563,8 @@ pub enum PaymentMethod { Stripe, /// On-chain Bitcoin payments OnChain, + /// A cryptocurrency (XMR, LTC, ETH, USDTTRC) paid through a third-party swap into our on-chain Bitcoin wallet + FixedFloat, } #[derive(Type, Clone, Copy, Debug, Default, PartialEq)] @@ -2581,6 +2583,7 @@ impl Display for PaymentMethod { PaymentMethod::Paypal => write!(f, "PayPal"), PaymentMethod::Stripe => write!(f, "Stripe"), PaymentMethod::OnChain => write!(f, "OnChain"), + PaymentMethod::FixedFloat => write!(f, "FixedFloat"), } } } @@ -2595,6 +2598,7 @@ impl FromStr for PaymentMethod { "paypal" => Ok(PaymentMethod::Paypal), "stripe" => Ok(PaymentMethod::Stripe), "onchain" => Ok(PaymentMethod::OnChain), + "fixedfloat" => Ok(PaymentMethod::FixedFloat), _ => bail!("Unknown payment method: {}", s), } } @@ -4235,6 +4239,7 @@ mod tests { ("paypal", PaymentMethod::Paypal), ("stripe", PaymentMethod::Stripe), ("onchain", PaymentMethod::OnChain), + ("fixedfloat", PaymentMethod::FixedFloat), ] { assert_eq!(PaymentMethod::from_str(s).unwrap(), m); } @@ -4285,6 +4290,45 @@ mod tests { assert_eq!(pmc.provider_type, "onchain"); } + #[test] + fn test_provider_config_fixedfloat() { + let json = serde_json::json!({ + "type": "fixedfloat", + "api_key": "k", + "api_secret": "s" + }); + let cfg: ProviderConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.provider_type(), "fixedfloat"); + assert_eq!(cfg.payment_method(), PaymentMethod::FixedFloat); + let ff = cfg.as_fixedfloat().unwrap(); + assert_eq!((ff.api_key.as_str(), ff.api_secret.as_str()), ("k", "s")); + assert!(ff.url.is_none() && ff.refcode.is_none() && ff.afftax.is_none()); + assert!(cfg.as_onchain().is_none()); + assert!( + ProviderConfig::OnChain(OnChainProviderConfig { + url: String::new(), + cert_path: "".into(), + macaroon_path: "".into(), + address_type: OnChainAddressType::default(), + account: None, + min_confirmations: 1, + }) + .as_fixedfloat() + .is_none() + ); + let mut pmc = PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::FixedFloat, + "xmr".to_string(), + true, + cfg, + ); + assert_eq!(pmc.provider_type, "fixedfloat"); + let parsed = pmc.get_provider_config().unwrap(); + pmc.set_provider_config(parsed); + assert_eq!(pmc.provider_type, "fixedfloat"); + } + #[test] fn test_onchain_provider_config_defaults() { // Old configs without the new fields deserialize with defaults @@ -4498,6 +4542,24 @@ pub struct PaypalProviderConfig { pub mode: String, } +/// FixedFloat swap provider configuration (any supported coin to BTC, settling to the on-chain wallet) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FixedFloatProviderConfig { + /// API key + pub api_key: String, + /// API secret used to sign requests + pub api_secret: String, + /// Base URL override, defaults to the public FixedFloat API + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Affiliate code credited for orders + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refcode: Option, + /// Affiliate commission as a percentage of the swap, added to the customer's rate + #[serde(default, skip_serializing_if = "Option::is_none")] + pub afftax: Option, +} + /// Typed provider configuration enum /// Wraps all supported payment provider configurations #[derive(Clone, Debug, Serialize, Deserialize)] @@ -4514,6 +4576,9 @@ pub enum ProviderConfig { /// On-chain Bitcoin payment configuration (LND wallet backend) #[serde(rename = "onchain")] OnChain(OnChainProviderConfig), + /// Cryptocurrency payments swapped to Bitcoin through FixedFloat + #[serde(rename = "fixedfloat")] + FixedFloat(FixedFloatProviderConfig), } impl ProviderConfig { @@ -4525,6 +4590,7 @@ impl ProviderConfig { ProviderConfig::Stripe(_) => "stripe", ProviderConfig::Paypal(_) => "paypal", ProviderConfig::OnChain(_) => "onchain", + ProviderConfig::FixedFloat(_) => "fixedfloat", } } @@ -4536,6 +4602,7 @@ impl ProviderConfig { ProviderConfig::Stripe(_) => PaymentMethod::Stripe, ProviderConfig::Paypal(_) => PaymentMethod::Paypal, ProviderConfig::OnChain(_) => PaymentMethod::OnChain, + ProviderConfig::FixedFloat(_) => PaymentMethod::FixedFloat, } } @@ -4578,6 +4645,14 @@ impl ProviderConfig { _ => None, } } + + /// Get FixedFloat config if this is a FixedFloat provider + pub fn as_fixedfloat(&self) -> Option<&FixedFloatProviderConfig> { + match self { + ProviderConfig::FixedFloat(cfg) => Some(cfg), + _ => None, + } + } } /// Payment method configuration stored in database