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
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.
|
https://github.com/synonymdev/bitkit-ios/actions/runs/30373648517/job/90572203950?pr=632 failure is caused by synonymdev/bitkit-e2e-tests#201 missing merge |
|
The remaining red check ( |
jvsena42
left a comment
There was a problem hiding this comment.
Good to merge. The feature is safely hidden under dev flag.
The user facing UI will be land on design V62
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.