Skip to content

feat: add boltz savings swap#1081

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

feat: add boltz savings swap#1081
coreyphillips wants to merge 13 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.
  • 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)

  • 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.
  • 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, 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, 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.

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