diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index a2cdb4832..bd8ec1f76 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1207,7 +1207,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.4.2; + version = 0.5.3; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 35ff21b4d..40c876eee 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "dd99b46d4b849a7716b45664136c4d1ce5e5905e", - "version" : "0.4.2" + "revision" : "4c859d3f2c0022fda25baa62a029e2c03d1892a7", + "version" : "0.5.3" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index d5f9d5a0f..36cfedbe0 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -467,6 +467,10 @@ struct AppScene: View { // Start watching pending orders after wallet is ready await blocktank.startWatchingPendingOrders(transferViewModel: transfer) + // Open the swap updates stream so any pending LN -> onchain swaps resume + // and auto-claim once their lockup confirms. Retries until the stream starts. + wallet.ensureSwapUpdatesRunning() + // Schedule full backup after wallet create/restore to prevent epoch dates in backup status await BackupService.shared.scheduleFullBackup() } catch { diff --git a/Bitkit/Components/AmountSlider.swift b/Bitkit/Components/AmountSlider.swift new file mode 100644 index 000000000..57165e3d1 --- /dev/null +++ b/Bitkit/Components/AmountSlider.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// Continuous slider over a `minValue`...`maxValue` range, styled to match `CustomSlider` +/// (same track and thumb) but without discrete steps. Used to pick a transfer amount +/// within its allowed limits. +struct AmountSlider: View { + @Binding var value: UInt64 + let minValue: UInt64 + let maxValue: UInt64 + + @State private var sliderWidth: CGFloat = 0 + + private var fraction: CGFloat { + guard maxValue > minValue else { return 0 } + let clamped = min(max(value, minValue), maxValue) + return CGFloat(clamped - minValue) / CGFloat(maxValue - minValue) + } + + private func value(at position: CGFloat) -> UInt64 { + guard sliderWidth > 0, maxValue > minValue else { return minValue } + let normalized = min(max(position / sliderWidth, 0), 1) + return minValue + UInt64((Double(maxValue - minValue) * Double(normalized)).rounded()) + } + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(height: 32) + .overlay( + ZStack { + // Track background + Rectangle() + .fill(Color.green32) + .frame(height: 8) + .cornerRadius(8) + + // Active track (from start to current position) + Rectangle() + .fill(Color.greenAccent) + .frame(width: fraction * sliderWidth, height: 8) + .cornerRadius(8) + .frame(maxWidth: .infinity, alignment: .leading) + + // Slider thumb + Circle() + .fill(Color.greenAccent) + .frame(width: 32, height: 32) + .overlay( + Circle() + .fill(Color.white) + .frame(width: 16, height: 16) + ) + .position(x: fraction * sliderWidth, y: 16) + .allowsHitTesting(false) + } + ) + .contentShape(Rectangle()) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + sliderWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { _, width in + sliderWidth = width + } + } + ) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + guard sliderWidth > 0 else { return } + value = value(at: gesture.location.x) + } + ) + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var value: UInt64 = 72000 + + var body: some View { + VStack { + AmountSlider(value: $value, minValue: 50000, maxValue: 100_000) + Text("\(value) sats") + } + .padding(32) + } + } + + return PreviewWrapper() + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index 3078cf756..2190ec9bc 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -3,12 +3,16 @@ import SwiftUI struct SwipeButton: View { let title: String let accentColor: Color + /// Blocks the swipe and shows the knob spinner while a prerequisite is still loading. + var isLoading = false /// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent. var swipeProgress: Binding? let onComplete: () async throws -> Void @State private var offset: CGFloat = 0 - @State private var isLoading = false + @State private var isSubmitting = false + + private var isBusy: Bool { isLoading || isSubmitting } private let buttonHeight: CGFloat = 76 private let innerPadding: CGFloat = 16 @@ -49,7 +53,7 @@ struct SwipeButton: View { .frame(width: buttonHeight - innerPadding, height: buttonHeight - innerPadding) .overlay( ZStack { - if isLoading { + if isBusy { ActivityIndicator(theme: .dark) } else { Image("arrow-right") @@ -72,21 +76,21 @@ struct SwipeButton: View { .gesture( DragGesture() .onChanged { value in - guard !isLoading else { return } + guard !isBusy else { return } withAnimation(.interactiveSpring()) { offset = value.translation.width swipeProgress?.wrappedValue = max(0, min(1, offset / maxOffset)) } } .onEnded { _ in - guard !isLoading else { return } + guard !isBusy else { return } withAnimation(.spring()) { let threshold = geometry.size.width * 0.7 if offset > threshold { Haptics.play(.medium) offset = geometry.size.width - buttonHeight swipeProgress?.wrappedValue = 1 - isLoading = true + isSubmitting = true Task { @MainActor in do { try await onComplete() @@ -99,7 +103,7 @@ struct SwipeButton: View { // Adjust the delay to match animation duration DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isLoading = false + isSubmitting = false } } } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index c0dfbc4b6..54d037750 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -129,6 +129,11 @@ enum Env { return .bitcoin }() + /// Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on + /// mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that + /// no build of ours can reach. Elsewhere the transfer to savings closes a channel instead. + static var isSwapSupported: Bool { network == .bitcoin } + static let ldkLogLevel = LDKNode.LogLevel.trace static let walletSyncIntervalSecs: UInt64 = 10 // TODO: play around with this diff --git a/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift b/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift new file mode 100644 index 000000000..6ebf71829 --- /dev/null +++ b/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift @@ -0,0 +1,21 @@ +import LDKNode + +extension PaymentFailureReason { + /// User-facing message for a failed payment, mirroring Android's + /// `PaymentFailureReason.toUserMessage`; reasons without a dedicated string fall back to + /// the generic payment-failed description. + static func userMessage(for reason: PaymentFailureReason?) -> String { + switch reason { + case .recipientRejected: + return t("wallet__toast_payment_failed_recipient_rejected") + case .retriesExhausted: + return t("wallet__toast_payment_failed_retries_exhausted") + case .routeNotFound: + return t("wallet__toast_payment_failed_route_not_found") + case .paymentExpired: + return t("wallet__toast_payment_failed_timeout") + default: + return t("wallet__toast_payment_failed_description") + } + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 700f4f0a2..215d58ace 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -584,6 +584,8 @@ struct MainNavView: View { case .probingTool: ProbingToolScreen() case .legacyRnRecovery: LegacyRnRecoveryScreen() case .orders: ChannelOrders() + case .swaps: SwapsListView() + case let .swapDetail(id): SwapDetailView(swapId: id) case .logs: LogView() case .trezor: TrezorRootView() } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 477a3f230..3f5756de1 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -221,11 +221,19 @@ "lightning__availability__text" = "Funds transfer to savings is usually instant, but settlement may take up to 14 days under certain network conditions."; "lightning__savings_confirm__label" = "Transfer to savings"; "lightning__savings_confirm__transfer_all" = "Transfer all"; +"lightning__savings_confirm__amount" = "To savings"; +"lightning__savings_confirm__amount_too_low" = "Amount is too low to transfer to savings."; +"lightning__savings_confirm__close_instead" = "Close channel instead"; +"lightning__savings_confirm__network_fee" = "Network fee"; +"lightning__savings_confirm__receive" = "You'll receive"; +"lightning__savings_confirm__service_fee" = "Service fee"; "lightning__savings_advanced__title" = "Select funds\nto transfer"; "lightning__savings_advanced__text" = "You can transfer part of your spending balance to savings, because you have multiple active Lightning Connections."; "lightning__savings_advanced__total" = "Total selected"; "lightning__savings_progress__title" = "Funds\nin transfer"; "lightning__savings_progress__text" = "Please wait, your funds transfer is in progress. This should take ±10 seconds."; +"lightning__savings_settling__title" = "Transfer\non the way"; +"lightning__savings_settling__text" = "Your transfer has been initiated and is settling on-chain. Your savings balance will update automatically once it completes."; "lightning__savings_interrupted__nav_title" = "Transfer\ninterrupted"; "lightning__savings_interrupted__title" = "Keep Bitkit\nopen"; "lightning__savings_interrupted__text" = "Funds were not transferred yet. Bitkit will try to initiate the transfer during the next 30 minutes. Please keep your app open."; @@ -1299,6 +1307,9 @@ "wallet__toast_payment_failed_title" = "Payment Failed"; "wallet__toast_payment_failed_description" = "Your instant payment failed. Please try again."; "wallet__toast_payment_failed_timeout" = "Payment timed out. Please try again."; +"wallet__toast_payment_failed_recipient_rejected" = "The recipient rejected this payment. Try a different amount."; +"wallet__toast_payment_failed_retries_exhausted" = "Could not find a route with sufficient liquidity. Try a smaller amount or wait and try again."; +"wallet__toast_payment_failed_route_not_found" = "Could not find a payment path to the recipient."; "wallet__toast_received_transaction_replaced_title" = "Received Transaction Replaced"; "wallet__toast_received_transaction_replaced_description" = "Your received transaction was replaced by a fee bump"; "wallet__toast_transaction_replaced_title" = "Transaction Replaced"; diff --git a/Bitkit/Services/BoltzService.swift b/Bitkit/Services/BoltzService.swift new file mode 100644 index 000000000..6fdc02d7a --- /dev/null +++ b/Bitkit/Services/BoltzService.swift @@ -0,0 +1,260 @@ +import BitkitCore +import Foundation +import LDKNode + +/// Thin wrapper around the bitkit-core Boltz swaps FFI (submarine + reverse swaps +/// between onchain Bitcoin and Lightning). +/// +/// Mirrors the existing service pattern (e.g. `CoreService`): a singleton that wraps +/// the FFI through `ServiceQueue` and bridges the `BoltzEventListener` foreign +/// callback to `AsyncStream`s. bitkit-core persists only a derivation index, never +/// key material; swap keys are re-derived on demand from the wallet mnemonic. +/// +/// The Lightning side (paying invoices, fresh onchain addresses) is owned by +/// `LightningService`; this service only talks to Boltz + the chain. +class BoltzService { + static let shared = BoltzService() + + private let continuationsLock = NSLock() + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + private lazy var listener = EventForwarder { [weak self] event in + Logger.info("Boltz event: \(event)", context: "BoltzService") + self?.emit(event) + } + + private init() {} + + // MARK: - Events + + /// Swap lifecycle events emitted while the updates stream is running. Each call + /// returns an independent stream; events are buffered from the moment the stream + /// is created, so subscribe before triggering the action whose events you await. + func events() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + continuationsLock.lock() + continuations[id] = continuation + continuationsLock.unlock() + + continuation.onTermination = { [weak self] _ in + guard let self else { return } + continuationsLock.lock() + continuations.removeValue(forKey: id) + continuationsLock.unlock() + } + } + } + + private func emit(_ event: BoltzSwapEvent) { + continuationsLock.lock() + let targets = Array(continuations.values) + continuationsLock.unlock() + + for continuation in targets { + continuation.yield(event) + } + } + + // MARK: - Limits + + func submarineLimits() async throws -> BoltzPairInfo { + try await ServiceQueue.background(.core) { + try await boltzGetSubmarineLimits(network: Self.boltzNetwork) + } + } + + func reverseLimits() async throws -> BoltzPairInfo { + try await ServiceQueue.background(.core) { + try await boltzGetReverseLimits(network: Self.boltzNetwork) + } + } + + // MARK: - Create + + /// Submarine swap: onchain BTC -> Lightning. Fund the returned lockup address. + func createSubmarineSwap(invoice: String) async throws -> SubmarineSwapResponse { + let (mnemonic, passphrase) = try credentials() + let response = try await ServiceQueue.background(.core) { + try await boltzCreateSubmarineSwap( + network: Self.boltzNetwork, + electrumUrl: Env.electrumServerUrl, + invoice: invoice, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + Logger.info("Created Boltz submarine swap \(response.id)", context: "BoltzService") + return response + } + + /// Reverse swap: Lightning -> onchain BTC. Pay the returned hold invoice. + func createReverseSwap(amountSat: UInt64, claimAddress: String) async throws -> ReverseSwapResponse { + let (mnemonic, passphrase) = try credentials() + let response = try await ServiceQueue.background(.core) { + try await boltzCreateReverseSwap( + network: Self.boltzNetwork, + electrumUrl: Env.electrumServerUrl, + amountSat: amountSat, + claimAddress: claimAddress, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + Logger.info("Created Boltz reverse swap \(response.id)", context: "BoltzService") + return response + } + + // MARK: - Query + + func listSwaps() async throws -> [BoltzSwap] { + try await ServiceQueue.background(.core) { + try await boltzListSwaps() + } + } + + func listPendingSwaps() async throws -> [BoltzSwap] { + try await ServiceQueue.background(.core) { + try await boltzListPendingSwaps() + } + } + + func getSwap(id: String) async throws -> BoltzSwap? { + try await ServiceQueue.background(.core) { + try await boltzGetSwap(swapId: id) + } + } + + // MARK: - Manual claim / refund + + func claimReverseSwap(id: String, feeRateSatPerVb: Double? = nil) async throws -> String { + let (mnemonic, passphrase) = try credentials() + return try await ServiceQueue.background(.core) { + try await boltzClaimReverseSwap( + swapId: id, + mnemonic: mnemonic, + bip39Passphrase: passphrase, + feeRateSatPerVb: feeRateSatPerVb + ) + } + } + + func refundSubmarineSwap(id: String, refundAddress: String, feeRateSatPerVb: Double? = nil) async throws -> String { + let (mnemonic, passphrase) = try credentials() + return try await ServiceQueue.background(.core) { + try await boltzRefundSubmarineSwap( + swapId: id, + refundAddress: refundAddress, + mnemonic: mnemonic, + bip39Passphrase: passphrase, + feeRateSatPerVb: feeRateSatPerVb + ) + } + } + + // MARK: - Updates stream + + /// Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim + /// reverse swaps. `feeRateSatPerVb` is the rate used for those auto-claims (Bitkit + /// owns fee estimation). `acceptZeroConf` claims a reverse swap as soon as its lockup + /// hits the mempool instead of waiting for its confirmation. Replaces any running stream. + func startUpdates(feeRateSatPerVb: Double?, acceptZeroConf: Bool = true) async throws { + let (mnemonic, passphrase) = try credentials() + try await ServiceQueue.background(.core) { + try await boltzStartSwapUpdates( + network: Self.boltzNetwork, + listener: self.listener, + mnemonic: mnemonic, + bip39Passphrase: passphrase, + feeRateSatPerVb: feeRateSatPerVb, + acceptZeroConf: acceptZeroConf + ) + } + Logger.info("Started Boltz updates stream on \(Self.boltzNetwork)", context: "BoltzService") + } + + func stopUpdates() async { + await boltzStopSwapUpdates() + Logger.info("Stopped Boltz updates stream", context: "BoltzService") + } + + // MARK: - Helpers + + /// Dev settings key for the savings swap gate. + static let savingsSwapEnabledKey = "savingsSwapEnabled" + + /// Whether the configured network has a reachable Boltz backend. See `Env.isSwapSupported`. + var isSwapSupported: Bool { Env.isSwapSupported } + + /// Whether swaps may run: the network needs a reachable Boltz backend and the savings swap + /// flow must be switched on in dev settings, since its UI is not final yet. + var isSwapEnabled: Bool { + isSwapSupported && UserDefaults.standard.bool(forKey: Self.savingsSwapEnabledKey) + } + + /// The Boltz network matching the app's configured network. + static var boltzNetwork: BoltzNetwork { + switch Env.network { + case .bitcoin: return .mainnet + case .testnet: return .testnet + case .regtest: return .regtest + // Boltz does not operate on signet; fall back to testnet for development. + default: return .testnet + } + } + + private func credentials() throws -> (mnemonic: String, passphrase: String?) { + let walletIndex = LightningService.shared.currentWalletIndex + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphraseRaw = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw + return (mnemonic, passphrase) + } +} + +/// Bridges the UniFFI foreign callback to the service's event streams. +private final class EventForwarder: BoltzEventListener { + private let handler: @Sendable (BoltzSwapEvent) -> Void + + init(handler: @escaping @Sendable (BoltzSwapEvent) -> Void) { + self.handler = handler + } + + func onEvent(event: BoltzSwapEvent) { + handler(event) + } +} + +extension BoltzSwap { + /// Whether a manual claim is worth attempting: a reverse swap with no claim broadcast yet + /// that has not reached a terminal state. + /// + /// Deliberately permissive about `status`. The persisted status only advances while the + /// updates stream is delivering events, so gating on it hides the recovery tool in exactly + /// the case it exists for: a stalled stream leaves the swap at `.swapCreated` locally even + /// after Boltz has locked up on-chain and the funds are claimable. The chain, not the cached + /// status, is the source of truth here, so offer the claim and let `boltzClaimReverseSwap` + /// decide; when there is nothing to claim it fails harmlessly and the error surfaces. + /// + /// `.invoiceSettled` is not terminal without a local claim txid: a cooperative claim can + /// disclose the preimage before the broadcast lands, so Boltz settles the invoice while the + /// funds still sit in the lockup. bitkit-core 0.5.3 keeps that swap pending and retries the + /// claim; the manual claim stays reachable for it too. + var isClaimable: Bool { + guard swapType == .reverse, claimTxId == nil else { return false } + switch status { + case .invoiceExpired, + .invoiceFailedToPay, + .swapExpired, + .transactionClaimed, + .transactionFailed, + .transactionLockupFailed, + .transactionRefunded: + return false + default: + return true + } + } +} diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 0d13db020..65d10ea25 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/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 8bde824b8..7512e757a 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -107,6 +107,8 @@ enum Route: Hashable { case probingTool case legacyRnRecovery case orders + case swaps + case swapDetail(id: String) case logs case trezor } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index b68ab74f5..3384af7de 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -112,6 +112,12 @@ class TransferViewModel: ObservableObject { @Published var channelsToClose: [ChannelDetails] = [] @Published var transferUnavailable = false + /// How the LN -> onchain "transfer to savings" is executed. Closing a channel is the + /// default because it always works; swapping funds out keeps channels open and is used + /// whenever a priced quote is available. + @Published var savingsTransferMode: SavingsTransferMode = .close + @Published var savingsSwapState = SavingsSwapState() + /// Hardware-wallet transfer-to-spending state. @Published var hwSpending = HwSpendingState() /// Bumped when a hardware funding tx is signed + broadcast, so the Sign screen advances. @@ -142,6 +148,22 @@ class TransferViewModel: ObservableObject { private let giveUpInterval: TimeInterval = 30 * 60 // 30 min private var coopCloseRetryTask: Task? + private let boltzService: BoltzService = .shared + /// The in-flight or completed swap execution for the current transfer commit. Held here so + /// SwiftUI cancelling and re-running the progress screen's `.task` neither cancels the swap + /// nor creates and pays a second one: re-entry awaits the same run and its memoized result. + private var savingsSwapRun: Task? + /// The amount (sat) that will actually be swapped out; adjustable via the confirm slider. + private var pendingSwapAmountSat: UInt64 = 0 + /// Cached swap limits so the slider can re-price locally without hitting the network. + private var reverseSwapLimits: BoltzPairInfo? + /// How long the confirm/progress flow waits for the on-chain claim before backgrounding it. + private let swapClaimTimeout: TimeInterval = 30 + /// Upper bound for fetching swap limits before the confirm screen gives up on a quote. + private let swapQuoteTimeout: TimeInterval = 15 + /// Minimum sats held back from a swap to cover Lightning routing fees. + private static let minLnRoutingFeeReserveSats: UInt64 = 10 + init( coreService: CoreService = .shared, lightningService: LightningService = .shared, @@ -947,9 +969,19 @@ class TransferViewModel: ObservableObject { selectedChannelIds = ids } - func onTransferToSavingsConfirm(channels: [ChannelDetails]) { + /// Commit the transfer and pick how it runs. A swap needs a priced quote, so without one + /// (swaps unsupported on this network, Boltz unreachable, or an amount below the swap + /// minimum) the transfer closes a channel exactly as it did before swaps existed. + func onTransferToSavingsConfirm(channels: [ChannelDetails], mode: SavingsTransferMode? = nil) { + savingsTransferMode = mode ?? resolvedSavingsTransferMode selectedChannelIds = [] channelsToClose = channels + // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. + savingsSwapRun = nil + } + + private var resolvedSavingsTransferMode: SavingsTransferMode { + savingsSwapState.quote != nil ? .swap : .close } func closeSelectedChannels() async throws -> [ChannelDetails] { @@ -1140,6 +1172,307 @@ class TransferViewModel: ObservableObject { return trustedChannels.count } + + // MARK: - Savings Swap (Boltz reverse swap) + + /// Fetch swap limits, derive the adjustable amount range, and publish an initial fee quote + /// (defaulting to the maximum transferable) so the user sees the cost before confirming. + /// The confirm slider then re-prices locally via `onSwapAmountChange`. A quote is the only + /// 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`. + /// Show the quote's loading state ahead of `loadSavingsSwapQuote`, covering work that runs + /// before the fetch (the confirm screen's balance sync) so the swipe cannot briefly land in + /// the channel-close fallback while a quote is still on its way. + func beginSavingsSwapQuoteLoad() { + guard boltzService.isSwapEnabled else { return } + savingsSwapState = SavingsSwapState(isLoading: true) + } + + func loadSavingsSwapQuote(requestedSat: UInt64, spendableSats: UInt64) async { + guard boltzService.isSwapEnabled else { return } + guard requestedSat > 0 else { + savingsSwapState = SavingsSwapState() + return + } + savingsSwapState = SavingsSwapState(isLoading: true) + + let fetchedLimits = await fetchReverseSwapLimits() + // The confirm screen restarts this whenever the amount changes; a superseded invocation + // must not clobber the state its replacement is about to publish. + guard !Task.isCancelled else { return } + + guard let limits = fetchedLimits else { + reverseSwapLimits = nil + savingsSwapState = SavingsSwapState() + return + } + reverseSwapLimits = limits + + // Reserve headroom for Lightning routing fees. Paying an invoice for 100% of + // outbound capacity leaves nothing for fees and fails to route, so cap the + // swap at outbound minus ~1% (with a small floor). + let routingReserve = max(spendableSats / 100, Self.minLnRoutingFeeReserveSats) + let sendable = spendableSats > routingReserve ? spendableSats - routingReserve : 0 + let maxSat = min(requestedSat, limits.maximalSat, sendable) + let minSat = limits.minimalSat + + guard maxSat >= minSat, maxSat > 0 else { + // Below the swap minimum: revert to the pre-swap view where the swipe closes + // the channel instead. No fees, slider, or extra close action are shown. + pendingSwapAmountSat = 0 + savingsSwapState = SavingsSwapState() + return + } + + // Default to transferring as much as possible; the slider can lower it. + pendingSwapAmountSat = maxSat + savingsSwapState = SavingsSwapState( + quote: SavingsSwapQuote.build(amountSat: maxSat, limits: limits), + minSat: minSat, + maxSat: maxSat + ) + } + + /// Bounded so a hanging Boltz request cannot leave the confirm swipe stuck loading. + private func fetchReverseSwapLimits() async -> BoltzPairInfo? { + await withTaskGroup(of: BoltzPairInfo?.self) { group in + group.addTask { + do { + return try await self.boltzService.reverseLimits() + } catch { + Logger.error("Failed to load reverse swap limits", context: error.localizedDescription) + return nil + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(self.swapQuoteTimeout * 1_000_000_000)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } + } + + /// Re-price the swap for a slider-selected amount, clamped to the allowed range. + func onSwapAmountChange(_ sat: UInt64) { + guard let limits = reverseSwapLimits, savingsSwapState.quote != nil, savingsSwapState.maxSat >= savingsSwapState.minSat else { + return + } + let amount = min(max(sat, savingsSwapState.minSat), savingsSwapState.maxSat) + pendingSwapAmountSat = amount + savingsSwapState.quote = SavingsSwapQuote.build(amountSat: amount, limits: limits) + } + + /// Execute the LN -> onchain swap: derive a fresh claim address, create the swap, pay the + /// returned hold invoice over Lightning, then wait for whichever resolves first: the on-chain + /// claim, a Boltz error, or a Lightning routing failure on the payment. A timeout is not a + /// failure; the claim is auto-broadcast by the updates stream once the lockup confirms, so the + /// swap completes in the background. `onEvent`/`removeEvent` register a node event listener so + /// an unroutable payment can be observed (see `awaitSwapOutcome`). + /// + /// At most one swap runs per confirm commit: the run is owned by the view model, so the + /// progress screen's `.task` being cancelled and re-entered joins the same run instead of + /// creating and paying a second swap. + func executeSavingsSwap( + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async -> SavingsSwapResult { + if let run = savingsSwapRun { + return await run.value + } + let run = Task { await performSavingsSwap(onEvent: onEvent, removeEvent: removeEvent) } + savingsSwapRun = run + return await run.value + } + + private func performSavingsSwap( + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async -> SavingsSwapResult { + let amount = pendingSwapAmountSat + guard amount > 0 else { + return .failure(message: t("lightning__savings_confirm__amount_too_low")) + } + + do { + let claimAddress = try await lightningService.newAddress() + let swap = try await boltzService.createReverseSwap(amountSat: amount, claimAddress: claimAddress) + Logger.info("Created savings transfer swap \(swap.id)", context: "TransferViewModel") + + // Subscribe before paying so a claim settling faster than the payment call returns is + // not missed (events buffer from stream creation). + let events = boltzService.events() + + // Pay the hold invoice. `send` returns a payment id as soon as the HTLC is dispatched; + // a hold invoice never settles until Boltz claims on-chain, so this does not block on + // 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( + swapId: swap.id, + paymentId: paymentId, + events: events, + onEvent: onEvent, + removeEvent: removeEvent + ) + await onBalanceRefresh?() + return result + } catch { + Logger.error("Savings transfer swap failed", context: error.localizedDescription) + return .failure(message: error.localizedDescription) + } + } + + /// Wait for whichever swap outcome resolves first. `send` returning does not mean the payment + /// routed, and a paid hold invoice never settles until the on-chain claim, so an unroutable + /// payment would otherwise idle into the claim timeout and read as a settling transfer that + /// never completes. Watching the node's payment-failed event alongside the claim surfaces it + /// as a failure instead. A timeout still resolves to `.pending`: the claim settles later. + private func awaitSwapOutcome( + swapId: String, + paymentId: String, + events: AsyncStream, + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async -> SavingsSwapResult { + let eventId = "savings-swap-\(swapId)" + let paymentFailure = SwapPaymentFailureCapture() + + // LDK events are dispatched on the main actor, so this handler runs there; it records a + // routing failure for the paid hold invoice and resumes the failure wait below. The + // returned payment id may land in either the event's payment id or its payment hash field. + onEvent(eventId) { event in + guard case let .paymentFailed(eventPaymentId, eventPaymentHash, reason) = event else { return } + guard [eventPaymentId, eventPaymentHash].compactMap({ $0 }).contains(paymentId) else { return } + Task { await paymentFailure.markFailed(reason: reason) } + } + + // Capture main-actor state locally so the detached group tasks stay Sendable. + let claimTimeout = swapClaimTimeout + + let outcome = await withTaskGroup(of: SavingsSwapResult?.self) { group in + // On-chain claim or a Boltz-side error. + group.addTask { + for await event in events { + switch event { + case let .claimed(swapId: id, txid: txid) where id == swapId: + return .success(txid: txid) + case let .error(swapId: id, message: message) where id == swapId: + return .failure(message: message) + default: + continue + } + } + return nil + } + // Lightning routing failure on the paid hold invoice, surfaced the moment the node + // reports it, with the failure reason mapped to a user-facing message. + group.addTask { + let wait = await withTaskCancellationHandler { + await paymentFailure.waitForFailure() + } onCancel: { + Task { await paymentFailure.cancelWaits() } + } + guard case let .failed(reason) = wait else { return nil } + return .failure(message: PaymentFailureReason.userMessage(for: reason)) + } + // Bounded wait: a timeout is not a failure, the claim settles in the background. + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(claimTimeout * 1_000_000_000)) + return .pending + } + + var result: SavingsSwapResult = .pending + for await value in group { + if let value { + result = value + break + } + } + group.cancelAll() + return result + } + + removeEvent(eventId) + return outcome + } +} + +/// Captures a Lightning routing failure for the savings swap's hold-invoice payment. The node +/// event handler runs on the main actor and resumes the waiting continuation, so the racing +/// wait in `awaitSwapOutcome` surfaces the failure the moment it is reported instead of polling. +actor SwapPaymentFailureCapture { + enum Wait: Equatable { + case failed(reason: PaymentFailureReason?) + case cancelled + } + + private var outcome: Wait? + private var waiters: [CheckedContinuation] = [] + + func markFailed(reason: PaymentFailureReason?) { + finish(with: .failed(reason: reason)) + } + + /// Unblocks the wait when its surrounding task is cancelled (another branch of the race + /// won); a continuation left pending would keep the task group from ever finishing. + func cancelWaits() { + finish(with: .cancelled) + } + + func waitForFailure() async -> Wait { + if let outcome { return outcome } + return await withCheckedContinuation { waiters.append($0) } + } + + private func finish(with wait: Wait) { + guard outcome == nil else { return } + outcome = wait + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume(returning: wait) + } + } +} + +/// Whether a transfer to savings swaps funds out or closes a channel (default). +enum SavingsTransferMode { + case swap + case close +} + +struct SavingsSwapQuote: Equatable { + let amountSat: UInt64 + let networkFeeSat: UInt64 + let swapFeeSat: UInt64 + let receiveSat: UInt64 + + /// Estimate the fee breakdown for swapping `amountSat` out under the given pair limits. + static func build(amountSat: UInt64, limits: BoltzPairInfo) -> SavingsSwapQuote { + let swapFee = UInt64(max(0, (Double(amountSat) * limits.feePercentage / 100.0).rounded())) + let networkFee = limits.minerFeesSat + let totalFees = swapFee + networkFee + let receive = amountSat > totalFees ? amountSat - totalFees : 0 + return SavingsSwapQuote(amountSat: amountSat, networkFeeSat: networkFee, swapFeeSat: swapFee, receiveSat: receive) + } +} + +struct SavingsSwapState { + var isLoading = false + var quote: SavingsSwapQuote? + /// Inclusive adjustable range for the confirm slider (sat). Equal/zero when unavailable. + var minSat: UInt64 = 0 + var maxSat: UInt64 = 0 +} + +enum SavingsSwapResult: Equatable { + /// Funds landed on-chain during the flow. + case success(txid: String) + /// Swap created and invoice paid; the claim completes in the background. + case pending + case failure(message: String) } /// Actor to safely capture channel data from channel pending events diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ef132b1b1..de489bb24 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -413,12 +413,92 @@ class WalletViewModel: ObservableObject { func stopLightningNode(clearEventCallback: Bool = false) async throws { nodeLifecycleState = .stopping + // Stop the swap updates stream with the node; it restarts on the next wallet start. + await stopSwapUpdates() try await lightningService.stop(clearEventCallback: clearEventCallback) nodeLifecycleState = .stopped probeOutcomes.removeAll() syncState() } + // MARK: - Boltz swap updates stream + + /// Base backoff between swap updates stream attempts; scales linearly per attempt. + private static let swapUpdatesRetryDelay: TimeInterval = 5 + /// Upper bound for the backoff between swap updates stream attempts. + private static let swapUpdatesRetryCap: TimeInterval = 60 + /// Ceiling on swap updates stream start attempts per run (~14 min of backoff). Giving up is + /// safe: the stream is retried on the next node start and when entering a swap flow. + private static let swapUpdatesMaxAttempts = 20 + + private var swapUpdatesTask: Task? + private var swapEventsTask: Task? + private var swapUpdatesRunning = false + + /// Ensure the swap updates stream is running so pending LN -> onchain swaps are tracked and + /// auto-claimed. A live stream is left untouched: restarting it would abort bitkit-core's + /// background tasks and could race an in-flight claim. Safe to call repeatedly. Runs only + /// where swaps are supported and enabled in dev settings, see `BoltzService.isSwapEnabled`. + func ensureSwapUpdatesRunning() { + guard BoltzService.shared.isSwapEnabled else { return } + collectSwapEventsOnce() + guard !swapUpdatesRunning, swapUpdatesTask == nil else { return } + swapUpdatesTask = Task { [weak self] in + await self?.startSwapUpdatesWithRetry() + } + } + + /// Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim. + /// Uses the wallet's current fee rate for the claim tx. Retries up to a ceiling: without the + /// 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 { + var attempt = 0 + while !Task.isCancelled, attempt < Self.swapUpdatesMaxAttempts { + do { + var feeRate: Double? + if let rates = await feeEstimatesManager.getEstimates() { + feeRate = Double(SettingsViewModel.shared.defaultTransactionSpeed.getFeeRate(from: rates)) + } + try await BoltzService.shared.startUpdates(feeRateSatPerVb: feeRate, acceptZeroConf: true) + swapUpdatesRunning = true + return + } catch { + attempt += 1 + Logger.warn("Failed to start swap updates, attempt \(attempt)", context: "WalletViewModel") + let delay = min(Self.swapUpdatesRetryDelay * Double(attempt), Self.swapUpdatesRetryCap) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + if !Task.isCancelled { + Logger.warn("Gave up starting swap updates after \(attempt) attempts", context: "WalletViewModel") + // Free the slot so the next trigger (node start or entering a swap flow) can retry; + // a parked spent task would keep ensureSwapUpdatesRunning from ever starting one. + swapUpdatesTask = nil + } + } + + /// Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. + private func collectSwapEventsOnce() { + guard swapEventsTask == nil else { return } + swapEventsTask = Task { [weak self] in + for await event in BoltzService.shared.events() { + if case let .claimed(swapId, _) = event { + Logger.info("Savings swap claimed: \(swapId)", context: "WalletViewModel") + await self?.syncStateAsync() + } + } + } + } + + private func stopSwapUpdates() async { + swapUpdatesTask?.cancel() + swapUpdatesTask = nil + swapUpdatesRunning = false + await BoltzService.shared.stopUpdates() + } + func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { let finalExpirySecs = expirySecs ?? 60 * 60 * 24 let invoice = try await lightningService.receive(amountSats: amountSats, description: note, expirySecs: finalExpirySecs) diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 6cb392d05..525245834 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -6,6 +6,7 @@ struct DevSettingsView: View { @AppStorage(ContactPaymentsService.confirmedPreferenceKey) private var hasConfirmedPublicPaykitEndpoints = false @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false + @AppStorage(BoltzService.savingsSwapEnabledKey) private var isSavingsSwapEnabled = false @EnvironmentObject var app: AppViewModel @EnvironmentObject var activity: ActivityListViewModel @@ -58,6 +59,20 @@ struct DevSettingsView: View { } .accessibilityIdentifier("Trezor") + SettingsSectionHeader("SWAPS") + .padding(.top, 16) + + NavigationLink(value: Route.swaps) { + SettingsRow(title: "Swaps") + } + + SettingsRow( + title: "Enable Savings Swap", + rightIcon: nil, + toggle: $isSavingsSwapEnabled, + testIdentifier: "SavingsSwapToggle" + ) + SettingsSectionHeader("RECOVERY") .padding(.top, 16) diff --git a/Bitkit/Views/Settings/SwapsListView.swift b/Bitkit/Views/Settings/SwapsListView.swift new file mode 100644 index 000000000..1df1c56d1 --- /dev/null +++ b/Bitkit/Views/Settings/SwapsListView.swift @@ -0,0 +1,231 @@ +import BitkitCore +import SwiftUI + +struct SwapsListView: View { + @State private var swaps: [BoltzSwap] = [] + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + List { + if let errorMessage { + Text("Error: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + } + + if swaps.isEmpty, !isLoading, errorMessage == nil { + Text("No swaps found") + .font(.caption) + .foregroundColor(.gray) + } else { + ForEach(swaps, id: \.id) { swap in + NavigationLink(value: Route.swapDetail(id: swap.id)) { + SwapRow(swap: swap) + } + } + } + } + .navigationTitle("Swaps") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await refresh() + } + .task { + await refresh() + } + } + + private func refresh() async { + do { + let list = try await BoltzService.shared.listSwaps() + swaps = list.sorted { $0.createdAt > $1.createdAt } + errorMessage = nil + } catch { + Logger.error("Failed to list swaps", context: error.localizedDescription) + errorMessage = error.localizedDescription + } + isLoading = false + } +} + +private struct SwapRow: View { + let swap: BoltzSwap + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(swap.id) + .font(.system(size: swap.id.count > 20 ? 10 : 12, design: .monospaced)) + .lineLimit(1) + Spacer() + Text(String(describing: swap.status)) + .font(.caption) + .padding(4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + + HStack { + VStack(alignment: .leading) { + Text("Type") + .font(.caption) + .foregroundColor(.gray) + Text(String(describing: swap.swapType)) + .font(.subheadline) + } + Spacer() + VStack(alignment: .trailing) { + Text("Amount") + .font(.caption) + .foregroundColor(.gray) + Text("\(swap.amountSat) sats") + .font(.subheadline) + } + } + + HStack { + VStack(alignment: .leading) { + Text("Receives") + .font(.caption) + .foregroundColor(.gray) + Text(swap.onchainAmountSat.map { "\($0) sats" } ?? "-") + .font(.subheadline) + } + Spacer() + VStack(alignment: .trailing) { + Text("Created") + .font(.caption) + .foregroundColor(.gray) + Text(formatEpochSeconds(swap.createdAt)) + .font(.subheadline) + } + } + } + .padding(.vertical, 4) + } +} + +struct SwapDetailView: View { + let swapId: String + + @EnvironmentObject var app: AppViewModel + @State private var swap: BoltzSwap? + @State private var isClaiming = false + + var body: some View { + List { + if let swap { + Section("Overview") { + SwapDetailRow(label: "ID", value: swap.id) + SwapDetailRow(label: "Type", value: String(describing: swap.swapType)) + SwapDetailRow(label: "Status", value: String(describing: swap.status)) + SwapDetailRow(label: "Network", value: String(describing: swap.network)) + } + + Section("Amounts") { + SwapDetailRow(label: "Amount", value: "\(swap.amountSat) sats") + SwapDetailRow(label: "Onchain amount", value: swap.onchainAmountSat.map { "\($0) sats" } ?? "-") + } + + Section("Addresses") { + SwapDetailRow(label: "Lockup", value: swap.lockupAddress ?? "-") + SwapDetailRow(label: "Claim / onchain", value: swap.onchainAddress ?? "-") + } + + if let invoice = swap.invoice { + Section("Lightning") { + SwapDetailRow(label: "Invoice", value: invoice) + } + } + + Section("Transactions") { + SwapDetailRow(label: "Claim txid", value: swap.claimTxId ?? "-") + SwapDetailRow(label: "Refund txid", value: swap.refundTxId ?? "-") + } + + Section("Recovery") { + SwapDetailRow(label: "Swap index", value: String(swap.swapIndex)) + SwapDetailRow(label: "Timeout block", value: String(swap.timeoutBlockHeight)) + } + + Section("Timestamps") { + SwapDetailRow(label: "Created", value: formatEpochSeconds(swap.createdAt)) + } + + if swap.isClaimable { + Section { + Button { + claim() + } label: { + if isClaiming { + ProgressView() + } else { + Text("Claim now") + } + } + .disabled(isClaiming) + } + } + } else { + Text("Loading...") + .font(.caption) + .foregroundColor(.gray) + } + } + .navigationTitle("Swap Details") + .navigationBarTitleDisplayMode(.inline) + .task { + await refresh() + } + } + + private func refresh() async { + do { + swap = try await BoltzService.shared.getSwap(id: swapId) + } catch { + Logger.error("Failed to load swap '\(swapId)'", context: error.localizedDescription) + } + } + + /// Manually broadcast the claim for a reverse swap (recovery when auto-claim didn't fire). + private func claim() { + isClaiming = true + Task { + do { + let txid = try await BoltzService.shared.claimReverseSwap(id: swapId) + app.toast(type: .success, title: "Claim broadcast", description: txid) + await refresh() + } catch { + Logger.error("Manual claim failed for '\(swapId)'", context: error.localizedDescription) + app.toast(type: .error, title: "Claim failed", description: error.localizedDescription) + } + isClaiming = false + } + } +} + +private struct SwapDetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundColor(.gray) + Spacer() + CopyableText(text: value) + } + } +} + +private let epochFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter +}() + +private func formatEpochSeconds(_ seconds: UInt64) -> String { + epochFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) +} diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index bdde774eb..cf29c1824 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -34,6 +34,14 @@ struct SavingsConfirmView: View { channels.reduce(0) { $0 + $1.balanceOnCloseSats } } + private var swapState: SavingsSwapState { + transfer.savingsSwapState + } + + private var headlineSats: UInt64 { + swapState.quote?.amountSat ?? totalSats + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) @@ -46,7 +54,11 @@ struct SavingsConfirmView: View { .padding(.top, 32) .padding(.bottom, 16) - MoneyText(sats: Int(totalSats), size: .display, symbol: true) + MoneyText(sats: Int(headlineSats), size: .display, symbol: true) + + if let quote = swapState.quote { + quoteSection(quote) + } if hasMultipleChannels { HStack(spacing: 16) { @@ -66,22 +78,32 @@ struct SavingsConfirmView: View { Spacer() - // Piggybank image - Image("piggybank-right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity, alignment: .center) + // Flexible middle: the piggybank shrinks when the fees/slider are shown and + // gives way to a spinner while the quote loads. + if swapState.quote == nil, swapState.isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 32) + } else { + Image("piggybank-right") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity, alignment: .center) + } Spacer() if !hideSwipeButton { SwipeButton( title: t("lightning__transfer__swipe"), - accentColor: .brandAccent + accentColor: .brandAccent, + isLoading: swapState.quote == nil && swapState.isLoading ) { + // The swipe always commits the transfer: it swaps when a quote is ready and + // otherwise falls back to the pre-swap behaviour of closing the channel, so it + // is never inert. do { - // Process transfer to savings action transfer.onTransferToSavingsConfirm(channels: channels) try await Task.sleep(nanoseconds: 300_000_000) @@ -96,11 +118,64 @@ struct SavingsConfirmView: View { } } } + + if swapState.quote != nil { + // Fallback: drain a whole channel on-chain by closing it instead of swapping. + CustomButton(title: t("lightning__savings_confirm__close_instead"), variant: .tertiary) { + transfer.onTransferToSavingsConfirm(channels: channels, mode: .close) + navigation.navigate(.savingsProgress) + } + .padding(.top, 12) + } } .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) + .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. + // The quote reads as loading across the sync too, so the swipe cannot land in the + // channel-close fallback while a quote is still on its way. + transfer.beginSavingsSwapQuoteLoad() + await wallet.syncStateAsync() + await transfer.loadSavingsSwapQuote( + requestedSat: totalSats, + spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) + ) + } + } + + @ViewBuilder + private func quoteSection(_ quote: SavingsSwapQuote) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 16) { + FeeDisplayRow(label: t("lightning__savings_confirm__network_fee"), amount: quote.networkFeeSat) + .frame(maxWidth: .infinity, alignment: .leading) + FeeDisplayRow(label: t("lightning__savings_confirm__service_fee"), amount: quote.swapFeeSat) + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(alignment: .top, spacing: 16) { + FeeDisplayRow(label: t("lightning__savings_confirm__amount"), amount: quote.amountSat) + .frame(maxWidth: .infinity, alignment: .leading) + FeeDisplayRow(label: t("lightning__savings_confirm__receive"), amount: quote.receiveSat) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Adjust how much to move to savings, bounded to a payable range. + if swapState.maxSat > swapState.minSat { + AmountSlider( + value: Binding( + get: { quote.amountSat }, + set: { transfer.onSwapAmountChange($0) } + ), + minValue: swapState.minSat, + maxValue: swapState.maxSat + ) + .padding(.top, 16) + } + } + .padding(.top, 24) } } @@ -111,6 +186,7 @@ struct SavingsConfirmView: View { .environmentObject(AppViewModel()) .environmentObject(CurrencyViewModel()) .environmentObject(TransferViewModel()) + .environmentObject(NavigationViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index 8abeaa670..7aef3f6d5 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -2,6 +2,9 @@ import SwiftUI enum SavingsProgressState { case inProgress + /// Swap hold invoice is paid but the on-chain claim has not landed within the wait window. + /// The claim auto-broadcasts once the lockup appears, so the transfer is committed and settling. + case settling case success case failed } @@ -18,7 +21,7 @@ struct SavingsProgressContentView: View { var navTitle: String { switch progressState { - case .inProgress: return t("lightning__transfer__nav_title") + case .inProgress, .settling: return t("lightning__transfer__nav_title") case .failed: return t("lightning__savings_interrupted__nav_title") case .success: return t("lightning__transfer__nav_title") } @@ -27,6 +30,7 @@ struct SavingsProgressContentView: View { var title: String { switch progressState { case .inProgress: return t("lightning__savings_progress__title") + case .settling: return t("lightning__savings_settling__title") case .failed: return t("lightning__savings_interrupted__title") case .success: return t("lightning__transfer_success__title_savings") } @@ -35,6 +39,7 @@ struct SavingsProgressContentView: View { var text: String { switch progressState { case .inProgress: return t("lightning__savings_progress__text") + case .settling: return t("lightning__savings_settling__text") case .failed: return t("lightning__savings_interrupted__text") case .success: return t("lightning__transfer_success__text_savings") } @@ -52,7 +57,7 @@ struct SavingsProgressContentView: View { Spacer() - if progressState == .inProgress { + if progressState == .inProgress || progressState == .settling { ZStack(alignment: .center) { // Outer ellipse Image("ellipse-outer-brand") @@ -123,6 +128,7 @@ struct SavingsProgressView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var transfer: TransferViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel @State private var progressState: SavingsProgressState = .inProgress var body: some View { @@ -131,42 +137,11 @@ struct SavingsProgressView: View { // 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() + case .close: + await runChannelClose() } } .onDisappear { @@ -184,6 +159,82 @@ struct SavingsProgressView: View { } } } + + /// Swaps spending funds out to on-chain savings. A pending claim is shown as "settling" + /// rather than success: the hold invoice is paid and the updates stream auto-claims it in + /// the background, so the transfer is committed but not yet landed on-chain. + private func runSavingsSwap() async { + // Ensure the updates stream is running so the new swap is tracked and auto-claimed + // once its lockup appears, even if the launch-time start had not yet succeeded. + wallet.ensureSwapUpdatesRunning() + + let result = await transfer.executeSavingsSwap( + onEvent: { id, handler in wallet.addOnEvent(id: id, handler: handler) }, + removeEvent: { id in wallet.removeOnEvent(id: id) } + ) + UIApplication.shared.isIdleTimerDisabled = false + + switch result { + case .success: + await wallet.syncStateAsync() + withAnimation { + progressState = .success + } + case .pending: + await wallet.syncStateAsync() + withAnimation { + progressState = .settling + } + case let .failure(message): + app.toast( + type: .error, + title: t("common__error"), + description: message + ) + navigation.reset() + } + } + + /// Legacy path: cooperatively close the selected channel(s), retrying on failure. + private func runChannelClose() async { + 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) + } + } } #Preview("In Progress") { @@ -195,6 +246,15 @@ struct SavingsProgressView: View { .preferredColorScheme(.dark) } +#Preview("Settling") { + NavigationStack { + SavingsProgressContentView(progressState: .settling) + .environmentObject(AppViewModel()) + .environmentObject(TransferViewModel()) + } + .preferredColorScheme(.dark) +} + #Preview("Success") { NavigationStack { SavingsProgressContentView(progressState: .success) diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index ed106c44d..3e09d7846 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -309,17 +309,18 @@ final class AddressTypeIntegrationTests: XCTestCase { XCTAssertTrue(success, "Enabling \(type.stringValue) monitoring should succeed") } - let expectations: [(LDKNode.AddressType, String, String)] = [ - (.legacy, "m", "Legacy address should start with m or n on regtest"), - (.nestedSegwit, "2", "Nested SegWit address should start with 2 on regtest"), - (.nativeSegwit, "bcrt1q", "Native SegWit address should start with bcrt1q on regtest"), - (.taproot, "bcrt1p", "Taproot address should start with bcrt1p on regtest"), + // Regtest P2PKH addresses start with m or n depending on the hash, so legacy accepts both. + let expectations: [(LDKNode.AddressType, [String], String)] = [ + (.legacy, ["m", "n"], "Legacy address should start with m or n on regtest"), + (.nestedSegwit, ["2"], "Nested SegWit address should start with 2 on regtest"), + (.nativeSegwit, ["bcrt1q"], "Native SegWit address should start with bcrt1q on regtest"), + (.taproot, ["bcrt1p"], "Taproot address should start with bcrt1p on regtest"), ] - for (type, prefix, message) in expectations { + for (type, prefixes, message) in expectations { let address = try await settings.lightningService.newAddressForType(type) Logger.test("\(type.stringValue) address: \(address)", context: "AddressTypeIntegrationTests") - XCTAssertTrue(address.hasPrefix(prefix), "\(message), got: \(address)") + XCTAssertTrue(prefixes.contains(where: address.hasPrefix), "\(message), got: \(address)") } } } diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift new file mode 100644 index 000000000..0f7aed361 --- /dev/null +++ b/BitkitTests/SavingsSwapTests.swift @@ -0,0 +1,238 @@ +import BitkitCore +import LDKNode +import XCTest + +@testable import Bitkit + +final class SavingsSwapTests: XCTestCase { + // MARK: - Quote math + + func testQuoteBuildsFeeBreakdown() { + let quote = SavingsSwapQuote.build(amountSat: 148_500, limits: limits()) + + XCTAssertEqual(quote.amountSat, 148_500) + XCTAssertEqual(quote.networkFeeSat, 300) + // 0.5% of 148_500 = 742.5, rounded to 743 + XCTAssertEqual(quote.swapFeeSat, 743) + XCTAssertEqual(quote.receiveSat, 148_500 - 743 - 300) + } + + func testQuoteClampsReceiveAtZeroWhenFeesExceedAmount() { + let quote = SavingsSwapQuote.build(amountSat: 100, limits: limits(minerFeesSat: 500)) + + XCTAssertEqual(quote.receiveSat, 0) + } + + // MARK: - Network support + + func testSwapsAreUnsupportedOffMainnet() { + // Unit tests run on regtest, where Boltz resolves to a local backend no build can reach. + XCTAssertEqual(Env.network, .regtest) + XCTAssertFalse(Env.isSwapSupported) + XCTAssertFalse(BoltzService.shared.isSwapSupported) + } + + func testSwapsStayDisabledWithoutTheDevFlag() { + withSavingsSwapDevFlag(false) { + XCTAssertFalse(BoltzService.shared.isSwapEnabled) + } + } + + func testDevFlagAloneDoesNotEnableSwapsOffMainnet() { + withSavingsSwapDevFlag(true) { + XCTAssertFalse(BoltzService.shared.isSwapEnabled) + } + } + + // MARK: - Transfer mode + + @MainActor + func testTransferToSavingsFallsBackToCloseWithoutAQuote() async { + let transfer = TransferViewModel() + // Swaps are unsupported here, so no quote can be published and the swipe must still commit. + await transfer.loadSavingsSwapQuote(requestedSat: 100_000, spendableSats: 100_000) + XCTAssertNil(transfer.savingsSwapState.quote) + + transfer.onTransferToSavingsConfirm(channels: []) + + XCTAssertEqual(transfer.savingsTransferMode, .close) + } + + @MainActor + func testTransferToSavingsSwapsWithAQuoteAndClosesWhenTheUserOptsOut() { + let transfer = TransferViewModel() + transfer.savingsSwapState = SavingsSwapState( + quote: SavingsSwapQuote.build(amountSat: 100_000, limits: limits()), + minSat: 25000, + maxSat: 100_000 + ) + + transfer.onTransferToSavingsConfirm(channels: []) + XCTAssertEqual(transfer.savingsTransferMode, .swap) + + transfer.onTransferToSavingsConfirm(channels: [], mode: .close) + XCTAssertEqual(transfer.savingsTransferMode, .close) + } + + // MARK: - Quote loading state + + @MainActor + func testBeginSavingsSwapQuoteLoadIsANoOpWhereSwapsAreUnavailable() { + // Unit tests run on regtest, so the swap gate is closed and the confirm screen's + // pre-sync loading marker must leave the state idle: the swipe stays immediately + // usable on the unchanged channel-close path. + let transfer = TransferViewModel() + + transfer.beginSavingsSwapQuoteLoad() + + XCTAssertFalse(transfer.savingsSwapState.isLoading) + XCTAssertNil(transfer.savingsSwapState.quote) + } + + // MARK: - Payment failure messages + + func testPaymentFailureReasonsMapToTheirUserMessages() { + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .recipientRejected), + t("wallet__toast_payment_failed_recipient_rejected") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .retriesExhausted), + t("wallet__toast_payment_failed_retries_exhausted") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .routeNotFound), + t("wallet__toast_payment_failed_route_not_found") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .paymentExpired), + t("wallet__toast_payment_failed_timeout") + ) + } + + func testUnmappedAndAbsentPaymentFailureReasonsFallBackToTheGenericMessage() { + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .unexpectedError), + t("wallet__toast_payment_failed_description") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: nil), + t("wallet__toast_payment_failed_description") + ) + } + + // MARK: - Payment failure capture + + func testWaitForFailureResumesWithTheReportedReason() async { + let capture = SwapPaymentFailureCapture() + + async let wait = capture.waitForFailure() + await capture.markFailed(reason: .routeNotFound) + + let outcome = await wait + XCTAssertEqual(outcome, .failed(reason: .routeNotFound)) + } + + func testWaitForFailureReturnsTheMemoizedOutcomeToLateWaiters() async { + let capture = SwapPaymentFailureCapture() + await capture.markFailed(reason: nil) + + let outcome = await capture.waitForFailure() + + XCTAssertEqual(outcome, .failed(reason: nil)) + } + + func testCancelWaitsUnblocksTheWaitAndAppliesToLaterWaiters() async { + let capture = SwapPaymentFailureCapture() + + async let wait = capture.waitForFailure() + await capture.cancelWaits() + let outcome = await wait + XCTAssertEqual(outcome, .cancelled) + + // The race is over; a failure arriving afterwards must not reopen it. + await capture.markFailed(reason: .routeNotFound) + let after = await capture.waitForFailure() + XCTAssertEqual(after, .cancelled) + } + + // MARK: - Claim gating + + func testIsClaimableWhileReverseSwapIsUnclaimedAndNotTerminal() { + XCTAssertTrue(swap(status: .transactionMempool).isClaimable) + XCTAssertTrue(swap(status: .transactionConfirmed).isClaimable) + XCTAssertTrue(swap(status: .transactionClaimPending).isClaimable) + XCTAssertTrue(swap(status: .invoicePending).isClaimable) + + // A stalled updates stream leaves the swap at swapCreated locally even once Boltz has + // locked up on-chain, so the claim must stay reachable: this is the recovery case. + XCTAssertTrue(swap(status: .swapCreated).isClaimable) + + // A cooperative claim can disclose the preimage before the broadcast lands, so Boltz + // settles the invoice while the funds still sit in the lockup. bitkit-core 0.5.3 keeps + // that swap pending and retries the claim; the manual claim stays reachable too. + XCTAssertTrue(swap(status: .invoiceSettled).isClaimable) + } + + func testIsClaimableFalseForTerminalAlreadyClaimedAndSubmarineSwaps() { + XCTAssertFalse(swap(status: .swapExpired).isClaimable) + XCTAssertFalse(swap(status: .transactionFailed).isClaimable) + XCTAssertFalse(swap(status: .transactionLockupFailed).isClaimable) + XCTAssertFalse(swap(status: .transactionRefunded).isClaimable) + XCTAssertFalse(swap(status: .transactionClaimed).isClaimable) + XCTAssertFalse(swap(status: .invoiceExpired).isClaimable) + XCTAssertFalse(swap(status: .invoiceFailedToPay).isClaimable) + XCTAssertFalse(swap(status: .transactionConfirmed, claimTxId: "txid1").isClaimable) + XCTAssertFalse(swap(status: .invoiceSettled, claimTxId: "txid1").isClaimable) + XCTAssertFalse(swap(swapType: .submarine, status: .transactionConfirmed).isClaimable) + } + + // MARK: - Fixtures + + private func withSavingsSwapDevFlag(_ enabled: Bool, _ body: () -> Void) { + let defaults = UserDefaults.standard + let previous = defaults.bool(forKey: BoltzService.savingsSwapEnabledKey) + defaults.set(enabled, forKey: BoltzService.savingsSwapEnabledKey) + defer { defaults.set(previous, forKey: BoltzService.savingsSwapEnabledKey) } + body() + } + + private func limits( + minimalSat: UInt64 = 25000, + maximalSat: UInt64 = 1_000_000, + feePercentage: Double = 0.5, + minerFeesSat: UInt64 = 300 + ) -> BoltzPairInfo { + BoltzPairInfo( + hash: "hash", + rate: 1.0, + minimalSat: minimalSat, + maximalSat: maximalSat, + feePercentage: feePercentage, + minerFeesSat: minerFeesSat + ) + } + + private func swap( + swapType: BoltzSwapType = .reverse, + status: BoltzSwapStatus = .transactionConfirmed, + claimTxId: String? = nil + ) -> BoltzSwap { + BoltzSwap( + id: "swap1", + swapType: swapType, + status: status, + network: .regtest, + swapIndex: 0, + amountSat: 100_000, + onchainAmountSat: 99000, + invoice: nil, + lockupAddress: nil, + onchainAddress: nil, + timeoutBlockHeight: 800, + createdAt: 0, + claimTxId: claimTxId, + refundTxId: nil + ) + } +} diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 3ab0460be..49ea105f7 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))