Skip to content

feat: add boltz savings swap - #632

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

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

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Jul 16, 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. It ports the equivalent Android feature (synonymdev/bitkit-android#1081) to iOS, including its later refinements.

Description

  • Adds a Boltz reverse swap flow to the transfer-to-savings journey. The confirm screen 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 flow is gated behind a new "Enable Savings Swap" toggle under Dev Settings while its UI is being finalised, so nothing changes for users until it is switched on. Dev Settings also gets a Swaps section grouping that toggle with the existing swaps list.
  • Swaps are offered on mainnet only. Boltz's testnet deployment is deprecated and regtest expects a local backend, so on every other network, and whenever the dev toggle is off, the journey behaves exactly as it did before this PR: the swipe closes the channel, with no quote, fees, slider, or extra close action, and the updates stream never starts.
  • 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. The quote fetch is bounded so a hanging Boltz request cannot leave the swipe stuck loading.
  • Holds back a small reserve for Lightning routing fees so the hold invoice can actually be paid.
  • Waits for the on-chain claim during the progress step. If the claim has not landed within the wait window (30s), the screen shows a "settling on-chain" state so the transfer reads as committed and completing in the background rather than as a false success.
  • Surfaces an unroutable payment as a failure instead of a false "settling" state. Paying the hold invoice returns as soon as the payment is dispatched and a hold invoice never settles until Boltz claims on-chain, so the progress step now watches the node's payment-failed event for the paid invoice alongside the claim: the claim, a Boltz error, a routing failure, and the timeout race, and whichever resolves first wins.
  • Resumes any pending swaps when the wallet starts and refreshes balances automatically once a swap lands on-chain. The updates stream is managed in the wallet view model: it only starts where swaps are supported and enabled, retries with backoff up to a ceiling (a paid swap needs the stream to broadcast its claim), stops with the node, and restarts on the next wallet start. The progress screen also ensures the stream is running before starting a swap.
  • Claims reverse swaps as soon as their lockup hits the mempool (acceptZeroConf) instead of waiting for a confirmation, for faster settlement.
  • 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 is a recovery tool, so it is offered for any unclaimed reverse swap that has not reached a terminal state, including a swap still marked created locally or one Boltz already reports settled while no claim transaction is recorded locally. A stalled updates stream can leave the cached status behind while the funds are already claimable on-chain, and the chain is the source of truth.
  • Adds a reusable continuous amount slider component used by the swap amount controls, and an optional loading state on the swipe-to-confirm button so it cannot be swiped while a quote is still being fetched.
  • Bumps bitkit-core to 0.5.3, which adds the Boltz module this flow depends on and the acceptZeroConf option on the updates stream.

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)

  • 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 → drag the amount slider: quote recomputes for the new amount.
  • 3. Savings Confirm with a balance below the swap minimum: no quote or fees show and the swipe closes the channel instead, with the separate close action hidden.
  • 4a. Savings Confirm → swipe to confirm → Savings Progress: completes after the on-chain claim, or shows the "settling on-chain" state if the claim is still landing.
    • 4b. After claim: savings balance reflects the swapped funds without a manual sync.
    • 4c. Swipe to confirm with no route to Boltz (e.g. no outbound liquidity): the transfer surfaces a payment failure instead of hanging on "settling".
  • 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 Details: Claim shows for any unclaimed reverse swap that is not in a terminal state, including one still marked created locally and one already reported settled with no local claim txid, the recovery cases.
    • 7b. Swap Details of a claimed, expired, refunded, or failed swap, or any submarine swap: no Claim button.
  • 8. 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.
  • 9. 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.
  • 10. Dev Settings → Swaps section: toggling Enable Savings Swap persists across an app restart.

Automated Checks

  • Unit tests added: savings swap quote fee breakdown, claimable status gating with permissive recovery semantics, the swap gate (unsupported off mainnet, off without the dev flag, and the dev flag alone not enabling it off mainnet), and the transfer resolving to the channel-close path without a quote, in BitkitTests/SavingsSwapTests.swift.
  • Ran locally against bitkit-core 0.5.3: simulator Debug build succeeds and SavingsSwapTests pass.
  • The swap execution path (payment/claim/failure race) is not unit tested: it drives the Boltz and Lightning singletons directly, which the existing suite does not mock; it mirrors the Android fix, which is unit tested there.
  • No changelog fragment: the flow is behind a dev toggle, so it is not user-facing yet.

@coreyphillips coreyphillips self-assigned this Jul 16, 2026
coreyphillips and others added 2 commits July 17, 2026 09:00
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
@coreyphillips
coreyphillips requested a review from piotr-iohk July 20, 2026 17:39
@coreyphillips
coreyphillips marked this pull request as ready for review July 20, 2026 17:39
@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

Greptile Summary

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

  • Live swap quotes with network and service fee details.
  • A bounded claim wait with a background settling state.
  • Automatic swap resumption, claim handling, and balance refresh.
  • Manual swap inspection and claim recovery in Dev Settings.
  • A reusable continuous amount slider and swap-focused tests.
  • An update to bitkit-core 0.5.2.

Confidence Score: 5/5

This looks safe to merge.

  • The payment call returns after initiation, so it does not delay the bounded claim wait.
  • Swap events are subscribed before payment starts.
  • No blocking issue related to the settlement timeout remains in the updated code.

Important Files Changed

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

Comment thread Bitkit/ViewModels/TransferViewModel.swift Outdated
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.
@piotr-iohk
piotr-iohk marked this pull request as draft July 21, 2026 06:08
@piotr-iohk
piotr-iohk marked this pull request as ready for review July 21, 2026 06:08
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.
@jvsena42

Copy link
Copy Markdown
Member

@coreyphillips please, fix the conflicts, I'm reviewing

# Conflicts:
#	Bitkit.xcodeproj/project.pbxproj
#	Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@ovitrif
ovitrif requested a review from jvsena42 July 27, 2026 16:04
Comment on lines 136 to +142
.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()

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.

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.

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.

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 {

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 swapUpdatesTask = nil when the retry loop exits without success for making possible recover after giving up

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

Comment on lines +135 to +143
.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))
)

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.

savingsSwapState.isLoading is only set inside loadSavingsSwapQuote, so during syncStateAsync() the swipe button is not in its loading state

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.

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 {

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 guard !Task.isCancelled . fetchReverseSwapLimits's network task isn't cancellation-aware. When .task(id:) restarts, the superseded invocation still assigns savingsSwapState on completion.

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 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(

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.

Android maps event.reason.toUserMessage(context). The Event.paymentFailed payload carries the reason on iOS too

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

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.

this method polls SwapPaymentFailureCapture.hasFailed every 200 ms (TransferViewModel.swift:1334). A CheckedContinuation resumed from the event handler removes the loop and the latency

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

@jvsena42

jvsena42 commented Jul 27, 2026

Copy link
Copy Markdown
Member

CI is red on a dependency collision, not on the swap code

All five failing checks reduce to one error:

Bitkit/Services/WatchOnlyAccountService.swift:913:34:
error: module 'BitkitCore' has no member named 'serializedExtendedPubkey'

Reproduced locally at 7f2089f7 — it is the only compile error; the savings-swap code itself builds clean against current master.

Root cause

There is no bitkit-core version that satisfies both sides of the merge:

bitkit-core serializedExtendedPubkey
(master needs)
boltzCreateReverseSwap
(this PR needs)
v0.4.2
v0.4.3
v0.5.0 / v0.5.1 / v0.5.2
master

bitkit-core v0.4.2 was cut from a branch that never merged to its own master. Its sole unique commit, dd99b46d "feat: expose serialized extended public keys", is stranded there:

$ gh api repos/synonymdev/bitkit-core/compare/v0.4.2...v0.5.2
{"status": "diverged", "ahead_by": 18, "behind_by": 1}

3c519555 "refactor: use core xpub serialization" (merged here via #630) then swapped a self-contained Base58Swift decode for that stranded symbol and dropped the Base58Swift dependency. That pinned the repo to a dead-end tag.

For the record on ordering: this branch pinned 0.5.2 in b1098344 on 2026-07-20; #630 landed the dependency on 2026-07-24. So this PR didn't introduce the problem — it's just the first to need a bitkit-core upgrade. Every future bump hits the same wall until it's resolved.


Option A — fix it in bitkit-core (preferred)

Land dd99b46d on bitkit-core master, cut a release carrying both it and the Boltz module, and bump this PR's pin to it. project.pbxproj and Package.resolved are the only files that change here, and #630's code is left untouched. This is the durable fix: it unblocks every consumer, not just this branch.

Option B — drop the FFI dependency in this PR

If waiting on a bitkit-core release isn't practical, decoding locally removes the constraint entirely. Diff below, verified locally:

  • Base58check decode + 78-byte length check + secp256k1_ec_pubkey_parse on the trailing compressed key, mirroring what Xpub::from_str does in Rust. secp256k1 is already linked into the target and used the same way in Utilities/Crypto.swift.
  • Output is byte-exact against bitkit-core's own Rust vectors (src/modules/onchain/extended_pubkey.rs @ v0.4.2) for both the mainnet xpub and the testnet tpub.
  • Six regression tests, including the off-curve-key case that exercises the secp256k1 parse rather than just the checksum.
  • Carries a TODO to revert once a proper release exists.

Verified: WatchOnlyAccountServiceTests + SavingsSwapTests41 tests, 0 failures. Full suite → 697 tests, 5 failures, all of them AddressTypeIntegrationTests/UtxoSelectionTests hitting 404 on api1.blocktank.to/api/regtest/chain/deposit (they need the regtest Docker backend, and CI runs them in the separate Run Integration Tests job).

Patch (git apply-able)
diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift
index 0d13db02..1d7384fc 100644
--- a/Bitkit/Services/WatchOnlyAccountService.swift
+++ b/Bitkit/Services/WatchOnlyAccountService.swift
@@ -3,6 +3,7 @@ import Combine
 import CryptoKit
 import Foundation
 import LDKNode
+import secp256k1
 
 enum WatchOnlyAccountSetupState: String, Codable {
     case pendingDelivery
@@ -904,19 +905,91 @@ enum WatchOnlyAccountClaimCodec {
         return claim
     }
 
+    /// Decode a BIP32 extended public key into its canonical 78-byte payload.
+    ///
+    /// This used to call `BitkitCore.serializedExtendedPubkey`, but that symbol only ever shipped
+    /// in bitkit-core v0.4.2 — a tag cut from a branch that never merged to bitkit-core `master`,
+    /// so no later release (0.4.3, any 0.5.x, or master) contains it. Depending on it pinned this
+    /// repo to a dead-end tag and blocked every bitkit-core upgrade. Decoding locally removes that
+    /// constraint.
+    ///
+    /// TODO: revert to `BitkitCore.serializedExtendedPubkey` once bitkit-core lands
+    /// `dd99b46d "feat: expose serialized extended public keys"` on `master` and cuts a release
+    /// carrying both it and the Boltz module. Delete this decoder and its helpers at that point;
+    /// the `testSerializedXpub*` cases pin the behaviour either way.
     static func serializedXpub(_ xpub: String) throws -> Data {
         guard xpub.count > 4 else {
             throw WatchOnlyAccountError.invalidExtendedPublicKey
         }
 
-        do {
-            let serialized = try BitkitCore.serializedExtendedPubkey(xpub: xpub)
-            guard serialized.count == serializedXpubLength else {
+        let payload = try base58CheckDecode(xpub)
+        guard payload.count == serializedXpubLength else {
+            throw WatchOnlyAccountError.invalidExtendedPublicKey
+        }
+
+        // A BIP32 xpub is version(4) ‖ depth(1) ‖ fingerprint(4) ‖ child(4) ‖ chaincode(32) ‖ key(33).
+        // Reject anything whose key is not a point on the curve, matching rust-bitcoin's Xpub parse.
+        let compressedKey = payload.suffix(33)
+        guard isValidCompressedPublicKey(compressedKey) else {
+            throw WatchOnlyAccountError.invalidExtendedPublicKey
+        }
+
+        return payload
+    }
+
+    private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8)
+
+    private static func base58CheckDecode(_ string: String) throws -> Data {
+        let decoded = try base58Decode(string)
+        guard decoded.count > 4 else {
+            throw WatchOnlyAccountError.invalidExtendedPublicKey
+        }
+
+        let payload = decoded.prefix(decoded.count - 4)
+        let checksum = decoded.suffix(4)
+        let expected = Data(SHA256.hash(data: Data(SHA256.hash(data: payload)))).prefix(4)
+        guard checksum.elementsEqual(expected) else {
+            throw WatchOnlyAccountError.invalidExtendedPublicKey
+        }
+
+        return Data(payload)
+    }
+
+    private static func base58Decode(_ string: String) throws -> Data {
+        var bytes: [UInt8] = [0]
+        for character in string.utf8 {
+            guard let digit = base58Alphabet.firstIndex(of: character) else {
                 throw WatchOnlyAccountError.invalidExtendedPublicKey
             }
-            return serialized
-        } catch {
-            throw WatchOnlyAccountError.invalidExtendedPublicKey
+
+            var carry = digit
+            for index in bytes.indices.reversed() {
+                carry += 58 * Int(bytes[index])
+                bytes[index] = UInt8(carry & 0xFF)
+                carry >>= 8
+            }
+            while carry > 0 {
+                bytes.insert(UInt8(carry & 0xFF), at: 0)
+                carry >>= 8
+            }
+        }
+
+        // Each leading '1' encodes a leading zero byte that the arithmetic above cannot represent.
+        let leadingZeros = string.utf8.prefix { $0 == base58Alphabet[0] }.count
+        let significant = bytes.drop { $0 == 0 }
+        return Data(repeating: 0, count: leadingZeros) + Data(significant)
+    }
+
+    private static func isValidCompressedPublicKey(_ key: Data) -> Bool {
+        guard key.count == 33, let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_NONE)) else {
+            return false
+        }
+        defer { secp256k1_context_destroy(context) }
+
+        var parsed = secp256k1_pubkey()
+        return Array(key).withUnsafeBufferPointer { buffer in
+            guard let baseAddress = buffer.baseAddress else { return false }
+            return secp256k1_ec_pubkey_parse(context, &parsed, baseAddress, buffer.count) == 1
         }
     }
 }
diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift
index 3ab0460b..49ea105f 100644
--- a/BitkitTests/WatchOnlyAccountServiceTests.swift
+++ b/BitkitTests/WatchOnlyAccountServiceTests.swift
@@ -11,6 +11,16 @@ private let alternateTestXpub =
 private let testSerializedXpubHex =
     "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" +
     "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d"
+private let mainnetTestXpub =
+    "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8" +
+    "YtGqsefD265TMg7usUDFdp6W1EGMcet8"
+private let mainnetSerializedXpubHex =
+    "0488b21e000000000000000000873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508" +
+    "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2"
+/// Base58check over version(4) ‖ 74 zero bytes: decodes cleanly, but its key is not on the curve.
+private let offCurveKeyXpub =
+    "xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU" +
+    "521AAwdZafEz7mnzBBsz4wKY5e4cp9LB"
 
 final class WatchOnlyAccountServiceTests: XCTestCase {
     func testUnsignedClaimContainsExactAccountMetadata() throws {
@@ -37,6 +47,56 @@ final class WatchOnlyAccountServiceTests: XCTestCase {
         }
     }
 
+    // MARK: - serializedXpub
+
+    /// Vectors below are bitkit-core's own, from `src/modules/onchain/extended_pubkey.rs` @ v0.4.2,
+    /// so the local decode stays byte-compatible with the FFI it replaces.
+    func testSerializedXpubMatchesCoreVectorForMainnetXpub() throws {
+        let serialized = try WatchOnlyAccountClaimCodec.serializedXpub(mainnetTestXpub)
+
+        XCTAssertEqual(serialized.count, WatchOnlyAccountClaimCodec.serializedXpubLength)
+        XCTAssertEqual(serialized, mainnetSerializedXpubHex.hexaData)
+    }
+
+    func testSerializedXpubMatchesCoreVectorForTestnetTpub() throws {
+        let serialized = try WatchOnlyAccountClaimCodec.serializedXpub(testXpub)
+
+        XCTAssertEqual(serialized.count, WatchOnlyAccountClaimCodec.serializedXpubLength)
+        XCTAssertEqual(serialized, testSerializedXpubHex.hexaData)
+    }
+
+    func testSerializedXpubRejectsInvalidBase58Character() throws {
+        // '0' is not in the base58 alphabet.
+        let invalidXpub = "0" + mainnetTestXpub.dropFirst()
+
+        XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(invalidXpub)) {
+            XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey)
+        }
+    }
+
+    func testSerializedXpubRejectsInvalidChecksum() throws {
+        let invalidXpub = String(mainnetTestXpub.dropLast()) + "1"
+
+        XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(invalidXpub)) {
+            XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey)
+        }
+    }
+
+    func testSerializedXpubRejectsWellFormedPayloadWithKeyOffTheCurve() throws {
+        // Valid base58check over a 78-byte payload whose 33-byte key is all zeros, so it decodes
+        // cleanly but is not a point on secp256k1. Guards the parse step, not just the checksum.
+        XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(offCurveKeyXpub)) {
+            XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey)
+        }
+    }
+
+    func testSerializedXpubRejectsPayloadOfTheWrongLength() throws {
+        // Base58check over 4 bytes: correct checksum, but nowhere near 78 bytes.
+        XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub("kz9795HmHu")) {
+            XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey)
+        }
+    }
+
     func testRestorePreservesAllocatorHighWaterMarkWhileClearingUnrestoredReservation() throws {
         let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)"
         let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))

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

Copy link
Copy Markdown
Contributor Author

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.

@coreyphillips

Copy link
Copy Markdown
Contributor Author

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: dd99b46d is still not on bitkit-core master, and the releases cut since (v0.4.4 and v0.5.3) do not carry it either, v0.5.3 being cut from the Boltz branch. So there is still no tag with both serializedExtendedPubkey and the Boltz module.

Applied your patch as posted, with one cosmetic tweak to the doc comment wording. Verified locally on the iOS 18.5 simulator: WatchOnlyAccountServiceTests plus SavingsSwapTests pass, 41 tests, 0 failures. The TODO to revert to BitkitCore.serializedExtendedPubkey stays in place, and the revert can land as a follow-up once bitkit-core gets dd99b46d onto master and cuts a release that also includes Boltz.

One unrelated note for anyone building from a cold cache: paykit-rs 0.1.0-rc37 currently has a missing LFS object on the remote (paykit-ffi/bindings/android/lib/src/main/jniLibs/arm64-v8a/libpaykit.so), which makes a fresh SwiftPM checkout fail. Excluding that path via lfs.fetchexclude works around it; the object should probably be restored upstream.

@coreyphillips
coreyphillips requested a review from jvsena42 July 28, 2026 15:10
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.
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.

2 participants