Skip to content

feat: add boltz savings swap#1081

Open
coreyphillips wants to merge 17 commits into
masterfrom
feat/savings-swap
Open

feat: add boltz savings swap#1081
coreyphillips wants to merge 17 commits into
masterfrom
feat/savings-swap

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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

  • Adds a Boltz reverse swap flow to the transfer-to-savings journey. The confirm screen now shows a live quote (amount to savings, network fee, service fee, and the amount you will receive) and lets the user proceed with a swap or fall back to closing the channel.
  • The whole swap flow is off by default and switched on under Dev Settings -> Swaps -> Enable Savings Swap, since the confirm screen changes are not in design yet. With the flag off the journey is byte for byte the pre-PR one.
  • Swaps are offered on mainnet only. Boltz's testnet deployment is deprecated and regtest expects a local backend, so on every other network the journey behaves exactly as it did before this PR: the swipe closes the channel, with no quote, fees, slider, or extra close action.
  • Closing a channel is the default and a priced quote is the only thing that upgrades a transfer to a swap. Without one, for any reason (unsupported network, Boltz unreachable, or an amount below the swap minimum), the swipe closes the channel rather than doing nothing.
  • Waits for the on-chain claim during the progress step and resumes any pending swaps when the wallet starts, refreshing balances automatically once a swap lands on-chain. The updates stream now also stops with the node when the app goes to background, and only starts where swaps are supported.
  • Adds a Swaps section under Dev Settings to list swaps and claim a reverse swap, backed by a new service that wraps the Boltz module. The manual claim button only shows while the swap is actually claimable (lockup on-chain and no claim broadcast yet).
  • Adds a reusable slider component used by the swap amount controls.
  • Bumps bitkit-core to 0.5.2, which introduces the Boltz module this flow depends on.

Preview

QA Notes

Manual Tests (swap path is mainnet only and needs Dev Settings -> Swaps -> Enable Savings Swap)

  • 1. With a spending balance, Transfer to Savings → Savings Confirm: quote shows amount to savings, network fee, service fee, and you'll receive.
  • 2. Savings Confirm → change amount: quote recomputes for the new amount.
  • 3. Savings Confirm → set an amount below the swap minimum: no quote or fees show and the swipe closes the channel instead.
  • 4a. Savings Confirm → confirm swap → Savings Progress: completes after the on-chain claim.
    • 4b. After claim: savings balance reflects the swapped funds without a manual sync.
  • 5. regression: Savings Confirm → Close channel instead: falls back to the channel-close path.
  • 6. regression: Start the app with a pending swap: swap resumes and balance updates once it lands on-chain.
  • 7a. Dev Settings → Swaps → tap a swap → Swap Detail: Claim now shows only for an unclaimed reverse swap with its lockup in mempool or confirmed.
    • 7b. Swap Detail of a created, expired, refunded, or claimed swap: no Claim button.
  • 8. 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.
  • 8b. regression: With Enable Savings Swap off on a mainnet build, Transfer to Savings: same pre-PR confirm screen and the swipe closes the channel.
  • 9. 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

  • Unit tests added: savings swap quote limits, slider clamping, swap execution outcomes (claimed, error, timeout, payment failure), that a second start is ignored while a swap is in flight, that the quote is skipped when swaps are unsupported or disabled in dev settings, that a failed limits fetch resolves to the close path, and that confirming clears a previous swap outcome, in TransferViewModelTest.kt.
  • Unit tests added: claimable status gating, swap list sorting, and manual claim in SwapsViewModelTest.kt.
  • Unit tests added: the swap updates stream starts only where swaps are supported and enabled, in WalletViewModelTest.kt.
  • Ran locally against bitkit-core 0.5.2: compileDevDebugKotlin, testDevDebugUnitTest, and detekt all pass.

Comment thread app/src/main/java/to/bitkit/services/BoltzService.kt Fixed
@coreyphillips
coreyphillips marked this pull request as ready for review July 16, 2026 12:58
@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Boltz reverse-swap path for moving Lightning funds into on-chain savings. The main changes are:

  • Live swap quotes, fee details, and adjustable transfer amounts.
  • Background swap execution, claim tracking, and pending-swap recovery.
  • A settling state for swaps whose claims have not landed yet.
  • Developer screens for listing and manually claiming swaps.
  • A reusable amount slider and the required bitkit-core update.

Confidence Score: 5/5

This looks safe to merge.

  • The latest changes address the event subscription and screen lifecycle races.
  • Swap update startup now retries, and claim events trigger balance refreshes.
  • Pending claims are shown as settling instead of completed.
  • No blocking issue was found in the updated code.

Important Files Changed

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

Comment thread app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt Outdated
Comment thread app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Comment thread app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
@coreyphillips
coreyphillips requested a review from piotr-iohk July 20, 2026 15:14
@piotr-iohk
piotr-iohk marked this pull request as draft July 21, 2026 06:09
@piotr-iohk
piotr-iohk marked this pull request as ready for review July 21, 2026 06:09

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The E2E is failing because of this error. Maybe the CI need to be updated
Image
  1. Is this UI change in design? otherwise it should be gated under dev settings flag

@coreyphillips

Copy link
Copy Markdown
Contributor Author

Fixed the failing E2E shards

Two shards were failing deterministically on every run of this branch, and both were caused by this PR:

  • e2e-tests-local - lnurl_transfer@transfer_2 - Can open a channel to external node (transfer.e2e.ts:358)
  • e2e-tests-staging - multi_address_2_regtest → inside transferSpendingToSavings (actions.ts:1036)

Both perform the same steps and then time out waiting for TransferSuccess / TransferSuccess-button. Both shards build assembleDevDebug, so both run BoltzNetwork.REGTEST.

Root cause. bitkit-core resolves the Boltz regtest API to http://localhost:9001, which inside the emulator is the emulator's own loopback. The device logs from the failing run show it directly:

ERROR [TransferViewModel.kt:968] Failed to load reverse swap limits
[ApiException='errorDetails=Failed to fetch reverse pairs:
error sending request for url (http://localhost:9001/v2/swap/reverse)']

That left savingsSwapState as {quote = null, error = <msg>, amountTooLow = false}, and the confirm swipe guard was:

if (!amountTooLow && quote == null) return@SwipeToConfirm

so 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 master the swipe always navigated, so this was a regression from this branch, not a flake. Both shards are green on every other branch in the sampled window.

Why not fix it in the E2E repo. These specs assert post-transfer state only a channel close can produce: expectSpendingBalance(0) (actions.ts:1045) and expectText('Connection 1', {visible: false}) (transfer.e2e.ts:365). A reverse swap keeps the channel open and always leaves the routing-fee reserve behind, so it cannot satisfy them on any network. bitkit-e2e-tests is untouched.

Fix, in 4fea1ad:

  1. Env.isSwapSupported gates swaps to mainnet. Boltz's testnet deployment is deprecated (api.testnet.boltz.exchange currently returns 502) and regtest expects a local backend, so mainnet is the only network with a reachable API. Off mainnet the quote is never requested and the flow behaves exactly as it did before this PR.
  2. The swipe can no longer be inert. CLOSE is now the default mode and a quote is the single thing that upgrades it to SWAP, so a missing quote for any reason (unsupported network, Boltz down, amount below the swap minimum) closes the channel instead of doing nothing. This was a real dead-end on mainnet too, not just in CI.
  3. The limits fetch is bounded by a 15s timeout, so a hanging request cannot leave the swipe stuck in its loading state.
  4. ensureSwapUpdatesRunning() is gated the same way, so the updates stream no longer retries against an unreachable backend off mainnet.
  5. onTransferToSavingsConfirm now clears savingsSwapResult. Without this, a stale Failure from an earlier attempt would fire onTransferUnavailable() on the next transfer and bounce the user out mid-close. This became reachable once a failed swap can fall back to close in the same session.

The raw error and amountTooLow fields are gone from SavingsSwapUiState; quote != null is now the only predicate, and the unlocalized FFI error string is no longer rendered on screen (it is still logged).

Tests added in TransferViewModelTest.kt: the quote is skipped entirely when swaps are unsupported, a failed limits fetch resolves to CLOSE, mode resolution for both swipe and "close instead", and the stale-result reset. Added in WalletViewModelTest.kt: the updates stream starts only when swaps are supported. compileDevDebugKotlin, testDevDebugUnitTest, and detekt all pass.

Corrections to earlier replies in this thread

  • I said startSwapUpdates() "retries up to 3 times with a linear backoff (5s base) before giving up". That was true when written, but a later commit changed it to an unbounded while (true) loop capped at 60s. It stays unbounded; the network gate is what stops it from running where Boltz is unreachable.
  • The description says this bumps bitkit-core to 0.5.1. It actually bumps 0.4.1 → 0.5.2.

Known limitation, not addressed here

The swap is sized from walletRepo.balanceState.maxSendLightningSats (whole-wallet outbound), while the confirm screen is a per-channel selection flow. On the swap path the selected channels only feed channelsToClose, which is unused, so selecting one connection can move funds routed over another. The close path honours the selection exactly, as before. Happy to fix here or track it separately.

# Conflicts:
#	app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
@jvsena42

Copy link
Copy Markdown
Member

We could run a Boltz instance in dev env in the future for better testing

@jvsena42 jvsena42 added this to the 2.5.0 milestone Jul 21, 2026
@coreyphillips

Copy link
Copy Markdown
Contributor Author

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.

@coreyphillips

Copy link
Copy Markdown
Contributor Author
  1. Is this UI change in design? otherwise it should be gated under dev settings flag

Correct, the confirm screen changes are not in design yet, so they are now behind a dev flag in 146af8f.

  • New isSavingsSwapEnabled setting, default false, toggled at Dev Settings -> Swaps -> Enable Savings Swap. The existing Swaps list row moved into that same section.
  • BoltzService.isSwapEnabled() is the single gate: the network must have a reachable Boltz backend (mainnet) and the dev flag must be on. Both consumers use it, so the flag cannot be honoured in one place and missed in the other.
  • TransferViewModel.loadSavingsSwapQuote() returns before touching state when the gate is closed, so no quote is requested and SavingsSwapUiState stays at its defaults. Since the confirm screen keys every new element off quote != null, that means no quote, fees, slider, or "close channel instead", and the swipe closes the channel exactly as it did before this PR.
  • WalletViewModel.ensureSwapUpdatesRunning() is gated the same way, so with the flag off nothing subscribes to swap events and the updates stream never opens.

Tests added: the quote is skipped when the flag is off in TransferViewModelTest.kt, and the updates stream does not start when the flag is off in WalletViewModelTest.kt. The existing swap tests now enable the flag explicitly.

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.

compileDevDebugKotlin, testDevDebugUnitTest, and detekt pass.

@coreyphillips
coreyphillips requested a review from jvsena42 July 22, 2026 14:13
@jvsena42

This comment was marked as resolved.

Comment thread app/src/main/java/to/bitkit/services/BoltzService.kt
*/
private suspend fun startSwapUpdates() {
var attempt = 0
while (true) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could add a ceiling

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jvsena42 jvsena42 Jul 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't find SWAP_UPDATES_MAX_ATTEMPTS in the code, probably not pushed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it was on the wrong branch. That whole round of fixes got committed to feat/send-swap (#1101) instead of here, so this PR never carried it. Moved onto this branch in 6b0abdf, and #1101 was rebuilt on top of it so it now only holds its own commits.

Comment thread app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Comment thread app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some style findings, no major issues

val swaps = _swaps.asStateFlow()

private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value never collected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit app conventions use AppError/requireNotNull

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. credentials() now throws ServiceError.MnemonicNotFound() in 5618b2f, matching how LightningRepo loads the same keychain entry.

Comment on lines +353 to +358
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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is existing utils for this in ext/DateTime

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Dropped the local epochFormatter/formatEpochSeconds and switched both call sites to ULong.formatToString() from ext/DateTime in 5618b2f.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cardColors, InfoCard, InfoCell, DetailRow could be extracted and shared under ui/components/settings/ The same components exist in ChannelOrdersScreen.kt

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@coreyphillips
coreyphillips requested a review from jvsena42 July 24, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants