use bitcoin::Network;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::error::{BitcoinError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SwapDirection {
OnchainToLightning,
LightningToOnchain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SubmarineSwapStatus {
Initiated,
WaitingForOnchain,
OnchainDetected,
WaitingForInvoice,
InvoiceCreated,
LightningPaymentSent,
LightningPaymentConfirmed,
Completed,
Failed,
Refunded,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubmarineSwap {
pub id: Uuid,
pub direction: SwapDirection,
pub status: SubmarineSwapStatus,
pub amount_sats: u64,
pub fee_sats: u64,
pub payment_hash: String,
pub preimage: Option<String>,
pub onchain_address: Option<String>,
pub lightning_invoice: Option<String>,
pub txid: Option<String>,
pub refund_address: String,
pub locktime: u32,
pub network: Network,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl SubmarineSwap {
#[allow(clippy::too_many_arguments)]
pub fn new_onchain_to_lightning(
amount_sats: u64,
fee_sats: u64,
payment_hash: String,
onchain_address: String,
refund_address: String,
locktime: u32,
network: Network,
expiry_hours: i64,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
direction: SwapDirection::OnchainToLightning,
status: SubmarineSwapStatus::Initiated,
amount_sats,
fee_sats,
payment_hash,
preimage: None,
onchain_address: Some(onchain_address),
lightning_invoice: None,
txid: None,
refund_address,
locktime,
network,
created_at: now,
expires_at: now + Duration::hours(expiry_hours),
completed_at: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn new_lightning_to_onchain(
amount_sats: u64,
fee_sats: u64,
payment_hash: String,
lightning_invoice: String,
refund_address: String,
locktime: u32,
network: Network,
expiry_hours: i64,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
direction: SwapDirection::LightningToOnchain,
status: SubmarineSwapStatus::Initiated,
amount_sats,
fee_sats,
payment_hash,
preimage: None,
onchain_address: None,
lightning_invoice: Some(lightning_invoice),
txid: None,
refund_address,
locktime,
network,
created_at: now,
expires_at: now + Duration::hours(expiry_hours),
completed_at: None,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn update_status(&mut self, status: SubmarineSwapStatus) {
self.status = status;
if matches!(
status,
SubmarineSwapStatus::Completed
| SubmarineSwapStatus::Failed
| SubmarineSwapStatus::Refunded
| SubmarineSwapStatus::Expired
) {
self.completed_at = Some(Utc::now());
}
}
pub fn set_preimage(&mut self, preimage: String) {
self.preimage = Some(preimage);
}
pub fn set_txid(&mut self, txid: String) {
self.txid = Some(txid);
}
pub fn total_cost(&self) -> u64 {
self.amount_sats + self.fee_sats
}
pub fn amount_received(&self) -> u64 {
self.amount_sats.saturating_sub(self.fee_sats)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubmarineSwapConfig {
pub min_amount: u64,
pub max_amount: u64,
pub fee_percentage: u64,
pub min_fee: u64,
pub default_expiry_hours: i64,
pub onchain_to_ln_locktime_blocks: u32,
pub ln_to_onchain_locktime_blocks: u32,
pub required_confirmations: u32,
}
impl Default for SubmarineSwapConfig {
fn default() -> Self {
Self {
min_amount: 10_000, max_amount: 100_000_000, fee_percentage: 100, min_fee: 1_000, default_expiry_hours: 24, onchain_to_ln_locktime_blocks: 144, ln_to_onchain_locktime_blocks: 144,
required_confirmations: 3,
}
}
}
impl SubmarineSwapConfig {
pub fn calculate_fee(&self, amount_sats: u64) -> u64 {
let percentage_fee = (amount_sats * self.fee_percentage) / 10_000;
percentage_fee.max(self.min_fee)
}
pub fn validate_amount(&self, amount_sats: u64) -> Result<()> {
if amount_sats < self.min_amount {
return Err(BitcoinError::Validation(format!(
"Amount {} is below minimum {}",
amount_sats, self.min_amount
)));
}
if amount_sats > self.max_amount {
return Err(BitcoinError::Validation(format!(
"Amount {} exceeds maximum {}",
amount_sats, self.max_amount
)));
}
Ok(())
}
}
pub struct SubmarineSwapService {
config: SubmarineSwapConfig,
swaps: Arc<RwLock<HashMap<Uuid, SubmarineSwap>>>,
network: Network,
}
impl SubmarineSwapService {
pub fn new(config: SubmarineSwapConfig, network: Network) -> Self {
Self {
config,
swaps: Arc::new(RwLock::new(HashMap::new())),
network,
}
}
pub async fn create_onchain_to_lightning_swap(
&self,
amount_sats: u64,
payment_hash: String,
onchain_address: String,
refund_address: String,
) -> Result<SubmarineSwap> {
self.config.validate_amount(amount_sats)?;
let fee = self.config.calculate_fee(amount_sats);
let swap = SubmarineSwap::new_onchain_to_lightning(
amount_sats,
fee,
payment_hash,
onchain_address,
refund_address,
self.config.onchain_to_ln_locktime_blocks,
self.network,
self.config.default_expiry_hours,
);
self.swaps.write().await.insert(swap.id, swap.clone());
tracing::info!(
swap_id = %swap.id,
amount = amount_sats,
fee = fee,
"Created on-chain to Lightning swap"
);
Ok(swap)
}
pub async fn create_lightning_to_onchain_swap(
&self,
amount_sats: u64,
payment_hash: String,
lightning_invoice: String,
refund_address: String,
) -> Result<SubmarineSwap> {
self.config.validate_amount(amount_sats)?;
let fee = self.config.calculate_fee(amount_sats);
let swap = SubmarineSwap::new_lightning_to_onchain(
amount_sats,
fee,
payment_hash,
lightning_invoice,
refund_address,
self.config.ln_to_onchain_locktime_blocks,
self.network,
self.config.default_expiry_hours,
);
self.swaps.write().await.insert(swap.id, swap.clone());
tracing::info!(
swap_id = %swap.id,
amount = amount_sats,
fee = fee,
"Created Lightning to on-chain swap"
);
Ok(swap)
}
pub async fn get_swap(&self, swap_id: Uuid) -> Option<SubmarineSwap> {
self.swaps.read().await.get(&swap_id).cloned()
}
pub async fn update_swap_status(
&self,
swap_id: Uuid,
status: SubmarineSwapStatus,
) -> Result<()> {
let mut swaps = self.swaps.write().await;
if let Some(swap) = swaps.get_mut(&swap_id) {
swap.update_status(status);
tracing::info!(
swap_id = %swap_id,
status = ?status,
"Updated swap status"
);
Ok(())
} else {
Err(BitcoinError::Validation(format!(
"Swap {} not found",
swap_id
)))
}
}
pub async fn set_swap_preimage(&self, swap_id: Uuid, preimage: String) -> Result<()> {
let mut swaps = self.swaps.write().await;
if let Some(swap) = swaps.get_mut(&swap_id) {
swap.set_preimage(preimage);
swap.update_status(SubmarineSwapStatus::Completed);
tracing::info!(swap_id = %swap_id, "Swap claimed with preimage");
Ok(())
} else {
Err(BitcoinError::Validation(format!(
"Swap {} not found",
swap_id
)))
}
}
pub async fn list_active_swaps(&self) -> Vec<SubmarineSwap> {
self.swaps
.read()
.await
.values()
.filter(|swap| {
!matches!(
swap.status,
SubmarineSwapStatus::Completed
| SubmarineSwapStatus::Failed
| SubmarineSwapStatus::Refunded
| SubmarineSwapStatus::Expired
)
})
.cloned()
.collect()
}
pub async fn cleanup_expired_swaps(&self) -> usize {
let mut swaps = self.swaps.write().await;
let mut expired_count = 0;
for swap in swaps.values_mut() {
if swap.is_expired()
&& matches!(
swap.status,
SubmarineSwapStatus::Initiated
| SubmarineSwapStatus::WaitingForOnchain
| SubmarineSwapStatus::WaitingForInvoice
)
{
swap.update_status(SubmarineSwapStatus::Expired);
expired_count += 1;
}
}
if expired_count > 0 {
tracing::info!(count = expired_count, "Cleaned up expired swaps");
}
expired_count
}
pub async fn get_statistics(&self) -> SubmarineSwapStatistics {
let swaps = self.swaps.read().await;
let total_swaps = swaps.len();
let completed_swaps = swaps
.values()
.filter(|s| s.status == SubmarineSwapStatus::Completed)
.count();
let failed_swaps = swaps
.values()
.filter(|s| {
matches!(
s.status,
SubmarineSwapStatus::Failed
| SubmarineSwapStatus::Refunded
| SubmarineSwapStatus::Expired
)
})
.count();
let active_swaps = swaps
.values()
.filter(|s| {
!matches!(
s.status,
SubmarineSwapStatus::Completed
| SubmarineSwapStatus::Failed
| SubmarineSwapStatus::Refunded
| SubmarineSwapStatus::Expired
)
})
.count();
let total_volume_sats = swaps
.values()
.filter(|s| s.status == SubmarineSwapStatus::Completed)
.map(|s| s.amount_sats)
.sum();
let total_fees_sats = swaps
.values()
.filter(|s| s.status == SubmarineSwapStatus::Completed)
.map(|s| s.fee_sats)
.sum();
SubmarineSwapStatistics {
total_swaps,
completed_swaps,
failed_swaps,
active_swaps,
total_volume_sats,
total_fees_sats,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubmarineSwapStatistics {
pub total_swaps: usize,
pub completed_swaps: usize,
pub failed_swaps: usize,
pub active_swaps: usize,
pub total_volume_sats: u64,
pub total_fees_sats: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_swap_config_defaults() {
let config = SubmarineSwapConfig::default();
assert_eq!(config.min_amount, 10_000);
assert_eq!(config.max_amount, 100_000_000);
assert_eq!(config.fee_percentage, 100); }
#[test]
fn test_fee_calculation() {
let config = SubmarineSwapConfig::default();
let fee = config.calculate_fee(100_000); assert_eq!(fee, 1_000);
let fee = config.calculate_fee(50_000); assert!(fee >= config.min_fee);
}
#[test]
fn test_amount_validation() {
let config = SubmarineSwapConfig::default();
assert!(config.validate_amount(50_000).is_ok());
assert!(config.validate_amount(5_000).is_err());
assert!(config.validate_amount(200_000_000).is_err());
}
#[test]
fn test_swap_creation() {
let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string();
let refund_address = address.clone();
let swap = SubmarineSwap::new_onchain_to_lightning(
100_000,
1_000,
"payment_hash_123".to_string(),
address,
refund_address,
144,
Network::Bitcoin,
24,
);
assert_eq!(swap.direction, SwapDirection::OnchainToLightning);
assert_eq!(swap.status, SubmarineSwapStatus::Initiated);
assert_eq!(swap.amount_sats, 100_000);
assert_eq!(swap.fee_sats, 1_000);
assert_eq!(swap.total_cost(), 101_000);
assert_eq!(swap.amount_received(), 99_000);
}
#[test]
fn test_swap_expiration() {
let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string();
let refund_address = address.clone();
let mut swap = SubmarineSwap::new_onchain_to_lightning(
100_000,
1_000,
"payment_hash_123".to_string(),
address,
refund_address,
144,
Network::Bitcoin,
24,
);
assert!(!swap.is_expired());
swap.expires_at = Utc::now() - Duration::hours(1);
assert!(swap.is_expired());
}
#[tokio::test]
async fn test_submarine_swap_service() {
let config = SubmarineSwapConfig::default();
let service = SubmarineSwapService::new(config, Network::Bitcoin);
let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string();
let refund_address = address.clone();
let swap = service
.create_onchain_to_lightning_swap(
100_000,
"payment_hash_123".to_string(),
address,
refund_address,
)
.await
.unwrap();
assert_eq!(swap.amount_sats, 100_000);
assert_eq!(swap.status, SubmarineSwapStatus::Initiated);
let retrieved = service.get_swap(swap.id).await;
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().id, swap.id);
}
#[tokio::test]
async fn test_swap_status_update() {
let config = SubmarineSwapConfig::default();
let service = SubmarineSwapService::new(config, Network::Bitcoin);
let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string();
let swap = service
.create_onchain_to_lightning_swap(
100_000,
"payment_hash_123".to_string(),
address.clone(),
address,
)
.await
.unwrap();
service
.update_swap_status(swap.id, SubmarineSwapStatus::OnchainDetected)
.await
.unwrap();
let updated = service.get_swap(swap.id).await.unwrap();
assert_eq!(updated.status, SubmarineSwapStatus::OnchainDetected);
}
}