Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45fce11
feat: add boltz swap support
coreyphillips Jul 14, 2026
cdce3eb
fix: keep savings slider off back gesture
coreyphillips Jul 15, 2026
e0c952a
fix: refresh balances entering savings
coreyphillips Jul 15, 2026
289b950
chore: bump bitkit-core to 0.5.1
coreyphillips Jul 15, 2026
eba8bc8
fix: gate swap claim and harden boltz flow
coreyphillips Jul 16, 2026
41c6c85
fix: harden savings swap races and lifecycle
coreyphillips Jul 16, 2026
1c7a3e5
Merge branch 'master' into feat/savings-swap
coreyphillips Jul 17, 2026
ce42da3
fix: claim savings swaps reliably
coreyphillips Jul 17, 2026
d372cf5
fix: speed up savings swap claim flow
coreyphillips Jul 20, 2026
1f6845f
Merge branch 'master' into feat/savings-swap
coreyphillips Jul 20, 2026
cd6499a
fix: fall back to close when swap amount too low
coreyphillips Jul 20, 2026
4fea1ad
fix: fall back to close when swaps unavailable
coreyphillips Jul 21, 2026
f594fb0
Merge remote-tracking branch 'origin/master' into feat/savings-swap
coreyphillips Jul 21, 2026
146af8f
feat: gate savings swap behind dev flag
coreyphillips Jul 22, 2026
7dfe6b7
Merge remote-tracking branch 'origin/master' into feat/savings-swap
coreyphillips Jul 22, 2026
6b0abdf
fix: handle unroutable savings swap payment
coreyphillips Jul 23, 2026
5618b2f
refactor: address savings swap review nits
coreyphillips Jul 24, 2026
8163828
refactor: use VerticalSpacer in savings screens
coreyphillips Jul 27, 2026
8fce0a2
Merge remote-tracking branch 'origin/master' into feat/savings-swap
coreyphillips Jul 27, 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
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ data class SettingsData(
val isBiometricEnabled: Boolean = false,
val isPinForPaymentsEnabled: Boolean = false,
val isDevModeEnabled: Boolean = Env.isDebug,
val isSavingsSwapEnabled: Boolean = false,
val showWidgets: Boolean = true,
val lastUsedTags: List<String> = emptyList(),
val enableSwipeToHideBalance: Boolean = true,
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ internal object Env {
else -> listOf()
}

/**
* Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on
* mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that
* no build of ours can reach. Elsewhere the transfer to savings closes a channel instead.
*/
val isSwapSupported get() = network == Network.BITCOIN

const val fxRateRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis
const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis
const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis
Expand Down
209 changes: 209 additions & 0 deletions app/src/main/java/to/bitkit/services/BoltzService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package to.bitkit.services

import com.synonym.bitkitcore.BoltzEventListener
import com.synonym.bitkitcore.BoltzNetwork
import com.synonym.bitkitcore.BoltzPairInfo
import com.synonym.bitkitcore.BoltzSwap
import com.synonym.bitkitcore.BoltzSwapEvent
import com.synonym.bitkitcore.ReverseSwapResponse
import com.synonym.bitkitcore.SubmarineSwapResponse
import com.synonym.bitkitcore.boltzClaimReverseSwap
import com.synonym.bitkitcore.boltzCreateReverseSwap
import com.synonym.bitkitcore.boltzCreateSubmarineSwap
import com.synonym.bitkitcore.boltzGetReverseLimits
import com.synonym.bitkitcore.boltzGetSubmarineLimits
import com.synonym.bitkitcore.boltzGetSwap
import com.synonym.bitkitcore.boltzListPendingSwaps
import com.synonym.bitkitcore.boltzListSwaps
import com.synonym.bitkitcore.boltzRefundSubmarineSwap
import com.synonym.bitkitcore.boltzStartSwapUpdates
import com.synonym.bitkitcore.boltzStopSwapUpdates
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import org.lightningdevkit.ldknode.Network
import to.bitkit.data.SettingsStore
import to.bitkit.data.keychain.Keychain
import to.bitkit.env.Env
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import javax.inject.Inject
import javax.inject.Singleton

/**
* Thin wrapper around the bitkit-core Boltz swaps FFI (submarine + reverse swaps between onchain
* Bitcoin and Lightning). A Hilt singleton that bridges the [BoltzEventListener] callback to a
* [SharedFlow]; swap keys are re-derived from the wallet mnemonic on demand, never persisted.
*/
Comment thread
jvsena42 marked this conversation as resolved.
@Suppress("TooManyFunctions")
@Singleton
class BoltzService @Inject constructor(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
private val keychain: Keychain,
private val settingsStore: SettingsStore,
) {
private val _events = MutableSharedFlow<BoltzSwapEvent>(extraBufferCapacity = 64)

/** Swap lifecycle events emitted while the updates stream is running. */
val events: SharedFlow<BoltzSwapEvent> = _events.asSharedFlow()

private val listener = object : BoltzEventListener {
override fun onEvent(event: BoltzSwapEvent) {
Logger.info("Boltz event: $event", context = TAG)
_events.tryEmit(event)
}
}

// region Limits

suspend fun submarineLimits(network: BoltzNetwork = boltzNetwork()): BoltzPairInfo =
boltzGetSubmarineLimits(network = network)

suspend fun reverseLimits(network: BoltzNetwork = boltzNetwork()): BoltzPairInfo =
boltzGetReverseLimits(network = network)

// endregion

// region Create

/** Submarine swap: onchain BTC -> Lightning. Fund the returned lockup address. */
suspend fun createSubmarineSwap(
invoice: String,
network: BoltzNetwork = boltzNetwork(),
electrumUrl: String? = null,
): SubmarineSwapResponse {
val (mnemonic, passphrase) = credentials()
return boltzCreateSubmarineSwap(
network = network,
electrumUrl = electrumUrl ?: electrumUrl(),
invoice = invoice,
mnemonic = mnemonic,
bip39Passphrase = passphrase,
).also { Logger.info("Created Boltz submarine swap ${it.id}", context = TAG) }
}

/** Reverse swap: Lightning -> onchain BTC. Pay the returned hold invoice. */
suspend fun createReverseSwap(
amountSat: ULong,
claimAddress: String,
network: BoltzNetwork = boltzNetwork(),
electrumUrl: String? = null,
): ReverseSwapResponse {
val (mnemonic, passphrase) = credentials()
return boltzCreateReverseSwap(
network = network,
electrumUrl = electrumUrl ?: electrumUrl(),
amountSat = amountSat,
claimAddress = claimAddress,
mnemonic = mnemonic,
bip39Passphrase = passphrase,
).also { Logger.info("Created Boltz reverse swap ${it.id}", context = TAG) }
}

// endregion

// region Query

suspend fun listSwaps(): List<BoltzSwap> = boltzListSwaps()

suspend fun listPendingSwaps(): List<BoltzSwap> = boltzListPendingSwaps()

suspend fun getSwap(id: String): BoltzSwap? = boltzGetSwap(swapId = id)

// endregion

// region Manual claim / refund

suspend fun claimReverseSwap(id: String, feeRateSatPerVb: Double? = null): String {
val (mnemonic, passphrase) = credentials()
return boltzClaimReverseSwap(
swapId = id,
mnemonic = mnemonic,
bip39Passphrase = passphrase,
feeRateSatPerVb = feeRateSatPerVb,
)
}

suspend fun refundSubmarineSwap(id: String, refundAddress: String, feeRateSatPerVb: Double? = null): String {
val (mnemonic, passphrase) = credentials()
return boltzRefundSubmarineSwap(
swapId = id,
refundAddress = refundAddress,
mnemonic = mnemonic,
bip39Passphrase = passphrase,
feeRateSatPerVb = feeRateSatPerVb,
)
}

// endregion

// region Updates stream

/**
* Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim
* reverse swaps. [feeRateSatPerVb] is the rate used for those auto-claims
* (Bitkit owns fee estimation). [acceptZeroConf] claims a reverse swap as soon
* as its lockup hits the mempool instead of waiting for its confirmation.
* Replaces any running stream.
*/
suspend fun startUpdates(
feeRateSatPerVb: Double?,
acceptZeroConf: Boolean,
network: BoltzNetwork = boltzNetwork(),
) {
val (mnemonic, passphrase) = credentials()
boltzStartSwapUpdates(
network = network,
listener = listener,
mnemonic = mnemonic,
bip39Passphrase = passphrase,
feeRateSatPerVb = feeRateSatPerVb,
acceptZeroConf = acceptZeroConf,
)
Logger.info("Started Boltz updates stream on $network", context = TAG)
}

suspend fun stopUpdates() {
boltzStopSwapUpdates()
Logger.info("Stopped Boltz updates stream", context = TAG)
}

// endregion

// region Helpers

/** Whether the configured network has a reachable Boltz backend. See [Env.isSwapSupported]. */
val isSwapSupported: Boolean get() = Env.isSwapSupported

/**
* Whether swaps may run: the network needs a reachable Boltz backend and the savings swap
* flow must be switched on in dev settings, since its UI is not final yet.
*/
suspend fun isSwapEnabled(): Boolean = isSwapSupported && settingsStore.data.first().isSavingsSwapEnabled

/** The Boltz network matching the app's configured network. */
fun boltzNetwork(network: Network = Env.network): BoltzNetwork = when (network) {
Network.BITCOIN -> BoltzNetwork.MAINNET
Network.TESTNET -> BoltzNetwork.TESTNET
Network.REGTEST -> BoltzNetwork.REGTEST
// Boltz does not operate on signet; fall back to testnet for development.
else -> BoltzNetwork.TESTNET
}

/** Current Electrum URL, used by Boltz for claim/refund broadcasting. */
suspend fun electrumUrl(): String = settingsStore.data.first().electrumServer

private suspend fun credentials(): Pair<String, String?> {
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw ServiceError.MnemonicNotFound()
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
?.takeIf { it.isNotEmpty() }
return mnemonic to passphrase
}

// endregion

companion object {
private const val TAG = "BoltzService"
}
}
33 changes: 33 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ import to.bitkit.ui.settings.LogDetailScreen
import to.bitkit.ui.settings.LogsScreen
import to.bitkit.ui.settings.OrderDetailScreen
import to.bitkit.ui.settings.SettingsScreen
import to.bitkit.ui.settings.SwapDetailScreen
import to.bitkit.ui.settings.SwapsScreen
import to.bitkit.ui.settings.advanced.AddressTypePreferenceScreen
import to.bitkit.ui.settings.advanced.AddressViewerScreen
import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen
Expand Down Expand Up @@ -686,6 +688,8 @@ private fun RootNavHost(
channelOrdersSettings(navController)
orderDetailSettings(navController)
cjitDetailSettings(navController)
swapsSettings(navController)
swapDetailSettings(navController)
lightningConnections(navController)
activityItem(activityListViewModel, navController, settingsViewModel)
authCheck(navController)
Expand Down Expand Up @@ -1561,6 +1565,28 @@ private fun NavGraphBuilder.cjitDetailSettings(
}
}

private fun NavGraphBuilder.swapsSettings(
navController: NavHostController,
) {
composableWithDefaultTransitions<Routes.SwapsSettings> {
SwapsScreen(
onBackClick = { navController.popBackStack() },
onSwapItemClick = { navController.navigateToSwapDetail(it) },
)
}
}

private fun NavGraphBuilder.swapDetailSettings(
navController: NavHostController,
) {
composableWithDefaultTransitions<Routes.SwapDetail> {
SwapDetailScreen(
swapItem = it.toRoute(),
onBackClick = { navController.popBackStack() },
)
}
}

private fun NavGraphBuilder.lightningConnections(
navController: NavHostController,
) {
Expand Down Expand Up @@ -1821,6 +1847,7 @@ fun NavController.navigateToLocalCurrencySettings() = navigateTo(Routes.LocalCur
fun NavController.navigateToBackupSettings() = navigateTo(Routes.BackupSettings)

fun NavController.navigateToOrderDetail(id: String) = navigateTo(Routes.OrderDetail(id))
fun NavController.navigateToSwapDetail(id: String) = navigateTo(Routes.SwapDetail(id))

fun NavController.navigateToCjitDetail(id: String) = navigateTo(Routes.CjitDetail(id))

Expand Down Expand Up @@ -1963,6 +1990,12 @@ sealed interface Routes {
@Serializable
data object ChannelOrdersSettings : Routes

@Serializable
data object SwapsSettings : Routes

@Serializable
data class SwapDetail(val id: String) : Routes

@Serializable
data object Logs : Routes

Expand Down
Loading
Loading