feat: add boltz savings swap - #632
Conversation
Bring the iOS savings-swap flow to parity with bitkit-android #1081: - Bump bitkit-core 0.5.1 -> 0.5.2 and pass acceptZeroConf so reverse swaps claim once the lockup hits the mempool - Make BoltzSwap.isClaimable permissive so the manual-claim recovery tool stays reachable when the updates stream stalls - Add a settling progress state instead of showing success while the on-chain claim is still landing - Move the updates-stream orchestration into WalletViewModel with retry/backoff and ensure it is running before starting a swap - Shorten the claim wait to 30s and let the swipe close the channel when the amount is below the swap minimum
Greptile SummaryThis PR adds a Boltz reverse-swap path for moving Lightning funds to on-chain savings. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| Bitkit/ViewModels/TransferViewModel.swift | Adds reverse-swap quotes, payment initiation, and a bounded wait for claim events. |
| Bitkit/ViewModels/WalletViewModel.swift | Adds swap-update startup, retry, shutdown, and balance refresh handling. |
| Bitkit/Services/BoltzService.swift | Wraps the Boltz API and forwards swap lifecycle events through asynchronous streams. |
| Bitkit/Views/Transfer/SavingsConfirmView.swift | Adds swap quotes, amount selection, and the channel-close fallback. |
| Bitkit/Views/Transfer/SavingsProgressView.swift | Runs the selected transfer path and shows pending claims as settling on-chain. |
| BitkitTests/SavingsSwapTests.swift | Covers quote calculations and manual claim eligibility. |
Reviews (2): Last reviewed commit: "refactor: simplify savings swap payment ..." | Re-trigger Greptile
Paying a Boltz hold invoice only settles once the swap is claimed on-chain, so awaiting the payment before starting the claim timeout could leave the progress screen spinning when a lockup is delayed. Run the hold-invoice payment in a background task and drive the outcome off the bounded claim wait, which now runs concurrently. A timeout means the transfer is settling in the background; a payment that fails to initiate is still surfaced.
…g send LDK returns a payment id as soon as a bolt11 payment is initiated; it does not block until a hold invoice settles, so awaiting the payment before the bounded claim wait does not gate the claim timeout. Restore the direct structure so a payment that fails to initiate surfaces immediately instead of after the claim timeout, and document the semantics so the ordering is not mistaken for a stuck flow.
Ports the latest bitkit-android #1081 refinement. Closing a channel is now the default and a priced quote is the only thing that upgrades a transfer to a swap, so the swipe always commits the transfer instead of going inert when no quote is available. Gate swaps to mainnet: Boltz's testnet deployment is deprecated and regtest expects a local backend, so on every other network the confirm screen shows no quote, fees, slider, or close-instead action and the swipe closes the channel exactly as it did before swaps existed. Skip the limits fetch entirely where swaps are unsupported, bound it to 15s elsewhere so a hanging Boltz request cannot leave the swipe stuck loading, and drop the quote error and amount-too-low state now that every failure simply leaves the quote nil. Also skip starting the swap updates stream on unsupported networks, and add an optional loading state to the swipe button so it cannot be swiped past a quote that is still being fetched.
Ports the bitkit-android #1081 dev gate. The savings swap UI is not final yet, so the flow now stays off until "Enable Savings Swap" is switched on under Dev Settings. With it off the confirm screen fetches no quote and resolves to the channel-close path, and the swap updates stream never starts, so the journey is exactly what shipped before swaps existed. Groups the toggle with the existing swaps list under a new Swaps section in Dev Settings, and drops the changelog fragment now that the flow is not user-facing.
Ports bitkit-android #1081. Paying the Boltz hold invoice returns as soon as the HTLC is dispatched, and a hold invoice never settles until Boltz claims on-chain, so a payment that fails to route went unnoticed: the swap idled into the claim timeout and reported a settling transfer that never completes. Watch the node's payment-failed event for the paid invoice alongside the on-chain claim so an unroutable payment resolves as a failure instead. The claim, a Boltz error, the routing failure, and the bounded timeout now race, and the payment id is matched against both the event's payment id and hash so a route-not-found failure is caught. Also cap the swap updates stream start retries at 20 attempts instead of retrying forever; the next node start or swap flow re-triggers it.
|
@coreyphillips please, fix the conflicts, I'm reviewing |
# Conflicts: # Bitkit.xcodeproj/project.pbxproj # Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
| .task { | ||
| // Disable screen timeout while this view is active | ||
| UIApplication.shared.isIdleTimerDisabled = true | ||
|
|
||
| do { | ||
| try await Task.sleep(nanoseconds: 2_000_000_000) | ||
|
|
||
| let channelsFailedToCoopClose = try await transfer.closeSelectedChannels() | ||
|
|
||
| if channelsFailedToCoopClose.isEmpty { | ||
| // Re-enable screen timeout when we're done | ||
| UIApplication.shared.isIdleTimerDisabled = false | ||
|
|
||
| withAnimation { | ||
| progressState = .success | ||
| } | ||
| } else { | ||
| // Check if any channels can be retried (filter out trusted peers) | ||
| let (_, nonTrustedChannels) = LightningService.shared.separateTrustedChannels(channelsFailedToCoopClose) | ||
|
|
||
| if nonTrustedChannels.isEmpty { | ||
| // All channels are trusted peers - show error and navigate back | ||
| UIApplication.shared.isIdleTimerDisabled = false | ||
| app.toast( | ||
| type: .error, | ||
| title: t("lightning__close_error"), | ||
| description: t("lightning__close_error_msg") | ||
| ) | ||
| navigation.reset() | ||
| } else { | ||
| withAnimation { | ||
| progressState = .failed | ||
| } | ||
|
|
||
| // Start retrying the cooperative close for non-trusted channels | ||
| transfer.startCoopCloseRetries(channels: nonTrustedChannels) | ||
| } | ||
| } | ||
| } catch { | ||
| app.toast(error) | ||
| switch transfer.savingsTransferMode { | ||
| case .swap: | ||
| await runSavingsSwap() |
There was a problem hiding this comment.
Re-entering the progress screen creates and pays a second swap.
the swap runs inside the view's .task, which SwiftUI cancels on disappear. TransferViewModel holds no in-flight guard and no result memo.
There was a problem hiding this comment.
Fixed in 27d7da6. The swap execution now lives on the view model as a memoized run: executeSavingsSwap starts at most one task per confirm commit, and re-entry awaits that same run, so SwiftUI cancelling and re-running the progress screen's .task neither cancels the in-flight swap nor creates and pays a second one. A completed run replays its result on re-entry instead of re-executing. onTransferToSavingsConfirm clears the memo, so each deliberate swipe commit still gets a fresh run. Owning the execution outside the view task also means view cancellation can no longer cut the outcome wait short mid-flow, which previously could resolve the race through the timeout branch early.
| /// stream a paid swap has nothing to broadcast its claim, so give up only after | ||
| /// `swapUpdatesMaxAttempts` and leave the next trigger to retry. Once started, bitkit-core | ||
| /// keeps the WebSocket alive with its own reconnect loop. | ||
| private func startSwapUpdatesWithRetry() async { |
There was a problem hiding this comment.
could swapUpdatesTask = nil when the retry loop exits without success for making possible recover after giving up
There was a problem hiding this comment.
Done in 27d7da6. startSwapUpdatesWithRetry now frees the task slot when it gives up, so the next trigger (node start or entering a swap flow) can start the stream again. The nil-out sits behind the existing Task.isCancelled check, so a stop/restart that already recycled the slot is not clobbered. On success the slot intentionally stays occupied: ensureSwapUpdatesRunning short-circuits on swapUpdatesRunning first, and stopSwapUpdates still clears both.
| .task(id: totalSats) { | ||
| // Pull the latest node balances so a just-received payment is reflected, then | ||
| // present the swap fee before the user commits. Recomputed when the amount changes. | ||
| await wallet.syncStateAsync() | ||
| guard totalSats > 0 else { return } | ||
| await transfer.loadSavingsSwapQuote( | ||
| requestedSat: totalSats, | ||
| spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) | ||
| ) |
There was a problem hiding this comment.
savingsSwapState.isLoading is only set inside loadSavingsSwapQuote, so during syncStateAsync() the swipe button is not in its loading state
There was a problem hiding this comment.
Fixed in 27d7da6. The confirm screen now calls beginSavingsSwapQuoteLoad before syncStateAsync, which puts savingsSwapState into its loading state behind the same isSwapEnabled gate (so regtest and the disabled flow keep the instant swipe as before), and loadSavingsSwapQuote keeps it through the fetch. The swipe button is therefore in its loading state across the whole sync plus quote window and cannot briefly land in the channel-close fallback. The totalSats == 0 early-out moved into loadSavingsSwapQuote so the loading marker is always cleared.
| /// thing that unlocks the swap, so every failure simply leaves it nil and the transfer | ||
| /// falls back to closing a channel. Skipped entirely where swaps are unsupported or the | ||
| /// flow is switched off in dev settings, see `BoltzService.isSwapEnabled`. | ||
| func loadSavingsSwapQuote(requestedSat: UInt64, spendableSats: UInt64) async { |
There was a problem hiding this comment.
could add a guard !Task.isCancelled . fetchReverseSwapLimits's network task isn't cancellation-aware. When .task(id:) restarts, the superseded invocation still assigns savingsSwapState on completion.
There was a problem hiding this comment.
Done in 27d7da6. loadSavingsSwapQuote now checks Task.isCancelled right after fetchReverseSwapLimits returns and bails without touching savingsSwapState, so a superseded .task(id:) invocation cannot clobber the state its replacement publishes. The reverseSwapLimits write sits behind the same guard.
| // the claim. A failure to dispatch the payment throws and is surfaced immediately. | ||
| let paymentId = try await lightningService.send(bolt11: swap.invoice) | ||
|
|
||
| let result = await awaitSwapOutcome( |
There was a problem hiding this comment.
Android maps event.reason.toUserMessage(context). The Event.paymentFailed payload carries the reason on iOS too
There was a problem hiding this comment.
Done in 27d7da6. The event handler now captures the reason from Event.paymentFailed, and the failure message goes through PaymentFailureReason.userMessage(for:), a mirror of Android's toUserMessage: recipientRejected, retriesExhausted, routeNotFound and paymentExpired map to their dedicated strings, and everything else (including a nil reason) falls back to the generic payment-failed description. The three strings Android has that iOS lacked were added to the English table with the same source text so Transifex keeps them aligned. Unit tests cover the mapping and the fallbacks.
| if await paymentFailure.hasFailed { | ||
| return .failure(message: paymentFailedMessage) | ||
| } | ||
| try? await Task.sleep(nanoseconds: 200_000_000) |
There was a problem hiding this comment.
this method polls SwapPaymentFailureCapture.hasFailed every 200 ms (TransferViewModel.swift:1334). A CheckedContinuation resumed from the event handler removes the loop and the latency
There was a problem hiding this comment.
Done in 27d7da6. SwapPaymentFailureCapture now parks the racing wait on a CheckedContinuation that the event handler resumes, so the failure branch fires the moment the node reports it and the 200 ms poll is gone. When another branch of the race wins, the group's cancellation resumes the wait as .cancelled through withTaskCancellationHandler, so the task group cannot hang on an unresumed continuation. The outcome is memoized, so a late waiter and a failure event arriving after cancellation are both handled deterministically. Unit tests cover resume-on-failure, the memo, and cancellation.
CI is red on a dependency collision, not on the swap codeAll five failing checks reduce to one error: Reproduced locally at Root causeThere is no bitkit-core version that satisfies both sides of the merge:
bitkit-core v0.4.2 was cut from a branch that never merged to its own
For the record on ordering: this branch pinned 0.5.2 in Option A — fix it in bitkit-core (preferred)Land Option B — drop the FFI dependency in this PRIf waiting on a bitkit-core release isn't practical, decoding locally removes the constraint entirely. Diff below, verified locally:
Verified: Patch (
|
BitkitCore.serializedExtendedPubkey only exists in bitkit-core v0.4.2, a tag cut from a branch that never merged to bitkit-core master, so no release carries both it and the Boltz module this branch needs. Replace the FFI call with a local base58check decode plus a secp256k1 point check on the trailing compressed key, byte-exact against bitkit-core's own test vectors. New regression tests cover the vectors and rejection cases, including an off-curve key. A TODO marks the revert once bitkit-core lands the symbol on master alongside Boltz.
Run at most one swap per confirm commit: the execution now lives on the view model as a memoized run, so re-entering the progress screen joins the in-flight swap instead of creating and paying a second one. Free the swap updates task slot when the retry loop gives up so a later trigger can start the stream again. Mark the quote as loading before the confirm screen's balance sync so the swipe cannot briefly land in the channel-close fallback, and drop a superseded quote load's state writes once its task is cancelled. Surface the node's payment failure reason in the failure message, mirroring Android's mapping, with new English strings for the three reasons that had none. Replace the 200 ms failure poll with a continuation resumed by the event handler.
Carries the Boltz review fixes: response validation binding the script hashlock and invoice amount, recovery keyed on local completion state, serialized updates stream replacement, and claim address plus fee rate validation at the entry points. The iOS bindings are byte-identical between 0.5.2 and 0.5.3, so no app code changes.
Align the claim gating with bitkit-core 0.5.3 recovery semantics: a cooperative claim can disclose the preimage before the broadcast lands, so Boltz settles the invoice while the funds still sit in the lockup. Core now keeps that swap pending and retries the claim, so the manual claim in Dev Settings stays reachable for it as well. A settled swap with a recorded claim txid remains hidden.
|
Addressed all six review comments in 27d7da6 and bumped bitkit-core to 0.5.3 in 24ddd45. The 0.5.3 release carries the Boltz review fixes from bitkit-core PR 116 (response validation binding the script hashlock and invoice amount, recovery keyed on local completion state, serialized updates stream replacement, and claim address plus fee rate validation at the entry points). The iOS bindings are byte-identical between 0.5.2 and 0.5.3, so the bump is a pin change only, with no app code affected. Unit suite passes locally against 0.5.3. Also aligned the manual claim gating with the new recovery semantics in ee07c98: a reverse swap Boltz reports settled but with no local claim txid keeps its Claim action in Dev Settings, since the preimage is disclosed while the claim broadcast may still be pending, and core now keeps that swap in the pending set and retries the claim. A settled swap with a recorded claim txid stays hidden. |
|
Thanks for the thorough analysis and for the ready-made patch. Went with Option B in 737918d so this PR is not blocked on a bitkit-core release. Checked upstream first: Applied your patch as posted, with one cosmetic tweak to the doc comment wording. Verified locally on the iOS 18.5 simulator: One unrelated note for anyone building from a cold cache: |
Regtest P2PKH addresses start with m or n depending on the hash, and the assertion message already said so, but the check only accepted m, so testNewAddressMatchesTypeFormat failed for roughly half of freshly generated legacy addresses in CI.
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. It ports the equivalent Android feature (synonymdev/bitkit-android#1081) to iOS, including its later refinements.
Description
Linked Issues/Tasks
Android counterpart: synonymdev/bitkit-android#1081
Screenshot / Video
Insert relevant screenshot / recording
QA Notes
Manual Tests (swap path needs mainnet plus Dev Settings → 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 → Savings Confirm: no quote, fees, slider, or close-instead action, and the swipe closes the channel as before.regression:With Dev Settings → Enable Savings Swap off, Transfer to Savings → Savings Confirm: no quote, fees, slider, or close-instead action, and the swipe closes the channel as before.Automated Checks
BitkitTests/SavingsSwapTests.swift.SavingsSwapTestspass.