Skip to content

feat: add boltz savings swap - #1081

Merged
jvsena42 merged 19 commits into
masterfrom
feat/savings-swap
Jul 28, 2026
Merged

feat: add boltz savings swap#1081
jvsena42 merged 19 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

This comment was marked as resolved.

@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
Comment thread app/src/main/java/to/bitkit/viewmodels/WalletViewModel.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 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

Comment thread app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/services/BoltzService.kt Outdated
Comment thread app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt Outdated
Comment thread app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt
@coreyphillips
coreyphillips requested a review from jvsena42 July 24, 2026 13:58

@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.

The reverse swaps are not marked as transfer to savings. Acceptable for now, giving this PR is a dev-only feature and we will probably have a more structured one for the final user

The dependency injection refactor can also be done in the next PR

Please, apply the Spacer refactor for prevent the AIs using it as reference

Comment thread app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt Outdated
Comment thread app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt Outdated
Comment thread app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt Outdated
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
@coreyphillips

Copy link
Copy Markdown
Contributor Author

Pushed 8163828:

  • SavingsConfirmScreen and SavingsProgressScreen now use VerticalSpacer/FillHeight instead of raw Spacer(modifier = Modifier.height(...)), and the unused Spacer/height imports are gone. Checked the rest of the diff too, no other raw spacers left in the touched UI files.
  • Marking reverse swaps as transfers to savings and moving BoltzService into TransferRepo/LightningRepo are deferred to the follow-up PR, as agreed.

compileDevDebugKotlin, testDevDebugUnitTest and detekt all pass.

@coreyphillips
coreyphillips requested a review from jvsena42 July 27, 2026 12:28

@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.

Safe to merge, all swaps flows are gated under the dev flag

Production design will come later in V62

@jvsena42
jvsena42 enabled auto-merge July 28, 2026 11:51
@jvsena42
jvsena42 merged commit 14fa929 into master Jul 28, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the feat/savings-swap branch July 28, 2026 12:05
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