feat: add boltz savings swap#1081
Conversation
cd6afdc to
45fce11
Compare
Greptile SummaryThis PR adds a Boltz reverse-swap path for moving Lightning funds into on-chain savings. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | Owns swap execution beyond the screen lifecycle, prevents concurrent starts, cancels stale quote requests, and subscribes before invoice payment. |
| app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | Starts claim event collection before the update stream, retries stream startup, and stops updates with the wallet lifecycle. |
| app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt | Observes swap outcomes and displays pending claims as settling rather than completed. |
| app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt | Adds swap quotes, amount controls, fee details, and channel-close fallback behavior. |
| app/src/main/java/to/bitkit/services/BoltzService.kt | Wraps Boltz swap creation, queries, claims, refunds, and lifecycle event streaming. |
Reviews (2): Last reviewed commit: "fix: fall back to close when swap amount..." | Re-trigger Greptile
Fixed the failing E2E shardsTwo shards were failing deterministically on every run of this branch, and both were caused by this PR:
Both perform the same steps and then time out waiting for Root cause. bitkit-core resolves the Boltz regtest API to That left if (!amountTooLow && quote == null) return@SwipeToConfirmso the swipe became a silent no-op. The failure screenshots confirm it: the GRAB circle is latched at the far right, the "close channel instead" button is visible, and there is no coop-close attempt anywhere in the log after the swipe. On Why not fix it in the E2E repo. These specs assert post-transfer state only a channel close can produce: Fix, in 4fea1ad:
The raw Tests added in Corrections to earlier replies in this thread
Known limitation, not addressed hereThe swap is sized from |
# Conflicts: # app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
|
We could run a Boltz instance in dev env in the future for better testing |
Agreed. For now I've been using mainnet for testing which will not be ideal long term. |
Correct, the confirm screen changes are not in design yet, so they are now behind a dev flag in 146af8f.
Tests added: the quote is skipped when the flag is off in I also dropped the changelog fragment, since a dev-flagged flow is not user-facing yet. It can come back in the PR that turns the flag on once the design lands.
|
This comment was marked as resolved.
This comment was marked as resolved.
# Conflicts: # gradle/libs.versions.toml
| */ | ||
| private suspend fun startSwapUpdates() { | ||
| var attempt = 0 | ||
| while (true) { |
There was a problem hiding this comment.
Done. The loop now stops after SWAP_UPDATES_MAX_ATTEMPTS (20, roughly 14 min of backoff) and logs that it gave up. Giving up is safe here because ensureSwapUpdatesRunning is re-triggered on the next node start and when entering a swap flow, so a paid swap is not left stranded.
There was a problem hiding this comment.
haven't find SWAP_UPDATES_MAX_ATTEMPTS in the code, probably not pushed
There was a problem hiding this comment.
jvsena42
left a comment
There was a problem hiding this comment.
Some style findings, no major issues
| val swaps = _swaps.asStateFlow() | ||
|
|
||
| private val _isLoading = MutableStateFlow(false) | ||
| val isLoading = _isLoading.asStateFlow() |
There was a problem hiding this comment.
Done. Removed _isLoading/isLoading in 5618b2f, nothing collected them so the flow was dead weight.
|
|
||
| private suspend fun credentials(): Pair<String, String?> { | ||
| val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) | ||
| ?: error("Mnemonic not found") |
There was a problem hiding this comment.
nit app conventions use AppError/requireNotNull
There was a problem hiding this comment.
Done. credentials() now throws ServiceError.MnemonicNotFound() in 5618b2f, matching how LightningRepo loads the same keychain entry.
| private val epochFormatter: DateTimeFormatter = | ||
| DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) | ||
|
|
||
| private fun formatEpochSeconds(seconds: ULong): String = | ||
| runCatching { epochFormatter.format(Instant.ofEpochSecond(seconds.toLong())) } | ||
| .getOrDefault(seconds.toString()) |
There was a problem hiding this comment.
there is existing utils for this in ext/DateTime
There was a problem hiding this comment.
Done. Dropped the local epochFormatter/formatEpochSeconds and switched both call sites to ULong.formatToString() from ext/DateTime in 5618b2f.
There was a problem hiding this comment.
cardColors, InfoCard, InfoCell, DetailRow could be extracted and shared under ui/components/settings/ The same components exist in ChannelOrdersScreen.kt
There was a problem hiding this comment.
Done. Moved cardColors, InfoCard, InfoCell and DetailRow into ui/components/settings/InfoCard.kt in 5618b2f, and pointed both SwapsScreen and ChannelOrdersScreen at them. The shared DetailRow keeps ChannelOrdersScreens isError flag plus SwapsScreens weight(1f, fill = false) on the value, so long values ellipsize instead of squeezing the label.

This PR adds a swap-based path for transferring spending balance to savings, so Lightning funds can move on-chain through a Boltz reverse swap instead of only by closing a channel.
Description
Preview
QA Notes
Manual Tests (swap path is mainnet only and needs Dev Settings -> Swaps -> Enable Savings Swap)
regression:Savings Confirm → Close channel instead: falls back to the channel-close path.regression:Start the app with a pending swap: swap resumes and balance updates once it lands on-chain.regression:On a regtest or testnet build, Transfer to Savings: confirm screen shows no quote, fees, slider, or "close channel instead", and the swipe closes the channel as before.regression:With Enable Savings Swap off on a mainnet build, Transfer to Savings: same pre-PR confirm screen and the swipe closes the channel.regression:After a swap fails, start a second transfer to savings in the same session: the second attempt runs normally instead of bouncing back to the transfer root.Automated Checks
TransferViewModelTest.kt.SwapsViewModelTest.kt.WalletViewModelTest.kt.compileDevDebugKotlin,testDevDebugUnitTest, anddetektall pass.