Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
```

Expand Down Expand Up @@ -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.
}
```

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 1 addition & 5 deletions lnvps_api/src/bin/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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.
Expand Down
23 changes: 0 additions & 23 deletions lnvps_api/src/payment_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,29 +195,6 @@ impl PaymentMethodFactory {
}
}

pub async fn get_swap_provider_for_company(
&self,
company_id: u64,
) -> Result<Option<Arc<dyn crate::swap::SwapProvider>>> {
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(
Expand Down
25 changes: 15 additions & 10 deletions lnvps_api/src/subscription/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ impl SubscriptionHandler {
})
}

pub fn with_swap(mut self, swap: Option<Arc<dyn SwapProvider>>) -> Self {
#[cfg(test)]
pub(crate) fn with_swap(mut self, swap: Option<Arc<dyn SwapProvider>>) -> Self {
self.swap = swap;
self
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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]
Expand Down
82 changes: 63 additions & 19 deletions lnvps_api/src/swap/mod.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,27 +22,69 @@ pub struct SwapOrder {
pub expires: DateTime<Utc>,
}

#[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<SwapOrder>;
}

impl SwapData {
pub fn parse(raw: &str) -> Result<Self> {
Ok(serde_json::from_str(raw)?)
pub fn provider_for_config(config: &PaymentMethodConfig) -> Result<Arc<dyn SwapProvider>> {
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<String> {
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<SwapOrder>;
#[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());
}
}
Loading
Loading