Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e10fbe8
feat(boltz): add Boltz submarine & reverse swaps with deterministic keys
coreyphillips Jun 26, 2026
ed74cb4
feat(boltz): supply auto-claim fee rate from the app
coreyphillips Jun 30, 2026
937996e
Merge remote-tracking branch 'origin/master' into feat/boltz-swaps-de…
coreyphillips Jul 7, 2026
ae60567
fix(boltz): persist claimed/refunded status when recording tx id
coreyphillips Jul 8, 2026
ef7612d
Merge remote-tracking branch 'origin/master' into feat/boltz-swaps-de…
coreyphillips Jul 14, 2026
86a9441
fix(boltz): update db test for terminal status on claim and refund
coreyphillips Jul 14, 2026
080d5f2
fix(boltz): validate Boltz create responses before persisting them
coreyphillips Jul 14, 2026
1d70976
fix(boltz): serialize claim and refund attempts per swap
coreyphillips Jul 14, 2026
cc503d5
chore: bump to 0.5.0 and regenerate bindings
coreyphillips Jul 14, 2026
0525f80
chore: update bindings
coreyphillips Jul 15, 2026
de11c39
Merge remote-tracking branch 'origin/master' into feat/boltz-swaps-de…
coreyphillips Jul 15, 2026
a36eafc
chore: regenerate bindings after merging master's onchain fix
coreyphillips Jul 15, 2026
0e02949
Merge remote-tracking branch 'origin/master' into feat/boltz-swaps-de…
coreyphillips Jul 15, 2026
158caf0
chore: bump to 0.5.1 and regenerate bindings
coreyphillips Jul 15, 2026
2bf8d4f
fix: reconcile swap status on subscribe
coreyphillips Jul 17, 2026
eb3842e
chore: bump to 0.5.2
coreyphillips Jul 17, 2026
e41fa96
feat: add instant claim and periodic reconcile
coreyphillips Jul 20, 2026
fdc2341
chore: update bindings
coreyphillips Jul 20, 2026
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(
Comment thread
ovitrif marked this conversation as resolved.
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() {
Comment thread
ovitrif marked this conversation as resolved.
Outdated
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