Skip to content

Commit cd6afdc

Browse files
committed
feat: add boltz swap support
1 parent 947d6bb commit cd6afdc

15 files changed

Lines changed: 1248 additions & 56 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package to.bitkit.services
2+
3+
import com.synonym.bitkitcore.BoltzEventListener
4+
import com.synonym.bitkitcore.BoltzNetwork
5+
import com.synonym.bitkitcore.BoltzPairInfo
6+
import com.synonym.bitkitcore.BoltzSwap
7+
import com.synonym.bitkitcore.BoltzSwapEvent
8+
import com.synonym.bitkitcore.ReverseSwapResponse
9+
import com.synonym.bitkitcore.SubmarineSwapResponse
10+
import com.synonym.bitkitcore.boltzClaimReverseSwap
11+
import com.synonym.bitkitcore.boltzCreateReverseSwap
12+
import com.synonym.bitkitcore.boltzCreateSubmarineSwap
13+
import com.synonym.bitkitcore.boltzGetReverseLimits
14+
import com.synonym.bitkitcore.boltzGetSubmarineLimits
15+
import com.synonym.bitkitcore.boltzGetSwap
16+
import com.synonym.bitkitcore.boltzListPendingSwaps
17+
import com.synonym.bitkitcore.boltzListSwaps
18+
import com.synonym.bitkitcore.boltzRefundSubmarineSwap
19+
import com.synonym.bitkitcore.boltzStartSwapUpdates
20+
import com.synonym.bitkitcore.boltzStopSwapUpdates
21+
import kotlinx.coroutines.flow.MutableSharedFlow
22+
import kotlinx.coroutines.flow.SharedFlow
23+
import kotlinx.coroutines.flow.asSharedFlow
24+
import kotlinx.coroutines.flow.first
25+
import org.lightningdevkit.ldknode.Network
26+
import to.bitkit.data.SettingsStore
27+
import to.bitkit.data.keychain.Keychain
28+
import to.bitkit.env.Env
29+
import to.bitkit.utils.Logger
30+
import javax.inject.Inject
31+
import javax.inject.Singleton
32+
33+
/**
34+
* Thin wrapper around the bitkit-core Boltz swaps FFI (submarine + reverse swaps
35+
* between onchain Bitcoin and Lightning).
36+
*
37+
* Mirrors the existing service pattern (e.g. [TrezorService]): a Hilt singleton
38+
* that wraps the FFI and bridges the [BoltzEventListener] foreign callback to a
39+
* [SharedFlow]. bitkit-core persists only a derivation index, never key material;
40+
* swap keys are re-derived on demand from the wallet mnemonic.
41+
*
42+
* The Lightning side (issuing/paying invoices, fresh onchain addresses) is owned
43+
* by [LightningService]; this service only talks to Boltz + the chain.
44+
*/
45+
@Singleton
46+
class BoltzService @Inject constructor(
47+
private val keychain: Keychain,
48+
private val settingsStore: SettingsStore,
49+
) {
50+
private val _events = MutableSharedFlow<BoltzSwapEvent>(extraBufferCapacity = 64)
51+
52+
/** Swap lifecycle events emitted while the updates stream is running. */
53+
val events: SharedFlow<BoltzSwapEvent> = _events.asSharedFlow()
54+
55+
private val listener = object : BoltzEventListener {
56+
override fun onEvent(event: BoltzSwapEvent) {
57+
Logger.info("Boltz event: $event", context = TAG)
58+
_events.tryEmit(event)
59+
}
60+
}
61+
62+
// region Limits
63+
64+
suspend fun submarineLimits(network: BoltzNetwork = boltzNetwork()): BoltzPairInfo =
65+
boltzGetSubmarineLimits(network = network)
66+
67+
suspend fun reverseLimits(network: BoltzNetwork = boltzNetwork()): BoltzPairInfo =
68+
boltzGetReverseLimits(network = network)
69+
70+
// endregion
71+
72+
// region Create
73+
74+
/** Submarine swap: onchain BTC -> Lightning. Fund the returned lockup address. */
75+
suspend fun createSubmarineSwap(
76+
invoice: String,
77+
network: BoltzNetwork = boltzNetwork(),
78+
electrumUrl: String? = null,
79+
): SubmarineSwapResponse {
80+
val (mnemonic, passphrase) = credentials()
81+
return boltzCreateSubmarineSwap(
82+
network = network,
83+
electrumUrl = electrumUrl ?: electrumUrl(),
84+
invoice = invoice,
85+
mnemonic = mnemonic,
86+
bip39Passphrase = passphrase,
87+
).also { Logger.info("Created Boltz submarine swap ${it.id}", context = TAG) }
88+
}
89+
90+
/** Reverse swap: Lightning -> onchain BTC. Pay the returned hold invoice. */
91+
suspend fun createReverseSwap(
92+
amountSat: ULong,
93+
claimAddress: String,
94+
network: BoltzNetwork = boltzNetwork(),
95+
electrumUrl: String? = null,
96+
): ReverseSwapResponse {
97+
val (mnemonic, passphrase) = credentials()
98+
return boltzCreateReverseSwap(
99+
network = network,
100+
electrumUrl = electrumUrl ?: electrumUrl(),
101+
amountSat = amountSat,
102+
claimAddress = claimAddress,
103+
mnemonic = mnemonic,
104+
bip39Passphrase = passphrase,
105+
).also { Logger.info("Created Boltz reverse swap ${it.id}", context = TAG) }
106+
}
107+
108+
// endregion
109+
110+
// region Query
111+
112+
suspend fun listSwaps(): List<BoltzSwap> = boltzListSwaps()
113+
114+
suspend fun listPendingSwaps(): List<BoltzSwap> = boltzListPendingSwaps()
115+
116+
suspend fun getSwap(id: String): BoltzSwap? = boltzGetSwap(swapId = id)
117+
118+
// endregion
119+
120+
// region Manual claim / refund
121+
122+
suspend fun claimReverseSwap(id: String, feeRateSatPerVb: Double? = null): String {
123+
val (mnemonic, passphrase) = credentials()
124+
return boltzClaimReverseSwap(
125+
swapId = id,
126+
mnemonic = mnemonic,
127+
bip39Passphrase = passphrase,
128+
feeRateSatPerVb = feeRateSatPerVb,
129+
)
130+
}
131+
132+
suspend fun refundSubmarineSwap(id: String, refundAddress: String, feeRateSatPerVb: Double? = null): String {
133+
val (mnemonic, passphrase) = credentials()
134+
return boltzRefundSubmarineSwap(
135+
swapId = id,
136+
refundAddress = refundAddress,
137+
mnemonic = mnemonic,
138+
bip39Passphrase = passphrase,
139+
feeRateSatPerVb = feeRateSatPerVb,
140+
)
141+
}
142+
143+
// endregion
144+
145+
// region Updates stream
146+
147+
/**
148+
* Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim
149+
* confirmed reverse swaps. [feeRateSatPerVb] is the rate used for those
150+
* auto-claims (Bitkit owns fee estimation). Replaces any running stream.
151+
*/
152+
suspend fun startUpdates(
153+
feeRateSatPerVb: Double?,
154+
network: BoltzNetwork = boltzNetwork(),
155+
) {
156+
val (mnemonic, passphrase) = credentials()
157+
boltzStartSwapUpdates(
158+
network = network,
159+
listener = listener,
160+
mnemonic = mnemonic,
161+
bip39Passphrase = passphrase,
162+
feeRateSatPerVb = feeRateSatPerVb,
163+
)
164+
Logger.info("Started Boltz updates stream on $network", context = TAG)
165+
}
166+
167+
suspend fun stopUpdates() {
168+
boltzStopSwapUpdates()
169+
Logger.info("Stopped Boltz updates stream", context = TAG)
170+
}
171+
172+
// endregion
173+
174+
// region Helpers
175+
176+
/** The Boltz network matching the app's configured network. */
177+
fun boltzNetwork(network: Network = Env.network): BoltzNetwork = when (network) {
178+
Network.BITCOIN -> BoltzNetwork.MAINNET
179+
Network.TESTNET -> BoltzNetwork.TESTNET
180+
Network.REGTEST -> BoltzNetwork.REGTEST
181+
// Boltz does not operate on signet; fall back to testnet for development.
182+
else -> BoltzNetwork.TESTNET
183+
}
184+
185+
/** Current Electrum URL, used by Boltz for claim/refund broadcasting. */
186+
suspend fun electrumUrl(): String = settingsStore.data.first().electrumServer
187+
188+
private suspend fun credentials(): Pair<String, String?> {
189+
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
190+
?: error("Mnemonic not found")
191+
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
192+
?.takeIf { it.isNotEmpty() }
193+
return mnemonic to passphrase
194+
}
195+
196+
// endregion
197+
198+
companion object {
199+
private const val TAG = "BoltzService"
200+
}
201+
}

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ import to.bitkit.ui.settings.LanguageSettingsScreen
154154
import to.bitkit.ui.settings.LogDetailScreen
155155
import to.bitkit.ui.settings.LogsScreen
156156
import to.bitkit.ui.settings.OrderDetailScreen
157+
import to.bitkit.ui.settings.SwapDetailScreen
158+
import to.bitkit.ui.settings.SwapsScreen
157159
import to.bitkit.ui.settings.SettingsScreen
158160
import to.bitkit.ui.settings.advanced.AddressTypePreferenceScreen
159161
import to.bitkit.ui.settings.advanced.AddressViewerScreen
@@ -688,6 +690,8 @@ private fun RootNavHost(
688690
channelOrdersSettings(navController)
689691
orderDetailSettings(navController)
690692
cjitDetailSettings(navController)
693+
swapsSettings(navController)
694+
swapDetailSettings(navController)
691695
lightningConnections(navController)
692696
activityItem(activityListViewModel, navController, settingsViewModel)
693697
authCheck(navController)
@@ -1564,6 +1568,28 @@ private fun NavGraphBuilder.cjitDetailSettings(
15641568
}
15651569
}
15661570

1571+
private fun NavGraphBuilder.swapsSettings(
1572+
navController: NavHostController,
1573+
) {
1574+
composableWithDefaultTransitions<Routes.SwapsSettings> {
1575+
SwapsScreen(
1576+
onBackClick = { navController.popBackStack() },
1577+
onSwapItemClick = { navController.navigateToSwapDetail(it) },
1578+
)
1579+
}
1580+
}
1581+
1582+
private fun NavGraphBuilder.swapDetailSettings(
1583+
navController: NavHostController,
1584+
) {
1585+
composableWithDefaultTransitions<Routes.SwapDetail> {
1586+
SwapDetailScreen(
1587+
swapItem = it.toRoute(),
1588+
onBackClick = { navController.popBackStack() },
1589+
)
1590+
}
1591+
}
1592+
15671593
private fun NavGraphBuilder.lightningConnections(
15681594
navController: NavHostController,
15691595
) {
@@ -1824,6 +1850,7 @@ fun NavController.navigateToLocalCurrencySettings() = navigateTo(Routes.LocalCur
18241850
fun NavController.navigateToBackupSettings() = navigateTo(Routes.BackupSettings)
18251851

18261852
fun NavController.navigateToOrderDetail(id: String) = navigateTo(Routes.OrderDetail(id))
1853+
fun NavController.navigateToSwapDetail(id: String) = navigateTo(Routes.SwapDetail(id))
18271854

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

@@ -1960,6 +1987,12 @@ sealed interface Routes {
19601987
@Serializable
19611988
data object ChannelOrdersSettings : Routes
19621989

1990+
@Serializable
1991+
data object SwapsSettings : Routes
1992+
1993+
@Serializable
1994+
data class SwapDetail(val id: String) : Routes
1995+
19631996
@Serializable
19641997
data object Logs : Routes
19651998

0 commit comments

Comments
 (0)