Skip to content
Draft
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
584 changes: 580 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ uuid = { version = "1.16.0", features = ["v4"] }
hex = "0.4.3"
bip39 = "2.0"
bdk = { version = "0.30.2", features = ["all-keys"] }
boltz-client = { version = "0.4.1", default-features = false, features = ["electrum", "ws"] }
base64 = "0.22"
log = "0.4"
pubky = "0.6.0"
Expand Down
275 changes: 274 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ use crate::modules::blocktank::{
IBt0ConfMinTxFeeWindow, IBtBolt11Invoice, IBtEstimateFeeResponse, IBtEstimateFeeResponse2,
IBtInfo, IBtOrder, ICJitEntry, IGift,
};
use crate::modules::boltz::{
self, BoltzDB, BoltzError, BoltzEventListener, BoltzNetwork, BoltzPairInfo, BoltzSwap,
ReverseSwapResponse, SubmarineSwapResponse,
};
use crate::modules::pubky::{PubkyAuthDetails, PubkyAuthKind, PubkyError, PubkyProfile};
use crate::modules::trezor::account_type_to_script_type;
pub use crate::modules::trezor::{
Expand Down Expand Up @@ -60,6 +64,7 @@ use crate::onchain::{
start_watcher, stop_all_watchers, stop_watcher, EventListener, WatcherParams,
};
pub use modules::activity;
pub use modules::boltz as boltz_swaps;
pub use modules::lnurl;
pub use modules::onchain;
pub use modules::scanner::{DecodingError, Scanner};
Expand All @@ -78,6 +83,7 @@ pub struct DatabaseConnections {

pub struct AsyncDatabaseConnections {
blocktank_db: Option<BlocktankDB>,
boltz_db: Option<Arc<BoltzDB>>,
}
// Two separate global states for sync and async connections
static DB: OnceCell<StdMutex<DatabaseConnections>> = OnceCell::new();
Expand Down Expand Up @@ -439,14 +445,20 @@ pub fn init_db(base_path: String) -> Result<String, DbError> {
DB.get_or_init(|| StdMutex::new(DatabaseConnections { activity_db: None }));

// Initialize async database state
ASYNC_DB.get_or_init(|| TokioMutex::new(AsyncDatabaseConnections { blocktank_db: None }));
ASYNC_DB.get_or_init(|| {
TokioMutex::new(AsyncDatabaseConnections {
blocktank_db: None,
boltz_db: None,
})
});

// Create runtime for async operations
let rt = ensure_runtime();
// Create database connections
let activity_db = ActivityDB::new(&format!("{}/activity.db", base_path))?;
let blocktank_db = rt
.block_on(async { BlocktankDB::new(&format!("{}/blocktank.db", base_path), None).await })?;
let boltz_db = rt.block_on(async { BoltzDB::new(&format!("{}/boltz.db", base_path)).await })?;

// Initialize sync database
{
Expand All @@ -460,6 +472,7 @@ pub fn init_db(base_path: String) -> Result<String, DbError> {
rt.block_on(async {
let mut guard = async_db.lock().await;
guard.blocktank_db = Some(blocktank_db);
guard.boltz_db = Some(Arc::new(boltz_db));
});
}

Expand Down Expand Up @@ -2698,3 +2711,263 @@ pub fn onchain_stop_watcher(watcher_id: String) -> Result<(), AccountInfoError>
pub fn onchain_stop_all_watchers() {
stop_all_watchers();
}

// ============================================================================
// Boltz swaps
// ============================================================================

/// Clone the shared Boltz database handle. The handle is held only briefly
/// while cloning, so long-running swap operations don't block other callers.
async fn get_boltz_db() -> Result<Arc<BoltzDB>, BoltzError> {
let cell = ASYNC_DB.get().ok_or(BoltzError::ConnectionError {
error_details: "Database not initialized. Call init_db first.".to_string(),
})?;
let guard = cell.lock().await;
guard.boltz_db.clone().ok_or(BoltzError::ConnectionError {
error_details: "Database not initialized. Call init_db first.".to_string(),
})
}

fn boltz_runtime_err(e: tokio::task::JoinError) -> BoltzError {
BoltzError::ConnectionError {
error_details: format!("Runtime error: {}", e),
}
}

/// Fetch fees and limits for submarine swaps (onchain -> Lightning).
#[uniffi::export]
pub async fn boltz_get_submarine_limits(
network: BoltzNetwork,
) -> Result<BoltzPairInfo, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move { boltz::get_submarine_limits(network).await })
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Fetch fees and limits for reverse swaps (Lightning -> onchain).
#[uniffi::export]
pub async fn boltz_get_reverse_limits(network: BoltzNetwork) -> Result<BoltzPairInfo, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move { boltz::get_reverse_limits(network).await })
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Create a submarine swap (onchain -> Lightning).
///
/// `invoice` is a BOLT11 invoice the caller's Lightning node generated. The
/// caller funds the returned lockup address from its onchain wallet. The refund
/// key is derived deterministically from `mnemonic` (only the derivation index
/// is persisted, never the key), and the swap is tracked if an updates stream is
/// running. `bip39_passphrase` must match the wallet's, or refunds will derive
/// the wrong key.
#[uniffi::export]
pub async fn boltz_create_submarine_swap(
network: BoltzNetwork,
electrum_url: String,
invoice: String,
mnemonic: String,
bip39_passphrase: Option<String>,
) -> Result<SubmarineSwapResponse, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
let response = db
.create_submarine_swap(network, electrum_url, invoice, mnemonic, bip39_passphrase)
.await?;
boltz::subscribe_if_active(network, &response.id).await;
Ok(response)
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Create a reverse swap (Lightning -> onchain).
///
/// The caller pays the returned hold invoice from its Lightning node;
/// `claim_address` is the onchain address the received funds are claimed to.
/// The claim key and preimage are derived deterministically from `mnemonic`
/// (only the derivation index is persisted, never the secrets) so the claim can
/// be made automatically once Boltz locks the funds. `bip39_passphrase` must
/// match the wallet's, or claims will derive the wrong key.
#[uniffi::export]
pub async fn boltz_create_reverse_swap(
network: BoltzNetwork,
electrum_url: String,
amount_sat: u64,
claim_address: String,
mnemonic: String,
bip39_passphrase: Option<String>,
) -> Result<ReverseSwapResponse, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
let response = db
.create_reverse_swap(
network,
electrum_url,
amount_sat,
claim_address,
mnemonic,
bip39_passphrase,
)
.await?;
boltz::subscribe_if_active(network, &response.id).await;
Ok(response)
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// List every persisted swap, newest first.
#[uniffi::export]
pub async fn boltz_list_swaps() -> Result<Vec<BoltzSwap>, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
Ok(db
.list_swaps()
.await?
.iter()
.map(|r| r.to_boltz_swap())
.collect())
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// List swaps that have not reached a terminal state (for recovery/resume).
#[uniffi::export]
pub async fn boltz_list_pending_swaps() -> Result<Vec<BoltzSwap>, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
Ok(db
.list_pending_swaps()
.await?
.iter()
.map(|r| r.to_boltz_swap())
.collect())
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Fetch a single swap by id.
#[uniffi::export]
pub async fn boltz_get_swap(swap_id: String) -> Result<Option<BoltzSwap>, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
Ok(db.get_swap(&swap_id).await?.map(|r| r.to_boltz_swap()))
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Claim a reverse swap's onchain funds to its claim address, returning the
/// broadcast claim transaction id. Normally happens automatically via the
/// updates stream; exposed for manual recovery. The claim key is re-derived from
/// `mnemonic`. If the swap was already claimed, the existing claim txid is
/// returned without re-broadcasting.
#[uniffi::export]
pub async fn boltz_claim_reverse_swap(
swap_id: String,
mnemonic: String,
bip39_passphrase: Option<String>,
fee_rate_sat_per_vb: Option<f64>,
) -> Result<String, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
let record = db.get_swap(&swap_id).await?.ok_or(BoltzError::NotFound {
error_details: format!("Swap {} not found", swap_id),
})?;
// Idempotent: don't re-broadcast a swap that already has a claim tx
// (e.g. an auto-claim that ran first).
if let Some(existing) = record.claim_tx_id.clone() {
return Ok(existing);
}
let txid = boltz::claim_reverse_swap(
&record,
&mnemonic,
bip39_passphrase.as_deref(),
fee_rate_sat_per_vb,
)
.await?;
db.set_claim_tx(&swap_id, &txid).await?;
Ok(txid)
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Refund a submarine swap's locked funds to `refund_address`, returning the
/// broadcast refund transaction id. Used when Boltz fails to pay the invoice or
/// the swap expires. The refund key is re-derived from `mnemonic`. If the swap
/// was already refunded, the existing refund txid is returned without
/// re-broadcasting.
#[uniffi::export]
pub async fn boltz_refund_submarine_swap(
swap_id: String,
refund_address: String,
mnemonic: String,
bip39_passphrase: Option<String>,
fee_rate_sat_per_vb: Option<f64>,
) -> Result<String, BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
let record = db.get_swap(&swap_id).await?.ok_or(BoltzError::NotFound {
error_details: format!("Swap {} not found", swap_id),
})?;
// Idempotent: don't re-broadcast a swap that already has a refund tx.
if let Some(existing) = record.refund_tx_id.clone() {
return Ok(existing);
}
let txid = boltz::refund_submarine_swap(
&record,
refund_address,
&mnemonic,
bip39_passphrase.as_deref(),
fee_rate_sat_per_vb,
)
.await?;
db.set_refund_tx(&swap_id, &txid).await?;
Ok(txid)
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Open a Boltz WebSocket for `network`, subscribe to all pending swaps, and
/// drive their lifecycle (auto-claiming reverse swaps) until stopped. Replaces
/// any previously running updates stream (only one network is tracked at a
/// time). `mnemonic` is held in memory for the lifetime of the stream so
/// confirmed reverse swaps can be auto-claimed; it is never persisted. Events
/// are delivered to `listener`.
#[uniffi::export]
pub async fn boltz_start_swap_updates(
network: BoltzNetwork,
listener: Arc<dyn BoltzEventListener>,
mnemonic: String,
bip39_passphrase: Option<String>,
) -> Result<(), BoltzError> {
let rt = ensure_runtime();
rt.spawn(async move {
let db = get_boltz_db().await?;
boltz::start_swap_updates(db, network, listener, mnemonic, bip39_passphrase).await
})
.await
.unwrap_or_else(|e| Err(boltz_runtime_err(e)))
}

/// Stop the running Boltz updates stream, if any.
#[uniffi::export]
pub async fn boltz_stop_swap_updates() {
let rt = ensure_runtime();
let _ = rt
.spawn(async move { boltz::stop_swap_updates().await })
.await;
}
12 changes: 12 additions & 0 deletions src/modules/activity/types.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::activity::ActivityError;
use crate::modules::blocktank::BlocktankError;
use crate::modules::boltz::BoltzError;
use serde::{Deserialize, Serialize};
use thiserror::Error;

Expand Down Expand Up @@ -306,6 +307,9 @@ pub enum DbError {
#[error("DB Blocktank Error: {error_details}")]
DbBlocktankError { error_details: BlocktankError },

#[error("DB Boltz Error: {error_details}")]
DbBoltzError { error_details: BoltzError },

#[error("Initialization Error: {error_details}")]
InitializationError { error_details: String },
}
Expand All @@ -325,3 +329,11 @@ impl From<BlocktankError> for DbError {
}
}
}

impl From<BoltzError> for DbError {
fn from(error: BoltzError) -> Self {
DbError::DbBoltzError {
error_details: error,
}
}
}
Loading