diff --git a/.agents/commands/release.md b/.agents/commands/release.md index f54c07016..6318e8b20 100644 --- a/.agents/commands/release.md +++ b/.agents/commands/release.md @@ -289,6 +289,8 @@ Print the path to the local draft. Store it for the summary. Slack posting is de ### 7. Build & Upload to TestFlight (optional) +Before archiving, `Secrets.xcconfig` must exist at the repo root with `BREEZ_API_KEY` set (copy `Secrets.xcconfig.template`). Release builds fail without it. Check with `grep -q '^BREEZ_API_KEY = .\+' Secrets.xcconfig`; if it fails, stop and ask the releaser for the key. Never print the key. + Use `AskUserQuestion`: **Question:** `"Archive and upload to TestFlight?"` diff --git a/.gitignore b/.gitignore index 493b4a6c4..cb63b2664 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,5 @@ buildServer.json # XcodeBuildMCP .xcodebuildmcp/config.yaml -# Local build secrets +# Local build secrets (see Secrets.xcconfig.template) Secrets.xcconfig diff --git a/Bitkit.xcconfig b/Bitkit.xcconfig new file mode 100644 index 000000000..fb68a49d5 --- /dev/null +++ b/Bitkit.xcconfig @@ -0,0 +1,4 @@ +// Base settings for the Bitkit app target. +// Local secrets stay out of git: copy Secrets.xcconfig.template to Secrets.xcconfig and fill it in. +// Without it the app builds with Spark (Lightning without channels) off on mainnet. +#include? "Secrets.xcconfig" diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 9518e214f..91f26a99f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; + 72B5EE0000000000000000A3 /* BreezSdkSpark in Frameworks */ = {isa = PBXBuildFile; productRef = 72B5EE0000000000000000A2 /* BreezSdkSpark */; }; 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; @@ -77,6 +78,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Bitkit.xcconfig; sourceTree = ""; }; 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -266,6 +268,7 @@ buildActionMask = 2147483647; files = ( 182817C12F59A7F10055A441 /* Paykit in Frameworks */, + 72B5EE0000000000000000A3 /* BreezSdkSpark in Frameworks */, 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, @@ -308,6 +311,7 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */, 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, @@ -385,6 +389,7 @@ isa = PBXNativeTarget; buildConfigurationList = 96FE1F862C2DE6AC006D0C8B /* Build configuration list for PBXNativeTarget "Bitkit" */; buildPhases = ( + B17C0F1A2EAE000000000002 /* Check Breez API Key */, 96FE1F5D2C2DE6AA006D0C8B /* Sources */, 96FE1F5E2C2DE6AA006D0C8B /* Frameworks */, 96FE1F5F2C2DE6AA006D0C8B /* Resources */, @@ -410,6 +415,7 @@ 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, 182817C02F59A7F10055A441 /* Paykit */, + 72B5EE0000000000000000A2 /* BreezSdkSpark */, ); productName = Bitkit; productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */; @@ -520,6 +526,7 @@ 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */, + 72B5EE0000000000000000A1 /* XCRemoteSwiftPackageReference "breez-sdk-spark-swift" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -574,6 +581,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + B17C0F1A2EAE000000000002 /* Check Breez API Key */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Check Breez API Key"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"$CONFIGURATION\" = \"Release\" ] && [ -z \"$(echo \"${BREEZ_API_KEY}\" | tr -d ' ')\" ]; then\n echo \"error: BREEZ_API_KEY is empty. Copy Secrets.xcconfig.template to Secrets.xcconfig and set the key before a Release build or archive (Docs/RELEASE.md).\"\n exit 1\nfi\n"; + showEnvVarsInLog = 0; + }; 751000000000000000000001 /* Generate Plural Localizations */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -953,6 +980,7 @@ }; 96FE1F872C2DE6AC006D0C8B /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1006,6 +1034,7 @@ }; 96FE1F882C2DE6AC006D0C8B /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1228,6 +1257,14 @@ version = 4.6.1; }; }; + 72B5EE0000000000000000A1 /* XCRemoteSwiftPackageReference "breez-sdk-spark-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/breez/breez-sdk-spark-swift"; + requirement = { + kind = exactVersion; + version = 0.28.2; + }; + }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; @@ -1275,6 +1312,11 @@ package = 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; + 72B5EE0000000000000000A2 /* BreezSdkSpark */ = { + isa = XCSwiftPackageProductDependency; + package = 72B5EE0000000000000000A1 /* XCRemoteSwiftPackageReference "breez-sdk-spark-swift" */; + productName = BreezSdkSpark; + }; 9613018B2C5022D700878183 /* LDKNode */ = { isa = XCSwiftPackageProductDependency; package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 41d71dcf8..d0fcdd4a9 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "86914da7e92df3dff5ecfc5ee3ce34ea4b3f911278d0c81ac20b137bacf28367", + "originHash" : "e06c417fd776e53210166a031513bb03039574ba3e0cc1f832151ad19d6a5057", "pins" : [ { "identity" : "bitkit-core", @@ -10,6 +10,15 @@ "version" : "0.8.0-rc15" } }, + { + "identity" : "breez-sdk-spark-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/breez/breez-sdk-spark-swift", + "state" : { + "revision" : "063f6ad1184674b77400c3a8f8e83bd9cd35dfd0", + "version" : "0.28.2" + } + }, { "identity" : "ldk-node", "kind" : "remoteSourceControl", @@ -37,6 +46,15 @@ "version" : "0.1.1-rc8" } }, + { + "identity" : "swift-bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mkrd/Swift-BigInt.git", + "state" : { + "revision" : "7e2117797cd1b608303cc2da1a3a2263cc4e7727", + "version" : "2.4.0" + } + }, { "identity" : "swift-secp256k1", "kind" : "remoteSourceControl", diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index a73d373ff..1813d933f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -297,6 +297,16 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { return IncomingPaykitPaymentRequestPresentationFeedback(paymentDetailsPendingWasRequested: wasRequested) } + /// Ends a presentation the user asked for once its payment details fail to resolve, so the send sheet stops loading + /// and the user sees why; the request stays in Payment Requests to try again. Automatic presentations keep retrying. + static func finishRequestedPresentationAfterResolutionFailure( + for request: PaykitPaymentRequest, + with manager: PaykitPaymentRequestManager + ) -> IncomingPaykitPaymentRequestPresentationFeedback? { + guard manager.requestedPresentationId == request.id else { return nil } + return finishPendingPrivateLink(for: request, with: manager) + } + static func handleStateChange( from previous: IncomingPaykitPaymentRequestPresentationState, to current: IncomingPaykitPaymentRequestPresentationState, @@ -599,6 +609,35 @@ struct AppScene: View { await pubkyProfile.retrySessionRestoration() } .task(id: [scenePhase == .active, network.isConnected, wipeCoordinator.isGateUp]) { await pollIncomingPaykitPaymentRequests() } + .task(id: [scenePhase == .active, SparkExitManager.shared.needsDriving]) { + guard scenePhase == .active else { return } + await SparkExitManager.shared.runWhileActive() + } + .task(id: [ + SparkExitManager.shared.opensProgress, + SparkExitManager.shared.showsResult, + SparkExitManager.shared.hasLoadedRecord, + isLockedForDeepLinks, + wallet.walletExists == true, + ]) { + // A tapped exit notification opens the exit once the wallet is unlocked and its record is loaded. + let exit = SparkExitManager.shared + guard exit.opensProgress, wallet.walletExists == true, !isLockedForDeepLinks else { return } + switch SparkExitManager.notificationOpen( + opensProgress: exit.opensProgress, + showsResult: exit.showsResult, + hasLoadedRecord: exit.hasLoadedRecord + ) { + case .wait: + return + case .drop: + exit.opensProgress = false + case .open: + exit.opensProgress = false + navigation.reset() + navigation.navigate(.sparkExitProgress) + } + } .task(id: foregroundContactSession) { shopContactImport?.task.cancel() shopContactImport = nil @@ -1801,6 +1840,20 @@ struct AppScene: View { return } guard case let .opened(paymentTarget, privatePaymentContext, endpoints) = result else { + let isWaitingForUpdatedList = if case .waitingForUpdatedPaymentList = result { + true + } else { + false + } + if !isWaitingForUpdatedList, + let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: paykitPaymentRequestManager + ) + { + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + return + } deferIncomingPaykitPaymentRequestPresentation( request, reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed @@ -1928,6 +1981,13 @@ struct AppScene: View { return } catch { guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } + if let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: paykitPaymentRequestManager + ) { + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + return + } deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed) return } diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json new file mode 100644 index 000000000..600f19a4b --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "coin-stack-x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png new file mode 100644 index 000000000..62388ddf9 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png differ diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json index f90a70255..0838f5531 100644 --- a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json +++ b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "hourglass-simple.pdf", + "filename" : "hourglass-simple.svg", "idiom" : "universal" } ], @@ -10,6 +10,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "template" } } diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf deleted file mode 100644 index cc1ee0ef0..000000000 Binary files a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf and /dev/null differ diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg new file mode 100644 index 000000000..510ad0b7e --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 03273d8aa..89ce0bdba 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -73,6 +73,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self + SparkExitManager.registerBackgroundTask() // Check notification authorization status at launch and re-register with APN if granted UNUserNotificationCenter.current().getNotificationSettings { settings in @@ -163,6 +164,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate { PaykitSubscriptionNotificationTargetStore.save(target) } NotificationCenter.default.post(name: .paykitSubscriptionPaymentDue, object: nil, userInfo: userInfo) + } else if userInfo["bitkit_action"] as? String == SparkExitManager.notificationAction { + Task { @MainActor in SparkExitManager.shared.opensProgress = true } } else { PushNotificationManager.shared.handleNotification(userInfo) } diff --git a/Bitkit/Components/FeeDisplayRow.swift b/Bitkit/Components/FeeDisplayRow.swift index a7ba62705..37ae26d21 100644 --- a/Bitkit/Components/FeeDisplayRow.swift +++ b/Bitkit/Components/FeeDisplayRow.swift @@ -3,12 +3,16 @@ import SwiftUI struct FeeDisplayRow: View { let label: String let amount: UInt64 + /// Shown before the amount when it is a bound or an estimate ("±"). + var prefix: String? + /// Shimmers the amount while it is being priced. + var isLoading = false var body: some View { VStack(alignment: .leading, spacing: 0) { CaptionMText(label) .padding(.bottom, 8) - MoneyText(sats: Int(amount), size: .bodySSB, symbol: true) + MoneyText(sats: Int(amount), size: .bodySSB, symbol: true, prefix: prefix, shimmer: isLoading) .padding(.bottom, 16) Divider() } diff --git a/Bitkit/Components/FeeItem.swift b/Bitkit/Components/FeeItem.swift index eb3c12e79..ec8c31633 100644 --- a/Bitkit/Components/FeeItem.swift +++ b/Bitkit/Components/FeeItem.swift @@ -2,57 +2,89 @@ import BitkitCore import SwiftUI struct FeeItem: View { - let speed: TransactionSpeed + let title: String + let subtitle: String + let iconName: String + let iconColor: Color let amount: UInt64 + /// Shown before the amount when it is a bound or an estimate ("±"). + var amountPrefix: String? let isSelected: Bool let isDisabled: Bool - /// When set (e.g. for custom speed with fee estimates), shown instead of `speed.range` as the subtitle. - var rangeOverride: String? + /// Draws the row's divider under it instead of above it, so a selected row's fill touches nothing above. + var dividerAtBottom = false let onPress: () -> Void @EnvironmentObject var currency: CurrencyViewModel - private var rangeText: String { - rangeOverride ?? speed.range + /// - Parameter rangeOverride: When set (e.g. for custom speed with fee estimates), shown instead of `speed.range`. + init( + speed: TransactionSpeed, + amount: UInt64, + isSelected: Bool, + isDisabled: Bool, + rangeOverride: String? = nil, + onPress: @escaping () -> Void + ) { + self.init( + title: speed.title, + subtitle: rangeOverride ?? speed.range, + iconName: speed.iconName, + iconColor: speed.iconColor, + amount: amount, + isSelected: isSelected, + isDisabled: isDisabled, + onPress: onPress + ) + } + + init( + title: String, + subtitle: String, + iconName: String, + iconColor: Color, + amount: UInt64, + amountPrefix: String? = nil, + isSelected: Bool, + isDisabled: Bool, + dividerAtBottom: Bool = false, + onPress: @escaping () -> Void + ) { + self.dividerAtBottom = dividerAtBottom + self.title = title + self.subtitle = subtitle + self.iconName = iconName + self.iconColor = iconColor + self.amount = amount + self.amountPrefix = amountPrefix + self.isSelected = isSelected + self.isDisabled = isDisabled + self.onPress = onPress } var body: some View { VStack(spacing: 0) { - Divider() - .padding(.horizontal, 16) + if !dividerAtBottom { + Divider() + .padding(.horizontal, 16) + } Button(action: onPress) { HStack(spacing: 16) { - Image(speed.iconName) - .foregroundColor(isDisabled ? .gray : speed.iconColor) + Image(iconName) + .foregroundColor(isDisabled ? .gray : iconColor) .frame(width: 32, height: 32) HStack { VStack(alignment: .leading, spacing: 0) { - BodyMSBText(speed.title, textColor: isDisabled ? .gray3 : .textPrimary) - BodySSBText(rangeText, textColor: isDisabled ? .gray3 : .textSecondary) + BodyMSBText(title, textColor: isDisabled ? .gray3 : .textPrimary) + BodySSBText(subtitle, textColor: isDisabled ? .gray3 : .textSecondary) } Spacer() if amount > 0 { - VStack(alignment: .trailing, spacing: 0) { - MoneyText( - sats: Int(amount), - unitType: .primary, - size: .bodyMSB, - symbol: true, - color: isDisabled ? .gray3 : .textPrimary - ) - - MoneyText( - sats: Int(amount), - unitType: .secondary, - size: .bodySSB, - symbol: true, - color: isDisabled ? .gray3 : .textSecondary - ) - } + FeeItemAmount(amount: amount, prefix: amountPrefix, isDisabled: isDisabled) } } } @@ -65,6 +97,41 @@ struct FeeItem: View { } .disabled(isDisabled) .buttonStyle(PlainButtonStyle()) + + if dividerAtBottom { + Divider() + .padding(.horizontal, 16) + } + } + } +} + +/// The fee in both units, kept whole beside a long subtitle. +struct FeeItemAmount: View { + let amount: UInt64 + var prefix: String? + var isDisabled = false + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + MoneyText( + sats: Int(amount), + unitType: .primary, + size: .bodyMSB, + symbol: true, + prefix: prefix, + color: isDisabled ? .gray3 : .textPrimary + ) + + MoneyText( + sats: Int(amount), + unitType: .secondary, + size: .bodySSB, + symbol: true, + prefix: prefix, + color: isDisabled ? .gray3 : .textSecondary + ) } + .fixedSize() } } diff --git a/Bitkit/Components/LightningChannel.swift b/Bitkit/Components/LightningChannel.swift index 5d35ecfe3..66ff6c77d 100644 --- a/Bitkit/Components/LightningChannel.swift +++ b/Bitkit/Components/LightningChannel.swift @@ -12,19 +12,23 @@ struct LightningChannel: View { let remoteBalance: UInt64 let status: ChannelStatus let showLabels: Bool + /// False for a balance whose receiving isn't capped by a channel, such as Spark: only the spending side is drawn. + let showReceiving: Bool init( capacity: UInt64, localBalance: UInt64, remoteBalance: UInt64, status: ChannelStatus = .open, - showLabels: Bool = false + showLabels: Bool = false, + showReceiving: Bool = true ) { self.capacity = capacity self.localBalance = localBalance self.remoteBalance = remoteBalance self.status = status self.showLabels = showLabels + self.showReceiving = showReceiving } private var spendingColor: Color { @@ -90,7 +94,9 @@ struct LightningChannel: View { Spacer() - CaptionMText(t("lightning__receiving_label")) + if showReceiving { + CaptionMText(t("lightning__receiving_label")) + } } } @@ -106,49 +112,75 @@ struct LightningChannel: View { Spacer() - HStack(spacing: 4) { - Image("arrow-down") - .resizable() - .frame(width: 14, height: 14) - .foregroundColor(receivingAvailableColor) + if showReceiving { + HStack(spacing: 4) { + Image("arrow-down") + .resizable() + .frame(width: 14, height: 14) + .foregroundColor(receivingAvailableColor) - BodySSBText(formatNumber(remoteBalance), textColor: receivingAvailableColor) + BodySSBText(formatNumber(remoteBalance), textColor: receivingAvailableColor) + } } } - HStack(spacing: 4) { - // Spending bar (left) - ZStack(alignment: .trailing) { - // Background - Rectangle() - .fill(spendingColor) - .cornerRadius(8, corners: [.topLeft, .bottomLeft]) - - // Available - Rectangle() - .fill(spendingAvailableColor) - .frame(width: spendingPercentage * 100.0) - .cornerRadius(8, corners: [.topLeft, .bottomLeft]) - } - - // Receiving bar (right) - ZStack(alignment: .leading) { - // Background - Rectangle() - .fill(receivingColor) - .cornerRadius(8, corners: [.topRight, .bottomRight]) - - // Available - Rectangle() - .fill(receivingAvailableColor) - .frame(width: receivingPercentage * 100.0) - .cornerRadius(8, corners: [.topRight, .bottomRight]) - } + if showReceiving { + channelBars + } else { + spendingBar } - .frame(height: 16) } .opacity(status == .pending ? 0.5 : 1.0) } + + private var spendingBar: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(localBalance == 0 ? Color.gray6 : spendingColor) + .cornerRadius(8) + + Rectangle() + .fill(spendingAvailableColor) + .frame(width: spendingPercentage * geometry.size.width) + .cornerRadius(8) + } + } + .frame(height: 16) + } + + private var channelBars: some View { + HStack(spacing: 4) { + // Spending bar (left) + ZStack(alignment: .trailing) { + // Background + Rectangle() + .fill(spendingColor) + .cornerRadius(8, corners: [.topLeft, .bottomLeft]) + + // Available + Rectangle() + .fill(spendingAvailableColor) + .frame(width: spendingPercentage * 100.0) + .cornerRadius(8, corners: [.topLeft, .bottomLeft]) + } + + // Receiving bar (right) + ZStack(alignment: .leading) { + // Background + Rectangle() + .fill(receivingColor) + .cornerRadius(8, corners: [.topRight, .bottomRight]) + + // Available + Rectangle() + .fill(receivingAvailableColor) + .frame(width: receivingPercentage * 100.0) + .cornerRadius(8, corners: [.topRight, .bottomRight]) + } + } + .frame(height: 16) + } } /// Extension to apply rounded corners to specific corners diff --git a/Bitkit/Components/ProgressSteps.swift b/Bitkit/Components/ProgressSteps.swift new file mode 100644 index 000000000..114a8c80b --- /dev/null +++ b/Bitkit/Components/ProgressSteps.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// A view that displays a list of steps with circles and numbers. +struct ProgressSteps: View { + let steps: [String] + let currentStep: Int + + private let size: CGFloat = 32 + private let stepPadding: CGFloat = 16 + + var body: some View { + VStack(spacing: 0) { + // Steps with circles and separators + GeometryReader { geometry in + ZStack(alignment: .center) { + // Dashed line from the first circle's centre to the last one's, behind the circles + Path { path in + let y = geometry.size.height / 2 + let stepWidth = size + stepPadding * 2 + let inset = (geometry.size.width - stepWidth * CGFloat(steps.count)) / 2 + stepWidth / 2 + path.move(to: CGPoint(x: inset, y: y)) + path.addLine(to: CGPoint(x: geometry.size.width - inset, y: y)) + } + .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundColor(Color.white32) + + // Circles with numbers + HStack(spacing: 0) { + ForEach(Array(steps.enumerated()), id: \.0) { index, _ in + // Circle with number or checkmark + ZStack { + Circle() + .fill(index < currentStep ? Color.purpleAccent : Color.black) + .frame(width: size, height: size) + + if index < currentStep { + // Checkmark for completed steps + Image("check-mark") + .foregroundColor(.black) + } else { + // Number for current and upcoming steps + Text("\(index + 1)") + .foregroundColor(index == currentStep ? Color.purpleAccent : .white32) + .font(.custom(Fonts.regular, size: 17)) + } + + // Border for uncompleted steps + if index >= currentStep { + Circle() + .stroke(index == currentStep ? Color.purpleAccent : Color.white32, lineWidth: 1) + .frame(width: size, height: size) + } + } + .padding(.horizontal, stepPadding) + } + } + } + } + .frame(height: size) + + VStack { + BodySSBText(steps[currentStep], textColor: .white32) + } + .frame(height: 56) + } + } +} diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 3936ba524..e9d81ce9b 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -374,7 +374,11 @@ struct Suggestions: View { case .hardware: sheets.showSheet(.hardwareConnect) case .transferToSpending: - sheets.showSheet(.transferUnavailable) + if SparkManager.shared.isReady { + route = app.hasSeenTransferIntro ? .fundingOptions : .transferIntro + } else { + sheets.showSheet(.transferUnavailable) + } } if let route { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 0a2de021d..9e27e75a0 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -221,6 +221,16 @@ enum Env { } } + /// Esplora REST API Spark reads the chain from and broadcasts through, in place of the SDK's default + /// (mempool.space with blockstream as fallback on mainnet); nil keeps the default. On regtest a debug or E2E build + /// can point Spark at Bitkit's own chain with `SPARK_CHAIN_SERVICE_URL`, so a Savings deposit reaches it. + static func sparkChainServiceUrl(for network: LDKNode.Network) -> String? { + switch network { + case .bitcoin: "https://bitkit.to/electrs" + default: configValue("SPARK_CHAIN_SERVICE_URL") + } + } + static var electrumServerUrl: String { if isE2E, e2eBackend == "local" { return "tcp://\(e2eLocalHost):60001" @@ -278,6 +288,46 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/ldk") } + static func sparkStorage(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark") + } + + /// A restored Spark exit data backup waiting to be imported once Spark connects; outside Spark's storage, which a + /// wipe removes. + static func sparkExitRestore(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit-restore.json") + } + + /// The running Spark emergency exit, if any: its signed recovery and how far it got. + static func sparkExitRecord(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit.json") + } + + /// Earlier Spark exits' funding addresses that hold, or may still receive, coins, kept past the exit record. + static func sparkExitEarlierAddresses(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit-earlier-addresses.json") + } + + /// The leaves an older state of the wallet claimed in an earlier Spark exit, kept out of every later one. + static func sparkExitLostLeaves(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit-lost-leaves.json") + } + + /// Breez SDK API key, set with the `BREEZ_API_KEY` build setting. Spark on mainnet needs it; its regtest does not. + static var breezApiKey: String? { + infoPlistValue("BREEZ_API_KEY") + } + static func bitkitCoreStorage(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) diff --git a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift index 3a954b13a..e56e9968e 100644 --- a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift +++ b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift @@ -5,4 +5,9 @@ extension FixedWidthInteger { let (sum, overflow) = addingReportingOverflow(other) return overflow ? Self.max : sum } + + func saturatingSub(_ other: Self) -> Self { + let (difference, overflow) = subtractingReportingOverflow(other) + return overflow ? Self.min : difference + } } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 3ef85706e..84f203398 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -2,6 +2,16 @@ + BGTaskSchedulerPermittedIdentifiers + + to.bitkit.spark-exit + to.bitkit.spark-exit.processing + + UIBackgroundModes + + fetch + processing + UIDesignRequiresCompatibility USDT_BRIDGE_NETWORKS @@ -32,6 +42,8 @@ + BREEZ_API_KEY + $(BREEZ_API_KEY) E2E_BACKEND $(E2E_BACKEND) E2E_HOMEGATE_URL diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index b014cc4cf..a51d3b910 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -25,6 +25,7 @@ struct MainNavView: View { @Environment(\.scenePhase) var scenePhase @State private var usdtReceipts = UsdtReceiptQueue() + @State private var sparkTransfer = SparkTransferFlow() @State private var showClipboardAlert = false @State private var clipboardUri: String? @State private var didResumePendingPubkyProfileSetup = false @@ -80,6 +81,12 @@ struct MainNavView: View { NavigationStack(path: $navigation.path) { navigationContent } + .environment(sparkTransfer) + .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + // The swipe's funding can resolve while another screen of the transfer shows. + guard resolution.isSparkFunding else { return } + _ = sparkTransfer.followIfOwned(txid: resolution.txid, attemptId: resolution.attemptId) + } .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } navigation.navigate(.createProfile) @@ -230,6 +237,11 @@ struct MainNavView: View { ) { config in SecuritySheet(config: config) } + .sheet(item: $sheets.moveToSparkSheetItem, onDismiss: { + sheets.hideSheetIfActive(.moveToSpark, reason: "Move to Spark intro dismissed") + }) { config in + MoveToSparkSheet(config: config) + } .sheet(item: $sheets.usdtIntroSheetItem, onDismiss: { sheets.hideSheetIfActive(.usdtIntro, reason: "USDT intro dismissed") }) { config in @@ -509,7 +521,17 @@ struct MainNavView: View { case .scanner: ScannerScreen() // Transfer + case .transferIntro: TransferIntroView() case .fundingOptions: FundingOptions() + case .spendingIntro: SpendingIntroView() + case let .spendingIntroHw(walletId): SpendingIntroView(walletId: walletId) + case .spendingAmount: SpendingAmount() + case let .spendingAmountHw(walletId): SpendingAmountHw(walletId: walletId) + case let .spendingHwSign(walletId): SpendingHwSign(walletId: walletId) + case .spendingHwSigned: SpendingHwSigned() + case .spendingConfirm: SpendingConfirm() + case .transferLearnMore: TransferLearnMoreView() + case .settingUp: SettingUpView() case let .fundManual(nodeUri): FundManualSetupView(initialNodeUri: nodeUri) case let .fundManualAmount(lnPeer): FundManualAmountView(lnPeer: lnPeer) case let .fundManualConfirm(lnPeer, amountSats): FundManualConfirmView(lnPeer: lnPeer, amountSats: amountSats) @@ -692,6 +714,14 @@ struct MainNavView: View { case .addressTypePreference: AddressTypePreferenceView() case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) + case .sparkConnectionDetail: SparkConnectionDetailView() + case .moveToSpark: MoveToSparkView() + case .sparkExitIntro: SparkExitIntroView() + case .sparkExitQuote: SparkExitQuoteView() + case .sparkExitFunding: SparkExitFundingView() + case .sparkExitConfirm: SparkExitConfirmView() + case .sparkExitProgress: SparkExitProgressView() + case .sparkExitFeeBump: SparkExitFeeBumpView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/BalanceManager.swift b/Bitkit/Managers/BalanceManager.swift index e54b635cf..f53e0e3f9 100644 --- a/Bitkit/Managers/BalanceManager.swift +++ b/Bitkit/Managers/BalanceManager.swift @@ -26,7 +26,9 @@ class BalanceManager { /// Derives the complete balance state including pending transfers /// - Returns: BalanceState with all balance calculations /// - Throws: Error if balance retrieval fails - func deriveBalanceState() async throws -> BalanceState { + /// - Parameter exitInTransfer: the running Spark exit's transfer and what it still brings to savings, counted in + /// place of that transfer's fixed amount. + func deriveBalanceState(exitInTransfer: (transferId: String, sats: UInt64)? = nil) async throws -> BalanceState { let balanceDetails = await MainActor.run { lightningService.balances } guard let balanceDetails else { throw AppError(message: "Balance details unavailable", debugMessage: "LightningService.balances is nil") @@ -43,7 +45,8 @@ class BalanceManager { let (lightningToSubtract, pendingCloseAmount) = await getCloseTransferAmounts( transfers: activeTransfers, - balanceDetails: balanceDetails + balanceDetails: balanceDetails, + exitInTransfer: exitInTransfer ) // Detect orphan closed channels: channels with lightning balance that are no longer @@ -138,12 +141,18 @@ class BalanceManager { /// - Returns: lightningToSubtract (always subtract from display), pendingAmount (add to total only if on-chain hasn't arrived) private func getCloseTransferAmounts( transfers: [Transfer], - balanceDetails: BalanceDetails + balanceDetails: BalanceDetails, + exitInTransfer: (transferId: String, sats: UInt64)? = nil ) async -> (lightningToSubtract: UInt64, pendingAmount: UInt64) { var lightningToSubtract: UInt64 = 0 var pendingAmount: UInt64 = 0 for transfer in transfers.filter({ $0.type.isToSavings() }) { + // A Spark exit has no channel: it is in transfer until its sweep confirms and the exit settles it. + if transfer.type == .sparkExit { + pendingAmount += Self.sparkExitInTransferSats(transfer, exitInTransfer: exitInTransfer) + continue + } guard let channelId = transfer.channelId else { continue } let balanceFromLdk = balanceDetails.lightningBalances @@ -165,6 +174,12 @@ class BalanceManager { return (lightningToSubtract, pendingAmount) } + /// The running exit's own transfer counts what it still brings to savings; any other exit transfer its amount. + static func sparkExitInTransferSats(_ transfer: Transfer, exitInTransfer: (transferId: String, sats: UInt64)?) -> UInt64 { + guard let exitInTransfer, exitInTransfer.transferId == transfer.id else { return transfer.amountSats } + return exitInTransfer.sats + } + /// Detects "orphan" closed channels - channels that have lightning balance in LDK /// but are no longer in the channels list and don't have a Transfer record yet. private func getOrphanClosedChannelBalance( diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift new file mode 100644 index 000000000..ca2718754 --- /dev/null +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -0,0 +1,390 @@ +import Foundation +import LDKNode + +/// The amounts behind moving the channels' balance to Spark, kept apart so they can be tested without a node. +enum MoveToSparkMath { + /// Below this a move is not offered: what is left is about the routing fee. + static let minMoveSats: UInt64 = 1000 + /// The fee guessed for the first quote of a full move, as a share of the amount in parts per million. + private static let firstFeeGuessPpm: UInt64 = 5000 + /// The least fee guessed for the first quote of a full move. + private static let minFeeGuessSats: UInt64 = 10 + /// Room left above the quoted fee, in sats, for the fee moving between the quote and the payment. + private static let feeMarginSats: UInt64 = 2 + + static func movableSats(channels: [ChannelDetails]?) -> UInt64 { + (channels ?? []).filter(\.isUsable).map { $0.nextOutboundHtlcLimitMsat / 1000 }.reduce(0, +) + } + + /// Whether Spark can take a move; every Move to Spark check goes through here. + @MainActor + static func isSparkUsable(_ spark: SparkManager) -> Bool { + isSparkUsable(isReady: spark.isReady, isExiting: spark.isExiting) + } + + /// Spark takes no move during an emergency exit, which is sweeping its funds to savings. + static func isSparkUsable(isReady: Bool, isExiting: Bool) -> Bool { + isReady && !isExiting + } + + /// Whether the channels hold a balance worth moving to a usable Spark wallet. + static func canMove(channels: [ChannelDetails]?, isSparkUsable: Bool) -> Bool { + isSparkUsable && hasMovableBalance(limitSats: movableSats(channels: channels)) + } + + /// Whether `limitSats` still leaves at least `minMoveSats` to quote once the first fee guess is reserved. + static func hasMovableBalance(limitSats: UInt64) -> Bool { + firstCandidate(wantedSats: limitSats, limitSats: limitSats) >= minMoveSats + } + + /// The first amount to quote: `wantedSats` when it leaves room for fees, else the limit less a guessed fee. + static func firstCandidate(wantedSats: UInt64, limitSats: UInt64) -> UInt64 { + let guessSats = max(limitSats.multipliedReportingOverflow(by: firstFeeGuessPpm).partialValue / 1_000_000, minFeeGuessSats) + return min(wantedSats, amountAfterFee(limitSats: limitSats, feeSats: guessSats)) + } + + /// The amount that fits under `limitSats` together with `feeSats` and its margin. + static func amountAfterFee(limitSats: UInt64, feeSats: UInt64) -> UInt64 { + let reserved = feeSats.addingReportingOverflow(feeMarginSats) + return reserved.overflow || reserved.partialValue >= limitSats ? 0 : limitSats - reserved.partialValue + } + + /// The routing fee to show for an estimate: the estimate and its margin, which also covers the estimate's rounding. + static func quotedFeeSats(estimateSats: UInt64) -> UInt64 { + estimateSats + feeMarginSats + } + + /// The routing fee cap for the payment: exactly the quoted fee, so it never pays more than shown. + static func feeCapMsat(feeSats: UInt64) -> UInt64 { + feeSats * 1000 + } + + /// The smaller amount to offer after one did not route: half of it, or nothing below the minimum. + static func smallerAmount(_ sats: UInt64) -> UInt64? { + let half = sats / 2 + return half >= minMoveSats ? half : nil + } +} + +struct MoveToSparkQuote: Equatable { + let invoice: String + let amountSats: UInt64 + let feeSats: UInt64 +} + +enum MoveToSparkError: Error { + case nothingToMove + case sparkUnavailable + /// `amountSats` did not route from the channels to Spark in one payment. + case tooLarge(amountSats: UInt64) +} + +/// The payments that move a channel balance to Spark, whose send and receive the app does not announce as payments. +@MainActor +enum MoveToSparkPayments { + private static var paymentHashes = Set() + + static func insert(_ paymentHash: String) { + paymentHashes.insert(paymentHash.lowercased()) + } + + static func contains(_ paymentHash: String?) -> Bool { + paymentHash.map { paymentHashes.contains($0.lowercased()) } ?? false + } + + static func removeAll() { + paymentHashes.removeAll() + } +} + +enum MoveToSparkFailure { + case nothingToMove + case sparkUnavailable + case unknown +} + +enum MoveToSparkState: Equatable { + case quoting + case confirm(MoveToSparkQuote, isMoving: Bool) + /// `hasRest`: the channels can still send enough to move again, as after a smaller amount. + case done(amountSats: UInt64, feeSats: UInt64, closeChannel: ChannelDetails?, hasRest: Bool) + /// The payment had no outcome within the wait; it may still settle or return to the channel. + case inFlight(amountSats: UInt64) + /// `amountSats` did not route in one payment; `smallerSats` is the next amount to try, if any is left. + case tooLarge(amountSats: UInt64, smallerSats: UInt64?) + case failed(MoveToSparkFailure) + + static func == (lhs: MoveToSparkState, rhs: MoveToSparkState) -> Bool { + switch (lhs, rhs) { + case (.quoting, .quoting): true + case let (.confirm(a, am), .confirm(b, bm)): a == b && am == bm + case let (.done(a, af, ac, ar), .done(b, bf, bc, br)): a == b && af == bf && ac?.channelId == bc?.channelId && ar == br + case let (.inFlight(a), .inFlight(b)): a == b + case let (.tooLarge(a, asm), .tooLarge(b, bsm)): a == b && asm == bsm + case let (.failed(a), .failed(b)): a == b + default: false + } + } +} + +/// Moves the channels' balance to the user's own Spark wallet, paying a Spark invoice over the channels only. +/// Blocktank is closing its channels, and this is the cheapest way to keep the balance in Spending. +@Observable @MainActor +final class MoveToSparkManager { + /// Shared so a move and its outcome outlive the screen, which is rebuilt when the app is unlocked. + static let shared = MoveToSparkManager() + + private static let invoiceDescription = "Move to Spark" + private static let invoiceExpirySecs: UInt32 = 3600 + private static let maxQuoteRounds = 4 + + private(set) var state: MoveToSparkState = .quoting + /// A move was started and its outcome is still the screen's, so showing the screen again keeps it. + private var keepsOutcome = false + /// Counts quotes, moves and resets; a quote writes its result only while it is still the latest. + private(set) var generation = 0 + private var isQuoting = false + /// A swiped payment that has not settled yet; leaving the screen keeps it, so it is never quoted over. + private var isMoveInFlight = false + + private let lightningService: LightningService + private let spark: SparkManager + + init() { + lightningService = .shared + spark = .shared + } + + /// Opens the screen: quotes a new move, unless a quote is still running or a move started earlier is still running + /// or showing its outcome. + func start(wallet: WalletViewModel) async { + guard !keepsOutcome, !isQuoting else { return } + await quote(wallet: wallet) + } + + /// Forgets the last move once its screen is left, unless its payment is still in flight. + func reset() { + guard !isMoveInFlight else { return } + generation += 1 + isQuoting = false + keepsOutcome = false + state = .quoting + } + + /// Forgets everything of the wiped wallet, its unsettled move too: a new generation makes that move's pending result + /// and late outcome leave the screen of the next wallet alone. + func resetForWipe() { + generation += 1 + isQuoting = false + keepsOutcome = false + isMoveInFlight = false + state = .quoting + MoveToSparkPayments.removeAll() + } + + /// Quotes moving `targetSats`, or everything the channels can send. + func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async { + generation += 1 + let quoteGeneration = generation + isQuoting = true + keepsOutcome = false + state = .quoting + await wallet.syncStateAsync() + let result: MoveToSparkState + do { + result = try await .confirm(makeQuote(channels: wallet.channels, targetSats: targetSats), isMoving: false) + } catch { + Logger.warn("Failed to quote move to Spark of '\(String(describing: targetSats))': \(error)", context: "MoveToSpark") + result = Self.state(for: error) + } + guard quoteGeneration == generation else { return } + isQuoting = false + state = result + } + + func move(wallet: WalletViewModel) async { + guard case let .confirm(quote, isMoving) = state, !isMoving else { return } + let moveGeneration = beginMove() + // A payment still unsettled after the wait keeps the move until its late outcome, so leaving and reopening the + // screen shows it again instead of a new quote. + var isSettled = true + defer { + if isSettled { + isMoveInFlight = false + } + } + state = .confirm(quote, isMoving: true) + do { + let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash() + MoveToSparkPayments.insert(String(describing: paymentHash)) + guard let settled = try await wallet.sendOverChannels( + bolt11: quote.invoice, + sats: quote.amountSats, + maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats), + // Shown in the step that stops waiting, so a late outcome right after it finds this move in flight. + onTimeout: { [weak self] in self?.showInFlight(amountSats: quote.amountSats, moveGeneration: moveGeneration) }, + onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats, moveGeneration: moveGeneration) }, + onLateSuccess: { [weak self, weak wallet] settled in + guard let self, let wallet else { return } + Task { + await self.lateSuccess( + amountSats: quote.amountSats, + feeSats: settled.feePaidSats, + moveGeneration: moveGeneration, + wallet: wallet + ) + } + } + ) else { + isSettled = false + return + } + guard moveGeneration == generation else { return } + await wallet.syncStateAsync() + guard moveGeneration == generation else { return } + state = Self.doneState(amountSats: quote.amountSats, feeSats: settled.feePaidSats, channels: wallet.channels) + } catch { + Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") + guard moveGeneration == generation else { return } + state = Self.isNoRoute(error) || Self.isSendWithoutRoute(error, invoice: quote.invoice) + ? .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats)) + : .failed(.unknown) + } + } + + /// Starts a move: it keeps its outcome and stays in flight until it settles. Returns the move's generation. + func beginMove() -> Int { + generation += 1 + keepsOutcome = true + isMoveInFlight = true + return generation + } + + func makeQuote(channels: [ChannelDetails]?, targetSats: UInt64?) async throws -> MoveToSparkQuote { + let limitSats = MoveToSparkMath.movableSats(channels: channels) + let wantedSats = min(targetSats ?? limitSats, limitSats) + guard wantedSats >= MoveToSparkMath.minMoveSats, MoveToSparkMath.hasMovableBalance(limitSats: limitSats) else { + throw MoveToSparkError.nothingToMove + } + guard MoveToSparkMath.isSparkUsable(spark) else { throw MoveToSparkError.sparkUnavailable } + + let invoice = try await spark.receive( + amountSats: nil, + description: Self.invoiceDescription, + expirySecs: Self.invoiceExpirySecs + ) + var amountSats = MoveToSparkMath.firstCandidate(wantedSats: wantedSats, limitSats: limitSats) + var fitting: MoveToSparkQuote? + // The first amount leaves room for a guessed fee; once the real fee is known, the amount grows to fit it. + for _ in 0 ..< Self.maxQuoteRounds where amountSats >= MoveToSparkMath.minMoveSats { + let estimateSats: UInt64 + do { + estimateSats = try await lightningService.estimateRoutingFees(bolt11: invoice, amountSats: amountSats, channelsOnly: true) + } catch { + if let fitting { + return fitting + } + throw Self.isNoRoute(error) ? MoveToSparkError.tooLarge(amountSats: amountSats) : error + } + let feeSats = MoveToSparkMath.quotedFeeSats(estimateSats: estimateSats) + let nextSats = min(wantedSats, MoveToSparkMath.amountAfterFee(limitSats: limitSats, feeSats: estimateSats)) + if amountSats + feeSats <= limitSats { + let quote = MoveToSparkQuote(invoice: invoice, amountSats: amountSats, feeSats: feeSats) + fitting = quote + if nextSats <= amountSats { + return quote + } + } + amountSats = nextSats + } + if let fitting { + return fitting + } + throw MoveToSparkError.tooLarge(amountSats: amountSats) + } + + /// The outcome of a move that paid `feeSats`: Close when one channel is left, Move The Rest when it can still send. + nonisolated static func doneState(amountSats: UInt64, feeSats: UInt64, channels: [ChannelDetails]?) -> MoveToSparkState { + let openChannels = (channels ?? []).filter(\.isChannelReady) + return .done( + amountSats: amountSats, + feeSats: feeSats, + closeChannel: openChannels.count == 1 ? openChannels.first : nil, + hasRest: MoveToSparkMath.hasMovableBalance(limitSats: MoveToSparkMath.movableSats(channels: channels)) + ) + } + + /// Whether the screen still shows the in-flight move of `moveGeneration`: a later move counts a new generation. + nonisolated static func showsInFlightMove(_ state: MoveToSparkState, generation: Int, moveGeneration: Int) -> Bool { + guard generation == moveGeneration, case .inFlight = state else { return false } + return true + } + + /// The wait for the move of `moveGeneration` ended unsettled: the screen, if it still shows that move, shows it in + /// flight. + func showInFlight(amountSats: UInt64, moveGeneration: Int) { + guard generation == moveGeneration else { return } + state = .inFlight(amountSats: amountSats) + } + + /// A move that was still in flight paid after the wait: the screen, if it still shows that same move, shows it moved. + private func lateSuccess(amountSats: UInt64, feeSats: UInt64, moveGeneration: Int, wallet: WalletViewModel) async { + Logger.info("Move to Spark of '\(amountSats)' sats succeeded after the wait", context: "MoveToSpark") + defer { + if generation == moveGeneration { + isMoveInFlight = false + } + } + guard Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) else { return } + await wallet.syncStateAsync() + guard Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) else { return } + state = Self.doneState(amountSats: amountSats, feeSats: feeSats, channels: wallet.channels) + } + + /// A move that was still in flight failed after the wait: its amount stays in the channel, so the screen, if it still + /// shows that same move, says it could not move, and a toast tells the user wherever they are. + private func lateFailure(amountSats: UInt64, moveGeneration: Int) { + Logger.warn("Move to Spark of '\(amountSats)' sats failed after the wait", context: "MoveToSpark") + if generation == moveGeneration { + isMoveInFlight = false + } + if Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) { + state = .failed(.unknown) + } + guard !ToastWindowManager.areToastsDisabled() else { return } + Haptics.notify(.error) + ToastWindowManager.shared.showToast(Toast( + type: .error, + title: t("wallet__toast_payment_failed_title"), + description: t("wallet__payment_failed_description"), + accessibilityIdentifier: "PaymentFailedToast" + )) + } + + /// Whether the payment failed for want of a route that carries the amount, so a smaller one may route. + private static func isNoRoute(_ error: Error) -> Bool { + let appError = error as? AppError + if case .RouteNotFound = (appError?.underlyingError ?? error) as? NodeError { + return true + } + switch appError?.paymentFailureReason { + case .routeNotFound, .retriesExhausted: return true + default: return false + } + } + + /// LDK reports a route lost since the quote, before any payment exists, as a sending failure on a live invoice. + private static func isSendWithoutRoute(_ error: Error, invoice: String) -> Bool { + guard case .PaymentSendingFailed = ((error as? AppError)?.underlyingError ?? error) as? NodeError else { return false } + return (try? Bolt11Invoice.fromStr(invoiceStr: invoice).isExpired()) == false + } + + private static func state(for error: Error) -> MoveToSparkState { + switch error as? MoveToSparkError { + case let .tooLarge(amountSats): .tooLarge(amountSats: amountSats, smallerSats: MoveToSparkMath.smallerAmount(amountSats)) + case .nothingToMove: .failed(.nothingToMove) + case .sparkUnavailable: .failed(.sparkUnavailable) + case nil: .failed(.unknown) + } + } +} diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift new file mode 100644 index 000000000..b4dcd3d7a --- /dev/null +++ b/Bitkit/Managers/SparkExitManager.swift @@ -0,0 +1,1670 @@ +import BackgroundTasks +import BitkitCore +import BreezSdkSpark +import Foundation +import LDKNode +import Observation +import UserNotifications + +/// Runs a Spark emergency exit: the quote, the fee funding, then the driver until every step is on-chain. The exit +/// survives restarts through `SparkExitStore`, and is driven while Bitkit is open and by background runs while it +/// is closed (`BGAppRefreshTask` and `BGProcessingTask`, Spark SDK only). Reminders, scheduled ahead of each step, +/// bring the user back when those runs do not get to a step in time. +@Observable @MainActor +final class SparkExitManager { + static let shared = SparkExitManager() + nonisolated static let backgroundTaskId = "to.bitkit.spark-exit" + nonisolated static let processingTaskId = "to.bitkit.spark-exit.processing" + private nonisolated static let notificationPrefix = "spark-exit" + /// Reminder ids with their fire dates, so one that fired is not posted again on the next run. + private nonisolated static let remindersKey = "sparkExitReminders" + private nonisolated static let walletIndexKey = "sparkExitWalletIndex" + /// The `bitkit_action` of the exit's notifications, so tapping one opens the exit. + nonisolated static let notificationAction = "spark_exit" + private static let foregroundTickSeconds: UInt64 = 60 + /// How long a foreground tick waits for Spark to connect again before it goes on without the SDK. + private static let reconnectTimeoutSeconds: TimeInterval = 20 + + private(set) var record: SparkExitRecord? { + didSet { noteBalanceInputs() } + } + + private(set) var quote: SparkExitQuote? + /// Funding addresses earlier exits left coins at, or may still receive them at: kept change and cancelled exits'. + private(set) var earlierAddresses: [SparkExitEarlierAddress] = [] + private(set) var quoteError: String? + private(set) var isQuoting = false + private(set) var tipHeight: UInt32? + /// Confirmed or not, what reached the funding address and is not a funding input yet. + private(set) var fundingSeenSats: UInt64 = 0 { + didSet { noteBalanceInputs() } + } + + /// How many payments make up `fundingSeenSats` on the pre-start funding screen. + private(set) var fundingSeenCount = 0 + /// The network's rates now, read on every tick: the SDK's recommendation, or the chain host's without the SDK. + private(set) var networkFees: SparkExitNetworkFees? + /// The higher rates a fee bump offers, priced against the running exit. + private(set) var feeBumpOptions: [SparkExitFeeBumpOption] = [] + private(set) var isPricingFeeBump = false + private(set) var feeBumpError: String? + /// Every higher rate would leave leaves on Spark, so none is offered. + private(set) var isFeeBumpLeavingLeaves = false + /// What the running exit shows about its fee funding while it lacks some. + var fundingState: SparkExitFundingState? { + Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingState : nil + } + + /// The full funding the Progress top-up asks for, every extra payment's input fee included. + var fundingAskTotal: UInt64? { + Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingAskTotal : nil + } + + private var storedFundingState: SparkExitFundingState? + private var storedFundingAskTotal: UInt64? + /// The exit `fundingState` and `fundingAskTotal` were read for. + private var fundingExitCreatedAt: Date? + private(set) var fundingAddress: String? + private(set) var lastError: String? + /// Set when the user tapped an exit notification, until the app shows the exit or the loaded record shows none. + var opensProgress = false + /// The record of this wallet was read (there may be none), so a tapped notification no longer waits for it. + private(set) var hasLoadedRecord = false + + /// The funding choice made on the funding screen, used when the exit starts. + @ObservationIgnored var fundFromSavings = false + @ObservationIgnored var savingsFeeRate: UInt32 = 0 + @ObservationIgnored var savingsFeeSats: UInt64 = 0 + /// The savings fee a higher-fee payment was refused for, by rate. + @ObservationIgnored private var feeBumpRefusedFees: [UInt64: UInt64] = [:] + /// The savings fee the start's funding payment was refused for: the next funding prices and approves at least that. + @ObservationIgnored private(set) var savingsFundingRefusedFeeSats: UInt64? + @ObservationIgnored private var walletIndex = 0 + @ObservationIgnored private var isTicking = false + /// The exit a start is still settling: no tick or background run drives it until its optimization wait, requote + /// and reconnect end, since optimization may still swap the leaves it exits. + @ObservationIgnored private var startingCreatedAt: Date? + @ObservationIgnored private(set) var pendingDestination: String? + @ObservationIgnored private var pendingFeeRate: UInt64? + @ObservationIgnored private(set) var pendingFundingIndex: UInt32? + + /// Recomputes the wallet's totals, through Spark's state hook, when what they read from the exit changed. + @ObservationIgnored private let balanceWatch = SparkExitBalanceWatch { SparkManager.shared.onStateChanged?() } + + /// The chain a test drives the exit on, in place of the network's hosts. + @ObservationIgnored private let testChain: SparkExitChain? + + /// The app uses `shared`; tests make their own, on a wallet index no wallet uses, optionally on their own chain. + init(walletIndex: Int = 0, chain: SparkExitChain? = nil) { + self.walletIndex = walletIndex + testChain = chain + } + + private func noteBalanceInputs() { + balanceWatch.update(record: record, fundingSeenSats: fundingSeenSats, sparkBalanceSats: SparkManager.shared.balanceSats) + } + + /// The fee rate of the quote on screen. + var quoteFeeRate: UInt64? { + pendingFeeRate + } + + /// What paying the fee funding from savings costs: that payment's own fee, and the total that leaves savings. Nil + /// when another wallet pays, or there is nothing to fund. + var savingsPayment: (feeSats: UInt64, totalSats: UInt64)? { + guard fundFromSavings else { return nil } + let sats = savingsSats + guard sats > 0 else { return nil } + return (savingsFeeSats, sats + savingsFeeSats) + } + + /// What savings pays for the quote on screen: what the funding address still lacks, with the input fee of the + /// savings payment when it completes a payment from another wallet. + var savingsSats: UInt64 { + guard let quote else { return 0 } + return Self.savingsFundingSats( + confirmed: quote, + fresh: quote, + seenSats: fundingSeenSats, + seenCount: fundingSeenCount, + feeRate: pendingFeeRate ?? 0 + ) + } + + var isExiting: Bool { + record?.isActive == true + } + + /// The exit runs, or its leftover fee funding is still on its way to savings. + var needsDriving: Bool { + record?.needsDriving == true + } + + /// Emergency exit opens this exit's progress or result instead of a new exit. + var showsResult: Bool { + record?.showsResult == true + } + + /// The earlier exits' addresses to show: one the current exit uses again is that exit's while Emergency exit opens + /// its progress or result, which shows it there. + var shownEarlierAddresses: [SparkExitEarlierAddress] { + Self.shownEarlierAddresses(earlierAddresses, record: record) + } + + nonisolated static func shownEarlierAddresses(_ entries: [SparkExitEarlierAddress], record: SparkExitRecord?) -> [SparkExitEarlierAddress] { + guard let record, record.showsResult, let current = record.funding.first?.address else { return entries } + return entries.filter { $0.address != current } + } + + private var earlierAddressStore: SparkExitEarlierAddressStore { + SparkExitEarlierAddressStore(walletIndex: walletIndex) + } + + /// Reads the watched earlier exit addresses, kept change and cancelled exits' for a late payment, and shows what + /// each holds. + func refreshEarlierAddresses() async { + do { + try await Self.recoverFunding(fundingRecovery, now: Date()) + } catch { + Logger.warn("Failed to look for earlier exits' funding after a restore: \(error)", context: "SparkExitManager") + } + let store = earlierAddressStore + earlierAddresses = (try? store.load()) ?? earlierAddresses + guard let chain else { return } + let refreshed = await Self.refreshEarlierAddresses(store: store, fallback: earlierAddresses, now: Date()) { + try await chain.utxos(address: $0) + } + if let refreshed { + earlierAddresses = refreshed + } + } + + /// The store's entries with what each watched address holds, saved; nil when nothing changed. The balances read + /// go into the store as it is once the reads are done, so an address added meanwhile stays. + nonisolated static func refreshEarlierAddresses( + store: SparkExitEarlierAddressStore, + fallback: [SparkExitEarlierAddress], + now: Date, + utxos: (String) async throws -> [EsploraUtxo] + ) async -> [SparkExitEarlierAddress]? { + let stored = (try? store.load()) ?? fallback + guard stored.contains(where: { $0.isWatched(now: now) }) else { return nil } + let balances = await SparkExitEarlierAddress.balances(stored, now: now, utxos: utxos) + let current = (try? store.load()) ?? stored + let refreshed = SparkExitEarlierAddress.updating(current, balances: balances) + guard refreshed != current else { return nil } + do { + try store.save(refreshed) + } catch { + Logger.warn("Failed to save the earlier Spark exit addresses: \(error)", context: "SparkExitManager") + } + return refreshed + } + + private var store: SparkExitStore { + SparkExitStore(walletIndex: walletIndex) + } + + private var lostLeafStore: SparkExitLostLeafStore { + SparkExitLostLeafStore(walletIndex: walletIndex) + } + + /// Saves the record, and keeps the leaves it lost to an older state for every later exit of the wallet. The lost-leaf + /// list is best effort: it never fails the save the driver relies on. + private var saveRecord: (SparkExitRecord) throws -> Void { + Self.gatedSave(store: store, lostLeafStore: lostLeafStore, earlierAddressStore: earlierAddressStore, gate: writeGate) + } + + /// Writes from ticks and background runs, closed for good once the wallet is wiped: a tick still running then can + /// never write the deleted exit back. + @ObservationIgnored private var writeGate = SparkExitWriteGate() + + nonisolated static func gatedSave( + store: SparkExitStore, + lostLeafStore: SparkExitLostLeafStore, + earlierAddressStore: SparkExitEarlierAddressStore? = nil, + gate: SparkExitWriteGate + ) -> (SparkExitRecord) throws -> Void { + { + guard gate.isOpen else { throw SparkExitWriteGate.Closed() } + try store.save($0) + do { + try lostLeafStore.add(lostIn: $0) + } catch { + Logger.warn("Failed to keep the Spark exit's lost leaves: \(error)", context: "SparkExitManager") + } + // Kept change stays reachable after a later exit replaced this record. Best effort, as the lost leaves. + if let earlierAddressStore, let kept = SparkExitEarlierAddress.keptChange($0, network: Env.bitkitCoreNetwork, at: Date()) { + do { + try earlierAddressStore.add(kept) + } catch { + Logger.warn("Failed to keep the Spark exit's kept change address: \(error)", context: "SparkExitManager") + } + } + } + } + + private var chain: SparkExitChain? { + if let testChain { + return testChain + } + guard let network = SparkManager.exitNetwork else { return nil } + return EsploraSparkExitChain.network(network) + } + + // MARK: Lifecycle + + func load(walletIndex: Int) { + self.walletIndex = walletIndex + do { + record = try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) + earlierAddresses = (try? earlierAddressStore.load()) ?? [] + hasLoadedRecord = true + } catch is SparkExitStore.Unavailable { + // Before the first unlock the file is there but cannot be read yet: it is read again on the next load. + Logger.warn("The Spark exit record cannot be read yet", context: "SparkExitManager") + record = nil + } catch { + // Names no active exit it could rebuild: kept aside for support, so a new exit never overwrites it. + Logger.error("Failed to read the Spark exit record: \(error)", context: "SparkExitManager") + try? store.setAside() + record = nil + hasLoadedRecord = true + } + if isExiting { + SparkManager.shared.detachForExit() + } + let record = record + Task { + if let record { + await settleIfFinished(record) + } + await settleOrphanedTransfers(record) + } + } + + /// Loads the stored exit and adds its lost leaves to the lost-leaf list, ended exits included, so leaves an exit + /// lost before that list existed stay out of the next quote too. + /// A record of an unknown version, or one that does not decode, is not forgotten while it names an active exit: + /// what it still tells is kept, the exit rebuilds from the chain with its funding, and the original stays aside. + nonisolated static func loadRecord(store: SparkExitStore, lostLeafStore: SparkExitLostLeafStore) throws -> SparkExitRecord? { + guard let data = try store.loadData() else { return nil } + let record: SparkExitRecord + do { + record = try SparkExitRecordCoder.decode(data) + } catch { + guard let salvaged = SparkExitRecordCoder.salvage(data) else { throw error } + Logger.warn("Rebuilding the Spark exit from the chain, its record could not be read: \(error)", context: "SparkExitManager") + // The original is copied aside first and the salvaged record then replaces it in one write. + try store.keepCopyAside() + try store.save(salvaged) + record = salvaged + } + try? lostLeafStore.add(lostIn: record) + return record + } + + /// Forgets the exit of a wallet being wiped: closes the writes of any tick still running, waits for it to stop, then + /// deletes the record, so the exit can't come back. + func wipe() async { + writeGate.close() + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + writeGate = SparkExitWriteGate() + do { + try store.deleteWithCopies() + } catch { + Logger.error("Failed to delete the wiped wallet's Spark exit record: \(error)", context: "SparkExitManager") + } + try? lostLeafStore.delete() + try? earlierAddressStore.delete() + earlierAddresses = [] + record = nil + hasLoadedRecord = false + quote = nil + // The quote's destination and funding belong to the wiped wallet: the next wallet's exit never pays to them. + pendingDestination = nil + pendingFeeRate = nil + pendingFundingIndex = nil + fundingAddress = nil + fundFromSavings = false + savingsFeeRate = 0 + savingsFeeSats = 0 + savingsFundingRefusedFeeSats = nil + fundingSeenSats = 0 + fundingSeenCount = 0 + Self.clearReminders() + } + + /// Stops an exit that has not built anything and whose funding address received nothing, so leaving it + /// costs nothing: Spark comes back as it was. + var canCancel: Bool { + guard let record else { return false } + return Self.canCancel(record, fundingSeenSats: fundingSeenSats, unresolvedSendAddress: unresolvedSendAddress) + } + + /// The address of an unresolved savings send, read on every tick and before a cancel. + private(set) var unresolvedSendAddress: String? + /// The savings payment of the start that a node refused, read on every tick: progress says so and offers to pay + /// again. + private(set) var refusedSavingsPayment: OnchainSendAttempt? + + var isSavingsPaymentRefused: Bool { + record.map { Self.isSavingsPaymentRefused($0, attempt: refusedSavingsPayment) } ?? false + } + + /// A node refused the savings payment of `record`'s fee funding, and none was adopted since: only before the exit + /// is funded, for the payment the start made. + nonisolated static func isSavingsPaymentRefused(_ record: SparkExitRecord, attempt: OnchainSendAttempt?) -> Bool { + guard record.isActive, case .awaitingFunding = record.phase, record.fundingPaymentTxid == nil, + let address = record.funding.first?.address, let attempt + else { return false } + return attempt.address == address && attempt.status == .rejected + } + + /// Pays a refused savings payment again: the same attempt, re-signed at the rate the user approved over the inputs + /// it already spends. Every version spends those inputs, so at most one ever confirms, and nothing waits for the + /// refused one to be known gone from the mempool first. The txid to wait for, or nil when it was refused again. + nonisolated static func payRefusedAgain( + _ refused: OnchainSendAttempt, + record: SparkExitRecord, + retrySamePayment: (OnchainSendPendingContext) async throws -> OnchainSendResult + ) async throws -> String? { + guard isSavingsPaymentRefused(record, attempt: refused), refused.canRetrySamePayment else { + throw OnchainSendAttemptError.unresolved + } + let result = try await retrySamePayment(OnchainSendPendingContext(attemptId: refused.id, walletId: refused.walletId, txid: refused.txid)) + return fundingTxid(result) + } + + func retrySavingsPayment() async throws { + // Under the ticks' lock from the check to the saved txid, as the start's payment: a cancel either ends the exit + // before savings pays again or finds the payment and refuses. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + isTicking = true + defer { isTicking = false } + if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) { + record = stored + } + guard let current = record, let refused = refusedSavingsPayment else { return } + let attempts = OnchainSendAttemptService.shared + let txid = try await Self.payRefusedAgain(refused, record: current) { context in + try await attempts.retrySamePayment(using: LightningService.shared, context: context) { attempt, _ in + guard attempt.id == refused.id, attempt.address == refused.address else { throw OnchainSendAttemptError.unresolved } + } + } + await refreshRefusedSavingsPayment() + guard let txid else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit savings payment refused again") + } + _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: refused.walletId, observedTxid: txid) + guard var paid = record, paid.createdAt == current.createdAt, paid.isActive else { return } + paid.fundingPaymentTxid = txid + try saveRecord(paid) + record = paid + setFunding(SparkExitMath.fundingState(paid, utxos: nil), for: paid) + refusedSavingsPayment = nil + Logger.info("Paid the Spark exit funding from savings again in \(txid)", context: "SparkExitManager") + Task { await tick() } + } + + private func refreshRefusedSavingsPayment() async { + let attempt = try? await OnchainSendAttemptService.shared.blockingAttempt() + refusedSavingsPayment = attempt?.status == .rejected ? attempt : nil + } + + /// What the exit still brings to savings, for its own transfer's in-transfer amount; nil without one. + var inTransfer: (transferId: String, sats: UInt64)? { + guard let record, let transferId = record.transferId else { return nil } + return (transferId, SparkExitMath.inTransferSats(record, fundingSeenSats: fundingSeenSats)) + } + + /// Nothing built, nothing paid from savings and nothing seen at the funding address, and no unresolved savings send + /// to that address: such a send can still be retried from Send, always to the same address, which a forgotten exit + /// would no longer watch. + nonisolated static func canCancel(_ record: SparkExitRecord, fundingSeenSats: UInt64, unresolvedSendAddress: String? = nil) -> Bool { + guard record.recovery == nil, record.fundedSats == 0, fundingSeenSats == 0, record.fundingPaymentTxid == nil else { return false } + if let unresolvedSendAddress, record.funding.contains(where: { $0.address == unresolvedSendAddress }) { + return false + } + if case .awaitingFunding = record.phase { + return true + } + return false + } + + enum CancelOutcome: Equatable { + /// Nothing reached the funding address: the exit is forgotten and Spark comes back. + case forget + /// Funding reached it after all: the exit ends and sends that funding back to savings. + case sweepFunding([SparkExitRecord.Leftover]) + /// The address could not be read: nothing changes. + case keep + /// The exit can no longer be cancelled, such as once a tick adopted its funding: nothing changes. + case notCancellable + } + + /// What cancelling does, given what the funding address holds now (nil when it could not be read). + /// What a tapped exit notification does now. + enum NotificationOpen: Equatable { + case wait, open, drop + } + + /// It waits for the record to load, opens the exit while it shows a result, and is let go once the loaded record + /// shows none, so a later result never jumps to Progress. + nonisolated static func notificationOpen(opensProgress: Bool, showsResult: Bool, hasLoadedRecord: Bool) -> NotificationOpen { + guard opensProgress else { return .drop } + if showsResult { + return .open + } + return hasLoadedRecord ? .drop : .wait + } + + /// The funding index counter once an exit using `index` is forgotten. + /// Given back when it is the last one used, so the next exit watches that address again. + nonisolated static func fundingCounterAfterForget(_ counter: Int, index: UInt32) -> Int { + counter == Int(index) + 1 ? Int(index) : counter + } + + nonisolated static func cancelOutcome(utxos: [EsploraUtxo]?) -> CancelOutcome { + guard let utxos else { return .keep } + guard !utxos.isEmpty else { return .forget } + return .sweepFunding(utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) }) + } + + /// Notes that the user opened the result of an exit that failed or lost leaves, so Emergency exit starts a new + /// one next time. + func markResultSeen() { + guard var record, record.hasUnseenResult else { return } + record.isResultSeen = true + do { + try store.save(record) + self.record = record + } catch { + Logger.error("Failed to save the seen Spark exit result: \(error)", context: "SparkExitManager") + } + } + + /// Reads the funding address first: a payment that arrived since the last tick is never orphaned. With funding there + /// the record stays, ends as cancelled and its leftover sweep returns the funding to savings. + /// Holds the same lock as the driver's ticks and the background run for the whole cancel, and decides on the record + /// as stored now: one a tick funded, built or published meanwhile is never cancelled. + @discardableResult + func cancel() async -> CancelOutcome { + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + isTicking = true + defer { isTicking = false } + if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) { + record = stored + } + unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() + guard canCancel, var record, let address = record.funding.first?.address else { return .notCancellable } + let utxos = try? await chain?.utxos(address: address) + let outcome = Self.cancelOutcome(utxos: utxos) + switch outcome { + case .keep: + Logger.warn("Spark exit not cancelled: its funding address could not be read", context: "SparkExitManager") + return outcome + case .notCancellable: + return outcome + case let .sweepFunding(leftover): + record.phase = .failed(reason: SparkExitRecord.cancelledReason) + record.isCancelled = true + record.isResultSeen = true + record.leftover = leftover + do { + try saveRecord(record) + } catch { + Logger.error("Failed to save the cancelled Spark exit: \(error)", context: "SparkExitManager") + return .keep + } + self.record = record + if let transferId = record.transferId { + try? await TransferService(lightningService: .shared).markSettled(id: transferId) + } + await reattachSpark(isCancel: true) + Logger.info("Cancelled the Spark exit after funding arrived; sweeping it back to savings", context: "SparkExitManager") + // After the lock is released by this function's return. + Task { await tick() } + return outcome + case .forget: + break + } + // Its address stays listed, and is read for a late payment while the app is in the foreground for four weeks. + if let cancelled = SparkExitEarlierAddress.cancelledFunding(record, network: Env.bitkitCoreNetwork, at: Date()) { + do { + earlierAddresses = try earlierAddressStore.add(cancelled) + } catch { + Logger.warn("Failed to keep the cancelled Spark exit's address: \(error)", context: "SparkExitManager") + } + } + try? store.delete() + self.record = nil + Self.clearReminders() + // A payment that lands at the forgotten address late is then seen by the next exit, which uses it again. + let counter = UserDefaults.standard.integer(forKey: fundingIndexKey) + UserDefaults.standard.set(Self.fundingCounterAfterForget(counter, index: record.fundingIndex), forKey: fundingIndexKey) + // The counter rides the metadata backup, which its dynamic key does not mark by itself. + SettingsViewModel.shared.notifyAppStateChanged() + if let transferId = record.transferId { + try? await TransferService(lightningService: .shared).markSettled(id: transferId) + } + await reattachSpark(isCancel: true) + Logger.info("Cancelled the Spark exit before it was funded", context: "SparkExitManager") + return .forget + } + + /// Ticks every minute while the app is active and an exit runs. Each step of an exit needs Bitkit to publish it. + func runWhileActive() async { + await refreshEarlierAddresses() + while !Task.isCancelled, needsDriving { + await tick() + try? await Task.sleep(nanoseconds: Self.foregroundTickSeconds * 1_000_000_000) + } + } + + func tick() async { + // Every tick asks for the next background run, so one failed or skipped tick never ends the chain. + defer { + if needsDriving { + Self.scheduleBackgroundRuns() + } + } + guard !isTicking, let current = record, Self.canDrive(current, startingCreatedAt: startingCreatedAt), let chain else { return } + isTicking = true + defer { isTicking = false } + let store = store + let walletIndex = walletIndex + // A reconnect for the exit that failed leaves Spark ready but disconnected: the tick connects it again. + if SparkManager.shared.isReady, !SparkManager.shared.isConnected { + _ = await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: Self.reconnectTimeoutSeconds) + } + let driver = SparkExitDriver( + chain: chain, + recovering: SparkManager.shared.exitRecovering, + makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, + save: saveRecord, + trustedFeeRate: Self.trustedFeeRate + ) + do { + tipHeight = await (try? chain.tipHeight()) ?? tipHeight + unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() + await refreshRefusedSavingsPayment() + await refreshNetworkFees(chain: chain) + await refreshFundingSeen(chain: chain, record: current) + var updated = try await driver.tick(current) + // A tick that adopted the funding and came up short would otherwise keep showing that payment as seen. + if updated.phase != current.phase { + await refreshFundingSeen(chain: chain, record: updated) + } + // The result may have been opened while this tick ran; a reset the driver made, such as for change kept + // after the result was opened, stays. + if record?.isResultSeen == true, current.isResultSeen != true, updated.isResultSeen != true { + updated.isResultSeen = true + try? store.save(updated) + } + record = updated + if Self.isFundingAskWithdrawn(updated, previous: current) { + Self.dismissDeliveredFundingNotifications(record: updated) + } + earlierAddresses = (try? earlierAddressStore.load()) ?? earlierAddresses + lastError = nil + await afterTick(updated, wasActive: current.isActive) + } catch { + lastError = error.localizedDescription + Logger.error("Spark exit tick failed: \(error)", context: "SparkExitManager") + // The driver saved along the way: the next tick starts from what is on disk, never from the copy it began with. + let stored = (try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore)) ?? current + record = stored + await scheduleReminders(stored) + } + } + + private func afterTick(_ record: SparkExitRecord, wasActive: Bool) async { + if let transferId = record.transferId { + try? TransferStorage.shared.updateClaimableAtHeight(id: transferId, claimableAtHeight: record.claimableAtHeight) + } + await settleIfFinished(record) + await scheduleReminders(record) + if wasActive, !record.isActive { + await reattachSpark() + } + } + + /// Ends the exit's transfer once the sweep reaches savings or once the exit ended, so its amount is never counted + /// both as in transfer and in the on-chain balance. Runs on every tick and launch until it holds. A sweep of a + /// generation the failed-leaf retry replaced is looked for too. + private func settleIfFinished(_ record: SparkExitRecord) async { + guard let transferId = record.transferId, let transfer = try? TransferStorage.shared.getById(transferId) else { return } + let activity = CoreService.shared.activity + var isSweepSeen = false + // The sweep's activity is marked as a transfer even after the transfer settled: it may reach the activity + // list only later. + for txid in SparkExitMath.sweepTxids(record) { + guard await activity.hasOnchainActivityForTxid(txid: txid) else { continue } + await activity.markOnchainActivityAsTransfer(txId: txid) + isSweepSeen = true + } + guard Self.isTransferFinished(record, isSweepSeen: isSweepSeen), !transfer.isSettled else { return } + try? await TransferService(lightningService: .shared).markSettled(id: transferId) + } + + /// The exit's transfer ends once the exit ended, or once its sweep reached savings and no leaf is outstanding (a + /// failed cooperative one before or during its retry, or one a build skipped): the value those leaves hold stays in + /// transfer, less what already reached savings (`inTransferSats`). + nonisolated static func isTransferFinished(_ record: SparkExitRecord, isSweepSeen: Bool) -> Bool { + !record.isActive || (isSweepSeen && !record.hasLeavesOutstanding) + } + + /// Settles exit transfers no running exit owns, so their amount never stays in transfer to savings for good. + private func settleOrphanedTransfers(_ record: SparkExitRecord?) async { + guard let active = try? TransferStorage.shared.getActiveTransfers() else { return } + for id in SparkExitMath.orphanedTransferIds(active, record: record) { + Logger.warn("Settling Spark exit transfer \(id) with no running exit", context: "SparkExitManager") + try? await TransferService(lightningService: .shared).markSettled(id: id) + } + } + + private func reattachSpark(isCancel: Bool = false) async { + let spark = SparkManager.shared + await spark.reconnectForExit(isCancel: isCancel) + // A Spark the reconnect left disconnected stays off the rail, so Lightning keeps paying through LDK. + if LightningService.shared.channelFreeRail == nil, spark.isReady, spark.isConnected { + LightningService.shared.channelFreeRail = spark + } + spark.onStateChanged?() + } + + /// Only new funding counts: the adopted inputs and the CPFP change at the address are already part of the exit. + private func refreshFundingSeen(chain: SparkExitChain, record: SparkExitRecord) async { + guard let address = record.funding.first?.address else { return } + fundingAddress = address + guard record.fundingShortfallSats != nil else { + // Adopted, or nothing to fund: what was seen is part of the exit now, never funding still to count. + fundingSeenSats = 0 + fundingSeenCount = 0 + setFunding(nil, for: record) + return + } + let utxos = try? await chain.utxos(address: address) + if let utxos { + fundingSeenSats = SparkExitMath.newFunding(utxos, record: record).reduce(0) { $0 + $1.value } + } + setFunding(SparkExitMath.fundingState(record, utxos: utxos), askTotal: SparkExitMath.fundingAskTotal(record, utxos: utxos), for: record) + } + + private func setFunding(_ state: SparkExitFundingState?, askTotal: UInt64? = nil, for record: SparkExitRecord) { + storedFundingState = state + storedFundingAskTotal = askTotal + fundingExitCreatedAt = record.createdAt + } + + /// Whether funding read for the exit created at `createdAt` belongs to `record`: a cancelled exit's savings + /// payment or funding ask never shows for the next one. + nonisolated static func isFundingOfExit(_ createdAt: Date?, record: SparkExitRecord?) -> Bool { + createdAt != nil && createdAt == record?.createdAt + } + + private func refreshNetworkFees(chain: SparkExitChain) async { + if SparkManager.shared.exitRecovering != nil, let fees = try? await SparkManager.shared.exitNetworkFees() { + networkFees = fees + } else if let fees = try? await chain.networkFees() { + networkFees = fees + } + } + + // MARK: Fee bump + + /// Whether the running exit can move to a higher fee: it is underpriced (refused under the mempool's minimum, or + /// accepted and unconfirmed for more than `unconfirmedBlocksToSurface` blocks), or the network's next-block rate + /// is above its own. Not while it rebuilds or waits for funding, and not once only leaves the operators hold are left. + var canPayHigherFee: Bool { + guard let record else { return false } + return Self.canPayHigherFee(record, tip: tipHeight, isNetworkAboveExitRate: isNetworkAboveExitRate) + } + + /// A recovery of the exit's own still has a step to confirm: a failed-leaf retry that built one counts too, its + /// timing kept for the leaves still outstanding. One waiting only on the operators has nothing to speed up. + nonisolated static func canPayHigherFee(_ record: SparkExitRecord, tip: UInt32?, isNetworkAboveExitRate: Bool) -> Bool { + guard record.phase == .running, !record.isRebuilding, let recovery = record.recovery?.response else { return false } + let hasStepToConfirm = recovery.transactions.contains { + if case .confirmed = $0.status { + return false + } + return true + } + guard !record.isRetryingFailedLeaves || hasStepToConfirm else { return false } + return record.underpricedStep(tip: tip) != nil || isNetworkAboveExitRate + } + + /// The network's next-block rate is above the exit's. + var isNetworkAboveExitRate: Bool { + guard let record, let networkFees else { return false } + return networkFees.fastest > record.feeRateSatPerVbyte + } + + /// Prices each higher rate against what the exit's funding holds now. + func loadFeeBumpOptions() async { + guard let record, let chain else { return } + isPricingFeeBump = true + feeBumpError = nil + isFeeBumpLeavingLeaves = false + defer { isPricingFeeBump = false } + await refreshNetworkFees(chain: chain) + guard let networkFees else { + feeBumpError = t("lightning__spark_exit__fee_bump_error") + return + } + let walletIndex = walletIndex + let driver = SparkExitDriver( + chain: chain, + recovering: SparkManager.shared.exitRecovering, + makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, + save: { _ in } + ) + var options = SparkExitFeeBumpOption.options(exitRate: record.feeRateSatPerVbyte, fees: networkFees) + var leavesBehind = false + for i in options.indices { + do { + options[i].extraSats = try await driver.feeBumpShortfall(record, rate: options[i].rate) + } catch let SparkExitFeeBumpError.leavesBehind(sats) { + leavesBehind = true + Logger.info( + "Spark exit fee bump to \(options[i].rate) sat/vB would leave \(sats) sats on Spark; not offered", + context: "SparkExitManager" + ) + } catch { + Logger.warn("Failed to price a Spark exit fee bump to \(options[i].rate) sat/vB: \(error)", context: "SparkExitManager") + } + } + feeBumpOptions = options.filter { $0.extraSats != nil } + if feeBumpOptions.isEmpty { + if leavesBehind { + isFeeBumpLeavingLeaves = true + } else { + feeBumpError = t("lightning__spark_exit__fee_bump_error") + } + } + } + + /// An ended exit keeps a savings payment that went out after it ended as pending, so the exit is driven until the + /// payment reaches the funding address and joins the leftover sweep (`followEndedPayment`). Nil while the exit is + /// active (its funding adopts the payment), without a txid, or while an earlier payment is still followed. + nonisolated static func followingLatePayment(_ record: SparkExitRecord, txid: String?) -> SparkExitRecord? { + guard !record.isActive, let txid, !record.isEndedPaymentPending else { return nil } + var record = record + record.fundingPaymentTxid = txid + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + return record + } + + /// Notes what savings pays in fees to send the extra funding of `rate`. + func setFeeBumpPaymentFee(rate: UInt64, sats: UInt64) { + guard let i = feeBumpOptions.firstIndex(where: { $0.rate == rate }) else { return } + feeBumpOptions[i].paymentFeeSats = Self.feeBumpPaymentFee(priced: sats, refusedFeeSats: feeBumpRefusedFees[rate]) + } + + /// Notes the savings fee the start's funding payment was refused for, so the next funding prices and approves it. + func noteSavingsFundingRefused(preparedFeeSats: UInt64?) { + savingsFundingRefusedFeeSats = preparedFeeSats + } + + /// Notes the savings fee a higher-fee payment at `rate` was refused for, so the next confirm approves it. + func noteFeeBumpRefused(rate: UInt64, preparedFeeSats: UInt64?) { + feeBumpRefusedFees[rate] = preparedFeeSats + } + + /// The fee a higher-fee payment is confirmed at: coin selection can need more than the estimate, so after a refusal + /// at least the fee it was refused for. + nonisolated static func feeBumpPaymentFee(priced: UInt64, refusedFeeSats: UInt64?) -> UInt64 { + max(priced, refusedFeeSats ?? 0) + } + + /// Moves the exit to `option`'s rate: pays its extra funding from savings at `savingsFeeRate` first, then rebuilds + /// what has not confirmed at the new rate, which replaces it on the network. + func payHigherFee(_ option: SparkExitFeeBumpOption, savingsFeeRate: UInt32) async throws { + guard let extraSats = option.extraSats, let address = record?.funding.first?.address, canPayHigherFee else { + throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "No fee bump to pay") + } + var txid: String? + if extraSats > 0 { + // The payment goes out on Bitkit's chain: while Spark reads another, the exit would never see it. + guard SparkManager.shared.canFundFromChain else { + throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Spark does not read Bitkit's chain") + } + guard let paymentFeeSats = option.paymentFeeSats else { + throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Fee bump payment not priced") + } + txid = try await payFunding(address: address, sats: extraSats, satsPerVbyte: savingsFeeRate, feeSats: paymentFeeSats) + } + // Written between ticks, so a tick in flight never saves over it. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + guard var record else { return } + // The exit may have ended while savings paid: the extra stays at the funding address for the leftover sweep. + guard record.phase == .running, !record.isRebuilding else { + Logger.warn("Spark exit ended while its higher fee was paid; the payment goes back with the leftover", context: "SparkExitManager") + if let followed = Self.followingLatePayment(record, txid: txid) { + try saveRecord(followed) + self.record = followed + Task { await tick() } + } + return + } + record.applyFeeBump(rate: option.rate, extraSats: extraSats, paymentTxid: txid) + try saveRecord(record) + self.record = record + setFunding(SparkExitMath.fundingState(record, utxos: nil), askTotal: storedFundingAskTotal, for: record) + Logger.info("Spark exit moves to \(option.rate) sat/vB with \(extraSats) sats more funding", context: "SparkExitManager") + Task { await tick() } + } + + // MARK: Quote + + func loadQuote() async { + isQuoting = true + quoteError = nil + defer { isQuoting = false } + do { + guard let recovering = SparkManager.shared.exitRecovering else { + throw AppError(message: "Spark is not connected", debugMessage: nil) + } + // Value an interrupted leaf optimization still owes the wallet would otherwise stay out of the quote. + await SparkManager.shared.settleForExit() + let feeRate = try await SparkManager.shared.exitFeeRate() + let destination: String = if let pendingDestination { + pendingDestination + } else { + try await LightningService.shared.newAddress() + } + let newQuote = try await SparkExitQuote.prepare( + recovering, + feeRateSatPerVbyte: feeRate, + destination: destination, + lostLeafIds: (try? lostLeafStore.load()) ?? [] + ) + let fundingIndex = if let pendingFundingIndex { + pendingFundingIndex + } else { + try await nextFundingIndex() + } + let fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address + useQuote(newQuote, destination: destination, feeRate: feeRate, fundingIndex: fundingIndex, fundingAddress: fundingAddress) + } catch { + Logger.error("Failed to quote the Spark exit: \(error)", context: "SparkExitManager") + quote = nil + quoteError = t("lightning__spark_exit__quote_error") + } + } + + /// The quote the funding and confirm screens show, and `start()` starts from. + func useQuote(_ quote: SparkExitQuote, destination: String, feeRate: UInt64, fundingIndex: UInt32, fundingAddress: String) { + // What reached another address, an earlier exit's, is not this exit's funding. + if self.fundingAddress != fundingAddress { + fundingSeenSats = 0 + fundingSeenCount = 0 + } + pendingDestination = destination + pendingFeeRate = feeRate + pendingFundingIndex = fundingIndex + self.quote = quote + self.fundingAddress = fundingAddress + } + + /// Watches the funding address while the user funds it from another wallet. + func watchFundingAddress() async { + guard let chain, let fundingAddress else { return } + while !Task.isCancelled { + if let utxos = try? await chain.utxos(address: fundingAddress) { + noteFundingSeen(utxos) + } + try? await Task.sleep(nanoseconds: 15 * 1_000_000_000) + } + } + + /// What reached the funding address before the exit starts. + func noteFundingSeen(_ utxos: [EsploraUtxo]) { + fundingSeenSats = utxos.reduce(0) { $0 + $1.value } + fundingSeenCount = utxos.count + } + + // MARK: Start + + /// Starts the exit for the current quote: writes its record, moves the Spark balance into transfer to savings + /// and, with `fundFromSavings`, pays the fee funding from savings at `savingsFeeRate`. + func start() async throws { + guard !isExiting, let quote, let destination = pendingDestination, let feeRate = pendingFeeRate, + let fundingIndex = pendingFundingIndex, let network = SparkManager.exitNetwork + else { + throw AppError(message: t("lightning__spark_exit__quote_error"), debugMessage: "No quote to start from") + } + let key = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex) + // A send that passed its exit check before the hold may still spend leaves. One still sending after the wait + // refuses the start before anything is saved, so the quote stays and the user starts again once it ended. + // The record holds Spark from its save on. + defer { SparkManager.shared.releaseSpendsHold() } + try await SparkManager.shared.holdSpendsForExit() + var record = SparkExitRecord( + network: "\(network)", + createdAt: Date(), + phase: .awaitingFunding(requiredSats: quote.fundingSats), + feeRateSatPerVbyte: feeRate, + destination: destination, + leafIds: quote.leafIds, + leafTotalSats: quote.leafTotalSats, + funding: [.init(index: fundingIndex, address: key.address, pubkey: key.pubkey)], + expectedSats: quote.incomingSats, + skippedLeafCount: quote.prepared.skipped.count + ) + record.fundingStrategy = SparkExitRecord.perNodeStrategy + record.outsideSats = SparkExitMath.outsideSats(sparkBalanceSats: SparkManager.shared.balanceSats, quote: quote) + record.leafSats = quote.leafSats + let transfers = TransferService(lightningService: .shared) + let store = store + record = try await Self.writeStart( + record, + incomingSats: quote.incomingSats, + createTransfer: { try await transfers.createTransfer(type: .sparkExit, amountSats: $0) }, + save: { try store.save($0) }, + markSettled: { try? await transfers.markSettled(id: $0) } + ) + UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey) + // Never lowers a counter a restore's scan moved past this quote's index. + UserDefaults.standard.set(max(UserDefaults.standard.integer(forKey: fundingIndexKey), Int(fundingIndex) + 1), forKey: fundingIndexKey) + SettingsViewModel.shared.notifyAppStateChanged() + pendingFundingIndex = nil + startingCreatedAt = record.createdAt + defer { startingCreatedAt = nil } + self.record = record + // In the same step the exit shows: a refresh before the reconnect below would read Spark as exiting. + SparkManager.shared.holdKeptBalanceForExit() + SparkManager.shared.detachForExit() + // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. + await SparkManager.shared.waitForOptimization() + // The rest runs under the ticks' lock, on this exit as stored: one cancelled during the wait above is never + // reconnected for or paid to. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + isTicking = true + defer { isTicking = false } + let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) + guard var started = Self.startedExit(stored: stored, createdAt: record.createdAt) else { + Logger.info("Spark exit was cancelled before it started; nothing paid", context: "SparkExitManager") + throw SparkExitStartCancelled() + } + // An optimization since the quote swapped leaves: the exit is quoted again over the leaves Spark holds now. + var exitQuote = quote + if let recovering = SparkManager.shared.exitRecovering, + let fresh = await Self.requote( + recovering, + confirmed: quote, + feeRate: feeRate, + destination: destination, + lostLeafIds: (try? lostLeafStore.load()) ?? [] + ), + let requoted = Self.requoted(started, fresh: fresh, sparkBalanceSats: SparkManager.shared.balanceSats) + { + let transfers = TransferService(lightningService: .shared) + let store = store + let written = await Self.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { try await transfers.createTransfer(type: .sparkExit, amountSats: $0) }, + save: { try store.save($0) }, + markSettled: { try? await transfers.markSettled(id: $0) } + ) + if let written { + started = written + self.record = written + exitQuote = fresh + } + } + await SparkManager.shared.reconnectForExit() + Logger.info("Started Spark exit of \(exitQuote.exitSats) sats over \(exitQuote.leafIds.count) leaves", context: "SparkExitManager") + + let savingsSats = Self.savingsFundingSats( + confirmed: quote, + fresh: exitQuote, + seenSats: fundingSeenSats, + seenCount: fundingSeenCount, + feeRate: feeRate + ) + if fundFromSavings, savingsSats > 0 { + let txid: String + do { + txid = try await payFunding(address: key.address, sats: savingsSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) + } catch { + // A refused payment shows on progress at once, with the offer to pay again. + await refreshRefusedSavingsPayment() + throw error + } + // Saved before the lock is released, so a cancel after the payment finds it and refuses. + started.fundingPaymentTxid = txid + do { + try store.save(started) + } catch { + Logger.error("Failed to save the Spark exit funding payment: \(error)", context: "SparkExitManager") + } + self.record = started + setFunding(SparkExitMath.fundingState(started, utxos: nil), for: started) + savingsFundingRefusedFeeSats = nil + } + Task { await tick() } + } + + /// The exit quoted again at its start, when the leaves Spark holds changed since the confirmed quote; nil when they + /// did not, when none is left, or when the quote fails (the exit then goes on with the confirmed one). + nonisolated static func requote( + _ recovering: SparkExitRecovering, + confirmed: SparkExitQuote, + feeRate: UInt64, + destination: String, + lostLeafIds: Set + ) async -> SparkExitQuote? { + do { + let fresh = try await SparkExitQuote.prepare(recovering, feeRateSatPerVbyte: feeRate, destination: destination, lostLeafIds: lostLeafIds) + guard !fresh.leafIds.isEmpty, Set(fresh.leafIds) != Set(confirmed.leafIds) else { return nil } + Logger.warn("Quoting the Spark exit again: its leaves changed since the quote", context: "SparkExitManager") + return fresh + } catch { + Logger.warn("Failed to quote the Spark exit again at its start: \(error)", context: "SparkExitManager") + return nil + } + } + + /// The started exit over the fresh quote's leaves, amount and funding, and what `sparkBalanceSats` holds beyond them. + nonisolated static func requoted(_ record: SparkExitRecord, fresh: SparkExitQuote, sparkBalanceSats: UInt64) -> SparkExitRecord? { + guard case .awaitingFunding = record.phase else { return nil } + var record = record + record.phase = .awaitingFunding(requiredSats: fresh.fundingSats) + record.leafIds = fresh.leafIds + record.leafTotalSats = fresh.leafTotalSats + record.expectedSats = fresh.incomingSats + record.skippedLeafCount = fresh.prepared.skipped.count + record.leafSats = fresh.leafSats + record.outsideSats = SparkExitMath.outsideSats(sparkBalanceSats: sparkBalanceSats, quote: fresh) + return record + } + + /// The new exit stored with its transfer in one write, so no running exit is ever stored without the transfer + /// counting it. A failed save settles the transfer again; one a killed start left behind is settled as an orphan + /// on the next load. + nonisolated static func writeStart( + _ record: SparkExitRecord, + incomingSats: UInt64, + createTransfer: (UInt64) async throws -> String, + save: (SparkExitRecord) throws -> Void, + markSettled: (String) async -> Void + ) async throws -> SparkExitRecord { + var record = record + let transferId = try await createTransfer(incomingSats) + record.transferId = transferId + do { + try save(record) + } catch { + await markSettled(transferId) + throw error + } + return record + } + + /// The started exit moved onto the fresh quote: a transfer for its amount, saved, and the confirmed quote's transfer + /// settled. Nil when that write fails, with a transfer made for it settled again: the start goes on with the + /// confirmed quote, as after a failed requote, so it still reconnects, pays and ticks. + nonisolated static func writeRequote( + _ requoted: SparkExitRecord, + replacing started: SparkExitRecord, + incomingSats: UInt64, + createTransfer: (UInt64) async throws -> String, + save: (SparkExitRecord) throws -> Void, + markSettled: (String) async -> Void + ) async -> SparkExitRecord? { + var requoted = requoted + do { + requoted.transferId = try await createTransfer(incomingSats) + } catch { + Logger.warn("Failed to create the requoted Spark exit transfer, going on with the confirmed quote: \(error)", context: "SparkExitManager") + return nil + } + do { + try save(requoted) + } catch { + Logger.warn("Failed to save the requoted Spark exit, going on with the confirmed quote: \(error)", context: "SparkExitManager") + if let created = requoted.transferId { + await markSettled(created) + } + return nil + } + if let replaced = started.transferId { + await markSettled(replaced) + } + return requoted + } + + /// Whether a tick may drive the exit now: not while its start still settles it (`startingCreatedAt`). + nonisolated static func canDrive(_ record: SparkExitRecord, startingCreatedAt: Date?) -> Bool { + record.needsDriving && record.createdAt != startingCreatedAt + } + + /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on + /// Progress, which asks for the rest. + /// Funding another wallet already sent to the address (`seenSats`) is not paid again. What is still missing is + /// paid at least at the dust limit, as the Progress top-up asks for it: the onchain wallet refuses less. + nonisolated static func savingsFundingSats( + confirmed: SparkExitQuote, + fresh: SparkExitQuote, + seenSats: UInt64, + seenCount: Int, + feeRate: UInt64 + ) -> UInt64 { + let total = SparkExitMath.prestartFunding( + fundingSats: min(confirmed.fundingSats, fresh.fundingSats), + payments: seenCount, + receivedSats: seenSats, + feeRate: feeRate + ).toSend + let missing = total.saturatingSub(seenSats) + return missing > 0 ? max(missing, SparkExitMath.fundingDustSats) : 0 + } + + /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps + /// dates to the millisecond. + nonisolated static func startedExit(stored: SparkExitRecord?, createdAt: Date) -> SparkExitRecord? { + guard let stored, stored.isActive else { return nil } + let millis = { (date: Date) in (date.timeIntervalSince1970 * 1000).rounded() } + return millis(stored.createdAt) == millis(createdAt) ? stored : nil + } + + /// Keeps the savings payment in the record, so the exit waits for it instead of asking for the funding again. + /// Written between ticks, so a tick in flight never saves over it. + /// The funding key index of the next exit: one no exit of this wallet used, so leftover of an earlier exit never + /// mixes into a new one. A scan's result rides the next metadata backup. + private func nextFundingIndex() async throws -> UInt32 { + let recovery = fundingRecovery + let wasKept = recovery.defaults.object(forKey: recovery.key) != nil && !recovery.defaults.bool(forKey: recovery.pendingKey) + let index = try await Self.nextFundingIndex(recovery, now: Date()) + if !wasKept { + SettingsViewModel.shared.notifyAppStateChanged() + } + return index + } + + /// One wallet's exit funding as a restore leaves it: the counter at `key`, the stored record, the earlier exit + /// addresses, and each funding index's address and path. + struct FundingRecovery { + let defaults: UserDefaults + let key: String + let pendingKey: String + let record: SparkExitRecord? + let chain: SparkExitChain? + let earlierAddressStore: SparkExitEarlierAddressStore + let funding: (UInt32) throws -> (address: String, path: String) + } + + private var fundingRecovery: FundingRecovery { + let walletIndex = walletIndex + return FundingRecovery( + defaults: .standard, + key: fundingIndexKey, + pendingKey: SparkExitFundingIndexes.recoveryPendingKey(network: "\(Env.network)", walletIndex: walletIndex), + record: record, + chain: chain, + earlierAddressStore: earlierAddressStore, + funding: { index in + let key = try Self.fundingKey(walletIndex: walletIndex, index: index) + return (key.address, SparkExitFundingKey.path(index: index, network: Env.bitkitCoreNetwork)) + } + ) + } + + /// From the counter the last start left and the stored record. While a restore is pending, as after a restore from + /// the phrase or from a backup, from a scan of the funding addresses first: a restored counter can predate the last + /// exit's funding. A scan the chain fails throws and keeps nothing, so no quote reuses that funding address. + static func nextFundingIndex(_ recovery: FundingRecovery, now: Date) async throws -> UInt32 { + try await recoverFunding(recovery, now: now) + return SparkExitMath.nextFundingIndex(counter: recovery.defaults.integer(forKey: recovery.key), record: recovery.record) + } + + /// Finds earlier exits' funding after a restore. Scans for the next index and keeps it: from 0 with no counter kept + /// and no record, else from the counter a restore brought back, reading each funding address below it once too. + /// Each funding address that still holds coins and nothing watches is listed as kept change, so it is watched for + /// good. Throws when a read or that listing fails, keeping no counter and the restore pending, so it runs again on + /// the next start. + /// + /// Returns the index the scan found, nil when no restore was pending. + @discardableResult + static func recoverFunding(_ recovery: FundingRecovery, now: Date) async throws -> UInt32? { + let defaults = recovery.defaults + let kept = defaults.object(forKey: recovery.key) as? Int + let isPending = kept == nil ? recovery.record == nil : defaults.bool(forKey: recovery.pendingKey) + guard isPending, let chain = recovery.chain else { return nil } + var derived: [UInt32: (address: String, path: String)] = [:] + func funding(_ index: UInt32) throws -> (address: String, path: String) { + if let known = derived[index] { + return known + } + let found = try recovery.funding(index) + derived[index] = found + return found + } + var used: [UInt32] = [] + let restored = kept.map { UInt32(clamping: max(0, $0)) } + // A restored index can predate the last exit's funding, when its backup didn't land, so the scan goes on from it. + let scanned = try await SparkExitMath.scanNextFundingIndex(from: restored ?? 0) { index in + let isUsed = try await chain.isAddressUsed(address: funding(index).address) + if isUsed { + used.append(index) + } + return isUsed + } + let candidates = restored.map { Array(0 ..< $0) + used } ?? used + // A forgotten exit's funding address is no longer watched once the scan moves the counter past it, so one that + // holds coins is listed again, as kept change. + let listed = ((try? recovery.earlierAddressStore.load()) ?? []).filter { $0.kind != .cancelledFunding } + let watched = Set(listed.map(\.address) + (recovery.record?.funding.map(\.address) ?? [])) + for index in candidates { + let (address, path) = try funding(index) + guard !watched.contains(address) else { continue } + let sats = try await chain.utxos(address: address).reduce(0) { $0 + $1.value } + guard sats > 0 else { continue } + try recovery.earlierAddressStore.add(SparkExitEarlierAddress(address: address, path: path, kind: .keptChange, sats: sats, date: now)) + } + defaults.set(max(defaults.integer(forKey: recovery.key), Int(scanned)), forKey: recovery.key) + defaults.removeObject(forKey: recovery.pendingKey) + return scanned + } + + private var fundingIndexKey: String { + SparkExitFundingIndexes.defaultsKey(network: "\(Env.network)", walletIndex: walletIndex) + } + + /// The fast rate of Bitkit's own fee source, the ceiling of a leftover sweep with the exit's own rate. + nonisolated static func trustedFeeRate() async -> UInt64? { + await (try? CoreService.shared.blocktank.fees())?.map { UInt64($0.fast) } + } + + /// Pays the fee funding from savings. It shows as an ordinary payment, not as a transfer to spending. + @discardableResult + func payFunding(address: String, sats: UInt64, satsPerVbyte: UInt32, feeSats: UInt64) async throws -> String { + let lightning = LightningService.shared + let attempts = OnchainSendAttemptService.shared + let result = try await Self.sendFunding( + using: lightning, attempts: attempts, address: address, sats: sats, satsPerVbyte: satsPerVbyte, feeSats: feeSats + ) + // An unknown outcome may still have gone out: it is kept as paid (pending), so the exit, and its address, stay + // until the payment lands or the own-payment expiry forgets it. + guard let txid = Self.fundingTxid(result) else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit funding outcome: \(result)") + } + if case .accepted = result { + _ = try? await attempts.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: lightning.currentWalletIndex), + observedTxid: txid + ) + Logger.info("Funded the Spark exit with \(sats) sats in \(txid)", context: "SparkExitManager") + } else { + Logger.warn("Spark exit funding \(txid) has an unknown outcome; kept as pending", context: "SparkExitManager") + } + return txid + } + + /// Sends the fee funding from savings for the mining fee `feeSats` the user confirmed, which it never exceeds: a higher + /// one is refused before broadcast (`SparkFundingChanged`) and asks again. + nonisolated static func sendFunding( + using sender: any OnchainSending, + attempts: OnchainSendAttemptService, + address: String, + sats: UInt64, + satsPerVbyte: UInt32, + feeSats: UInt64 + ) async throws -> OnchainSendResult { + try await attempts.send( + using: sender, + address: address, + amountSats: sats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: nil, + isMaxAmount: false, + followupContext: OnchainSendFollowupContext( + feeSats: feeSats, + feeRate: satsPerVbyte, + tags: [], + contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ), + approvedMiningFeeSats: feeSats + ) + } + + /// The funding payment's txid to keep as paid: accepted, or unknown (it may still have gone out). A refused one is none. + nonisolated static func fundingTxid(_ result: OnchainSendResult) -> String? { + switch result { + case let .accepted(txid), let .unknown(txid): txid + case .rejected: nil + } + } + + // MARK: Keys + + private nonisolated static func fundingKey(walletIndex: Int, index: UInt32) throws -> SparkExitFundingKey.Derived { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + return try SparkExitFundingKey.derive(index: index, mnemonic: mnemonic, passphrase: passphrase, network: Env.bitkitCoreNetwork) + } + + private nonisolated static func signer(walletIndex: Int, index: UInt32) throws -> CpfpSigner { + try singleKeyCpfpSigner(secretKeyBytes: fundingKey(walletIndex: walletIndex, index: index).secretKey) + } + + // MARK: Background + + /// One background run from a cold launch or a suspended app, with no UI: the keyless pass, then the Spark SDK + /// only (never the node) for the check, the signing and the broadcast, then the reminders and the next runs. + /// Waits for a foreground tick in flight instead of skipping, so the run never reports work it did not do. + @discardableResult + func runInBackground(deadline: Date) async -> SparkExitBackgroundRun.Outcome { + defer { + if needsDriving { + Self.scheduleBackgroundRuns() + } + } + if record == nil { + load(walletIndex: UserDefaults.standard.integer(forKey: Self.walletIndexKey)) + // Before the first unlock after a restart the record cannot be read yet: this run ends, and the next one + // comes once it can. + if record == nil, FileManager.default.fileExists(atPath: store.url.path) { + Logger.info("Spark exit background run waits for the first unlock", context: "SparkExitManager") + Self.scheduleBackgroundRuns() + return .init(record: nil) + } + } + while isTicking, !Task.isCancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 250_000_000) + } + guard !isTicking, startingCreatedAt == nil, let chain else { return .init(record: record) } + let wasActive = record?.isActive == true + isTicking = true + defer { isTicking = false } + let store = store + let lostLeafStore = lostLeafStore + let saveRecord = saveRecord + let walletIndex = walletIndex + let run = SparkExitBackgroundRun( + load: { try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) }, + makeDriver: { chain, recovering in + SparkExitDriver( + chain: chain, + recovering: recovering, + makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, + save: saveRecord, + trustedFeeRate: Self.trustedFeeRate + ) + }, + chain: chain, + connected: SparkManager.shared.exitRecovering, + connect: { timeout in await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: timeout) }, + deadline: deadline + ) + tipHeight = await (try? chain.tipHeight()) ?? tipHeight + let outcome = await run.run() + let seconds = SparkExitBackgroundRun.Step.allCases.compactMap { step in + outcome.seconds[step].map { "\(step.rawValue)=\(String(format: "%.2f", $0))s" } + } + Logger.info( + "Spark exit background run: connected=\(outcome.isConnected) phase=\(String(describing: outcome.record?.phase)) " + + "\(seconds.joined(separator: " ")) footprint=\(Self.footprintMegabytes())MB", + context: "SparkExitManager" + ) + if let updated = outcome.record { + record = updated + await afterTick(updated, wasActive: wasActive) + } + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.lastBackgroundRunKey) + return outcome + } + + private nonisolated static let lastBackgroundRunKey = "sparkExitLastBackgroundRun" + + /// The process's physical footprint, as `footprint` and Xcode's memory gauge report it. + nonisolated static func footprintMegabytes() -> Int { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) } + } + return result == KERN_SUCCESS ? Int(info.phys_footprint / 1_048_576) : -1 + } + + /// Registers the background runs that drive an exit while Bitkit is closed. Must run before launch finishes. + nonisolated static func registerBackgroundTask() { + // The OS ends a refresh after about 30 seconds and a processing task after a few minutes. + register(backgroundTaskId, seconds: 25) + register(processingTaskId, seconds: 4 * 60) + } + + private nonisolated static func register(_ identifier: String, seconds: TimeInterval) { + BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in + let completion = SparkExitTaskCompletion(task) + let work = Task { @MainActor in + await SparkExitManager.shared.runInBackground(deadline: Date(timeIntervalSinceNow: seconds)) + completion.complete(success: !Task.isCancelled) + } + // The driver stops before its next SDK call or broadcast and has saved every broadcast, so the record on + // disk is whole whenever the OS suspends the app. + task.expirationHandler = { + work.cancel() + completion.complete(success: false) + } + } + } + + nonisolated static func scheduleBackgroundRuns() { + // The refresh moves out on every run; the processing task is left alone while pending, or runs ticking more + // often than its floor would keep it from ever coming due. + BGTaskScheduler.shared.getPendingTaskRequests { pending in + let refresh = BGAppRefreshTaskRequest(identifier: backgroundTaskId) + refresh.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + var requests: [BGTaskRequest] = [refresh] + if !pending.contains(where: { $0.identifier == processingTaskId }) { + let processing = BGProcessingTaskRequest(identifier: processingTaskId) + processing.requiresNetworkConnectivity = true + processing.requiresExternalPower = false + processing.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + requests.append(processing) + } + for request in requests { + do { + try BGTaskScheduler.shared.submit(request) + } catch { + Logger.warn("Failed to schedule Spark exit run \(request.identifier): \(error)", context: "SparkExitManager") + } + } + } + } + + // MARK: Reminders + + /// Schedules a reminder ahead of each step, so it fires even when no background run gets to that step: at each + /// timelock's maturity plus a grace well inside the ~50 blocks after which the operators may publish their own + /// version, after a grace for steps ready to publish now, and at once when the exit needs more fee funding or a + /// step keeps being rejected. Each run (foreground or background) recomputes them, which drops the reminders of + /// steps it published or that confirmed, and of an exit that ended. + private func scheduleReminders(_ record: SparkExitRecord) async { + let center = UNUserNotificationCenter.current() + let now = Date() + let remembered = Self.rememberedReminders() + let planned = SparkExitMath.reminders(record, tip: tipHeight, now: now, network: Env.network) + var kept: [String: TimeInterval] = [:] + var requests: [UNNotificationRequest] = [] + for reminder in planned { + let id = Self.notificationId(reminder, record: record) + let date = Self.reminderDate(reminder, rememberedAt: remembered[id].map(Date.init(timeIntervalSince1970:)), now: now) + kept[id] = date.remembered?.timeIntervalSince1970 + guard let fireAt = date.fireAt else { continue } + let content = UNMutableNotificationContent() + content.userInfo = ["bitkit_action": Self.notificationAction] + content.title = t("lightning__spark_exit__notification_title") + content.body = if reminder.isLostLeaves { + t("lightning__spark_exit__notification_lost", variables: ["amount": "₿ \(CurrencyFormatter.formatSats(record.lostSats))"]) + } else if reminder.isFailed { + t("lightning__spark_exit__notification_failed") + } else if reminder.isNotMoving { + t("lightning__spark_exit__notification_not_moving") + } else { + t(reminder.isFunding ? "lightning__spark_exit__notification_funding" : "lightning__spark_exit__notification_body") + } + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: fireAt.timeIntervalSince(now), repeats: false) + requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) + } + let pending = await center.pendingNotificationRequests().map(\.identifier).filter { $0.hasPrefix(Self.notificationPrefix) } + center.removePendingNotificationRequests(withIdentifiers: pending) + for request in requests { + try? await center.add(request) + } + UserDefaults.standard.set(Self.keepingFired(kept, remembered: remembered, now: now), forKey: Self.remindersKey) + } + + /// When `reminder` posts, nil once it fired, and the date remembered for it. A reminder that fired is not posted + /// again. One still ahead keeps its earliest date, so runs that do not publish its step never push it out. + /// A date guessed without the tip keeps the remembered one and is never remembered itself, so a later run with the + /// tip still plans the reminder at maturity. + nonisolated static func reminderDate(_ reminder: SparkExitReminder, rememberedAt: Date?, now: Date) -> (fireAt: Date?, remembered: Date?) { + if let rememberedAt, rememberedAt <= now { + return (nil, rememberedAt) + } + if reminder.isEstimated { + return (max(now.addingTimeInterval(1), rememberedAt ?? reminder.fireAt), rememberedAt) + } + let fireAt = max(now.addingTimeInterval(1), min(reminder.fireAt, rememberedAt ?? reminder.fireAt)) + return (fireAt, fireAt) + } + + /// How long a fired reminder stays remembered after it left the plan, so a plan that drops an id for one run (a + /// tip that could not be read, a phase that flickered) never posts it again. + nonisolated static let firedReminderMemory: TimeInterval = 30 * 24 * 60 * 60 + + /// `kept` plus every reminder that already fired within `firedReminderMemory`, whether or not it is planned now. + nonisolated static func keepingFired(_ kept: [String: TimeInterval], remembered: [String: TimeInterval], now: Date) -> [String: TimeInterval] { + var result = kept + for (id, firedAt) in remembered where result[id] == nil { + let age = now.timeIntervalSince1970 - firedAt + if age >= 0, age < firedReminderMemory { + result[id] = firedAt + } + } + return result + } + + /// A reminder's notification id, scoped to its exit (its creation time), so a later exit's `failed`, `lost-1` or + /// `1-rebuild` is never taken for one an earlier exit already fired. + nonisolated static func notificationId(_ reminder: SparkExitReminder, record: SparkExitRecord) -> String { + "\(notificationPrefix)-\(Int64(record.createdAt.timeIntervalSince1970 * 1000))-\(reminder.id)" + } + + private nonisolated static func rememberedReminders() -> [String: TimeInterval] { + UserDefaults.standard.dictionary(forKey: remindersKey) as? [String: TimeInterval] ?? [:] + } + + /// An unverified rebuild's fee ask the tick from `previous` withdrew: the valid generation needs nothing more. + nonisolated static func isFundingAskWithdrawn(_ record: SparkExitRecord, previous: SparkExitRecord) -> Bool { + guard previous.isUnverifiedAsk == true, case .needsFunding = previous.phase else { return false } + return record.phase == .running + } + + /// Takes back the exit's delivered fee asks, which no longer hold. + private nonisolated static func dismissDeliveredFundingNotifications(record: SparkExitRecord) { + let prefix = notificationId(SparkExitReminder(id: "", fireAt: Date()), record: record) + UNUserNotificationCenter.current().getDeliveredNotifications { delivered in + let ids = delivered.map(\.request.identifier).filter { $0.hasPrefix(prefix) && $0.contains("funding-") } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + } + } + + private nonisolated static func clearReminders() { + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: requests.map(\.identifier).filter { $0.hasPrefix(notificationPrefix) }) + } + UserDefaults.standard.removeObject(forKey: remindersKey) + } +} + +/// Completes a background task once, whichever comes first: the run finishing or the OS expiring it. +private final class SparkExitTaskCompletion: @unchecked Sendable { + private let lock = NSLock() + private var task: BGTask? + + init(_ task: BGTask) { + self.task = task + } + + func complete(success: Bool) { + let task = lock.withLock { + defer { self.task = nil } + return self.task + } + task?.setTaskCompleted(success: success) + } +} + +/// A start whose exit was cancelled while it waited: nothing was paid and Spark was not taken off Lightning. +struct SparkExitStartCancelled: Error {} + +/// Lets the exit's writes through until a wipe closes it. +final class SparkExitWriteGate: @unchecked Sendable { + struct Closed: Error {} + + private let lock = NSLock() + private var closed = false + + var isOpen: Bool { + lock.withLock { !closed } + } + + func close() { + lock.withLock { closed = true } + } +} + +/// What the wallet's totals read from the exit: its transfer, what is still in transfer to savings and what stays on +/// Spark. A funding payment seen at the address or a record write changes them without any Spark state change, so +/// the watch asks the wallet to recompute when they changed, and only then. +struct SparkExitBalanceInputs: Equatable { + let transferId: String? + let inTransferSats: UInt64? + let outsideExitSats: UInt64? + + init(record: SparkExitRecord?, fundingSeenSats: UInt64, sparkBalanceSats: UInt64) { + transferId = record?.transferId + inTransferSats = record.map { SparkExitMath.inTransferSats($0, fundingSeenSats: fundingSeenSats) } + outsideExitSats = record.flatMap { SparkExitMath.outsideExitSats(sparkBalanceSats: sparkBalanceSats, record: $0) } + } +} + +@MainActor +final class SparkExitBalanceWatch { + private var last: SparkExitBalanceInputs? + private let onChange: () -> Void + + init(onChange: @escaping () -> Void) { + self.onChange = onChange + } + + func update(record: SparkExitRecord?, fundingSeenSats: UInt64, sparkBalanceSats: UInt64) { + let inputs = SparkExitBalanceInputs(record: record, fundingSeenSats: fundingSeenSats, sparkBalanceSats: sparkBalanceSats) + guard inputs != last else { return } + last = inputs + onChange() + } +} diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift new file mode 100644 index 000000000..bb0aeff7a --- /dev/null +++ b/Bitkit/Managers/SparkManager.swift @@ -0,0 +1,2275 @@ +import BigNumber +import BitkitCore +import BreezSdkSpark +import Combine +import Foundation +import LDKNode +import Observation + +/// Lightning without channels, through the Breez SDK on Spark. Savings to Spending funds this wallet, +/// receives fall back to it when no channel has the inbound capacity, and sends fall back to it when no +/// channel has the outbound capacity. Its keys derive from the Bitkit seed on Spark's own path (`m/8797555'`). +/// Its payments reach the app as LDK events through `LightningService.emitChannelFreeRailEvent`, so the +/// receive and send flows built on node events work for both rails. +@Observable @MainActor +final class SparkManager: ChannelFreeLightningRail { + static let shared = SparkManager() + nonisolated static let routeService = "spark" + private nonisolated static let routeAtomicity = "trusted" + private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() + private nonisolated static let exitStateLock = NSLock() + private nonisolated(unsafe) static var exitStateGenerationValue = 0 + + /// Bumped on every connect and exit data change, kept outside the backup observers, which stop while the node does, + /// so a change they missed is still backed up once they start again. + nonisolated static var exitStateGeneration: Int { + exitStateLock.withLock { exitStateGenerationValue } + } + + nonisolated static func notifyExitStateChanged() { + exitStateLock.withLock { exitStateGenerationValue += 1 } + exitStateChanged.send() + } + + private nonisolated(unsafe) static let contactWithdrawalResolutionSubject = PassthroughSubject() + /// How each contact withdrawal's journal closes, which a pending send screen waits on. + static var contactWithdrawalResolutionPublisher: AnyPublisher { + contactWithdrawalResolutionSubject.eraseToAnyPublisher() + } + + /// The latest journal close in this process, for a pending screen that opens after it; a new journal clears it. + private(set) static var lastContactWithdrawalResolution: SparkContactWithdrawalResolution? + + /// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. + nonisolated static var exitStateChangedPublisher: AnyPublisher { + exitStateChanged.eraseToAnyPublisher() + } + + private nonisolated static let depositClaimFeeLeewaySatPerVbyte: UInt64 = 2 + private nonisolated static let depositClaimFeePpm: UInt32 = 2000 + + private(set) var isReady = false + /// The last start failed; the next start clears it when it begins. + private(set) var hasStartFailed = false + private(set) var walletIndex = 0 + /// What Spark read, which sends and withdrawals use. + private(set) var balanceSats: UInt64 = 0 + /// The last balance shown, kept while Spark reads 0 after a failed withdrawal or a failed sync; display only. + private(set) var keptBalanceSats: UInt64? + /// The kept balance when an exit started, shown again should the exit be cancelled. + @ObservationIgnored private var keptBeforeExit: UInt64? + /// The balance Home and the Spark connection show. + var shownBalanceSats: UInt64 { + keptBalanceSats ?? balanceSats + } + + /// The balance Spending and its connections show: 0 during an emergency exit, whose amount shows in transfer to + /// savings instead. + var shownSpendingSats: UInt64 { + Self.shownSpendingSats(isExiting: isExiting, shownBalanceSats: shownBalanceSats) + } + + nonisolated static func shownSpendingSats(isExiting: Bool, shownBalanceSats: UInt64) -> UInt64 { + isExiting ? 0 : shownBalanceSats + } + + /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. + private(set) var pendingDepositSats: UInt64 = 0 + /// Funds an emergency exit can still recover, as Spark reports them. + private(set) var recoverableFundsSats: UInt64 = 0 + /// On-chain deposits into Spark, pending ones first, for the Lightning Connections screens. + private(set) var deposits: [SparkDeposit] = [] + private(set) var withdrawals: [SparkWithdrawal] = [] + /// Called after the Spark balance or readiness was read again, so balances and the receive invoice can follow. + var onStateChanged: (() -> Void)? + + /// Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. + @ObservationIgnored private var broadcastDeposits: [String: UInt64] = [:] + /// Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. + @ObservationIgnored private var sendsRecordedWithoutFee: Set = [] + /// Payments whose activity failed to save, written again at the next refresh. + @ObservationIgnored private var unsavedPayments: Set = [] + @ObservationIgnored private var sdk: BreezSdk? + /// Whether a wallet sync ended since this run connected, so the SDK's sync error describes this run. + @ObservationIgnored private var syncedThisRun = false + /// The last withdrawal quote, which the withdrawal reuses while it holds. + @ObservationIgnored private var preparedWithdrawal: (quote: SparkWithdrawalQuote, response: PrepareSendPaymentResponse)? + /// Withdrawals this app sent, known before Spark lists them. + @ObservationIgnored private var sentWithdrawalTxIds: Set = [] + /// When a withdrawal last failed, in seconds since 1970, until Spark reads a balance again or syncs after it + /// (`keptBalanceSats`). + @ObservationIgnored private var withdrawalFailedAt: UInt64? + /// When a withdrawal moved the whole balance, in seconds since 1970, so a read of 0 is real until a sync after it + /// reads a balance again (`withdrewAllAt(_:read:lastSyncedAt:)`). + @ObservationIgnored private var withdrewAllAt: UInt64? + @ObservationIgnored private var listenerId: String? + @ObservationIgnored private var connectTask: Task? + /// A leaf optimization is running, as its events tell. + @ObservationIgnored private var isOptimizing = false + /// Sends and withdrawals past their exit check, which an exit starting waits for. + let spends = SparkSpends { SparkExitManager.shared.isExiting } + /// Waiting before an exit quote for value an interrupted leaf optimization still owes the wallet. + private(set) var isSettlingForExit = false + /// When a leaf optimization last moved without completing, so a counter-swap it left may still be unclaimed. + private nonisolated static let optimizationActivityKey = "sparkOptimizationActivityAt" + /// The SDK claims a counter-swap left by an interrupted optimization only after this long + /// (spark-wallet `COUNTER_SWAP_CLAIM_GRACE_PERIOD_SECS`), plus a margin for the round that created it. + static let counterSwapClaimGraceSeconds: TimeInterval = 300 + 30 + /// The longest an exit waits for a running optimization before going ahead. + private static let optimizationWaitSeconds: TimeInterval = 120 + /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. + @ObservationIgnored private var connectGeneration = 0 + /// Set for the whole wipe: no connect, start or exit reconnect is admitted, so nothing brings the wiped wallet's + /// Spark identity back while its mnemonic still exists. + @ObservationIgnored private var isWiping = false + /// The refresh in flight; callers during it wait for it, and it reads once more for them. + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var refreshAgain = false + /// A contact withdrawal is being sent, so its journal entry isn't settled from a list that can't show it yet. + @ObservationIgnored private var isWithdrawingToContact = false + /// Set once a full activity sync succeeded in this process; `wipe` clears it. + @ObservationIgnored private var activitiesSynced = false + + private init() {} + + var isSupported: Bool { + Self.sparkNetwork != nil + } + + /// Whether savings or a hardware wallet can fund Spark: its deposit goes out on Bitkit's chain, which Spark must read. + var canFundFromChain: Bool { + isReady && Self.readsBitkitChain(network: Env.network, chainServiceUrl: Env.sparkChainServiceUrl(for: Env.network)) + } + + /// On mainnet both read Bitcoin; Spark's default regtest is its own chain, so a regtest deposit reaches Spark only + /// when Spark reads Bitkit's chain through `chainServiceUrl`. + nonisolated static func readsBitkitChain(network: LDKNode.Network, chainServiceUrl: String?) -> Bool { + network == .bitcoin || chainServiceUrl != nil + } + + var isConnected: Bool { + sdk != nil + } + + /// An emergency exit is moving the Spark balance on-chain: Spark then neither sends nor receives, and its + /// balance counts as in transfer to savings. + var isExiting: Bool { + SparkExitManager.shared.isExiting + } + + /// Takes Spark off the Lightning rail for an emergency exit, so no payment moves leaves the exit is spending. + func detachForExit() { + if LightningService.shared.channelFreeRail === self { + LightningService.shared.channelFreeRail = nil + } + } + + /// Saves the kept balance as an exit starts, before a refresh reads Spark as exiting and drops it: a cancelled + /// exit shows it again. + func holdKeptBalanceForExit() { + keptBeforeExit = keptBalanceSats ?? keptBeforeExit + keptBalanceSats = nil + } + + /// Connects Spark again so its leaf optimization follows whether an exit runs: off when one starts, back on + /// once it ends. An exit cancelled before it paid anything (`isCancel`) shows the kept balance again. + func reconnectForExit(isCancel: Bool = false) async { + guard let network = Self.sparkNetwork, !isWiping else { return } + if isExiting { + holdKeptBalanceForExit() + } else { + keptBalanceSats = Self.keptBalanceSats(afterExitKept: keptBeforeExit, read: balanceSats, isCancel: isCancel) + ?? keptBalanceSats + keptBeforeExit = nil + } + // A connect still running, such as the launch's, builds an SDK on the same storage: it ends first and is + // disconnected below, so no second SDK keeps running. + if let connecting = connectTask { + _ = try? await connecting.value + } + noteOptimizationCutShort() + if let sdk { + if let listenerId { + _ = await sdk.removeEventListener(id: listenerId) + } + try? await sdk.disconnect() + } + sdk = nil + listenerId = nil + do { + let generation = connectGeneration + let walletIndex = walletIndex + let connecting = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } + connectTask = connecting + defer { + if connectTask == connecting { + connectTask = nil + } + } + try await connecting.value + try await refresh() + } catch { + Logger.error("Failed to reconnect Spark for the exit: \(error)", context: "SparkManager") + } + } + + /// Connects the Spark SDK alone for a background exit run, never the node: no refresh, no activity sync and no + /// Lightning rail. Returns nil when it does not connect within `timeout`; the connect keeps going, and a later + /// `start` picks it up. + /// `operation`'s result, or nil once `timeout` passes first. Returns at the timeout without waiting for `operation`, + /// which a task group would do; the operation carries on, unstructured, and its late result is dropped. + nonisolated static func firstOf(timeout: TimeInterval, _ operation: @escaping @Sendable () async -> T) async -> T? { + await withCheckedContinuation { continuation in + let resumed = SparkFirstResume() + Task { + let value = await operation() + if resumed.claim() { + continuation.resume(returning: value) + } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + if resumed.claim() { + continuation.resume(returning: nil) + } + } + } + } + + func connectForExit(walletIndex: Int, timeout: TimeInterval) async -> SparkExitRecovering? { + guard let network = Self.sparkNetwork else { return nil } + if sdk == nil { + self.walletIndex = walletIndex + if connectTask == nil { + let generation = connectGeneration + connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } + } + let connecting = connectTask + // nil while still connecting at the timeout; the connect itself keeps running for `start`. + let didConnect: Bool? = await Self.firstOf(timeout: timeout) { await (try? connecting?.value) != nil } + // A finished connect is forgotten, so a failed one is tried again; one still running is kept for `start`. + if didConnect != nil { + connectTask = nil + } + guard didConnect == true else { + Logger.warn("Spark did not connect within \(Int(timeout))s for the exit", context: "SparkManager") + return nil + } + } + return exitRecovering + } + + /// Waits until no leaf optimization runs and no counter-swap one left behind is still inside the SDK's claim + /// grace, then syncs, which claims it. An optimization swaps leaves with the operators; one cut short (by a + /// disconnect or the app closing) leaves its counter-swap unclaimed for 5 minutes, and a quote taken meanwhile + /// would leave that value out of the exit. + func settleForExit() async { + isSettlingForExit = true + defer { isSettlingForExit = false } + await waitForOptimization() + let activity = UserDefaults.standard.object(forKey: Self.optimizationActivityKey) as? TimeInterval + if let activity { + let wait = Self.counterSwapClaimGraceSeconds - Date().timeIntervalSince1970 + activity + if wait > 0 { + Logger.info("Waiting \(Int(wait))s for an interrupted Spark optimization before the exit quote", context: "SparkManager") + try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) + } + } + guard !Task.isCancelled, let sdk else { return } + do { + _ = try await sdk.syncWallet(request: SyncWalletRequest()) + if activity != nil, !isOptimizing { + UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey) + } + } catch { + Logger.warn("Failed to sync Spark before the exit quote: \(error)", context: "SparkManager") + } + await refreshState() + } + + /// Waits for a running leaf optimization to finish, for up to two minutes. + func waitForOptimization() async { + let deadline = Date().addingTimeInterval(Self.optimizationWaitSeconds) + while isOptimizing, !Task.isCancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + + /// Waits for the sends and withdrawals that passed their exit check before the exit held Spark, for up to two + /// minutes, so the exit quotes the leaves they leave. + func holdSpendsForExit() async throws { + try await spends.holdForExit(timeout: Self.optimizationWaitSeconds) + } + + func releaseSpendsHold() { + spends.release() + } + + private func noteOptimization(_ event: AutoOptimizationEvent) { + switch event { + case .started, .roundCompleted: + isOptimizing = true + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.optimizationActivityKey) + case .cancelled, .failed: + isOptimizing = false + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.optimizationActivityKey) + case .completed, .skipped: + isOptimizing = false + UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey) + } + } + + private func noteOptimizationCutShort() { + guard isOptimizing else { return } + isOptimizing = false + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.optimizationActivityKey) + } + + /// The SDK calls of an emergency exit, or nil while Spark is not connected. + var exitRecovering: SparkExitRecovering? { + sdk.map(BreezSparkExitRecovering.init) + } + + /// The fee rate an exit is built at: the fastest the SDK's chain service recommends, at least 1 sat/vB. + func exitFeeRate() async throws -> UInt64 { + try await max(1, requireSdk().recommendedFees().fastestFee) + } + + /// The SDK's recommended rates for an exit's fee bump. + func exitNetworkFees() async throws -> SparkExitNetworkFees { + let fees = try await requireSdk().recommendedFees() + return SparkExitNetworkFees(fastest: max(1, fees.fastestFee), halfHour: max(1, fees.halfHourFee)) + } + + nonisolated static var exitNetwork: BreezSdkSpark.Network? { + switch Env.network { + case .bitcoin: .mainnet + case .regtest: .regtest + default: nil + } + } + + /// The exit data's generation while connected, nil while disconnected, so a backup can tell a change from observing + /// that restarted while Spark stayed connected. + var exitStateVersion: Int? { + sdk == nil ? nil : Self.exitStateGeneration + } + + /// On regtest, an invoice that carries a Spark address is paid as a Spark transfer. Bitkit's own invoices carry none, + /// so two dev builds pay each other over Lightning through Spark's provider, as on mainnet. + nonisolated static var prefersSpark: Bool { + Env.network == .regtest + } + + /// Returns as soon as the payment starts, like LDK, so its completion reaches callers as an event they are already + /// watching for; waiting for completion here fired that event before they watched. + nonisolated static var bolt11SendOptions: SendPaymentOptions { + .bolt11Invoice(preferSpark: prefersSpark, completionTimeoutSecs: nil) + } + + private static var sparkNetwork: BreezSdkSpark.Network? { + switch Env.network { + case .bitcoin: Env.breezApiKey == nil ? nil : .mainnet + case .regtest: .regtest + default: nil + } + } + + func start(walletIndex: Int) async { + guard let network = Self.sparkNetwork, !isWiping else { return } + // A retry reads as connecting until it fails again. + hasStartFailed = false + self.walletIndex = walletIndex + SparkExitManager.shared.load(walletIndex: walletIndex) + do { + if sdk == nil { + if connectTask == nil { + let generation = connectGeneration + connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } + } + let task = connectTask + // A reconnect may have replaced the task while this one was awaited: only the task it awaited is cleared. + defer { + if connectTask == task { + connectTask = nil + } + } + try await task?.value + } + // Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they + // still go to LDK, even when that refresh or the activity sync fails. An emergency exit keeps it out. + LightningService.shared.channelFreePaymentHistory = self + if !isExiting { + LightningService.shared.channelFreeRail = self + } + activitiesSynced = try await Self.startConnected( + activitiesSynced: activitiesSynced, + refresh: { try await refresh() }, + syncWallet: { + _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) + syncedThisRun = true + }, + syncActivities: { try await syncActivities() } + ) + await settleContactWithdrawal(allowClear: false) + } catch { + hasStartFailed = true + Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") + } + } + + /// A start reads the balance every time, but rewrites every Spark activity only until that has succeeded once, so + /// each foreground doesn't redo it; a start whose refresh failed leaves the sync to the next one. Returns whether + /// the activities are synced. + /// Spark lists a send that was still in flight when the app was killed only once its own sync reaches it, so the + /// first activity sync waits for one. + static func startConnected( + activitiesSynced: Bool, + refresh: () async throws -> Void, + syncWallet: () async throws -> Void = {}, + syncActivities: () async throws -> Void + ) async throws -> Bool { + try await refresh() + guard !activitiesSynced else { return true } + do { + try await syncWallet() + } catch { + Logger.warn("Failed to sync Spark before recording its activities: \(error)", context: "SparkManager") + } + try await syncActivities() + return true + } + + private func connect(walletIndex: Int, network: BreezSdkSpark.Network, generation: Int) async throws { + guard !isWiping else { throw CancellationError() } + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let config = Self.sdkConfig(network: network, apiKey: Env.breezApiKey, isExiting: isExiting) + let storageDir = Env.sparkStorage(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) + // What `BreezSdkSpark.connect` builds, plus our own chain service where Env names one. + let builder = SdkBuilder(config: config, seed: .mnemonic(mnemonic: mnemonic, passphrase: passphrase)) + await builder.withDefaultStorage(storageDir: storageDir.path) + if let chainServiceUrl = Env.sparkChainServiceUrl(for: Env.network) { + await builder.withRestChainService(url: chainServiceUrl, apiType: .esplora, credentials: nil) + } + syncedThisRun = false + let connected = try await builder.build() + guard generation == connectGeneration, !isWiping else { + try? await connected.disconnect() + throw CancellationError() + } + listenerId = await connected.addEventListener(listener: SparkEventListener { [weak self] event in + await self?.onSdkEvent(event) + }) + sdk = connected + Logger.info("Connected Spark wallet on '\(network)'", context: "SparkManager") + await importRestoredExitState(walletIndex: walletIndex) + Self.notifyExitStateChanged() + } + + /// The SDK config Bitkit connects with. + nonisolated static func sdkConfig(network: BreezSdkSpark.Network, apiKey: String?, isExiting: Bool) -> BreezSdkSpark.Config { + var config = defaultConfig(network: network) + config.apiKey = apiKey + // No Breez data sync server, no Lightning address server and no third-party input lookups: Bitkit uses none. + config.realTimeSyncServerUrl = nil + config.lnurlDomain = nil + config.useDefaultExternalInputParsers = false + // Lowering it below the spread keeps the deposit from being claimed early, and does not hold back its claim at + // maturity. An early claim is made only when the user chose Instant (`claimEarly`). + config.maxDepositClaimFee = MaxFee( + onchain: .networkRecommended(leewaySatPerVbyte: depositClaimFeeLeewaySatPerVbyte), + proportionalPpm: nil + ) + // Optimizing swaps leaves with the operators, which would hand the leaves an exit is spending to someone + // else, whose refunds then confirm first. Off for as long as an emergency exit runs. + config.leafOptimizationConfig.autoEnabled = !isExiting + return config + } + + /// Disconnects and deletes this wallet's Spark data. A connect still running is waited for first, so it can't + /// keep the wiped wallet; throws when the data could not be deleted, so the wipe is retried. + func wipe(walletIndex: Int) async throws { + defer { isWiping = false } + var disconnectError: Error? + try await Self.wipeInOrder( + closeAdmission: { + isWiping = true + connectGeneration += 1 + syncedThisRun = false + }, + drainExitWork: { await SparkExitManager.shared.wipe() }, + disconnect: { disconnectError = await disconnectForWipe() }, + deleteData: { try deleteWipedData(walletIndex: walletIndex) } + ) + if let disconnectError { + throw disconnectError + } + } + + /// The wipe's steps, in the order that keeps the wiped wallet's identity from coming back. + static func wipeInOrder( + closeAdmission: () -> Void, + drainExitWork: () async -> Void, + disconnect: () async -> Void, + deleteData: () throws -> Void + ) async throws { + closeAdmission() + // An exit tick finishing now may reconnect; with admission closed it can't, and it is done before the SDK goes. + await drainExitWork() + await disconnect() + try deleteData() + } + + private func disconnectForWipe() async -> Error? { + if let connectTask { + _ = try? await connectTask.value + } + connectTask = nil + UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey) + isOptimizing = false + if LightningService.shared.channelFreeRail === self { + LightningService.shared.channelFreeRail = nil + } + if LightningService.shared.channelFreePaymentHistory === self { + LightningService.shared.channelFreePaymentHistory = nil + } + LightningService.shared.forgetRailAttempts() + var disconnectError: Error? + if let sdk { + if let listenerId { + _ = await sdk.removeEventListener(id: listenerId) + } + do { + try await sdk.disconnect() + } catch { + Logger.error("Failed to disconnect Spark before wipe: \(error)", context: "SparkManager") + disconnectError = error + } + } + sdk = nil + listenerId = nil + refreshAgain = false + activitiesSynced = false + isReady = false + hasStartFailed = false + return disconnectError + } + + private func deleteWipedData(walletIndex: Int) throws { + balanceSats = 0 + keptBalanceSats = nil + keptBeforeExit = nil + withdrawalFailedAt = nil + withdrewAllAt = nil + pendingDepositSats = 0 + recoverableFundsSats = 0 + deposits = [] + withdrawals = [] + broadcastDeposits = [:] + sendsRecordedWithoutFee = [] + unsavedPayments = [] + sentWithdrawalTxIds = [] + preparedWithdrawal = nil + Self.pendingContactWithdrawal = nil + Self.contactWithdrawalTxIds = [] + onStateChanged?() + try Self.removeIfPresent(Env.sparkStorage(walletIndex: walletIndex)) + try Self.removeIfPresent(Env.sparkExitRestore(walletIndex: walletIndex)) + // The exit's own wipe only logs a failed delete: here it throws, so the wipe is retried, and covers the wiped + // wallet's index even when the exit manager holds another one. + try SparkExitStore(walletIndex: walletIndex).deleteWithCopies() + try Self.removeIfPresent(Env.sparkExitLostLeaves(walletIndex: walletIndex)) + try Self.removeIfPresent(Env.sparkExitEarlierAddresses(walletIndex: walletIndex)) + } + + nonisolated static func removeIfPresent(_ url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + // MARK: ChannelFreeLightningRail + + nonisolated func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + let method = ReceivePaymentMethod.bolt11Invoice( + description: description, + amountSats: amountSats, + expirySecs: expirySecs, + paymentHash: nil, + receiverIdentityPublicKey: nil, + preimage: nil + ) + return try await requireSdk().receivePayment(request: ReceivePaymentRequest(paymentMethod: method)).paymentRequest + } + + /// Errors before `sendPayment` are `ChannelFreeRailSendRefused`, since nothing left the wallet; a failure inside it + /// stays uncertain, as the payment may have been dispatched, unless it is one raised before dispatch + /// (`sendPaymentFailure`). + nonisolated func send( + bolt11: String, + sats: UInt64?, + maxFeeSats: UInt64?, + beforeSubmission: @escaping () throws -> Void + ) async throws -> PaymentHash { + let prepared: PrepareSendPaymentResponse + let sdk: BreezSdk + do { + sdk = try await requireSdk() + prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) + } catch { + Logger.warn("Spark could not prepare the payment: \(error)", context: "SparkManager") + throw Self.expiredInvoiceRefusal(error) ?? ChannelFreeRailSendRefused( + reason: SparkErrorCopy.message(for: error, fallback: t("wallet__payment_failed_description")), + underlying: error + ) + } + guard case let .bolt11Invoice(invoiceDetails, sparkTransferFeeSats, lightningFeeSats) = prepared.paymentMethod else { + Logger.error("Unexpected Spark payment method: \(prepared.paymentMethod)", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + } + let feeSats = Self.prefersSpark ? sparkTransferFeeSats ?? lightningFeeSats : lightningFeeSats + let amountSats = sats ?? invoiceDetails.amountMsat.map { $0 / 1000 } ?? 0 + let balanceSats = await balanceSats + guard Self.fitsBalance(amountSats: amountSats, feeSats: feeSats, balanceSats: balanceSats) else { + Logger.info("Spark balance can't cover \(amountSats) sats plus its quoted fee of \(feeSats) sats", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("other__pay_insufficient_spending_description")) + } + // The fee limit is checked on the quote that is sent, not only on the earlier estimate. + try Self.checkSendFee(feeSats, maxFeeSats: maxFeeSats) + // An exit that started while the quote was prepared refuses it here, before its hash is recorded. + let response: SendPaymentResponse = try await spends.run { + // A deadline that passed while the quote was prepared stops the payment here, before anything leaves. + try beforeSubmission() + do { + return try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: Self.bolt11SendOptions + )) + } catch { + throw Self.sendPaymentFailure(error) + } + } + Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + return invoiceDetails.paymentHash + } + + nonisolated static func checkSendFee(_ feeSats: UInt64, maxFeeSats: UInt64?) throws { + guard let maxFeeSats, feeSats > maxFeeSats else { return } + Logger.info("Spark fee of \(feeSats) sats exceeds the limit of \(maxFeeSats) sats", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("wallet__max_possible_fee_rate")) + } + + /// Whether the balance covers the amount and the fee `prepareSendPayment` quoted, the real sendable check behind + /// the estimate in `ChannelFreeRailFees`. + nonisolated static func fitsBalance(amountSats: UInt64, feeSats: UInt64, balanceSats: UInt64) -> Bool { + let (total, overflow) = amountSats.addingReportingOverflow(feeSats) + return !overflow && total <= balanceSats + } + + /// What a failure inside `sendPayment` means for the caller. + nonisolated static func sendPaymentFailure(_ error: Error) -> Error { + // The SDK checks the expiry again before sending, so an invoice that expired on the confirm screen left nothing. + if let refusal = expiredInvoiceRefusal(error) { + return refusal + } + // Spark selects the leaves to spend before anything goes to the operators, so a balance short of the payment + // left nothing, for instance when an earlier send spent it after the cached balance was read. + if case SdkError.InsufficientFunds = error { + return ChannelFreeRailSendRefused(reason: t("other__pay_insufficient_spending_description"), underlying: error) + } + return error + } + + /// The SDK refuses an expired Lightning invoice before anything leaves the wallet, with the scanner's expiry copy. + nonisolated static func expiredInvoiceRefusal(_ error: Error) -> ChannelFreeRailSendRefused? { + guard case let SdkError.InvalidInput(message) = error, message.localizedCaseInsensitiveContains("invoice has expired") else { return nil } + return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) + } + + nonisolated func hasSynced(after seconds: UInt64?) async -> Bool { + guard let info = try? await requireSdk().getInfo(request: GetInfoRequest(ensureSynced: false)) else { return false } + return await Self.hasSynced( + after: seconds, + syncedThisRun: syncedThisRun, + lastSyncFailed: info.lastSyncError != nil, + lastSyncedAt: info.lastSyncedAt + ) + } + + /// The SDK keeps `lastSyncedAt` across launches but not `lastSyncError`, which is unset after each connect until that + /// run's first sync ends, so only a sync this run, after a wallet sync that ever succeeded, shows Spark's list is complete. + nonisolated static func hasSynced(after seconds: UInt64?, syncedThisRun: Bool, lastSyncFailed: Bool, lastSyncedAt: UInt64?) -> Bool { + // The SDK sets `lastSyncedAt` only on a wallet sync that succeeded, while it reports Synced for partial ones too. + guard syncedThisRun, !lastSyncFailed, let lastSyncedAt else { return false } + guard let seconds else { return true } + return lastSyncedAt > seconds + } + + nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + return payments.compactMap { payment in + guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { return nil } + return ChannelFreeRailPayment( + paymentHash: htlcDetails.paymentHash, + direction: payment.paymentType == .send ? .outbound : .inbound, + status: Self.ldkStatus(payment.status), + preimage: htlcDetails.preimage, + timestamp: payment.timestamp, + feeSats: Self.sats(payment.fees) + ) + } + } + + nonisolated func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + let prepared = try await prepareSend(paymentRequest: bolt11, amountSats: amountSats) + guard case let .bolt11Invoice(_, sparkTransferFeeSats, lightningFeeSats) = prepared.paymentMethod else { return 0 } + if Self.prefersSpark, let sparkTransferFeeSats { + return sparkTransferFeeSats + } + return lightningFeeSats + } + + // MARK: Transfers + + /// The static on-chain address that funds the Spark wallet, claimed automatically once confirmed. + func depositAddress() async throws -> String { + let request = ReceivePaymentRequest(paymentMethod: .bitcoinAddress(newAddress: nil)) + return try await requireSdk().receivePayment(request: request).paymentRequest + } + + /// Size of Spark's deposit claim transaction; the SDK prices a standard claim as the fastest recommended fee rate + /// times this size before the provider quotes it (breez/spark-sdk `CLAIM_TX_SIZE_VBYTES`). + private nonisolated static let claimTxSizeVbytes: UInt64 = 99 + + /// What claiming a deposit of `amountSats` costs for each speed. The provider quotes the real fee only once the + /// deposit exists, so Instant is the ceiling the user accepts and Later the mature claim at the half-hour rate. + func depositClaimFees(amountSats: UInt64) async throws -> SparkClaimFees { + let fees = try await requireSdk().recommendedFees() + return SparkClaimFees( + instantMaxSats: Self.maxDepositClaimFeeSats(fastestFeeRate: fees.fastestFee, amountSats: amountSats), + laterSats: Self.matureDepositClaimFeeSats(halfHourFeeRate: fees.halfHourFee) + ) + } + + nonisolated static func matureDepositClaimFeeSats(halfHourFeeRate: UInt64) -> UInt64 { + halfHourFeeRate * claimTxSizeVbytes + } + + nonisolated static func maxDepositClaimFeeSats(fastestFeeRate: UInt64, amountSats: UInt64) -> UInt64 { + let onchain = (fastestFeeRate + depositClaimFeeLeewaySatPerVbyte) * claimTxSizeVbytes + let proportional = amountSats.multipliedReportingOverflow(by: UInt64(depositClaimFeePpm)).partialValue / 1_000_000 + return onchain + proportional + } + + /// How the deposit in `txid` can be claimed, or nil while Spark has not seen it yet. The early quote asks the + /// provider on every call, so this is called once a deposit is listed, not on a timer. + func depositClaimOffer(txid: String) async throws -> SparkClaimOffer? { + let sdk = try await requireSdk() + let unclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits + guard let deposit = unclaimed.first(where: { $0.txid.caseInsensitiveCompare(txid) == .orderedSame }) else { return nil } + let quote = try await sdk.fetchClaimDepositQuote(request: FetchClaimDepositQuoteRequest(txid: deposit.txid, vout: deposit.vout)) + return SparkClaimOffer(txid: deposit.txid, vout: deposit.vout, quote: quote) + } + + /// Clears the fee cap an early claim of `offer` left on its deposit, back to the configured one, which never claims + /// early (`sdkConfig`): a claim that names no cap clears the stored one before it is attempted. False when the cap + /// may still be there: the call failed, or another claim of the deposit held it, which leaves the cap unchanged. + func releaseEarlyClaim(_ offer: SparkClaimOffer) async -> Bool { + do { + _ = try await requireSdk().claimDeposit(request: ClaimDepositRequest(txid: offer.txid, vout: offer.vout, maxFee: nil)) + return true + } catch { + Logger.warn("Failed to clear the early claim cap of Spark deposit \(offer.txid): \(error)", context: "SparkManager") + return false + } + } + + /// Accepts the early claim of `offer` at its quoted fee. The fee ceiling stays on the deposit, so an early claim + /// not offered at this depth is still made once the provider offers it. + func claimEarly(_ offer: SparkClaimOffer) async throws -> SparkEarlyClaim { + guard case let .choice(earlyFeeSats, _, _, _) = offer.kind else { return .claimed } + let request = ClaimDepositRequest( + txid: offer.txid, + vout: offer.vout, + maxFee: MaxFee(onchain: .fixed(amount: earlyFeeSats), proportionalPpm: nil) + ) + let claim: SparkEarlyClaim + do { + let outcome = try await requireSdk().claimDeposit(request: request).outcome + claim = SparkEarlyClaim(outcome) + } catch SdkError.DepositClaimInProgress { + claim = .submitted + } + Logger.info("Early claim of Spark deposit \(offer.txid): \(claim)", context: "SparkManager") + await refreshState() + return claim + } + + /// Moves the whole Spark balance to the on-chain `address`, fees included, for Spending to Savings. Refuses before + /// sending when the fee is above `maxFeeSats`, the fee the confirm screen showed. + func withdrawAll(to address: String, maxFeeSats: UInt64) async throws { + do { + let prepared = try await prepareWithdrawAll(to: address) + try Self.checkWithdrawalFee(Self.withdrawalFeeSats(prepared), shownFeeSats: maxFeeSats) + let sdk = try await requireSdk() + // Preparing suspends, so an exit can start meanwhile. + let response = try await spends.run { + try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + } + preparedWithdrawal = nil + if case let .withdraw(txId, _, _) = response.payment.details { + sentWithdrawalTxIds.insert(txId) + } + withdrawalFailedAt = response.payment.status == .failed ? Self.nowSecs() : nil + withdrewAllAt = response.payment.status == .failed ? nil : Self.nowSecs() + try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } + } catch { + withdrawalFailedAt = Self.nowSecs() + throw error + } + } + + /// A sent withdrawal has left the balance, so only Spark's own failed status fails it; reading the balance + /// again afterwards is best effort. + static func settleWithdrawal(status: BreezSdkSpark.PaymentStatus, refresh: () async throws -> Void) async throws { + guard status != .failed else { + throw AppError(message: "common__error_body", debugMessage: "Spark reported the withdrawal as failed") + } + do { + try await refresh() + } catch { + Logger.warn("Failed to refresh Spark after its withdrawal: \(error)", context: "SparkManager") + } + } + + /// Whether Spark refused to quote a withdrawal because even its cheapest fee leaves dust. + nonisolated static func isBelowMinimum(_ error: Error) -> Bool { + guard case let SdkError.InvalidInput(message) = error else { return false } + return message.lowercased().hasPrefix("amount is below the minimum") + } + + /// The fee `withdrawAll(to:maxFeeSats:)` pays at its speed, which comes out of the balance it moves. + func withdrawAllFeeSats(to address: String) async throws -> UInt64 { + try await Self.withdrawalFeeSats(prepareWithdrawAll(to: address)) + } + + private static func withdrawalFeeSats(_ prepared: PrepareSendPaymentResponse) -> UInt64 { + guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { return 0 } + return feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat + } + + nonisolated static func checkWithdrawalFee(_ feeSats: UInt64, shownFeeSats: UInt64) throws { + guard feeSats > shownFeeSats else { return } + throw SparkWithdrawalFeeIncreased(feeSats: feeSats, shownFeeSats: shownFeeSats) + } + + /// Whether `txid` is a withdrawal of this wallet's Spark balance to Savings, so its receipt is a transfer. + func isWithdrawal(txid: String) -> Bool { + Self.isWithdrawal(txid: txid, sent: sentWithdrawalTxIds, withdrawals: withdrawals) + } + + nonisolated static func isWithdrawal(txid: String, sent: Set, withdrawals: [SparkWithdrawal]) -> Bool { + sent.contains(txid) || withdrawals.contains { $0.txid == txid } + } + + /// Whether `txid` is a Spark withdrawal, which LDK never held and so can't boost: one this app sent, to Savings or a + /// contact, or one Spark lists. + func isSparkWithdrawal(txid: String, isSent: Bool) async -> Bool { + let known = sentWithdrawalTxIds.union(Self.contactWithdrawalTxIds).union(withdrawals.map(\.txid)) + if Self.isSparkWithdrawal(txid: txid, isSent: isSent, known: known, payments: []) { + return true + } + guard isReady else { return false } + do { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + return Self.isSparkWithdrawal(txid: txid, isSent: isSent, known: known, payments: payments) + } catch { + Logger.warn("Failed to list Spark payments for \(txid): \(error)", context: "SparkManager") + return false + } + } + + nonisolated static func isSparkWithdrawal(txid: String, isSent: Bool, known: Set, payments: [Payment]) -> Bool { + // Only the sent side was Spark's; Savings owns the output a withdrawal to it pays, so its receipt can be boosted. + guard isSent else { return false } + return known.contains(txid) || payments.contains { payment in + guard case let .withdraw(txId, _, _) = payment.details else { return false } + return txId == txid + } + } + + /// Reuses the provider's withdrawal quote while it holds for the same address and balance, so the withdrawal + /// pays the fee the confirm screen showed; a second full-balance quote can take minutes. + private func prepareWithdrawAll(to address: String) async throws -> PrepareSendPaymentResponse { + let balanceSats = balanceSats + let now = UInt64(Date().timeIntervalSince1970) + if let preparedWithdrawal, preparedWithdrawal.quote.holds(address: address, balanceSats: balanceSats, now: now) { + return preparedWithdrawal.response + } + let response = try await prepareSend(paymentRequest: address, amountSats: balanceSats, feePolicy: .feesIncluded) + if case let .bitcoinAddress(_, feeQuote) = response.paymentMethod { + let quote = SparkWithdrawalQuote(address: address, balanceSats: balanceSats, expiresAt: feeQuote.expiresAt) + preparedWithdrawal = (quote, response) + } + return response + } + + /// A Paykit route that withdraws exactly `amountSats` to the contact's on-chain `endpoint` at medium speed, with + /// Spark's fee on top, or nil while Spark cannot send it. bitkit-core's Paykit router weighs it against the contact's + /// other endpoints. Throws `SparkLegUnaffordable` when the balance can't cover the amount and its fee. + func withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: UInt64) async throws -> ServiceRouteLeg? { + guard isReady else { return nil } + guard balanceSats >= amountSats else { throw SparkLegUnaffordable(debitSats: amountSats, balanceSats: balanceSats) } + let prepared = try await prepareSend(paymentRequest: endpoint.value, amountSats: amountSats) + guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { + throw AppError(message: "Unexpected Spark payment method", debugMessage: "\(prepared.paymentMethod)") + } + guard !feeQuote.isEstimate else { return nil } + return try Self.withdrawalRouteLeg( + endpoint: endpoint, + amountSats: amountSats, + fee: feeQuote.speedMedium, + validUntilUnix: feeQuote.expiresAt, + balanceSats: balanceSats + ) + } + + /// The leg that withdraws `amountSats` to `endpoint` with `fee` on top. Throws `SparkLegUnaffordable` when + /// `balanceSats` can't cover both. + nonisolated static func withdrawalRouteLeg( + endpoint: PaykitEndpoint, + amountSats: UInt64, + fee: SendOnchainSpeedFeeQuote, + validUntilUnix: UInt64, + balanceSats: UInt64 + ) throws -> ServiceRouteLeg { + let debitSats = withdrawalDebitSats(amountSats: amountSats, fee: fee) + guard balanceSats >= debitSats else { throw SparkLegUnaffordable(debitSats: debitSats, balanceSats: balanceSats) } + return ServiceRouteLeg( + service: routeService, + endpoint: endpoint, + deliveredSat: amountSats, + debitSat: debitSats, + atomicity: routeAtomicity, + validUntilUnix: validUntilUnix + ) + } + + /// What a withdrawal of `amountSats` takes from the balance: the amount, Spark's fee and the L1 broadcast fee. + nonisolated static func withdrawalDebitSats(amountSats: UInt64, fee: SendOnchainSpeedFeeQuote) -> UInt64 { + amountSats.saturatingAdd(fee.userFeeSat).saturatingAdd(fee.l1BroadcastFeeSat) + } + + /// Withdraws exactly `amountSats` to a contact's on-chain `address` at medium speed and returns its txid. The + /// withdrawal is written to a journal before it is sent, so one interrupted mid-send settles from Spark's payment + /// list; a second one is refused while it is open. It is recorded as a sent on-chain activity for the contact. + /// A withdrawal that pays a Paykit request (`request`) completes the request's on-chain proof with its txid, also when + /// it settles from the journal. `beforeSend` runs right before Spark sends, after the quote. + func withdraw( + to address: String, + amountSats: UInt64, + contact: String?, + request: SparkContactWithdrawal.PaykitRequest? = nil, + tags: [String] = [], + maxFeeSats: UInt64? = nil, + paymentDeadline: PaykitPreciseInstant? = nil, + beforeSend: () async throws -> Void = {} + ) async throws -> String { + // Everything before the journal is written sends nothing, so a refusal here says so to the caller. + let sdk: BreezSdk + let prepared: PrepareSendPaymentResponse + let earlierPayments: [Payment] + do { + // An exit spends every leaf, so no contact withdrawal starts while one runs. + guard !isExiting else { + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + } + try claimContactWithdrawal() + } catch { + throw SparkWithdrawalNotSent(underlying: error) + } + defer { releaseContactWithdrawalClaim() } + do { + sdk = try await requireSdk() + prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) + if let maxFeeSats, case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod { + // The fee Confirm showed caps the one sent, broadcast fee included. + try Self.checkWithdrawalFee( + Self.withdrawalDebitSats(amountSats: amountSats, fee: feeQuote.speedMedium) - amountSats, + shownFeeSats: maxFeeSats + ) + } + // Withdrawals Spark already lists for this address and amount belong to earlier attempts, so recovery never + // takes one of them for this attempt. + earlierPayments = try await sdk.listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + } catch { + throw SparkWithdrawalNotSent(underlying: error) + } + var pending = SparkContactWithdrawal( + address: address, + amountSats: amountSats, + contact: contact, + startedAt: UInt64(Date().timeIntervalSince1970), + request: request, + tags: tags, + earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments), + idempotencyKey: UUID().uuidString.lowercased() + ) + let journaled = pending + try await Self.startJournaledWithdrawal( + writeJournal: { + Self.pendingContactWithdrawal = journaled + Self.lastContactWithdrawalResolution = nil + }, + beforeSend: beforeSend, + closeUnsent: { + await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + }, + paymentDeadline: paymentDeadline + ) + let idempotencyKey = pending.idempotencyKey + let response = try await Self.sendJournaledWithdrawal( + spends: spends, + send: { + try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium), + idempotencyKey: idempotencyKey + )) + }, + closeUnsent: { + await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + }, + settleLater: { + let failedAt = UInt64(Date().timeIntervalSince1970) + Task { + await Self.settleAfterFailedWithdrawal( + failedAt: failedAt, + syncWallet: { + _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest()) + self.syncedThisRun = true + }, + hasSynced: { await self.hasSynced(after: $0) }, + settle: { await self.settleContactWithdrawal(allowClear: $0) } + ) + } + } + ) + Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + pending.paymentId = response.payment.id + Self.pendingContactWithdrawal = pending + if case let .withdraw(txId, _, _) = response.payment.details { + await recordContactWithdrawal(response.payment, txId: txId, pending: pending) + } + await refreshState() + return try Self.sentWithdrawalId(response.payment) + } + + /// Claims the one contact withdrawal slot before anything awaits, so a second send started meanwhile is refused + /// instead of overwriting the first one's journal. + func claimContactWithdrawal() throws { + guard Self.pendingContactWithdrawal == nil, !isWithdrawingToContact else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") + } + isWithdrawingToContact = true + } + + func releaseContactWithdrawalClaim() { + isWithdrawingToContact = false + } + + /// What `withdraw` returns for the payment Spark sent: its txid, or its id while Spark gives none yet. A withdrawal + /// Spark returned as failed paid nothing, so the call ends as a refusal: the caller releases the request's proof, + /// private list and allowance attempt and shows a failure, never a success. One still pending can fail until the + /// provider accepts its payout, so the caller holds it as unknown, on the pending screen, until its journal settles. + nonisolated static func sentWithdrawalId(_ payment: Payment) throws -> String { + guard payment.status != .failed else { + throw SparkWithdrawalNotSent(underlying: AppError( + message: "wallet__payment_failed_description", + debugMessage: "Spark returned withdrawal '\(payment.id)' as failed" + )) + } + guard payment.status == .completed else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "Spark withdrawal '\(payment.id)' is still pending") + } + guard case let .withdraw(txId, _, _) = payment.details else { return payment.id } + return txId + } + + /// Sends a contact withdrawal whose journal is open, counted in `spends` so an exit starting meanwhile refuses it or + /// waits for it. A send refused before it left closes the journal and ends as `SparkWithdrawalNotSent`; any other + /// failure settles later from Spark's payment list. + static func sendJournaledWithdrawal( + spends: SparkSpends, + send: () async throws -> T, + closeUnsent: () async -> Void, + settleLater: () -> Void + ) async throws -> T { + do { + return try await spends.run(send) + } catch { + throw await withdrawalSendFailure(error, closeUnsent: closeUnsent, settleLater: settleLater) + } + } + + /// What a failed `sendPayment` of a journaled withdrawal means. A refusal Spark makes before anything reaches the + /// operators, as on the Lightning rail, closes the journal at once; an outcome that may have sent it stays journaled + /// and settles from the sync after it. + static func withdrawalSendFailure( + _ error: Error, + closeUnsent: () async -> Void, + settleLater: () -> Void + ) async -> Error { + let failure = sendPaymentFailure(error) + if failure is ChannelFreeRailSendRefused || isWithdrawalFeeRise(error) { + Logger.warn("Spark refused a withdrawal to a contact before sending it: \(error)", context: "SparkManager") + await closeUnsent() + return SparkWithdrawalNotSent(underlying: failure) + } + Logger.warn("Spark withdrawal to a contact failed or its outcome is unknown: \(error)", context: "SparkManager") + settleLater() + return error + } + + /// Spark refetches a withdrawal's quote near its expiry and refuses one whose fee rose past the prepared one at every + /// speed with this input error, before it withdraws (`affordable_speed` in the 0.28.0 SDK's `send/bitcoin_address.rs`). + nonisolated static func isWithdrawalFeeRise(_ error: Error) -> Bool { + guard case let SdkError.InvalidInput(message) = error else { return false } + return message.localizedCaseInsensitiveContains("onchain fee rose to") + } + + /// Settles the open contact withdrawal from Spark's payments: its own payment, once Spark returned it, or else a + /// withdrawal to its address and amount that wasn't listed before it started. With `allowClear` (after a sync that + /// succeeded), a list without one closes it as never sent and releases the request's proof, but only once the + /// attempt is past `contactWithdrawalExpirySecs`, so a withdrawal Spark hasn't listed yet is never dropped. One still + /// in flight in this process is never settled. + func settleContactWithdrawal(allowClear: Bool, now: UInt64 = UInt64(Date().timeIntervalSince1970)) async { + guard let pending = Self.pendingContactWithdrawal, !isWithdrawingToContact, + let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + else { return } + let payment = Self.matchingWithdrawal(pending, in: payments) + if let payment, case let .withdraw(txId, _, _) = payment.details { + await recordContactWithdrawal(payment, txId: txId, pending: pending) + } else if allowClear, Self.canCloseUnlisted(pending, listed: payment, now: now) { + Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") + await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + } + } + + /// Settles a withdrawal whose send failed or has an unknown outcome from the sync after it. That sync returns even + /// when it never reached Spark, so only one that completed after `failedAt` may close an unlisted one as unsent. + static func settleAfterFailedWithdrawal( + failedAt: UInt64, + syncWallet: () async throws -> Void, + hasSynced: (UInt64) async -> Bool, + settle: (_ allowClear: Bool) async -> Void + ) async { + guard await (try? syncWallet()) != nil else { return } + await settleAfterSync(since: failedAt, hasSynced: hasSynced, settle: settle) + } + + /// Spark reports a sync that never reached it as done too, its `.synced` event included, so only one that completed + /// after `since` may close an unlisted withdrawal as unsent. + static func settleAfterSync( + since: UInt64, + hasSynced: (UInt64) async -> Bool, + settle: (_ allowClear: Bool) async -> Void + ) async { + let synced = await hasSynced(since) + await settle(synced) + } + + /// Writes the attempt's journal before `beforeSend` starts the request's proof, so a kill between the two leaves a + /// journal that recovery closes as unsent once it expires, releasing the proof; a refusal in `beforeSend`, or a + /// `paymentDeadline` passed by then, closes it at once, releasing what it started. + static func startJournaledWithdrawal( + writeJournal: () -> Void, + beforeSend: () async throws -> Void, + closeUnsent: () async -> Void, + paymentDeadline: PaykitPreciseInstant? = nil, + now: () -> Date = Date.init + ) async throws { + writeJournal() + do { + try await beforeSend() + // A quote can expire while the request and its allowance are checked, so it is checked last. + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: now()) + } catch { + await closeUnsent() + throw SparkWithdrawalNotSent(underlying: error) + } + } + + nonisolated static let contactWithdrawalExpirySecs: UInt64 = 600 + + /// An attempt Spark never returned nor listed is taken as unsent only after the expiry. Spark can list its payment + /// without withdrawal details while the payout may still go out, so only one it failed is unsent too. + nonisolated static func canCloseUnlisted(_ pending: SparkContactWithdrawal, listed: Payment? = nil, now: UInt64) -> Bool { + pending.paymentId == nil && (listed?.status ?? .failed) == .failed && now >= pending.startedAt + contactWithdrawalExpirySecs + } + + /// The attempt's own payment, by the id Spark returned or the idempotency key it keeps as the id, also while Spark + /// lists it without withdrawal details; else, for one Spark never returned, a new withdrawal to its address and amount. + nonisolated static func matchingWithdrawal(_ pending: SparkContactWithdrawal, in payments: [Payment]) -> Payment? { + if let ownId = pending.paymentId ?? pending.idempotencyKey, let own = payments.first(where: { $0.id == ownId }) { + return own + } + guard pending.paymentId == nil else { return nil } + let earlier = Set(pending.earlierPaymentIds ?? []) + return payments.first { payment in + !earlier.contains(payment.id) && withdrawalIds(to: pending.address, amountSats: pending.amountSats, in: [payment]) == [payment.id] + } + } + + nonisolated static func withdrawalIds(to address: String, amountSats: UInt64, in payments: [Payment]) -> [String] { + payments.compactMap { payment in + guard payment.paymentType == .send, case let .withdraw(_, _, destination) = payment.details, + destination == address, sats(payment.amount) == amountSats + else { return nil } + return payment.id + } + } + + /// What an open withdrawal's journal does with the payment Spark returned or lists for it. Spark keeps a withdrawal + /// pending until the provider accepts its payout, and one it never accepts still fails, so only a completed one paid. + nonisolated static func settlement(of status: BreezSdkSpark.PaymentStatus) -> SparkWithdrawalSettlement { + switch status { + case .completed: .record + case .failed: .closeUnsent + case .pending: .wait + } + } + + /// Writes the withdrawal's sent activity, tags and request proof, and closes the journal only once all of them are + /// saved, so a failed or interrupted write is retried from the journal. + private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { + // A settle that raced this one already saved everything; its request's proof and allowance may be a retry's now. + guard Self.isOpenJournal(pending) else { return } + switch Self.settlement(of: payment.status) { + case .wait: + return + case .closeUnsent: + await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + return + case .record: + break + } + Self.contactWithdrawalTxIds.insert(txId) + let isDurable = await Self.recordWithdrawalLocally( + saveProof: { + guard let request = pending.request else { return true } + return await PaykitPaymentProofService.shared.completeSparkOnchainPayment( + requestId: request.id, + identity: request.identity, + txid: txId + ) + }, + saveActivity: { + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txId, + address: pending.address, + amount: pending.amountSats, + fee: Self.sats(payment.fees), + feeRate: 0, + contact: pending.contact, + feeIsExact: true + ) + }, + saveTags: { + guard let tags = pending.tags, !tags.isEmpty else { return true } + return await (try? CoreService.shared.activity.appendTags(toActivity: txId, tags)) != nil + } + ) + Self.confirmMinedContactPayouts(Self.completedContactPayouts([payment], contactWithdrawals: [txId])) + if isDurable { + if let request = pending.request { + await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .succeeded, transactionId: txId) + } + if Self.clearJournal(pending) { + Self.resolveContactWithdrawal(pending, txId: txId) + } + } else { + Logger.warn("Kept the Spark contact withdrawal journal: its local records are not all saved yet", context: "SparkManager") + } + } + + /// Closes the journal of a withdrawal that never went out, but only once its request's started proof is released: + /// a proof left behind would block the request, so the journal stays for the next sync to retry. The request's + /// allowance attempt, left unknown when the send's outcome was, then fails, so the ledger lets it be paid again. + static func closeUnsentWithdrawal( + _ pending: SparkContactWithdrawal, + releaseProof: (SparkContactWithdrawal.PaykitRequest, _ failAllowance: () async -> Bool) async -> SparkProofRelease, + failAllowance: @escaping (SparkContactWithdrawal.PaykitRequest) async -> Bool + ) async { + // The proof and allowance attempt match by request, so a stale close would release a retry's. + guard isOpenJournal(pending) else { return } + // The allowance attempt begins only once the proof started, so only a started proof fails one, and it fails + // before that proof goes, so a restart in between still finds the proof to settle it from. + if let request = pending.request, await releaseProof(request, { await failAllowance(request) }) == .failed { + Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") + return + } + if clearJournal(pending) { + resolveContactWithdrawal(pending, txId: nil) + } + } + + nonisolated static func isOpenJournal(_ pending: SparkContactWithdrawal) -> Bool { + pendingContactWithdrawal == pending + } + + /// Clears `pending`'s journal only while it is still the open one, so a stale recovery never drops a newer + /// withdrawal's journal, which could then be paid twice. Returns whether it cleared it: only then is the resolution + /// this attempt's to publish, since a retry of the same request shares its attempt. + nonisolated static func clearJournal(_ pending: SparkContactWithdrawal) -> Bool { + guard isOpenJournal(pending) else { return false } + pendingContactWithdrawal = nil + return true + } + + static func resolveContactWithdrawal(_ pending: SparkContactWithdrawal, txId: String?) { + let resolution = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: txId) + lastContactWithdrawalResolution = resolution + contactWithdrawalResolutionSubject.send(resolution) + } + + private static func releaseUnsentProof( + _ request: SparkContactWithdrawal.PaykitRequest, + failAllowance: () async -> Bool + ) async -> SparkProofRelease { + await PaykitPaymentProofService.shared.failSparkOnchainPayment( + requestId: request.id, + identity: request.identity, + unstartedPaymentList: request.paymentList, + settleAllowance: failAllowance + ) + } + + private static func failAllowance(_ request: SparkContactWithdrawal.PaykitRequest) async -> Bool { + await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .failed) + } + + /// Saves the proof, then the activity, then its tags; true only when every write succeeded. + static func recordWithdrawalLocally( + saveProof: () async -> Bool, + saveActivity: () async -> Bool, + saveTags: () async -> Bool + ) async -> Bool { + let proofSaved = await saveProof() + let activitySaved = await saveActivity() + let tagsSaved = activitySaved ? await saveTags() : false + return proofSaved && activitySaved && tagsSaved + } + + private nonisolated static let pendingContactWithdrawalKey = "sparkPendingContactWithdrawal" + private nonisolated static let contactWithdrawalTxIdsKey = "sparkContactWithdrawalTxIds" + + nonisolated static var pendingContactWithdrawal: SparkContactWithdrawal? { + get { + UserDefaults.standard.data(forKey: pendingContactWithdrawalKey) + .flatMap { try? JSONDecoder().decode(SparkContactWithdrawal.self, from: $0) } + } + set { + if let newValue, let data = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(data, forKey: pendingContactWithdrawalKey) + } else { + UserDefaults.standard.removeObject(forKey: pendingContactWithdrawalKey) + } + } + } + + /// Withdrawals paid to contacts, which stay sent payments rather than transfers to Savings. + nonisolated static var contactWithdrawalTxIds: Set { + get { Set(UserDefaults.standard.stringArray(forKey: contactWithdrawalTxIdsKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: contactWithdrawalTxIdsKey) } + } + + /// What a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. + func exportExitState() async throws -> String { + try await requireSdk().exportUnilateralExitState().exitState + } + + /// The exit data backup, or nil while Spark is not connected and there is nothing to back up. + func exitBackup() async throws -> SparkExitBackupV1? { + guard isConnected, let network = Self.sparkNetwork else { return nil } + return try await SparkExitBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), + network: "\(network)", + exitState: exportExitState() + ) + } + + /// Puts a restored exit data backup back: at once while Spark is connected, otherwise right after it connects, + /// before it is used, so a restored wallet can exit on-chain even while the Spark operators are unreachable. + func restoreExitBackup(_ backup: SparkExitBackupV1, walletIndex: Int) async throws { + guard let network = Self.sparkNetwork, backup.network == "\(network)" else { + Logger.warn("Skipped Spark exit data from network '\(backup.network)'", context: "SparkManager") + return + } + let file = Env.sparkExitRestore(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(backup.exitState.utf8).write(to: file, options: .atomic) + if isConnected { + await importRestoredExitState(walletIndex: walletIndex) + } + } + + /// Whether restored exit data is still waiting for its import, after trying that again while connected. + func hasStagedExitState(walletIndex: Int) async -> Bool { + await importRestoredExitState(walletIndex: walletIndex) + return FileManager.default.fileExists(atPath: Env.sparkExitRestore(walletIndex: walletIndex).path) + } + + private func importRestoredExitState(walletIndex: Int) async { + let file = Env.sparkExitRestore(walletIndex: walletIndex) + guard let sdk, let data = FileManager.default.contents(atPath: file.path) else { return } + do { + let imported = try await sdk.importUnilateralExitState( + request: ImportUnilateralExitStateRequest(exitState: String(decoding: data, as: UTF8.self)) + ) + try FileManager.default.removeItem(at: file) + Logger.info( + "Imported Spark exit data: '\(imported.importedLeaves)' leaves, '\(imported.skippedForeignLeaves)' foreign, " + + "'\(imported.skippedConflictingLeaves)' conflicting, '\(imported.skippedChains)' already held", + context: "SparkManager" + ) + } catch { + Logger.error("Failed to import Spark exit data: \(error)", context: "SparkManager") + } + } + + // MARK: Private + + private nonisolated func prepareSend( + paymentRequest: String, + amountSats: UInt64?, + feePolicy: FeePolicy? = nil + ) async throws -> PrepareSendPaymentResponse { + try await requireSdk().prepareSendPayment(request: PrepareSendPaymentRequest( + paymentRequest: .input(input: paymentRequest), + amount: amountSats.map { BInt(Int($0)) }, + tokenIdentifier: nil, + conversionOptions: nil, + feePolicy: feePolicy + )) + } + + private nonisolated func requireSdk() async throws -> BreezSdk { + guard let sdk = await sdk else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "Spark wallet is not connected") + } + return sdk + } + + /// Reads the Spark balance and deposits again, for pull to refresh. + /// Lists a deposit the app just broadcast as pending right away, then syncs so Spark sees it sooner than its + /// minute-long background sync; Spark's own deposit state replaces it once listed. + func onDepositBroadcast(txid: String, amountSats: UInt64) { + broadcastDeposits[txid] = amountSats + applyBroadcastDeposits() + onStateChanged?() + Task { + do { + _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) + syncedThisRun = true + } catch { + Logger.warn("Failed to sync Spark after deposit \(txid): \(error)", context: "SparkManager") + } + await refreshState() + } + } + + private func applyBroadcastDeposits() { + let known = Set(deposits.map(\.txid)) + let local = broadcastDeposits + .filter { !known.contains($0.key) } + .map { SparkDeposit(txid: $0.key, amountSats: $0.value, isClaimed: false, confirmations: 0) } + guard !local.isEmpty else { return } + deposits = local + deposits + pendingDepositSats = deposits.filter { !$0.isClaimed }.reduce(0) { $0 + $1.amountSats } + } + + func refreshState() async { + guard sdk != nil else { return } + do { + try await refresh() + } catch { + Logger.warn("Failed to refresh Spark state: \(error)", context: "SparkManager") + } + } + + /// One refresh at a time: a call during one waits for it, and it reads once more so the caller sees fresh state. + private func refresh() async throws { + if let refreshTask { + refreshAgain = true + return try await refreshTask.value + } + let task = Task { + defer { refreshTask = nil } + repeat { + refreshAgain = false + try await refreshOnce() + } while refreshAgain + } + refreshTask = task + try await task.value + } + + private func refreshOnce() async throws { + let sdk = try await requireSdk() + let info = try await sdk.getInfo(request: GetInfoRequest(ensureSynced: false)) + if let syncError = info.lastSyncError { + Logger.warn("Spark's last sync failed: \(syncError)", context: "SparkManager") + } + let listedUnclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits + let payments = try await sdk.listPayments(request: ListPaymentsRequest()).payments + let claimed = payments.filter { $0.method == .deposit && $0.status == .completed } + let unclaimed = Self.unclaimedDeposits(listedUnclaimed, claimed: claimed) + let listedDeposits = await Self.deposits(unclaimed: unclaimed, claimed: claimed) + // A wipe while this read was in flight leaves the state it cleared. + guard self.sdk === sdk else { return } + let pending = unclaimed.reduce(0) { $0 + $1.amountSats } + withdrawalFailedAt = Self.withdrawalFailedAt( + withdrawalFailedAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt, syncFailed: info.lastSyncError != nil + ) + withdrewAllAt = Self.withdrewAllAt(withdrewAllAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt) + let kept = Self.keptBalanceSats( + read: info.balanceSats, + lastShown: shownBalanceSats, + isStale: withdrawalFailedAt != nil || info.lastSyncError != nil, + withdrewAll: withdrewAllAt != nil, + isExiting: isExiting + ) + let changed = !isReady || Self.balancesChanged( + from: SparkBalances(readSats: balanceSats, keptSats: keptBalanceSats, pendingSats: pendingDepositSats), + to: SparkBalances(readSats: info.balanceSats, keptSats: kept, pendingSats: pending) + ) + isReady = true + balanceSats = info.balanceSats + keptBalanceSats = kept + pendingDepositSats = pending + recoverableFundsSats = info.recoverableFundsSats + deposits = listedDeposits + withdrawals = Self.withdrawals(payments) + for deposit in deposits { + broadcastDeposits[deposit.txid] = nil + } + applyBroadcastDeposits() + if changed { + onStateChanged?() + } + for payment in Self.paymentsToRecordAgain(payments, unsaved: unsavedPayments, recordedWithoutFee: sendsRecordedWithoutFee) { + await recordActivity(payment) + } + let contactWithdrawals = Self.contactWithdrawalTxIds + for txId in Self.savingsTransferTxIds(unclaimed: unclaimed, payments: payments, contactWithdrawals: contactWithdrawals) { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) + } + Self.confirmMinedContactPayouts(Self.completedContactPayouts(payments, contactWithdrawals: contactWithdrawals)) + } + + /// The output of a contact withdrawal's transaction that pays the contact. + struct ContactPayout: Equatable { + let txId: String + let vout: UInt32 + let address: String + } + + /// The payouts of the contact withdrawals Spark lists as completed. + nonisolated static func completedContactPayouts(_ payments: [Payment], contactWithdrawals: Set) -> [ContactPayout] { + payments.compactMap { payment in + guard payment.status == .completed, case let .withdraw(txId, vout, address) = payment.details, + contactWithdrawals.contains(txId) + else { return nil } + return ContactPayout(txId: txId, vout: vout, address: address) + } + } + + /// Looks the payouts up on chain off the refresh, so a slow Electrum server never holds a send that waits for it. + private static func confirmMinedContactPayouts(_ payouts: [ContactPayout]) { + guard !payouts.isEmpty else { return } + Task { + await confirmMinedPayouts( + payouts, + needsConfirmation: { txId in + await (try? CoreService.shared.activity.getOnchainActivityByTxId(txid: txId))?.confirmed == false + }, + unspentOutputs: { address in + try await OnChainHwService.shared.getAddressInfo( + address: address, + electrumUrl: OnChainHwService.getElectrumUrl(), + network: OnChainHwService.appDefaultCoinType + ).utxos + }, + isMined: { txId in + guard let network = Self.exitNetwork else { return false } + do { + return try await EsploraSparkExitChain.network(network).txStatus(txid: txId).confirmed + } catch let error as URLError where error.code == .fileDoesNotExist { + // Spark can list a payout before it is broadcast, and the chain then knows no such transaction. + return false + } + }, + markConfirmed: { await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: $0) } + ) + } + } + + /// Spark lists a withdrawal completed once its provider took the payout on, which can be before the payout transaction + /// is broadcast or mined, so a payout's activity is confirmed only once the payout is in a block. The payout address's + /// unspent outputs show that first; an output missing there, such as one the contact spent before Bitkit saw it mined, + /// or a failed read of them, is looked up by its transaction (`isMined`) instead. + nonisolated static func confirmMinedPayouts( + _ payouts: [ContactPayout], + needsConfirmation: (String) async -> Bool, + unspentOutputs: (String) async throws -> [AccountUtxo], + isMined: (String) async throws -> Bool, + markConfirmed: (String) async -> Void + ) async { + for payout in payouts { + guard await needsConfirmation(payout.txId) else { continue } + do { + let outputs: [AccountUtxo] + do { + outputs = try await unspentOutputs(payout.address) + } catch { + // A failed Electrum lookup falls through to the transaction's status, as a missing output does. + Logger.warn("Failed to read the payout address of Spark withdrawal '\(payout.txId)': \(error)", context: "SparkManager") + outputs = [] + } + let isPayoutMined: Bool = if let output = outputs.first(where: { $0.txid == payout.txId && $0.vout == payout.vout }) { + output.confirmations > 0 + } else { + try await isMined(payout.txId) + } + if isPayoutMined { + await markConfirmed(payout.txId) + } + } catch { + Logger.warn("Failed to look up the payout of Spark withdrawal '\(payout.txId)': \(error)", context: "SparkManager") + } + } + } + + /// Whether balances and the receive invoice must follow a refresh: the read balance counts too, as sends use it, and + /// it can come back to the shown one while that stays the same. + nonisolated static func balancesChanged(from old: SparkBalances, to new: SparkBalances) -> Bool { + old != new + } + + /// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still + /// there, so the last shown balance stays shown until it reads one again. After a withdrawal of the whole balance + /// (`withdrewAll`) the 0 is real. An emergency exit moves the balance to savings, so a 0 read during one keeps nothing. + nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool, withdrewAll: Bool, isExiting: Bool) -> UInt64? { + read == 0 && isStale && !withdrewAll && lastShown > 0 && !isExiting ? lastShown : nil + } + + /// The balance kept again once an exit ends: a cancelled exit moved nothing, so what was kept before it still is. + nonisolated static func keptBalanceSats(afterExitKept kept: UInt64?, read: UInt64, isCancel: Bool) -> UInt64? { + isCancel && read == 0 ? kept : nil + } + + /// A failed withdrawal stops keeping the balance once Spark reads one, or reads 0 after a sync that ended after it. + nonisolated static func withdrawalFailedAt(_ failedAt: UInt64?, read: UInt64, lastSyncedAt: UInt64?, syncFailed: Bool) -> UInt64? { + guard let failedAt, read == 0, syncFailed || (lastSyncedAt ?? 0) <= failedAt else { return nil } + return failedAt + } + + /// A full withdrawal stops making a read of 0 real once a sync that ended after it reads a balance, so a refresh + /// that read the balance before the withdrawal can't bring it back. + nonisolated static func withdrewAllAt(_ withdrewAt: UInt64?, read: UInt64, lastSyncedAt: UInt64?) -> UInt64? { + guard let withdrewAt, read == 0 || (lastSyncedAt ?? 0) <= withdrewAt else { return nil } + return withdrewAt + } + + private nonisolated static func nowSecs() -> UInt64 { + UInt64(Date().timeIntervalSince1970) + } + + /// Spark can still list a deposit it already claimed early as unclaimed, so a claimed one is listed only as claimed. + /// Matched by output, as one transaction can hold several deposits. + nonisolated static func unclaimedDeposits(_ unclaimed: [DepositInfo], claimed: [Payment]) -> [DepositInfo] { + let claimedOutputs = Set(claimed.compactMap { payment -> String? in + guard case let .deposit(txId, vout) = payment.details else { return nil } + return "\(txId.lowercased()):\(vout)" + }) + return unclaimed.filter { !claimedOutputs.contains("\($0.txid.lowercased()):\($0.vout)") } + } + + /// Spark's withdrawals to Savings, without the ones that paid contacts: those recorded as sent, and the open + /// journal's, which Spark keeps pending until its payout is accepted. + nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { + let contactWithdrawals = contactWithdrawalTxIds + let journaledId = pendingContactWithdrawal.flatMap { matchingWithdrawal($0, in: payments)?.id } + return payments.compactMap { payment in + guard payment.status != .failed, payment.id != journaledId, case let .withdraw(txId, _, _) = payment.details, + !contactWithdrawals.contains(txId) else { return nil } + return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) + } + } + + /// The transfers between Savings and Spark, without the withdrawals that paid contacts, which stay sends to them. + nonisolated static func savingsTransferTxIds(unclaimed: [DepositInfo], payments: [Payment], contactWithdrawals: Set) -> [String] { + transferTxIds(unclaimed: unclaimed, payments: payments).filter { !contactWithdrawals.contains($0) } + } + + /// On-chain transactions that moved funds between Savings and Spark, so their activities show as transfers. + nonisolated static func transferTxIds(unclaimed: [DepositInfo], payments: [Payment]) -> [String] { + unclaimed.map(\.txid) + payments.compactMap { payment in + guard payment.status != .failed else { return nil } + switch payment.details { + case let .deposit(txId, _), let .withdraw(txId, _, _): return txId + default: return nil + } + } + } + + private static func deposits(unclaimed: [DepositInfo], claimed: [Payment]) async -> [SparkDeposit] { + let confirmations = await onchainConfirmations() + let pending = unclaimed.map { + SparkDeposit(txid: $0.txid, amountSats: $0.amountSats, isClaimed: false, confirmations: confirmations[$0.txid] ?? nil) + } + let done: [SparkDeposit] = claimed.compactMap { payment in + guard case let .deposit(txId, _) = payment.details else { return nil } + return SparkDeposit( + txid: txId, + amountSats: sats(payment.amount), + isClaimed: true, + feeSats: sats(payment.fees), + claimedAt: payment.timestamp + ) + } + return pending + done + } + + /// Confirmations of the on-chain transactions this wallet sent or received, 0 while unconfirmed. + private static func onchainConfirmations() async -> [String: UInt32?] { + let tip = LightningService.shared.status?.currentBestBlock.height + let payments = await LightningService.shared.listPayments() ?? [] + var result: [String: UInt32?] = [:] + for payment in payments { + guard case let .onchain(txid, status) = payment.kind else { continue } + switch status { + case .unconfirmed: + result[txid] = 0 + case let .confirmed(_, height, _): + result[txid] = tip.map { $0 >= height ? $0 - height + 1 : 0 } + } + } + return result + } + + /// Payment hashes whose every Lightning send failed, so a hold on a channel retry can be released. + nonisolated static func failedSendHashes(_ payments: [Payment]) -> [String] { + var failed: [String] = [] + var live: Set = [] + for payment in payments where payment.paymentType == .send { + guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { continue } + if payment.status == .failed { + failed.append(htlcDetails.paymentHash) + } else { + live.insert(htlcDetails.paymentHash) + } + } + return failed.filter { !live.contains($0) } + } + + private func syncActivities() async throws { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest()).payments + var recordError: Error? + do { + try await Self.recordAll(payments) { await self.recordActivity($0) } + } catch { + recordError = error + } + // A send that failed while the app was closed released no hold on a channel retry. + await Self.releaseFailedSendHolds { try await self.requireSdk().listPayments(request: ListPaymentsRequest()).payments } + if let recordError { + throw recordError + } + } + + /// Releases the holds of the sends Spark's list shows failed. The list is read after the activities were recorded, + /// so a retry the user started meanwhile keeps its hold, as does one recorded or ending while the list is read. + static func releaseFailedSendHolds(listPayments: () async throws -> [Payment]) async { + let mark = LightningService.shared.attemptMark + guard let latest = try? await listPayments() else { return } + for paymentHash in failedSendHashes(latest) { + LightningService.shared.releaseRailAttempt(paymentHash: paymentHash, since: mark) + } + } + + /// Records every payment, then throws when any failed to save, so the activity sync isn't marked complete. + static func recordAll(_ payments: [Payment], record: (Payment) async -> Bool) async throws { + var unsaved = 0 + for payment in payments where await !record(payment) { + unsaved += 1 + } + guard unsaved == 0 else { + throw AppError(message: "Failed to save \(unsaved) Spark activities", debugMessage: nil) + } + } + + /// The payments a refresh writes again: those whose last write failed, and sends recorded before Spark knew the fee + /// it now reports. + nonisolated static func paymentsToRecordAgain( + _ payments: [Payment], + unsaved: Set, + recordedWithoutFee: Set + ) -> [Payment] { + payments.filter { unsaved.contains($0.id) || (recordedWithoutFee.contains($0.id) && sats($0.fees) > 0) } + } + + private func onSdkEvent(_ event: SdkEvent) async { + switch event { + case .synced: + syncedThisRun = true + await LightningService.shared.listRailSendsIfUnseeded() + await refreshState() + await Self.settleAfterSync( + since: Self.pendingContactWithdrawal?.startedAt ?? 0, + hasSynced: { await self.hasSynced(after: $0) }, + settle: { await self.settleContactWithdrawal(allowClear: $0) } + ) + case .newDeposits, .claimedDeposits: + await refreshState() + case let .unclaimedDeposits(deposits): + Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") + case let .paymentSucceeded(payment), let .paymentFailed(payment): + await Self.handleSettledPayment( + payment, + record: { await self.recordActivity($0) }, + emit: { LightningService.shared.emitChannelFreeRailEvent($0) }, + refresh: { try await self.refresh() } + ) + case let .paymentPending(payment): + await recordActivity(payment) + case .unilateralExitStateChanged: + Self.notifyExitStateChanged() + case let .autoOptimization(optimizationEvent): + noteOptimization(optimizationEvent) + default: + break + } + } + + /// Records a settled payment and delivers its node event before reading the balance again, so a failed read + /// never drops the payment the send and receive flows wait for. + static func handleSettledPayment( + _ payment: Payment, + record: (Payment) async -> Void, + emit: (Event) -> Void, + refresh: () async throws -> Void + ) async { + await record(payment) + if let event = nodeEvent(for: payment) { + emit(event) + } + do { + try await refresh() + } catch { + Logger.warn("Failed to refresh Spark after payment '\(payment.id)': \(error)", context: "SparkManager") + } + } + + /// Returns whether the payment's activity is saved; a failed write is kept in `unsavedPayments` for the next refresh. + @discardableResult + private func recordActivity(_ payment: Payment) async -> Bool { + guard case let .lightning(description, invoice, _, htlcDetails, _, _, _, _) = payment.details else { return true } + let details = PaymentDetails( + id: htlcDetails.paymentHash, + kind: .bolt11( + hash: htlcDetails.paymentHash, + preimage: htlcDetails.preimage, + secret: nil, + description: description, + bolt11: invoice + ), + amountMsat: Self.sats(payment.amount) * 1000, + feePaidMsat: Self.sats(payment.fees) * 1000, + direction: payment.paymentType == .send ? .outbound : .inbound, + status: Self.ldkStatus(payment.status), + latestUpdateTimestamp: payment.timestamp + ) + if payment.paymentType == .send, Self.sats(payment.fees) == 0 { + sendsRecordedWithoutFee.insert(payment.id) + } else { + sendsRecordedWithoutFee.remove(payment.id) + } + do { + try await CoreService.shared.activity.recordChannelFreeRailPayment(details) + unsavedPayments.remove(payment.id) + return true + } catch { + Logger.error("Failed to record Spark payment '\(payment.id)': \(error)", context: "SparkManager") + unsavedPayments.insert(payment.id) + return false + } + } + + static func nodeEvent(for payment: Payment) -> Event? { + guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { return nil } + let hash = htlcDetails.paymentHash + switch (payment.paymentType, payment.status) { + case (.receive, .completed): + return .paymentReceived(paymentId: hash, paymentHash: hash, amountMsat: sats(payment.amount) * 1000, customRecords: []) + case (.send, .completed): + return .paymentSuccessful( + paymentId: hash, + paymentHash: hash, + paymentPreimage: htlcDetails.preimage, + feePaidMsat: sats(payment.fees) * 1000 + ) + case (.send, .failed): + return .paymentFailed(paymentId: hash, paymentHash: hash, reason: .unexpectedError) + default: + return nil + } + } + + private nonisolated static func ldkStatus(_ status: BreezSdkSpark.PaymentStatus) -> LDKNode.PaymentStatus { + switch status { + case .completed: .succeeded + case .pending: .pending + case .failed: .failed + } + } + + private nonisolated static func sats(_ value: U128) -> UInt64 { + guard let sats = UInt64(value.description) else { + Logger.error("Spark amount '\(value)' does not fit in sats", context: "SparkManager") + return 0 + } + return sats + } +} + +/// An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. +struct SparkDeposit: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let isClaimed: Bool + var feeSats: UInt64 = 0 + var claimedAt: UInt64? + /// On-chain confirmations, known for deposits this wallet sent. + var confirmations: UInt32? + + var id: String { + "\(txid)-\(isClaimed)" + } +} + +/// How a deposit into Spark can be claimed. The provider decides per deposit whether to credit it early, at 0 +/// confirmations or 1-2, for a fee above the standard claim's at 3 confirmations. +struct SparkClaimOffer: Equatable { + enum Kind: Equatable { + /// No early claim is offered, so Spark claims it at `confirmations` for `feeSats`. + case standard(feeSats: UInt64, confirmations: UInt32) + /// The early claim fits this wallet's fee cap, so Spark claims it at `confirmations` by itself. + case automatic(feeSats: UInt64, confirmations: UInt32) + /// The early claim costs more than the cap, so it waits for the user to accept `earlyFeeSats`. + case choice(earlyFeeSats: UInt64, earlyConfirmations: UInt32, standardFeeSats: UInt64, standardConfirmations: UInt32) + } + + let txid: String + let vout: UInt32 + var kind: Kind + + init(txid: String, vout: UInt32, kind: Kind) { + self.txid = txid + self.vout = vout + self.kind = kind + } + + init(txid: String, vout: UInt32, quote: FetchClaimDepositQuoteResponse) { + let mature = quote.mature + let kind: Kind = if let early = quote.instant { + if let cap = early.maxFeeSats, early.feeSats <= cap { + .automatic(feeSats: early.feeSats, confirmations: early.confirmationsRequired) + } else { + .choice( + earlyFeeSats: early.feeSats, + earlyConfirmations: early.confirmationsRequired, + standardFeeSats: mature.feeSats, + standardConfirmations: mature.confirmationsRequired + ) + } + } else { + .standard(feeSats: mature.feeSats, confirmations: mature.confirmationsRequired) + } + self.init(txid: txid, vout: vout, kind: kind) + } +} + +/// What claiming a deposit costs: at most `instantMaxSats` to add it before confirmations, about `laterSats` to add it +/// after 3 confirmations. +struct SparkClaimFees: Equatable { + let instantMaxSats: UInt64 + let laterSats: UInt64 +} + +/// What accepting an early claim did. +enum SparkEarlyClaim: Equatable { + /// The claim settled, so the deposit is in the balance. + case claimed + /// The claim is submitted and Spark adds the deposit to the balance once it settles. + case submitted + /// Not offered at this depth yet; Spark claims it early once a confirmation brings the offer. + case waiting + /// The provider now asks `requiredFeeSats`, more than was accepted. + case repriced(requiredFeeSats: UInt64) + /// The provider declined it, so Spark claims the deposit at 3 confirmations. + case declined + + init(_ outcome: ClaimDepositOutcome) { + switch outcome { + case .settled: + self = .claimed + case .submitted: + self = .submitted + case let .deferred(reason): + switch reason { + case .noEarlyClaimAvailable: self = .waiting + case let .maxFeeExceeded(requiredFeeSats, _): self = .repriced(requiredFeeSats: requiredFeeSats) + case .providerDeclined: self = .declined + } + } + } +} + +/// A Transfer to Savings out of Spark, with what Spark kept for it. +/// A withdrawal quote the provider issued; it holds for the same address and balance until shortly before it expires, +/// so it can't expire mid-send. An estimate, which no provider issued, expires at 0 and never holds. +struct SparkWithdrawalQuote: Equatable { + static let expiryMarginSecs: UInt64 = 30 + + let address: String + let balanceSats: UInt64 + let expiresAt: UInt64 + + func holds(address: String, balanceSats: UInt64, now: UInt64) -> Bool { + self.address == address && self.balanceSats == balanceSats && expiresAt > now + Self.expiryMarginSecs + } +} + +/// The withdrawal now costs more than the confirm screen showed, so nothing was sent. +struct SparkWithdrawalFeeIncreased: LocalizedError, Equatable { + let feeSats: UInt64 + let shownFeeSats: UInt64 + + var errorDescription: String? { + t("lightning__spending_confirm__fees_changed_title") + } +} + +/// User copy for a Spark failure; the SDK's own text goes to the log only. +enum SparkErrorCopy { + static func message(for error: Error, fallback: String) -> String { + switch error { + case let refused as ChannelFreeRailSendRefused: + return refused.reason + case let appError as AppError where !appError.isGeneric: + return t(appError.message) + case SdkError.InsufficientFunds: + return t("other__pay_insufficient_spending_description") + case SdkError.NetworkError, SdkError.ChainServiceError: + return t("hardware__send_broadcast_failed_text") + default: + return fallback + } + } +} + +/// A Spark withdrawal to a contact's on-chain address, journaled before it is sent. +/// A contact withdrawal refused before its journal was written: nothing was sent. +enum SparkWithdrawalSettlement: Equatable { + /// Paid: its records are saved and its journal closes. + case record + /// Never paid: its journal closes as unsent, releasing what it started. + case closeUnsent + /// Still in flight: its journal stays open until Spark settles it. + case wait +} + +struct SparkWithdrawalNotSent: Error { + let underlying: Error +} + +/// Spark's balance can't cover a contact withdrawal's amount and fee, so the route offers no Spark leg. +struct SparkLegUnaffordable: Error { + let debitSats: UInt64 + let balanceSats: UInt64 +} + +struct SparkContactWithdrawal: Codable, Equatable { + /// The Paykit request a withdrawal pays, and the identity its proof belongs to. + struct PaykitRequest: Codable, Equatable { + let id: PaykitPaymentRequest.ID + let identity: String + /// The contact's private list the request consumed, given back when the withdrawal never started its proof. + var paymentList: PaymentList? + } + + /// A private payment list version a send consumed, and the one before it. + struct PaymentList: Codable, Equatable { + let version: UInt64 + let previousVersion: UInt64? + } + + let address: String + let amountSats: UInt64 + let contact: String? + let startedAt: UInt64 + var request: PaykitRequest? + /// Tags picked on Confirm, saved on the sent activity. + var tags: [String]? + /// Withdrawals to the same address and amount Spark listed before this attempt; recovery skips them. + var earlierPaymentIds: [String]? + /// This attempt's Spark payment, once Spark returned it. + var paymentId: String? + /// The UUID the send passed as its idempotency key, which Spark keeps as the payment's id. + var idempotencyKey: String? +} + +/// One contact withdrawal, which a pending send screen follows until its journal closes. +struct SparkWithdrawalAttempt: Hashable { + let address: String + let amountSats: UInt64 + let requestId: PaykitPaymentRequest.ID? +} + +/// How a contact withdrawal's journal closed: sent with its txid, or never sent when `txId` is nil. +struct SparkContactWithdrawalResolution: Equatable { + let attempt: SparkWithdrawalAttempt + let txId: String? +} + +extension SparkContactWithdrawal { + var attempt: SparkWithdrawalAttempt { + SparkWithdrawalAttempt(address: address, amountSats: amountSats, requestId: request?.id) + } +} + +struct SparkWithdrawal: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let feeSats: UInt64 + + var id: String { + txid + } +} + +struct BreezSparkExitRecovering: SparkExitRecovering { + let sdk: BreezSdk + + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse { + try await sdk.prepareRecoverFunds(request: request) + } + + func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse { + try await sdk.recoverFunds(request: request, signer: signer) + } + + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { + try await sdk.checkRecoverFunds(request: CheckRecoverFundsRequest(recovery: recovery)) + } + + func exportExitState() async throws -> String { + try await sdk.exportUnilateralExitState().exitState + } + + func importExitState(_ exitState: String) async throws { + let imported = try await sdk.importUnilateralExitState(request: ImportUnilateralExitStateRequest(exitState: exitState)) + Logger.info( + "Imported the Spark exit's own exit data: '\(imported.importedLeaves)' leaves, '\(imported.skippedForeignLeaves)' foreign, " + + "'\(imported.skippedConflictingLeaves)' conflicting, '\(imported.skippedChains)' already held", + context: "SparkManager" + ) + } +} + +private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked Sendable { + private let handler: @Sendable (SdkEvent) async -> Void + + init(handler: @escaping @Sendable (SdkEvent) async -> Void) { + self.handler = handler + } + + func onEvent(event: SdkEvent) async { + await handler(event) + } +} + +/// The Spark sends and withdrawals past their exit check. An exit holds Spark before it waits for them, so every later +/// one refuses, and it quotes the leaves they leave once they end. +@MainActor +final class SparkSpends { + private let isExiting: () -> Bool + private(set) var inFlight = 0 + /// An exit start holds Spark while it waits for the sends, before its record exists. + private(set) var isHeld = false + + init(isExiting: @escaping () -> Bool) { + self.isExiting = isExiting + } + + /// Runs `send` counted, unless an exit holds Spark. Called after the send's last await but the one inside `send`, + /// so an exit starting meanwhile either refuses it here or waits for it. + func run(_ send: () async throws -> T) async throws -> T { + guard !isExiting(), !isHeld else { + Logger.info("Refused a Spark send: an emergency exit holds Spark", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + } + inFlight += 1 + defer { inFlight -= 1 } + return try await send() + } + + /// Holds Spark for an exit start and waits for the counted sends to end, for up to `timeout`. Throws, and lets go, + /// when one is still sending then: the exit must not start while a send can spend its leaves, and the user starts + /// it again once the send ended. The start lets go (`release`) once its record holds Spark. + func holdForExit(timeout: TimeInterval) async throws { + isHeld = true + let deadline = Date().addingTimeInterval(timeout) + while inFlight > 0, !Task.isCancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } + if inFlight > 0 { + Logger.warn("Gave up waiting for Spark sends in flight to end", context: "SparkManager") + isHeld = false + throw AppError(message: t("other__try_again"), debugMessage: "A Spark payment is still sending") + } + } + + func release() { + isHeld = false + } +} + +/// Lets the first of two racing tasks resume a continuation, once. +private final class SparkFirstResume: @unchecked Sendable { + private let lock = NSLock() + private var isClaimed = false + + func claim() -> Bool { + lock.withLock { + defer { isClaimed = true } + return !isClaimed + } + } +} + +/// The balances a refresh reads: what Spark read (`readSats`), the balance kept on screen, and pending deposits. +struct SparkBalances: Equatable { + let readSats: UInt64 + let keptSats: UInt64? + let pendingSats: UInt64 +} diff --git a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift new file mode 100644 index 000000000..cf173e027 --- /dev/null +++ b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Shown once after the update to users whose channels still hold a balance worth moving to Spark. +struct MoveToSparkTimedSheet: TimedSheetItem { + let id = SheetID.moveToSpark + let priority = TimedSheetPriority.critical + let sheetItem: any SheetItem = MoveToSparkSheetItem() + let app: AppViewModel + let wallet: WalletViewModel + + @MainActor + func shouldShow() async -> Bool { + guard !app.hasSeenMoveToSparkIntro else { return false } + let spark = SparkManager.shared + return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(spark)) + } + + func onShown() { + Task { @MainActor in + app.hasSeenMoveToSparkIntro = true + } + } + + func onDismissed() {} +} diff --git a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift index f3ba85bdf..bc9cf01de 100644 --- a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift +++ b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift @@ -6,6 +6,7 @@ enum TimedSheetPriority: Int, CaseIterable { case low = 1 case medium = 2 case high = 3 + case critical = 4 } /// Protocol for timed sheet items @@ -75,6 +76,7 @@ class TimedSheetManager: ObservableObject { registerSheet(notificationsSheet) registerSheet(UsdtIntroTimedSheet(wallet: walletViewModel, settings: settingsViewModel)) + registerSheet(MoveToSparkTimedSheet(app: appViewModel, wallet: walletViewModel)) let quickpaySheet = QuickpayTimedSheet(appViewModel: appViewModel, walletViewModel: walletViewModel) registerSheet(quickpaySheet) diff --git a/Bitkit/Models/BackupCategory.swift b/Bitkit/Models/BackupCategory.swift index 1ea1acb40..8b3b90017 100644 --- a/Bitkit/Models/BackupCategory.swift +++ b/Bitkit/Models/BackupCategory.swift @@ -9,6 +9,7 @@ enum BackupCategory: String, CaseIterable { case settings = "SETTINGS" case widgets = "WIDGETS" case metadata = "METADATA" + case sparkExit = "SPARK_EXIT" } // MARK: - UI Extensions @@ -32,6 +33,8 @@ extension BackupCategory { return "stack" case .metadata: return "tag" + case .sparkExit: + return "bolt-hollow" } } @@ -53,6 +56,8 @@ extension BackupCategory { return t("settings__backup__category_widgets") case .metadata: return t("settings__backup__category_tags") + case .sparkExit: + return t("settings__backup__category_spark_exit") } } } diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 5992a353d..628345dcd 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -27,6 +27,9 @@ struct MetadataBackupV1: Codable { /// before this field, and in envelopes from an app that knows no names. Shared with /// bitkit-android, which spells the key the same way — see its `MetadataBackupV1`. let hwWalletNames: [String: String]? + /// The earlier Spark exit addresses each wallet watches, keyed `/wallet` as bitkit-android keys them. + /// Nil in envelopes written before this field. + var sparkExitEarlierAddresses: [String: [SparkExitEarlierAddressBackupV1]]? init( version: Int, @@ -35,7 +38,8 @@ struct MetadataBackupV1: Codable { cache: AppCacheData, pubkySession: PubkySessionBackupV1?, pubkyContactProfileOverrides: [String: PubkyProfileData]?, - hwWalletNames: [String: String]? = nil + hwWalletNames: [String: String]? = nil, + sparkExitEarlierAddresses: [String: [SparkExitEarlierAddressBackupV1]]? = nil ) { self.version = version self.createdAt = createdAt @@ -44,9 +48,37 @@ struct MetadataBackupV1: Codable { self.pubkySession = pubkySession self.pubkyContactProfileOverrides = pubkyContactProfileOverrides self.hwWalletNames = hwWalletNames + self.sparkExitEarlierAddresses = sparkExitEarlierAddresses } } +extension MetadataBackupV1 { + /// An earlier exit address list this app can't read is left out, so it never fails the rest of the envelope. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(Int.self, forKey: .version) + createdAt = try c.decode(UInt64.self, forKey: .createdAt) + tagMetadata = try c.decode([PreActivityMetadata].self, forKey: .tagMetadata) + cache = try c.decode(AppCacheData.self, forKey: .cache) + pubkySession = try c.decodeIfPresent(PubkySessionBackupV1.self, forKey: .pubkySession) + pubkyContactProfileOverrides = try c.decodeIfPresent([String: PubkyProfileData].self, forKey: .pubkyContactProfileOverrides) + hwWalletNames = try c.decodeIfPresent([String: String].self, forKey: .hwWalletNames) + sparkExitEarlierAddresses = try? c.decodeIfPresent( + [String: [SparkExitEarlierAddressBackupV1]].self, + forKey: .sparkExitEarlierAddresses + ) + } +} + +/// An earlier Spark exit address as both apps back it up. `kind` is `keptChange` or `cancelledFunding`; a reader skips +/// one it does not know. What the address holds is read again from the chain. +struct SparkExitEarlierAddressBackupV1: Codable, Equatable { + let address: String + let path: String + let kind: String + let atMillis: Int64 +} + struct PubkySessionBackupV1: Codable, Equatable { enum Kind: String, Codable { case localSeed @@ -69,6 +101,8 @@ struct AppCacheData: Codable { let hasSeenNotificationsIntro: Bool let hasSeenQuickpayIntro: Bool let hasSeenShopIntro: Bool + let hasSeenTransferIntro: Bool + let hasSeenTransferToSpendingIntro: Bool let hasSeenTransferToSavingsIntro: Bool let hasSeenWidgetsIntro: Bool let hasDismissedWidgetsOnboardingHint: Bool @@ -82,6 +116,9 @@ struct AppCacheData: Codable { let detachedActivityContacts: Set /// Imported cache addresses may have been public; local snapshots do not export the live receive address. let onchainAddress: String? + /// The funding key index each wallet's next Spark exit takes, keyed `/wallet` as bitkit-android keys + /// its own. Nil in envelopes written before this field. + let sparkExitNextFundingIndexes: [String: Int]? init( hasSeenContactsIntro: Bool, @@ -89,6 +126,8 @@ struct AppCacheData: Codable { hasSeenNotificationsIntro: Bool, hasSeenQuickpayIntro: Bool, hasSeenShopIntro: Bool, + hasSeenTransferIntro: Bool = false, + hasSeenTransferToSpendingIntro: Bool = false, hasSeenTransferToSavingsIntro: Bool, hasSeenWidgetsIntro: Bool, hasDismissedWidgetsOnboardingHint: Bool, @@ -99,13 +138,16 @@ struct AppCacheData: Codable { dismissedSuggestions: [String], lastUsedTags: [String], quickPayLedger: QuickPayLedger? = nil, - detachedActivityContacts: Set = [] + detachedActivityContacts: Set = [], + sparkExitNextFundingIndexes: [String: Int]? = nil ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro self.hasSeenNotificationsIntro = hasSeenNotificationsIntro self.hasSeenQuickpayIntro = hasSeenQuickpayIntro self.hasSeenShopIntro = hasSeenShopIntro + self.hasSeenTransferIntro = hasSeenTransferIntro + self.hasSeenTransferToSpendingIntro = hasSeenTransferToSpendingIntro self.hasSeenTransferToSavingsIntro = hasSeenTransferToSavingsIntro self.hasSeenWidgetsIntro = hasSeenWidgetsIntro self.hasDismissedWidgetsOnboardingHint = hasDismissedWidgetsOnboardingHint @@ -117,6 +159,7 @@ struct AppCacheData: Codable { self.lastUsedTags = lastUsedTags self.quickPayLedger = quickPayLedger self.detachedActivityContacts = detachedActivityContacts + self.sparkExitNextFundingIndexes = sparkExitNextFundingIndexes onchainAddress = nil } @@ -127,6 +170,8 @@ struct AppCacheData: Codable { hasSeenNotificationsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenNotificationsIntro) ?? false hasSeenQuickpayIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenQuickpayIntro) ?? false hasSeenShopIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenShopIntro) ?? false + hasSeenTransferIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferIntro) ?? false + hasSeenTransferToSpendingIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSpendingIntro) ?? false hasSeenTransferToSavingsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSavingsIntro) ?? false hasSeenWidgetsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenWidgetsIntro) ?? false hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) ?? false @@ -139,17 +184,19 @@ struct AppCacheData: Codable { quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger) detachedActivityContacts = try c.decodeIfPresent(Set.self, forKey: .detachedActivityContacts) ?? [] onchainAddress = try c.decodeIfPresent(String.self, forKey: .onchainAddress) + sparkExitNextFundingIndexes = try c.decodeIfPresent([String: Int].self, forKey: .sparkExitNextFundingIndexes) } private enum CodingKeys: String, CodingKey { case hasSeenContactsIntro, hasSeenProfileIntro, hasSeenNotificationsIntro, hasSeenQuickpayIntro - case hasSeenShopIntro, hasSeenTransferToSavingsIntro + case hasSeenShopIntro, hasSeenTransferIntro, hasSeenTransferToSpendingIntro, hasSeenTransferToSavingsIntro case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp case dismissedSuggestions, lastUsedTags case quickPayLedger case detachedActivityContacts case onchainAddress + case sparkExitNextFundingIndexes } } @@ -220,3 +267,12 @@ struct WidgetsBackupV1 { return try JSONSerialization.data(withJSONObject: dict) } } + +/// The Spark wallet's unilateral exit data, as `exportUnilateralExitState` returns it, kept so a wallet restored on +/// another device can still exit on-chain while the Spark operators are unreachable. +struct SparkExitBackupV1: Codable { + let version: Int + let createdAt: UInt64 + let network: String + let exitState: String +} diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 9e631a952..d1143b40c 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -225,6 +225,8 @@ struct PaykitPaymentStateBackup: Codable { let candidateMiningFees: [String: String]? let followup: Followup? let transfer: Transfer? + /// A transfer to Spark's deposit address pays no order, so only this keeps it a transfer through a restore. + let sparkFunding: Bool? init(_ attempt: OnchainSendAttempt, wallet: Wallet, billingPeriod: PaykitBillingPeriod? = nil) throws { guard attempt.walletId == wallet.originalWalletId else { throw invalidBackup("On-chain attempt belongs to another backup wallet") } @@ -277,6 +279,7 @@ struct PaykitPaymentStateBackup: Codable { return Transfer(txTotalSats: String($0.txTotalSats), preTransferOnchainSats: String($0.preTransferOnchainSats), originalOrderClientBalanceSats: String($0.clientBalanceSats), originalOrderFeeSats: String(fee)) } + sparkFunding = attempt.isSparkFunding ? true : nil } func restored(wallet destination: Wallet, proofs: [PendingPaykitPaymentProof]) throws -> (OnchainSendAttempt, [PendingPaykitPaymentProof]) { @@ -369,7 +372,8 @@ struct PaykitPaymentStateBackup: Codable { recoveryContext: OnchainSendRecoveryContext(inputs: inputs ?? [], satsPerVbyte: rate, paymentIdentity: payerIdentity, candidateTxids: candidateTxids, candidateFeeRates: rates, - candidateFeeSats: miningFees)) + candidateFeeSats: miningFees), + sparkFunding: sparkFunding) return (attempt, restoredProofs) } diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 03a769836..b494f9e6e 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -21,6 +21,8 @@ enum SettingsBackupConfig { "hasSeenNotificationsIntro", "hasSeenQuickpayIntro", "hasSeenShopIntro", + "hasSeenTransferIntro", + "hasSeenTransferToSpendingIntro", "hasSeenTransferToSavingsIntro", "hasSeenWidgetsIntro", "hasDismissedWidgetsOnboardingHint", diff --git a/Bitkit/Models/TransferType.swift b/Bitkit/Models/TransferType.swift index 909481edb..f8f416be9 100644 --- a/Bitkit/Models/TransferType.swift +++ b/Bitkit/Models/TransferType.swift @@ -6,10 +6,12 @@ enum TransferType: String, Codable { case manualSetup = "MANUAL_SETUP" case forceClose = "FORCE_CLOSE" case coopClose = "COOP_CLOSE" + /// The Spark balance leaving on-chain without the Spark operators. + case sparkExit = "SPARK_EXIT" func isToSavings() -> Bool { switch self { - case .toSavings, .coopClose, .forceClose: + case .toSavings, .coopClose, .forceClose, .sparkExit: return true default: return false diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index c9fd03f2e..3c6736f85 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -170,11 +170,19 @@ "fee__custom__description" = "يعتمد على الرسوم"; "fee__custom__shortDescription" = "يعتمد على الرسوم"; "fee__custom__range" = "يعتمد على الرسوم"; +"lightning__transfer_intro__title" = "رصيد\nالإنفاق"; +"lightning__transfer_intro__text" = "موّل رصيد إنفاقك للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار."; +"lightning__transfer_intro__button" = "ابدأ"; +"lightning__funding__text" = "أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة."; "lightning__funding__title" = "موّل رصيد إنفاقك"; "lightning__funding__button1" = "تحويل من المدخرات"; "lightning__funding__button2" = "استخدام محفظة أخرى"; "lightning__funding__button3" = "إعداد يدوي"; "lightning__funding_advanced__button_short" = "إعداد متقدم"; +"lightning__no_funds__title" = "لا توجد أموال متاحة"; +"lightning__no_funds__description" = "قبل أن تتمكن من تحويل الأموال من رصيد مدخراتك، تحتاج إلى إرسال Bitcoin إلى محفظة Bitkit الخاصة بك."; +"lightning__no_funds__fund" = "تمويل المحفظة"; +"lightning__spending_amount__title" = "تحويل\nإلى الإنفاق"; "lightning__transfer__nav_title" = "تحويل الأموال"; "lightning__transfer__confirm" = "يرجى\nالتأكيد"; "lightning__transfer__swipe" = "اسحب للتحويل"; @@ -182,15 +190,28 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "ربما تم بث معاملة التمويل. يرجى التحقق من نشاطك قبل المحاولة مرة أخرى."; "lightning__transfer_hw__reconnect_error_description" = "يرجى إعادة توصيل جهازك، يبدو أنه غير متصل بهاتفك."; "lightning__transfer_hw__reconnect_error_title" = "إعادة توصيل الجهاز"; +"lightning__transfer_hw__sign_title" = "وقّع باستخدام\nجهازك"; +"lightning__transfer_hw__signed_title" = "تم توقيع\nالمعاملة"; "lightning__transfer_to_savings_button" = "تحويل إلى المدخرات"; "lightning__transfer_to_spending_button" = "تحويل إلى الإنفاق"; +"lightning__spending_intro__title" = "تحويل\nإلى الإنفاق"; +"lightning__spending_intro__text" = "حوّل الأموال إلى رصيد إنفاقك للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار."; +"lightning__spending_intro__button" = "ابدأ"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "الحد الأقصى لرصيد الإنفاق"; "lightning__spending_amount__error_max__description" = "المبلغ الذي يمكنك تحويله إلى رصيد الإنفاق محدود حاليًا بـ ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "رسوم الشبكة"; "lightning__spending_confirm__lsp_fee" = "رسوم الخدمة"; "lightning__spending_confirm__amount" = "إلى الإنفاق"; "lightning__spending_confirm__total" = "الإجمالي"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "تغيّرت الرسوم"; +"lightning__spending_confirm__fees_changed_network" = "الرسوم أعلى بمقدار {amount} لأن معدلات الرسوم أو رصيد مدخراتك تغيّر أثناء وجودك في هذه الشاشة. اسحب مرة أخرى للتأكيد."; "lightning__savings_intro__title" = "تحويل\nإلى المدخرات"; "lightning__savings_intro__text" = "حوّل رصيد إنفاقك إلى مدخراتك لتخزين Bitcoin على المدى الطويل وتأمين مستقبلك."; "lightning__savings_intro__button" = "ابدأ"; @@ -202,6 +223,7 @@ "lightning__savings_confirm__amount_too_low" = "المبلغ منخفض جدًا للتحويل إلى المدخرات."; "lightning__savings_confirm__close_instead" = "إغلاق القناة بدلاً من ذلك"; "lightning__savings_confirm__network_fee" = "رسوم الشبكة"; +"lightning__savings_confirm__spark_fee" = "رسوم Spark"; "lightning__savings_confirm__receive" = "ستستلم"; "lightning__savings_confirm__service_fee" = "رسوم الخدمة"; "lightning__savings_advanced__title" = "اختر الأموال\nللتحويل"; @@ -214,6 +236,13 @@ "lightning__savings_interrupted__nav_title" = "تحويل\nمتقطع"; "lightning__savings_interrupted__title" = "أبقِ Bitkit\nمفتوحًا"; "lightning__savings_interrupted__text" = "لم تُحوَّل الأموال بعد. سيحاول Bitkit بدء التحويل خلال الـ 30 دقيقة القادمة. يرجى إبقاء التطبيق مفتوحًا."; +"lightning__savings_spark_failed__title" = "فشل\nالتحويل"; +"lightning__savings_spark_failed__text" = "تعذّر نقل رصيد Spark إلى المدخرات، ويبقى في رصيد الإنفاق."; +"lightning__savings_spark_failed__text_partial" = "يجري نقل أموال قنواتك إلى المدخرات، لكن تعذّر نقل رصيد Spark ويبقى في رصيد الإنفاق."; +"lightning__savings_spark_failed__toast" = "فشل تحويل Spark"; +"lightning__transfer_success__nav_title" = "نجح التحويل"; +"lightning__transfer_success__title_spending" = "انتقلت الأموال\nإلى الإنفاق"; +"lightning__transfer_success__text_spending" = "تم تحويل أموالك ورصيد إنفاقك جاهز للاستخدام."; "lightning__transfer_success__title_savings" = "انتقلت الأموال\nإلى المدخرات"; "lightning__transfer_success__text_savings" = "تم تحويل أموالك. يمكنك استخدام هذه الأموال فورًا."; "lightning__external__nav_title" = "اتصال Lightning"; @@ -232,6 +261,11 @@ "lightning__savings" = "المدخرات"; "lightning__spending_label" = "رصيد الإنفاق"; "lightning__receiving_label" = "سعة الاستلام"; +"lightning__setting_up_step1" = "جارٍ معالجة الدفع"; +"lightning__setting_up_step2" = "نجح الدفع"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "متابعة استخدام Bitkit"; "lightning__node_info" = "عقدة Lightning"; "lightning__connections" = "اتصالات Lightning"; "lightning__conn_pending" = "الاتصالات المعلقة"; @@ -258,6 +292,120 @@ "lightning__transaction" = "المعاملة"; "lightning__order_fee" = "رسوم الطلب"; "lightning__balance" = "الرصيد"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "رسوم المطالبة"; +"lightning__spark_confirmations" = "{count}/3 تأكيدات"; +"lightning__spark_deposit" = "إيداع"; +"lightning__spark_deposit__choice" = "يمكن لـ Spark إضافته {when} مقابل رسوم أعلى، أو بعد {standard} تأكيدات، أي بعد نحو {minutes} دقيقة."; +"lightning__spark_deposit__claim_early" = "استخدمه {when}"; +"lightning__spark_deposit__claimed" = "يضيفه Spark إلى رصيد الإنفاق."; +"lightning__spark_deposit__early_fee" = "رسوم الاستخدام {when}"; +"lightning__spark_deposit__later" = "بعد {count} تأكيدات"; +"lightning__spark_deposit__later_one" = "بعد تأكيد واحد"; +"lightning__spark_deposit__looking" = "بانتظار أن يرى Spark تحويلك."; +"lightning__spark_deposit__now" = "الآن"; +"lightning__spark_deposit__standard" = "يضيفه Spark إلى رصيد الإنفاق بعد {standard} تأكيدات، أي بعد نحو {minutes} دقيقة."; +"lightning__spark_deposit__standard_fee" = "رسوم الانتظار"; +"lightning__spark_deposit__wait_button" = "انتظر"; +"lightning__spark_deposit__waiting" = "يضيفه Spark إلى رصيد الإنفاق {when}."; +"lightning__spark_deposit__waiting_next" = "يضيفه Spark إلى رصيد الإنفاق بعد التأكيد التالي."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "إيداعات تمت المطالبة بها"; +"lightning__spark_deposits_pending" = "إيداعات قيد التنفيذ"; +"lightning__spark_provider" = "المزوّد"; +"lightning__spark_provider_value" = "Spark عبر Breez"; +"lightning__spark_receiving_unlimited" = "بلا حد"; +"lightning__spark_trust" = "يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة."; +"lightning__spark_exit__button" = "خروج طارئ"; +"lightning__spark_exit__nav_title" = "الخروج الطارئ"; +"lightning__spark_exit__intro_title" = "غادر Spark\nعلى السلسلة"; +"lightning__spark_exit__intro_text" = "استخدم هذا فقط إذا توقف Spark عن العمل لديك. ينقل رصيد الإنفاق إلى المدخرات من دون مشغّلي Spark.\n\nيستغرق حتى 4 أسابيع، ويكلّف رسومًا على السلسلة تُدفع من المدخرات، وقد تبقى مبالغ صغيرة جدًا. يعمل في الخلفية، ويُعلمك Bitkit إذا احتاج إليك."; +"lightning__spark_exit__quote_title" = "عرض\nالخروج"; +"lightning__spark_exit__amount" = "المبلغ المراد إخراجه"; +"lightning__spark_exit__fees" = "رسوم الشبكة"; +"lightning__spark_exit__receive" = "ستستلم نحو"; +"lightning__spark_exit__savings_fee" = "رسوم الدفع من المدخرات"; +"lightning__spark_exit__savings_refused" = "لم يكتمل دفعك من المدخرات. ادفع مرة أخرى، أو موّل من محفظة أخرى."; +"lightning__spark_exit__savings_total" = "الإجمالي من المدخرات"; +"lightning__spark_exit__funding" = "تمويل الرسوم المطلوب"; +"lightning__spark_exit__left_behind" = "يبقى على Spark"; +"lightning__spark_exit__outside_exit" = "{amount} على Spark ليس جزءًا من هذا الخروج ويبقى على Spark."; +"lightning__spark_exit__quote_loading" = "جارٍ الحصول على عرض…"; +"lightning__spark_exit__quote_settling" = "بانتظار أن ينتهي Spark من نقل رصيدك. قد يستغرق ذلك حتى 5 دقائق."; +"lightning__spark_exit__quote_error" = "تعذّر الحصول على عرض للخروج. حاول مرة أخرى."; +"lightning__spark_exit__nothing" = "لا يوجد ما يمكن إخراجه الآن."; +"lightning__spark_exit__uneconomic" = "الرسوم أعلى مما ستستلمه."; +"lightning__spark_exit__funding_title" = "موّل\nالرسوم"; +"lightning__spark_exit__funding_text" = "يدفع الخروج رسومه من دفعة منفصلة على السلسلة بقيمة {amount}. ما لا يُنفق منها يعود إلى المدخرات في النهاية."; +"lightning__spark_exit__fund_savings" = "ادفع من المدخرات"; +"lightning__spark_exit__fund_external" = "موّل من محفظة أخرى"; +"lightning__spark_exit__fund_external_text" = "أرسل {amount} على الأقل إلى هذا العنوان. يبدأ الخروج بمجرد تأكيدها."; +"lightning__spark_exit__fund_insufficient" = "لا تكفي مدخراتك لتغطية تمويل الرسوم."; +"lightning__spark_exit__fund_seen" = "تم رصد الدفعة."; +"lightning__spark_exit__fund_partial" = "تم استلام {received} من {required}، أرسل {missing} إضافية."; +"lightning__spark_exit__confirm_title" = "بدء\nالخروج؟"; +"lightning__spark_exit__confirm_text" = "لن تتمكن من استخدام هذه الأموال لمدة تصل إلى 4 أسابيع."; +"lightning__spark_exit__swipe" = "اسحب للخروج"; +"lightning__spark_exit__progress_title" = "الخروج\nقيد التنفيذ"; +"lightning__spark_exit__progress_text" = "يعمل هذا في الخلفية. يُعلمك Bitkit إذا احتاج إليك."; +"lightning__spark_exit__step_funding" = "تأكيد تمويل الرسوم"; +"lightning__spark_exit__step_publish" = "نشر معاملات الخروج"; +"lightning__spark_exit__step_timelock" = "انتظار القفل الزمني"; +"lightning__spark_exit__step_sweep" = "الإرسال إلى المدخرات"; +"lightning__spark_exit__ready_in" = "جاهز خلال {duration}"; +"lightning__spark_exit__done_title" = "اكتمل\nالخروج"; +"lightning__spark_exit__done_text" = "أموالك في المدخرات."; +"lightning__spark_exit__partial_text" = "بقيت {count} مبالغ صغيرة على Spark."; +"lightning__spark_exit__failed_leaves" = "نقل مشغّلو Spark مبلغ {amount} إلى السلسلة وهو ليس على Spark. لا يزال بإمكان المشغّلين تحريره لك."; +"lightning__spark_exit__leftover" = "لا يزال {amount} من تمويل الرسوم في {address}."; +"lightning__spark_exit__leftover_swept" = "أُرسل {amount} من تمويل الرسوم المتبقي إلى المدخرات."; +"lightning__spark_exit__leftover_sending" = "جارٍ إرسال {amount} من تمويل الرسوم المتبقي إلى المدخرات."; +"lightning__spark_exit__leftover_dust" = "{amount} من تمويل الرسوم المتبقي صغير جدًا بحيث لا يمكن إرساله ويبقى في {address}، مسار الاشتقاق {path}."; +"lightning__spark_exit__fund_paid" = "تم الدفع من المدخرات، بانتظار التأكيد."; +"lightning__spark_exit__fund_pending" = "تم رصد الدفعة، بانتظار التأكيد."; +"lightning__spark_exit__lost_leaves" = "طالب مالك سابق لهذه الأموال بمبلغ {amount} على السلسلة ولا يمكن استرداده."; +"lightning__spark_exit__notification_lost" = "لا يمكن استرداد {amount} من خروجك. افتح Bitkit لمعرفة السبب."; +"lightning__spark_exit__notification_failed" = "توقف خروجك الطارئ. افتح Bitkit لمعرفة السبب."; +"lightning__spark_exit__quote_outside" = "لا يمكن إخراج {amount} من رصيد Spark الآن: مبالغ أصغر من أن تدفع رسومها، أو لا تزال تتحرك داخل Spark. يبقى على Spark."; +"lightning__spark_exit__left_behind_leaves" = "لا يمكن إخراج {amount} بمعدل الرسوم هذا ويبقى على Spark."; +"lightning__spark_exit__needs_funding_title" = "مطلوب\nرسوم إضافية"; +"lightning__spark_exit__needs_funding_text" = "يحتاج الخروج إلى {amount} إضافية لدفع رسومه. موّله للمتابعة."; +"lightning__spark_exit__failed_title" = "توقف\nالخروج"; +"lightning__spark_exit__cancel" = "إلغاء الخروج"; +"lightning__spark_exit__failed_text" = "تعذّر استرداد أي شيء."; +"lightning__spark_exit__failed_stays" = "يبقى {amount} على Spark."; +"lightning__spark_exit__notification_title" = "خروج طارئ"; +"lightning__spark_exit__notification_body" = "افتح Bitkit لمتابعة خروجك."; +"lightning__spark_exit__notification_funding" = "يحتاج خروجك إلى رسوم إضافية للمتابعة. افتح Bitkit لتمويله."; +"lightning__spark_exit__bg_refresh_off" = "تحديث التطبيقات في الخلفية متوقف لـ Bitkit، لذا لا يتقدم الخروج إلا عندما يكون Bitkit مفتوحًا. فعّله في الإعدادات ليعمل في الخلفية."; +"lightning__spark_exit__low_power" = "يوقف وضع الطاقة المنخفضة العمل في الخلفية مؤقتًا. يتابع الخروج بمجرد إيقافه، أو عند فتح Bitkit."; +"lightning__spark_exit__notifications_off" = "الإشعارات متوقفة، لذا لا يستطيع Bitkit إعلامك عندما يحتاج إليك الخروج. فعّلها في الإعدادات لتلقي التذكيرات."; +"lightning__spark_exit__not_moving" = "الخروج لا يتقدم: {error}"; +"lightning__spark_exit__fee_low" = "الشبكة أكثر ازدحامًا من معدل رسوم هذا الخروج البالغ {rate} sat/vB. ادفع رسومًا أعلى، أو انتظر انخفاض الرسوم."; +"lightning__spark_exit__waiting_leaves" = "تتعذّر قراءة جزء من هذا الخروج على السلسلة الآن. يحاول Bitkit مجددًا لمدة تصل إلى أسبوع."; +"lightning__spark_exit__rebuilt_from_chain" = "تعذّر على Bitkit قراءة التقدم المحفوظ لهذا الخروج، لذا يعيد بناءه من السلسلة."; +"lightning__spark_exit__done_received" = "وصل {amount} إلى مدخراتك."; +"lightning__spark_exit__leftover_rejected" = "تعذّر إرسال {amount} من تمويل الرسوم المتبقي ويبقى في {address}، مسار الاشتقاق {path}."; +"lightning__spark_exit__notification_not_moving" = "خروجك الطارئ لا يتقدم. افتح Bitkit لمعرفة السبب."; +"lightning__spark_exit__funding_split" = "يقسّمه Bitkit إلى {count} أجزاء، جزء لكل خطوة، حتى لا تعطّل خطوة عالقة الخطوات الأخرى."; +"lightning__spark_exit__waiting_operators" = "{amount} الذي نقله مشغّلو Spark إلى السلسلة ينتظر أن يحرروه. يطلب Bitkit ذلك مجددًا لمدة تصل إلى أسبوع."; +"lightning__spark_exit__fee_rose" = "ارتفعت رسوم الشبكة فوق رسوم هذا الخروج. قد يتباطأ."; +"lightning__spark_exit__fee_bump_button" = "ادفع رسومًا أعلى"; +"lightning__spark_exit__fee_bump_title" = "ادفع\nرسومًا أعلى"; +"lightning__spark_exit__fee_bump_text" = "يدفع هذا الخروج {rate} sat/vB، وهو أقل مما تطلبه الشبكة الآن. اختر معدلًا أعلى وسيعيد Bitkit إرسال الخطوات المنتظرة. يدفع الفرق من المدخرات: المبلغ الظاهر بجانب كل سرعة."; +"lightning__spark_exit__fee_bump_error" = "تعذّر تسعير رسوم أعلى. حاول مرة أخرى."; +"lightning__spark_exit__fee_bump_confirm" = "استخدم هذه الرسوم"; +"lightning__spark_exit__leftover_waiting" = "سيصل {amount} من فكة الرسوم إلى مدخراتك عند انخفاض رسوم الشبكة."; +"lightning__spark_exit__cancelled_title" = "أُلغي\nالخروج"; +"lightning__spark_exit__cancelled_text" = "لقد ألغيت الخروج. يعود تمويل الرسوم الذي وصل إلى المدخرات."; +"lightning__spark_exit__cancel_failed" = "تعذّر التحقق من عنوان التمويل. حاول مرة أخرى."; +"lightning__spark_exit__fee_bump_leaves_behind" = "قد تترك رسوم أعلى جزءًا من هذا الخروج على Spark، لذا لا يُعرض أي منها. يتأكد الخروج بمجرد انخفاض رسوم الشبكة."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "رصيد الاحتياطي (غير قابل للإنفاق)"; "lightning__total_size" = "الحجم الإجمالي للقناة"; "lightning__fees" = "الرسوم"; @@ -693,6 +841,7 @@ "settings__backup__category_transaction_log" = "سجل المعاملات"; "settings__backup__category_wallet" = "التعزيزات والتحويلات"; "settings__backup__category_settings" = "الإعدادات"; +"settings__backup__category_spark_exit" = "بيانات الخروج من Spark"; "settings__backup__category_widgets" = "الأدوات"; "settings__backup__category_tags" = "العلامات"; "wallet__payment_recipient_rejected" = "رفض المستلم دفعة Lightning هذه. تحقق من الفاتورة وحاول مرة أخرى."; @@ -761,6 +910,14 @@ "settings__status__lightning_connection__ready" = "مفتوح"; "settings__status__lightning_connection__pending" = "جارٍ الفتح..."; "settings__status__lightning_connection__error" = "لا توجد اتصالات مفتوحة"; +"settings__status__lightning_connection__spark" = "يستخدم Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "متصل"; +"settings__status__spark__pending" = "جارٍ الاتصال..."; +"settings__status__spark__error" = "غير متصل"; +"settings__status__exit__title" = "الخروج الطارئ"; +"settings__status__exit__pending" = "قيد التنفيذ"; +"settings__status__exit__funding" = "تمويل الرسوم المطلوب"; "settings__status__backup__title" = "آخر نسخة احتياطية كاملة للبيانات"; "settings__status__backup__error" = "فشل إكمال نسخة احتياطية كاملة"; "settings__adv__section_debug" = "تصحيح"; @@ -1008,6 +1165,9 @@ "wallet__send_sync_long_title" = "مشاكل\nالاتصال"; "wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ."; "wallet__send_to" = "إلى"; +"wallet__send_swap_route" = "المسار"; +"wallet__send_paykit_no_route__title" = "تعذّر الدفع لجهة الاتصال هذه"; +"wallet__send_paykit_no_route__description" = "ليس لدى جهة الاتصال هذه طريقة دفع يمكن لـ Bitkit استخدامها حاليًا."; "wallet__recipient_contact" = "جهة الاتصال"; "wallet__recipient_invoice" = "لصق فاتورة"; "wallet__recipient_manual" = "إدخال يدوي"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 9f5b5f194..d0d4122cb 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__slow__longRange" = "± 1-2 hores"; "fee__custom__longTitle" = "Personalitzat"; "fee__custom__longRange" = "Depèn de la tarifa"; +"lightning__transfer_intro__title" = "Saldo de\ndespesa"; +"lightning__transfer_intro__text" = "Finança el teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants."; +"lightning__transfer_intro__button" = "Començar"; "lightning__funding__nav_title" = "Saldo de despesa"; +"lightning__funding__text" = "La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera."; "lightning__funding__title" = "Finança el teu saldo de despesa"; "lightning__funding__button1" = "Transferir des d\'estalvis"; "lightning__funding__button2" = "Utilitza un altre moneder"; "lightning__funding__button3" = "Configuració manual"; "lightning__funding_advanced__button_short" = "Configuració avançada"; +"lightning__no_funds__title" = "No hi ha fons disponibles"; +"lightning__no_funds__description" = "Abans de poder transferir fons del teu saldo d'estalvis, has d'enviar bitcoin a la teva cartera Bitkit."; +"lightning__no_funds__fund" = "Finançar cartera"; +"lightning__spending_amount__title" = "Transferir\na despesa"; "lightning__transfer__nav_title" = "Transferir fons"; "lightning__transfer__confirm" = "Si us plau\nconfirma"; "lightning__transfer__swipe" = "Llisca per transferir"; @@ -208,16 +216,29 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "És possible que la teva transacció de finançament s'hagi difós. Comprova la teva activitat abans de tornar-ho a provar."; "lightning__transfer_hw__reconnect_error_description" = "Torna a connectar el teu dispositiu de maquinari; sembla que s'ha desconnectat del telèfon."; "lightning__transfer_hw__reconnect_error_title" = "Torna a connectar el dispositiu"; +"lightning__transfer_hw__sign_title" = "Signa amb\nel teu dispositiu"; +"lightning__transfer_hw__signed_title" = "Transacció\nsignada"; "lightning__transfer_to_savings_button" = "Transferir a estalvis"; "lightning__transfer_to_spending_button" = "Transferir a despesa"; +"lightning__spending_intro__title" = "Transferir\na despesa"; +"lightning__spending_intro__text" = "Transfereix fons al teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants."; +"lightning__spending_intro__button" = "Començar"; "lightning__transfer_failed__continue" = "Torna a intentar"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Màxim del saldo de despesa"; "lightning__spending_amount__error_max__description" = "L'import que pots transferir al teu saldo de despesa està limitat a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Comissions de xarxa"; "lightning__spending_confirm__lsp_fee" = "Comissions de servei"; "lightning__spending_confirm__amount" = "A despesa"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Les tarifes han canviat"; +"lightning__spending_confirm__fees_changed_network" = "Les tarifes són {amount} més altes perquè les taxes de tarifa o el teu saldo d'estalvis han canviat mentre eres en aquesta pantalla. Torna a desplaçar per confirmar."; "lightning__savings_intro__title" = "Transferir\na estalvis"; "lightning__savings_intro__text" = "Transfereix el teu saldo de despesa als teus estalvis per emmagatzemar els teus Bitcoin a llarg termini i assegurar el teu futur."; "lightning__savings_intro__button" = "Començar"; @@ -229,6 +250,7 @@ "lightning__savings_confirm__amount_too_low" = "L'import és massa baix per transferir-lo a estalvis."; "lightning__savings_confirm__close_instead" = "Tancar el canal"; "lightning__savings_confirm__network_fee" = "Tarifa de xarxa"; +"lightning__savings_confirm__spark_fee" = "Tarifa de Spark"; "lightning__savings_confirm__receive" = "Rebràs"; "lightning__savings_confirm__service_fee" = "Tarifa de servei"; "lightning__savings_advanced__title" = "Selecciona fons\nper transferir"; @@ -241,6 +263,13 @@ "lightning__savings_interrupted__nav_title" = "Transferència\ninterrompuda"; "lightning__savings_interrupted__title" = "Mantén Bitkit\nobert"; "lightning__savings_interrupted__text" = "Els fons encara no s\'han transferit. Bitkit intentarà iniciar la transferència durant els pròxims 30 minuts. Si us plau, mantén l\'aplicació oberta."; +"lightning__savings_spark_failed__title" = "Transferència\nfallida"; +"lightning__savings_spark_failed__text" = "El teu saldo Spark no s'ha pogut moure a l'estalvi i es queda al teu saldo de despesa."; +"lightning__savings_spark_failed__text_partial" = "Els fons dels teus canals s'estan movent a l'estalvi, però el teu saldo Spark no s'ha pogut moure i es queda al teu saldo de despesa."; +"lightning__savings_spark_failed__toast" = "Transferència Spark fallida"; +"lightning__transfer_success__nav_title" = "Transferència realitzada"; +"lightning__transfer_success__title_spending" = "Fons transferits\na despesa"; +"lightning__transfer_success__text_spending" = "Els teus fons s\'han transferit i el teu saldo de despesa està llest per utilitzar."; "lightning__transfer_success__title_savings" = "Fons transferits\na estalvis"; "lightning__transfer_success__text_savings" = "Els teus fons s\'han transferit. Pots utilitzar aquests fons immediatament."; "lightning__external__nav_title" = "Connexió Lightning"; @@ -261,6 +290,11 @@ "lightning__savings" = "Estalvis"; "lightning__spending_label" = "Saldo de despesa"; "lightning__receiving_label" = "Capacitat de recepció"; +"lightning__setting_up_step1" = "Processant pagament"; +"lightning__setting_up_step2" = "Pagament realitzat amb èxit"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continua utilitzant Bitkit"; "lightning__node_info" = "Node Lightning"; "lightning__node_id" = "ID de Node LDK"; "lightning__node_disconnected" = "desconnectat"; @@ -295,6 +329,120 @@ "lightning__transaction" = "Transacció"; "lightning__order_fee" = "Comissió de comanda"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Comissió de reclamació"; +"lightning__spark_confirmations" = "{count}/3 confirmacions"; +"lightning__spark_deposit" = "Dipòsit"; +"lightning__spark_deposit__choice" = "Spark pot afegir-ho {when} per una comissió més alta, o després de {standard} confirmacions, d'aquí a uns {minutes} minuts."; +"lightning__spark_deposit__claim_early" = "Fes-ho servir {when}"; +"lightning__spark_deposit__claimed" = "Spark ho està afegint al teu saldo de despeses."; +"lightning__spark_deposit__early_fee" = "Comissió per fer-ho servir {when}"; +"lightning__spark_deposit__later" = "després de {count} confirmacions"; +"lightning__spark_deposit__later_one" = "després d'1 confirmació"; +"lightning__spark_deposit__looking" = "Esperant que Spark vegi la teva transferència."; +"lightning__spark_deposit__now" = "ara"; +"lightning__spark_deposit__standard" = "Spark ho afegeix al teu saldo de despeses després de {standard} confirmacions, d'aquí a uns {minutes} minuts."; +"lightning__spark_deposit__standard_fee" = "Comissió per esperar"; +"lightning__spark_deposit__wait_button" = "Espera"; +"lightning__spark_deposit__waiting" = "Spark ho afegeix al teu saldo de despeses {when}."; +"lightning__spark_deposit__waiting_next" = "Spark ho afegeix al teu saldo de despeses després de la confirmació següent."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Dipòsits reclamats"; +"lightning__spark_deposits_pending" = "Dipòsits en curs"; +"lightning__spark_provider" = "Proveïdor"; +"lightning__spark_provider_value" = "Spark mitjançant Breez"; +"lightning__spark_receiving_unlimited" = "Sense límit"; +"lightning__spark_trust" = "Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain."; +"lightning__spark_exit__button" = "Sortida d'emergència"; +"lightning__spark_exit__nav_title" = "Sortida d'emergència"; +"lightning__spark_exit__intro_title" = "Surt de Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Fes-ho servir només si Spark deixa de funcionar-te. Mou el teu saldo de despesa a estalvis sense els operadors de Spark.\n\nTriga fins a 4 setmanes, té comissions on-chain pagades des d'estalvis, i és possible que quedin enrere quantitats molt petites. S'executa en segon pla i Bitkit t'avisa si et necessita."; +"lightning__spark_exit__quote_title" = "Pressupost\nde sortida"; +"lightning__spark_exit__amount" = "Import a retirar"; +"lightning__spark_exit__fees" = "Comissions de xarxa"; +"lightning__spark_exit__receive" = "Rebràs aproximadament"; +"lightning__spark_exit__savings_fee" = "Comissió del pagament des d'estalvis"; +"lightning__spark_exit__savings_refused" = "El teu pagament dels estalvis no s'ha completat. Torna a pagar o finança des d'un altre moneder."; +"lightning__spark_exit__savings_total" = "Total des d'estalvis"; +"lightning__spark_exit__funding" = "Finançament de comissions necessari"; +"lightning__spark_exit__left_behind" = "Es queda a Spark"; +"lightning__spark_exit__outside_exit" = "{amount} a Spark no forma part d'aquesta sortida i es queda a Spark."; +"lightning__spark_exit__quote_loading" = "Obtenint un pressupost…"; +"lightning__spark_exit__quote_settling" = "Esperant que Spark acabi de moure el teu saldo. Pot trigar fins a 5 minuts."; +"lightning__spark_exit__quote_error" = "No s'ha pogut obtenir un pressupost de sortida. Torna-ho a provar."; +"lightning__spark_exit__nothing" = "Ara mateix no es pot retirar res."; +"lightning__spark_exit__uneconomic" = "Les comissions són més altes que el que rebries."; +"lightning__spark_exit__funding_title" = "Finança les\ncomissions"; +"lightning__spark_exit__funding_text" = "La sortida paga les comissions amb un pagament on-chain separat de {amount}. El que no gasti torna a estalvis al final."; +"lightning__spark_exit__fund_savings" = "Paga des d'estalvis"; +"lightning__spark_exit__fund_external" = "Finança des d'una altra cartera"; +"lightning__spark_exit__fund_external_text" = "Envia almenys {amount} a aquesta adreça. La sortida comença quan es confirmi."; +"lightning__spark_exit__fund_insufficient" = "Els teus estalvis no poden cobrir el finançament de comissions."; +"lightning__spark_exit__fund_seen" = "Pagament detectat."; +"lightning__spark_exit__fund_partial" = "S'han rebut {received} de {required}, envia'n {missing} més."; +"lightning__spark_exit__confirm_title" = "Iniciar la\nsortida?"; +"lightning__spark_exit__confirm_text" = "No podràs fer servir aquests fons durant fins a 4 setmanes."; +"lightning__spark_exit__swipe" = "Llisca per sortir"; +"lightning__spark_exit__progress_title" = "Sortida\nen curs"; +"lightning__spark_exit__progress_text" = "S'executa en segon pla. Bitkit t'avisa si et necessita."; +"lightning__spark_exit__step_funding" = "Confirmació del finançament de comissions"; +"lightning__spark_exit__step_publish" = "Publicant les transaccions de sortida"; +"lightning__spark_exit__step_timelock" = "Esperant el bloqueig temporal"; +"lightning__spark_exit__step_sweep" = "Enviant a estalvis"; +"lightning__spark_exit__ready_in" = "A punt d'aquí a {duration}"; +"lightning__spark_exit__done_title" = "Sortida\ncompletada"; +"lightning__spark_exit__done_text" = "Els teus fons són a estalvis."; +"lightning__spark_exit__partial_text" = "{count} quantitats petites s'han quedat a Spark."; +"lightning__spark_exit__failed_leaves" = "Els operadors de Spark han mogut {amount} on-chain i no és a Spark. Els operadors encara te'ls poden alliberar."; +"lightning__spark_exit__leftover" = "{amount} del finançament de comissions encara és a {address}."; +"lightning__spark_exit__leftover_swept" = "S'han enviat a estalvis {amount} del finançament de comissions sobrant."; +"lightning__spark_exit__leftover_sending" = "Enviant a estalvis {amount} del finançament de comissions sobrant."; +"lightning__spark_exit__leftover_dust" = "{amount} del finançament de comissions sobrant és massa poc per enviar i es queda a {address}, camí de derivació {path}."; +"lightning__spark_exit__fund_paid" = "Pagat des d'estalvis, esperant la confirmació."; +"lightning__spark_exit__fund_pending" = "Pagament detectat, esperant la confirmació."; +"lightning__spark_exit__lost_leaves" = "Un propietari anterior d'aquests fons ha reclamat {amount} on-chain i no es pot recuperar."; +"lightning__spark_exit__notification_lost" = "{amount} de la teva sortida no es pot recuperar. Obre Bitkit per saber per què."; +"lightning__spark_exit__notification_failed" = "La teva sortida d'emergència s'ha aturat. Obre Bitkit per saber per què."; +"lightning__spark_exit__quote_outside" = "{amount} del teu saldo Spark no es pot retirar ara mateix: quantitats massa petites per pagar les seves pròpies comissions, o que encara s'estan movent dins de Spark. Es queda a Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} no es pot retirar amb aquesta taxa de comissió i es queda a Spark."; +"lightning__spark_exit__needs_funding_title" = "Calen més\ncomissions"; +"lightning__spark_exit__needs_funding_text" = "La sortida necessita {amount} més per pagar les comissions. Finança-la per continuar."; +"lightning__spark_exit__failed_title" = "Sortida\naturada"; +"lightning__spark_exit__cancel" = "Cancel·la la sortida"; +"lightning__spark_exit__failed_text" = "No s'ha pogut recuperar res."; +"lightning__spark_exit__failed_stays" = "{amount} es queda a Spark."; +"lightning__spark_exit__notification_title" = "Sortida d'emergència"; +"lightning__spark_exit__notification_body" = "Obre Bitkit per continuar la teva sortida."; +"lightning__spark_exit__notification_funding" = "La teva sortida necessita més comissions per continuar. Obre Bitkit per finançar-la."; +"lightning__spark_exit__bg_refresh_off" = "L'actualització en segon pla està desactivada per a Bitkit, així que la sortida només avança mentre Bitkit està obert. Activa-la a Configuració perquè s'executi en segon pla."; +"lightning__spark_exit__low_power" = "El mode de baix consum pausa la feina en segon pla. La sortida continua quan es desactiva, o quan obres Bitkit."; +"lightning__spark_exit__notifications_off" = "Les notificacions estan desactivades, així que Bitkit no et pot avisar quan la sortida et necessiti. Activa-les a Configuració per rebre recordatoris."; +"lightning__spark_exit__not_moving" = "La sortida no avança: {error}"; +"lightning__spark_exit__fee_low" = "La xarxa està més saturada que la taxa de comissió d'aquesta sortida, de {rate} sat/vB. Paga una comissió més alta o espera que baixin les comissions."; +"lightning__spark_exit__waiting_leaves" = "Ara mateix no es pot llegir on-chain una part d'aquesta sortida. Bitkit ho torna a provar durant una setmana com a màxim."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no ha pogut llegir el progrés desat d'aquesta sortida, així que el reconstrueix a partir de la cadena."; +"lightning__spark_exit__done_received" = "{amount} ha arribat als teus estalvis."; +"lightning__spark_exit__leftover_rejected" = "{amount} del finançament de comissions sobrant no s'ha pogut enviar i es queda a {address}, camí de derivació {path}."; +"lightning__spark_exit__notification_not_moving" = "La teva sortida d'emergència no avança. Obre Bitkit per saber per què."; +"lightning__spark_exit__funding_split" = "Bitkit el divideix en {count} parts, una per a cada pas, perquè un pas encallat no pugui retenir els altres."; +"lightning__spark_exit__waiting_operators" = "{amount} que els operadors de Spark han mogut on-chain espera que l'alliberin. Bitkit ho torna a demanar durant una setmana com a màxim."; +"lightning__spark_exit__fee_rose" = "Les comissions de xarxa han pujat per sobre de la comissió d'aquesta sortida. Pot alentir-se."; +"lightning__spark_exit__fee_bump_button" = "Paga una comissió més alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncomissió més alta"; +"lightning__spark_exit__fee_bump_text" = "Aquesta sortida paga {rate} sat/vB, menys del que la xarxa demana ara. Tria una taxa més alta i Bitkit tornarà a enviar els passos pendents. Paga la diferència des d'estalvis: l'import al costat de cada velocitat."; +"lightning__spark_exit__fee_bump_error" = "No s'ha pogut calcular el preu d'una comissió més alta. Torna-ho a provar."; +"lightning__spark_exit__fee_bump_confirm" = "Fes servir aquesta comissió"; +"lightning__spark_exit__leftover_waiting" = "{amount} de canvi de comissions arribarà als teus estalvis quan baixin les comissions de xarxa."; +"lightning__spark_exit__cancelled_title" = "Sortida\ncancel·lada"; +"lightning__spark_exit__cancelled_text" = "Has cancel·lat la sortida. El finançament de comissions que ha arribat torna a estalvis."; +"lightning__spark_exit__cancel_failed" = "No s'ha pogut comprovar l'adreça de finançament. Torna-ho a provar."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una comissió més alta deixaria part d'aquesta sortida a Spark, així que no se n'ofereix cap. La sortida es confirma quan baixin les comissions de xarxa."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (no gastable)"; "lightning__total_size" = "Mida total del canal"; "lightning__fees" = "Comissions"; @@ -789,6 +937,7 @@ "settings__backup__category_transaction_log" = "Registre de transaccions"; "settings__backup__category_wallet" = "Impulsos i transferències"; "settings__backup__category_settings" = "Configuració"; +"settings__backup__category_spark_exit" = "Dades de sortida de Spark"; "settings__backup__category_widgets" = "Ginys"; "settings__backup__category_tags" = "Etiquetes"; "settings__backup__category_profile" = "Perfil"; @@ -828,6 +977,14 @@ "settings__status__lightning_connection__ready" = "Obrir"; "settings__status__lightning_connection__pending" = "Obrint ..."; "settings__status__lightning_connection__error" = "No hi ha connexions obertes"; +"settings__status__lightning_connection__spark" = "Utilitzant Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connectat"; +"settings__status__spark__pending" = "Conectant ..."; +"settings__status__spark__error" = "Fora de línia"; +"settings__status__exit__title" = "Sortida d'emergència"; +"settings__status__exit__pending" = "En curs"; +"settings__status__exit__funding" = "Finançament de comissions necessari"; "settings__status__backup__title" = "Última còpia de seguretat completa de dades"; "settings__status__backup__error" = "No s\'ha pogut completar una còpia de seguretat completa"; "settings__adv__section_debug" = "Debug"; @@ -1127,6 +1284,9 @@ "wallet__send_sync_long_title" = "Problemes de\nconnexió"; "wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons."; "wallet__send_to" = "A"; +"wallet__send_swap_route" = "Ruta"; +"wallet__send_paykit_no_route__title" = "No es pot pagar aquest contacte"; +"wallet__send_paykit_no_route__description" = "Aquest contacte no té cap mètode de pagament que Bitkit pugui fer servir ara mateix."; "wallet__recipient_contact" = "Contacte"; "wallet__recipient_invoice" = "Enganxa factura"; "wallet__recipient_manual" = "Introdueix manualment"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index df7eb8272..0bdf79f47 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Odvíjí se od poplatku"; "fee__custom__shortRange" = "Odvíjí se od poplatku"; "fee__custom__longRange" = "Odvíjí se od poplatku"; +"lightning__transfer_intro__title" = "Disponibilní\nZůstatek"; +"lightning__transfer_intro__text" = "Navyšte svůj disponibilní zůstatek a užívejte si okamžité a levné transakce s přáteli, rodinou a obchodníky."; +"lightning__transfer_intro__button" = "Začít"; "lightning__funding__nav_title" = "Dostupný zůstatek"; +"lightning__funding__text" = "Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky."; "lightning__funding__title" = "Navyšte svůj disponibilní zůstatek"; "lightning__funding__button1" = "Převod z úspor"; "lightning__funding__button2" = "Použijte jinou peněženku"; "lightning__funding__button3" = "Manuální nastavení"; "lightning__funding_advanced__button_short" = "Pokročilé nastavení"; +"lightning__no_funds__title" = "Žádné dostupné prostředky"; +"lightning__no_funds__description" = "Než budete moci převést prostředky ze svého zůstatku na spořicím účtu, musíte poslat bitcoiny do peněženky Bitkit."; +"lightning__no_funds__fund" = "Nabít peněženku"; +"lightning__spending_amount__title" = "Převést\ndo výdajů"; "lightning__transfer__nav_title" = "Převést prostředky"; "lightning__transfer__confirm" = "Prosím,\npotvrďte"; "lightning__transfer__custom_fee" = "Vlastní poplatek"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Vaše financující transakce mohla být odeslána do sítě. Než to zkusíte znovu, zkontrolujte prosím svou aktivitu."; "lightning__transfer_hw__reconnect_error_description" = "Připojte prosím znovu své hardwarové zařízení, zdá se, že je od telefonu odpojené."; "lightning__transfer_hw__reconnect_error_title" = "Znovu připojte hardwarové zařízení"; +"lightning__transfer_hw__sign_title" = "Podepište\nsvým zařízením"; +"lightning__transfer_hw__signed_title" = "Transakce\npodepsána"; "lightning__transfer_to_savings_button" = "Převést do úspor"; "lightning__transfer_to_spending_button" = "Převést do útraty"; +"lightning__spending_intro__title" = "Převést\ndo výdajů"; +"lightning__spending_intro__text" = "Převeďte peníze na svůj disponibilní zůstatek a využívejte okamžité a levné transakce s přáteli, rodinou a obchodníky."; +"lightning__spending_intro__button" = "Začít"; "lightning__transfer_failed__nav_title" = "Selhání převodu"; "lightning__transfer_failed__title" = "Převod\nneúspěšný"; "lightning__transfer_failed__text" = "Omlouváme se, ale Bitkit se nepodařilo nastavit váš disponibilní zůstatek nebo dokončit převod. Zkuste to prosím znovu."; @@ -221,11 +234,19 @@ "lightning__spending_amount__error_min__description" = "K vytvoření disponibilního zůstatku je zapotřebí minimálně ₿ {amount}."; "lightning__spending_amount__error_max__title" = "Maximální disponibilní zůstatek"; "lightning__spending_amount__error_max__description" = "Částka, kterou můžete převést na svůj disponibilní zůstatek, je v současné době omezena na ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Váš převod do disponibilního zůstatku je omezen z důvodu pravidel likvidity. Podrobnosti naleznete v Centru nápovědy."; "lightning__spending_confirm__network_fee" = "Síťové poplatky"; "lightning__spending_confirm__lsp_fee" = "Poplatky za služby"; "lightning__spending_confirm__amount" = "Do výdajů"; "lightning__spending_confirm__total" = "Celkem"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Poplatky se změnily"; +"lightning__spending_confirm__fees_changed_network" = "Poplatky jsou o {amount} vyšší, protože se během zobrazení této obrazovky změnily sazby poplatků nebo zůstatek vašich úspor. Pro potvrzení přejeďte znovu."; "lightning__savings_intro__title" = "Převést\ndo úspor"; "lightning__savings_intro__text" = "Převeďte si disponibilní zůstatek na spoření, abyste si Bitcoiny uložili na delší dobu a zajistili si budoucnost."; "lightning__savings_intro__button" = "Začít"; @@ -237,6 +258,7 @@ "lightning__savings_confirm__amount_too_low" = "Částka je příliš nízká pro převod do úspor."; "lightning__savings_confirm__close_instead" = "Místo toho uzavřít kanál"; "lightning__savings_confirm__network_fee" = "Síťový poplatek"; +"lightning__savings_confirm__spark_fee" = "Poplatek Spark"; "lightning__savings_confirm__receive" = "Obdržíte"; "lightning__savings_confirm__service_fee" = "Poplatek za službu"; "lightning__savings_advanced__title" = "Vyberte prostředky\nk převodu"; @@ -249,6 +271,13 @@ "lightning__savings_interrupted__nav_title" = "Převod\npřerušen"; "lightning__savings_interrupted__title" = "Ponechte Bitkit\notevřený"; "lightning__savings_interrupted__text" = "Prostředky dosud nebyly převedeny. Bitkit se pokusí převod iniciovat během následujících 30 minut. Nechte prosím aplikaci otevřenou."; +"lightning__savings_spark_failed__title" = "Převod\nneúspěšný"; +"lightning__savings_spark_failed__text" = "Zůstatek Spark se nepodařilo přesunout do úspor a zůstává ve vašem zůstatku na útratu."; +"lightning__savings_spark_failed__text_partial" = "Prostředky z kanálů se přesouvají do úspor, ale zůstatek Spark se přesunout nepodařilo a zůstává ve vašem zůstatku na útratu."; +"lightning__savings_spark_failed__toast" = "Převod Spark selhal"; +"lightning__transfer_success__nav_title" = "Přenos úspěšný"; +"lightning__transfer_success__title_spending" = "Prostředky převedeny\ndo výdajů"; +"lightning__transfer_success__text_spending" = "Vaše prostředky byly převedeny a disponibilní zůstatek je připraven k použití."; "lightning__transfer_success__title_savings" = "Prostředky převedeny\ndo úspor"; "lightning__transfer_success__text_savings" = "Vaše prostředky byly převedeny. Tyto prostředky můžete okamžitě použít."; "lightning__external__nav_title" = "Připojení lightning"; @@ -271,6 +300,11 @@ "lightning__spending_label" = "Disponibilní zůstatek"; "lightning__receiving_label" = "Kapacita příjmu"; "lightning__setting_up_header" = "V převodu"; +"lightning__setting_up_step1" = "Zpracování platby"; +"lightning__setting_up_step2" = "Platba úspěšně proběhla"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Pokračujte v používání Bitkit"; "lightning__node_info" = "Lightning uzel"; "lightning__node_id" = "ID uzlu LDK"; "lightning__node_disconnected" = "Odpojeno"; @@ -311,6 +345,120 @@ "lightning__transaction" = "Transakce"; "lightning__order_fee" = "Poplatek za objednávku"; "lightning__balance" = "Zůstatek"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Poplatek za připsání"; +"lightning__spark_confirmations" = "{count}/3 potvrzení"; +"lightning__spark_deposit" = "Vklad"; +"lightning__spark_deposit__choice" = "Spark ji může připsat {when} za vyšší poplatek, nebo po {standard} potvrzeních, asi za {minutes} minut."; +"lightning__spark_deposit__claim_early" = "Použít {when}"; +"lightning__spark_deposit__claimed" = "Spark ji připisuje na váš zůstatek k utrácení."; +"lightning__spark_deposit__early_fee" = "Poplatek za použití {when}"; +"lightning__spark_deposit__later" = "po {count} potvrzeních"; +"lightning__spark_deposit__later_one" = "po 1 potvrzení"; +"lightning__spark_deposit__looking" = "Čekáme, až Spark uvidí váš převod."; +"lightning__spark_deposit__now" = "hned"; +"lightning__spark_deposit__standard" = "Spark ji připíše na váš zůstatek k utrácení po {standard} potvrzeních, asi za {minutes} minut."; +"lightning__spark_deposit__standard_fee" = "Poplatek za čekání"; +"lightning__spark_deposit__wait_button" = "Počkat"; +"lightning__spark_deposit__waiting" = "Spark ji připíše na váš zůstatek k utrácení {when}."; +"lightning__spark_deposit__waiting_next" = "Spark ji připíše na váš zůstatek k utrácení po dalším potvrzení."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Připsané vklady"; +"lightning__spark_deposits_pending" = "Probíhající vklady"; +"lightning__spark_provider" = "Poskytovatel"; +"lightning__spark_provider_value" = "Spark přes Breez"; +"lightning__spark_receiving_unlimited" = "Bez limitu"; +"lightning__spark_trust" = "Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup."; +"lightning__spark_exit__button" = "Nouzový výstup"; +"lightning__spark_exit__nav_title" = "Nouzový výstup"; +"lightning__spark_exit__intro_title" = "Opusťte Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Použijte to jen tehdy, když vám Spark přestane fungovat. Přesune váš zůstatek k utrácení do úspor bez operátorů Spark.\n\nTrvá to až 4 týdny, stojí to on-chain poplatky placené z úspor a velmi malé částky mohou zůstat. Běží to na pozadí a Bitkit vás upozorní, pokud vás bude potřebovat."; +"lightning__spark_exit__quote_title" = "Nabídka\nvýstupu"; +"lightning__spark_exit__amount" = "Částka k výstupu"; +"lightning__spark_exit__fees" = "Síťové poplatky"; +"lightning__spark_exit__receive" = "Obdržíte přibližně"; +"lightning__spark_exit__savings_fee" = "Poplatek za platbu z úspor"; +"lightning__spark_exit__savings_refused" = "Vaše platba z úspor neprošla. Zaplaťte znovu, nebo financujte z jiné peněženky."; +"lightning__spark_exit__savings_total" = "Celkem z úspor"; +"lightning__spark_exit__funding" = "Potřebné krytí poplatků"; +"lightning__spark_exit__left_behind" = "Zůstává na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark není součástí tohoto výstupu a zůstává na Spark."; +"lightning__spark_exit__quote_loading" = "Získáváme nabídku…"; +"lightning__spark_exit__quote_settling" = "Čekáme, až Spark dokončí přesun vašeho zůstatku. Může to trvat až 5 minut."; +"lightning__spark_exit__quote_error" = "Nabídku výstupu se nepodařilo získat. Zkuste to znovu."; +"lightning__spark_exit__nothing" = "Momentálně nelze nic vyvést."; +"lightning__spark_exit__uneconomic" = "Poplatky jsou vyšší než částka, kterou byste obdrželi."; +"lightning__spark_exit__funding_title" = "Uhraďte\npoplatky"; +"lightning__spark_exit__funding_text" = "Výstup platí své poplatky ze samostatné on-chain platby ve výši {amount}. Co neutratí, se na konci vrátí do úspor."; +"lightning__spark_exit__fund_savings" = "Zaplatit z úspor"; +"lightning__spark_exit__fund_external" = "Uhradit z jiné peněženky"; +"lightning__spark_exit__fund_external_text" = "Pošlete na tuto adresu alespoň {amount}. Výstup začne, jakmile se platba potvrdí."; +"lightning__spark_exit__fund_insufficient" = "Vaše úspory nepokryjí krytí poplatků."; +"lightning__spark_exit__fund_seen" = "Platba zaznamenána."; +"lightning__spark_exit__fund_partial" = "Přijato {received} z {required}, pošlete ještě {missing}."; +"lightning__spark_exit__confirm_title" = "Zahájit\nvýstup?"; +"lightning__spark_exit__confirm_text" = "Tyto prostředky nebudete moci používat až 4 týdny."; +"lightning__spark_exit__swipe" = "Přejeďte pro výstup"; +"lightning__spark_exit__progress_title" = "Výstup\nprobíhá"; +"lightning__spark_exit__progress_text" = "Běží to na pozadí. Bitkit vás upozorní, pokud vás bude potřebovat."; +"lightning__spark_exit__step_funding" = "Potvrzení krytí poplatků"; +"lightning__spark_exit__step_publish" = "Zveřejňování výstupních transakcí"; +"lightning__spark_exit__step_timelock" = "Čekání na časový zámek"; +"lightning__spark_exit__step_sweep" = "Odesílání do úspor"; +"lightning__spark_exit__ready_in" = "Připraveno za {duration}"; +"lightning__spark_exit__done_title" = "Výstup\ndokončen"; +"lightning__spark_exit__done_text" = "Vaše prostředky jsou v úsporách."; +"lightning__spark_exit__partial_text" = "Malé částky ({count}) zůstaly na Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} přesunuli operátoři Spark na blockchain a není na Spark. Operátoři vám ji stále mohou uvolnit."; +"lightning__spark_exit__leftover" = "{amount} z krytí poplatků je stále na adrese {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} zbývajícího krytí poplatků bylo odesláno do úspor."; +"lightning__spark_exit__leftover_sending" = "Odesíláme {amount} zbývajícího krytí poplatků do úspor."; +"lightning__spark_exit__leftover_dust" = "{amount} zbývajícího krytí poplatků je příliš málo k odeslání a zůstává na adrese {address}, derivační cesta {path}."; +"lightning__spark_exit__fund_paid" = "Zaplaceno z úspor, čeká se na potvrzení."; +"lightning__spark_exit__fund_pending" = "Platba zaznamenána, čeká se na potvrzení."; +"lightning__spark_exit__lost_leaves" = "{amount} si on-chain nárokoval dřívější vlastník těchto prostředků a nelze to získat zpět."; +"lightning__spark_exit__notification_lost" = "{amount} z vašeho výstupu nelze získat zpět. Otevřete Bitkit a zjistěte proč."; +"lightning__spark_exit__notification_failed" = "Váš nouzový výstup se zastavil. Otevřete Bitkit a zjistěte proč."; +"lightning__spark_exit__quote_outside" = "{amount} z vašeho zůstatku Spark nyní nelze vyvést: částky příliš malé na zaplacení vlastních poplatků nebo se stále přesouvají uvnitř Spark. Zůstává to na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} nelze při této sazbě poplatku vyvést a zůstává na Spark."; +"lightning__spark_exit__needs_funding_title" = "Potřeba více\npoplatků"; +"lightning__spark_exit__needs_funding_text" = "Výstup potřebuje na poplatky ještě {amount}. Pro pokračování je uhraďte."; +"lightning__spark_exit__failed_title" = "Výstup\nzastaven"; +"lightning__spark_exit__cancel" = "Zrušit výstup"; +"lightning__spark_exit__failed_text" = "Nic se nepodařilo získat zpět."; +"lightning__spark_exit__failed_stays" = "{amount} zůstává na Spark."; +"lightning__spark_exit__notification_title" = "Nouzový výstup"; +"lightning__spark_exit__notification_body" = "Otevřete Bitkit a pokračujte ve výstupu."; +"lightning__spark_exit__notification_funding" = "Váš výstup potřebuje k pokračování více poplatků. Otevřete Bitkit a uhraďte je."; +"lightning__spark_exit__bg_refresh_off" = "Aktualizace aplikací na pozadí je pro Bitkit vypnutá, takže výstup postupuje jen při otevřeném Bitkitu. Zapněte ji v Nastavení, aby mohl běžet na pozadí."; +"lightning__spark_exit__low_power" = "Režim nízké spotřeby pozastavuje práci na pozadí. Výstup bude pokračovat po jeho vypnutí nebo po otevření Bitkitu."; +"lightning__spark_exit__notifications_off" = "Oznámení jsou vypnutá, takže vám Bitkit nemůže dát vědět, když vás výstup potřebuje. Zapněte je v Nastavení, abyste dostávali připomenutí."; +"lightning__spark_exit__not_moving" = "Výstup nepostupuje: {error}"; +"lightning__spark_exit__fee_low" = "Síť je vytíženější, než odpovídá sazbě poplatku tohoto výstupu {rate} sat/vB. Zaplaťte vyšší poplatek, nebo počkejte, až poplatky klesnou."; +"lightning__spark_exit__waiting_leaves" = "Část tohoto výstupu teď nelze na blockchainu načíst. Bitkit to bude zkoušet znovu až týden."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit nedokázal načíst uložený postup tohoto výstupu, proto ho obnovuje z blockchainu."; +"lightning__spark_exit__done_received" = "{amount} dorazilo do vašich úspor."; +"lightning__spark_exit__leftover_rejected" = "{amount} zbývajícího krytí poplatků se nepodařilo odeslat a zůstává na adrese {address}, derivační cesta {path}."; +"lightning__spark_exit__notification_not_moving" = "Váš nouzový výstup nepostupuje. Otevřete Bitkit a zjistěte proč."; +"lightning__spark_exit__funding_split" = "Bitkit ji rozdělí na {count} částí, jednu pro každý krok, aby jeden zaseknutý krok nezdržel ostatní."; +"lightning__spark_exit__waiting_operators" = "{amount}, které operátoři Spark přesunuli na blockchain, čeká, až je uvolní. Bitkit se bude ptát znovu až týden."; +"lightning__spark_exit__fee_rose" = "Síťové poplatky vzrostly nad poplatek tohoto výstupu. Může se zpomalit."; +"lightning__spark_exit__fee_bump_button" = "Zaplatit vyšší poplatek"; +"lightning__spark_exit__fee_bump_title" = "Zaplatit\nvyšší poplatek"; +"lightning__spark_exit__fee_bump_text" = "Tento výstup platí {rate} sat/vB, méně, než síť nyní požaduje. Zvolte vyšší sazbu a Bitkit čekající kroky odešle znovu. Rozdíl zaplatí z úspor: částku uvedenou u každé rychlosti."; +"lightning__spark_exit__fee_bump_error" = "Cenu vyššího poplatku se nepodařilo zjistit. Zkuste to znovu."; +"lightning__spark_exit__fee_bump_confirm" = "Použít tento poplatek"; +"lightning__spark_exit__leftover_waiting" = "{amount} vrácených poplatků dorazí do vašich úspor, až síťové poplatky klesnou."; +"lightning__spark_exit__cancelled_title" = "Výstup\nzrušen"; +"lightning__spark_exit__cancelled_text" = "Výstup jste zrušili. Krytí poplatků, které dorazilo, se vrací do úspor."; +"lightning__spark_exit__cancel_failed" = "Adresu pro krytí poplatků se nepodařilo zkontrolovat. Zkuste to znovu."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Vyšší poplatek by část tohoto výstupu ponechal na Spark, proto žádný není nabízen. Výstup se potvrdí, až síťové poplatky klesnou."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Rezervní zůstatek"; "lightning__total_size" = "Celková velikost kanálu"; "lightning__fees" = "Poplatky"; @@ -852,6 +1000,7 @@ "settings__backup__category_transaction_log" = "Transakční protokol"; "settings__backup__category_wallet" = "Posílení a převody"; "settings__backup__category_settings" = "Nastavení"; +"settings__backup__category_spark_exit" = "Data pro výstup ze Spark"; "settings__backup__category_widgets" = "Widgety"; "settings__backup__category_tags" = "Tagy"; "settings__backup__category_profile" = "Profil"; @@ -891,6 +1040,14 @@ "settings__status__lightning_connection__ready" = "Otevřít"; "settings__status__lightning_connection__pending" = "Otevírání..."; "settings__status__lightning_connection__error" = "Žádná otevřená spojení"; +"settings__status__lightning_connection__spark" = "Používá Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Připojeno"; +"settings__status__spark__pending" = "Připojování..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Nouzový výstup"; +"settings__status__exit__pending" = "Probíhá"; +"settings__status__exit__funding" = "Potřebné krytí poplatků"; "settings__status__backup__title" = "Nejnovější úplná záloha dat"; "settings__status__backup__ready" = "Zálohováno"; "settings__status__backup__pending" = "Zálohování..."; @@ -1234,6 +1391,9 @@ "wallet__send_sync_long_title" = "Problémy\ns připojením"; "wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit."; "wallet__send_to" = "Komu"; +"wallet__send_swap_route" = "Trasa"; +"wallet__send_paykit_no_route__title" = "Tomuto kontaktu nelze zaplatit"; +"wallet__send_paykit_no_route__description" = "Tento kontakt teď nemá žádný platební způsob, který by Bitkit mohl použít."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Vložit fakturu"; "wallet__recipient_manual" = "Zadat ručně"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index c9bd2a249..703d383c7 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Abhängig von der Gebühr"; "fee__custom__shortRange" = "Abhängig von der Gebühr"; "fee__custom__longRange" = "Abhängig von der Gebühr"; +"lightning__transfer_intro__title" = "Spending\nBalance"; +"lightning__transfer_intro__text" = "Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen."; +"lightning__transfer_intro__button" = "Los geht\'s"; "lightning__funding__nav_title" = "Spending Balance"; +"lightning__funding__text" = "Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden."; "lightning__funding__title" = "Fund your spending balance"; "lightning__funding__button1" = "Von Sparkonto übertragen"; "lightning__funding__button2" = "Anderes Wallet verwenden"; "lightning__funding__button3" = "Manuelle Einrichtung"; "lightning__funding_advanced__button_short" = "Fortgeschrittenes Setup"; +"lightning__no_funds__title" = "Keine verfügbaren Mittel"; +"lightning__no_funds__description" = "Bevor du Geld von deinem Sparkonto übertragen kannst, musst du Bitcoin zu deiner Bitkit Wallet senden."; +"lightning__no_funds__fund" = "Wallet aufladen"; +"lightning__spending_amount__title" = "zu den Ausgaben"; "lightning__transfer__nav_title" = "Übertrage Guthaben"; "lightning__transfer__confirm" = "Bitte\nbestätige"; "lightning__transfer__custom_fee" = "Benutzerdefiniert fee"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Deine Finanzierungstransaktion wurde möglicherweise bereits übertragen. Bitte überprüfe deine Aktivität, bevor du es erneut versuchst."; "lightning__transfer_hw__reconnect_error_description" = "Bitte verbinde dein Hardware-Gerät erneut, es scheint von deinem Telefon getrennt zu sein."; "lightning__transfer_hw__reconnect_error_title" = "Hardware-Gerät neu verbinden"; +"lightning__transfer_hw__sign_title" = "Mit deinem\nGerät signieren"; +"lightning__transfer_hw__signed_title" = "Transaktion\nsigniert"; "lightning__transfer_to_savings_button" = "Zu Ersparnissen transferieren"; "lightning__transfer_to_spending_button" = "Zu Ausgaben transferieren"; +"lightning__spending_intro__title" = "zu den Ausgaben"; +"lightning__spending_intro__text" = "Überweisen Sie Geld auf Ihr Guthaben, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu tätigen."; +"lightning__spending_intro__button" = "Los geht\'s"; "lightning__transfer_failed__nav_title" = "Übertragung fehlgeschlagen"; "lightning__transfer_failed__title" = "Übertragung\nfehlgeschlagen"; "lightning__transfer_failed__text" = "Entschuldigung, Bitkit konnte dein Guthaben nicht einrichten oder deine Übertragung nicht abschließen. Bitte versuche es erneut."; @@ -220,10 +233,18 @@ "lightning__spending_amount__error_min__title" = "Minimales Sparguthaben"; "lightning__spending_amount__error_max__title" = "Maximaler Ausgabensaldo"; "lightning__spending_amount__error_max__description" = "Der Betrag, den Sie auf Ihr Ausgabenkonto übertragen können, ist derzeit auf ₿ {amount} begrenzt."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Netzgebühren"; "lightning__spending_confirm__lsp_fee" = "Servicegebühren"; "lightning__spending_confirm__amount" = "Zu den Ausgaben"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Gebühren geändert"; +"lightning__spending_confirm__fees_changed_network" = "Die Gebühren sind um {amount} höher, weil sich die Gebührensätze oder dein Sparguthaben geändert haben, während du auf diesem Bildschirm warst. Wische erneut, um zu bestätigen."; "lightning__savings_intro__title" = "Übertragung\nzu den Einsparungen"; "lightning__savings_intro__text" = "Übertragen Sie Ihr Ausgabensaldo auf Ihr Sparguthaben, um Ihre Bitcoin langfristig zu speichern und Ihre Zukunft zu sichern."; "lightning__savings_intro__button" = "Los geht\'s"; @@ -235,6 +256,7 @@ "lightning__savings_confirm__amount_too_low" = "Der Betrag ist zu niedrig für eine Übertragung auf das Sparkonto."; "lightning__savings_confirm__close_instead" = "Stattdessen Kanal schließen"; "lightning__savings_confirm__network_fee" = "Netzwerkgebühr"; +"lightning__savings_confirm__spark_fee" = "Spark-Gebühr"; "lightning__savings_confirm__receive" = "Du erhältst"; "lightning__savings_confirm__service_fee" = "Servicegebühr"; "lightning__savings_advanced__title" = "Fonds auswählen\nzu übertragen"; @@ -247,6 +269,13 @@ "lightning__savings_interrupted__nav_title" = "Transfer\nunterbrochen"; "lightning__savings_interrupted__title" = "Bitkit behalten\nopen"; "lightning__savings_interrupted__text" = "Die Gelder wurden noch nicht übertragen. Bitkit wird versuchen, die Übertragung in den nächsten 30 Minuten zu initiieren. Bitte halte deine App offen."; +"lightning__savings_spark_failed__title" = "Übertragung\nfehlgeschlagen"; +"lightning__savings_spark_failed__text" = "Dein Spark-Guthaben konnte nicht ins Sparkonto verschoben werden und bleibt in deinem Ausgabenguthaben."; +"lightning__savings_spark_failed__text_partial" = "Dein Kanalguthaben wird ins Sparkonto verschoben, aber dein Spark-Guthaben konnte nicht verschoben werden und bleibt in deinem Ausgabenguthaben."; +"lightning__savings_spark_failed__toast" = "Spark-Transfer fehlgeschlagen"; +"lightning__transfer_success__nav_title" = "Übertragung erfolgreich"; +"lightning__transfer_success__title_spending" = "Bewegte Mittel\nzu den Ausgaben"; +"lightning__transfer_success__text_spending" = "Deine Gelder wurden übertragen und dein Ausgabenguthaben ist einsatzbereit."; "lightning__transfer_success__title_savings" = "Bewegte Mittel\nzu den Einsparungen"; "lightning__transfer_success__text_savings" = "Ihr Geld wurde überwiesen. Sie können diese Mittel sofort verwenden."; "lightning__external__nav_title" = "Lightning-Verbindung"; @@ -269,6 +298,11 @@ "lightning__spending_label" = "Ausgabenguthaben"; "lightning__receiving_label" = "Empfangskapazität"; "lightning__setting_up_header" = "In Übertragung"; +"lightning__setting_up_step1" = "Zahlung wird verarbeitet"; +"lightning__setting_up_step2" = "Zahlung erfolgreich"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Bitkit weiter verwenden"; "lightning__node_info" = "Lightning Node"; "lightning__node_id" = "LDK Node ID"; "lightning__node_disconnected" = "getrennt"; @@ -309,6 +343,120 @@ "lightning__transaction" = "Transaktion"; "lightning__order_fee" = "Auftragsgebühr"; "lightning__balance" = "Guthaben"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Einlösegebühr"; +"lightning__spark_confirmations" = "{count}/3 Bestätigungen"; +"lightning__spark_deposit" = "Einzahlung"; +"lightning__spark_deposit__choice" = "Spark kann es {when} gegen eine höhere Gebühr gutschreiben, oder nach {standard} Bestätigungen, in etwa {minutes} Minuten."; +"lightning__spark_deposit__claim_early" = "{when} nutzen"; +"lightning__spark_deposit__claimed" = "Spark schreibt es deinem Ausgabenguthaben gut."; +"lightning__spark_deposit__early_fee" = "Gebühr für {when}"; +"lightning__spark_deposit__later" = "nach {count} Bestätigungen"; +"lightning__spark_deposit__later_one" = "nach 1 Bestätigung"; +"lightning__spark_deposit__looking" = "Warte, bis Spark deine Überweisung sieht."; +"lightning__spark_deposit__now" = "sofort"; +"lightning__spark_deposit__standard" = "Spark schreibt es deinem Ausgabenguthaben nach {standard} Bestätigungen gut, in etwa {minutes} Minuten."; +"lightning__spark_deposit__standard_fee" = "Gebühr fürs Warten"; +"lightning__spark_deposit__wait_button" = "Warten"; +"lightning__spark_deposit__waiting" = "Spark schreibt es deinem Ausgabenguthaben {when} gut."; +"lightning__spark_deposit__waiting_next" = "Spark schreibt es deinem Ausgabenguthaben nach der nächsten Bestätigung gut."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Eingelöste Einzahlungen"; +"lightning__spark_deposits_pending" = "Laufende Einzahlungen"; +"lightning__spark_provider" = "Anbieter"; +"lightning__spark_provider_value" = "Spark über Breez"; +"lightning__spark_receiving_unlimited" = "Kein Limit"; +"lightning__spark_trust" = "Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit."; +"lightning__spark_exit__button" = "Notausstieg"; +"lightning__spark_exit__nav_title" = "Notausstieg"; +"lightning__spark_exit__intro_title" = "Spark verlassen\non-chain"; +"lightning__spark_exit__intro_text" = "Nutze dies nur, wenn Spark für dich nicht mehr funktioniert. Es verschiebt dein Ausgabenguthaben ins Sparkonto, ohne die Spark-Betreiber.\n\nEs dauert bis zu 4 Wochen, kostet On-Chain-Gebühren aus dem Sparkonto, und sehr kleine Beträge können zurückbleiben. Es läuft im Hintergrund, und Bitkit benachrichtigt dich, wenn es dich braucht."; +"lightning__spark_exit__quote_title" = "Ausstiegs-\nangebot"; +"lightning__spark_exit__amount" = "Auszahlungsbetrag"; +"lightning__spark_exit__fees" = "Netzwerkgebühren"; +"lightning__spark_exit__receive" = "Du erhältst etwa"; +"lightning__spark_exit__savings_fee" = "Gebühr für Zahlung aus dem Sparkonto"; +"lightning__spark_exit__savings_refused" = "Deine Zahlung vom Sparkonto ist nicht durchgegangen. Zahle erneut oder finanziere aus einer anderen Wallet."; +"lightning__spark_exit__savings_total" = "Gesamt aus dem Sparkonto"; +"lightning__spark_exit__funding" = "Benötigte Gebührendeckung"; +"lightning__spark_exit__left_behind" = "Bleibt auf Spark"; +"lightning__spark_exit__outside_exit" = "{amount} auf Spark sind nicht Teil dieses Ausstiegs und bleiben auf Spark."; +"lightning__spark_exit__quote_loading" = "Angebot wird abgerufen…"; +"lightning__spark_exit__quote_settling" = "Warte, bis Spark dein Guthaben fertig verschoben hat. Das kann bis zu 5 Minuten dauern."; +"lightning__spark_exit__quote_error" = "Ausstiegsangebot konnte nicht abgerufen werden. Versuche es erneut."; +"lightning__spark_exit__nothing" = "Derzeit kann nichts ausgezahlt werden."; +"lightning__spark_exit__uneconomic" = "Die Gebühren sind höher als der Betrag, den du erhalten würdest."; +"lightning__spark_exit__funding_title" = "Gebühren\ndecken"; +"lightning__spark_exit__funding_text" = "Der Ausstieg zahlt seine Gebühren aus einer separaten On-Chain-Zahlung von {amount}. Was nicht verbraucht wird, kommt am Ende zurück ins Sparkonto."; +"lightning__spark_exit__fund_savings" = "Aus dem Sparkonto zahlen"; +"lightning__spark_exit__fund_external" = "Aus einer anderen Wallet zahlen"; +"lightning__spark_exit__fund_external_text" = "Sende mindestens {amount} an diese Adresse. Der Ausstieg startet, sobald die Zahlung bestätigt ist."; +"lightning__spark_exit__fund_insufficient" = "Dein Sparkonto reicht nicht für die Gebührendeckung."; +"lightning__spark_exit__fund_seen" = "Zahlung erkannt."; +"lightning__spark_exit__fund_partial" = "{received} von {required} erhalten, sende noch {missing}."; +"lightning__spark_exit__confirm_title" = "Ausstieg\nstarten?"; +"lightning__spark_exit__confirm_text" = "Du kannst diese Mittel bis zu 4 Wochen lang nicht nutzen."; +"lightning__spark_exit__swipe" = "Swipe zum Aussteigen"; +"lightning__spark_exit__progress_title" = "Ausstieg\nläuft"; +"lightning__spark_exit__progress_text" = "Dies läuft im Hintergrund. Bitkit benachrichtigt dich, wenn es dich braucht."; +"lightning__spark_exit__step_funding" = "Gebührendeckung wird bestätigt"; +"lightning__spark_exit__step_publish" = "Ausstiegstransaktionen werden veröffentlicht"; +"lightning__spark_exit__step_timelock" = "Warten auf den Timelock"; +"lightning__spark_exit__step_sweep" = "Senden ans Sparkonto"; +"lightning__spark_exit__ready_in" = "Bereit in {duration}"; +"lightning__spark_exit__done_title" = "Ausstieg\nabgeschlossen"; +"lightning__spark_exit__done_text" = "Deine Mittel sind im Sparkonto."; +"lightning__spark_exit__partial_text" = "{count} kleine Beträge sind auf Spark geblieben."; +"lightning__spark_exit__failed_leaves" = "{amount} wurden von den Spark-Betreibern on-chain verschoben und sind nicht auf Spark. Die Betreiber können sie dir weiterhin freigeben."; +"lightning__spark_exit__leftover" = "{amount} der Gebührendeckung liegen noch auf {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} übrige Gebührendeckung wurden ans Sparkonto gesendet."; +"lightning__spark_exit__leftover_sending" = "{amount} übrige Gebührendeckung werden ans Sparkonto gesendet."; +"lightning__spark_exit__leftover_dust" = "{amount} übrige Gebührendeckung sind zu klein zum Senden und bleiben auf {address}, Ableitungspfad {path}."; +"lightning__spark_exit__fund_paid" = "Aus dem Sparkonto bezahlt, warte auf Bestätigung."; +"lightning__spark_exit__fund_pending" = "Zahlung erkannt, warte auf Bestätigung."; +"lightning__spark_exit__lost_leaves" = "{amount} wurden von einem früheren Besitzer dieser Mittel on-chain beansprucht und können nicht wiederhergestellt werden."; +"lightning__spark_exit__notification_lost" = "{amount} deines Ausstiegs können nicht wiederhergestellt werden. Öffne Bitkit, um den Grund zu sehen."; +"lightning__spark_exit__notification_failed" = "Dein Notausstieg wurde gestoppt. Öffne Bitkit, um den Grund zu sehen."; +"lightning__spark_exit__quote_outside" = "{amount} deines Spark-Guthabens können derzeit nicht ausgezahlt werden: Beträge, die zu klein sind, um ihre eigenen Gebühren zu zahlen, oder die sich noch innerhalb von Spark bewegen. Sie bleiben auf Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} können bei diesem Gebührensatz nicht ausgezahlt werden und bleiben auf Spark."; +"lightning__spark_exit__needs_funding_title" = "Mehr Gebühren\nnötig"; +"lightning__spark_exit__needs_funding_text" = "Der Ausstieg braucht {amount} mehr, um seine Gebühren zu zahlen. Decke sie, um fortzufahren."; +"lightning__spark_exit__failed_title" = "Ausstieg\ngestoppt"; +"lightning__spark_exit__cancel" = "Ausstieg abbrechen"; +"lightning__spark_exit__failed_text" = "Es konnte nichts wiederhergestellt werden."; +"lightning__spark_exit__failed_stays" = "{amount} bleiben auf Spark."; +"lightning__spark_exit__notification_title" = "Notausstieg"; +"lightning__spark_exit__notification_body" = "Öffne Bitkit, um deinen Ausstieg fortzusetzen."; +"lightning__spark_exit__notification_funding" = "Dein Ausstieg braucht mehr Gebühren, um fortzufahren. Öffne Bitkit, um sie zu decken."; +"lightning__spark_exit__bg_refresh_off" = "Hintergrundaktualisierung ist für Bitkit deaktiviert, daher läuft der Ausstieg nur, solange Bitkit geöffnet ist. Aktiviere sie in den Einstellungen, damit er im Hintergrund laufen kann."; +"lightning__spark_exit__low_power" = "Der Stromsparmodus pausiert Hintergrundaktivität. Der Ausstieg läuft weiter, sobald er aus ist oder wenn du Bitkit öffnest."; +"lightning__spark_exit__notifications_off" = "Benachrichtigungen sind deaktiviert, daher kann Bitkit dir nicht sagen, wenn der Ausstieg dich braucht. Aktiviere sie in den Einstellungen, um erinnert zu werden."; +"lightning__spark_exit__not_moving" = "Der Ausstieg kommt nicht voran: {error}"; +"lightning__spark_exit__fee_low" = "Das Netzwerk ist stärker ausgelastet als der Gebührensatz dieses Ausstiegs von {rate} sat/vB. Zahle eine höhere Gebühr oder warte, bis die Gebühren sinken."; +"lightning__spark_exit__waiting_leaves" = "Ein Teil dieses Ausstiegs kann derzeit nicht on-chain gelesen werden. Bitkit versucht es bis zu einer Woche lang erneut."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit konnte den gespeicherten Fortschritt dieses Ausstiegs nicht lesen und stellt ihn daher aus der Blockchain wieder her."; +"lightning__spark_exit__done_received" = "{amount} sind in deinem Sparkonto angekommen."; +"lightning__spark_exit__leftover_rejected" = "{amount} übrige Gebührendeckung konnten nicht gesendet werden und bleiben auf {address}, Ableitungspfad {path}."; +"lightning__spark_exit__notification_not_moving" = "Dein Notausstieg kommt nicht voran. Öffne Bitkit, um den Grund zu sehen."; +"lightning__spark_exit__funding_split" = "Bitkit teilt sie in {count} Teile auf, einen pro Schritt, damit ein hängender Schritt die anderen nicht aufhält."; +"lightning__spark_exit__waiting_operators" = "{amount}, die die Spark-Betreiber on-chain verschoben haben, warten darauf, dass sie sie freigeben. Bitkit fragt bis zu einer Woche lang erneut nach."; +"lightning__spark_exit__fee_rose" = "Die Netzwerkgebühren sind über die Gebühr dieses Ausstiegs gestiegen. Er kann sich verlangsamen."; +"lightning__spark_exit__fee_bump_button" = "Höhere Gebühr zahlen"; +"lightning__spark_exit__fee_bump_title" = "Höhere Gebühr\nzahlen"; +"lightning__spark_exit__fee_bump_text" = "Dieser Ausstieg zahlt {rate} sat/vB, weniger als das Netzwerk derzeit verlangt. Wähle einen höheren Satz, und Bitkit sendet die wartenden Schritte erneut. Die Differenz wird aus dem Sparkonto bezahlt: der Betrag neben jeder Geschwindigkeit."; +"lightning__spark_exit__fee_bump_error" = "Höhere Gebühr konnte nicht berechnet werden. Versuche es erneut."; +"lightning__spark_exit__fee_bump_confirm" = "Diese Gebühr nutzen"; +"lightning__spark_exit__leftover_waiting" = "{amount} Gebührenwechselgeld erreichen dein Sparkonto, wenn die Netzwerkgebühren sinken."; +"lightning__spark_exit__cancelled_title" = "Ausstieg\nabgebrochen"; +"lightning__spark_exit__cancelled_text" = "Du hast den Ausstieg abgebrochen. Die eingegangene Gebührendeckung geht zurück ins Sparkonto."; +"lightning__spark_exit__cancel_failed" = "Die Adresse für die Gebührendeckung konnte nicht geprüft werden. Versuche es erneut."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Eine höhere Gebühr würde einen Teil dieses Ausstiegs auf Spark lassen, daher wird keine angeboten. Der Ausstieg wird bestätigt, sobald die Netzwerkgebühren sinken."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Reserveguthaben"; "lightning__total_size" = "Gesamtgröße des Channels"; "lightning__fees" = "Gebühren"; @@ -850,6 +998,7 @@ "settings__backup__category_transaction_log" = "Transaktionshistorie"; "settings__backup__category_wallet" = "Boosts & Übertragungen"; "settings__backup__category_settings" = "Einstellungen"; +"settings__backup__category_spark_exit" = "Spark-Ausstiegsdaten"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profil"; @@ -889,6 +1038,14 @@ "settings__status__lightning_connection__ready" = "Geöffnet"; "settings__status__lightning_connection__pending" = "In Vorbereitung..."; "settings__status__lightning_connection__error" = "Keine offenen Verbindungen"; +"settings__status__lightning_connection__spark" = "Nutzt Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Verbunden"; +"settings__status__spark__pending" = "Verbinden..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Notausstieg"; +"settings__status__exit__pending" = "Läuft"; +"settings__status__exit__funding" = "Benötigte Gebührendeckung"; "settings__status__backup__title" = "Vollständiges Backup"; "settings__status__backup__ready" = "Abgeschlossen"; "settings__status__backup__pending" = "In Bearbeitung..."; @@ -1232,6 +1389,9 @@ "wallet__send_sync_long_title" = "Verbindungs-\nprobleme"; "wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden."; "wallet__send_to" = "An"; +"wallet__send_swap_route" = "Route"; +"wallet__send_paykit_no_route__title" = "Zahlung an diesen Kontakt nicht möglich"; +"wallet__send_paykit_no_route__description" = "Dieser Kontakt hat derzeit keine Zahlungsmethode, die Bitkit nutzen kann."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Rechnung einfügen"; "wallet__recipient_manual" = "Manuell eingeben"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index a27c43aaf..636fad0bc 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -191,12 +191,20 @@ "fee__custom__range" = "Εξαρτάται από το τέλος"; "fee__custom__shortRange" = "Εξαρτάται από το τέλος"; "fee__custom__longRange" = "Εξαρτάται από την προμήθεια"; +"lightning__transfer_intro__title" = "Υπόλοιπο\nΔαπανών"; +"lightning__transfer_intro__text" = "Χρηματοδοτήστε το υπόλοιπο δαπανών σας για να απολαύσετε άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους."; +"lightning__transfer_intro__button" = "Ξεκινήστε"; "lightning__funding__nav_title" = "Υπόλοιπο Δαπανών"; +"lightning__funding__text" = "Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι."; "lightning__funding__title" = "Χρηματοδοτήστε το υπόλοιπο δαπανών σας"; "lightning__funding__button1" = "Μεταφορά από Αποταμιεύσεις"; "lightning__funding__button2" = "Χρήση Άλλου Πορτοφολιού"; "lightning__funding__button3" = "Χειροκίνητη Ρύθμιση"; "lightning__funding_advanced__button_short" = "Ρύθμιση για Προχωρημένους"; +"lightning__no_funds__title" = "Δεν υπάρχουν διαθέσιμα κεφάλαια"; +"lightning__no_funds__description" = "Πριν μπορέσεις να μεταφέρεις κεφάλαια από το υπόλοιπο αποταμιεύσεων, πρέπει να στείλεις bitcoin στο πορτοφόλι Bitkit."; +"lightning__no_funds__fund" = "Χρηματοδότηση πορτοφολιού"; +"lightning__spending_amount__title" = "Μεταφορά\nστις δαπάνες"; "lightning__transfer__nav_title" = "Μεταφορά Κεφαλαίων"; "lightning__transfer__confirm" = "Παρακαλώ\nεπιβεβαιώστε"; "lightning__transfer__swipe" = "Σύρετε για Μεταφορά"; @@ -204,16 +212,29 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Η συναλλαγή χρηματοδότησης μπορεί να έχει μεταδοθεί. Ελέγξτε τη δραστηριότητά σας πριν δοκιμάσετε ξανά."; "lightning__transfer_hw__reconnect_error_description" = "Σύνδεσε ξανά τη συσκευή υλικού σου, φαίνεται ότι αποσυνδέθηκε από το τηλέφωνό σου."; "lightning__transfer_hw__reconnect_error_title" = "Επανασύνδεση συσκευής υλικού"; +"lightning__transfer_hw__sign_title" = "Υπόγραψε με\nτη συσκευή σου"; +"lightning__transfer_hw__signed_title" = "Η συναλλαγή\nυπογράφηκε"; "lightning__transfer_to_savings_button" = "Μεταφορά στις Αποταμιεύσεις"; "lightning__transfer_to_spending_button" = "Μεταφορά στις Δαπάνες"; +"lightning__spending_intro__title" = "Μεταφορά\nστις δαπάνες"; +"lightning__spending_intro__text" = "Μεταφέρετε κεφάλαια στο υπόλοιπο δαπανών σας για να απολαύσετε άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους."; +"lightning__spending_intro__button" = "Ξεκινήστε"; "lightning__transfer_failed__continue" = "Προσπαθείστε Ξανά"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Μέγιστο υπόλοιπο δαπανών"; "lightning__spending_amount__error_max__description" = "Το ποσό που μπορείς να μεταφέρεις στο υπόλοιπο δαπανών περιορίζεται προς το παρόν σε ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Τέλη δικτύου"; "lightning__spending_confirm__lsp_fee" = "Τέλη υπηρεσίας"; "lightning__spending_confirm__amount" = "Στις δαπάνες"; "lightning__spending_confirm__total" = "Σύνολο"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Τα τέλη άλλαξαν"; +"lightning__spending_confirm__fees_changed_network" = "Τα τέλη είναι {amount} υψηλότερα, επειδή τα ποσοστά τελών ή το υπόλοιπο αποταμιεύσεων άλλαξαν ενώ ήσουν σε αυτή την οθόνη. Σύρε ξανά για επιβεβαίωση."; "lightning__savings_intro__title" = "Μεταφορά\nστις αποταμιεύσεις"; "lightning__savings_intro__text" = "Μεταφέρετε το υπόλοιπο δαπανών σας στις αποταμιεύσεις για να αποθηκεύσετε τα Bitcoin σας μακροπρόθεσμα και να εξασφαλίσετε το μέλλον σας."; "lightning__savings_intro__button" = "Ξεκινήστε"; @@ -225,6 +246,7 @@ "lightning__savings_confirm__amount_too_low" = "Το ποσό είναι πολύ χαμηλό για μεταφορά στις αποταμιεύσεις."; "lightning__savings_confirm__close_instead" = "Κλείσιμο καναλιού αντί αυτού"; "lightning__savings_confirm__network_fee" = "Τέλος δικτύου"; +"lightning__savings_confirm__spark_fee" = "Τέλος Spark"; "lightning__savings_confirm__receive" = "Θα λάβεις"; "lightning__savings_confirm__service_fee" = "Τέλος υπηρεσίας"; "lightning__savings_advanced__title" = "Επιλέξτε κεφάλαια\nγια μεταφορά"; @@ -237,6 +259,13 @@ "lightning__savings_interrupted__nav_title" = "Μεταφορά\nδιακόπηκε"; "lightning__savings_interrupted__title" = "Κρατήστε το Bitkit\nανοιχτό"; "lightning__savings_interrupted__text" = "Τα κεφάλαια δεν έχουν μεταφερθεί ακόμα. Το Bitkit θα προσπαθήσει να ξεκινήσει τη μεταφορά τα επόμενα 30 λεπτά. Παρακαλώ κρατήστε την εφαρμογή ανοιχτή."; +"lightning__savings_spark_failed__title" = "Μεταφορά\nαπέτυχε"; +"lightning__savings_spark_failed__text" = "Το υπόλοιπο Spark δεν μεταφέρθηκε στις αποταμιεύσεις και παραμένει στο υπόλοιπο δαπανών."; +"lightning__savings_spark_failed__text_partial" = "Τα κεφάλαια των καναλιών σου μεταφέρονται στις αποταμιεύσεις, αλλά το υπόλοιπο Spark δεν μεταφέρθηκε και παραμένει στο υπόλοιπο δαπανών."; +"lightning__savings_spark_failed__toast" = "Η μεταφορά Spark απέτυχε"; +"lightning__transfer_success__nav_title" = "Επιτυχής Μεταφορά"; +"lightning__transfer_success__title_spending" = "Τα κεφάλαια μεταφέρθηκαν\nστις δαπάνες"; +"lightning__transfer_success__text_spending" = "Τα κεφάλαιά σας έχουν μεταφερθεί και το υπόλοιπο δαπανών σας είναι έτοιμο για χρήση."; "lightning__transfer_success__title_savings" = "Τα κεφάλαια μεταφέρθηκαν\nστις αποταμιεύσεις"; "lightning__transfer_success__text_savings" = "Τα κεφάλαιά σας έχουν μεταφερθεί. Μπορείτε να χρησιμοποιήσετε αυτά τα κεφάλαια αμέσως."; "lightning__external__nav_title" = "Σύνδεση Lightning"; @@ -255,6 +284,11 @@ "lightning__savings" = "Αποταμιεύσεις"; "lightning__spending_label" = "Υπόλοιπο δαπανών"; "lightning__receiving_label" = "Χωρητικότητα λήψης"; +"lightning__setting_up_step1" = "Επεξεργασία Πληρωμής"; +"lightning__setting_up_step2" = "Επιτυχής Πληρωμή"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Συνεχίστε να Χρησιμοποιείτε το Bitkit"; "lightning__node_info" = "Κόμβος Lightning"; "lightning__node_disconnected" = "αποσυνδεδεμένο"; "lightning__connections" = "Συνδέσεις Lightning"; @@ -282,6 +316,120 @@ "lightning__transaction" = "Συναλλαγή"; "lightning__order_fee" = "Τέλος παραγγελίας"; "lightning__balance" = "Υπόλοιπο"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Χρέωση εξαργύρωσης"; +"lightning__spark_confirmations" = "{count}/3 επιβεβαιώσεις"; +"lightning__spark_deposit" = "Κατάθεση"; +"lightning__spark_deposit__choice" = "Το Spark μπορεί να το προσθέσει {when} με υψηλότερη χρέωση, ή μετά από {standard} επιβεβαιώσεις, σε περίπου {minutes} λεπτά."; +"lightning__spark_deposit__claim_early" = "Χρήση {when}"; +"lightning__spark_deposit__claimed" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας."; +"lightning__spark_deposit__early_fee" = "Χρέωση για χρήση {when}"; +"lightning__spark_deposit__later" = "μετά από {count} επιβεβαιώσεις"; +"lightning__spark_deposit__later_one" = "μετά από 1 επιβεβαίωση"; +"lightning__spark_deposit__looking" = "Αναμονή για να δει το Spark τη μεταφορά σας."; +"lightning__spark_deposit__now" = "τώρα"; +"lightning__spark_deposit__standard" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας μετά από {standard} επιβεβαιώσεις, σε περίπου {minutes} λεπτά."; +"lightning__spark_deposit__standard_fee" = "Χρέωση αναμονής"; +"lightning__spark_deposit__wait_button" = "Αναμονή"; +"lightning__spark_deposit__waiting" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας {when}."; +"lightning__spark_deposit__waiting_next" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας μετά την επόμενη επιβεβαίωση."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Εξαργυρωμένες καταθέσεις"; +"lightning__spark_deposits_pending" = "Καταθέσεις σε εξέλιξη"; +"lightning__spark_provider" = "Πάροχος"; +"lightning__spark_provider_value" = "Spark μέσω Breez"; +"lightning__spark_receiving_unlimited" = "Χωρίς όριο"; +"lightning__spark_trust" = "Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain."; +"lightning__spark_exit__button" = "Έξοδος έκτακτης ανάγκης"; +"lightning__spark_exit__nav_title" = "Έξοδος Έκτακτης Ανάγκης"; +"lightning__spark_exit__intro_title" = "Αποχώρηση από το Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Χρησιμοποιήστε το μόνο αν το Spark σταματήσει να λειτουργεί για εσάς. Μεταφέρει το υπόλοιπο δαπανών σας στις αποταμιεύσεις χωρίς τους διαχειριστές του Spark.\n\nΔιαρκεί έως 4 εβδομάδες, κοστίζει τέλη on-chain που πληρώνονται από τις αποταμιεύσεις, και πολύ μικρά ποσά μπορεί να μείνουν πίσω. Εκτελείται στο παρασκήνιο και το Bitkit σας ειδοποιεί αν σας χρειαστεί."; +"lightning__spark_exit__quote_title" = "Προσφορά\nεξόδου"; +"lightning__spark_exit__amount" = "Ποσό εξόδου"; +"lightning__spark_exit__fees" = "Τέλη δικτύου"; +"lightning__spark_exit__receive" = "Λαμβάνετε περίπου"; +"lightning__spark_exit__savings_fee" = "Τέλος πληρωμής από αποταμιεύσεις"; +"lightning__spark_exit__savings_refused" = "Η πληρωμή σας από αποταμιεύσεις δεν ολοκληρώθηκε. Πληρώστε ξανά ή χρηματοδοτήστε από άλλο πορτοφόλι."; +"lightning__spark_exit__savings_total" = "Σύνολο από αποταμιεύσεις"; +"lightning__spark_exit__funding" = "Απαιτούμενη χρηματοδότηση τελών"; +"lightning__spark_exit__left_behind" = "Παραμένει στο Spark"; +"lightning__spark_exit__outside_exit" = "{amount} στο Spark δεν περιλαμβάνεται σε αυτή την έξοδο και παραμένει στο Spark."; +"lightning__spark_exit__quote_loading" = "Λήψη προσφοράς…"; +"lightning__spark_exit__quote_settling" = "Αναμονή για να ολοκληρώσει το Spark τη μεταφορά του υπολοίπου σας. Αυτό μπορεί να διαρκέσει έως 5 λεπτά."; +"lightning__spark_exit__quote_error" = "Δεν ήταν δυνατή η λήψη προσφοράς εξόδου. Δοκιμάστε ξανά."; +"lightning__spark_exit__nothing" = "Δεν υπάρχει τίποτα για έξοδο αυτή τη στιγμή."; +"lightning__spark_exit__uneconomic" = "Τα τέλη είναι υψηλότερα από όσα θα λαμβάνατε."; +"lightning__spark_exit__funding_title" = "Χρηματοδοτήστε\nτα τέλη"; +"lightning__spark_exit__funding_text" = "Η έξοδος πληρώνει τα τέλη της από ξεχωριστή πληρωμή on-chain ύψους {amount}. Ό,τι δεν ξοδευτεί επιστρέφει στις αποταμιεύσεις στο τέλος."; +"lightning__spark_exit__fund_savings" = "Πληρωμή από αποταμιεύσεις"; +"lightning__spark_exit__fund_external" = "Χρηματοδότηση από άλλο πορτοφόλι"; +"lightning__spark_exit__fund_external_text" = "Στείλτε τουλάχιστον {amount} σε αυτή τη διεύθυνση. Η έξοδος ξεκινά μόλις επιβεβαιωθεί."; +"lightning__spark_exit__fund_insufficient" = "Οι αποταμιεύσεις σας δεν επαρκούν για τη χρηματοδότηση των τελών."; +"lightning__spark_exit__fund_seen" = "Η πληρωμή εντοπίστηκε."; +"lightning__spark_exit__fund_partial" = "Ελήφθησαν {received} από {required}, στείλτε ακόμη {missing}."; +"lightning__spark_exit__confirm_title" = "Έναρξη\nεξόδου;"; +"lightning__spark_exit__confirm_text" = "Δεν θα μπορείτε να χρησιμοποιήσετε αυτά τα κεφάλαια για έως 4 εβδομάδες."; +"lightning__spark_exit__swipe" = "Σύρετε για Έξοδο"; +"lightning__spark_exit__progress_title" = "Έξοδος\nσε εξέλιξη"; +"lightning__spark_exit__progress_text" = "Εκτελείται στο παρασκήνιο. Το Bitkit σας ειδοποιεί αν σας χρειαστεί."; +"lightning__spark_exit__step_funding" = "Επιβεβαίωση χρηματοδότησης τελών"; +"lightning__spark_exit__step_publish" = "Δημοσίευση συναλλαγών εξόδου"; +"lightning__spark_exit__step_timelock" = "Αναμονή για το χρονοκλείδωμα"; +"lightning__spark_exit__step_sweep" = "Αποστολή στις αποταμιεύσεις"; +"lightning__spark_exit__ready_in" = "Έτοιμο σε {duration}"; +"lightning__spark_exit__done_title" = "Η έξοδος\nολοκληρώθηκε"; +"lightning__spark_exit__done_text" = "Τα κεφάλαιά σας βρίσκονται στις αποταμιεύσεις."; +"lightning__spark_exit__partial_text" = "{count} μικρά ποσά παρέμειναν στο Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} μεταφέρθηκε on-chain από τους διαχειριστές του Spark και δεν βρίσκεται στο Spark. Οι διαχειριστές μπορούν ακόμη να το αποδεσμεύσουν σε εσάς."; +"lightning__spark_exit__leftover" = "{amount} της χρηματοδότησης τελών βρίσκεται ακόμη στη διεύθυνση {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} από την υπόλοιπη χρηματοδότηση τελών στάλθηκε στις αποταμιεύσεις."; +"lightning__spark_exit__leftover_sending" = "Αποστολή {amount} από την υπόλοιπη χρηματοδότηση τελών στις αποταμιεύσεις."; +"lightning__spark_exit__leftover_dust" = "{amount} από την υπόλοιπη χρηματοδότηση τελών είναι πολύ μικρό για αποστολή και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}."; +"lightning__spark_exit__fund_paid" = "Πληρώθηκε από τις αποταμιεύσεις, αναμονή επιβεβαίωσης."; +"lightning__spark_exit__fund_pending" = "Η πληρωμή εντοπίστηκε, αναμονή επιβεβαίωσης."; +"lightning__spark_exit__lost_leaves" = "{amount} διεκδικήθηκε on-chain από προηγούμενο κάτοχο αυτών των κεφαλαίων και δεν μπορεί να ανακτηθεί."; +"lightning__spark_exit__notification_lost" = "{amount} της εξόδου σας δεν μπορεί να ανακτηθεί. Ανοίξτε το Bitkit για να δείτε γιατί."; +"lightning__spark_exit__notification_failed" = "Η έξοδος έκτακτης ανάγκης σταμάτησε. Ανοίξτε το Bitkit για να δείτε γιατί."; +"lightning__spark_exit__quote_outside" = "{amount} του υπολοίπου Spark σας δεν μπορεί να εξέλθει αυτή τη στιγμή: ποσά πολύ μικρά για να πληρώσουν τα δικά τους τέλη, ή που ακόμη μετακινούνται μέσα στο Spark. Παραμένει στο Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} δεν μπορεί να εξέλθει με αυτόν τον ρυθμό τελών και παραμένει στο Spark."; +"lightning__spark_exit__needs_funding_title" = "Απαιτούνται\nεπιπλέον τέλη"; +"lightning__spark_exit__needs_funding_text" = "Η έξοδος χρειάζεται ακόμη {amount} για να πληρώσει τα τέλη της. Χρηματοδοτήστε τη για να συνεχίσει."; +"lightning__spark_exit__failed_title" = "Η έξοδος\nσταμάτησε"; +"lightning__spark_exit__cancel" = "Ακύρωση εξόδου"; +"lightning__spark_exit__failed_text" = "Δεν ήταν δυνατή η ανάκτηση κανενός ποσού."; +"lightning__spark_exit__failed_stays" = "{amount} παραμένει στο Spark."; +"lightning__spark_exit__notification_title" = "Έξοδος έκτακτης ανάγκης"; +"lightning__spark_exit__notification_body" = "Ανοίξτε το Bitkit για να συνεχίσετε την έξοδό σας."; +"lightning__spark_exit__notification_funding" = "Η έξοδός σας χρειάζεται επιπλέον τέλη για να συνεχίσει. Ανοίξτε το Bitkit για να τη χρηματοδοτήσετε."; +"lightning__spark_exit__bg_refresh_off" = "Η Ανανέωση εφαρμογών στο παρασκήνιο είναι απενεργοποιημένη για το Bitkit, οπότε η έξοδος προχωρά μόνο όσο το Bitkit είναι ανοιχτό. Ενεργοποιήστε τη στις Ρυθμίσεις για να εκτελείται στο παρασκήνιο."; +"lightning__spark_exit__low_power" = "Η Λειτουργία χαμηλής κατανάλωσης διακόπτει την εργασία στο παρασκήνιο. Η έξοδος συνεχίζεται μόλις απενεργοποιηθεί, ή όταν ανοίξετε το Bitkit."; +"lightning__spark_exit__notifications_off" = "Οι ειδοποιήσεις είναι απενεργοποιημένες, οπότε το Bitkit δεν μπορεί να σας ενημερώσει όταν η έξοδος σας χρειάζεται. Ενεργοποιήστε τις στις Ρυθμίσεις για να λαμβάνετε υπενθυμίσεις."; +"lightning__spark_exit__not_moving" = "Η έξοδος δεν προχωρά: {error}"; +"lightning__spark_exit__fee_low" = "Το δίκτυο είναι πιο φορτωμένο από τον ρυθμό τελών αυτής της εξόδου, {rate} sat/vB. Πληρώστε υψηλότερο τέλος ή περιμένετε να μειωθούν τα τέλη."; +"lightning__spark_exit__waiting_leaves" = "Μέρος αυτής της εξόδου δεν μπορεί να διαβαστεί on-chain αυτή τη στιγμή. Το Bitkit προσπαθεί ξανά για έως μία εβδομάδα."; +"lightning__spark_exit__rebuilt_from_chain" = "Το Bitkit δεν μπόρεσε να διαβάσει την αποθηκευμένη πρόοδο αυτής της εξόδου, οπότε την ανακατασκευάζει από την αλυσίδα."; +"lightning__spark_exit__done_received" = "{amount} έφτασε στις αποταμιεύσεις σας."; +"lightning__spark_exit__leftover_rejected" = "{amount} από την υπόλοιπη χρηματοδότηση τελών δεν ήταν δυνατό να σταλεί και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}."; +"lightning__spark_exit__notification_not_moving" = "Η έξοδος έκτακτης ανάγκης δεν προχωρά. Ανοίξτε το Bitkit για να δείτε γιατί."; +"lightning__spark_exit__funding_split" = "Το Bitkit τη χωρίζει σε {count} μέρη, ένα για κάθε βήμα, ώστε ένα κολλημένο βήμα να μην καθυστερεί τα υπόλοιπα."; +"lightning__spark_exit__waiting_operators" = "{amount} που μετέφεραν on-chain οι διαχειριστές του Spark περιμένει να το αποδεσμεύσουν. Το Bitkit ζητά ξανά για έως μία εβδομάδα."; +"lightning__spark_exit__fee_rose" = "Τα τέλη δικτύου αυξήθηκαν πάνω από το τέλος αυτής της εξόδου. Μπορεί να καθυστερήσει."; +"lightning__spark_exit__fee_bump_button" = "Πληρωμή υψηλότερου τέλους"; +"lightning__spark_exit__fee_bump_title" = "Πληρώστε\nυψηλότερο τέλος"; +"lightning__spark_exit__fee_bump_text" = "Αυτή η έξοδος πληρώνει {rate} sat/vB, λιγότερο από όσο ζητά τώρα το δίκτυο. Επιλέξτε υψηλότερο ρυθμό και το Bitkit στέλνει ξανά τα βήματα σε αναμονή. Πληρώνει τη διαφορά από τις αποταμιεύσεις: το ποσό δίπλα σε κάθε ταχύτητα."; +"lightning__spark_exit__fee_bump_error" = "Δεν ήταν δυνατός ο υπολογισμός υψηλότερου τέλους. Δοκιμάστε ξανά."; +"lightning__spark_exit__fee_bump_confirm" = "Χρήση αυτού του τέλους"; +"lightning__spark_exit__leftover_waiting" = "{amount} ρέστα τελών θα φτάσουν στις αποταμιεύσεις σας όταν μειωθούν τα τέλη δικτύου."; +"lightning__spark_exit__cancelled_title" = "Η έξοδος\nακυρώθηκε"; +"lightning__spark_exit__cancelled_text" = "Ακυρώσατε την έξοδο. Η χρηματοδότηση τελών που έφτασε επιστρέφει στις αποταμιεύσεις."; +"lightning__spark_exit__cancel_failed" = "Δεν ήταν δυνατός ο έλεγχος της διεύθυνσης χρηματοδότησης. Δοκιμάστε ξανά."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Ένα υψηλότερο τέλος θα άφηνε μέρος αυτής της εξόδου στο Spark, οπότε δεν προσφέρεται κανένα. Η έξοδος επιβεβαιώνεται μόλις μειωθούν τα τέλη δικτύου."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Υπόλοιπο αποθεματικού (μη δαπανήσιμο)"; "lightning__total_size" = "Συνολικό μέγεθος καναλιού"; "lightning__fees" = "Τέλη"; @@ -734,6 +882,7 @@ "settings__backup__category_transaction_log" = "Αρχείο Συναλλαγών"; "settings__backup__category_wallet" = "Ενισχύσεις & Μεταφορές"; "settings__backup__category_settings" = "Ρυθμίσεις"; +"settings__backup__category_spark_exit" = "Δεδομένα εξόδου Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Ετικέτες"; "settings__backup__category_profile" = "Προφίλ"; @@ -772,6 +921,14 @@ "settings__status__lightning_connection__ready" = "Ανοιχτή"; "settings__status__lightning_connection__pending" = "Άνοιγμα..."; "settings__status__lightning_connection__error" = "Δεν υπάρχουν ανοιχτές συνδέσεις"; +"settings__status__lightning_connection__spark" = "Χρήση Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Συνδεδεμένο"; +"settings__status__spark__pending" = "Σύνδεση..."; +"settings__status__spark__error" = "Εκτός σύνδεσης"; +"settings__status__exit__title" = "Έξοδος Έκτακτης Ανάγκης"; +"settings__status__exit__pending" = "Σε εξέλιξη"; +"settings__status__exit__funding" = "Απαιτούμενη χρηματοδότηση τελών"; "settings__status__backup__title" = "Τελευταίο Πλήρες Αντίγραφο Δεδομένων"; "settings__status__backup__error" = "Αποτυχία ολοκλήρωσης πλήρους αντιγράφου"; "settings__adv__section_debug" = "Debug"; @@ -1026,6 +1183,9 @@ "wallet__send_sync_long_title" = "Προβλήματα\nσύνδεσης"; "wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα."; "wallet__send_to" = "Προς"; +"wallet__send_swap_route" = "Διαδρομή"; +"wallet__send_paykit_no_route__title" = "Δεν είναι δυνατή η πληρωμή αυτής της επαφής"; +"wallet__send_paykit_no_route__description" = "Αυτή η επαφή δεν έχει αυτή τη στιγμή κάποια μέθοδο πληρωμής που μπορεί να χρησιμοποιήσει το Bitkit."; "wallet__recipient_contact" = "Επαφή"; "wallet__recipient_invoice" = "Επικόλληση Τιμολογίου"; "wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 1d3129eae..39207d68b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -188,12 +188,20 @@ "fee__custom__description" = "Depends on the fee"; "fee__custom__shortDescription" = "Depends on the fee"; "fee__custom__range" = "Depends on the fee"; +"lightning__transfer_intro__title" = "Spending\nBalance"; +"lightning__transfer_intro__text" = "Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants."; +"lightning__transfer_intro__button" = "Get Started"; "lightning__funding__nav_title" = "Spending Balance"; +"lightning__funding__text" = "The easiest option is to use your Bitkit savings or send bitcoin from a different wallet."; "lightning__funding__title" = "Fund your spending balance"; -"lightning__funding__button1" = "Transfer from Savings"; +"lightning__funding__button1" = "Transfer From Savings"; "lightning__funding__button2" = "Use Other Wallet"; "lightning__funding__button3" = "Manual Setup"; "lightning__funding_advanced__button_short" = "Advanced Setup"; +"lightning__no_funds__title" = "No Available Funds"; +"lightning__no_funds__description" = "Before you can transfer funds from your savings balance, you need to send bitcoin to your Bitkit wallet."; +"lightning__no_funds__fund" = "Fund wallet"; +"lightning__spending_amount__title" = "Transfer\nto spending"; "lightning__transfer__nav_title" = "Transfer Funds"; "lightning__transfer__confirm" = "Please\nconfirm"; "lightning__transfer__custom_fee" = "Custom fee"; @@ -202,8 +210,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Your funding transaction may have been broadcast. Please check your activity before trying again."; "lightning__transfer_hw__reconnect_error_description" = "Please reconnect your hardware device, it appears to be disconnected from your phone."; "lightning__transfer_hw__reconnect_error_title" = "Reconnect Hardware Device"; +"lightning__transfer_hw__sign_title" = "Sign with\nyour device"; +"lightning__transfer_hw__signed_title" = "Transaction\nsigned"; "lightning__transfer_to_savings_button" = "Transfer To Savings"; "lightning__transfer_to_spending_button" = "Transfer To Spending"; +"lightning__spending_intro__title" = "Transfer\nto spending"; +"lightning__spending_intro__text" = "Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants."; +"lightning__spending_intro__button" = "Get Started"; "lightning__transfer_failed__nav_title" = "Transfer Failure"; "lightning__transfer_failed__title" = "Transfer\nfailed"; "lightning__transfer_failed__text" = "Sorry, Bitkit could not set up your spending balance or complete your transfer. Please try again."; @@ -214,11 +227,21 @@ "lightning__spending_amount__error_min__description" = "A minimum of ₿ {amount} is needed to set up your spending balance."; "lightning__spending_amount__error_max__title" = "Spending Balance Maximum"; "lightning__spending_amount__error_max__description" = "The amount you can transfer to your spending balance is currently limited to ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center."; "lightning__spending_confirm__network_fee" = "Network fees"; "lightning__spending_confirm__lsp_fee" = "Service fees"; "lightning__spending_confirm__amount" = "To spending"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Fees changed"; +"lightning__spending_confirm__max_changed_title" = "Amount changed"; +"lightning__spending_confirm__max_changed" = "The most you can transfer is now {amount}, because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm."; +"lightning__spending_confirm__fees_changed_network" = "Fees are {amount} higher because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm."; "lightning__savings_intro__title" = "Transfer\nto savings"; "lightning__savings_intro__text" = "Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future."; "lightning__savings_intro__button" = "Get Started"; @@ -230,6 +253,7 @@ "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__spark_fee" = "Spark fee"; "lightning__savings_confirm__receive" = "You'll receive"; "lightning__savings_confirm__service_fee" = "Service fee"; "lightning__savings_advanced__title" = "Select funds\nto transfer"; @@ -242,6 +266,13 @@ "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."; +"lightning__savings_spark_failed__title" = "Transfer\nfailed"; +"lightning__savings_spark_failed__text" = "Your Spark balance could not move to savings and stays in your spending balance."; +"lightning__savings_spark_failed__text_partial" = "Your channel funds are moving to savings, but your Spark balance could not move and stays in your spending balance."; +"lightning__savings_spark_failed__toast" = "Spark transfer failed"; +"lightning__transfer_success__nav_title" = "Transfer Successful"; +"lightning__transfer_success__title_spending" = "Funds moved\nto spending"; +"lightning__transfer_success__text_spending" = "Your funds have been transferred and your spending balance is ready to use."; "lightning__transfer_success__title_savings" = "Funds moved\nto savings"; "lightning__transfer_success__text_savings" = "Your funds have been transferred. You are able to use these funds immediately."; "lightning__external__nav_title" = "Lightning Connection"; @@ -264,6 +295,11 @@ "lightning__spending_label" = "Spending balance"; "lightning__receiving_label" = "Receiving capacity"; "lightning__setting_up_header" = "In transfer"; +"lightning__setting_up_step1" = "Processing Payment"; +"lightning__setting_up_step2" = "Payment Successful"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continue Using Bitkit"; "lightning__node_info" = "Lightning Node"; "lightning__node_id" = "LDK Node ID"; "lightning__node_disconnected" = "disconnected"; @@ -304,6 +340,149 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Order fee"; "lightning__balance" = "Balance"; +"lightning__move_to_spark__amount" = "Amount"; +"lightning__move_to_spark__confirm_text" = "Your balance moves to Spark over Lightning, the lowest-fee way to keep it in Spending. The routing fee below is the most you pay."; +"lightning__move_to_spark__confirm_title" = "Move to\nSpark"; +"lightning__move_to_spark__done_rest_text" = "Part of your balance is on Spark now. Move the rest the same way, then close the emptied connection."; +"lightning__move_to_spark__done_text" = "Your balance is on Spark now. Close the emptied connection to return its reserve to Savings."; +"lightning__move_to_spark__done_title" = "Moved to\nSpark"; +"lightning__move_to_spark__failed_nothing" = "Your Lightning connections have no balance left to move."; +"lightning__move_to_spark__failed_spark" = "Spark is not ready to receive yet. Try again in a moment."; +"lightning__move_to_spark__failed_title" = "Could not\nmove"; +"lightning__move_to_spark__failed_unknown" = "Something went wrong while preparing the move. Try again."; +"lightning__move_to_spark__fee" = "Routing fee"; +"lightning__move_to_spark__intro_text" = "Blocktank is closing its Lightning connections. Move your balance to Spark now: it is the lowest-fee option, and it stays in Spending."; +"lightning__move_to_spark__intro_title" = "Your connection\nis closing"; +"lightning__move_to_spark__move_half" = "Move Half"; +"lightning__move_to_spark__move_now" = "Move Now"; +"lightning__move_to_spark__move_rest" = "Move Rest"; +"lightning__move_to_spark__nav_title" = "Move To Spark"; +"lightning__move_to_spark__quoting" = "Finding a route and its fee…"; +"lightning__move_to_spark__swipe" = "Swipe To Move"; +"lightning__move_to_spark__too_large_none" = "Close the connection instead to return the balance to Savings."; +"lightning__move_to_spark__too_large_smaller" = "You can move it in smaller parts."; +"lightning__move_to_spark__too_large_text" = "{amount} is too large to route to Spark in one payment."; +"lightning__move_to_spark__too_large_title" = "Amount\ntoo large"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Claim fee"; +"lightning__spark_confirmations" = "{count}/3 confirmations"; +"lightning__spark_deposit" = "Deposit"; +"lightning__spark_deposit__choice" = "Spark can add it {when} for a higher fee, or after {standard} confirmations, about {minutes} minutes."; +"lightning__spark_deposit__claim_early" = "Use {when}"; +"lightning__spark_deposit__claimed" = "Spark is adding it to your spending balance."; +"lightning__spark_deposit__early_fee" = "Fee to use {when}"; +"lightning__spark_deposit__later" = "after {count} confirmations"; +"lightning__spark_deposit__later_one" = "after 1 confirmation"; +"lightning__spark_deposit__looking" = "Waiting for Spark to see your transfer."; +"lightning__spark_deposit__now" = "now"; +"lightning__spark_deposit__standard" = "Spark adds it to your spending balance after {standard} confirmations, about {minutes} minutes."; +"lightning__spark_deposit__standard_fee" = "Fee to wait"; +"lightning__spark_deposit__wait_button" = "Wait"; +"lightning__spark_deposit__waiting" = "Spark adds it to your spending balance {when}."; +"lightning__spark_deposit__waiting_next" = "Spark adds it to your spending balance after the next confirmation."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Claimed deposits"; +"lightning__spark_deposits_pending" = "Deposits in progress"; +"lightning__spark_provider" = "Provider"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "No limit"; +"lightning__spark_trust" = "This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_refused" = "Your savings payment didn't go through. Pay again, or fund from another wallet."; +"lightning__spark_exit__savings_total" = "Total from savings"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__earlier_addresses" = "Earlier exit addresses"; +"lightning__spark_exit__earlier_addresses_text" = "Earlier emergency exits used these funding addresses. Coins left at one stay yours: spend them with your recovery phrase and its derivation path."; +"lightning__spark_exit__earlier_path" = "Derivation path"; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you if the exit needs you. Without them, open Bitkit every few hours until the exit finishes."; +"lightning__spark_exit__note_notifications_button" = "Turn on notifications"; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__cancel_text" = "Spark goes back to how it was. If a payment reached the exit's fee funding address, Bitkit sends it back to savings."; +"lightning__spark_exit__cancel_title" = "Cancel exit?"; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Reserve balance (not spendable)"; "lightning__total_size" = "Total channel size"; "lightning__fees" = "Fees"; @@ -835,6 +1014,7 @@ "settings__backup__category_transaction_log" = "Transaction Log"; "settings__backup__category_wallet" = "Boosts & Transfers"; "settings__backup__category_settings" = "Settings"; +"settings__backup__category_spark_exit" = "Spark exit data"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profile"; @@ -874,6 +1054,14 @@ "settings__status__lightning_connection__ready" = "Open"; "settings__status__lightning_connection__pending" = "Opening..."; "settings__status__lightning_connection__error" = "No open connections"; +"settings__status__lightning_connection__spark" = "Using Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connected"; +"settings__status__spark__pending" = "Connecting..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Emergency Exit"; +"settings__status__exit__pending" = "In progress"; +"settings__status__exit__funding" = "Fee funding needed"; "settings__status__backup__title" = "Latest Full Data Backup"; "settings__status__backup__ready" = "Backed up"; "settings__status__backup__pending" = "Backing up..."; @@ -1247,6 +1435,10 @@ "wallet__send_sync_long_title" = "Connection\nissues"; "wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds."; "wallet__send_to" = "To"; +"wallet__send_swap_route" = "Route"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_withdrawal_settling__description" = "An earlier withdrawal from your spending balance is still settling. Try again once it completes."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; "wallet__recipient_manual" = "Enter Manually"; @@ -1337,6 +1529,7 @@ "wallet__payment_recipient_rejected" = "The recipient rejected this Lightning payment. Please check the invoice and try again."; "wallet__payment_abandoned" = "The Lightning payment was stopped before it completed."; "wallet__payment_expired" = "This Lightning payment expired. Please request a new invoice."; +"wallet__payment_restored_invoice" = "Bitkit can't confirm this invoice wasn't paid before you restored your wallet. Please request a new invoice."; "wallet__payment_timeout" = "Payment timed out. Please try again."; "wallet__payment_route_not_found" = "Bitkit couldn't find a Lightning route for this payment."; "wallet__payment_retries_exhausted" = "Bitkit tried several Lightning routes, but the payment could not be completed."; @@ -1366,7 +1559,7 @@ "wallet__receive_show_qr" = "QR Code"; "wallet__receive_text_lnfunds" = "Want to receive Lightning funds?"; "wallet__receive_spending" = "Receive Lightning Funds"; -"wallet__receive_spending_text" = "Enjoy instant and cheap bitcoin payments on the Lightning Network."; +"wallet__receive_spending_text" = "Enjoy instant and cheap transactions with friends, family, and merchants."; "wallet__receive_spending_title" = "Receive on spending balance"; "wallet__receive_cjit_error" = "Transaction Failed"; "wallet__receive_cjit_error_msg" = "Failed to send funds to your spending account."; @@ -1998,3 +2191,5 @@ "usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network."; "wallet__balance_updating" = "Updating balance…"; +"lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. If it fails, the amount stays in your connection."; +"lightning__move_to_spark__in_flight_title" = "Moving to\nSpark"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 28a3d93f3..3496976a6 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -196,12 +196,20 @@ "fee__custom__range" = "Depende de la tarifa"; "fee__custom__shortRange" = "Depende de la tarifa"; "fee__custom__longRange" = "Depende de la tasa"; +"lightning__transfer_intro__title" = "Saldo de\nGasto"; +"lightning__transfer_intro__text" = "Fondee el saldo de gastos para disfrutar de transacciones baratas e instantáneas con amigos y comercios."; +"lightning__transfer_intro__button" = "Empezar"; "lightning__funding__nav_title" = "Saldo para gastos"; +"lightning__funding__text" = "La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente."; "lightning__funding__title" = "Fondee su saldo de gastos"; "lightning__funding__button1" = "Transferir desde Ahorros"; "lightning__funding__button2" = "Usar Otra Billetera"; "lightning__funding__button3" = "Configuración Manual"; "lightning__funding_advanced__button_short" = "Configuración avanzada"; +"lightning__no_funds__title" = "Sin fondos disponibles"; +"lightning__no_funds__description" = "Para transferir fondos a su saldo de ahorros, primero tiene que enviar bitcoin a su billetera Bitkit."; +"lightning__no_funds__fund" = "Fondear billetera"; +"lightning__spending_amount__title" = "Transferir\na gastos"; "lightning__transfer__nav_title" = "Transferir fondos"; "lightning__transfer__confirm" = "Por favor,\nconfirme"; "lightning__transfer__custom_fee" = "Tarifa manual"; @@ -210,8 +218,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Es posible que su transacción de financiamiento ya se haya transmitido. Revise su actividad antes de volver a intentarlo."; "lightning__transfer_hw__reconnect_error_description" = "Vuelva a conectar su dispositivo de hardware; parece que está desconectado de su teléfono."; "lightning__transfer_hw__reconnect_error_title" = "Reconectar dispositivo de hardware"; +"lightning__transfer_hw__sign_title" = "Firme con\nsu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transacción\nfirmada"; "lightning__transfer_to_savings_button" = "Transferir a ahorros"; "lightning__transfer_to_spending_button" = "Transferir a gastos"; +"lightning__spending_intro__title" = "Transferir\na gastos"; +"lightning__spending_intro__text" = "Transfiera fondos a su saldo de gastos para disfrutar de transacciones baratas e instantáneas con amigos y comercios."; +"lightning__spending_intro__button" = "Empezar"; "lightning__transfer_failed__nav_title" = "Falla en transfer"; "lightning__transfer_failed__title" = "Transfer\nfallido"; "lightning__transfer_failed__text" = "Lo sentimos, Bitkit no pudo configurar tu saldo para gastar ni completar la transferencia. Intenta de nuevo."; @@ -222,11 +235,19 @@ "lightning__spending_amount__error_min__description" = "Se necesita un mínimo de ₿ {amount} para configurar tu saldo para gastar."; "lightning__spending_amount__error_max__title" = "Saldo máximo de gastos"; "lightning__spending_amount__error_max__description" = "El monto que puedes transferir a tu saldo para gastar está limitado a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Tu transferencia al saldo para gastos está limitada por la política de liquidez. Visita el Centro de ayuda para más detalles."; "lightning__spending_confirm__network_fee" = "Comisiones de red"; "lightning__spending_confirm__lsp_fee" = "Comisión de servicio"; "lightning__spending_confirm__amount" = "Hacia Gastos"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Las comisiones cambiaron"; +"lightning__spending_confirm__fees_changed_network" = "Las comisiones son {amount} más altas porque las tarifas o su saldo de ahorros cambiaron mientras estaba en esta pantalla. Deslice de nuevo para confirmar."; "lightning__savings_intro__title" = "Transferir\na ahorros"; "lightning__savings_intro__text" = "Transfiere tu saldo de gastos al de ahorros para guardar tus Bitcoin a largo plazo y asegurar tu futuro."; "lightning__savings_intro__button" = "Empezar"; @@ -238,6 +259,7 @@ "lightning__savings_confirm__amount_too_low" = "El monto es demasiado bajo para transferirlo a ahorros."; "lightning__savings_confirm__close_instead" = "Cerrar el canal en su lugar"; "lightning__savings_confirm__network_fee" = "Comisión de red"; +"lightning__savings_confirm__spark_fee" = "Comisión de Spark"; "lightning__savings_confirm__receive" = "Recibirá"; "lightning__savings_confirm__service_fee" = "Comisión de servicio"; "lightning__savings_advanced__title" = "Selecciona fondos\na transferir"; @@ -250,6 +272,13 @@ "lightning__savings_interrupted__nav_title" = "Transfer\nfallido"; "lightning__savings_interrupted__title" = "Manten Bitkit\nabierto"; "lightning__savings_interrupted__text" = "Los fondos todavía no se transfirieron. Bitkit intentará iniciar la transferencia durante los próximos 30 minutos. Por favor, mantenga la aplicación abierta."; +"lightning__savings_spark_failed__title" = "Transfer\nfallido"; +"lightning__savings_spark_failed__text" = "Tu saldo de Spark no se pudo mover a ahorros y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Los fondos de tus canales se están moviendo a ahorros, pero tu saldo de Spark no se pudo mover y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferencia de Spark fallida"; +"lightning__transfer_success__nav_title" = "Transferencia exitosa"; +"lightning__transfer_success__title_spending" = "Fondos movidos\na gastos"; +"lightning__transfer_success__text_spending" = "Tus fondos han sido transferidos y tu saldo para gastar está listo para usarse."; "lightning__transfer_success__title_savings" = "Fondos movidos\na gastos"; "lightning__transfer_success__text_savings" = "Sus fondos han sido transferidos. Podrá utilizar estos fondos inmediatamente."; "lightning__external__nav_title" = "Conexión Lightning"; @@ -272,6 +301,11 @@ "lightning__spending_label" = "Saldo para gastos"; "lightning__receiving_label" = "Capacidad de recepción"; "lightning__setting_up_header" = "En proceso"; +"lightning__setting_up_step1" = "Procesando pago"; +"lightning__setting_up_step2" = "Pago Efectuado"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando Bitkit"; "lightning__node_info" = "Nodo Lightning"; "lightning__node_id" = "ID del nodo LDK"; "lightning__node_disconnected" = "desconectado"; @@ -312,6 +346,120 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Comisión de reclamo"; +"lightning__spark_confirmations" = "{count}/3 confirmaciones"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "Spark puede agregarlo {when} por una comisión más alta, o después de {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "Spark lo está agregando a tu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Comisión para usar {when}"; +"lightning__spark_deposit__later" = "después de {count} confirmaciones"; +"lightning__spark_deposit__later_one" = "después de 1 confirmación"; +"lightning__spark_deposit__looking" = "Esperando a que Spark vea tu transferencia."; +"lightning__spark_deposit__now" = "ahora"; +"lightning__spark_deposit__standard" = "Spark lo agrega a tu saldo de gastos después de {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Comisión por esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "Spark lo agrega a tu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "Spark lo agrega a tu saldo de gastos después de la próxima confirmación."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos reclamados"; +"lightning__spark_deposits_pending" = "Depósitos en curso"; +"lightning__spark_provider" = "Proveedor"; +"lightning__spark_provider_value" = "Spark mediante Breez"; +"lightning__spark_receiving_unlimited" = "Sin límite"; +"lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; +"lightning__spark_exit__button" = "Salida de emergencia"; +"lightning__spark_exit__nav_title" = "Salida de emergencia"; +"lightning__spark_exit__intro_title" = "Sal de Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Usa esto solo si Spark deja de funcionarte. Mueve tu saldo de gastos a ahorros sin los operadores de Spark.\n\nTarda hasta 4 semanas, cuesta comisiones on-chain que se pagan desde ahorros, y las cantidades muy pequeñas pueden quedarse atrás. Se ejecuta en segundo plano y Bitkit te avisa si te necesita."; +"lightning__spark_exit__quote_title" = "Cotización\nde salida"; +"lightning__spark_exit__amount" = "Cantidad a retirar"; +"lightning__spark_exit__fees" = "Comisiones de red"; +"lightning__spark_exit__receive" = "Recibes aproximadamente"; +"lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros"; +"lightning__spark_exit__savings_refused" = "Tu pago desde ahorros no se completó. Paga de nuevo o financia desde otra billetera."; +"lightning__spark_exit__savings_total" = "Total desde ahorros"; +"lightning__spark_exit__funding" = "Fondos para comisiones necesarios"; +"lightning__spark_exit__left_behind" = "Se queda en Spark"; +"lightning__spark_exit__outside_exit" = "{amount} en Spark no forma parte de esta salida y se queda en Spark."; +"lightning__spark_exit__quote_loading" = "Obteniendo una cotización…"; +"lightning__spark_exit__quote_settling" = "Esperando a que Spark termine de mover tu saldo. Esto puede tardar hasta 5 minutos."; +"lightning__spark_exit__quote_error" = "No se pudo obtener una cotización de salida. Vuelve a intentarlo."; +"lightning__spark_exit__nothing" = "En este momento no se puede retirar nada."; +"lightning__spark_exit__uneconomic" = "Las comisiones son más altas que lo que recibirías."; +"lightning__spark_exit__funding_title" = "Financia las\ncomisiones"; +"lightning__spark_exit__funding_text" = "La salida paga sus comisiones con un pago on-chain aparte de {amount}. Lo que no gaste vuelve a ahorros al final."; +"lightning__spark_exit__fund_savings" = "Pagar desde ahorros"; +"lightning__spark_exit__fund_external" = "Financiar desde otro billetera"; +"lightning__spark_exit__fund_external_text" = "Envía al menos {amount} a esta dirección. La salida empieza en cuanto se confirme."; +"lightning__spark_exit__fund_insufficient" = "Tus ahorros no alcanzan para financiar las comisiones."; +"lightning__spark_exit__fund_seen" = "Pago detectado."; +"lightning__spark_exit__fund_partial" = "Recibidos {received} de {required}, envía {missing} más."; +"lightning__spark_exit__confirm_title" = "¿Iniciar\nla salida?"; +"lightning__spark_exit__confirm_text" = "No podrás usar estos fondos durante hasta 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar para salir"; +"lightning__spark_exit__progress_title" = "Salida\nen curso"; +"lightning__spark_exit__progress_text" = "Esto se ejecuta en segundo plano. Bitkit te avisa si te necesita."; +"lightning__spark_exit__step_funding" = "Confirmación de los fondos para comisiones"; +"lightning__spark_exit__step_publish" = "Publicando las transacciones de salida"; +"lightning__spark_exit__step_timelock" = "Esperando el bloqueo temporal"; +"lightning__spark_exit__step_sweep" = "Enviando a ahorros"; +"lightning__spark_exit__ready_in" = "Listo en {duration}"; +"lightning__spark_exit__done_title" = "Salida\ncompletada"; +"lightning__spark_exit__done_text" = "Tus fondos están en ahorros."; +"lightning__spark_exit__partial_text" = "{count} cantidades pequeñas se quedaron en Spark."; +"lightning__spark_exit__failed_leaves" = "Los operadores de Spark movieron {amount} on-chain y ya no está en Spark. Los operadores todavía pueden liberártelo."; +"lightning__spark_exit__leftover" = "{amount} de los fondos para comisiones sigue en {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros."; +"lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones."; +"lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación."; +"lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación."; +"lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar."; +"lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué."; +"lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué."; +"lightning__spark_exit__quote_outside" = "{amount} de tu saldo de Spark no se puede retirar en este momento: cantidades demasiado pequeñas para pagar sus propias comisiones o que aún se están moviendo dentro de Spark. Se queda en Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} no se puede retirar con esta tasa de comisión y se queda en Spark."; +"lightning__spark_exit__needs_funding_title" = "Se necesitan\nmás comisiones"; +"lightning__spark_exit__needs_funding_text" = "La salida necesita {amount} más para pagar sus comisiones. Agrega fondos para continuar."; +"lightning__spark_exit__failed_title" = "Salida\ndetenida"; +"lightning__spark_exit__cancel" = "Cancelar salida"; +"lightning__spark_exit__failed_text" = "No se pudo recuperar nada."; +"lightning__spark_exit__failed_stays" = "{amount} se queda en Spark."; +"lightning__spark_exit__notification_title" = "Salida de emergencia"; +"lightning__spark_exit__notification_body" = "Abre Bitkit para continuar tu salida."; +"lightning__spark_exit__notification_funding" = "Tu salida necesita más comisiones para continuar. Abre Bitkit para agregar fondos."; +"lightning__spark_exit__bg_refresh_off" = "La Actualización en segundo plano está desactivada para Bitkit, así que la salida solo avanza mientras Bitkit está abierto. Actívala en Ajustes para que funcione en segundo plano."; +"lightning__spark_exit__low_power" = "El Modo de bajo consumo pausa el trabajo en segundo plano. La salida continúa cuando se desactive o cuando abras Bitkit."; +"lightning__spark_exit__notifications_off" = "Las notificaciones están desactivadas, así que Bitkit no puede avisarte cuando la salida te necesite. Actívalas en Ajustes para recibir recordatorios."; +"lightning__spark_exit__not_moving" = "La salida no avanza: {error}"; +"lightning__spark_exit__fee_low" = "La red está más saturada que la tasa de comisión de esta salida, de {rate} sat/vB. Paga una comisión más alta o espera a que bajen las comisiones."; +"lightning__spark_exit__waiting_leaves" = "En este momento no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena."; +"lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros."; +"lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué."; +"lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás."; +"lightning__spark_exit__waiting_operators" = "{amount} que los operadores de Spark movieron on-chain espera a que lo liberen. Bitkit vuelve a pedirlo durante hasta una semana."; +"lightning__spark_exit__fee_rose" = "Las comisiones de red superan la comisión de esta salida. Puede ir más lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta"; +"lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad."; +"lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Vuelve a intentarlo."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión"; +"lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red."; +"lightning__spark_exit__cancelled_title" = "Salida\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Cancelaste la salida. Los fondos para comisiones que llegaron vuelven a ahorros."; +"lightning__spark_exit__cancel_failed" = "No se pudo verificar la dirección de financiación. Vuelve a intentarlo."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una comisión más alta dejaría parte de esta salida en Spark, así que no se ofrece ninguna. La salida se confirma cuando bajen las comisiones de red."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (no gastable)"; "lightning__total_size" = "Tamaño total del canal"; "lightning__fees" = "Tarifa"; @@ -853,6 +1001,7 @@ "settings__backup__category_transaction_log" = "Registro de transacciones"; "settings__backup__category_wallet" = "Impulsos y transferencias"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Datos de salida de Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Etiquetas"; "settings__backup__category_profile" = "Perfil"; @@ -892,6 +1041,14 @@ "settings__status__lightning_connection__ready" = "Abierta"; "settings__status__lightning_connection__pending" = "Abriendo..."; "settings__status__lightning_connection__error" = "No hay conexiones abiertas"; +"settings__status__lightning_connection__spark" = "Usando Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Sin conexión"; +"settings__status__exit__title" = "Salida de emergencia"; +"settings__status__exit__pending" = "En curso"; +"settings__status__exit__funding" = "Fondos para comisiones necesarios"; "settings__status__backup__title" = "Última copia de seguridad completa de los datos"; "settings__status__backup__ready" = "Copia de seguridad"; "settings__status__backup__pending" = "Respaldando..."; @@ -1237,6 +1394,9 @@ "wallet__send_sync_long_title" = "Problemas de\nconexión"; "wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Ruta"; +"wallet__send_paykit_no_route__title" = "No se puede pagar a este contacto"; +"wallet__send_paykit_no_route__description" = "Este contacto no tiene ningún método de pago que Bitkit pueda usar en este momento."; "wallet__recipient_contact" = "Contacto"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 3d1a49374..84565f8f1 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -193,12 +193,20 @@ "fee__custom__range" = "Depende de la comisión"; "fee__custom__shortRange" = "Depende de la tarifa"; "fee__custom__longRange" = "Depende de la comisión"; +"lightning__transfer_intro__title" = "Saldo de\nGasto"; +"lightning__transfer_intro__text" = "Rellene el saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes."; +"lightning__transfer_intro__button" = "Empezar"; "lightning__funding__nav_title" = "Saldo de gastos"; +"lightning__funding__text" = "La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde un monedero diferente."; "lightning__funding__title" = "Rellene su saldo de gastos"; "lightning__funding__button1" = "Transferir desde Ahorros"; "lightning__funding__button2" = "Usar Otro Monedero"; "lightning__funding__button3" = "Configuración Manual"; "lightning__funding_advanced__button_short" = "Configuración avanzada"; +"lightning__no_funds__title" = "No hay fondos disponibles"; +"lightning__no_funds__description" = "Antes de que pueda transferir fondos a su saldo de ahorros, tiene que enviar bitcoin a su monedero Bitkit."; +"lightning__no_funds__fund" = "Añadir fondos"; +"lightning__spending_amount__title" = "Transferir\na gasto"; "lightning__transfer__nav_title" = "Transferir fondos"; "lightning__transfer__confirm" = "Por favor,\nconfirme"; "lightning__transfer__swipe" = "Deslizar para transferir"; @@ -206,16 +214,29 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Es posible que su transacción de financiación se haya difundido. Revise su actividad antes de volver a intentarlo."; "lightning__transfer_hw__reconnect_error_description" = "Vuelva a conectar su dispositivo de hardware; parece que se ha desconectado de su teléfono."; "lightning__transfer_hw__reconnect_error_title" = "Reconecte el dispositivo de hardware"; +"lightning__transfer_hw__sign_title" = "Firme con\nsu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transacción\nfirmada"; "lightning__transfer_to_savings_button" = "Transferir a Ahorros"; "lightning__transfer_to_spending_button" = "Transferir a Gasto"; +"lightning__spending_intro__title" = "Transferir\na gasto"; +"lightning__spending_intro__text" = "Transfiera fondos a su saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes."; +"lightning__spending_intro__button" = "Empezar"; "lightning__transfer_failed__continue" = "Inténtelo de nuevo"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Máximo del Saldo de Gastos"; "lightning__spending_amount__error_max__description" = "La cantidad que puedes transferir a tu saldo de gastos está actualmente limitada a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Comisión de red"; "lightning__spending_confirm__lsp_fee" = "Comisión de servicio"; "lightning__spending_confirm__amount" = "A gasto"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Las comisiones han cambiado"; +"lightning__spending_confirm__fees_changed_network" = "Las comisiones son {amount} más altas porque las tasas de comisión o su saldo de ahorro cambiaron mientras estaba en esta pantalla. Deslice de nuevo para confirmar."; "lightning__savings_intro__title" = "Transferir\na ahorros"; "lightning__savings_intro__text" = "Transfiera su saldo de gasto a sus ahorros para almacenar su Bitcoin a largo plazo y asegurar su futuro."; "lightning__savings_intro__button" = "Empezar"; @@ -227,6 +248,7 @@ "lightning__savings_confirm__amount_too_low" = "La cantidad es demasiado baja para transferirla a ahorros."; "lightning__savings_confirm__close_instead" = "Cerrar el canal en su lugar"; "lightning__savings_confirm__network_fee" = "Comisión de red"; +"lightning__savings_confirm__spark_fee" = "Comisión de Spark"; "lightning__savings_confirm__receive" = "Recibirá"; "lightning__savings_confirm__service_fee" = "Comisión de servicio"; "lightning__savings_advanced__title" = "Seleccionar fondos\npara transferir"; @@ -239,6 +261,13 @@ "lightning__savings_interrupted__nav_title" = "Transferencia\ninterrumpida"; "lightning__savings_interrupted__title" = "Mantener Bitkit\nabierto"; "lightning__savings_interrupted__text" = "Los fondos todavía no se transfirieron. Bitkit intentará iniciar la transferencia durante los próximos 30 minutos. Por favor, mantenga la aplicación abierta."; +"lightning__savings_spark_failed__title" = "Transferencia\nfallida"; +"lightning__savings_spark_failed__text" = "Tu saldo de Spark no se pudo mover a ahorros y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Los fondos de tus canales se están moviendo a ahorros, pero tu saldo de Spark no se pudo mover y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferencia de Spark fallida"; +"lightning__transfer_success__nav_title" = "Transferencia exitosa"; +"lightning__transfer_success__title_spending" = "Fondos movidos\na gasto"; +"lightning__transfer_success__text_spending" = "Sus fondos han sido transferidos y su saldo de gasto está listo para usar."; "lightning__transfer_success__title_savings" = "Fondos movidos\na ahorros"; "lightning__transfer_success__text_savings" = "Sus fondos han sido transferidos. Puede usar estos fondos inmediatamente."; "lightning__external__nav_title" = "Conexión Lightning"; @@ -259,6 +288,11 @@ "lightning__savings" = "Ahorro"; "lightning__spending_label" = "Saldo para gastos"; "lightning__receiving_label" = "Capacidad de recepción"; +"lightning__setting_up_step1" = "Procesando Pago"; +"lightning__setting_up_step2" = "Pago Exitoso"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando Bitkit"; "lightning__node_info" = "Nodo Lightning"; "lightning__node_id" = "ID del nodo LDK"; "lightning__node_disconnected" = "desconectado"; @@ -296,6 +330,120 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Comisión de reclamo"; +"lightning__spark_confirmations" = "{count}/3 confirmaciones"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "Spark puede añadirlo {when} por una comisión más alta, o tras {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "Spark lo está añadiendo a tu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Comisión para usar {when}"; +"lightning__spark_deposit__later" = "tras {count} confirmaciones"; +"lightning__spark_deposit__later_one" = "tras 1 confirmación"; +"lightning__spark_deposit__looking" = "Esperando a que Spark vea tu transferencia."; +"lightning__spark_deposit__now" = "ahora"; +"lightning__spark_deposit__standard" = "Spark lo añade a tu saldo de gastos tras {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Comisión por esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "Spark lo añade a tu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "Spark lo añade a tu saldo de gastos tras la próxima confirmación."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos reclamados"; +"lightning__spark_deposits_pending" = "Depósitos en curso"; +"lightning__spark_provider" = "Proveedor"; +"lightning__spark_provider_value" = "Spark mediante Breez"; +"lightning__spark_receiving_unlimited" = "Sin límite"; +"lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; +"lightning__spark_exit__button" = "Salida de emergencia"; +"lightning__spark_exit__nav_title" = "Salida de emergencia"; +"lightning__spark_exit__intro_title" = "Sal de Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Usa esto solo si Spark deja de funcionarte. Mueve tu saldo de gastos a ahorros sin los operadores de Spark.\n\nTarda hasta 4 semanas, cuesta comisiones on-chain que se pagan desde ahorros, y las cantidades muy pequeñas pueden quedarse atrás. Se ejecuta en segundo plano y Bitkit te avisa si te necesita."; +"lightning__spark_exit__quote_title" = "Presupuesto\nde salida"; +"lightning__spark_exit__amount" = "Cantidad a retirar"; +"lightning__spark_exit__fees" = "Comisiones de red"; +"lightning__spark_exit__receive" = "Recibes aproximadamente"; +"lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros"; +"lightning__spark_exit__savings_refused" = "Tu pago desde ahorros no se completó. Paga de nuevo o financia desde otro monedero."; +"lightning__spark_exit__savings_total" = "Total desde ahorros"; +"lightning__spark_exit__funding" = "Fondos para comisiones necesarios"; +"lightning__spark_exit__left_behind" = "Se queda en Spark"; +"lightning__spark_exit__outside_exit" = "{amount} en Spark no forma parte de esta salida y se queda en Spark."; +"lightning__spark_exit__quote_loading" = "Obteniendo un presupuesto…"; +"lightning__spark_exit__quote_settling" = "Esperando a que Spark termine de mover tu saldo. Esto puede tardar hasta 5 minutos."; +"lightning__spark_exit__quote_error" = "No se pudo obtener un presupuesto de salida. Inténtalo de nuevo."; +"lightning__spark_exit__nothing" = "Ahora mismo no se puede retirar nada."; +"lightning__spark_exit__uneconomic" = "Las comisiones son más altas que lo que recibirías."; +"lightning__spark_exit__funding_title" = "Financia las\ncomisiones"; +"lightning__spark_exit__funding_text" = "La salida paga sus comisiones con un pago on-chain aparte de {amount}. Lo que no gaste vuelve a ahorros al final."; +"lightning__spark_exit__fund_savings" = "Pagar desde ahorros"; +"lightning__spark_exit__fund_external" = "Financiar desde otro monedero"; +"lightning__spark_exit__fund_external_text" = "Envía al menos {amount} a esta dirección. La salida empieza en cuanto se confirme."; +"lightning__spark_exit__fund_insufficient" = "Tus ahorros no alcanzan para financiar las comisiones."; +"lightning__spark_exit__fund_seen" = "Pago detectado."; +"lightning__spark_exit__fund_partial" = "Recibidos {received} de {required}, envía {missing} más."; +"lightning__spark_exit__confirm_title" = "¿Iniciar\nla salida?"; +"lightning__spark_exit__confirm_text" = "No podrás usar estos fondos durante hasta 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar para salir"; +"lightning__spark_exit__progress_title" = "Salida\nen curso"; +"lightning__spark_exit__progress_text" = "Esto se ejecuta en segundo plano. Bitkit te avisa si te necesita."; +"lightning__spark_exit__step_funding" = "Confirmación de los fondos para comisiones"; +"lightning__spark_exit__step_publish" = "Publicando las transacciones de salida"; +"lightning__spark_exit__step_timelock" = "Esperando el bloqueo temporal"; +"lightning__spark_exit__step_sweep" = "Enviando a ahorros"; +"lightning__spark_exit__ready_in" = "Listo en {duration}"; +"lightning__spark_exit__done_title" = "Salida\ncompletada"; +"lightning__spark_exit__done_text" = "Tus fondos están en ahorros."; +"lightning__spark_exit__partial_text" = "{count} cantidades pequeñas se quedaron en Spark."; +"lightning__spark_exit__failed_leaves" = "Los operadores de Spark movieron {amount} on-chain y ya no está en Spark. Los operadores todavía pueden liberártelo."; +"lightning__spark_exit__leftover" = "{amount} de los fondos para comisiones sigue en {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros."; +"lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones."; +"lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación."; +"lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación."; +"lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar."; +"lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué."; +"lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué."; +"lightning__spark_exit__quote_outside" = "{amount} de tu saldo de Spark no se puede retirar ahora mismo: cantidades demasiado pequeñas para pagar sus propias comisiones o que aún se están moviendo dentro de Spark. Se queda en Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} no se puede retirar con esta tasa de comisión y se queda en Spark."; +"lightning__spark_exit__needs_funding_title" = "Se necesitan\nmás comisiones"; +"lightning__spark_exit__needs_funding_text" = "La salida necesita {amount} más para pagar sus comisiones. Añade fondos para continuar."; +"lightning__spark_exit__failed_title" = "Salida\ndetenida"; +"lightning__spark_exit__cancel" = "Cancelar salida"; +"lightning__spark_exit__failed_text" = "No se pudo recuperar nada."; +"lightning__spark_exit__failed_stays" = "{amount} se queda en Spark."; +"lightning__spark_exit__notification_title" = "Salida de emergencia"; +"lightning__spark_exit__notification_body" = "Abre Bitkit para continuar tu salida."; +"lightning__spark_exit__notification_funding" = "Tu salida necesita más comisiones para continuar. Abre Bitkit para añadir fondos."; +"lightning__spark_exit__bg_refresh_off" = "La Actualización en segundo plano está desactivada para Bitkit, así que la salida solo avanza mientras Bitkit está abierto. Actívala en Ajustes para que funcione en segundo plano."; +"lightning__spark_exit__low_power" = "El Modo de bajo consumo pausa el trabajo en segundo plano. La salida continúa cuando se desactive o cuando abras Bitkit."; +"lightning__spark_exit__notifications_off" = "Las notificaciones están desactivadas, así que Bitkit no puede avisarte cuando la salida te necesite. Actívalas en Ajustes para recibir recordatorios."; +"lightning__spark_exit__not_moving" = "La salida no avanza: {error}"; +"lightning__spark_exit__fee_low" = "La red está más congestionada que la tasa de comisión de esta salida, de {rate} sat/vB. Paga una comisión más alta o espera a que bajen las comisiones."; +"lightning__spark_exit__waiting_leaves" = "Ahora mismo no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena."; +"lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros."; +"lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué."; +"lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás."; +"lightning__spark_exit__waiting_operators" = "{amount} que los operadores de Spark movieron on-chain espera a que lo liberen. Bitkit vuelve a pedirlo durante hasta una semana."; +"lightning__spark_exit__fee_rose" = "Las comisiones de red superan la comisión de esta salida. Puede ir más lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta"; +"lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad."; +"lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Inténtalo de nuevo."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión"; +"lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red."; +"lightning__spark_exit__cancelled_title" = "Salida\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Cancelaste la salida. Los fondos para comisiones que llegaron vuelven a ahorros."; +"lightning__spark_exit__cancel_failed" = "No se pudo comprobar la dirección de financiación. Inténtalo de nuevo."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una comisión más alta dejaría parte de esta salida en Spark, así que no se ofrece ninguna. La salida se confirma cuando bajen las comisiones de red."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva"; "lightning__total_size" = "Tamaño total del canal"; "lightning__fees" = "Comisiones"; @@ -800,6 +948,7 @@ "settings__backup__category_transaction_log" = "Registro de Transacciones"; "settings__backup__category_wallet" = "Impulsos y Transferencias"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Datos de salida de Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Etiquetas"; "settings__backup__category_profile" = "Perfil"; @@ -839,6 +988,14 @@ "settings__status__lightning_connection__ready" = "Abierta"; "settings__status__lightning_connection__pending" = "Abriendo..."; "settings__status__lightning_connection__error" = "Sin conexiones abiertas"; +"settings__status__lightning_connection__spark" = "Usando Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Sin conexión"; +"settings__status__exit__title" = "Salida de emergencia"; +"settings__status__exit__pending" = "En curso"; +"settings__status__exit__funding" = "Fondos para comisiones necesarios"; "settings__status__backup__title" = "Último Backup de Datos Completo"; "settings__status__backup__error" = "Falló al hacer una copia de seguridad completa"; "settings__adv__section_debug" = "Debug"; @@ -1145,6 +1302,9 @@ "wallet__send_sync_long_title" = "Problemas de\nconexión"; "wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Ruta"; +"wallet__send_paykit_no_route__title" = "No se puede pagar a este contacto"; +"wallet__send_paykit_no_route__description" = "Este contacto no tiene ningún método de pago que Bitkit pueda usar ahora mismo."; "wallet__recipient_contact" = "Contactar"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index add3b5ca6..63a31e945 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -196,12 +196,20 @@ "fee__custom__range" = "Dépend des frais"; "fee__custom__shortRange" = "Dépend des frais"; "fee__custom__longRange" = "Dépend du montant des frais"; +"lightning__transfer_intro__title" = "Dépenses \nSolde"; +"lightning__transfer_intro__text" = "Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants."; +"lightning__transfer_intro__button" = "Commencer"; "lightning__funding__nav_title" = "Solde Dépenses"; +"lightning__funding__text" = "Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille."; "lightning__funding__title" = "Financez votre solde Dépenses"; "lightning__funding__button1" = "Transfert depuis l\'épargne"; "lightning__funding__button2" = "Utiliser un autre portefeuille"; "lightning__funding__button3" = "Configuration manuelle"; "lightning__funding_advanced__button_short" = "Configuration avancée"; +"lightning__no_funds__title" = "Pas de fonds disponibles"; +"lightning__no_funds__description" = "Avant de pouvoir transférer des fonds depuis votre solde d\'épargne, vous devez envoyer des bitcoins à votre portefeuille Bitkit."; +"lightning__no_funds__fund" = "Alimentez votre portefeuille"; +"lightning__spending_amount__title" = "Transfert \nDépenses"; "lightning__transfer__nav_title" = "Transfert de fonds"; "lightning__transfer__confirm" = "Veuillez\nconfirmer"; "lightning__transfer__custom_fee" = "Frais sur mesure"; @@ -210,8 +218,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Votre transaction de financement a peut-être été diffusée. Veuillez vérifier votre activité avant de réessayer."; "lightning__transfer_hw__reconnect_error_description" = "Veuillez reconnecter votre appareil matériel, il semble être déconnecté de votre téléphone."; "lightning__transfer_hw__reconnect_error_title" = "Reconnecter l'appareil matériel"; +"lightning__transfer_hw__sign_title" = "Signez avec\nvotre appareil"; +"lightning__transfer_hw__signed_title" = "Transaction\nsignée"; "lightning__transfer_to_savings_button" = "Transférer vers l\'épargne"; "lightning__transfer_to_spending_button" = "Transférer vers les dépenses"; +"lightning__spending_intro__title" = "Transfert\naux dépenses"; +"lightning__spending_intro__text" = "Transférez des fonds vers votre solde Dépenses pour profiter de transactions instantanées et moins chers avec vos amis, votre famille et les commerçants."; +"lightning__spending_intro__button" = "Commencer"; "lightning__transfer_failed__nav_title" = "Échec du transfert"; "lightning__transfer_failed__title" = "Transfert\néchoué"; "lightning__transfer_failed__text" = "Désolé, Bitkit n’a pas pu configurer votre solde Dépenses ou effectuer le transfert. Veuillez réessayer."; @@ -222,11 +235,19 @@ "lightning__spending_amount__error_min__description" = "Un minimum de ₿ {amount} est nécessaire pour établir votre solde de Dépenses."; "lightning__spending_amount__error_max__title" = "Seuil Maximum Épargne"; "lightning__spending_amount__error_max__description" = "Le montant que vous pouvez transférer vers votre solde de dépenses est actuellement limité à ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Votre transfert vers le solde des dépenses est limité en raison de la politique de liquidité. Pour plus de détails, consultez le Centre d\'aide."; "lightning__spending_confirm__network_fee" = "Frais de réseau"; "lightning__spending_confirm__lsp_fee" = "Frais de service"; "lightning__spending_confirm__amount" = "Vers Dépenses"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Frais modifiés"; +"lightning__spending_confirm__fees_changed_network" = "Les frais sont plus élevés de {amount}, car les taux de frais ou votre solde d'épargne ont changé pendant que vous étiez sur cet écran. Glissez à nouveau pour confirmer."; "lightning__savings_intro__title" = "Transfert \nvers l\'Épargne"; "lightning__savings_intro__text" = "Transférez le solde de votre compte Dépenses vers votre Épargne pour conserver vos bitcoins à long terme."; "lightning__savings_intro__button" = "Commencer"; @@ -238,6 +259,7 @@ "lightning__savings_confirm__amount_too_low" = "Le montant est trop faible pour être transféré vers l'épargne."; "lightning__savings_confirm__close_instead" = "Fermer plutôt le canal"; "lightning__savings_confirm__network_fee" = "Frais de réseau"; +"lightning__savings_confirm__spark_fee" = "Frais Spark"; "lightning__savings_confirm__receive" = "Vous recevrez"; "lightning__savings_confirm__service_fee" = "Frais de service"; "lightning__savings_advanced__title" = "Sélectionnez les fonds\nà transférer"; @@ -250,6 +272,13 @@ "lightning__savings_interrupted__nav_title" = "Transfert \ninterrompu"; "lightning__savings_interrupted__title" = "Gardez Bitkit \nouvert"; "lightning__savings_interrupted__text" = "Les fonds n\'ont pas encore été transférés. Bitkit va essayer d\'initier le transfert au cours des 30 prochaines minutes. Veuillez garder votre application ouverte."; +"lightning__savings_spark_failed__title" = "Transfert\néchoué"; +"lightning__savings_spark_failed__text" = "Ton solde Spark n'a pas pu être transféré vers l'épargne et reste dans ton solde de dépenses."; +"lightning__savings_spark_failed__text_partial" = "Les fonds de tes canaux sont transférés vers l'épargne, mais ton solde Spark n'a pas pu l'être et reste dans ton solde de dépenses."; +"lightning__savings_spark_failed__toast" = "Échec du transfert Spark"; +"lightning__transfer_success__nav_title" = "Transfert réussi"; +"lightning__transfer_success__title_spending" = "Fonds transférés \nvers Dépenses"; +"lightning__transfer_success__text_spending" = "Vos fonds ont été transférés et votre solde Dépenses est prêt à être utilisé."; "lightning__transfer_success__title_savings" = "Fonds transférés \nà l\'épargne"; "lightning__transfer_success__text_savings" = "Vos fonds ont été transférés. Vous pouvez utiliser ces fonds immédiatement."; "lightning__external__nav_title" = "Connexion Lightning"; @@ -272,6 +301,11 @@ "lightning__spending_label" = "Solde Dépenses"; "lightning__receiving_label" = "Capacité de réception"; "lightning__setting_up_header" = "En transfert"; +"lightning__setting_up_step1" = "Traitement des paiements"; +"lightning__setting_up_step2" = "Paiement réussi"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuer à utiliser Bitkit"; "lightning__node_info" = "Nœud Lightning"; "lightning__node_id" = "ID du nœud LDK"; "lightning__node_disconnected" = "déconnecté"; @@ -312,6 +346,120 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Frais"; "lightning__balance" = "Solde"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Frais de réclamation"; +"lightning__spark_confirmations" = "{count}/3 confirmations"; +"lightning__spark_deposit" = "Dépôt"; +"lightning__spark_deposit__choice" = "Spark peut l'ajouter {when} pour des frais plus élevés, ou après {standard} confirmations, dans environ {minutes} minutes."; +"lightning__spark_deposit__claim_early" = "Utiliser {when}"; +"lightning__spark_deposit__claimed" = "Spark l'ajoute à votre solde de dépenses."; +"lightning__spark_deposit__early_fee" = "Frais pour l'utiliser {when}"; +"lightning__spark_deposit__later" = "après {count} confirmations"; +"lightning__spark_deposit__later_one" = "après 1 confirmation"; +"lightning__spark_deposit__looking" = "En attente que Spark voie votre transfert."; +"lightning__spark_deposit__now" = "maintenant"; +"lightning__spark_deposit__standard" = "Spark l'ajoute à votre solde de dépenses après {standard} confirmations, dans environ {minutes} minutes."; +"lightning__spark_deposit__standard_fee" = "Frais d'attente"; +"lightning__spark_deposit__wait_button" = "Attendre"; +"lightning__spark_deposit__waiting" = "Spark l'ajoute à votre solde de dépenses {when}."; +"lightning__spark_deposit__waiting_next" = "Spark l'ajoute à votre solde de dépenses après la prochaine confirmation."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Dépôts réclamés"; +"lightning__spark_deposits_pending" = "Dépôts en cours"; +"lightning__spark_provider" = "Fournisseur"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Sans limite"; +"lightning__spark_trust" = "Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain."; +"lightning__spark_exit__button" = "Sortie d'urgence"; +"lightning__spark_exit__nav_title" = "Sortie d'urgence"; +"lightning__spark_exit__intro_title" = "Quitter Spark\non-chain"; +"lightning__spark_exit__intro_text" = "N'utilisez ceci que si Spark ne fonctionne plus pour vous. Cela transfère votre solde de dépenses vers l'épargne sans les opérateurs Spark.\n\nCela prend jusqu'à 4 semaines, coûte des frais on-chain payés depuis l'épargne, et de très petits montants peuvent rester sur Spark. Cela s'exécute en arrière-plan, et Bitkit vous avertit s'il a besoin de vous."; +"lightning__spark_exit__quote_title" = "Devis de\nsortie"; +"lightning__spark_exit__amount" = "Montant à sortir"; +"lightning__spark_exit__fees" = "Frais de réseau"; +"lightning__spark_exit__receive" = "Vous recevez environ"; +"lightning__spark_exit__savings_fee" = "Frais de paiement depuis l'épargne"; +"lightning__spark_exit__savings_refused" = "Votre paiement depuis l'épargne n'a pas abouti. Payez à nouveau ou financez depuis un autre portefeuille."; +"lightning__spark_exit__savings_total" = "Total depuis l'épargne"; +"lightning__spark_exit__funding" = "Financement des frais requis"; +"lightning__spark_exit__left_behind" = "Reste sur Spark"; +"lightning__spark_exit__outside_exit" = "{amount} sur Spark ne fait pas partie de cette sortie et reste sur Spark."; +"lightning__spark_exit__quote_loading" = "Obtention d'un devis…"; +"lightning__spark_exit__quote_settling" = "En attente que Spark termine le transfert de votre solde. Cela peut prendre jusqu'à 5 minutes."; +"lightning__spark_exit__quote_error" = "Impossible d'obtenir un devis de sortie. Réessayez."; +"lightning__spark_exit__nothing" = "Rien ne peut être sorti pour le moment."; +"lightning__spark_exit__uneconomic" = "Les frais sont supérieurs à ce que vous recevriez."; +"lightning__spark_exit__funding_title" = "Financer les\nfrais"; +"lightning__spark_exit__funding_text" = "La sortie paie ses frais à partir d'un paiement on-chain séparé de {amount}. Ce qui n'est pas dépensé revient dans l'épargne à la fin."; +"lightning__spark_exit__fund_savings" = "Payer depuis l'épargne"; +"lightning__spark_exit__fund_external" = "Financer depuis un autre portefeuille"; +"lightning__spark_exit__fund_external_text" = "Envoyez au moins {amount} à cette adresse. La sortie démarre une fois le paiement confirmé."; +"lightning__spark_exit__fund_insufficient" = "Votre épargne ne peut pas couvrir le financement des frais."; +"lightning__spark_exit__fund_seen" = "Paiement détecté."; +"lightning__spark_exit__fund_partial" = "{received} sur {required} reçus, envoyez encore {missing}."; +"lightning__spark_exit__confirm_title" = "Lancer la\nsortie ?"; +"lightning__spark_exit__confirm_text" = "Vous ne pourrez pas utiliser ces fonds pendant jusqu'à 4 semaines."; +"lightning__spark_exit__swipe" = "Glissez pour sortir"; +"lightning__spark_exit__progress_title" = "Sortie\nen cours"; +"lightning__spark_exit__progress_text" = "Cela s'exécute en arrière-plan. Bitkit vous avertit s'il a besoin de vous."; +"lightning__spark_exit__step_funding" = "Confirmation du financement des frais"; +"lightning__spark_exit__step_publish" = "Publication des transactions de sortie"; +"lightning__spark_exit__step_timelock" = "En attente du timelock"; +"lightning__spark_exit__step_sweep" = "Envoi vers l'épargne"; +"lightning__spark_exit__ready_in" = "Prêt dans {duration}"; +"lightning__spark_exit__done_title" = "Sortie\nterminée"; +"lightning__spark_exit__done_text" = "Vos fonds sont dans l'épargne."; +"lightning__spark_exit__partial_text" = "{count} petits montants sont restés sur Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} a été déplacé on-chain par les opérateurs Spark et n'est plus sur Spark. Les opérateurs peuvent encore vous le libérer."; +"lightning__spark_exit__leftover" = "{amount} de financement des frais se trouve encore à {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} de financement des frais restant a été envoyé vers l'épargne."; +"lightning__spark_exit__leftover_sending" = "Envoi de {amount} de financement des frais restant vers l'épargne."; +"lightning__spark_exit__leftover_dust" = "{amount} de financement des frais restant est trop faible pour être envoyé et reste à {address}, chemin de dérivation {path}."; +"lightning__spark_exit__fund_paid" = "Payé depuis l'épargne, en attente de confirmation."; +"lightning__spark_exit__fund_pending" = "Paiement détecté, en attente de confirmation."; +"lightning__spark_exit__lost_leaves" = "{amount} a été réclamé on-chain par un ancien propriétaire de ces fonds et ne peut pas être récupéré."; +"lightning__spark_exit__notification_lost" = "{amount} de votre sortie ne peut pas être récupéré. Ouvrez Bitkit pour savoir pourquoi."; +"lightning__spark_exit__notification_failed" = "Votre sortie d'urgence s'est arrêtée. Ouvrez Bitkit pour savoir pourquoi."; +"lightning__spark_exit__quote_outside" = "{amount} de votre solde Spark ne peut pas être sorti pour le moment : des montants trop faibles pour payer leurs propres frais, ou encore en mouvement au sein de Spark. Ils restent sur Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} ne peut pas être sorti à ce taux de frais et reste sur Spark."; +"lightning__spark_exit__needs_funding_title" = "Frais supplémentaires\nrequis"; +"lightning__spark_exit__needs_funding_text" = "La sortie a besoin de {amount} de plus pour payer ses frais. Financez-la pour continuer."; +"lightning__spark_exit__failed_title" = "Sortie\narrêtée"; +"lightning__spark_exit__cancel" = "Annuler la sortie"; +"lightning__spark_exit__failed_text" = "Rien n'a pu être récupéré."; +"lightning__spark_exit__failed_stays" = "{amount} reste sur Spark."; +"lightning__spark_exit__notification_title" = "Sortie d'urgence"; +"lightning__spark_exit__notification_body" = "Ouvrez Bitkit pour poursuivre votre sortie."; +"lightning__spark_exit__notification_funding" = "Votre sortie a besoin de plus de frais pour continuer. Ouvrez Bitkit pour la financer."; +"lightning__spark_exit__bg_refresh_off" = "L'actualisation en arrière-plan est désactivée pour Bitkit, la sortie n'avance donc que lorsque Bitkit est ouvert. Activez-la dans Réglages pour qu'elle s'exécute en arrière-plan."; +"lightning__spark_exit__low_power" = "Le mode Économie d'énergie met en pause le travail en arrière-plan. La sortie reprend une fois qu'il est désactivé, ou lorsque vous ouvrez Bitkit."; +"lightning__spark_exit__notifications_off" = "Les notifications sont désactivées, Bitkit ne peut donc pas vous prévenir quand la sortie a besoin de vous. Activez-les dans Réglages pour recevoir des rappels."; +"lightning__spark_exit__not_moving" = "La sortie n'avance pas : {error}"; +"lightning__spark_exit__fee_low" = "Le réseau est plus chargé que le taux de frais de cette sortie de {rate} sat/vB. Payez des frais plus élevés, ou attendez que les frais baissent."; +"lightning__spark_exit__waiting_leaves" = "Une partie de cette sortie ne peut pas être lue on-chain pour le moment. Bitkit réessaie pendant une semaine au maximum."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit n'a pas pu lire la progression enregistrée de cette sortie, il la reconstruit donc à partir de la blockchain."; +"lightning__spark_exit__done_received" = "{amount} est arrivé dans votre épargne."; +"lightning__spark_exit__leftover_rejected" = "{amount} de financement des frais restant n'a pas pu être envoyé et reste à {address}, chemin de dérivation {path}."; +"lightning__spark_exit__notification_not_moving" = "Votre sortie d'urgence n'avance pas. Ouvrez Bitkit pour savoir pourquoi."; +"lightning__spark_exit__funding_split" = "Bitkit le divise en {count} parties, une pour chaque étape, afin qu'une étape bloquée ne retarde pas les autres."; +"lightning__spark_exit__waiting_operators" = "{amount} que les opérateurs Spark ont déplacé on-chain attend qu'ils le libèrent. Bitkit redemande pendant une semaine au maximum."; +"lightning__spark_exit__fee_rose" = "Les frais de réseau ont dépassé les frais de cette sortie. Elle pourrait ralentir."; +"lightning__spark_exit__fee_bump_button" = "Payer des frais plus élevés"; +"lightning__spark_exit__fee_bump_title" = "Payer des\nfrais plus élevés"; +"lightning__spark_exit__fee_bump_text" = "Cette sortie paie {rate} sat/vB, moins que ce que le réseau demande actuellement. Choisissez un taux plus élevé et Bitkit renvoie les étapes en attente. La différence est payée depuis l'épargne : le montant affiché à côté de chaque vitesse."; +"lightning__spark_exit__fee_bump_error" = "Impossible d'estimer des frais plus élevés. Réessayez."; +"lightning__spark_exit__fee_bump_confirm" = "Utiliser ces frais"; +"lightning__spark_exit__leftover_waiting" = "{amount} de monnaie de frais arrivera dans votre épargne lorsque les frais de réseau baisseront."; +"lightning__spark_exit__cancelled_title" = "Sortie\nannulée"; +"lightning__spark_exit__cancelled_text" = "Vous avez annulé la sortie. Le financement des frais déjà reçu retourne dans l'épargne."; +"lightning__spark_exit__cancel_failed" = "Impossible de vérifier l'adresse de financement. Réessayez."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Des frais plus élevés laisseraient une partie de cette sortie sur Spark, donc aucun n'est proposé. La sortie se confirme une fois que les frais de réseau baissent."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Solde de réserve (non dépensable)"; "lightning__total_size" = "Taille totale du canal"; "lightning__fees" = "Frais"; @@ -853,6 +1001,7 @@ "settings__backup__category_transaction_log" = "Journal des transactions"; "settings__backup__category_wallet" = "Boosts et transferts"; "settings__backup__category_settings" = "Réglages"; +"settings__backup__category_spark_exit" = "Données de sortie Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profil"; @@ -892,6 +1041,14 @@ "settings__status__lightning_connection__ready" = "Ouvrir"; "settings__status__lightning_connection__pending" = "Ouverture..."; "settings__status__lightning_connection__error" = "Pas de connexions ouvertes"; +"settings__status__lightning_connection__spark" = "Via Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connecté"; +"settings__status__spark__pending" = "Connexion..."; +"settings__status__spark__error" = "Hors ligne"; +"settings__status__exit__title" = "Sortie d'urgence"; +"settings__status__exit__pending" = "En cours"; +"settings__status__exit__funding" = "Financement des frais requis"; "settings__status__backup__title" = "Dernière sauvegarde complète"; "settings__status__backup__ready" = "Sauvegardé"; "settings__status__backup__pending" = "Sauvegarde..."; @@ -1245,6 +1402,9 @@ "wallet__send_sync_long_title" = "Problèmes de\nconnexion"; "wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes."; "wallet__send_to" = "Pour"; +"wallet__send_swap_route" = "Itinéraire"; +"wallet__send_paykit_no_route__title" = "Impossible de payer ce contact"; +"wallet__send_paykit_no_route__description" = "Ce contact n’a actuellement aucun moyen de paiement que Bitkit peut utiliser."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Coller la facture"; "wallet__recipient_manual" = "Entrer manuellement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 2ea47d593..15279da98 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -194,12 +194,20 @@ "fee__custom__range" = "Dipende dalla commissione"; "fee__custom__shortRange" = "Dipende dalle fee"; "fee__custom__longRange" = "Dipende dalla commissione"; +"lightning__transfer_intro__title" = "Conto\ndi Spesa"; +"lightning__transfer_intro__text" = "Manda fondi al tuo conto di spesa per usufruire di transazioni istantanee ed economiche con amici, familiari e commercianti."; +"lightning__transfer_intro__button" = "Inizia"; "lightning__funding__nav_title" = "Conto di Spesa"; +"lightning__funding__text" = "Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso."; "lightning__funding__title" = "Manda fondi al tuo conto di spesa"; "lightning__funding__button1" = "Trasferimento da risparmio"; "lightning__funding__button2" = "Usa un altro portafoglio"; "lightning__funding__button3" = "Configurazione manuale"; "lightning__funding_advanced__button_short" = "Configurazione Avanzata"; +"lightning__no_funds__title" = "Nessun fondo disponibile"; +"lightning__no_funds__description" = "Prima di poter trasferire fondi dal tuo conto di risparmio, devi inviare bitcoin al tuo portafoglio Bitkit."; +"lightning__no_funds__fund" = "Finanzia portafoglio"; +"lightning__spending_amount__title" = "Trasferisci\na spesa"; "lightning__transfer__nav_title" = "Trasferimento Fondi"; "lightning__transfer__confirm" = "Per favore\nconferma"; "lightning__transfer__swipe" = "Swipe Per Trasferire"; @@ -207,8 +215,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "La tua transazione di finanziamento potrebbe essere stata trasmessa. Controlla la tua attività prima di riprovare."; "lightning__transfer_hw__reconnect_error_description" = "Ricollega il tuo dispositivo hardware, sembra essere disconnesso dal telefono."; "lightning__transfer_hw__reconnect_error_title" = "Ricollega il Dispositivo Hardware"; +"lightning__transfer_hw__sign_title" = "Firma con\nil tuo dispositivo"; +"lightning__transfer_hw__signed_title" = "Transazione\nfirmata"; "lightning__transfer_to_savings_button" = "Trasferisci Ai Risparmi"; "lightning__transfer_to_spending_button" = "Trasferisci A Spesa"; +"lightning__spending_intro__title" = "Trasferisci\na spesa"; +"lightning__spending_intro__text" = "Trasferisci fondi al tuo conto di spesa per usufruire di transazioni istantanee ed economiche con amici, familiari e commercianti."; +"lightning__spending_intro__button" = "Inizia"; "lightning__transfer_failed__nav_title" = "Trasferimento fallito"; "lightning__transfer_failed__title" = "Trasferimento\nfallito"; "lightning__transfer_failed__text" = "Siamo spiacenti, Bitkit non è riuscito a impostare il conto di spesa o a completare il trasferimento. Per favore riprova."; @@ -217,10 +230,18 @@ "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Massimo Conto di Spesa"; "lightning__spending_amount__error_max__description" = "L'importo che puoi trasferire al tuo conto di spesa e' attualmente limitato a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Commissioni di rete"; "lightning__spending_confirm__lsp_fee" = "Commissioni di servizio"; "lightning__spending_confirm__amount" = "A spesa"; "lightning__spending_confirm__total" = "Totale"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Commissioni cambiate"; +"lightning__spending_confirm__fees_changed_network" = "Le commissioni sono aumentate di {amount} perché i tassi di commissione o il saldo dei tuoi risparmi sono cambiati mentre eri su questa schermata. Scorri di nuovo per confermare."; "lightning__savings_intro__title" = "Trasferisci\nai risparmi"; "lightning__savings_intro__text" = "Trasferisci il tuo saldo di spesa ai tuoi risparmi per conservare i tuoi Bitcoin a lungo termine e garantire il tuo futuro."; "lightning__savings_intro__button" = "Inizia"; @@ -232,6 +253,7 @@ "lightning__savings_confirm__amount_too_low" = "L'importo è troppo basso per essere trasferito ai risparmi."; "lightning__savings_confirm__close_instead" = "Chiudi invece il canale"; "lightning__savings_confirm__network_fee" = "Commissione di rete"; +"lightning__savings_confirm__spark_fee" = "Commissione Spark"; "lightning__savings_confirm__receive" = "Riceverai"; "lightning__savings_confirm__service_fee" = "Commissione di servizio"; "lightning__savings_advanced__title" = "Seleziona fondi\nda trasferire"; @@ -244,6 +266,13 @@ "lightning__savings_interrupted__nav_title" = "Trasferimento\ninterrotto"; "lightning__savings_interrupted__title" = "Mantieni Bitkit\naperto"; "lightning__savings_interrupted__text" = "I fondi non sono stati ancora trasferiti. Bitkit tenterà di avviare il trasferimento durante i prossimi 30 minuti. Per favore mantieni l'app aperta."; +"lightning__savings_spark_failed__title" = "Trasferimento\nfallito"; +"lightning__savings_spark_failed__text" = "Il tuo saldo Spark non è stato spostato nei risparmi e resta nel tuo saldo di spesa."; +"lightning__savings_spark_failed__text_partial" = "I fondi dei tuoi canali stanno passando ai risparmi, ma il tuo saldo Spark non è stato spostato e resta nel tuo saldo di spesa."; +"lightning__savings_spark_failed__toast" = "Trasferimento Spark non riuscito"; +"lightning__transfer_success__nav_title" = "Trasferimento Riuscito"; +"lightning__transfer_success__title_spending" = "Fondi trasferiti\na spesa"; +"lightning__transfer_success__text_spending" = "I tuoi fondi sono stati trasferiti e il tuo conto di spesa è pronto per essere utilizzato."; "lightning__transfer_success__title_savings" = "Fondi trasferiti\nai risparmi"; "lightning__transfer_success__text_savings" = "I tuoi fondi sono stati trasferiti. Puoi utilizzare questi fondi immediatamente."; "lightning__external__nav_title" = "Connessione Lightning"; @@ -266,6 +295,11 @@ "lightning__spending_label" = "Conto di spesa"; "lightning__receiving_label" = "Capacità di ricezione"; "lightning__setting_up_header" = "In trasferimento"; +"lightning__setting_up_step1" = "Elaborazione del pagamento"; +"lightning__setting_up_step2" = "Pagamento Riuscito"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continua ad utilizzare Bitkit"; "lightning__node_info" = "Nodo Lightning"; "lightning__node_id" = "ID Nodo LDK"; "lightning__node_disconnected" = "disconnesso"; @@ -305,6 +339,120 @@ "lightning__transaction" = "Transazione"; "lightning__order_fee" = "Fee Ordine"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Commissione di riscatto"; +"lightning__spark_confirmations" = "{count}/3 conferme"; +"lightning__spark_deposit" = "Deposito"; +"lightning__spark_deposit__choice" = "Spark può aggiungerlo {when} con una commissione più alta, oppure dopo {standard} conferme, tra circa {minutes} minuti."; +"lightning__spark_deposit__claim_early" = "Usa {when}"; +"lightning__spark_deposit__claimed" = "Spark lo sta aggiungendo al tuo saldo di spesa."; +"lightning__spark_deposit__early_fee" = "Commissione per usarlo {when}"; +"lightning__spark_deposit__later" = "dopo {count} conferme"; +"lightning__spark_deposit__later_one" = "dopo 1 conferma"; +"lightning__spark_deposit__looking" = "In attesa che Spark veda il tuo trasferimento."; +"lightning__spark_deposit__now" = "ora"; +"lightning__spark_deposit__standard" = "Spark lo aggiunge al tuo saldo di spesa dopo {standard} conferme, tra circa {minutes} minuti."; +"lightning__spark_deposit__standard_fee" = "Commissione per l'attesa"; +"lightning__spark_deposit__wait_button" = "Attendi"; +"lightning__spark_deposit__waiting" = "Spark lo aggiunge al tuo saldo di spesa {when}."; +"lightning__spark_deposit__waiting_next" = "Spark lo aggiunge al tuo saldo di spesa dopo la prossima conferma."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depositi riscattati"; +"lightning__spark_deposits_pending" = "Depositi in corso"; +"lightning__spark_provider" = "Fornitore"; +"lightning__spark_provider_value" = "Spark tramite Breez"; +"lightning__spark_receiving_unlimited" = "Nessun limite"; +"lightning__spark_trust" = "Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain."; +"lightning__spark_exit__button" = "Uscita di emergenza"; +"lightning__spark_exit__nav_title" = "Uscita di emergenza"; +"lightning__spark_exit__intro_title" = "Lascia Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Usala solo se Spark smette di funzionare per te. Sposta il tuo saldo di spesa nei risparmi senza gli operatori Spark.\n\nRichiede fino a 4 settimane, costa commissioni on-chain pagate dai risparmi e importi molto piccoli possono restare su Spark. Funziona in background e Bitkit ti avvisa se ha bisogno di te."; +"lightning__spark_exit__quote_title" = "Preventivo\ndi uscita"; +"lightning__spark_exit__amount" = "Importo da far uscire"; +"lightning__spark_exit__fees" = "Commissioni di rete"; +"lightning__spark_exit__receive" = "Ricevi circa"; +"lightning__spark_exit__savings_fee" = "Commissione del pagamento dai risparmi"; +"lightning__spark_exit__savings_refused" = "Il tuo pagamento dai risparmi non è andato a buon fine. Paga di nuovo o finanzia da un altro portafoglio."; +"lightning__spark_exit__savings_total" = "Totale dai risparmi"; +"lightning__spark_exit__funding" = "Finanziamento commissioni necessario"; +"lightning__spark_exit__left_behind" = "Resta su Spark"; +"lightning__spark_exit__outside_exit" = "{amount} su Spark non fa parte di questa uscita e resta su Spark."; +"lightning__spark_exit__quote_loading" = "Recupero del preventivo…"; +"lightning__spark_exit__quote_settling" = "In attesa che Spark finisca di spostare il tuo saldo. Può richiedere fino a 5 minuti."; +"lightning__spark_exit__quote_error" = "Impossibile ottenere un preventivo di uscita. Riprova."; +"lightning__spark_exit__nothing" = "Al momento non è possibile far uscire nulla."; +"lightning__spark_exit__uneconomic" = "Le commissioni superano quanto riceveresti."; +"lightning__spark_exit__funding_title" = "Finanzia le\ncommissioni"; +"lightning__spark_exit__funding_text" = "L'uscita paga le sue commissioni con un pagamento on-chain separato di {amount}. Ciò che non spende torna nei risparmi alla fine."; +"lightning__spark_exit__fund_savings" = "Paga dai risparmi"; +"lightning__spark_exit__fund_external" = "Finanzia da un altro wallet"; +"lightning__spark_exit__fund_external_text" = "Invia almeno {amount} a questo indirizzo. L'uscita inizia appena il pagamento è confermato."; +"lightning__spark_exit__fund_insufficient" = "I tuoi risparmi non bastano a coprire il finanziamento delle commissioni."; +"lightning__spark_exit__fund_seen" = "Pagamento rilevato."; +"lightning__spark_exit__fund_partial" = "Ricevuti {received} su {required}, invia altri {missing}."; +"lightning__spark_exit__confirm_title" = "Avviare\nl'uscita?"; +"lightning__spark_exit__confirm_text" = "Non potrai usare questi fondi per fino a 4 settimane."; +"lightning__spark_exit__swipe" = "Scorri Per Uscire"; +"lightning__spark_exit__progress_title" = "Uscita\nin corso"; +"lightning__spark_exit__progress_text" = "Funziona in background. Bitkit ti avvisa se ha bisogno di te."; +"lightning__spark_exit__step_funding" = "Conferma del finanziamento commissioni"; +"lightning__spark_exit__step_publish" = "Pubblicazione delle transazioni di uscita"; +"lightning__spark_exit__step_timelock" = "In attesa del timelock"; +"lightning__spark_exit__step_sweep" = "Invio ai risparmi"; +"lightning__spark_exit__ready_in" = "Pronto tra {duration}"; +"lightning__spark_exit__done_title" = "Uscita\ncompletata"; +"lightning__spark_exit__done_text" = "I tuoi fondi sono nei risparmi."; +"lightning__spark_exit__partial_text" = "{count} piccoli importi sono rimasti su Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} è stato spostato on-chain dagli operatori Spark e non è su Spark. Gli operatori possono ancora rilasciarlo a te."; +"lightning__spark_exit__leftover" = "{amount} del finanziamento commissioni è ancora su {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} di finanziamento commissioni avanzato è stato inviato ai risparmi."; +"lightning__spark_exit__leftover_sending" = "Invio di {amount} di finanziamento commissioni avanzato ai risparmi."; +"lightning__spark_exit__leftover_dust" = "{amount} di finanziamento commissioni avanzato è troppo poco per essere inviato e resta su {address}, percorso di derivazione {path}."; +"lightning__spark_exit__fund_paid" = "Pagato dai risparmi, in attesa di conferma."; +"lightning__spark_exit__fund_pending" = "Pagamento rilevato, in attesa di conferma."; +"lightning__spark_exit__lost_leaves" = "{amount} è stato riscattato on-chain da un precedente proprietario di questi fondi e non può essere recuperato."; +"lightning__spark_exit__notification_lost" = "{amount} della tua uscita non può essere recuperato. Apri Bitkit per scoprire perché."; +"lightning__spark_exit__notification_failed" = "La tua uscita di emergenza si è fermata. Apri Bitkit per scoprire perché."; +"lightning__spark_exit__quote_outside" = "{amount} del tuo saldo Spark non può uscire al momento: importi troppo piccoli per pagare le proprie commissioni, o ancora in movimento dentro Spark. Resta su Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} non può uscire con questo tasso di commissione e resta su Spark."; +"lightning__spark_exit__needs_funding_title" = "Servono altre\ncommissioni"; +"lightning__spark_exit__needs_funding_text" = "L'uscita ha bisogno di altri {amount} per pagare le commissioni. Finanziala per continuare."; +"lightning__spark_exit__failed_title" = "Uscita\ninterrotta"; +"lightning__spark_exit__cancel" = "Annulla uscita"; +"lightning__spark_exit__failed_text" = "Non è stato possibile recuperare nulla."; +"lightning__spark_exit__failed_stays" = "{amount} resta su Spark."; +"lightning__spark_exit__notification_title" = "Uscita di emergenza"; +"lightning__spark_exit__notification_body" = "Apri Bitkit per continuare la tua uscita."; +"lightning__spark_exit__notification_funding" = "La tua uscita ha bisogno di altre commissioni per continuare. Apri Bitkit per finanziarla."; +"lightning__spark_exit__bg_refresh_off" = "L'aggiornamento app in background è disattivato per Bitkit, quindi l'uscita avanza solo mentre Bitkit è aperto. Attivalo nelle Impostazioni per farla procedere in background."; +"lightning__spark_exit__low_power" = "La modalità Risparmio energetico sospende le attività in background. L'uscita riprende quando la disattivi o quando apri Bitkit."; +"lightning__spark_exit__notifications_off" = "Le notifiche sono disattivate, quindi Bitkit non può avvisarti quando l'uscita ha bisogno di te. Attivale nelle Impostazioni per ricevere promemoria."; +"lightning__spark_exit__not_moving" = "L'uscita non avanza: {error}"; +"lightning__spark_exit__fee_low" = "La rete è più congestionata del tasso di commissione di questa uscita di {rate} sat/vB. Paga una commissione più alta o attendi che le commissioni scendano."; +"lightning__spark_exit__waiting_leaves" = "Parte di questa uscita non può essere letta on-chain al momento. Bitkit riprova per un massimo di una settimana."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit non è riuscito a leggere i progressi salvati di questa uscita, quindi li ricostruisce dalla blockchain."; +"lightning__spark_exit__done_received" = "{amount} è arrivato nei tuoi risparmi."; +"lightning__spark_exit__leftover_rejected" = "{amount} di finanziamento commissioni avanzato non è stato inviato e resta su {address}, percorso di derivazione {path}."; +"lightning__spark_exit__notification_not_moving" = "La tua uscita di emergenza non avanza. Apri Bitkit per scoprire perché."; +"lightning__spark_exit__funding_split" = "Bitkit lo divide in {count} parti, una per ogni passaggio, così un passaggio bloccato non può trattenere gli altri."; +"lightning__spark_exit__waiting_operators" = "{amount} che gli operatori Spark hanno spostato on-chain attende che lo rilascino. Bitkit lo richiede per un massimo di una settimana."; +"lightning__spark_exit__fee_rose" = "Le commissioni di rete hanno superato la commissione di questa uscita. Potrebbe rallentare."; +"lightning__spark_exit__fee_bump_button" = "Paga una commissione più alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncommissione più alta"; +"lightning__spark_exit__fee_bump_text" = "Questa uscita paga {rate} sat/vB, meno di quanto chiede ora la rete. Scegli un tasso più alto e Bitkit invia di nuovo i passaggi in attesa. Paga la differenza dai risparmi: l'importo accanto a ogni velocità."; +"lightning__spark_exit__fee_bump_error" = "Impossibile calcolare una commissione più alta. Riprova."; +"lightning__spark_exit__fee_bump_confirm" = "Usa questa commissione"; +"lightning__spark_exit__leftover_waiting" = "{amount} di resto delle commissioni arriverà nei tuoi risparmi quando le commissioni di rete scenderanno."; +"lightning__spark_exit__cancelled_title" = "Uscita\nannullata"; +"lightning__spark_exit__cancelled_text" = "Hai annullato l'uscita. Il finanziamento commissioni arrivato torna nei risparmi."; +"lightning__spark_exit__cancel_failed" = "Impossibile controllare l'indirizzo di finanziamento. Riprova."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una commissione più alta lascerebbe parte di questa uscita su Spark, quindi non ne viene offerta nessuna. L'uscita si conferma quando le commissioni di rete scendono."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo di riserva (non spendibile)"; "lightning__total_size" = "Dimensione totale del canale"; "lightning__fees" = "Commissioni"; @@ -833,6 +981,7 @@ "settings__backup__category_transaction_log" = "Log delle transazioni"; "settings__backup__category_wallet" = "Potenziamenti e Trasferimenti"; "settings__backup__category_settings" = "Impostazioni"; +"settings__backup__category_spark_exit" = "Dati di uscita Spark"; "settings__backup__category_widgets" = "Widget"; "settings__backup__category_tags" = "Tag"; "settings__backup__category_profile" = "Profilo"; @@ -872,6 +1021,14 @@ "settings__status__lightning_connection__ready" = "Aperta"; "settings__status__lightning_connection__pending" = "In apertura..."; "settings__status__lightning_connection__error" = "Nessuna connessione aperta"; +"settings__status__lightning_connection__spark" = "Tramite Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connesso"; +"settings__status__spark__pending" = "In connessione..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Uscita di emergenza"; +"settings__status__exit__pending" = "In corso"; +"settings__status__exit__funding" = "Finanziamento commissioni necessario"; "settings__status__backup__title" = "Ultimo backup completo dei dati"; "settings__status__backup__ready" = "Backup eseguito"; "settings__status__backup__pending" = "Backup in esecuzione..."; @@ -1210,6 +1367,9 @@ "wallet__send_sync_long_title" = "Problemi di\nconnessione"; "wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi."; "wallet__send_to" = "A"; +"wallet__send_swap_route" = "Percorso"; +"wallet__send_paykit_no_route__title" = "Impossibile pagare questo contatto"; +"wallet__send_paykit_no_route__description" = "Al momento questo contatto non ha un metodo di pagamento che Bitkit possa usare."; "wallet__recipient_contact" = "Contatto"; "wallet__recipient_invoice" = "Incolla fattura"; "wallet__recipient_manual" = "Inserisci Manualmente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 989bebf7a..818c1d9a0 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Afhankelijk van de vergoeding"; "fee__custom__shortRange" = "Afhankelijk van het tarief"; "fee__custom__longRange" = "Afhankelijk van de kosten"; +"lightning__transfer_intro__title" = "Bestedings\nSaldo"; +"lightning__transfer_intro__text" = "Activeer uw bestedingssaldo en profiteer van directe en goedkope transacties met vrienden, familie en winkeliers."; +"lightning__transfer_intro__button" = "Starten"; "lightning__funding__nav_title" = "Bestedingssaldo"; +"lightning__funding__text" = "De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet."; "lightning__funding__title" = "Vul uw bestedingssaldo"; "lightning__funding__button1" = "Overboeking van Spaargeld"; "lightning__funding__button2" = "Gebruik Andere Wallet"; "lightning__funding__button3" = "Handmatige configuratie"; "lightning__funding_advanced__button_short" = "Geavanceerde Configuratie"; +"lightning__no_funds__title" = "Geen Geld Beschikbaar"; +"lightning__no_funds__description" = "Voordat u geld van uw spaarsaldo kunt overboeken, moet u bitcoin naar uw Bitkit wallet sturen."; +"lightning__no_funds__fund" = "Wallet aufladen"; +"lightning__spending_amount__title" = "Overboeking\nnaar bestedingssaldo"; "lightning__transfer__nav_title" = "Geld Overboeken"; "lightning__transfer__confirm" = "Bevestig\nalstublieft"; "lightning__transfer__custom_fee" = "Benutzerdefiniert fee"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Uw financieringstransactie is mogelijk al verzonden. Controleer uw activiteit voordat u het opnieuw probeert."; "lightning__transfer_hw__reconnect_error_description" = "Verbind uw hardwareapparaat opnieuw, de verbinding met uw telefoon lijkt verbroken."; "lightning__transfer_hw__reconnect_error_title" = "Hardwareapparaat opnieuw verbinden"; +"lightning__transfer_hw__sign_title" = "Onderteken met\nuw apparaat"; +"lightning__transfer_hw__signed_title" = "Transactie\nondertekend"; "lightning__transfer_to_savings_button" = "Overboeken Naar Spaargeld"; "lightning__transfer_to_spending_button" = "Overboeken Naar Bestedingssaldo"; +"lightning__spending_intro__title" = "Overboeking\nnaar bestedingssaldo"; +"lightning__spending_intro__text" = "Boek geld over naar uw bestedingssaldo om te genieten van directe en goedkope transacties met vrienden, familie en winkeliers."; +"lightning__spending_intro__button" = "Starten"; "lightning__transfer_failed__nav_title" = "Overboeking Mislukt"; "lightning__transfer_failed__title" = "Overboeking\nMislukt"; "lightning__transfer_failed__text" = "Sorry, Bitkit kon uw bestedingssaldo niet opzetten of uw overboeking niet voltooien. Probeer het alstublieft opnieuw."; @@ -220,10 +233,18 @@ "lightning__spending_amount__error_min__title" = "Minimales Sparguthaben"; "lightning__spending_amount__error_max__title" = "Maximaler Ausgabensaldo"; "lightning__spending_amount__error_max__description" = "Der Betrag, den Sie auf Ihr Ausgabenkonto übertragen können, ist derzeit auf ₿ {amount} begrenzt."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Netwerkkosten"; "lightning__spending_confirm__lsp_fee" = "Servicekosten"; "lightning__spending_confirm__amount" = "Naar bestedingssaldo"; "lightning__spending_confirm__total" = "Totaal"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Vergoedingen gewijzigd"; +"lightning__spending_confirm__fees_changed_network" = "De vergoedingen zijn {amount} hoger, omdat de vergoedingstarieven of uw spaargeld zijn gewijzigd terwijl u op dit scherm was. Veeg opnieuw om te bevestigen."; "lightning__savings_intro__title" = "Overboeking\nnaar spaargeld"; "lightning__savings_intro__text" = "Boek uw bestedingssaldo over naar uw spaargeld om uw Bitcoin langdurig te bewaren en uw toekomst veilig te stellen."; "lightning__savings_intro__button" = "Starten"; @@ -235,6 +256,7 @@ "lightning__savings_confirm__amount_too_low" = "Bedrag is te laag om naar spaargeld over te boeken."; "lightning__savings_confirm__close_instead" = "In plaats daarvan kanaal sluiten"; "lightning__savings_confirm__network_fee" = "Netwerkvergoeding"; +"lightning__savings_confirm__spark_fee" = "Spark-vergoeding"; "lightning__savings_confirm__receive" = "U ontvangt"; "lightning__savings_confirm__service_fee" = "Servicevergoeding"; "lightning__savings_advanced__title" = "Selecteer geld\nom over te boeken"; @@ -247,6 +269,13 @@ "lightning__savings_interrupted__nav_title" = "Overboeking\nonderbroken"; "lightning__savings_interrupted__title" = "Houd Bitkit\nopen"; "lightning__savings_interrupted__text" = "Het geld is nog niet overgeboekt. Bitkit zal proberen de overboeking gedurende 30 minuten te initiëren. Houd uw app open."; +"lightning__savings_spark_failed__title" = "Overboeking\nMislukt"; +"lightning__savings_spark_failed__text" = "Je Spark-saldo kon niet naar sparen en blijft in je bestedingssaldo."; +"lightning__savings_spark_failed__text_partial" = "Je kanaalsaldo gaat naar sparen, maar je Spark-saldo kon niet mee en blijft in je bestedingssaldo."; +"lightning__savings_spark_failed__toast" = "Spark-overboeking mislukt"; +"lightning__transfer_success__nav_title" = "Overboeking Voltooid"; +"lightning__transfer_success__title_spending" = "Geld verplaatst\nnaar bestedingssaldo"; +"lightning__transfer_success__text_spending" = "Uw geld is overgeboekt en uw bestedingssaldo is klaar voor gebruik."; "lightning__transfer_success__title_savings" = "Geld verplaatst\nnaar spaargeld"; "lightning__transfer_success__text_savings" = "Uw geld is overgeboekt. U kunt dit geld direct gebruiken."; "lightning__external__nav_title" = "Lightning-verbinding"; @@ -269,6 +298,11 @@ "lightning__spending_label" = "Bestedingssaldo"; "lightning__receiving_label" = "Ontvangstcapaciteit"; "lightning__setting_up_header" = "Onderweg"; +"lightning__setting_up_step1" = "Betaling Verwerken"; +"lightning__setting_up_step2" = "Betaling Voltooid"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Ga Door Met Bitkit"; "lightning__node_info" = "Lightning Node"; "lightning__node_id" = "LDK Node ID"; "lightning__node_disconnected" = "verbinding verbroken"; @@ -309,6 +343,120 @@ "lightning__transaction" = "Transactie"; "lightning__order_fee" = "Order kosten"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Claimkosten"; +"lightning__spark_confirmations" = "{count}/3 bevestigingen"; +"lightning__spark_deposit" = "Storting"; +"lightning__spark_deposit__choice" = "Spark kan het {when} toevoegen voor een hogere vergoeding, of na {standard} bevestigingen, over ongeveer {minutes} minuten."; +"lightning__spark_deposit__claim_early" = "{when} gebruiken"; +"lightning__spark_deposit__claimed" = "Spark voegt het toe aan je bestedingssaldo."; +"lightning__spark_deposit__early_fee" = "Vergoeding om {when} te gebruiken"; +"lightning__spark_deposit__later" = "na {count} bevestigingen"; +"lightning__spark_deposit__later_one" = "na 1 bevestiging"; +"lightning__spark_deposit__looking" = "Wachten tot Spark je overboeking ziet."; +"lightning__spark_deposit__now" = "nu"; +"lightning__spark_deposit__standard" = "Spark voegt het na {standard} bevestigingen toe aan je bestedingssaldo, over ongeveer {minutes} minuten."; +"lightning__spark_deposit__standard_fee" = "Vergoeding om te wachten"; +"lightning__spark_deposit__wait_button" = "Wachten"; +"lightning__spark_deposit__waiting" = "Spark voegt het {when} toe aan je bestedingssaldo."; +"lightning__spark_deposit__waiting_next" = "Spark voegt het na de volgende bevestiging toe aan je bestedingssaldo."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Geclaimde stortingen"; +"lightning__spark_deposits_pending" = "Lopende stortingen"; +"lightning__spark_provider" = "Aanbieder"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Geen limiet"; +"lightning__spark_trust" = "Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit."; +"lightning__spark_exit__button" = "Nooduitgang"; +"lightning__spark_exit__nav_title" = "Nooduitgang"; +"lightning__spark_exit__intro_title" = "Spark verlaten\non-chain"; +"lightning__spark_exit__intro_text" = "Gebruik dit alleen als Spark niet meer voor je werkt. Het zet je bestedingssaldo over naar spaargeld zonder de Spark-operators.\n\nHet duurt tot 4 weken, kost on-chain vergoedingen die uit je spaargeld worden betaald, en zeer kleine bedragen kunnen achterblijven. Het loopt op de achtergrond, en Bitkit stuurt je een melding als het je nodig heeft."; +"lightning__spark_exit__quote_title" = "Exit-\nofferte"; +"lightning__spark_exit__amount" = "Bedrag om op te nemen"; +"lightning__spark_exit__fees" = "Netwerkkosten"; +"lightning__spark_exit__receive" = "Je ontvangt ongeveer"; +"lightning__spark_exit__savings_fee" = "Vergoeding betaling uit spaargeld"; +"lightning__spark_exit__savings_refused" = "Je betaling uit spaargeld is niet gelukt. Betaal opnieuw of financier vanuit een andere wallet."; +"lightning__spark_exit__savings_total" = "Totaal uit spaargeld"; +"lightning__spark_exit__funding" = "Benodigde vergoedingsdekking"; +"lightning__spark_exit__left_behind" = "Blijft op Spark"; +"lightning__spark_exit__outside_exit" = "{amount} op Spark valt niet onder deze exit en blijft op Spark."; +"lightning__spark_exit__quote_loading" = "Offerte ophalen…"; +"lightning__spark_exit__quote_settling" = "Wachten tot Spark klaar is met het verplaatsen van je saldo. Dit kan tot 5 minuten duren."; +"lightning__spark_exit__quote_error" = "Kon geen exit-offerte ophalen. Probeer het opnieuw."; +"lightning__spark_exit__nothing" = "Er kan nu niets worden opgenomen."; +"lightning__spark_exit__uneconomic" = "De vergoedingen zijn hoger dan wat je zou ontvangen."; +"lightning__spark_exit__funding_title" = "Dek de\nvergoedingen"; +"lightning__spark_exit__funding_text" = "De exit betaalt zijn vergoedingen uit een aparte on-chain betaling van {amount}. Wat niet wordt gebruikt, komt aan het einde terug naar je spaargeld."; +"lightning__spark_exit__fund_savings" = "Betalen uit spaargeld"; +"lightning__spark_exit__fund_external" = "Betalen vanuit een andere wallet"; +"lightning__spark_exit__fund_external_text" = "Stuur minimaal {amount} naar dit adres. De exit start zodra de betaling is bevestigd."; +"lightning__spark_exit__fund_insufficient" = "Je spaargeld is niet genoeg om de vergoedingen te dekken."; +"lightning__spark_exit__fund_seen" = "Betaling gezien."; +"lightning__spark_exit__fund_partial" = "{received} van {required} ontvangen, stuur nog {missing}."; +"lightning__spark_exit__confirm_title" = "Exit\nstarten?"; +"lightning__spark_exit__confirm_text" = "Je kunt dit geld tot 4 weken niet gebruiken."; +"lightning__spark_exit__swipe" = "Veeg Om Te Verlaten"; +"lightning__spark_exit__progress_title" = "Exit\nbezig"; +"lightning__spark_exit__progress_text" = "Dit loopt op de achtergrond. Bitkit stuurt je een melding als het je nodig heeft."; +"lightning__spark_exit__step_funding" = "Vergoedingsdekking wordt bevestigd"; +"lightning__spark_exit__step_publish" = "Exit-transacties publiceren"; +"lightning__spark_exit__step_timelock" = "Wachten op de timelock"; +"lightning__spark_exit__step_sweep" = "Versturen naar spaargeld"; +"lightning__spark_exit__ready_in" = "Klaar over {duration}"; +"lightning__spark_exit__done_title" = "Exit\nvoltooid"; +"lightning__spark_exit__done_text" = "Je geld staat in je spaargeld."; +"lightning__spark_exit__partial_text" = "{count} kleine bedragen zijn op Spark gebleven."; +"lightning__spark_exit__failed_leaves" = "{amount} is door de Spark-operators on-chain gezet en staat niet op Spark. De operators kunnen het nog steeds aan je vrijgeven."; +"lightning__spark_exit__leftover" = "{amount} aan vergoedingsdekking staat nog op {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} aan overgebleven vergoedingsdekking is naar je spaargeld gestuurd."; +"lightning__spark_exit__leftover_sending" = "{amount} aan overgebleven vergoedingsdekking wordt naar je spaargeld gestuurd."; +"lightning__spark_exit__leftover_dust" = "{amount} aan overgebleven vergoedingsdekking is te klein om te versturen en blijft op {address}, afleidingspad {path}."; +"lightning__spark_exit__fund_paid" = "Betaald uit spaargeld, wachten op bevestiging."; +"lightning__spark_exit__fund_pending" = "Betaling gezien, wachten op bevestiging."; +"lightning__spark_exit__lost_leaves" = "{amount} is on-chain geclaimd door een eerdere eigenaar van dit geld en kan niet worden teruggehaald."; +"lightning__spark_exit__notification_lost" = "{amount} van je exit kan niet worden teruggehaald. Open Bitkit om te zien waarom."; +"lightning__spark_exit__notification_failed" = "Je nooduitgang is gestopt. Open Bitkit om te zien waarom."; +"lightning__spark_exit__quote_outside" = "{amount} van je Spark-saldo kan nu niet worden opgenomen: bedragen die te klein zijn om hun eigen vergoedingen te betalen, of die nog binnen Spark worden verplaatst. Het blijft op Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} kan bij dit vergoedingstarief niet worden opgenomen en blijft op Spark."; +"lightning__spark_exit__needs_funding_title" = "Meer vergoeding\nnodig"; +"lightning__spark_exit__needs_funding_text" = "De exit heeft nog {amount} nodig om zijn vergoedingen te betalen. Dek dit om door te gaan."; +"lightning__spark_exit__failed_title" = "Exit\ngestopt"; +"lightning__spark_exit__cancel" = "Exit annuleren"; +"lightning__spark_exit__failed_text" = "Er kon niets worden teruggehaald."; +"lightning__spark_exit__failed_stays" = "{amount} blijft op Spark."; +"lightning__spark_exit__notification_title" = "Nooduitgang"; +"lightning__spark_exit__notification_body" = "Open Bitkit om je exit voort te zetten."; +"lightning__spark_exit__notification_funding" = "Je exit heeft meer vergoeding nodig om door te gaan. Open Bitkit om dit te dekken."; +"lightning__spark_exit__bg_refresh_off" = "Ververs op achtergrond staat uit voor Bitkit, dus de exit gaat alleen verder als Bitkit open is. Zet het aan in Instellingen zodat het op de achtergrond kan lopen."; +"lightning__spark_exit__low_power" = "De energiebesparingsmodus pauzeert achtergrondtaken. De exit gaat verder zodra die uit staat, of wanneer je Bitkit opent."; +"lightning__spark_exit__notifications_off" = "Meldingen staan uit, dus Bitkit kan je niet laten weten wanneer de exit je nodig heeft. Zet ze aan in Instellingen om herinnerd te worden."; +"lightning__spark_exit__not_moving" = "De exit komt niet verder: {error}"; +"lightning__spark_exit__fee_low" = "Het netwerk is drukker dan het vergoedingstarief van deze exit van {rate} sat/vB. Betaal een hogere vergoeding, of wacht tot de vergoedingen dalen."; +"lightning__spark_exit__waiting_leaves" = "Een deel van deze exit kan nu niet on-chain worden gelezen. Bitkit probeert het tot een week lang opnieuw."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit kon de opgeslagen voortgang van deze exit niet lezen, dus bouwt het die opnieuw op vanuit de blockchain."; +"lightning__spark_exit__done_received" = "{amount} is aangekomen in je spaargeld."; +"lightning__spark_exit__leftover_rejected" = "{amount} aan overgebleven vergoedingsdekking kon niet worden verstuurd en blijft op {address}, afleidingspad {path}."; +"lightning__spark_exit__notification_not_moving" = "Je nooduitgang komt niet verder. Open Bitkit om te zien waarom."; +"lightning__spark_exit__funding_split" = "Bitkit splitst het in {count} delen, één per stap, zodat één vastgelopen stap de andere niet ophoudt."; +"lightning__spark_exit__waiting_operators" = "{amount} dat de Spark-operators on-chain hebben gezet, wacht tot zij het vrijgeven. Bitkit vraagt het tot een week lang opnieuw."; +"lightning__spark_exit__fee_rose" = "De netwerkkosten zijn boven de vergoeding van deze exit gestegen. Het kan trager gaan."; +"lightning__spark_exit__fee_bump_button" = "Hogere vergoeding betalen"; +"lightning__spark_exit__fee_bump_title" = "Betaal een\nhogere vergoeding"; +"lightning__spark_exit__fee_bump_text" = "Deze exit betaalt {rate} sat/vB, minder dan het netwerk nu vraagt. Kies een hoger tarief en Bitkit verstuurt de wachtende stappen opnieuw. Het verschil wordt uit je spaargeld betaald: het bedrag naast elke snelheid."; +"lightning__spark_exit__fee_bump_error" = "Kon de prijs van een hogere vergoeding niet berekenen. Probeer het opnieuw."; +"lightning__spark_exit__fee_bump_confirm" = "Deze vergoeding gebruiken"; +"lightning__spark_exit__leftover_waiting" = "{amount} aan wisselgeld van de vergoeding komt in je spaargeld wanneer de netwerkkosten dalen."; +"lightning__spark_exit__cancelled_title" = "Exit\ngeannuleerd"; +"lightning__spark_exit__cancelled_text" = "Je hebt de exit geannuleerd. De vergoedingsdekking die is binnengekomen, gaat terug naar je spaargeld."; +"lightning__spark_exit__cancel_failed" = "Kon het dekkingsadres niet controleren. Probeer het opnieuw."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Een hogere vergoeding zou een deel van deze exit op Spark laten, dus wordt er geen aangeboden. De exit wordt bevestigd zodra de netwerkkosten dalen."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Reservesaldo (niet besteedbaar)"; "lightning__total_size" = "Grootte van kanaal"; "lightning__fees" = "Vergoedingen"; @@ -850,6 +998,7 @@ "settings__backup__category_transaction_log" = "Transactie Log"; "settings__backup__category_wallet" = "Boosts & Overboekingen"; "settings__backup__category_settings" = "Instellingen"; +"settings__backup__category_spark_exit" = "Spark-exitgegevens"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Labels"; "settings__backup__category_profile" = "Profiel"; @@ -889,6 +1038,14 @@ "settings__status__lightning_connection__ready" = "Open"; "settings__status__lightning_connection__pending" = "Openen..."; "settings__status__lightning_connection__error" = "Geen open verbindingen"; +"settings__status__lightning_connection__spark" = "Via Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Aangesloten"; +"settings__status__spark__pending" = "Aansluiten..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Nooduitgang"; +"settings__status__exit__pending" = "Bezig"; +"settings__status__exit__funding" = "Benodigde vergoedingsdekking"; "settings__status__backup__title" = "Laatste volledige gegevensback-up"; "settings__status__backup__ready" = "Back-up klaar"; "settings__status__backup__pending" = "Een back-up maken..."; @@ -1240,6 +1397,9 @@ "wallet__send_sync_long_title" = "Verbindings\nproblemen"; "wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken."; "wallet__send_to" = "Naar"; +"wallet__send_swap_route" = "Route"; +"wallet__send_paykit_no_route__title" = "Kan dit contact niet betalen"; +"wallet__send_paykit_no_route__description" = "Dit contact heeft op dit moment geen betaalmethode die Bitkit kan gebruiken."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Factuur Plakken"; "wallet__recipient_manual" = "Handmatig Invoeren"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index f586b19d3..b0a449adc 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Zależy od opłaty"; "fee__custom__shortRange" = "Zależy od opłaty"; "fee__custom__longRange" = "Zależy od opłaty transakcyjnej"; +"lightning__transfer_intro__title" = "Saldo \nWydatków"; +"lightning__transfer_intro__text" = "Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami."; +"lightning__transfer_intro__button" = "Rozpocznij"; "lightning__funding__nav_title" = "Saldo wydatków"; +"lightning__funding__text" = "Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela."; "lightning__funding__title" = "Zasil swoje saldo wydatków"; "lightning__funding__button1" = "Prześlij z Oszczędności"; "lightning__funding__button2" = "Użyj innego portfela"; "lightning__funding__button3" = "Ręczna konfiguracja"; "lightning__funding_advanced__button_short" = "Ustawienia zaawansowane"; +"lightning__no_funds__title" = "Brak dostępnych środków"; +"lightning__no_funds__description" = "Zanim będzie można przelać środki z salda oszczędności, należy wysłać bitcoiny do portfela Bitkit."; +"lightning__no_funds__fund" = "Zasil portfel"; +"lightning__spending_amount__title" = "Prześlij \nna saldo wydatków"; "lightning__transfer__nav_title" = "Prześlij środki"; "lightning__transfer__confirm" = "Proszę \npotwierdzić"; "lightning__transfer__custom_fee" = "Niestandardowa opłata transakcyjna"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Twoja transakcja zasilająca mogła zostać rozgłoszona. Sprawdź swoją aktywność, zanim spróbujesz ponownie."; "lightning__transfer_hw__reconnect_error_description" = "Połącz ponownie urządzenie sprzętowe, wygląda na to, że zostało odłączone od telefonu."; "lightning__transfer_hw__reconnect_error_title" = "Połącz ponownie urządzenie"; +"lightning__transfer_hw__sign_title" = "Podpisz\nurządzeniem"; +"lightning__transfer_hw__signed_title" = "Transakcja\npodpisana"; "lightning__transfer_to_savings_button" = "Prześlij na oszczędności"; "lightning__transfer_to_spending_button" = "Prześlij na wydatki"; +"lightning__spending_intro__title" = "Prześlij \nna saldo wydatków"; +"lightning__spending_intro__text" = "Przelewaj środki na swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami."; +"lightning__spending_intro__button" = "Rozpocznij"; "lightning__transfer_failed__nav_title" = "Błąd podczas przelewu środków"; "lightning__transfer_failed__title" = "Transfer środków \nniepowiódł się"; "lightning__transfer_failed__text" = "Przepraszamy, Bitkit nie mógł skonfigurować salda wydatków lub zakończyć transferu. Proszę spróbować ponownie."; @@ -221,11 +234,19 @@ "lightning__spending_amount__error_min__description" = "Do utworzenia salda wydatków wymagana jest minimalna kwota ₿ {amount}."; "lightning__spending_amount__error_max__title" = "Maksymalne saldo wydatków"; "lightning__spending_amount__error_max__description" = "Kwota, którą można przelać na saldo wydatków, jest obecnie ograniczona do ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Transfer do salda wydatków jest ograniczony ze względu na politykę płynności. Aby uzyskać szczegółowe informacje, proszę odwiedzić Centrum pomocy."; "lightning__spending_confirm__network_fee" = "Opłaty sieciowe"; "lightning__spending_confirm__lsp_fee" = "Opłaty serwisowe"; "lightning__spending_confirm__amount" = "Do salda wydatków"; "lightning__spending_confirm__total" = "Razem"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Opłaty się zmieniły"; +"lightning__spending_confirm__fees_changed_network" = "Opłaty są wyższe o {amount}, ponieważ stawki opłat lub saldo oszczędności zmieniły się podczas wyświetlania tego ekranu. Przesuń ponownie, aby potwierdzić."; "lightning__savings_intro__title" = "Prześlij \nna saldo oszczędności"; "lightning__savings_intro__text" = "Przenieś saldo wydatków do oszczędności, aby przechowywać Bitcoiny długoterminowo i zabezpieczyć swoją przyszłość."; "lightning__savings_intro__button" = "Rozpocznij"; @@ -237,6 +258,7 @@ "lightning__savings_confirm__amount_too_low" = "Kwota jest zbyt niska, aby przesłać ją na oszczędności."; "lightning__savings_confirm__close_instead" = "Zamiast tego zamknij kanał"; "lightning__savings_confirm__network_fee" = "Opłata sieciowa"; +"lightning__savings_confirm__spark_fee" = "Opłata Spark"; "lightning__savings_confirm__receive" = "Otrzymasz"; "lightning__savings_confirm__service_fee" = "Opłata serwisowa"; "lightning__savings_advanced__title" = "Wybierz fundusze \ndo przesłania"; @@ -249,6 +271,13 @@ "lightning__savings_interrupted__nav_title" = "Transfer środków\nprzerwany"; "lightning__savings_interrupted__title" = "Pozostaw Bitkit\notwarty"; "lightning__savings_interrupted__text" = "Środki nie zostały jeszcze przelane. Bitkit spróbuje zainicjować transfer w ciągu najbliższych 30 minut. Proszę pozostawić aplikację otwartą."; +"lightning__savings_spark_failed__title" = "Transfer środków \nniepowiódł się"; +"lightning__savings_spark_failed__text" = "Nie udało się przenieść salda Spark do oszczędności; pozostaje ono w saldzie wydatków."; +"lightning__savings_spark_failed__text_partial" = "Środki z kanałów trafiają do oszczędności, ale nie udało się przenieść salda Spark; pozostaje ono w saldzie wydatków."; +"lightning__savings_spark_failed__toast" = "Transfer Spark nieudany"; +"lightning__transfer_success__nav_title" = "Transfer środków powiódł się"; +"lightning__transfer_success__title_spending" = "Środki przeniesione\nna saldo wydatków"; +"lightning__transfer_success__text_spending" = "Środki zostały przeniesione i saldo wydatków jest gotowe do użycia."; "lightning__transfer_success__title_savings" = "Środki przeniesione\nna saldo oszczędności"; "lightning__transfer_success__text_savings" = "Środki zostały przelane i są gotowe do natychmiastowego użycia."; "lightning__external__nav_title" = "Połączenie z Lightning"; @@ -271,6 +300,11 @@ "lightning__spending_label" = "Saldo wydatków"; "lightning__receiving_label" = "Zdolność odbioru"; "lightning__setting_up_header" = "W transferze"; +"lightning__setting_up_step1" = "Przetwarzanie płatności"; +"lightning__setting_up_step2" = "Płatność powiodła się"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Kontunuuj korzystanie z Bitkit\'a"; "lightning__node_info" = "Węzeł Lightning"; "lightning__node_id" = "Identyfikator węzła LDK"; "lightning__node_disconnected" = "odłączony"; @@ -311,6 +345,120 @@ "lightning__transaction" = "Transakcja"; "lightning__order_fee" = "Opłata za zamówienie"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Opłata za odbiór"; +"lightning__spark_confirmations" = "{count}/3 potwierdzeń"; +"lightning__spark_deposit" = "Wpłata"; +"lightning__spark_deposit__choice" = "Spark może je dodać {when} za wyższą opłatą albo po {standard} potwierdzeniach, za około {minutes} minut."; +"lightning__spark_deposit__claim_early" = "Użyj {when}"; +"lightning__spark_deposit__claimed" = "Spark dodaje je do Twojego salda wydatków."; +"lightning__spark_deposit__early_fee" = "Opłata za użycie {when}"; +"lightning__spark_deposit__later" = "po {count} potwierdzeniach"; +"lightning__spark_deposit__later_one" = "po 1 potwierdzeniu"; +"lightning__spark_deposit__looking" = "Czekamy, aż Spark zobaczy Twój przelew."; +"lightning__spark_deposit__now" = "teraz"; +"lightning__spark_deposit__standard" = "Spark doda je do Twojego salda wydatków po {standard} potwierdzeniach, za około {minutes} minut."; +"lightning__spark_deposit__standard_fee" = "Opłata za czekanie"; +"lightning__spark_deposit__wait_button" = "Czekaj"; +"lightning__spark_deposit__waiting" = "Spark doda je do Twojego salda wydatków {when}."; +"lightning__spark_deposit__waiting_next" = "Spark doda je do Twojego salda wydatków po następnym potwierdzeniu."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Odebrane wpłaty"; +"lightning__spark_deposits_pending" = "Wpłaty w toku"; +"lightning__spark_provider" = "Dostawca"; +"lightning__spark_provider_value" = "Spark przez Breez"; +"lightning__spark_receiving_unlimited" = "Bez limitu"; +"lightning__spark_trust" = "To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain."; +"lightning__spark_exit__button" = "Wyjście awaryjne"; +"lightning__spark_exit__nav_title" = "Wyjście awaryjne"; +"lightning__spark_exit__intro_title" = "Opuść Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Używaj tego tylko wtedy, gdy Spark przestanie u Ciebie działać. Przenosi to Twoje saldo wydatków do oszczędności bez operatorów Spark.\n\nTrwa to do 4 tygodni, kosztuje opłaty on-chain płacone z oszczędności, a bardzo małe kwoty mogą pozostać. Działa w tle, a Bitkit powiadomi Cię, jeśli będziesz potrzebny."; +"lightning__spark_exit__quote_title" = "Wycena\nwyjścia"; +"lightning__spark_exit__amount" = "Kwota do wypłaty"; +"lightning__spark_exit__fees" = "Opłaty sieciowe"; +"lightning__spark_exit__receive" = "Otrzymasz około"; +"lightning__spark_exit__savings_fee" = "Opłata za płatność z oszczędności"; +"lightning__spark_exit__savings_refused" = "Twoja płatność z Oszczędności nie przeszła. Zapłać ponownie lub sfinansuj z innego portfela."; +"lightning__spark_exit__savings_total" = "Łącznie z oszczędności"; +"lightning__spark_exit__funding" = "Potrzebne środki na opłaty"; +"lightning__spark_exit__left_behind" = "Zostaje na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark nie jest częścią tego wyjścia i zostaje na Spark."; +"lightning__spark_exit__quote_loading" = "Pobieranie wyceny…"; +"lightning__spark_exit__quote_settling" = "Czekamy, aż Spark zakończy przenoszenie Twojego salda. Może to potrwać do 5 minut."; +"lightning__spark_exit__quote_error" = "Nie udało się pobrać wyceny wyjścia. Spróbuj ponownie."; +"lightning__spark_exit__nothing" = "W tej chwili nie ma nic do wypłacenia."; +"lightning__spark_exit__uneconomic" = "Opłaty są wyższe niż kwota, którą otrzymasz."; +"lightning__spark_exit__funding_title" = "Opłać\nopłaty"; +"lightning__spark_exit__funding_text" = "Wyjście pokrywa opłaty z osobnej płatności on-chain w wysokości {amount}. To, czego nie wyda, wraca na koniec do oszczędności."; +"lightning__spark_exit__fund_savings" = "Zapłać z oszczędności"; +"lightning__spark_exit__fund_external" = "Zasil z innego portfela"; +"lightning__spark_exit__fund_external_text" = "Wyślij co najmniej {amount} na ten adres. Wyjście rozpocznie się po potwierdzeniu."; +"lightning__spark_exit__fund_insufficient" = "Twoje oszczędności nie wystarczą na pokrycie opłat."; +"lightning__spark_exit__fund_seen" = "Płatność wykryta."; +"lightning__spark_exit__fund_partial" = "Otrzymano {received} z {required}, wyślij jeszcze {missing}."; +"lightning__spark_exit__confirm_title" = "Rozpocząć\nwyjście?"; +"lightning__spark_exit__confirm_text" = "Nie będziesz mógł korzystać z tych środków przez do 4 tygodni."; +"lightning__spark_exit__swipe" = "Przesuń, aby wyjść"; +"lightning__spark_exit__progress_title" = "Wyjście\nw toku"; +"lightning__spark_exit__progress_text" = "To działa w tle. Bitkit powiadomi Cię, jeśli będziesz potrzebny."; +"lightning__spark_exit__step_funding" = "Potwierdzenie środków na opłaty"; +"lightning__spark_exit__step_publish" = "Publikowanie transakcji wyjścia"; +"lightning__spark_exit__step_timelock" = "Oczekiwanie na timelock"; +"lightning__spark_exit__step_sweep" = "Wysyłanie do oszczędności"; +"lightning__spark_exit__ready_in" = "Gotowe za {duration}"; +"lightning__spark_exit__done_title" = "Wyjście\nzakończone"; +"lightning__spark_exit__done_text" = "Twoje środki są w oszczędnościach."; +"lightning__spark_exit__partial_text" = "Małe kwoty pozostałe na Spark: {count}."; +"lightning__spark_exit__failed_leaves" = "{amount} zostało przeniesione on-chain przez operatorów Spark i nie jest na Spark. Operatorzy wciąż mogą Ci je wydać."; +"lightning__spark_exit__leftover" = "{amount} środków na opłaty wciąż jest pod adresem {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} pozostałych środków na opłaty wysłano do oszczędności."; +"lightning__spark_exit__leftover_sending" = "Wysyłanie {amount} pozostałych środków na opłaty do oszczędności."; +"lightning__spark_exit__leftover_dust" = "{amount} pozostałych środków na opłaty to za mało, by je wysłać, i zostaje pod adresem {address}, ścieżka derywacji {path}."; +"lightning__spark_exit__fund_paid" = "Zapłacono z oszczędności, czekamy na potwierdzenie."; +"lightning__spark_exit__fund_pending" = "Płatność wykryta, czekamy na potwierdzenie."; +"lightning__spark_exit__lost_leaves" = "{amount} zostało przejęte on-chain przez poprzedniego właściciela tych środków i nie można go odzyskać."; +"lightning__spark_exit__notification_lost" = "Nie można odzyskać {amount} z Twojego wyjścia. Otwórz Bitkit, aby sprawdzić dlaczego."; +"lightning__spark_exit__notification_failed" = "Twoje wyjście awaryjne zostało zatrzymane. Otwórz Bitkit, aby sprawdzić dlaczego."; +"lightning__spark_exit__quote_outside" = "{amount} Twojego salda Spark nie można teraz wypłacić: kwoty są zbyt małe, by pokryć własne opłaty, albo wciąż przemieszczają się w Spark. Zostają na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} nie można wypłacić przy tej stawce opłaty i zostaje na Spark."; +"lightning__spark_exit__needs_funding_title" = "Potrzeba więcej\nopłat"; +"lightning__spark_exit__needs_funding_text" = "Wyjście potrzebuje jeszcze {amount} na opłaty. Zasil je, aby kontynuować."; +"lightning__spark_exit__failed_title" = "Wyjście\nzatrzymane"; +"lightning__spark_exit__cancel" = "Anuluj wyjście"; +"lightning__spark_exit__failed_text" = "Nic nie udało się odzyskać."; +"lightning__spark_exit__failed_stays" = "{amount} zostaje na Spark."; +"lightning__spark_exit__notification_title" = "Wyjście awaryjne"; +"lightning__spark_exit__notification_body" = "Otwórz Bitkit, aby kontynuować wyjście."; +"lightning__spark_exit__notification_funding" = "Twoje wyjście potrzebuje więcej opłat, aby kontynuować. Otwórz Bitkit, aby je zasilić."; +"lightning__spark_exit__bg_refresh_off" = "Odświeżanie aplikacji w tle jest wyłączone dla Bitkit, więc wyjście postępuje tylko przy otwartym Bitkit. Włącz je w Ustawieniach, aby działało w tle."; +"lightning__spark_exit__low_power" = "Tryb niskiego zużycia energii wstrzymuje pracę w tle. Wyjście będzie kontynuowane po jego wyłączeniu lub po otwarciu Bitkit."; +"lightning__spark_exit__notifications_off" = "Powiadomienia są wyłączone, więc Bitkit nie może Cię powiadomić, gdy wyjście będzie Cię potrzebować. Włącz je w Ustawieniach, aby otrzymywać przypomnienia."; +"lightning__spark_exit__not_moving" = "Wyjście nie postępuje: {error}"; +"lightning__spark_exit__fee_low" = "Sieć jest bardziej obciążona, niż pozwala stawka opłaty tego wyjścia wynosząca {rate} sat/vB. Zapłać wyższą opłatę lub poczekaj, aż opłaty spadną."; +"lightning__spark_exit__waiting_leaves" = "Części tego wyjścia nie można teraz odczytać on-chain. Bitkit będzie próbować ponownie przez maksymalnie tydzień."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit nie mógł odczytać zapisanego postępu tego wyjścia, więc odtwarza go z łańcucha."; +"lightning__spark_exit__done_received" = "{amount} trafiło do Twoich oszczędności."; +"lightning__spark_exit__leftover_rejected" = "Nie udało się wysłać {amount} pozostałych środków na opłaty, które zostają pod adresem {address}, ścieżka derywacji {path}."; +"lightning__spark_exit__notification_not_moving" = "Twoje wyjście awaryjne nie postępuje. Otwórz Bitkit, aby sprawdzić dlaczego."; +"lightning__spark_exit__funding_split" = "Bitkit dzieli je na części ({count}), po jednej na każdy krok, aby jeden zablokowany krok nie wstrzymywał pozostałych."; +"lightning__spark_exit__waiting_operators" = "{amount} przeniesione on-chain przez operatorów Spark czeka, aż je wydadzą. Bitkit będzie pytać ponownie przez maksymalnie tydzień."; +"lightning__spark_exit__fee_rose" = "Opłaty sieciowe wzrosły powyżej opłaty tego wyjścia. Może ono zwolnić."; +"lightning__spark_exit__fee_bump_button" = "Zapłać wyższą opłatę"; +"lightning__spark_exit__fee_bump_title" = "Zapłać\nwyższą opłatę"; +"lightning__spark_exit__fee_bump_text" = "To wyjście płaci {rate} sat/vB, mniej niż sieć wymaga teraz. Wybierz wyższą stawkę, a Bitkit ponownie wyśle oczekujące kroki. Różnicę zapłaci z oszczędności: kwota obok każdej prędkości."; +"lightning__spark_exit__fee_bump_error" = "Nie udało się wycenić wyższej opłaty. Spróbuj ponownie."; +"lightning__spark_exit__fee_bump_confirm" = "Użyj tej opłaty"; +"lightning__spark_exit__leftover_waiting" = "{amount} reszty z opłat trafi do Twoich oszczędności, gdy opłaty sieciowe spadną."; +"lightning__spark_exit__cancelled_title" = "Wyjście\nanulowane"; +"lightning__spark_exit__cancelled_text" = "Anulowałeś wyjście. Środki na opłaty, które dotarły, wracają do oszczędności."; +"lightning__spark_exit__cancel_failed" = "Nie udało się sprawdzić adresu środków na opłaty. Spróbuj ponownie."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Wyższa opłata pozostawiłaby część tego wyjścia na Spark, więc nie jest oferowana. Wyjście potwierdzi się, gdy opłaty sieciowe spadną."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo rezerwy (nie do wydania)"; "lightning__total_size" = "Całkowity rozmiar kanału"; "lightning__fees" = "Opłaty"; @@ -852,6 +1000,7 @@ "settings__backup__category_transaction_log" = "Log transakcji"; "settings__backup__category_wallet" = "Przyśpieszenia i transfery"; "settings__backup__category_settings" = "Ustawienia"; +"settings__backup__category_spark_exit" = "Dane wyjścia ze Spark"; "settings__backup__category_widgets" = "Widgety"; "settings__backup__category_tags" = "Tagi"; "settings__backup__category_profile" = "Profil"; @@ -891,6 +1040,14 @@ "settings__status__lightning_connection__ready" = "Otwarte"; "settings__status__lightning_connection__pending" = "Otwieranie..."; "settings__status__lightning_connection__error" = "Brak otwartych połączeń"; +"settings__status__lightning_connection__spark" = "Przez Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Połączono"; +"settings__status__spark__pending" = "Łączenie..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Wyjście awaryjne"; +"settings__status__exit__pending" = "W toku"; +"settings__status__exit__funding" = "Potrzebne środki na opłaty"; "settings__status__backup__title" = "Ostatnia kopia zapasowa"; "settings__status__backup__ready" = "Zapisano"; "settings__status__backup__pending" = "Zapisywanie..."; @@ -1244,6 +1401,9 @@ "wallet__send_sync_long_title" = "Problemy\nz połączeniem"; "wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund."; "wallet__send_to" = "Do"; +"wallet__send_swap_route" = "Trasa"; +"wallet__send_paykit_no_route__title" = "Nie można zapłacić temu kontaktowi"; +"wallet__send_paykit_no_route__description" = "Ten kontakt nie ma obecnie żadnej metody płatności, której Bitkit może użyć."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Wklej fakturę"; "wallet__recipient_manual" = "Wprowadź ręcznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index d735c9e5e..5f26f8da9 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Depende da taxa"; "fee__custom__shortRange" = "Depende da taxa"; "fee__custom__longRange" = "Depende da taxa"; +"lightning__transfer_intro__title" = "Saldo de\nGastos"; +"lightning__transfer_intro__text" = "Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__transfer_intro__button" = "Começar"; "lightning__funding__nav_title" = "Saldo de Gastos"; +"lightning__funding__text" = "A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente."; "lightning__funding__title" = "Transfira para seu saldo de gastos"; "lightning__funding__button1" = "Transferir do Saldo de Poupança"; "lightning__funding__button2" = "Transferir de Outra Carteira"; "lightning__funding__button3" = "Configuração manual"; "lightning__funding_advanced__button_short" = "Configuração Avançada"; +"lightning__no_funds__title" = "Não há Fundos Disponíveis"; +"lightning__no_funds__description" = "Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit."; +"lightning__no_funds__fund" = "Adicione fundos"; +"lightning__spending_amount__title" = "Transferir\npara gastos"; "lightning__transfer__nav_title" = "Transf. fundos"; "lightning__transfer__confirm" = "Por favor\nconfirme"; "lightning__transfer__custom_fee" = "Taxa personalizada"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Sua transação de financiamento pode ter sido transmitida. Verifique sua atividade antes de tentar novamente."; "lightning__transfer_hw__reconnect_error_description" = "Reconecte seu dispositivo de hardware, ele parece estar desconectado do seu telefone."; "lightning__transfer_hw__reconnect_error_title" = "Reconectar Dispositivo de Hardware"; +"lightning__transfer_hw__sign_title" = "Assine com\nseu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transação\nassinada"; "lightning__transfer_to_savings_button" = "Transferir para poupança"; "lightning__transfer_to_spending_button" = "Transferir para gastos"; +"lightning__spending_intro__title" = "Transferir\npara gastos"; +"lightning__spending_intro__text" = "Transfira fundos para seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__spending_intro__button" = "Começar"; "lightning__transfer_failed__nav_title" = "Falha na Transferência"; "lightning__transfer_failed__title" = "Transferência\nfalhou"; "lightning__transfer_failed__text" = "Desculpe, a Bitkit não conseguiu configurar seu saldo de gastos ou concluir sua transferência. Por favor, tente novamente."; @@ -221,11 +234,19 @@ "lightning__spending_amount__error_min__description" = "É necessário um mínimo de ₿ {amount} para estabelecer seu saldo de gastos."; "lightning__spending_amount__error_max__title" = "Saldo de gastos Máximo"; "lightning__spending_amount__error_max__description" = "O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Sua transferência para o saldo de gastos é limitada devido à política de liquidez. Para obter detalhes, visite a Central de Ajuda."; "lightning__spending_confirm__network_fee" = "Taxa da rede"; "lightning__spending_confirm__lsp_fee" = "Taxa de serviço"; "lightning__spending_confirm__amount" = "Para gastos"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Taxas alteradas"; +"lightning__spending_confirm__fees_changed_network" = "As taxas estão {amount} mais altas porque as taxas de rede ou seu saldo de poupança mudaram enquanto você estava nesta tela. Deslize novamente para confirmar."; "lightning__savings_intro__title" = "Transferir\npara poupança"; "lightning__savings_intro__text" = "Transfira seu saldo de gastos para sua poupança para armazenar seu Bitcoin a longo prazo e garantir seu futuro."; "lightning__savings_intro__button" = "Começar"; @@ -237,6 +258,7 @@ "lightning__savings_confirm__amount_too_low" = "O valor é baixo demais para transferir para a poupança."; "lightning__savings_confirm__close_instead" = "Fechar o canal em vez disso"; "lightning__savings_confirm__network_fee" = "Taxa de rede"; +"lightning__savings_confirm__spark_fee" = "Taxa do Spark"; "lightning__savings_confirm__receive" = "Você receberá"; "lightning__savings_confirm__service_fee" = "Taxa de serviço"; "lightning__savings_advanced__title" = "Selecione fundos\npara transferir"; @@ -249,6 +271,13 @@ "lightning__savings_interrupted__nav_title" = "Transferência\ninterrompida"; "lightning__savings_interrupted__title" = "Mantenha a Bitkit\naberta"; "lightning__savings_interrupted__text" = "Os fundos ainda não foram transferidos. A Bitkit tentará iniciar a transferência durante os próximos 30 minutos. Por favor, mantenha o aplicativo aberto."; +"lightning__savings_spark_failed__title" = "Transferência\nfalhou"; +"lightning__savings_spark_failed__text" = "Seu saldo Spark não pôde ser movido para a poupança e continua no seu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Os fundos dos seus canais estão indo para a poupança, mas seu saldo Spark não pôde ser movido e continua no seu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferência Spark falhou"; +"lightning__transfer_success__nav_title" = "Transferência bem-sucedida"; +"lightning__transfer_success__title_spending" = "Fundos transferidos\npara gastos"; +"lightning__transfer_success__text_spending" = "Seus fundos foram transferidos e seu saldo de gastos está pronto para ser usado."; "lightning__transfer_success__title_savings" = "Fundos transferidos\npara poupança"; "lightning__transfer_success__text_savings" = "Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente."; "lightning__external__nav_title" = "Conexões Lightning"; @@ -271,6 +300,11 @@ "lightning__spending_label" = "Saldo de gastos"; "lightning__receiving_label" = "Capacidade de recebimento"; "lightning__setting_up_header" = "Em transferência"; +"lightning__setting_up_step1" = "Processando Pagamento"; +"lightning__setting_up_step2" = "Pagamento Realizado"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando o Bitkit"; "lightning__node_info" = "Nó Lightning"; "lightning__node_id" = "ID do LDK Node "; "lightning__node_disconnected" = "desconectado"; @@ -311,6 +345,120 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Taxa de resgate"; +"lightning__spark_confirmations" = "{count}/3 confirmações"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "A Spark pode adicionar {when} por uma taxa maior, ou após {standard} confirmações, em cerca de {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "A Spark está adicionando ao seu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Taxa para usar {when}"; +"lightning__spark_deposit__later" = "após {count} confirmações"; +"lightning__spark_deposit__later_one" = "após 1 confirmação"; +"lightning__spark_deposit__looking" = "Aguardando a Spark ver sua transferência."; +"lightning__spark_deposit__now" = "agora"; +"lightning__spark_deposit__standard" = "A Spark adiciona ao seu saldo de gastos após {standard} confirmações, em cerca de {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Taxa para esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "A Spark adiciona ao seu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "A Spark adiciona ao seu saldo de gastos após a próxima confirmação."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos resgatados"; +"lightning__spark_deposits_pending" = "Depósitos em andamento"; +"lightning__spark_provider" = "Provedor"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Sem limite"; +"lightning__spark_trust" = "Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain."; +"lightning__spark_exit__button" = "Saída de emergência"; +"lightning__spark_exit__nav_title" = "Saída de Emergência"; +"lightning__spark_exit__intro_title" = "Sair da Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use isto somente se a Spark parar de funcionar para você. Isso move seu saldo de gastos para a poupança sem os operadores da Spark.\n\nLeva até 4 semanas, tem taxas on-chain pagas da poupança, e valores muito pequenos podem ficar para trás. Roda em segundo plano, e a Bitkit avisa você se precisar."; +"lightning__spark_exit__quote_title" = "Cotação\nda saída"; +"lightning__spark_exit__amount" = "Valor a sair"; +"lightning__spark_exit__fees" = "Taxas de rede"; +"lightning__spark_exit__receive" = "Você recebe cerca de"; +"lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança"; +"lightning__spark_exit__savings_refused" = "Seu pagamento da poupança não foi concluído. Pague novamente ou financie de outra carteira."; +"lightning__spark_exit__savings_total" = "Total da poupança"; +"lightning__spark_exit__funding" = "Fundos para taxas necessários"; +"lightning__spark_exit__left_behind" = "Fica na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark não faz parte desta saída e fica na Spark."; +"lightning__spark_exit__quote_loading" = "Obtendo uma cotação…"; +"lightning__spark_exit__quote_settling" = "Aguardando a Spark terminar de mover seu saldo. Isso pode levar até 5 minutos."; +"lightning__spark_exit__quote_error" = "Não foi possível obter uma cotação da saída. Tente novamente."; +"lightning__spark_exit__nothing" = "Nada pode sair no momento."; +"lightning__spark_exit__uneconomic" = "As taxas são maiores do que o que você receberia."; +"lightning__spark_exit__funding_title" = "Financiar as\ntaxas"; +"lightning__spark_exit__funding_text" = "A saída paga suas taxas com um pagamento on-chain separado de {amount}. O que ela não gastar volta para a poupança no final."; +"lightning__spark_exit__fund_savings" = "Pagar da poupança"; +"lightning__spark_exit__fund_external" = "Financiar de outra carteira"; +"lightning__spark_exit__fund_external_text" = "Envie pelo menos {amount} para este endereço. A saída começa assim que ele for confirmado."; +"lightning__spark_exit__fund_insufficient" = "Sua poupança não cobre os fundos para taxas."; +"lightning__spark_exit__fund_seen" = "Pagamento detectado."; +"lightning__spark_exit__fund_partial" = "{received} de {required} recebidos, envie mais {missing}."; +"lightning__spark_exit__confirm_title" = "Iniciar\nsaída?"; +"lightning__spark_exit__confirm_text" = "Você não poderá usar esses fundos por até 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar Para Sair"; +"lightning__spark_exit__progress_title" = "Saída em\nandamento"; +"lightning__spark_exit__progress_text" = "Isso roda em segundo plano. A Bitkit avisa você se precisar."; +"lightning__spark_exit__step_funding" = "Confirmação dos fundos para taxas"; +"lightning__spark_exit__step_publish" = "Publicando as transações de saída"; +"lightning__spark_exit__step_timelock" = "Aguardando o bloqueio de tempo"; +"lightning__spark_exit__step_sweep" = "Enviando para a poupança"; +"lightning__spark_exit__ready_in" = "Pronto em {duration}"; +"lightning__spark_exit__done_title" = "Saída\nconcluída"; +"lightning__spark_exit__done_text" = "Seus fundos estão na poupança."; +"lightning__spark_exit__partial_text" = "{count} valores pequenos ficaram na Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} foi movido on-chain pelos operadores da Spark e não está na Spark. Os operadores ainda podem liberá-lo para você."; +"lightning__spark_exit__leftover" = "{amount} dos fundos para taxas ainda está em {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança."; +"lightning__spark_exit__leftover_sending" = "Enviando {amount} de fundos para taxas restantes para a poupança."; +"lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é pequeno demais para enviar e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__fund_paid" = "Pago da poupança, aguardando confirmação."; +"lightning__spark_exit__fund_pending" = "Pagamento detectado, aguardando confirmação."; +"lightning__spark_exit__lost_leaves" = "{amount} foi resgatado on-chain por um antigo dono desses fundos e não pode ser recuperado."; +"lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver o motivo."; +"lightning__spark_exit__notification_failed" = "Sua saída de emergência parou. Abra a Bitkit para ver o motivo."; +"lightning__spark_exit__quote_outside" = "{amount} do seu saldo Spark não pode sair no momento: valores pequenos demais para pagar as próprias taxas, ou ainda em movimento dentro da Spark. Ele fica na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} não pode sair com esta taxa e fica na Spark."; +"lightning__spark_exit__needs_funding_title" = "Mais taxas\nnecessárias"; +"lightning__spark_exit__needs_funding_text" = "A saída precisa de mais {amount} para pagar suas taxas. Financie para continuar."; +"lightning__spark_exit__failed_title" = "Saída\ninterrompida"; +"lightning__spark_exit__cancel" = "Cancelar saída"; +"lightning__spark_exit__failed_text" = "Nada pôde ser recuperado."; +"lightning__spark_exit__failed_stays" = "{amount} fica na Spark."; +"lightning__spark_exit__notification_title" = "Saída de emergência"; +"lightning__spark_exit__notification_body" = "Abra a Bitkit para continuar sua saída."; +"lightning__spark_exit__notification_funding" = "Sua saída precisa de mais taxas para continuar. Abra a Bitkit para financiá-la."; +"lightning__spark_exit__bg_refresh_off" = "A Atualização em 2º Plano está desativada para a Bitkit, então a saída só avança enquanto a Bitkit estiver aberta. Ative-a nos Ajustes para deixá-la rodar em segundo plano."; +"lightning__spark_exit__low_power" = "O Modo Pouca Energia pausa o trabalho em segundo plano. A saída continua quando ele for desativado, ou quando você abrir a Bitkit."; +"lightning__spark_exit__notifications_off" = "As notificações estão desativadas, então a Bitkit não pode avisar quando a saída precisar de você. Ative-as nos Ajustes para receber lembretes."; +"lightning__spark_exit__not_moving" = "A saída não está avançando: {error}"; +"lightning__spark_exit__fee_low" = "A rede está mais congestionada do que a taxa desta saída de {rate} sat/vB. Pague uma taxa maior, ou espere as taxas caírem."; +"lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain no momento. A Bitkit tenta novamente por até uma semana."; +"lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso salvo desta saída, então o reconstrói a partir da blockchain."; +"lightning__spark_exit__done_received" = "{amount} chegou à sua poupança."; +"lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__notification_not_moving" = "Sua saída de emergência não está avançando. Abra a Bitkit para ver o motivo."; +"lightning__spark_exit__funding_split" = "A Bitkit divide em {count} partes, uma para cada etapa, para que uma etapa travada não segure as outras."; +"lightning__spark_exit__waiting_operators" = "{amount} que os operadores da Spark moveram on-chain aguarda que eles o liberem. A Bitkit pede novamente por até uma semana."; +"lightning__spark_exit__fee_rose" = "As taxas de rede subiram acima da taxa desta saída. Ela pode ficar mais lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar uma taxa maior"; +"lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa maior"; +"lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa maior e a Bitkit envia novamente as etapas pendentes. Ela paga a diferença da poupança: o valor ao lado de cada velocidade."; +"lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa maior. Tente novamente."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa"; +"lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede caírem."; +"lightning__spark_exit__cancelled_title" = "Saída\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Você cancelou a saída. Os fundos para taxas que chegaram voltam para a poupança."; +"lightning__spark_exit__cancel_failed" = "Não foi possível verificar o endereço de financiamento. Tente novamente."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Uma taxa maior deixaria parte desta saída na Spark, então nenhuma é oferecida. A saída é confirmada quando as taxas de rede caírem."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (não utilizável)"; "lightning__total_size" = "Tamanho total do canal"; "lightning__fees" = "Taxas"; @@ -852,6 +1000,7 @@ "settings__backup__category_transaction_log" = "Histórico de transações"; "settings__backup__category_wallet" = "Boosts e transferências"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Dados de saída do Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Perfil"; @@ -891,6 +1040,14 @@ "settings__status__lightning_connection__ready" = "Ativa"; "settings__status__lightning_connection__pending" = "Ativando..."; "settings__status__lightning_connection__error" = "Nenhuma conexão ativa"; +"settings__status__lightning_connection__spark" = "Usando o Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Saída de Emergência"; +"settings__status__exit__pending" = "Em andamento"; +"settings__status__exit__funding" = "Fundos para taxas necessários"; "settings__status__backup__title" = "Último Backup Completo"; "settings__status__backup__ready" = "Backup Feito"; "settings__status__backup__pending" = "Fazendo o backup..."; @@ -1245,6 +1402,9 @@ "wallet__send_sync_long_title" = "Problemas de\nconexão"; "wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Rota"; +"wallet__send_paykit_no_route__title" = "Não é possível pagar este contato"; +"wallet__send_paykit_no_route__description" = "Este contato não tem nenhum método de pagamento que o Bitkit possa usar agora."; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 836ce1a0b..7f3d95514 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1,3 +1,5 @@ +"lightning__funding__text" = "A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente."; +"lightning__spending_amount__title" = "Transferir\npara gastos"; "security__cp_text" = "Você pode alterar seu código PIN para uma nova\ncombinação de 4 dígitos. Primeiro, digite seu código PIN atual."; "wallet__onchain_abandon_transfer_note" = "As transferências para o saldo de gastos já não estão disponíveis. A Bitkit verifica primeiro a rede e depois devolve as moedas desta transferência à sua carteira, para que a transferência original nunca seja confirmada. Isto tem uma taxa de rede."; "wallet__payment_recipient_rejected" = "O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente."; @@ -221,11 +223,17 @@ "fee__custom__description" = "Depende da taxa"; "fee__custom__shortDescription" = "Depende da taxa"; "fee__custom__range" = "Depende da taxa"; +"lightning__transfer_intro__title" = "Saldo de\nGastos"; +"lightning__transfer_intro__text" = "Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__transfer_intro__button" = "Começar"; "lightning__funding__title" = "Transfira para seu saldo de gastos"; "lightning__funding__button1" = "Transferir do Saldo de Poupança"; "lightning__funding__button2" = "Transferir de Outra Carteira"; "lightning__funding__button3" = "Setup Manual"; "lightning__funding_advanced__button_short" = "Configuração Avançada"; +"lightning__no_funds__title" = "Não há Fundos Disponíveis"; +"lightning__no_funds__description" = "Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit."; +"lightning__no_funds__fund" = "Financiar carteira"; "lightning__transfer__nav_title" = "Transf. fundos"; "lightning__transfer__confirm" = "Por favor\nconfirme"; "lightning__transfer__swipe" = "Deslizar Para Transferir"; @@ -233,15 +241,28 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "A sua transação de financiamento pode ter sido difundida. Verifique a sua atividade antes de tentar novamente."; "lightning__transfer_hw__reconnect_error_description" = "Volte a ligar o seu dispositivo de hardware; parece estar desligado do telemóvel."; "lightning__transfer_hw__reconnect_error_title" = "Voltar a ligar o dispositivo"; +"lightning__transfer_hw__sign_title" = "Assine com\no seu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transação\nassinada"; "lightning__transfer_to_savings_button" = "Transferir Para Poupança"; "lightning__transfer_to_spending_button" = "Transferir Para Gastos"; +"lightning__spending_intro__title" = "Transferir\npara gastos"; +"lightning__spending_intro__text" = "Transfira fundos para seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__spending_intro__button" = "Começar"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Saldo de gastos Máximo"; "lightning__spending_amount__error_max__description" = "O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Taxa da rede"; "lightning__spending_confirm__lsp_fee" = "Taxa de serviço"; "lightning__spending_confirm__amount" = "Para gastos"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "As taxas mudaram"; +"lightning__spending_confirm__fees_changed_network" = "As taxas estão {amount} mais altas porque as taxas de rede ou o seu saldo de poupança mudaram enquanto estava neste ecrã. Deslize novamente para confirmar."; "lightning__savings_intro__title" = "Transferir\npara poupança"; "lightning__savings_intro__text" = "Transfira seu saldo de gastos para sua poupança para armazenar seu Bitcoin a longo prazo e garantir seu futuro."; "lightning__savings_intro__button" = "Começar"; @@ -253,6 +274,7 @@ "lightning__savings_confirm__amount_too_low" = "O montante é demasiado baixo para transferir para a poupança."; "lightning__savings_confirm__close_instead" = "Fechar o canal"; "lightning__savings_confirm__network_fee" = "Taxa de rede"; +"lightning__savings_confirm__spark_fee" = "Taxa do Spark"; "lightning__savings_confirm__receive" = "Vai receber"; "lightning__savings_confirm__service_fee" = "Taxa de serviço"; "lightning__savings_advanced__title" = "Selecione fundos\npara transferir"; @@ -265,6 +287,13 @@ "lightning__savings_interrupted__nav_title" = "Transferência\ninterrompida"; "lightning__savings_interrupted__title" = "Mantenha a Bitkit\naberta"; "lightning__savings_interrupted__text" = "Os fundos ainda não foram transferidos. A Bitkit tentará iniciar a transferência durante os próximos 30 minutos. Por favor, mantenha o aplicativo aberto."; +"lightning__savings_spark_failed__title" = "Transferência\nfalhou"; +"lightning__savings_spark_failed__text" = "O teu saldo Spark não pôde ser movido para a poupança e fica no teu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Os fundos dos teus canais estão a ir para a poupança, mas o teu saldo Spark não pôde ser movido e fica no teu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferência Spark falhou"; +"lightning__transfer_success__nav_title" = "Transferência bem-sucedida"; +"lightning__transfer_success__title_spending" = "Fundos transferidos\npara gastos"; +"lightning__transfer_success__text_spending" = "Seus fundos foram transferidos e seu saldo de gastos está pronto para ser usado."; "lightning__transfer_success__title_savings" = "Fundos transferidos\npara poupança"; "lightning__transfer_success__text_savings" = "Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente."; "lightning__external__nav_title" = "Conexões Lightning"; @@ -283,6 +312,11 @@ "lightning__savings" = "Poupança"; "lightning__spending_label" = "Saldo de gastos"; "lightning__receiving_label" = "Capacidade de recebimento"; +"lightning__setting_up_step1" = "Processando Pagamento"; +"lightning__setting_up_step2" = "Pagamento Realizado"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando o Bitkit"; "lightning__node_info" = "Nó Lightning"; "lightning__connections" = "Conexões Lightning"; "lightning__conn_pending" = "Conexões pendentes"; @@ -309,6 +343,120 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Taxa de resgate"; +"lightning__spark_confirmations" = "{count}/3 confirmações"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "A Spark pode adicioná-lo {when} por uma taxa mais alta, ou após {standard} confirmações, dentro de cerca de {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "A Spark está a adicioná-lo ao seu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Taxa para usar {when}"; +"lightning__spark_deposit__later" = "após {count} confirmações"; +"lightning__spark_deposit__later_one" = "após 1 confirmação"; +"lightning__spark_deposit__looking" = "A aguardar que a Spark veja a sua transferência."; +"lightning__spark_deposit__now" = "agora"; +"lightning__spark_deposit__standard" = "A Spark adiciona-o ao seu saldo de gastos após {standard} confirmações, dentro de cerca de {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Taxa para esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "A Spark adiciona-o ao seu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "A Spark adiciona-o ao seu saldo de gastos após a próxima confirmação."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos resgatados"; +"lightning__spark_deposits_pending" = "Depósitos em curso"; +"lightning__spark_provider" = "Fornecedor"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Sem limite"; +"lightning__spark_trust" = "Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain."; +"lightning__spark_exit__button" = "Saída de emergência"; +"lightning__spark_exit__nav_title" = "Saída de Emergência"; +"lightning__spark_exit__intro_title" = "Sair da Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use isto apenas se a Spark deixar de funcionar para si. Move o seu saldo de gastos para a poupança sem os operadores da Spark.\n\nDemora até 4 semanas, tem taxas on-chain pagas da poupança, e montantes muito pequenos podem ficar para trás. Corre em segundo plano, e a Bitkit notifica-o se precisar de si."; +"lightning__spark_exit__quote_title" = "Cotação\nda saída"; +"lightning__spark_exit__amount" = "Montante a retirar"; +"lightning__spark_exit__fees" = "Taxas de rede"; +"lightning__spark_exit__receive" = "Recebe cerca de"; +"lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança"; +"lightning__spark_exit__savings_refused" = "O seu pagamento da poupança não foi concluído. Pague novamente ou financie a partir de outra carteira."; +"lightning__spark_exit__savings_total" = "Total da poupança"; +"lightning__spark_exit__funding" = "Fundos para taxas necessários"; +"lightning__spark_exit__left_behind" = "Fica na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark não faz parte desta saída e fica na Spark."; +"lightning__spark_exit__quote_loading" = "A obter uma cotação…"; +"lightning__spark_exit__quote_settling" = "A aguardar que a Spark termine de mover o seu saldo. Isto pode demorar até 5 minutos."; +"lightning__spark_exit__quote_error" = "Não foi possível obter uma cotação da saída. Tente novamente."; +"lightning__spark_exit__nothing" = "Nada pode ser retirado neste momento."; +"lightning__spark_exit__uneconomic" = "As taxas são superiores ao que receberia."; +"lightning__spark_exit__funding_title" = "Financiar as\ntaxas"; +"lightning__spark_exit__funding_text" = "A saída paga as suas taxas a partir de um pagamento on-chain separado de {amount}. O que não gastar volta para a poupança no final."; +"lightning__spark_exit__fund_savings" = "Pagar da poupança"; +"lightning__spark_exit__fund_external" = "Financiar a partir de outra carteira"; +"lightning__spark_exit__fund_external_text" = "Envie pelo menos {amount} para este endereço. A saída começa assim que for confirmado."; +"lightning__spark_exit__fund_insufficient" = "A sua poupança não cobre os fundos para taxas."; +"lightning__spark_exit__fund_seen" = "Pagamento detetado."; +"lightning__spark_exit__fund_partial" = "{received} de {required} recebidos, envie mais {missing}."; +"lightning__spark_exit__confirm_title" = "Iniciar\nsaída?"; +"lightning__spark_exit__confirm_text" = "Não poderá usar estes fundos durante até 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar Para Sair"; +"lightning__spark_exit__progress_title" = "Saída em\ncurso"; +"lightning__spark_exit__progress_text" = "Isto corre em segundo plano. A Bitkit notifica-o se precisar de si."; +"lightning__spark_exit__step_funding" = "Confirmação dos fundos para taxas"; +"lightning__spark_exit__step_publish" = "A publicar as transações de saída"; +"lightning__spark_exit__step_timelock" = "A aguardar o bloqueio temporal"; +"lightning__spark_exit__step_sweep" = "A enviar para a poupança"; +"lightning__spark_exit__ready_in" = "Pronto em {duration}"; +"lightning__spark_exit__done_title" = "Saída\nconcluída"; +"lightning__spark_exit__done_text" = "Os seus fundos estão na poupança."; +"lightning__spark_exit__partial_text" = "{count} montantes pequenos ficaram na Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} foi movido on-chain pelos operadores da Spark e não está na Spark. Os operadores ainda o podem libertar para si."; +"lightning__spark_exit__leftover" = "{amount} dos fundos para taxas ainda está em {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança."; +"lightning__spark_exit__leftover_sending" = "A enviar {amount} de fundos para taxas restantes para a poupança."; +"lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é demasiado pequeno para enviar e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__fund_paid" = "Pago da poupança, a aguardar confirmação."; +"lightning__spark_exit__fund_pending" = "Pagamento detetado, a aguardar confirmação."; +"lightning__spark_exit__lost_leaves" = "{amount} foi reclamado on-chain por um antigo dono destes fundos e não pode ser recuperado."; +"lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver porquê."; +"lightning__spark_exit__notification_failed" = "A sua saída de emergência parou. Abra a Bitkit para ver porquê."; +"lightning__spark_exit__quote_outside" = "{amount} do seu saldo Spark não pode ser retirado neste momento: montantes demasiado pequenos para pagar as próprias taxas, ou ainda em movimento dentro da Spark. Fica na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} não pode ser retirado com esta taxa e fica na Spark."; +"lightning__spark_exit__needs_funding_title" = "Mais taxas\nnecessárias"; +"lightning__spark_exit__needs_funding_text" = "A saída precisa de mais {amount} para pagar as suas taxas. Financie-a para continuar."; +"lightning__spark_exit__failed_title" = "Saída\nparada"; +"lightning__spark_exit__cancel" = "Cancelar saída"; +"lightning__spark_exit__failed_text" = "Não foi possível recuperar nada."; +"lightning__spark_exit__failed_stays" = "{amount} fica na Spark."; +"lightning__spark_exit__notification_title" = "Saída de emergência"; +"lightning__spark_exit__notification_body" = "Abra a Bitkit para continuar a sua saída."; +"lightning__spark_exit__notification_funding" = "A sua saída precisa de mais taxas para continuar. Abra a Bitkit para a financiar."; +"lightning__spark_exit__bg_refresh_off" = "A Atualização em segundo plano está desativada para a Bitkit, por isso a saída só avança enquanto a Bitkit estiver aberta. Ative-a nas Definições para a deixar correr em segundo plano."; +"lightning__spark_exit__low_power" = "O Modo de baixo consumo pausa o trabalho em segundo plano. A saída continua quando for desativado, ou quando abrir a Bitkit."; +"lightning__spark_exit__notifications_off" = "As notificações estão desativadas, por isso a Bitkit não o pode avisar quando a saída precisar de si. Ative-as nas Definições para receber lembretes."; +"lightning__spark_exit__not_moving" = "A saída não está a avançar: {error}"; +"lightning__spark_exit__fee_low" = "A rede está mais congestionada do que a taxa desta saída de {rate} sat/vB. Pague uma taxa mais alta, ou aguarde que as taxas desçam."; +"lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain neste momento. A Bitkit tenta novamente durante até uma semana."; +"lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso guardado desta saída, por isso reconstrói-o a partir da blockchain."; +"lightning__spark_exit__done_received" = "{amount} chegou à sua poupança."; +"lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__notification_not_moving" = "A sua saída de emergência não está a avançar. Abra a Bitkit para ver porquê."; +"lightning__spark_exit__funding_split" = "A Bitkit divide-o em {count} partes, uma para cada passo, para que um passo bloqueado não atrase os outros."; +"lightning__spark_exit__waiting_operators" = "{amount} que os operadores da Spark moveram on-chain aguarda que o libertem. A Bitkit volta a pedir durante até uma semana."; +"lightning__spark_exit__fee_rose" = "As taxas de rede subiram acima da taxa desta saída. Pode ficar mais lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar uma taxa mais alta"; +"lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa mais alta"; +"lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa mais alta e a Bitkit volta a enviar os passos em espera. Paga a diferença da poupança: o montante ao lado de cada velocidade."; +"lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa mais alta. Tente novamente."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa"; +"lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede descerem."; +"lightning__spark_exit__cancelled_title" = "Saída\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Cancelou a saída. Os fundos para taxas que chegaram voltam para a poupança."; +"lightning__spark_exit__cancel_failed" = "Não foi possível verificar o endereço de financiamento. Tente novamente."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Uma taxa mais alta deixaria parte desta saída na Spark, por isso não é oferecida nenhuma. A saída confirma quando as taxas de rede descerem."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (não utilizável)"; "lightning__total_size" = "Tamanho total do canal"; "lightning__fees" = "Taxas"; @@ -740,6 +888,7 @@ "settings__backup__category_transaction_log" = "Histórico de transações"; "settings__backup__category_wallet" = "Boosts e transferências"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Dados de saída do Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__support__title" = "Suporte"; @@ -776,6 +925,14 @@ "settings__status__lightning_connection__ready" = "Ativa"; "settings__status__lightning_connection__pending" = "Ativando..."; "settings__status__lightning_connection__error" = "Nenhuma conexão ativa"; +"settings__status__lightning_connection__spark" = "Usando o Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Offline"; +"settings__status__exit__title" = "Saída de Emergência"; +"settings__status__exit__pending" = "Em curso"; +"settings__status__exit__funding" = "Fundos para taxas necessários"; "settings__status__backup__title" = "Último Backup Completo"; "settings__status__backup__error" = "Falha ao concluir backup"; "settings__adv__section_debug" = "Debug"; @@ -999,6 +1156,9 @@ "wallet__send_sync_long_title" = "Problemas\nde ligação"; "wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Rota"; +"wallet__send_paykit_no_route__title" = "Não é possível pagar a este contacto"; +"wallet__send_paykit_no_route__description" = "Este contacto não tem neste momento nenhum método de pagamento que a Bitkit possa usar."; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 64ac5c454..a6946a0a0 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Зависит от комиссии"; "fee__custom__shortRange" = "Зависит от комиссии"; "fee__custom__longRange" = "Зависит от комиссии"; +"lightning__transfer_intro__title" = "Расходы\nБаланс"; +"lightning__transfer_intro__text" = "Пополните свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами."; +"lightning__transfer_intro__button" = "Начать"; "lightning__funding__nav_title" = "Баланс Расходов"; +"lightning__funding__text" = "Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька."; "lightning__funding__title" = "Пополните свой Баланс Расходов"; "lightning__funding__button1" = "Перевести из Сбережений"; "lightning__funding__button2" = "Использовать Другой Кошелёк"; "lightning__funding__button3" = "Ручная настройка"; "lightning__funding_advanced__button_short" = "Расширенная Настройка"; +"lightning__no_funds__title" = "Недостаточно Средств"; +"lightning__no_funds__description" = "Прежде чем вы сможете перевести средства со своего баланса сбережений, вам нужно отправить биткойны в ваш кошелёк Bitkit."; +"lightning__no_funds__fund" = "Пополнить Кошелёк"; +"lightning__spending_amount__title" = "Перевод\nна Расходы"; "lightning__transfer__nav_title" = "Перевод Средств"; "lightning__transfer__confirm" = "Пожалуйста,\nПодтвердите"; "lightning__transfer__swipe" = "Проведите для Перевода"; @@ -208,8 +216,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Ваша транзакция пополнения могла быть отправлена. Проверьте активность, прежде чем пробовать снова."; "lightning__transfer_hw__reconnect_error_description" = "Переподключите аппаратное устройство: похоже, оно отключилось от телефона."; "lightning__transfer_hw__reconnect_error_title" = "Переподключите Аппаратное Устройство"; +"lightning__transfer_hw__sign_title" = "Подпишите на\nустройстве"; +"lightning__transfer_hw__signed_title" = "Транзакция\nподписана"; "lightning__transfer_to_savings_button" = "Перевести в Сбережения"; "lightning__transfer_to_spending_button" = "Перевести на Расходы"; +"lightning__spending_intro__title" = "Перевод\nна Расходы"; +"lightning__spending_intro__text" = "Переведите средства на свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами."; +"lightning__spending_intro__button" = "Начать"; "lightning__transfer_failed__nav_title" = "Неудачный Перевод"; "lightning__transfer_failed__title" = "Перевод\nНе Удался"; "lightning__transfer_failed__text" = "Извините, Bitkit не смог настроить ваш баланс расходов или завершить ваш перевод. Пожалуйста, попробуйте снова."; @@ -218,10 +231,18 @@ "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Максимум Баланса Расходов"; "lightning__spending_amount__error_max__description" = "Сумма, которую вы можете перевести на баланс расходов, в настоящее время ограничена ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Сетевые Комиссии"; "lightning__spending_confirm__lsp_fee" = "Сервисные Комиссии"; "lightning__spending_confirm__amount" = "На Баланс Расходов"; "lightning__spending_confirm__total" = "Итого"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Комиссии изменились"; +"lightning__spending_confirm__fees_changed_network" = "Комиссии выросли на {amount}, потому что ставки комиссий или баланс сбережений изменились, пока вы были на этом экране. Проведите ещё раз для подтверждения."; "lightning__savings_intro__title" = "Перевод\nв Сбережения"; "lightning__savings_intro__text" = "Переведите свой баланс расходов в сбережения, чтобы хранить свой биткойн в долгосрочной перспективе и обеспечить своё будущее."; "lightning__savings_intro__button" = "Начать"; @@ -233,6 +254,7 @@ "lightning__savings_confirm__amount_too_low" = "Сумма слишком мала для перевода в сбережения."; "lightning__savings_confirm__close_instead" = "Вместо этого закрыть канал"; "lightning__savings_confirm__network_fee" = "Сетевая комиссия"; +"lightning__savings_confirm__spark_fee" = "Комиссия Spark"; "lightning__savings_confirm__receive" = "Вы получите"; "lightning__savings_confirm__service_fee" = "Сервисная комиссия"; "lightning__savings_advanced__title" = "Выберите Средства\nдля Перевода"; @@ -245,6 +267,13 @@ "lightning__savings_interrupted__nav_title" = "Перевод\nПрерван"; "lightning__savings_interrupted__title" = "Держите Bitkit\nОткрытым"; "lightning__savings_interrupted__text" = "Средства ещё не переведены. Bitkit попытается инициировать перевод в течение следующих 30 минут. Пожалуйста, держите приложение открытым."; +"lightning__savings_spark_failed__title" = "Перевод\nНе Удался"; +"lightning__savings_spark_failed__text" = "Баланс Spark не удалось перевести в сбережения, он остаётся на балансе расходов."; +"lightning__savings_spark_failed__text_partial" = "Средства каналов переводятся в сбережения, но баланс Spark перевести не удалось, он остаётся на балансе расходов."; +"lightning__savings_spark_failed__toast" = "Перевод Spark не выполнен"; +"lightning__transfer_success__nav_title" = "Перевод Выполнен Успешно"; +"lightning__transfer_success__title_spending" = "Средства Переведены\nна Расходы"; +"lightning__transfer_success__text_spending" = "Ваши средства переведены, и ваш баланс расходов готов к использованию."; "lightning__transfer_success__title_savings" = "Средства Переведены\nв Сбережения"; "lightning__transfer_success__text_savings" = "Ваши средства переведены. Вы можете использовать эти средства немедленно."; "lightning__external__nav_title" = "Lightning Соединение"; @@ -267,6 +296,11 @@ "lightning__spending_label" = "Баланс Расходов"; "lightning__receiving_label" = "Входящая Ёмкость"; "lightning__setting_up_header" = "В Процессе Перевода"; +"lightning__setting_up_step1" = "Обработка Платежа"; +"lightning__setting_up_step2" = "Платеж Успешен"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Продолжить Использование Bitkit"; "lightning__node_info" = "Lightning Узел"; "lightning__node_id" = "ID Узла LDK"; "lightning__node_disconnected" = "Отключено"; @@ -306,6 +340,120 @@ "lightning__transaction" = "Транзакция"; "lightning__order_fee" = "Комиссия Ордера"; "lightning__balance" = "Баланс"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Комиссия за зачисление"; +"lightning__spark_confirmations" = "{count}/3 подтверждений"; +"lightning__spark_deposit" = "Депозит"; +"lightning__spark_deposit__choice" = "Spark может зачислить их {when} за более высокую комиссию или после {standard} подтверждений, примерно через {minutes} минут."; +"lightning__spark_deposit__claim_early" = "Использовать {when}"; +"lightning__spark_deposit__claimed" = "Spark зачисляет их на ваш расходный баланс."; +"lightning__spark_deposit__early_fee" = "Комиссия, чтобы использовать {when}"; +"lightning__spark_deposit__later" = "после {count} подтверждений"; +"lightning__spark_deposit__later_one" = "после 1 подтверждения"; +"lightning__spark_deposit__looking" = "Ждём, пока Spark увидит ваш перевод."; +"lightning__spark_deposit__now" = "сейчас"; +"lightning__spark_deposit__standard" = "Spark зачислит их на ваш расходный баланс после {standard} подтверждений, примерно через {minutes} минут."; +"lightning__spark_deposit__standard_fee" = "Комиссия за ожидание"; +"lightning__spark_deposit__wait_button" = "Ждать"; +"lightning__spark_deposit__waiting" = "Spark зачислит их на ваш расходный баланс {when}."; +"lightning__spark_deposit__waiting_next" = "Spark зачислит их на ваш расходный баланс после следующего подтверждения."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Зачисленные депозиты"; +"lightning__spark_deposits_pending" = "Депозиты в обработке"; +"lightning__spark_provider" = "Провайдер"; +"lightning__spark_provider_value" = "Spark через Breez"; +"lightning__spark_receiving_unlimited" = "Без ограничений"; +"lightning__spark_trust" = "Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода."; +"lightning__spark_exit__button" = "Экстренный выход"; +"lightning__spark_exit__nav_title" = "Экстренный Выход"; +"lightning__spark_exit__intro_title" = "Выход из Spark\nончейн"; +"lightning__spark_exit__intro_text" = "Используйте это, только если Spark перестал у вас работать. Выход переводит ваш баланс расходов в сбережения без операторов Spark.\n\nОн занимает до 4 недель, требует ончейн-комиссий, оплачиваемых из сбережений, а очень маленькие суммы могут остаться. Выход идёт в фоне, и Bitkit уведомит вас, если вы понадобитесь."; +"lightning__spark_exit__quote_title" = "Расчёт\nвыхода"; +"lightning__spark_exit__amount" = "Сумма к выводу"; +"lightning__spark_exit__fees" = "Сетевые комиссии"; +"lightning__spark_exit__receive" = "Вы получите примерно"; +"lightning__spark_exit__savings_fee" = "Комиссия платежа из сбережений"; +"lightning__spark_exit__savings_refused" = "Платёж из сбережений не прошёл. Оплатите снова или пополните с другого кошелька."; +"lightning__spark_exit__savings_total" = "Всего из сбережений"; +"lightning__spark_exit__funding" = "Нужно средств на комиссии"; +"lightning__spark_exit__left_behind" = "Остаётся в Spark"; +"lightning__spark_exit__outside_exit" = "{amount} в Spark не входит в этот выход и остаётся в Spark."; +"lightning__spark_exit__quote_loading" = "Получаем расчёт…"; +"lightning__spark_exit__quote_settling" = "Ждём, пока Spark завершит перемещение вашего баланса. Это может занять до 5 минут."; +"lightning__spark_exit__quote_error" = "Не удалось получить расчёт выхода. Попробуйте снова."; +"lightning__spark_exit__nothing" = "Сейчас нечего выводить."; +"lightning__spark_exit__uneconomic" = "Комиссии выше суммы, которую вы получите."; +"lightning__spark_exit__funding_title" = "Оплатите\nкомиссии"; +"lightning__spark_exit__funding_text" = "Выход оплачивает комиссии отдельным ончейн-платежом на {amount}. Неизрасходованное вернётся в сбережения в конце."; +"lightning__spark_exit__fund_savings" = "Оплатить из сбережений"; +"lightning__spark_exit__fund_external" = "Пополнить с другого кошелька"; +"lightning__spark_exit__fund_external_text" = "Отправьте не менее {amount} на этот адрес. Выход начнётся после подтверждения."; +"lightning__spark_exit__fund_insufficient" = "В ваших сбережениях недостаточно средств на комиссии."; +"lightning__spark_exit__fund_seen" = "Платёж обнаружен."; +"lightning__spark_exit__fund_partial" = "Получено {received} из {required}, отправьте ещё {missing}."; +"lightning__spark_exit__confirm_title" = "Начать\nвыход?"; +"lightning__spark_exit__confirm_text" = "Вы не сможете пользоваться этими средствами до 4 недель."; +"lightning__spark_exit__swipe" = "Проведите для Выхода"; +"lightning__spark_exit__progress_title" = "Выход\nв процессе"; +"lightning__spark_exit__progress_text" = "Выход идёт в фоне. Bitkit уведомит вас, если вы понадобитесь."; +"lightning__spark_exit__step_funding" = "Подтверждение оплаты комиссий"; +"lightning__spark_exit__step_publish" = "Публикация транзакций выхода"; +"lightning__spark_exit__step_timelock" = "Ожидание таймлока"; +"lightning__spark_exit__step_sweep" = "Отправка в сбережения"; +"lightning__spark_exit__ready_in" = "Готово через {duration}"; +"lightning__spark_exit__done_title" = "Выход\nзавершён"; +"lightning__spark_exit__done_text" = "Ваши средства в сбережениях."; +"lightning__spark_exit__partial_text" = "Небольших сумм, оставшихся в Spark: {count}."; +"lightning__spark_exit__failed_leaves" = "{amount} перевели ончейн операторы Spark, и в Spark этой суммы нет. Операторы всё ещё могут выдать её вам."; +"lightning__spark_exit__leftover" = "{amount} средств на комиссии всё ещё на адресе {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} оставшихся средств на комиссии отправлено в сбережения."; +"lightning__spark_exit__leftover_sending" = "Отправляем {amount} оставшихся средств на комиссии в сбережения."; +"lightning__spark_exit__leftover_dust" = "{amount} оставшихся средств на комиссии слишком мало для отправки, они остаются на адресе {address}, путь деривации {path}."; +"lightning__spark_exit__fund_paid" = "Оплачено из сбережений, ждём подтверждения."; +"lightning__spark_exit__fund_pending" = "Платёж обнаружен, ждём подтверждения."; +"lightning__spark_exit__lost_leaves" = "{amount} забрал ончейн один из прежних владельцев этих средств, их нельзя вернуть."; +"lightning__spark_exit__notification_lost" = "{amount} из вашего выхода нельзя вернуть. Откройте Bitkit, чтобы узнать причину."; +"lightning__spark_exit__notification_failed" = "Ваш экстренный выход остановлен. Откройте Bitkit, чтобы узнать причину."; +"lightning__spark_exit__quote_outside" = "{amount} вашего баланса Spark сейчас нельзя вывести: суммы слишком малы, чтобы покрыть свои комиссии, или ещё перемещаются внутри Spark. Они остаются в Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} нельзя вывести при этой ставке комиссии, сумма остаётся в Spark."; +"lightning__spark_exit__needs_funding_title" = "Нужно больше\nкомиссий"; +"lightning__spark_exit__needs_funding_text" = "Выходу нужно ещё {amount} на комиссии. Пополните, чтобы продолжить."; +"lightning__spark_exit__failed_title" = "Выход\nостановлен"; +"lightning__spark_exit__cancel" = "Отменить выход"; +"lightning__spark_exit__failed_text" = "Ничего не удалось вернуть."; +"lightning__spark_exit__failed_stays" = "{amount} остаётся в Spark."; +"lightning__spark_exit__notification_title" = "Экстренный выход"; +"lightning__spark_exit__notification_body" = "Откройте Bitkit, чтобы продолжить выход."; +"lightning__spark_exit__notification_funding" = "Для продолжения выходу нужно больше комиссий. Откройте Bitkit, чтобы пополнить."; +"lightning__spark_exit__bg_refresh_off" = "Фоновое обновление приложений для Bitkit выключено, поэтому выход продвигается только при открытом Bitkit. Включите его в Настройках, чтобы выход шёл в фоне."; +"lightning__spark_exit__low_power" = "Режим энергосбережения приостанавливает фоновую работу. Выход продолжится, когда режим будет выключен или когда вы откроете Bitkit."; +"lightning__spark_exit__notifications_off" = "Уведомления выключены, поэтому Bitkit не сможет сообщить, когда выходу понадобится ваше участие. Включите их в Настройках, чтобы получать напоминания."; +"lightning__spark_exit__not_moving" = "Выход не продвигается: {error}"; +"lightning__spark_exit__fee_low" = "Сеть загружена сильнее, чем позволяет ставка комиссии этого выхода в {rate} sat/vB. Заплатите более высокую комиссию или дождитесь снижения комиссий."; +"lightning__spark_exit__waiting_leaves" = "Часть этого выхода сейчас не удаётся прочитать в блокчейне. Bitkit будет пробовать снова в течение недели."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit не смог прочитать сохранённый прогресс этого выхода и восстанавливает его по блокчейну."; +"lightning__spark_exit__done_received" = "{amount} поступило в ваши сбережения."; +"lightning__spark_exit__leftover_rejected" = "{amount} оставшихся средств на комиссии не удалось отправить, они остаются на адресе {address}, путь деривации {path}."; +"lightning__spark_exit__notification_not_moving" = "Ваш экстренный выход не продвигается. Откройте Bitkit, чтобы узнать причину."; +"lightning__spark_exit__funding_split" = "Bitkit делит их на части ({count}), по одной на каждый шаг, чтобы один застрявший шаг не задерживал остальные."; +"lightning__spark_exit__waiting_operators" = "{amount}, которые операторы Spark перевели ончейн, ждут, пока операторы их выдадут. Bitkit будет запрашивать снова в течение недели."; +"lightning__spark_exit__fee_rose" = "Сетевые комиссии превысили комиссию этого выхода. Он может замедлиться."; +"lightning__spark_exit__fee_bump_button" = "Заплатить больше"; +"lightning__spark_exit__fee_bump_title" = "Повысить\nкомиссию"; +"lightning__spark_exit__fee_bump_text" = "Этот выход платит {rate} sat/vB, меньше, чем сейчас требует сеть. Выберите более высокую ставку, и Bitkit заново отправит ожидающие шаги. Разницу он оплатит из сбережений: сумма указана рядом с каждой скоростью."; +"lightning__spark_exit__fee_bump_error" = "Не удалось рассчитать более высокую комиссию. Попробуйте снова."; +"lightning__spark_exit__fee_bump_confirm" = "Использовать эту комиссию"; +"lightning__spark_exit__leftover_waiting" = "{amount} сдачи с комиссий поступит в ваши сбережения, когда сетевые комиссии снизятся."; +"lightning__spark_exit__cancelled_title" = "Выход\nотменён"; +"lightning__spark_exit__cancelled_text" = "Вы отменили выход. Поступившие средства на комиссии вернутся в сбережения."; +"lightning__spark_exit__cancel_failed" = "Не удалось проверить адрес для оплаты комиссий. Попробуйте снова."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Более высокая комиссия оставила бы часть этого выхода в Spark, поэтому она не предлагается. Выход подтвердится, когда сетевые комиссии снизятся."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Резервный Баланс (не доступен для трат)"; "lightning__total_size" = "Общий Размер Канала"; "lightning__fees" = "Комиссии"; @@ -847,6 +995,7 @@ "settings__backup__category_transaction_log" = "Журнал Транзакций"; "settings__backup__category_wallet" = "Ускорения и Переводы"; "settings__backup__category_settings" = "Настройки"; +"settings__backup__category_spark_exit" = "Данные выхода из Spark"; "settings__backup__category_widgets" = "Виджеты"; "settings__backup__category_tags" = "Теги"; "settings__backup__category_profile" = "Профиль"; @@ -886,6 +1035,14 @@ "settings__status__lightning_connection__ready" = "Открыто"; "settings__status__lightning_connection__pending" = "Открытие..."; "settings__status__lightning_connection__error" = "Нет открытых соединений"; +"settings__status__lightning_connection__spark" = "Через Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Подключено"; +"settings__status__spark__pending" = "Подключение..."; +"settings__status__spark__error" = "Не в сети"; +"settings__status__exit__title" = "Экстренный Выход"; +"settings__status__exit__pending" = "Выполняется"; +"settings__status__exit__funding" = "Нужно средств на комиссии"; "settings__status__backup__title" = "Последнее Полное Резервное Копирование Данных"; "settings__status__backup__ready" = "Скопировано"; "settings__status__backup__pending" = "Резервное копирование..."; @@ -1229,6 +1386,9 @@ "wallet__send_sync_long_title" = "Проблемы\nс подключением"; "wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд."; "wallet__send_to" = "Кому"; +"wallet__send_swap_route" = "Маршрут"; +"wallet__send_paykit_no_route__title" = "Не удаётся оплатить этому контакту"; +"wallet__send_paykit_no_route__description" = "У этого контакта сейчас нет способа оплаты, который может использовать Bitkit."; "wallet__recipient_contact" = "Контакт"; "wallet__recipient_invoice" = "Вставить Инвойс"; "wallet__recipient_manual" = "Ввести Вручную"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 7ace6170a..536de26ed 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -130,7 +130,12 @@ class BackupService { private var foregroundObservationGeneration: UInt64 = 0 private let defaults: UserDefaults + private let fetchBackup: (String) async throws -> Data? + /// Set while the Spark exit data restore hasn't succeeded or found no older backup, kept across launches, so a fresh + /// export never replaces the remote backup it couldn't read yet. + private static let sparkExitRestorePendingKey = "sparkExitRestorePending" private let backupData: ((BackupCategory) async throws -> Data)? + private let isSparkExitActive: () async -> Bool private let uploadBackup: (String, Data) async throws -> Void private let waitForBackupDelay: () async throws -> Void private let waitForTemporaryFailureDelay: (UInt64) async throws -> Void @@ -140,6 +145,7 @@ class BackupService { private let statusUpdateQueue = DispatchQueue(label: "backup-service-status-update", qos: .userInitiated) private let stateQueue = DispatchQueue(label: "backup-service-state", qos: .userInitiated) + private var sparkExitMark = SparkExitBackupMark() private let backupStatusesSubject = PassthroughSubject<[BackupCategory: BackupItemStatus], Never>() private let backupFailureSubject = PassthroughSubject() @@ -160,6 +166,7 @@ class BackupService { init( defaults: UserDefaults = .standard, backupData: ((BackupCategory) async throws -> Data)? = nil, + isSparkExitActive: @escaping () async -> Bool = { @MainActor in SparkExitManager.shared.isExiting }, usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(), uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in _ = try await VssBackupClient.shared.putObject(key: key, data: data) @@ -172,9 +179,14 @@ class BackupService { }, waitForForegroundRecoveryTimeout: @escaping () async throws -> Void = { try await Task.sleep(for: .seconds(BackupService.backupFailureCheckInterval)) + }, + fetchBackup: @escaping (String) async throws -> Data? = { key in + try await VssBackupClient.shared.getObject(key: key)?.value } ) { self.defaults = defaults + self.isSparkExitActive = isSparkExitActive + self.fetchBackup = fetchBackup self.backupData = backupData self.usdtRecoveryBackup = usdtRecoveryBackup self.uploadBackup = uploadBackup @@ -310,6 +322,13 @@ class BackupService { Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") await installBackupObservers(generation: observation.generation, foregroundGeneration: observation.foregroundGeneration) + + // Spark's exit data signals are missed while observing is stopped, as it is while the node stops and Spark + // stays connected: mark it when the exit data changed since it was last marked, not on every restart. + let version = await SparkManager.shared.exitStateVersion + if stateQueue.sync(execute: { sparkExitMark.claim(version: version) }) { + markBackupRequired(category: .sparkExit) + } } } @@ -409,6 +428,20 @@ class BackupService { } else { try await persistWalletBackup() } + } else if category == .sparkExit { + // A restore that couldn't read the remote backup is tried again first, and blocks replacing it until then. + if defaults.bool(forKey: Self.sparkExitRestorePendingKey) { + await restoreSparkExit() + } + guard !defaults.bool(forKey: Self.sparkExitRestorePendingKey) else { throw SparkExitBackupError.restorePending } + // Restored exit data Spark couldn't import yet lives only in the remote backup, so nothing replaces it. + guard await !SparkManager.shared.hasStagedExitState(walletIndex: LightningService.shared.currentWalletIndex) else { + throw SparkExitBackupError.restorePending + } + // Nothing to back up until Spark connects, which marks this category required again. + if let data = try await sparkExitBackupData() { + try await uploadBackup(category.rawValue, data) + } } else { if category == .metadata { try await prepareMetadataBackup() @@ -448,6 +481,10 @@ class BackupService { if case MetadataBackupError.deferred = error { outcome = .busy Logger.debug("Metadata backup deferred until wallet restoration and backup complete", context: "BackupService") + } else if error is SparkExitBackupError { + // Backs off like an unreachable server: retrying every few seconds refetches several MB each time. + outcome = .temporarilyUnavailable + Logger.warn("Spark exit backup deferred until its restore reads the remote backup", context: "BackupService") } else if error is WalletBackupSnapshotError { outcome = .snapshotChanged Logger.warn("Wallet backup deferred after three changing snapshots", context: "BackupService") @@ -755,6 +792,9 @@ class BackupService { // App-owned, so it takes no part in the core field migration above and never sets // needsRewrite. Restored names wait as pending ones until each wallet is paired again. HwKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:]) + SparkExitEarlierAddressBackup.restore(payload.sparkExitEarlierAddresses, network: "\(Env.network)") { + SparkExitEarlierAddressStore(walletIndex: $0) + } // Force address rotation by clearing onchain address UserDefaults.standard.set("", forKey: "onchainAddress") @@ -784,6 +824,9 @@ class BackupService { Logger.debug("Restored \(payload.orders.count) orders, \(payload.cjitEntries.count) CJITs", context: "BackupService") } + await restoreSparkExit() + markRestoreComplete(category: .sparkExit) + // Always reset PIN settings after restore (PIN is never backed up for security) await SettingsViewModel.shared.resetPinSettings() @@ -860,6 +903,17 @@ class BackupService { } .store(in: cancellables) + SparkManager.exitStateChangedPublisher + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .sparkExit) + Task { @MainActor in + let version = SparkManager.shared.exitStateVersion + _ = self.stateQueue.sync { self.sparkExitMark.claim(version: version) } + } + } + .store(in: cancellables) + // Record requirements synchronously; the scheduled upload already has a debounce. WalletBackupChangeTracker.localChanges .merge(with: PaykitSdkService.walletBackupDataChangedPublisher) @@ -905,6 +959,15 @@ class BackupService { } .store(in: cancellables) + // METADATA (earlier Spark exit addresses) + SparkExitEarlierAddressStore.savedPublisher + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { @Sendable [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .metadata) + } + .store(in: cancellables) + // APP STATE (UserDefaults changes, etc.) SettingsViewModel.shared.appStatePublisher .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) @@ -1104,9 +1167,15 @@ class BackupService { return statuses[category] ?? BackupItemStatus() } + /// The categories whose remote timestamp tells when the wallet was last backed up. + /// The Spark exit data is left out: reading its time downloads data that can reach several MB. + static let latestBackupTimeCategories = BackupCategory.allCases.filter { + $0 != .lightningConnections && $0 != .usdt && $0 != .sparkExit + } + func getLatestBackupTime() async -> UInt64? { let timestamps = await withTaskGroup(of: UInt64?.self) { group in - for category in BackupCategory.allCases where category != .lightningConnections && category != .usdt { + for category in Self.latestBackupTimeCategories { group.addTask { await self.getRemoteBackupTimestamp(category: category) } @@ -1326,7 +1395,11 @@ class BackupService { cache: cache, pubkySession: pubkySession, pubkyContactProfileOverrides: pubkyContactProfileOverrides, - hwWalletNames: hwWalletNames.isEmpty ? nil : hwWalletNames + hwWalletNames: hwWalletNames.isEmpty ? nil : hwWalletNames, + sparkExitEarlierAddresses: SparkExitEarlierAddressBackup.backup( + SparkExitEarlierAddressStore(walletIndex: LightningService.shared.currentWalletIndex), + walletKey: SparkExitFundingIndexes.walletKey(network: "\(Env.network)", walletIndex: LightningService.shared.currentWalletIndex) + ) ) return try JSONEncoder().encode(payload) @@ -1366,9 +1439,50 @@ class BackupService { code: -1, userInfo: [NSLocalizedDescriptionKey: "LIGHTNING_CONNECTIONS backup is managed by ldk-node"] ) + + case .sparkExit: + guard let data = try await sparkExitBackupData() else { + throw NSError( + domain: "BackupService", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "SPARK_EXIT has nothing to back up while Spark is not connected"] + ) + } + return data + } + } + + /// Restores the Spark exit data backup. Until that succeeds, or the backup is found absent (not just unreadable), the + /// restore stays pending and the next backup attempt tries it again before uploading. + func restoreSparkExit() async { + defaults.set(true, forKey: Self.sparkExitRestorePendingKey) + do { + if let data = try await fetchBackup(BackupCategory.sparkExit.rawValue) { + let backup = try JSONDecoder().decode(SparkExitBackupV1.self, from: data) + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: LightningService.shared.currentWalletIndex) + Logger.info("Restore success for: '\(BackupCategory.sparkExit.rawValue)'", context: "BackupService") + } else { + Logger.warn("Restore null for: '\(BackupCategory.sparkExit.rawValue)'", context: "BackupService") + } + defaults.removeObject(forKey: Self.sparkExitRestorePendingKey) + } catch { + Logger.warn("Restore error for: '\(BackupCategory.sparkExit.rawValue)': \(error)", context: "BackupService") } } + /// Nothing while an exit runs: the operators may stop reporting a leaf once it is being exited, so a fresh export + /// could drop the very leaf a restored wallet needs. The backup from before the exit stays. + private func sparkExitBackupData() async throws -> Data? { + if await isSparkExitActive() { + return nil + } + if let backupData { + return try await backupData(.sparkExit) + } + guard let backup = try await SparkManager.shared.exitBackup() else { return nil } + return try JSONEncoder().encode(backup) + } + /// Re-upload the envelopes whose Core-owned fields the restore had to migrate, so later restores /// read current wallet-scoped entries instead of re-running the legacy path forever. Uploads /// from the rows Core just persisted, so this can only ever publish state Core accepted. @@ -1598,3 +1712,21 @@ struct BackupFailureNotificationGate { return true } } + +enum SparkExitBackupError: Error { + /// The restore couldn't read the remote backup or import it yet, so a fresh export must not replace it. + case restorePending +} + +/// Which Spark exit data generation the backup was last marked for, so observing restarting on a foreground marks it +/// only when the exit data changed meanwhile. +struct SparkExitBackupMark { + private var markedVersion: Int? + + /// True when `version` is an exit data generation not marked yet, and records it; nil while Spark is disconnected. + mutating func claim(version: Int?) -> Bool { + guard let version, version != markedVersion else { return false } + markedVersion = version + return true + } +} diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 068197415..7fcb533da 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -955,7 +955,28 @@ class ActivityService { } } - private func processLightningPayment(_ payment: PaymentDetails) async throws { + /// Records a Lightning payment the channel-free rail sent or received the way an LDK payment is recorded, so it keeps + /// its Paykit contact and seen state across syncs. + func recordChannelFreeRailPayment(_ payment: PaymentDetails) async throws { + try await ServiceQueue.background(.core) { + try await self.processLightningPayment(payment, rail: .channelFree) + } + } + + /// Contacts of Lightning sends, by payment hash, kept from before the payment goes out until its activity is saved, + /// so a send recorded after the app was killed mid-payment still names its contact. + nonisolated(unsafe) static var sentPaymentContacts = SentPaymentContacts(defaults: .standard) + + /// Whether a Lightning activity keeps its status against an update: a settled payment stays settled, and a failure + /// from the rail that didn't carry the latest attempt is stale. + static func keepsLightningStatus(existing: BitkitCore.PaymentState, update: BitkitCore.PaymentState, isStaleRailFailure: Bool) -> Bool { + if existing == .succeeded, update != .succeeded { + return true + } + return update == .failed && existing != .failed && isStaleRailFailure + } + + private func processLightningPayment(_ payment: PaymentDetails, rail: LightningPaymentRail = .ldk) async throws { guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } // Skip pending inbound payments - just means they created an invoice @@ -975,17 +996,42 @@ class ActivityService { case .succeeded: .succeeded } + // Paying an invoice of the wallet's own Spark (Move to Spark) gives an LDK send and a Spark receive under one hash: + // the send is the payment, so its row wins whichever leg arrives first. + let isChannelFreeReceive = rail == .channelFree && payment.direction == .inbound + if isChannelFreeReceive, let sent = existingLightning, sent.txType == .sent { + try settleMove(sent, receiveState: state) + return + } + let replacesSelfReceive = rail == .ldk && payment.direction == .outbound && existingLightning?.txType == .received + + if let existing = existingLightning, + Self.keepsLightningStatus( + existing: existing.status, + update: state, + isStaleRailFailure: LightningService.shared.isStaleFailure(paymentHash: payment.id, from: rail) + ) + { + return + } + // Skip if existing activity has newer timestamp, unless payment status is changing if let existing = existingLightning, let existingUpdatedAt = existing.updatedAt { let statusChanging = existing.status != state + // The Spark receive of a move can settle the send before the node's routing fee is recorded. + let feeChanging = payment.feePaidMsat.map { $0 / 1000 != existing.fee } ?? false let needsPrivateContactAttribution = existing.contact == nil && payment.direction == .inbound - if existingUpdatedAt > paymentTimestamp && !statusChanging && !needsPrivateContactAttribution { + if existingUpdatedAt > paymentTimestamp && !statusChanging && !feeChanging && !needsPrivateContactAttribution + && !replacesSelfReceive + { return } } let contact = if let existingContact = existingLightning?.contact { existingContact + } else if payment.direction == .outbound, let sentContact = Self.sentPaymentContacts.contact(forPaymentHash: payment.id) { + sentContact } else { await privatePaykitContactPublicKey( forReceivedInvoicePaymentHash: payment.id, @@ -1010,23 +1056,41 @@ class ActivityService { seenAt: existingLightning?.seenAt ) - if existingActivity != nil { - try await ServiceQueue.background(.core) { - var updated = ln - if case let .lightning(latest)? = try getActivityById(walletId: ln.walletId, activityId: payment.id), - latest.contact != existingLightning?.contact - { - updated.contact = latest.contact - } - if self.isContactDetached(activityId: payment.id, walletId: ln.walletId) { - updated.contact = nil - } - try updateActivity(activityId: payment.id, activity: .lightning(updated)) + try await ServiceQueue.background(.core) { + let latestActivity = try getActivityById(walletId: ln.walletId, activityId: payment.id) + // The move's LDK send can be stored while this receive waited on its contact lookup; it still wins. + if isChannelFreeReceive, case let .lightning(latest)? = latestActivity, latest.txType == .sent { + try self.settleMove(latest, receiveState: state) + return + } + guard let latestActivity else { + try upsertActivity(activity: .lightning(ln)) + self.updateBoostTxIdsCache(for: .lightning(ln)) self.activitiesChangedSubject.send() + return } - } else { - try await upsert(.lightning(ln)) + var updated = ln + if case let .lightning(latest) = latestActivity, latest.contact != existingLightning?.contact { + updated.contact = latest.contact + } + if self.isContactDetached(activityId: payment.id, walletId: ln.walletId) { + updated.contact = nil + } + try updateActivity(activityId: payment.id, activity: .lightning(updated)) + self.activitiesChangedSubject.send() } + if payment.direction == .outbound, ln.contact != nil, payment.status != .pending { + Self.sentPaymentContacts.forget(paymentHash: payment.id) + } + } + + /// A succeeded Spark receive proves the move's LDK send settled, so the send's row becomes succeeded and keeps its amount and fee. + private func settleMove(_ sent: LightningActivity, receiveState: BitkitCore.PaymentState) throws { + guard receiveState == .succeeded, sent.status != .succeeded else { return } + var settled = sent + settled.status = .succeeded + try updateActivity(activityId: sent.id, activity: .lightning(settled)) + activitiesChangedSubject.send() } private func privatePaykitContactPublicKey(forReceivedInvoicePaymentHash paymentHash: String, direction: PaymentDirection) async -> String? { @@ -1459,6 +1523,42 @@ class ActivityService { } } + /// Marks a sent on-chain activity the node doesn't watch, such as a Spark withdrawal to a contact, as confirmed once + /// its sender reports it complete. + func markOnchainActivityAsConfirmed(txId: String) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId), + !existing.confirmed + else { return } + var updated = existing + updated.confirmed = true + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + } + } catch { + Logger.error("Failed to mark activity as confirmed for \(txId): \(error)", context: "ActivityService") + } + } + + /// Marks the on-chain activity for `txId` as a transfer between Savings and Spending that has no channel, + /// such as a Spark deposit or withdrawal. No-op until the activity exists or once it is marked. + func markOnchainActivityAsTransfer(txId: String) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId), + !existing.isTransfer + else { return } + var updated = existing + updated.isTransfer = true + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + } + } catch { + Logger.error("Failed to mark activity as transfer for \(txId): \(error)", context: "ActivityService") + } + } + /// Resolve the on-chain activity for `txId`, preferring the row that most likely represents the /// transfer: one already flagged as a transfer, then a hardware-wallet send (the hardware /// funding path), then whatever else matches. @@ -2576,3 +2676,33 @@ class CoreService { } } } + +/// The contact a Lightning send pays, remembered by payment hash before it goes out. +/// Shared across actors: every read-modify-write holds `lock`. +struct SentPaymentContacts: @unchecked Sendable { + private static let key = "sentPaymentContacts" + /// `remember` runs on the main actor and `forget` on the core queue, so each read-modify-write holds this. + private static let lock = NSLock() + let defaults: UserDefaults + + func remember(_ contact: String, forPaymentHash paymentHash: String) { + Self.lock.withLock { + var contacts = defaults.dictionary(forKey: Self.key) as? [String: String] ?? [:] + contacts[paymentHash.lowercased()] = contact + defaults.set(contacts, forKey: Self.key) + } + } + + func contact(forPaymentHash paymentHash: String) -> String? { + (defaults.dictionary(forKey: Self.key) as? [String: String])?[paymentHash.lowercased()] + } + + func forget(paymentHash: String) { + Self.lock.withLock { + guard var contacts = defaults.dictionary(forKey: Self.key) as? [String: String], + contacts.removeValue(forKey: paymentHash.lowercased()) != nil + else { return } + defaults.set(contacts, forKey: Self.key) + } + } +} diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index cfc03b871..5dff60a31 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -5,6 +5,89 @@ import LDKNode // TODO: catch all errors and pass a readable error message to the UI +/// A Lightning rail that needs no channels (Breez SDK on Spark). The main app sets it; the notification +/// extension leaves it unset, so only LDK runs there. +protocol ChannelFreeLightningRail: AnyObject { + @MainActor var isReady: Bool { get } + @MainActor var balanceSats: UInt64 { get } + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String + /// Refuses before sending when the prepared fee is above `maxFeeSats`, and runs `beforeSubmission` right before the + /// payment goes out, as the node path does. + func send(bolt11: String, sats: UInt64?, maxFeeSats: UInt64?, beforeSubmission: @escaping () throws -> Void) async throws -> PaymentHash + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 + /// Throws when the rail could not list its payments, so a duplicate-payment check can tell that from none. + func lightningPayments() async throws -> [ChannelFreeRailPayment] + /// Whether the rail's last wallet sync reached it, and started after `seconds` since 1970 when given. The rail + /// finishes an interrupted send only on such a sync, so before one its list can lack a send that went out. + func hasSynced(after seconds: UInt64?) async -> Bool +} + +@MainActor +extension ChannelFreeLightningRail { + /// The most the rail can send once its fee is paid, so a payment near the full balance isn't offered and then refused. + var sendableSats: UInt64 { + ChannelFreeRailFees.sendableSats(balanceSats: balanceSats) + } +} + +enum ChannelFreeRailFees { + /// Spark keeps 0.25% of a Lightning send as its service fee, on top of routing. + static let serviceFeePpm: UInt64 = 2500 + + static func sendableSats(balanceSats: UInt64) -> UInt64 { + // The fee on the sent amount, rounded up, must fit beside it: amount + ceil(amount * ppm / 1e6) <= balance. + let scaled = balanceSats.multipliedFullWidth(by: 1_000_000) + let sendable = (1_000_000 + serviceFeePpm).dividingFullWidth((scaled.high, scaled.low)).quotient + let fee = sendable / 1_000_000 * serviceFeePpm + (sendable % 1_000_000 * serviceFeePpm + 999_999) / 1_000_000 + return sendable + fee <= balanceSats || sendable == 0 ? sendable : sendable - 1 + } +} + +/// The channel-free rail refused a payment before anything left the wallet: the rail was not connected, could not +/// prepare the payment, or its fee was over the caller's limit. A Paykit request or allowance can then end as failed. +/// The rail runs here but isn't connected, so its payments are unknown rather than none. +struct ChannelFreeRailUnavailable: Error {} + +struct ChannelFreeRailSendRefused: LocalizedError { + let reason: String + var underlying: Error? + /// A still-valid invoice created before the restore, as Spark can't show it didn't pay it then. + var isRestoredInvoiceUnverified = false + + static func restoredInvoiceUnverified() -> ChannelFreeRailSendRefused { + ChannelFreeRailSendRefused(reason: t("wallet__payment_restored_invoice"), isRestoredInvoiceUnverified: true) + } + + /// Always the user copy in `reason`; the underlying error is for logs. + var errorDescription: String? { + reason + } +} + +/// A Lightning payment the channel-free rail sent or received, for checks that would otherwise read only LDK's payments. +struct ChannelFreeRailPayment: Equatable { + let paymentHash: String + let direction: PaymentDirection + let status: PaymentStatus + let preimage: String? + var timestamp: UInt64 = 0 + var feeSats: UInt64 = 0 + + /// The attempt that decides an outbound payment of `paymentHash`: a retried invoice is listed once per attempt, + /// so a pending or settled one wins over failed ones, then the newest. + static func latestOutboundAttempt(_ payments: [ChannelFreeRailPayment], paymentHash: String) -> ChannelFreeRailPayment? { + payments + .filter { $0.direction == .outbound && $0.paymentHash.caseInsensitiveCompare(paymentHash) == .orderedSame } + .max { ($0.status == .failed ? 0 : 1, $0.timestamp) < ($1.status == .failed ? 0 : 1, $1.timestamp) } + } +} + +/// The rail a Lightning payment attempt went out on. +enum LightningPaymentRail { + case ldk + case channelFree +} + /// The node lifecycle `WalletViewModel` drives, apart from the rest of `LightningService` so its ordering can be tested. protocol NodeLifecycleService: AnyObject { var hasNode: Bool { get } @@ -46,7 +129,27 @@ class LightningService: NodeLifecycleService { @MainActor private var cachedPeers: [PeerDetails]? @MainActor private var cachedChannels: [ChannelDetails]? + /// Guards the event callback and the rail events waiting for it, which the node and the rail set from different threads. + private let eventCallbackLock = NSLock() private var storedEventCallback: ((Event) -> Void)? + /// Rail events that arrived before the node's event callback was set, delivered once it is. + private var pendingChannelFreeRailEvents: [Event] = [] + /// The rail each payment hash's latest attempt went out on, so a stale failure from the other rail can't settle a + /// retry of the same invoice. + private var attemptRails: [String: LightningPaymentRail] = [:] + /// Payment hashes inside a rail send, which Spark lists only once it returns. + private var railSendsInFlight: Set = [] + /// Counts attempts recorded in this run, so a listing drops a hash just when the hash was recorded before the + /// listing was read, never a retry recorded while it was read. + private var attemptRecords = 0 + /// Per store key, the `attemptRecords` count at each hash's latest record. + private var attemptRecordedAt: [String: [String: Int]] = [:] + /// Node sends under way, so a listing that shows only an earlier failed attempt doesn't drop their hash. + private var ldkSendsInFlight: Set = [] + weak var channelFreeRail: ChannelFreeLightningRail? + /// The rail whose payments the duplicate-payment check reads, kept while an emergency exit takes Spark off + /// Lightning: an invoice Spark paid or is paying is never paid again over a channel. + weak var channelFreePaymentHistory: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? typealias OnchainActivityCompletion = @MainActor () async -> Void @@ -266,10 +369,10 @@ class LightningService: NodeLifecycleService { } if let onEvent { - storedEventCallback = onEvent + setEventCallback(onEvent) } - listenForEvents(onEvent: storedEventCallback) + listenForEvents(onEvent: eventCallbackLock.withLock { storedEventCallback }) Logger.debug("Starting node...") try await ServiceQueue.background(.ldk) { @@ -286,6 +389,7 @@ class LightningService: NodeLifecycleService { await refreshChannelCache() await refreshCache() + await listNodePaymentsKeepingAttempts() Logger.info("Node started") } @@ -323,7 +427,7 @@ class LightningService: NodeLifecycleService { self.node = nil if clearEventCallback { - storedEventCallback = nil + self.clearEventCallback() } await MainActor.run { @@ -362,6 +466,7 @@ class LightningService: NodeLifecycleService { Logger.warn("Wiping on lighting wallet...") try FileManager.default.removeItem(at: directory) + forgetLdkAttempts() await MainActor.run { clearCache() @@ -740,11 +845,38 @@ class LightningService: NodeLifecycleService { } } - func receive(amountSats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { - try await receiveMsats(amountMsats: amountSats.map { $0 * 1000 }, description: description, expirySecs: expirySecs) + /// `isPaykitEndpoint` marks the amountless invoice published as a Paykit Lightning endpoint, which contacts pay any + /// amount: it goes to the channel-free rail unless the channels' inbound covers `paykitEndpointNodeInboundSats`. + func receive(amountSats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600, isPaykitEndpoint: Bool = false) async throws -> String { + try await receiveMsats( + amountMsats: amountSats.map { $0 * 1000 }, + description: description, + expirySecs: expirySecs, + isPaykitEndpoint: isPaykitEndpoint + ) } - func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { + func receiveMsats( + amountMsats: UInt64? = nil, + description: String, + expirySecs: UInt32 = 3600, + isPaykitEndpoint: Bool = false + ) async throws -> String { + if let rail = channelFreeRail { + // Judged on the amount rounded up, so channels that can't take the last part of a sat don't get it. + let railTakes = await shouldReceiveWithRail(rail, amountSats: amountMsats.map { ($0 + 999) / 1000 }, isPaykitEndpoint: isPaykitEndpoint) + switch Self.receiveRoute(amountMsats: amountMsats, railTakes: railTakes) { + case let .rail(amountSats): + return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) + case .refused: + throw AppError( + message: t("other__lnurl_withdr_error_no_capacity"), + debugMessage: "Spark invoices are whole sats, and no channel can receive \(amountMsats ?? 0) msat" + ) + case .node: + break + } + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -771,12 +903,77 @@ class LightningService: NodeLifecycleService { return bolt11.description } + enum ReceiveRoute: Equatable { + case rail(amountSats: UInt64?) + case node + /// Neither rail can receive the exact amount. + case refused + } + + /// Where an invoice for `amountMsats` is made, given whether the channel-free rail takes it. The rail's invoices + /// are whole sats, so an amount with a part of a sat goes to the channels, or is refused when they can't take it, + /// rather than invoicing less than asked. + static func receiveRoute(amountMsats: UInt64?, railTakes: Bool) -> ReceiveRoute { + guard railTakes else { return .node } + guard let amountMsats, amountMsats > 0 else { return .rail(amountSats: nil) } + return amountMsats % 1000 == 0 ? .rail(amountSats: amountMsats / 1000) : .refused + } + + /// Receives on the channel-free rail when no ready channel has the inbound capacity for the amount. + @MainActor + private func shouldReceiveWithRail(_ rail: ChannelFreeLightningRail, amountSats: UInt64?, isPaykitEndpoint: Bool) -> Bool { + guard rail.isReady else { return false } + let readyChannels = (channels ?? []).filter(\.isChannelReady) + let inboundSats: UInt64? = readyChannels.isEmpty ? nil : readyChannels.map(\.inboundCapacityMsat).reduce(0, +) / 1000 + return Self.receivesOnRail(amountSats: amountSats, readyInboundSats: inboundSats, isPaykitEndpoint: isPaykitEndpoint) + } + + /// Below this channel inbound, a contact's amountless Paykit invoice goes to the channel-free rail, so a small + /// channel can't cap what contacts can pay. + static let paykitEndpointNodeInboundSats: UInt64 = 1_000_000 + + /// Whether a ready channel-free rail takes an invoice, given the ready channels' total inbound (nil without any). + /// An amount the channels can't receive goes to the rail; an amountless invoice does only when no channel can + /// receive at all, except a Paykit endpoint, which does below `paykitEndpointNodeInboundSats`. + static func receivesOnRail(amountSats: UInt64?, readyInboundSats: UInt64?, isPaykitEndpoint: Bool) -> Bool { + guard let inboundSats = readyInboundSats else { return true } + guard let amountSats, amountSats > 0 else { + return isPaykitEndpoint ? inboundSats < paykitEndpointNodeInboundSats : inboundSats == 0 + } + return amountSats > inboundSats + } + + /// Pays on the channel-free rail when no channel has the outbound capacity and the rail's balance covers the amount. + @MainActor + private func shouldPayWithRail(_ rail: ChannelFreeLightningRail, bolt11: String, sats: UInt64?) -> Bool { + guard rail.isReady, let amountSats = Self.paymentAmountSats(bolt11: bolt11, sats: sats) else { return false } + return !canSendWithChannels(amountSats: amountSats) && rail.sendableSats >= amountSats + } + + private static func paymentAmountSats(bolt11: String, sats: UInt64?) -> UInt64? { + sats ?? (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } + } + + /// LDK's routing fee limit when a caller sets none, 1% + 50 sats, so a send over the rail is capped the same way. + private static func defaultMaxFeeSats(amountSats: UInt64) -> UInt64 { + amountSats / 100 + 50 + } + /// Checks if we have the correct outbound capacity to send the amount /// - Parameter amountSats: Amount to send in satoshis /// - Returns: True if we can send the amount /// Note: Uses cached channels for fast, non-blocking checks @MainActor func canSend(amountSats: UInt64) -> Bool { + // An amountless invoice still needs funds on Spark, as the channels need capacity above the amount. + if let rail = channelFreeRail, rail.isReady, rail.sendableSats >= max(1, amountSats), !canSendWithChannels(amountSats: amountSats) { + return true + } + return canSendWithChannels(amountSats: amountSats) + } + + @MainActor + private func canSendWithChannels(amountSats: UInt64) -> Bool { guard let channels else { return false } @@ -850,20 +1047,109 @@ class LightningService: NodeLifecycleService { } } + /// `channelsOnly` keeps a payment quoted against the channels on them, never falling back to the channel-free rail. func send( bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil, + channelsOnly: Bool = false, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { + let invoice = try? Bolt11Invoice.fromStr(invoiceStr: bolt11) + let paymentHash = invoice.map { String($0.paymentHash()) } + let expiresAt = invoice.map { Int(clamping: min($0.secondsSinceEpoch(), .max - $0.expiryTimeSeconds()) + $0.expiryTimeSeconds()) } + // Read before the checks, so a send on the other rail admitted after them refuses this one at submission. + let mark = attemptMark + if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. + if let paid = try await settledEarlierAttempt( + paymentHash: paymentHash, + on: [.ldk, .channelFree], + invoiceCreatedAt: invoice?.secondsSinceEpoch(), + invoiceExpiresAt: expiresAt + ) { + return paid + } + guard beginRailSend(paymentHash: paymentHash) else { throw Self.pendingDuplicate(paymentHash: paymentHash) } + defer { endRailSend(paymentHash: paymentHash) } + // Spark takes no route parameters, so a caller's routing fee cap is checked against its estimate instead. + if let maxFeeMsat = params?.maxTotalRoutingFeeMsat { + let feeSats: UInt64 + do { + feeSats = try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: sats) + } catch { + Logger.warn("Spark fee estimate failed: \(error)", context: "LightningService") + throw error as? ChannelFreeRailSendRefused ?? ChannelFreeRailSendRefused( + reason: t("wallet__payment_failed_description"), + underlying: error + ) + } + guard feeSats * 1000 <= maxFeeMsat else { + Logger.info("Spark routing fee of \(feeSats) sats exceeds the limit of \(maxFeeMsat / 1000) sats", context: "LightningService") + throw ChannelFreeRailSendRefused(reason: t("wallet__max_possible_fee_rate")) + } + } + do { + let sent = try await rail.send( + bolt11: bolt11, + sats: sats, + maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 } + ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), + beforeSubmission: { [weak self] in + try beforeSubmission() + try self?.admitSend(paymentHash: paymentHash, on: .channelFree, since: mark) + // Noted once admitted, so a refused attempt never takes the place of the one that went out. + self?.noteAttempt(on: .channelFree, bolt11: bolt11) + self?.recordRailAttempt(paymentHash: paymentHash, expiresAt: expiresAt) + // Kept until the call returns, so a send cut off by process death stays held after a relaunch. + if let paymentHash { + self?.markRailSendUncertain(paymentHash: paymentHash, since: mark) + } + } + ) + if let paymentHash { + endRailSendUncertainty(paymentHash: paymentHash) + } + return sent + } catch let refusal as ChannelFreeRailSendRefused { + // Nothing left the wallet, and the checks above leave no earlier live attempt under this hash. + if let paymentHash { + endRailSendUncertainty(paymentHash: paymentHash) + releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) + } + throw refusal + } catch { + // A send that failed once submitted may still go out, so its outcome waits for a later sync. + if let paymentHash { + markRailSendUncertain(paymentHash: paymentHash, since: mark) + } + throw error + } + } + if let paid = try await settledEarlierAttempt( + paymentHash: paymentHash, + on: [.channelFree], + invoiceCreatedAt: invoice?.secondsSinceEpoch(), + invoiceExpiresAt: expiresAt + ) { + return paid + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } Logger.info("Paying bolt11: \(bolt11)") + let sendKey = paymentHash?.lowercased() + defer { endLdkSend(paymentHash: sendKey) } do { - return try await Self.submitPayment(beforeSubmission: beforeSubmission) { + return try await Self.submitPayment(beforeSubmission: { [weak self] in + try beforeSubmission() + guard let self else { return } + try admitSend(paymentHash: sendKey, on: .ldk, since: mark) + noteAttempt(on: .ldk, bolt11: bolt11) + recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) + }) { if let sats { try node.bolt11Payment().sendUsingAmount( invoice: .fromStr(invoiceStr: bolt11), amountMsat: sats * 1000, routeParameters: params @@ -879,6 +1165,512 @@ class LightningService: NodeLifecycleService { } } + /// The node's payment list; tests replace it, as they run without a node. + lazy var nodePayments: () async -> [PaymentDetails]? = { [unowned self] in await listPayments() } + + /// Whether this build runs the channel-free rail, so a rail that isn't connected may still hold payments; the app + /// sets it, as extensions build this service without the rail. + var channelFreeRailSupported: () async -> Bool = { false } + + /// Payment hashes sent over the rail, kept across launches: while the rail can't list its payments, only these may + /// have an attempt there that a node payment would repeat. + var railAttemptsStore: UserDefaults = .standard + /// The clock a trim checks invoice expiries against; tests set it. + var now: () -> Date = Date.init + private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" + /// Rail sends submitted but not returned, or that failed after they were submitted, by hash, with the time they + /// were submitted or failed in seconds since 1970. Kept across launches, as Spark may still resume one after a + /// relaunch while its list shows an earlier failed attempt. + private static let uncertainRailSendsKey = "channelFreeRailUncertainSendFailedAt" + /// A listing drops the hashes it shows failed only, or paid once the invoice has expired, so the other rail can't + /// pay it again, and ones it doesn't show once the invoice expired `unlistedAttemptSettleSeconds` ago. Paid or + /// unlisted hashes with no known expiry (kept before expiries were, or whose invoice can't be parsed) are dropped + /// oldest first only past this many per key. A hash whose payment may still be pending always stays. + private static let railAttemptsLimit = 500 + /// No send starts after its invoice expires, and one settles within its HTLC timeout, which LDK caps at 1008 + /// blocks (about a week). Spark's routes are shorter, so twice that cap leaves room for slow blocks. + private static let unlistedAttemptSettleSeconds = 14 * 24 * 60 * 60 + + /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not + /// started yet), only these may have an attempt there that a Spark payment would repeat. + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" + /// Set once the node's own list has filled `ldkAttemptsKey`, as node payments made before it was kept carry no hash + /// there. Until then an unlistable node refuses every Spark payment. + static let ldkAttemptsSeededKey = "ldkAttemptedPaymentHashesSeeded" + /// When the wallet was restored, in seconds since 1970, until Spark lists its sends after a sync since. No backup + /// keeps `railAttemptsKey`, so until then an invoice made before the restore may be one Spark already paid. + static let sparkHistoryUnseededSinceKey = "sparkHistoryUnseededSince" + + /// Called as a wallet is restored, before Spark starts. + func markSparkHistoryUnseeded() { + let since = Int(now().timeIntervalSince1970) + eventCallbackLock.withLock { + railAttemptsStore.set(since, forKey: Self.sparkHistoryUnseededSinceKey) + railAttemptsStore.set(since, forKey: Self.sparkRestoredAtKey) + } + } + + /// When the wallet was restored, in seconds since 1970. Spark can report a sync while its payment history failed to + /// sync, so its list can lack a send it made before then, and a still-valid invoice created before it that Spark + /// hasn't listed as pending or paid is refused on either rail until it expires. + static let sparkRestoredAtKey = "sparkRestoredAt" + + var sparkRestoredAt: Int? { + eventCallbackLock.withLock { railAttemptsStore.object(forKey: Self.sparkRestoredAtKey) as? Int } + } + + /// Called when a restore fails before the wallet exists, so nothing stays held for it. + func clearSparkRestore() { + clearSparkHistoryUnseeded() + eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.sparkRestoredAtKey) } + } + + func clearSparkHistoryUnseeded() { + eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.sparkHistoryUnseededSinceKey) } + } + + var sparkHistoryUnseededSince: Int? { + eventCallbackLock.withLock { railAttemptsStore.object(forKey: Self.sparkHistoryUnseededSinceKey) as? Int } + } + + /// Reads Spark's sends after its sync, which ends the hold on invoices made before a restore. + func listRailSendsIfUnseeded() async { + guard sparkHistoryUnseededSince != nil else { return } + _ = try? await channelFreeRailPayments() + } + + /// `expiresAt` is the invoice's expiry in seconds since 1970, kept beside each key's hashes. + func recordRailAttempt(paymentHash: String?, expiresAt: Int? = nil) { + recordAttempt(paymentHash: paymentHash, expiresAt: expiresAt, key: Self.railAttemptsKey) + } + + func recordLdkAttempt(paymentHash: String?, expiresAt: Int? = nil) { + recordAttempt(paymentHash: paymentHash, expiresAt: expiresAt, key: Self.ldkAttemptsKey) + } + + private static func expiriesKey(_ key: String) -> String { + key + "Expiries" + } + + /// Callers hold `eventCallbackLock`. + private func attemptExpiries(key: String) -> [String: Int] { + railAttemptsStore.dictionary(forKey: Self.expiriesKey(key)) as? [String: Int] ?? [:] + } + + /// A retry of a kept hash moves it to the newest end, so a trim working from a listing read before the retry + /// drops older hashes first. + private func recordAttempt(paymentHash: String?, expiresAt: Int?, key: String) { + guard let paymentHash = paymentHash?.lowercased() else { return } + eventCallbackLock.withLock { + var hashes = railAttemptsStore.stringArray(forKey: key) ?? [] + hashes.removeAll { $0 == paymentHash } + hashes.append(paymentHash) + railAttemptsStore.set(hashes, forKey: key) + attemptRecords += 1 + attemptRecordedAt[key, default: [:]][paymentHash] = attemptRecords + var expiries = attemptExpiries(key: key) + expiries[paymentHash] = expiresAt + railAttemptsStore.set(expiries, forKey: Self.expiriesKey(key)) + } + } + + /// Drops the hashes a listing showed as `failed` only, or as `paid` with an expired invoice, ones it didn't show + /// (`listed` is every hash it shows) whose invoice expired past `unlistedAttemptSettleSeconds`, and past the limit + /// the oldest paid or unlisted ones with no known expiry. A hash whose payment may still be pending (`live`) keeps + /// holding the other rail, as does a paid one whose invoice can still be paid. Callers hold `eventCallbackLock`. + private func trimSettledAttempts(paid: Set, failed: Set, listed: Set, live: Set, key: String) { + guard let hashes = railAttemptsStore.stringArray(forKey: key) else { return } + let expiries = attemptExpiries(key: key) + let nowSeconds = Int(now().timeIntervalSince1970) + // Against now minus the window, as an invoice that never expires is kept at Int.max. + let isExpired = { (hash: String, settleSeconds: Int) in expiries[hash].map { $0 <= nowSeconds - settleSeconds } == true } + let live = key == Self.railAttemptsKey ? live.union(uncertainRailSendFailedAt().keys) : live + let unsettled = hashes.filter { hash in + live.contains(hash) || !(failed.contains(hash) || paid.contains(hash) && isExpired(hash, 0) + || !listed.contains(hash) && isExpired(hash, Self.unlistedAttemptSettleSeconds)) + } + var excess = unsettled.count - Self.railAttemptsLimit + var kept: [String] = [] + for hash in unsettled { + if excess > 0, !live.contains(hash), expiries[hash] == nil, paid.contains(hash) || !listed.contains(hash) { + excess -= 1 + } else { + kept.append(hash) + } + } + guard kept.count < hashes.count else { return } + let keptSet = Set(kept) + railAttemptsStore.set(kept, forKey: key) + railAttemptsStore.set(expiries.filter { keptSet.contains($0.key) }, forKey: Self.expiriesKey(key)) + attemptRecordedAt[key] = attemptRecordedAt[key]?.filter { keptSet.contains($0.key) } + } + + /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. + /// With `unlessInFlight`, skipped while a rail send of the invoice is in flight, as that retry holds it again. + /// With `since`, skipped too when the hash was in flight when `attemptMark` was read, or recorded after it, as a + /// retry that ended or was recorded while a listing was read holds it again. + func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true, since mark: AttemptMark? = nil) { + let hash = paymentHash.lowercased() + eventCallbackLock.withLock { + guard !unlessInFlight || !railSendsInFlight.contains(hash) else { return } + // A send whose outcome is unknown may still go out, whatever an earlier attempt's state. + guard uncertainRailSendFailedAt()[hash] == nil else { return } + if let mark, attemptsLive(since: mark, key: Self.railAttemptsKey).contains(hash) { + return + } + removeAttempt(paymentHash: hash, key: Self.railAttemptsKey) + } + } + + /// The attempts recorded so far and the sends in flight, read before a listing so a trim or release from it skips + /// the retries recorded since, and the ones in flight then that ended while it was read. + var attemptMark: AttemptMark { + eventCallbackLock.withLock { AttemptMark(records: attemptRecords, railInFlight: railSendsInFlight, ldkInFlight: ldkSendsInFlight) } + } + + private func releaseLdkAttempt(paymentHash: String) { + eventCallbackLock.withLock { removeAttempt(paymentHash: paymentHash.lowercased(), key: Self.ldkAttemptsKey) } + } + + /// Callers hold `eventCallbackLock`. + private func removeAttempt(paymentHash hash: String, key: String) { + removeAttempts([hash], key: key) + } + + /// Callers hold `eventCallbackLock`. + private func removeAttempts(_ removed: Set, key: String) { + guard var hashes = railAttemptsStore.stringArray(forKey: key), hashes.contains(where: removed.contains) else { return } + hashes.removeAll { removed.contains($0) } + railAttemptsStore.set(hashes, forKey: key) + railAttemptsStore.set(attemptExpiries(key: key).filter { !removed.contains($0.key) }, forKey: Self.expiriesKey(key)) + removed.forEach { attemptRecordedAt[key]?[$0] = nil } + } + + /// The hashes recorded under `key` after `attemptRecords` read `records`. Callers hold `eventCallbackLock`. + private func attemptsRecorded(since records: Int, key: String) -> Set { + Set((attemptRecordedAt[key] ?? [:]).filter { $0.value > records }.keys) + } + + /// The hashes under `key` that may still be pending since `mark`: in flight now or then, or recorded after it. + /// Callers hold `eventCallbackLock`. + private func attemptsLive(since mark: AttemptMark, key: String) -> Set { + let inFlight = key == Self.railAttemptsKey ? railSendsInFlight.union(mark.railInFlight) : ldkSendsInFlight.union(mark.ldkInFlight) + return inFlight.union(attemptsRecorded(since: mark.records, key: key)) + } + + private func hasRailAttempt(paymentHash: String) -> Bool { + hasAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) + } + + private func hasAttempt(paymentHash: String, key: String) -> Bool { + eventCallbackLock.withLock { + railAttemptsStore.stringArray(forKey: key)?.contains(paymentHash.lowercased()) ?? false + } + } + + /// Called when the rail's wallet is wiped, as its payments go with it. + func forgetRailAttempts() { + eventCallbackLock.withLock { + railAttemptsStore.removeObject(forKey: Self.uncertainRailSendsKey) + railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) + railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.railAttemptsKey)) + attemptRecordedAt[Self.railAttemptsKey] = nil + } + } + + /// Called when the node's wallet is wiped, as its payments go with it. + func forgetLdkAttempts() { + eventCallbackLock.withLock { + railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) + railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.ldkAttemptsKey)) + attemptRecordedAt[Self.ldkAttemptsKey] = nil + railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) + } + } + + /// The node's payments, after keeping its attempts from them, with the hashes still holding Spark (`live`); nil + /// while the node can't list them. + @discardableResult + private func listNodePaymentsKeepingAttempts() async -> (payments: [PaymentDetails], live: Set)? { + let mark = attemptMark + guard let payments = await nodePayments() else { return nil } + let live = keepLdkAttempts(from: payments, since: mark) + return (payments, live) + } + + /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept + /// hold Spark while the node can't list them. Then drops the ones listed only as failed that were recorded before + /// the listing was read (`mark`), and trims paid ones past the limit. Returns the hashes still live. + private func keepLdkAttempts(from payments: [PaymentDetails], since mark: AttemptMark) -> Set { + let outbound = payments.filter { $0.direction == .outbound } + let sends = outbound + .filter { $0.status != .failed } + .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } + let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.id.lowercased() }) } + return eventCallbackLock.withLock { + if !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) { + let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + let seeded = sends.map { $0.id.lowercased() }.filter { !kept.contains($0) } + railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) + railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) + } + let live = hashes(.pending).union(attemptsLive(since: mark, key: Self.ldkAttemptsKey)) + let paid = hashes(.succeeded).subtracting(live) + let listed = Set(outbound.map { $0.id.lowercased() }) + trimSettledAttempts( + paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), listed: listed, live: live, key: Self.ldkAttemptsKey + ) + return live + } + } + + /// Trims the rail's settled hashes, so a payment that may still be pending, a send in flight when the listing was + /// read (`mark`) or since, or one recorded after it keeps holding the node. Returns those live hashes. + private func keepRailAttempts(from payments: [ChannelFreeRailPayment], since mark: AttemptMark) -> Set { + let outbound = payments.filter { $0.direction == .outbound } + let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } + return eventCallbackLock.withLock { + let live = hashes(.pending).union(attemptsLive(since: mark, key: Self.railAttemptsKey)) + let paid = hashes(.succeeded).subtracting(live) + let listed = Set(outbound.map { $0.paymentHash.lowercased() }) + trimSettledAttempts( + paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), listed: listed, live: live, key: Self.railAttemptsKey + ) + return live + } + } + + private var hasSeededLdkAttempts: Bool { + eventCallbackLock.withLock { railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) } + } + + /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a + /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed or unknown one lets the + /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too, and + /// while Spark hasn't listed its sends since a restore, one created (`invoiceCreatedAt`) before it is refused, as + /// is one Spark's list doesn't show pending or paid until it expires (`refuseUnverifiedRestoredInvoice`). + private func settledEarlierAttempt( + paymentHash: String?, + on rails: [LightningPaymentRail], + invoiceCreatedAt: UInt64?, + invoiceExpiresAt: Int? + ) async throws -> PaymentHash? { + guard let paymentHash else { return nil } + var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] + if rails.contains(.ldk) { + if let (payments, live) = await listNodePaymentsKeepingAttempts() { + let payment = payments.first { + $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame + } + if let payment { + statuses.append(payment.status) + } + // A retry in flight, or one recorded while the listing was read, holds the invoice as pending. + if payment == nil || payment?.status == .failed { + if live.contains(paymentHash.lowercased()) { + statuses.append(.pending) + } else { + releaseLdkAttempt(paymentHash: paymentHash) + } + } + } else if hasAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) { + Logger.warn("Refused paying '\(paymentHash)', the node can't list the earlier attempt now", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: paymentHash) + } else if !hasSeededLdkAttempts { + Logger.warn("Refused paying '\(paymentHash)' until the node lists its earlier payments once", context: "LightningService") + throw AppError(serviceError: .nodeNotStarted) + } + } + // The node's paid row is final, so no uncertainty about Spark's pre-restore sends refuses it. + let isPaidOverTheNode = statuses.contains(.succeeded) + if rails.contains(.channelFree) { + let mark = attemptMark + if let payments = await readableRailPayments() { + let live = keepRailAttempts(from: payments, since: mark) + let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) + if let payment { + statuses.append(payment.status) + } + if payment == nil || payment?.status == .failed { + if live.contains(paymentHash.lowercased()) || isRailSendInFlight(paymentHash: paymentHash) { + statuses.append(.pending) + } else if await !railSyncedSinceUncertainSend(paymentHash: paymentHash) { + Logger.info("Held '\(paymentHash)' until Spark syncs after its unknown send", context: "LightningService") + statuses.append(.pending) + } else { + releaseRailAttempt(paymentHash: paymentHash) + if !isPaidOverTheNode { + try await refuseUnverifiedRestoredInvoice( + paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + ) + } + } + } else if payment?.status == .succeeded { + // Only a settled row ends the unknown send: a pending one may be the synthetic row it is listed as. + endRailSendUncertainty(paymentHash: paymentHash) + } + } else if hasRailAttempt(paymentHash: paymentHash) { + Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: paymentHash) + } else if isPaidOverTheNode { + Logger.info("Skipped Spark's restore checks of '\(paymentHash)', the node already paid it", context: "LightningService") + } else if let invoiceCreatedAt, let since = sparkHistoryUnseededSince, Int(clamping: invoiceCreatedAt) < since { + // Nothing left the wallet, and an invoice made after the restore can't be one Spark paid before it. + Logger.warn("Refused paying '\(paymentHash)' until Spark lists its sends since the restore", context: "LightningService") + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } else { + try await refuseUnverifiedRestoredInvoice( + paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + ) + } + } + if statuses.contains(.succeeded) { + Logger.info("Skipped paying '\(paymentHash)', an earlier attempt already paid it", context: "LightningService") + return paymentHash + } + if statuses.contains(.pending) { + Logger.info("Refused paying '\(paymentHash)', an earlier attempt is still pending", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: paymentHash) + } + return nil + } + + /// After a restore, refuses a still-valid invoice created before it, whatever Spark's sync said, until it expires. + private func refuseUnverifiedRestoredInvoice(paymentHash: String, invoiceCreatedAt: UInt64?, invoiceExpiresAt: Int?) async throws { + guard let restoredAt = sparkRestoredAt, Self.holdsRestoredInvoice( + restoredAt: restoredAt, + invoiceCreatedAt: invoiceCreatedAt, + invoiceExpiresAt: invoiceExpiresAt, + now: Int(now().timeIntervalSince1970) + ) else { return } + let sparkRuns = channelFreeRail != nil ? true : await channelFreeRailSupported() + guard sparkRuns else { return } + Logger.warn("Refused paying '\(paymentHash)', a pre-restore invoice Spark hasn't listed", context: "LightningService") + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } + + /// An unknown time counts as before the restore and never expiring. + static func holdsRestoredInvoice(restoredAt: Int, invoiceCreatedAt: UInt64?, invoiceExpiresAt: Int?, now: Int) -> Bool { + Int(clamping: invoiceCreatedAt ?? 0) < restoredAt && invoiceExpiresAt.map { now < $0 } ?? true + } + + /// Nil while the rail's payments can't be read: listing them failed, or the rail runs here but isn't connected. + private func readableRailPayments() async -> [ChannelFreeRailPayment]? { + try? await channelFreeRailPayments() + } + + /// Callers hold `eventCallbackLock`. + private func uncertainRailSendFailedAt() -> [String: Int] { + railAttemptsStore.dictionary(forKey: Self.uncertainRailSendsKey) as? [String: Int] ?? [:] + } + + /// A rail send about to be submitted, or that failed after it was, until a sync after it and its invoice's expiry + /// settle it. The expiry is the one kept beside the hash. + private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark) { + let hash = paymentHash.lowercased() + let failedAt = Int(max(0, now().timeIntervalSince1970)) + eventCallbackLock.withLock { + guard attemptsRecorded(since: mark.records, key: Self.railAttemptsKey).contains(hash) else { return } + var uncertain = uncertainRailSendFailedAt() + uncertain[hash] = failedAt + railAttemptsStore.set(uncertain, forKey: Self.uncertainRailSendsKey) + } + } + + /// Rail sends whose outcome stayed unknown, listed as pending until a sync after them reaches the rail: Spark may + /// still finish one, while its own list shows only an earlier attempt, or nothing. + private func unsettledUncertainRailSends() async -> [ChannelFreeRailPayment] { + let hashes = eventCallbackLock.withLock { Array(uncertainRailSendFailedAt().keys) } + var pending: [ChannelFreeRailPayment] = [] + for hash in hashes { + if await !railSyncedSinceUncertainSend(paymentHash: hash) { + pending.append(ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .pending, preimage: nil)) + } + } + return pending + } + + /// Spark's kept sends whose invoice is unexpired. A sync can still resume one Spark committed but lists no row for, + /// also after a relaunch, so it is pending until its invoice expires, the rule `railSyncedSinceUncertainSend` holds. + private func unexpiredRailAttempts() -> Set { + let seconds = Int(now().timeIntervalSince1970) + return eventCallbackLock.withLock { + let expiries = attemptExpiries(key: Self.railAttemptsKey) + let hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + return Set(hashes.filter { hash in expiries[hash].map { $0 > seconds } ?? false }) + } + } + + private func endRailSendUncertainty(paymentHash: String) { + eventCallbackLock.withLock { + var uncertain = uncertainRailSendFailedAt() + guard uncertain.removeValue(forKey: paymentHash.lowercased()) != nil else { return } + railAttemptsStore.set(uncertain, forKey: Self.uncertainRailSendsKey) + } + } + + /// True unless the hash's last rail send failed after it was submitted and may still go out: no sync has reached the + /// rail since, or its invoice hasn't expired. + private func railSyncedSinceUncertainSend(paymentHash: String) async -> Bool { + let hash = paymentHash.lowercased() + let uncertain = eventCallbackLock.withLock { + uncertainRailSendFailedAt()[hash].map { (failedAt: UInt64($0), expiresAt: attemptExpiries(key: Self.railAttemptsKey)[hash]) } + } + guard let uncertain else { return true } + guard let rail = channelFreeRail, await rail.hasSynced(after: uncertain.failedAt) else { return false } + // Spark keeps a send it committed but didn't finish in a cache no call exposes, and a sync that reached Spark + // can still fail to resume it, so only an expired invoice shows a missing send will never go out. + guard let expiresAt = uncertain.expiresAt, Int(now().timeIntervalSince1970) >= expiresAt else { return false } + endRailSendUncertainty(paymentHash: hash) + return true + } + + private static func pendingDuplicate(paymentHash: String?) -> NodeError { + .DuplicatePayment(message: "An earlier attempt to pay '\(paymentHash ?? "")' is still pending") + } + + func beginRailSend(paymentHash: String?) -> Bool { + guard let paymentHash else { return true } + return eventCallbackLock.withLock { railSendsInFlight.insert(paymentHash.lowercased()).inserted } + } + + /// The last check before a send leaves on `rail`, under the same lock that marks the other rail's sends in flight, + /// so of two sends of one invoice only the first admitted goes out. Refuses while the other rail has one in flight, + /// or recorded one since `mark`. An admitted node send stays in flight until `endLdkSend`; a Spark send already is. + func admitSend(paymentHash: String?, on rail: LightningPaymentRail, since mark: AttemptMark) throws { + guard let hash = paymentHash?.lowercased() else { return } + try eventCallbackLock.withLock { + let otherInFlight = rail == .ldk ? railSendsInFlight : ldkSendsInFlight + let otherKey = rail == .ldk ? Self.railAttemptsKey : Self.ldkAttemptsKey + if otherInFlight.contains(hash) || attemptsRecorded(since: mark.records, key: otherKey).contains(hash) { + Logger.warn("Refused paying '\(hash)', the other rail sent it meanwhile", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: hash) + } + if rail == .ldk { + ldkSendsInFlight.insert(hash) + } + } + } + + func beginLdkSend(paymentHash: String?) { + guard let paymentHash else { return } + _ = eventCallbackLock.withLock { ldkSendsInFlight.insert(paymentHash.lowercased()) } + } + + func endLdkSend(paymentHash: String?) { + guard let paymentHash else { return } + _ = eventCallbackLock.withLock { ldkSendsInFlight.remove(paymentHash.lowercased()) } + } + + func endRailSend(paymentHash: String?) { + guard let paymentHash else { return } + _ = eventCallbackLock.withLock { railSendsInFlight.remove(paymentHash.lowercased()) } + } + + private func isRailSendInFlight(paymentHash: String) -> Bool { + eventCallbackLock.withLock { railSendsInFlight.contains(paymentHash.lowercased()) } + } + static func submitPayment( beforeSubmission: @escaping () throws -> Void, send: @escaping () throws -> Result @@ -1376,6 +2168,111 @@ extension LightningService { // MARK: Events extension LightningService { + /// Delivers a channel-free rail's payment as an LDK event, so flows that wait on node events cover both rails. + /// Sets the callback node and rail events are delivered to, and delivers the rail events that arrived before it. + func setEventCallback(_ onEvent: @escaping (Event) -> Void) { + let pending = eventCallbackLock.withLock { + storedEventCallback = onEvent + defer { pendingChannelFreeRailEvents = [] } + return pendingChannelFreeRailEvents + } + pending.forEach(onEvent) + } + + /// Drops the event callback and the rail events waiting for it. + func clearEventCallback() { + eventCallbackLock.withLock { + storedEventCallback = nil + pendingChannelFreeRailEvents = [] + } + } + + /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. + func emitChannelFreeRailEvent(_ event: Event) { + switch event { + case let .paymentFailed(_, paymentHash?, _): + // The event settles a send whose outcome was unknown, so retries of its invoice are no longer held. + endRailSendUncertainty(paymentHash: paymentHash) + // The failure of the send itself, which may arrive while its send call is still returning. + releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) + case let .paymentSuccessful(_, paymentHash, _, _): + endRailSendUncertainty(paymentHash: paymentHash) + default: + break + } + if isStaleFailure(event, from: .channelFree) { + Logger.info("Ignored a Spark failure for a payment retried over a channel: \(event)", context: "LightningService") + return + } + let callback = eventCallbackLock.withLock { + if storedEventCallback == nil { + pendingChannelFreeRailEvents.append(event) + } + return storedEventCallback + } + callback?(event) + } + + func noteAttempt(on rail: LightningPaymentRail, paymentHash: String) { + eventCallbackLock.withLock { attemptRails[paymentHash.lowercased()] = rail } + } + + private func noteAttempt(on rail: LightningPaymentRail, bolt11: String) { + guard let hash = try? Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash() else { return } + noteAttempt(on: rail, paymentHash: String(hash)) + } + + /// The rail the latest attempt to pay `paymentHash` went out on, nil when none went out since launch. + func attemptRail(paymentHash: String) -> LightningPaymentRail? { + eventCallbackLock.withLock { attemptRails[paymentHash.lowercased()] } + } + + /// A failure `rail` reports for a payment whose latest attempt went out on the other rail: it ended an earlier + /// attempt, so it must not settle the retry. + func isStaleFailure(paymentHash: String, from rail: LightningPaymentRail) -> Bool { + guard let current = attemptRail(paymentHash: paymentHash) else { return false } + return current != rail + } + + func isStaleFailure(_ event: Event, from rail: LightningPaymentRail) -> Bool { + guard case let .paymentFailed(paymentId, paymentHash, _) = event, let hash = paymentHash ?? paymentId else { return false } + return isStaleFailure(paymentHash: hash, from: rail) + } + + /// The rail's Lightning payments, none where the rail doesn't run; throws when the rail could not list them, runs + /// here but isn't connected, or no sync has reached it yet, so a payment it may hold is never read as one it never + /// made. + func channelFreeRailPayments() async throws -> [ChannelFreeRailPayment] { + // During an emergency exit Spark is off the rail but still lists the payments it made. + if let rail = channelFreeRail ?? channelFreePaymentHistory { + // A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves + // nothing. + guard await rail.hasSynced(after: nil) else { throw ChannelFreeRailUnavailable() } + // Read before the listing, so a sync that ends while it is read can't settle a send the listing predates. + let unsettled = await unsettledUncertainRailSends() + let kept = unexpiredRailAttempts() + let payments = try await rail.lightningPayments() + if let since = sparkHistoryUnseededSince, await rail.hasSynced(after: UInt64(clamping: since)) { + // Kept, so after a relaunch the sends Spark listed hold the node until Spark syncs again. + let seeded = payments.filter { $0.direction == .outbound && $0.status != .failed }.map { $0.paymentHash.lowercased() } + eventCallbackLock.withLock { + let hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + let added = Array(Set(seeded).subtracting(hashes)).sorted() + railAttemptsStore.set(added + hashes, forKey: Self.railAttemptsKey) + } + clearSparkHistoryUnseeded() + } + let listed = Set((payments + unsettled).filter { $0.direction == .outbound }.map { $0.paymentHash.lowercased() }) + let unlisted = kept.subtracting(listed).sorted() + .map { ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .pending, preimage: nil) } + return payments + unsettled + unlisted + } + if await channelFreeRailSupported() { + throw ChannelFreeRailUnavailable() + } + return [] + } + func deliverEvent(_ event: Event, onEvent: ((Event) -> Void)?) async -> OnchainActivityCompletion? { let completion: OnchainActivityCompletion? = switch event { case .onchainTransactionReceived, .onchainTransactionConfirmed, .onchainTransactionReplaced, @@ -1414,6 +2311,11 @@ extension LightningService { await refreshChannelCache() } + if self.isStaleFailure(event, from: .ldk) { + Logger.info("Ignored an LDK failure for a payment retried over Spark: \(event)", context: "LightningService") + continue + } + let onchainActivityCompletion = await deliverEvent(event, onEvent: onEvent) switch event { @@ -1772,7 +2674,10 @@ extension LightningService { } } - func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil) async throws -> UInt64 { + func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil, channelsOnly: Bool = false) async throws -> UInt64 { + if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: amountSats) { + return try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -1852,3 +2757,10 @@ extension LightningService { } } } + +/// The attempts recorded and the sends in flight on each rail when a listing was about to be read. +struct AttemptMark { + let records: Int + let railInFlight: Set + let ldkInFlight: Set +} diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 6c26735a7..e802537af 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -292,6 +292,12 @@ struct OnchainSendAttempt: Codable, Equatable { var releaseApproved: Bool? = nil /// The transaction spending the original's coins back to the wallet, saved before it is broadcast. var releaseTxid: String? = nil + /// Set on a transfer from savings to the Spark deposit address, which pays no order. + var sparkFunding: Bool? = nil + + var isSparkFunding: Bool { + sparkFunding == true && requestId == nil && orderId == nil + } var blocksNewSend: Bool { status.blocksNewSend || !localFollowupComplete @@ -344,6 +350,13 @@ struct OnchainSendFollowupContext: Codable, Equatable { var channelId: String? = nil } +/// A Spark funding refused before broadcast because it would send another amount, or pay a higher mining fee, than the +/// user approved, as after the balance grew. A fixed amount's fee is bound too, as its coin selection can vary. +struct SparkFundingChanged: Error { + /// The mining fee of the refused prepared send, nil when it was unknown. + var preparedFeeSats: UInt64? +} + struct OnchainSendTransferContext: Codable, Equatable { let clientBalanceSats: UInt64 let txTotalSats: UInt64 @@ -358,6 +371,8 @@ struct OnchainSendLocalResolution { let amountSats: UInt64 let contact: String? let activity: OnchainActivity + /// A recovered transfer to the Spark deposit address, which Spark must be told about and the funding flow left. + var isSparkFunding = false } protocol OnchainSendLocalFollowupHandling { @@ -591,6 +606,8 @@ actor OnchainSendAttemptService { paymentIdentity: String? = nil, followupContext: OnchainSendFollowupContext? = nil, transferContext: OnchainSendTransferContext? = nil, + isSparkFunding: Bool = false, + approvedMiningFeeSats: UInt64? = nil, paymentDeadline: PaykitPreciseInstant? = nil, beforeBroadcastAttempt: () async throws -> Void = {}, onPreDispatchFailure: (Error) async throws -> Void = { _ in throw OnchainSendAttemptError.preDispatchCleanupFailed } @@ -613,7 +630,8 @@ actor OnchainSendAttemptService { amountSats: amountSats, isMaxAmount: isMaxAmount, followupContext: followupContext, - transferContext: transferContext + transferContext: transferContext, + isSparkFunding: isSparkFunding ) nativeDispatchInProgress = attemptId defer { nativeDispatchInProgress = nil } @@ -638,6 +656,12 @@ actor OnchainSendAttemptService { prepared.recipientAmountSats <= context.txTotalSats - miningFee else { throw OnchainSendAttemptError.unresolved } } + // Savings never pay more than the Total the user confirmed, so a higher fee asks for another swipe. + if isSparkFunding || approvedMiningFeeSats != nil { + guard prepared.recipientAmountSats == amountSats, let miningFee = prepared.miningFeeSats, + let approvedFee = approvedMiningFeeSats ?? followupContext?.feeSats, miningFee <= approvedFee + else { throw SparkFundingChanged(preparedFeeSats: prepared.miningFeeSats) } + } attempt.amountSats = prepared.recipientAmountSats attempt.txid = prepared.txid if let miningFee = prepared.miningFeeSats { @@ -869,7 +893,8 @@ actor OnchainSendAttemptService { amountSats: UInt64, isMaxAmount: Bool, followupContext: OnchainSendFollowupContext? = nil, - transferContext: OnchainSendTransferContext? = nil + transferContext: OnchainSendTransferContext? = nil, + isSparkFunding: Bool = false ) throws -> UUID { guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.blocked(try? currentAttempt()) } if let previous = try currentAdmissionAttempt() { @@ -890,7 +915,8 @@ actor OnchainSendAttemptService { isMaxAmount: isMaxAmount, status: .pending, followupContext: followupContext, - transferContext: transferContext + transferContext: transferContext, + sparkFunding: isSparkFunding ? true : nil ) try store.save([attempt]) knownAttempt = attempt @@ -929,11 +955,24 @@ actor OnchainSendAttemptService { try store.save([attempt]) } + /// The Spark funding still waiting on its outcome, so the confirm screen can keep the approval its swipe gave. + func unresolvedSparkFundingAttemptId() -> UUID? { + guard let attempt = try? currentAttempt(), attempt.isSparkFunding, attempt.status != .accepted else { return nil } + return attempt.id + } + private func currentAdmissionAttempt() throws -> OnchainSendAttempt? { do { return try currentAttempt() } catch { throw OnchainSendAttemptError.unresolved } } + /// The address of the send still unresolved (pending, unknown or refused, so retryable or blocking a new send), if any. + /// A retry always pays this same address with the same inputs. + func unresolvedSendAddress() -> String? { + guard let attempt = try? currentAttempt(), attempt.status.blocksNewSend else { return nil } + return attempt.address + } + private func currentAttempt() throws -> OnchainSendAttempt? { let attempts = try store.load() guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved } @@ -987,7 +1026,8 @@ actor OnchainSendAttemptService { saved.txId.caseInsensitiveCompare(txid) == .orderedSame, saved.txType == .sent else { throw OnchainSendAttemptError.localFollowupNotSaved } return OnchainSendLocalResolution( - attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved, + isSparkFunding: attempt.isSparkFunding ) } ordinaryFollowupInProgress = attempt.id @@ -996,7 +1036,8 @@ actor OnchainSendAttemptService { let activity = try await localFollowup.save(attempt) try acknowledgeLocalFollowup(txid: txid) let resolution = OnchainSendLocalResolution( - attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact, activity: activity + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact, activity: activity, + isSparkFunding: attempt.isSparkFunding ) Self.localResolutionSubject.send(resolution) return resolution diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 7bcede912..8b64473aa 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -1372,8 +1372,10 @@ actor PaykitAllowanceExecutor { setStage(.sending, attemptId: attemptId, identity: identity) } - func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { - guard let identity = activeIdentity else { return } + /// Returns whether the outcome was recorded. + @discardableResult + func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async -> Bool { + guard let identity = activeIdentity else { return false } if let transactionId { updateLocalState(identity: identity) { state in guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } @@ -1382,11 +1384,26 @@ actor PaykitAllowanceExecutor { } do { try await record(attemptId: attemptId, outcome: outcome, identity: identity) + return true } catch { Logger.warn("Failed to record a manual payment outcome: \(error)", context: "PaykitAllowance") + return false } } + /// Settles the manual attempt still open for a request once its outcome is learned later, as a Spark withdrawal's + /// journal learns it, so an unsent send no longer blocks the request on the ledger. + /// Returns whether no attempt is left open for the request. + @discardableResult + func settleManualPayment(requestId: PaykitPaymentRequest.ID, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async -> Bool { + guard let identity = activeIdentity else { return false } + guard let entry = localState(identity: identity).journal.last(where: { + !$0.isAutomatic && $0.requestId == requestId && !$0.stage.isTerminal + }) + else { return true } + return await finishManualPayment(attemptId: entry.attemptId, outcome: outcome, transactionId: transactionId) + } + // MARK: Helpers private func checks(_ request: PaykitPaymentRequest, endpointIdentifier: String, trustedTime: String) throws -> Paykit.PaymentExecutionChecks { diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index adaac567e..162a59b9d 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -69,7 +69,7 @@ final class PaykitAllowanceManager { @ObservationIgnored private let sdk: any PaykitAllowanceSdkHandling @ObservationIgnored private let executor: PaykitAllowanceExecutor @ObservationIgnored private let now: () -> Date - @ObservationIgnored private let canPayNow: @MainActor () -> Bool + @ObservationIgnored private let payableNow: @MainActor ([PaykitPaymentRequest]) -> [PaykitPaymentRequest] @ObservationIgnored private var identity: String? /// Changes on every activation and deactivation, so work started under an earlier one drops its results. @ObservationIgnored private var activation = 0 @@ -101,22 +101,38 @@ final class PaykitAllowanceManager { sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, executor: PaykitAllowanceExecutor = .shared, now: @escaping () -> Date = { Date() }, - canPayNow: @escaping @MainActor () -> Bool = PaykitAllowanceManager.lightningReady + payableNow: @escaping @MainActor ([PaykitPaymentRequest]) -> [PaykitPaymentRequest] = PaykitAllowanceManager.payableNow ) { self.sdk = sdk self.executor = executor self.now = now - self.canPayNow = canPayNow + self.payableNow = payableNow + } + + /// The covered requests that can be paid right now. + static func payableNow(_ covered: [PaykitPaymentRequest]) -> [PaykitPaymentRequest] { + let rail = LightningService.shared.channelFreeRail + return payable(covered, nodeCanPay: nodeCanPay(), sparkSendableSats: rail?.isReady == true ? rail?.sendableSats : nil) } /// A node that just started lists its channels before they reconnect, and a payment sent then finds no route. - static func lightningReady() -> Bool { + static func nodeCanPay() -> Bool { guard LightningService.shared.status?.isRunning == true, let channels = LightningService.shared.channels else { return false } return channels.isEmpty || channels.contains(where: \.isUsable) } + /// Everything covered pays while the node can; otherwise Spark pays what it can cover, without the node, and the + /// rest waits for the next refresh. + static func payable(_ covered: [PaykitPaymentRequest], nodeCanPay: Bool, sparkSendableSats: UInt64?) -> [PaykitPaymentRequest] { + if nodeCanPay { + return covered + } + guard let sparkSendableSats else { return [] } + return covered.filter { $0.allowanceAmountSats <= sparkSendableSats } + } + var entries: [PaykitAllowanceEntry] { var grouped: [String: [PaykitAllowance]] = [:] var order: [String] = [] @@ -460,21 +476,26 @@ final class PaykitAllowanceManager { let signature = allowancesSignature let covered = requests.filter(isAwaitingAutomaticPayment) guard !covered.isEmpty else { return false } - guard canPayNow() else { - // A request over the limits needs the user anyway, so it is handed back before waiting for a channel. - var handedBack = false - for request in covered where await executor.exceedsLimits(request, allowances: allowances, identity: identity) { - manualRequestIds[request.id] = signature - handedBack = true - } - Logger.info("Holding covered requests until the node has a usable channel", context: "PaykitAllowance") + let payable = payableNow(covered) + let payableIds = Set(payable.map(\.id)) + let waiting = covered.filter { !payableIds.contains($0.id) } + // A request over the limits needs the user anyway, so it is handed back before waiting for a channel. + var handedBack = false + for request in waiting where await executor.exceedsLimits(request, allowances: allowances, identity: identity) { + manualRequestIds[request.id] = signature + handedBack = true + } + if !waiting.isEmpty { + Logger.info("Holding '\(waiting.count)' covered requests until the node or Spark can pay them", context: "PaykitAllowance") + } + guard !payable.isEmpty else { return handedBack } isProcessingRequests = true defer { isProcessingRequests = false } var handledAny = false - for request in covered { + for request in payable { guard activation == passActivation else { break } let result = await executor.autoPay(request, allowances: allowances, identity: identity) Logger.info("Automatic payment pass for a covered request ended as \(result)", context: "PaykitAllowance") @@ -490,7 +511,7 @@ final class PaykitAllowanceManager { if handledAny { await refresh() } - return handledAny + return handledAny || handedBack } /// Whether the allowance flow owns the request, so no sheet may open for it. A covered request belongs to the flow diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 9273f9637..6b9908766 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -131,6 +131,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainLocalFollowupComplete: Bool? /// Retain the bounded original proof until its payer consumes the local resolution. var onchainResolutionAwaitingConsumption: Bool? = false + /// A Spark withdrawal pays this proof, so only that withdrawal's journal may complete or drop it. + var sparkWithdrawal: Bool? var hasUnsupportedOnchainWallet: Bool { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default @@ -250,7 +252,45 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payments = await LightningService.shared.listPayments() else { return .unavailable } + await Self.combined( + ldk: Self.ldkStatus(paymentHash: paymentHash), + rail: Self.railStatus(paymentHash: paymentHash), + latestAttempt: LightningService.shared.attemptRail(paymentHash: paymentHash) + ) + } + + /// One outcome from both rails, since an invoice retried on the other rail is listed by each. A settled payment is + /// settled whichever rail paid it; otherwise the rail that carried the latest attempt decides, and without one known + /// (after a restart) a pending attempt wins over a failed one. + static func combined( + ldk: PaykitLightningPaymentProofStatus, + rail: PaykitLightningPaymentProofStatus, + latestAttempt: LightningPaymentRail? + ) -> PaykitLightningPaymentProofStatus { + for status in [ldk, rail] { + if case .succeeded = status { + return status + } + } + let latest: PaykitLightningPaymentProofStatus? = switch latestAttempt { + case .ldk: ldk + case .channelFree: rail + case nil: nil + } + if let latest, latest != .unknown, latest != .unavailable { + return latest + } + // A rail that can't list its payments may hold a live retry, so it outranks the other rail's failure. + for wanted in [PaykitLightningPaymentProofStatus.pending, .unavailable, .failed] where ldk == wanted || rail == wanted { + return wanted + } + return .unknown + } + + private static func ldkStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + guard let payments = await LightningService.shared.listPayments() else { + return .unavailable + } guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { @@ -267,6 +307,22 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { return .succeeded(preimage: preimage) } } + + /// Spark payments are not in LDK's list, so a proof whose completion event was missed settles from Spark's own record. + /// Unavailable while Spark can't list its payments. + static func railStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + guard let payments = try? await LightningService.shared.channelFreeRailPayments() else { + return .unavailable + } + guard let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) else { + return .unknown + } + switch payment.status { + case .pending: return .pending + case .failed: return .failed + case .succeeded: return .succeeded(preimage: payment.preimage) + } + } } private actor PaykitProofMutationLock { @@ -828,7 +884,8 @@ actor PaykitPaymentProofService { paymentIdentity: String? = nil, signedTx: HwFundingSignedTx? = nil, privatePaymentListVersion: UInt64? = nil, - previousPrivatePaymentListVersion: UInt64? = nil + previousPrivatePaymentListVersion: UInt64? = nil, + sparkWithdrawal: Bool = false ) async throws { let identity = try await currentIdentity() try await mutationLock.withLock { @@ -841,7 +898,8 @@ actor PaykitPaymentProofService { serializedTx: signedTx?.serializedTx, signedTx: signedTx, privatePaymentListVersion: privatePaymentListVersion, - previousPrivatePaymentListVersion: previousPrivatePaymentListVersion + previousPrivatePaymentListVersion: previousPrivatePaymentListVersion, + sparkWithdrawal: sparkWithdrawal ) } } @@ -855,7 +913,8 @@ actor PaykitPaymentProofService { serializedTx: String?, signedTx: HwFundingSignedTx?, privatePaymentListVersion: UInt64?, - previousPrivatePaymentListVersion: UInt64? + previousPrivatePaymentListVersion: UInt64?, + sparkWithdrawal: Bool = false ) async throws { if let hardwareWalletId { guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) @@ -897,6 +956,7 @@ actor PaykitPaymentProofService { pendingProofs[index].hardwareDispatchAttempted = signedTx.map { _ in false } pendingProofs[index].privatePaymentListVersion = privatePaymentListVersion pendingProofs[index].previousPrivatePaymentListVersion = privatePaymentListVersion == nil ? nil : previousPrivatePaymentListVersion + pendingProofs[index].sparkWithdrawal = sparkWithdrawal ? true : nil try await persist(pendingProofs) } @@ -1321,6 +1381,8 @@ actor PaykitPaymentProofService { let underlyingError = (submissionError as? AppError)?.underlyingError ?? submissionError if underlyingError as? PaykitPaymentRequestError == .requestExpired { // The local deadline gate runs before submitting to the node. + } else if underlyingError is ChannelFreeRailSendRefused { + // Spark refused it before sending: not connected, not preparable, or over the fee limit. } else if let serviceError = underlyingError as? CustomServiceError { guard serviceError == .nodeNotSetup || serviceError == .nodeNotStarted else { return false } } else if let nodeError = underlyingError as? NodeError { @@ -1337,6 +1399,95 @@ actor PaykitPaymentProofService { return true } + /// Completes the on-chain proof of a request Spark paid by withdrawing to the payee's address. Spark's withdrawal + /// isn't one of the node's send attempts, so its txid is the proof as soon as Spark returns it. + func completeSparkOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String, txid: String) async -> Bool { + guard Self.isHex(txid, byteCount: 32) else { return false } + do { + let completed: PendingPaykitPaymentProof? = try await mutationLock.withLock { + var proofs = try await loadProofs() + guard let index = proofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && + $0.paymentStarted && $0.onchainWalletId == nil && $0.sparkWithdrawal == true && + ($0.paymentIdentifier == nil || $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame) + }) else { return nil } + proofs[index].paymentIdentifier = txid.lowercased() + proofs[index].proofData = txid.lowercased() + proofs[index].onchainAcceptanceVerified = true + proofs[index].onchainLocalFollowupComplete = true + try await persist(proofs) + return proofs[index] + } + // No started proof left means an earlier pass already saved it, or the request no longer waits on one. + if let completed { + submitInBackground(completed, priority: .interactive) + } + return true + } catch { + logWarning("Failed to retain a Spark on-chain proof for retry: \(error)") + return false + } + } + + /// Drops the started on-chain proof of a request whose Spark withdrawal never went out, giving back the private list + /// version it consumed when the contact's list is still at exactly that version. Only a proof the withdrawal started + /// goes, never a direct on-chain send's for the same request. `.failed` when it could not be dropped, so the + /// withdrawal's journal stays open for a retry. + @discardableResult + func failSparkOnchainPayment( + requestId: PaykitPaymentRequest.ID, + identity: String, + unstartedPaymentList: SparkContactWithdrawal.PaymentList? = nil, + privatePaykitService: PrivatePaykitService = .shared, + settleAllowance: () async -> Bool = { true } + ) async -> SparkProofRelease { + do { + return try await mutationLock.withLock { + let proofs = try await loadProofs() + let unsent = proofs.filter { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && + $0.paymentStarted && $0.onchainWalletId == nil && $0.sparkWithdrawal == true && + $0.paymentIdentifier == nil && $0.proofData == nil + } + guard !unsent.isEmpty else { + // A kill before the proof started leaves the list the request consumed only in the journal. + if let list = unstartedPaymentList { + try await privatePaykitService.releaseUnsentPaymentListVersion( + publicKey: requestId.counterparty, + version: list.version, + previousVersion: list.previousVersion + ) + } + return .nothingStarted + } + // The allowance attempt is settled before the proof goes: a restart in between, or a failed ledger + // write, leaves the proof and so the journal, which retry the settlement. + guard await settleAllowance() else { return .failed } + for proof in unsent { + guard let version = proof.privatePaymentListVersion else { continue } + try await privatePaykitService.releaseUnsentPaymentListVersion( + publicKey: proof.requestId.counterparty, + version: version, + previousVersion: proof.previousPrivatePaymentListVersion + ) + } + do { + try await persist(proofs.filter { !unsent.contains($0) }) + } catch { + for proof in unsent { + guard let version = proof.privatePaymentListVersion else { continue } + try await privatePaykitService.retainOriginalPaymentListVersion(publicKey: proof.requestId.counterparty, version: version) + } + throw error + } + return .released + } + } catch { + logWarning("Failed to clear an unsent Spark on-chain proof: \(error)") + return .failed + } + } + @discardableResult func failOnchainPayment(_ request: PaykitPaymentRequest, paymentIdentity: String? = nil) async -> Bool { let liveIdentity = paymentIdentity == nil ? try? await currentIdentity() : nil @@ -1968,3 +2119,13 @@ actor PaykitPaymentProofService { } } } + +/// What dropping a Spark withdrawal's started proof did. +enum SparkProofRelease: Equatable { + /// The withdrawal's started proof is gone. + case released + /// The withdrawal never started a proof: a kill came before it, or a direct send owns the request's proof. + case nothingStarted + /// The proof could not be dropped, so it stays for a retry. + case failed +} diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 723ad1cbd..330e0be04 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -686,7 +686,7 @@ struct PaykitPaymentRequestService { UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) }, canReceiveLightning: @escaping @MainActor @Sendable () -> Bool = { - LightningService.shared.channels?.contains(where: \.isUsable) ?? false + LightningService.shared.channels?.contains(where: \.isUsable) ?? false || SparkManager.shared.isReady }, marketRate: @escaping @MainActor @Sendable () -> PaykitExchangeRate? = { CurrencyService.shared.loadCachedRates()?.first { $0.quote == "USD" }.map { PaykitExchangeRate( @@ -2074,6 +2074,16 @@ final class PaykitPaymentRequestManager { } } + private static let declinedLifecycleStates: [Paykit.PaymentRequestLifecycleState] = [.rejected, .canceled] + + /// Requests the user declined stay marked as presented while the SDK still lists them, so one never pops up again + /// if a later read reports it as proposed. + private var declinedRequestIds: Set { + Set(historyRequests.filter { + $0.direction == .incoming && Self.declinedLifecycleStates.contains($0.lifecycleState) + }.map(\.id)) + } + func canDismiss(_ request: PaykitPaymentRequest) -> Bool { retainedHardwareRetries[request.id] == nil } @@ -2714,7 +2724,7 @@ final class PaykitPaymentRequestManager { clockOffset: subscriptionClockOffset ) let currentRequestIds = Set(pendingRequests.map(\.id)) - presentedRequestIds.formIntersection(currentRequestIds) + presentedRequestIds.formIntersection(currentRequestIds.union(declinedRequestIds)) presentationRetryAttempts = presentationRetryAttempts.filter { currentRequestIds.contains($0.key) } presentationRetryDeadlines = presentationRetryDeadlines.filter { currentRequestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { currentRequestIds.contains($0.key) } @@ -2825,7 +2835,11 @@ final class PaykitPaymentRequestManager { historyRequests.removeAll { $0.id == request.id } historyRequests.insert(updatedRequest, at: 0) pendingRequests.removeAll { $0.id == request.id } - presentedRequestIds.remove(request.id) + if Self.declinedLifecycleStates.contains(resultingState) { + presentedRequestIds.insert(request.id) + } else { + presentedRequestIds.remove(request.id) + } presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDeadlines.removeValue(forKey: request.id) automaticPresentationDiagnosticReasons.removeValue(forKey: request.id) @@ -2898,7 +2912,7 @@ final class PaykitPaymentRequestManager { requestedSubscriptionProposalId = nil } let requestIds = Set(pendingRequests.map(\.id)) - presentedRequestIds.formIntersection(requestIds) + presentedRequestIds.formIntersection(requestIds.union(declinedRequestIds)) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDeadlines = presentationRetryDeadlines.filter { requestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift new file mode 100644 index 000000000..8638c664f --- /dev/null +++ b/Bitkit/Services/PaykitRouteService.swift @@ -0,0 +1,483 @@ +import BitkitCore +import Foundation +import Paykit + +/// A contact payment bitkit-core's Paykit router picks the way for: the contact's bitcoin endpoints, whether a Spark leg +/// may reach its on-chain ones, the leg once the router picked one, and the decoded invoice and LNURL a Direct pick pays. +struct PaykitRouteUi: Equatable { + let endpoints: [PaykitEndpoint] + let offersSparkLeg: Bool + var serviceLeg: ServiceRouteLeg? + var invoice: LightningInvoice? + var lnurl: LnurlPayData? + + var deliveredSat: UInt64? { + serviceLeg?.deliveredSat + } + + var totalFeeSat: UInt64? { + serviceLeg.map { $0.debitSat.saturatingSub($0.deliveredSat) } + } + + var onchainEndpoints: [PaykitEndpoint] { + endpoints.filter(\.isOnchain) + } +} + +extension PaykitEndpoint { + var isOnchain: Bool { + PublicPaykitService.MethodId(rawValue: methodId)?.onchainNetwork != nil + } + + var isLnurl: Bool { + methodId == PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + } +} + +/// Routes every bitcoin contact payment through bitkit-core's Paykit router: Direct pays one of the contact's +/// endpoints, Service pays through a service the wallet holds a balance on (a Spark withdrawal to the contact's on-chain +/// address), None pays nothing. +enum PaykitRouteService { + /// The source the payment is paid from, as picked on the amount screen. + enum Source { + case spending + case savings + } + + /// Spark can carry a leg while it is ready and holds a sendable balance. + @MainActor + static var canRouteThroughSpark: Bool { + SparkManager.shared.isReady && SparkManager.shared.sendableSats > 0 + } + + /// The route for a contact payment, or nil without bitcoin endpoints. A Spark leg is offered to a contact with an + /// on-chain endpoint while Spark can route, whatever Lightning can pay, since the router still picks a direct payment + /// that works; a request it pays gets its on-chain proof from the withdrawal's txid. + static func routeUi(for context: ContactPaymentContext, canRouteThroughSpark: Bool) -> PaykitRouteUi? { + let endpoints = context.endpoints + .filter { $0.methodId != .usdtArbitrum } + .map { PaykitEndpoint(methodId: $0.methodId.rawValue, value: $0.value) } + guard !endpoints.isEmpty else { return nil } + let offersSparkLeg = canRouteThroughSpark && endpoints.contains(where: \.isOnchain) + return PaykitRouteUi(endpoints: endpoints, offersSparkLeg: offersSparkLeg) + } + + /// What the router is asked from `source`: Spending offers every endpoint but an LNURL whose limits refuse the amount, + /// Savings only the on-chain ones. + static func request( + route: PaykitRouteUi, + amountSats: UInt64, + source: Source, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + serviceLegs: [ServiceRouteLeg] + ) -> PaykitRouteRequest { + switch source { + case .spending: + PaykitRouteRequest( + amountSat: amountSats, + contactEndpoints: route.endpoints.filter { !$0.isLnurl || route.lnurl?.accepts(amountSats: amountSats) != false }, + lightningSpendableSat: lightningSpendableSats, + onchainSpendableSat: 0, + serviceLegs: serviceLegs, + // Only bitkit-core's Pubky Swaps route reads these, and the app never asks it, so no swap comes back. + providers: [], + feeRateSatVb: 0 + ) + case .savings: + PaykitRouteRequest( + amountSat: amountSats, + contactEndpoints: route.onchainEndpoints, + lightningSpendableSat: 0, + onchainSpendableSat: onchainSpendableSats, + serviceLegs: [], + providers: [], + feeRateSatVb: 0 + ) + } + } + + /// Asks the router how to pay `amountSats` from `source`. The router pays directly before it weighs any leg, so a + /// Spark leg to each on-chain endpoint is quoted only when the route offers one and no balance pays. Spark takes one + /// contact withdrawal at a time, so while an earlier one's journal is open no leg is quoted and this throws + /// `SparkWithdrawalSettling`. + static func route( + _ route: PaykitRouteUi, + amountSats: UInt64, + source: Source, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + quoteLegs: ([PaykitEndpoint], UInt64) async throws -> [ServiceRouteLeg] = { try await sparkLegs(to: $0, amountSats: $1) } + ) async throws -> PaykitRoute { + func pick(_ serviceLegs: [ServiceRouteLeg]) throws -> PaykitRoute { + try paykitRoutePayment( + request: request( + route: route, + amountSats: amountSats, + source: source, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats, + serviceLegs: serviceLegs + ), + network: Env.bitkitCoreNetwork + ) + } + let direct = try pick([]) + guard case .none = direct, source == .spending, route.offersSparkLeg else { return direct } + guard SparkManager.pendingContactWithdrawal == nil else { throw SparkWithdrawalSettling() } + let serviceLegs = try await quoteLegs(route.onchainEndpoints, amountSats) + return serviceLegs.isEmpty ? direct : try pick(serviceLegs) + } + + /// Spark's leg to each of `endpoints`, from `quote`. Throws `SparkLegUnaffordable` when no leg is quoted and one + /// was dropped because Spark's balance can't cover the amount and its fee. + static func sparkLegs( + to endpoints: [PaykitEndpoint], + amountSats: UInt64, + quote: (PaykitEndpoint, UInt64) async throws -> ServiceRouteLeg? = { + try await SparkManager.shared.withdrawalRouteLeg(endpoint: $0, amountSats: $1) + } + ) async throws -> [ServiceRouteLeg] { + var legs: [ServiceRouteLeg] = [] + var unaffordable: SparkLegUnaffordable? + for endpoint in endpoints { + do { + if let leg = try await quote(endpoint, amountSats) { + legs.append(leg) + } + } catch { + unaffordable = unaffordable ?? error as? SparkLegUnaffordable + Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PaykitRoute") + } + } + if legs.isEmpty, let unaffordable { + throw unaffordable + } + return legs + } + + /// Whether a Spark withdrawal carries `amountSats` on `route`, or nil when Lightning's limit decides. A withdrawal + /// is paid from Spark's whole balance, so Lightning's limit never caps it: a quoted leg fits when its debit, fees + /// included, does, and before the quote an amount the balance covers lets Continue ask for one. + static func sparkWithdrawalValidity(route: PaykitRouteUi?, amountSats: UInt64, sparkBalanceSats: UInt64) -> Bool? { + guard let route, route.offersSparkLeg, amountSats > 0 else { return nil } + if let leg = route.serviceLeg, leg.deliveredSat == amountSats { + return sparkBalanceSats >= leg.debitSat + } + return sparkBalanceSats >= amountSats ? true : nil + } + + /// Whether a None route is a short balance rather than a contact Bitkit can't pay. + static func isBalanceShort(amountSats: UInt64, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64) -> Bool { + amountSats > lightningSpendableSats.saturatingAdd(onchainSpendableSats) + } + + /// Runs a service leg the router picked and returns the txid that pays the contact. + /// Whether a failed leg send releases this call's started proof: only when Spark refused it before sending, and no + /// open journal of the same request (an earlier attempt that may have paid) still owns that proof. Another + /// request's open journal never decides it. + static func legFailureReleasesProof( + _ error: Error, + openJournalRequest: SparkContactWithdrawal.PaykitRequest?, + proofRequest: SparkContactWithdrawal.PaykitRequest + ) -> Bool { + error is SparkWithdrawalNotSent && openJournalRequest != proofRequest + } + + /// Prepares the on-chain proof of a request a Spark leg pays, in a direct send's order: the proof is prepared, then + /// the request (its checks and private list consumption). Spark's withdrawal marks the proof started once its journal + /// is written. Any failure releases the private payment list as unsent, and one after the proof was prepared cancels + /// it, so the request can be paid again. + static func startLegRequestProof( + prepareProof: () async throws -> Void, + prepareRequest: () async throws -> Void, + paymentIdentity: () async throws -> String, + cancelProof: () async -> Void, + releasePaymentList: () async -> Void + ) async throws -> String { + do { + try await prepareProof() + } catch { + await releasePaymentList() + throw error + } + do { + try await prepareRequest() + return try await paymentIdentity() + } catch { + await cancelProof() + await releasePaymentList() + throw error + } + } + + /// Pays a leg as a manual payment the allowance ledger counts, as a direct send is: the attempt is reserved right + /// before Spark's send, marked sending once the request is authorized, and closed with the send's outcome: + /// succeeded with its txid, failed when Spark refused it before sending, and unknown otherwise. + static func payReportingAllowance( + beginAllowance: @escaping () async throws -> String?, + authorize: @escaping () async throws -> Void, + markSending: @escaping (String) async -> Void, + finishAllowance: (String, Paykit.PaymentOutcome, String?) async -> Void, + pay: (_ beforeSend: () async throws -> Void) async throws -> String + ) async throws -> String { + var attemptId: String? + do { + let txid = try await pay { + attemptId = try await beginAllowance() + try await authorize() + if let attemptId { + await markSending(attemptId) + } + } + if let attemptId { + await finishAllowance(attemptId, .succeeded, txid) + } + return txid + } catch { + if let attemptId { + await finishAllowance(attemptId, error is SparkWithdrawalNotSent ? .failed : .unknown, nil) + } + throw error + } + } + + static func pay( + _ leg: ServiceRouteLeg, + contact: String?, + request: SparkContactWithdrawal.PaykitRequest? = nil, + tags: [String] = [], + paymentDeadline: PaykitPreciseInstant? = nil, + beforeSend: () async throws -> Void = {} + ) async throws -> String { + guard leg.service == SparkManager.routeService else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "Unsupported route service \(leg.service)") + } + return try await SparkManager.shared.withdraw( + to: leg.endpoint.value, + amountSats: leg.deliveredSat, + contact: contact, + request: request, + tags: tags, + maxFeeSats: leg.debitSat.saturatingSub(leg.deliveredSat), + paymentDeadline: paymentDeadline, + beforeSend: beforeSend + ) + } +} + +/// Only a Spark leg could pay, but an earlier contact withdrawal's journal is still open. +struct SparkWithdrawalSettling: Error {} + +/// What asking the router did to a contact payment. +enum PaykitRouteOutcome: Equatable { + /// The send pays the endpoint the router picked. + case direct + /// The send pays through the service leg in `AppViewModel.paykitRoute`. + case leg + /// No route pays the contact; the app's copy says why. + case refused + /// The payment changed while the router answered, so its answer was dropped. + case stale +} + +@MainActor +extension AppViewModel { + /// Asks bitkit-core's router how to pay the contact from `source`. Direct switches the send to the endpoint it + /// names, a service leg is kept in `paykitRoute` for Confirm, and none shows the app's copy, its reason going to the + /// log only. + func routeContactPayment( + _ route: PaykitRouteUi, + amountSats: UInt64, + source: PaykitRouteService.Source, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + request: PaykitPaymentRequest?, + router: (PaykitRouteUi, UInt64, PaykitRouteService.Source, UInt64, UInt64) async throws -> PaykitRoute = { + try await PaykitRouteService.route($0, amountSats: $1, source: $2, lightningSpendableSats: $3, onchainSpendableSats: $4) + }, + decodeEndpoint: (String) async throws -> BitkitCore.Scanner = { try await decode(invoice: $0) } + ) async -> PaykitRouteOutcome { + var unrouted = route + unrouted.serviceLeg = nil + unrouted.invoice = route.invoice ?? scannedLightningInvoice + unrouted.lnurl = route.lnurl ?? lnurlPayData + let contactId = contactPaymentContext?.id + paykitRoutingGeneration += 1 + let generation = paykitRoutingGeneration + // A dismissed or replaced payment, a newer routing request (a new amount or source) or a source picked since + // makes the answer stale, so it never lands on what followed it. + // A short balance names itself first, then the limit of an LNURL that refused the amount (the router never saw + // it), then a Spark leg the balance can't cover with its fee. + func refuse(sparkLegShort: Bool) -> PaykitRouteOutcome { + paykitRoute = unrouted + let isBalanceShort = PaykitRouteService.isBalanceShort( + amountSats: amountSats, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats + ) + if isBalanceShort || unrouted.lnurl.map({ validateLnurlAmount($0, amountSats: amountSats) }) != false { + showNoPaykitRouteToast(isBalanceShort: isBalanceShort || sparkLegShort) + } + return .refused + } + let isCurrent = { + self.contactPaymentContext?.id == contactId && self.paykitRoutingGeneration == generation && + (self.selectedWalletToPayFrom == .lightning) == (source == .spending) && + (self.paykitRoute.map { $0.endpoints == route.endpoints } ?? true) + } + do { + // The known invoice and LNURL go to the router too, so it never picks an LNURL whose limits refuse the amount. + let picked = try await router(unrouted, amountSats, source, lightningSpendableSats, onchainSpendableSats) + guard isCurrent() else { return .stale } + switch picked { + case let .direct(endpoint): + paykitRoute = unrouted + // A decode is a network fetch for an LNURL: the send it was for can change while it runs. + guard let paid = await payDirectly(endpoint, route: unrouted, decode: decodeEndpoint, isCurrent: isCurrent) else { + return .stale + } + guard paid else { return .refused } + if let request { + paykitPaymentTerms = try request.payment(using: paykitPaymentMethod) + } + return .direct + case let .service(leg): + var routed = unrouted + routed.serviceLeg = leg + paykitRoute = routed + selectedWalletToPayFrom = .lightning + return .leg + case let .none(reason): + Logger.warn("Found no Paykit route: '\(reason)'", context: "PaykitRoute") + return refuse(sparkLegShort: false) + case .swap: + // The app never offers Pubky Swaps providers, so a swap route can't come back; it pays nothing either. + Logger.warn("Found no Paykit route: 'Swap routes are unsupported'", context: "PaykitRoute") + return refuse(sparkLegShort: false) + } + } catch is SparkWithdrawalSettling { + Logger.warn("Found no Paykit route: an earlier Spark contact withdrawal is still settling", context: "PaykitRoute") + guard isCurrent() else { return .stale } + // A short balance and an LNURL limit name themselves first, as for any other refusal. + let lnurlRefuses = unrouted.lnurl.map { !$0.accepts(amountSats: amountSats) } ?? false + if lnurlRefuses || PaykitRouteService.isBalanceShort( + amountSats: amountSats, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats + ) { + return refuse(sparkLegShort: false) + } + paykitRoute = unrouted + toast( + type: .error, + title: t("wallet__send_paykit_no_route__title"), + description: t("wallet__send_paykit_withdrawal_settling__description"), + accessibilityIdentifier: "PaykitRouteSettlingToast" + ) + return .refused + } catch let error as SparkLegUnaffordable { + Logger.warn("Found no Paykit route: \(error)", context: "PaykitRoute") + guard isCurrent() else { return .stale } + return refuse(sparkLegShort: true) + } catch { + Logger.error("Failed to route Paykit payment: \(error)", context: "PaykitRoute") + guard isCurrent() else { return .stale } + showNoPaykitRouteToast(isBalanceShort: false) + return .refused + } + } + + /// Switches the send to exactly the endpoint the router picked, clearing the other Lightning one. An earlier pick may + /// have cleared the picked one, so it comes back from `route`; one never decoded is decoded here. Returns false when + /// it cannot pay the endpoint, and nil when the send changed during a decode, writing nothing. + private func payDirectly( + _ endpoint: PaykitEndpoint, + route: PaykitRouteUi, + decode: (String) async throws -> BitkitCore.Scanner, + isCurrent: () -> Bool + ) async -> Bool? { + if endpoint.isOnchain { + selectedWalletToPayFrom = .onchain + if scannedOnchainInvoice?.address != endpoint.value { + scannedOnchainInvoice = OnChainInvoice(address: endpoint.value, amountSatoshis: 0, label: nil, message: nil, params: nil) + } + return true + } + selectedWalletToPayFrom = .lightning + if endpoint.isLnurl { + if let lnurl = lnurlPayData ?? route.lnurl { + lnurlPayData = lnurl + } else { + // A route built once Spark became ready after the scan holds the contact's LNURL undecoded. + let decoded = try? await decode(endpoint.value) + guard isCurrent() else { return nil } + guard case let .lnurlPay(data) = decoded else { + Logger.warn("Failed to decode the LNURL endpoint the router picked", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return false + } + lnurlPayData = data + paykitRoute?.lnurl = data + } + scannedLightningInvoice = nil + return true + } + let known = [scannedLightningInvoice, route.invoice].compactMap { $0 } + .first { $0.bolt11.caseInsensitiveCompare(endpoint.value) == .orderedSame } + if let known { + scannedLightningInvoice = known + } else { + let decoded = try? await decode(endpoint.value) + guard isCurrent() else { return nil } + guard case let .lightning(invoice) = decoded else { + Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return false + } + scannedLightningInvoice = invoice + } + lnurlPayData = nil + return true + } + + func showNoPaykitRouteToast(isBalanceShort: Bool) { + toast( + type: .error, + title: t(isBalanceShort ? "wallet__send_amount_exceeded__title" : "wallet__send_paykit_no_route__title"), + description: t(isBalanceShort ? "wallet__send_amount_exceeded__description" : "wallet__send_paykit_no_route__description"), + accessibilityIdentifier: isBalanceShort ? "PaykitRouteBalanceToast" : "PaykitRouteNoneToast" + ) + } + + /// The route can restore an LNURL a BOLT11 pick cleared, so Amount checks its limits after routing. + func validateRoutedLnurlAmount(amountSats: UInt64) -> Bool { + guard let lnurlPayData else { return true } + return validateLnurlAmount(lnurlPayData, amountSats: amountSats) + } + + /// Shows `lnurl`'s too-low or too-high toast when `amountSats` is outside its limits; true when it accepts it. + func validateLnurlAmount(_ lnurlPayData: LnurlPayData, amountSats: UInt64) -> Bool { + guard !lnurlPayData.accepts(amountSats: amountSats) else { return true } + let minimumAmount = max(1, lnurlPayData.minSendableSat) + if amountSats < minimumAmount { + toast( + type: .error, + title: t("wallet__lnurl_pay__error_min__title"), + description: t( + "wallet__lnurl_pay__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(minimumAmount)] + ), + accessibilityIdentifier: "LnurlPayAmountTooLowToast" + ) + } else { + toast( + type: .error, + title: t("wallet__lnurl_pay__error_max__title"), + description: t("wallet__lnurl_pay__error_max__description"), + accessibilityIdentifier: "LnurlPayAmountTooHighToast" + ) + } + return false + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 3056c4b0e..6c5433eed 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -894,10 +894,12 @@ extension PrivatePaykitService { updates.removeAll { !knownSavedContactKeys.contains($0.counterparty) } guard generation == preparationGeneration else { throw PrivatePaykitError.privateUnavailable } guard !updates.isEmpty else { + Logger.debug("Skipped private payment list publication during \(reason): no list changed", context: "PrivatePaykit") schedulePendingPrivateMessageDrainRetries(reason: reason, retryKeys: linkRetryKeys) return } let report = try await operations.syncPaymentLists(updates) + Logger.debug("Published private payment lists to \(updates.count) contacts during \(reason)", context: "PrivatePaykit") let deliveryError = applyPrivatePaymentListDeliveryReport(report, reason: reason) firstError = firstError ?? deliveryError let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 0beb64c7e..e21931eae 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -72,13 +72,15 @@ extension PrivatePaykitService { return decodedInvoice.paymentHash.hex } - func hasAttemptedOutboundBolt11Payment(paymentHash: String) async -> Bool { - await attemptedOutboundBolt11PaymentHashes().contains(paymentHash) + /// Whether this wallet already tried to pay `paymentHash`, or nil when the node or Spark could not list its payments. + func hasAttemptedOutboundBolt11Payment(paymentHash: String) async -> Bool? { + guard let attempted = try? await attemptedOutboundBolt11PaymentHashes() else { return nil } + return attempted.contains(paymentHash) } @MainActor func walletHasUsableChannels(_ wallet: WalletViewModel) -> Bool { - wallet.hasUsableChannels + wallet.canReceiveLightning } @MainActor @@ -112,7 +114,7 @@ extension PrivatePaykitService { @MainActor func createVariableInvoice(_ wallet: WalletViewModel) async throws -> String { - try await wallet.createInvoice(amountSats: nil, note: "") + try await wallet.createInvoice(amountSats: nil, note: "", isPaykitEndpoint: true) } func settledPrivateInvoicePaymentHashes() async -> [String] { @@ -127,33 +129,37 @@ extension PrivatePaykitService { } func receivedSettledPaymentHashes() async -> Set { - guard let payments = await LightningService.shared.listPayments() else { return [] } - - return Set( - payments.compactMap { payment in - guard payment.direction == .inbound, - payment.status == .succeeded, - case .bolt11 = payment.kind - else { return nil } - - return payment.id - } - ) + let payments = await LightningService.shared.listPayments() ?? [] + let ldkHashes = payments.compactMap { payment -> String? in + guard payment.direction == .inbound, + payment.status == .succeeded, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + let railHashes = await ((try? LightningService.shared.channelFreeRailPayments()) ?? []) + .filter { $0.direction == .inbound && $0.status == .succeeded } + .map(\.paymentHash) + return Set(ldkHashes + railHashes) } - func attemptedOutboundBolt11PaymentHashes() async -> Set { - guard let payments = await LightningService.shared.listPayments() else { return [] } + /// Throws when the node or Spark could not list its payments, so a payment either may have attempted isn't read as + /// never tried. + func attemptedOutboundBolt11PaymentHashes() async throws -> Set { + guard let payments = await LightningService.shared.nodePayments() else { throw AppError(serviceError: .nodeNotSetup) } + let ldkHashes = payments.compactMap { payment -> String? in + guard payment.direction == .outbound, + payment.status != .failed, + case .bolt11 = payment.kind + else { return nil } - return Set( - payments.compactMap { payment in - guard payment.direction == .outbound, - payment.status != .failed, - case .bolt11 = payment.kind - else { return nil } - - return payment.id - } - ) + return payment.id + } + let railHashes = try await LightningService.shared.channelFreeRailPayments() + .filter { $0.direction == .outbound && $0.status != .failed } + .map(\.paymentHash) + return Set(ldkHashes + railHashes) } func localInvoice(for publicKey: String) -> StoredInvoice? { diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 28eacf7bf..d74133681 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -554,9 +554,11 @@ extension PrivatePaykitService { continue } - guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value), - await !hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) - else { + // Unknown while Spark can't list its payments: the invoice may be paid already, so it isn't offered. + guard let hasAttempted = await hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) else { + continue + } + guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value), !hasAttempted else { staleLightningPaymentHashes.insert(paymentHash) continue } diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift new file mode 100644 index 000000000..9e12a6ae9 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -0,0 +1,185 @@ +import BreezSdkSpark +import Foundation +import os + +/// One background run of an exit, from a cold launch with no UI: loads the record, sends what is ready from the +/// stored, already signed transactions without the SDK, then starts the Spark SDK only (never the node) and ticks +/// again with it, which checks the exit against the chain, signs a build or rebuild, and broadcasts. An ended exit +/// whose leftover fee funding still has to reach savings gets the keyless pass alone, which sweeps it. The driver +/// saves the record before and after each broadcast, so a run that expires mid-step leaves a whole record and the +/// next run carries on from it: a step that does not fit one run (connect and sign, say) finishes in the next. +struct SparkExitBackgroundRun { + enum Step: String, CaseIterable { + case load, keyless, connect, check, sign, broadcast, total + } + + struct Outcome { + var record: SparkExitRecord? + var isConnected = false + var seconds: [Step: TimeInterval] = [:] + } + + let load: () throws -> SparkExitRecord? + /// A driver over the given SDK, or over none for the keyless pass. The run times its SDK and broadcast calls. + let makeDriver: (_ chain: SparkExitChain, _ recovering: SparkExitRecovering?) -> SparkExitDriver + let chain: SparkExitChain + /// The SDK when it is already connected, so the keyless pass is skipped. + let connected: SparkExitRecovering? + /// Starts the Spark SDK only; nil when it does not connect within the given seconds. + let connect: (_ timeout: TimeInterval) async -> SparkExitRecovering? + /// When the run must have saved and returned; the OS ends a refresh after about 30 seconds. + let deadline: Date + var now: () -> Date = Date.init + + /// Time kept back from the connect for the SDK tick and the final save. + static let reserveSeconds: TimeInterval = 8 + + func run() async -> Outcome { + let started = now() + var outcome = await steps() + outcome.seconds[.total] = now().timeIntervalSince(started) + return outcome + } + + private func steps() async -> Outcome { + let timings = SparkExitTimings() + var outcome = Outcome() + let loadStarted = now() + do { + outcome.record = try load() + } catch { + Logger.error("Spark exit background run could not read the record: \(error)", context: "SparkExitBackgroundRun") + } + timings.add(.load, now().timeIntervalSince(loadStarted)) + guard var record = outcome.record, record.needsDriving else { + outcome.seconds = timings.seconds + return outcome + } + let chain = TimedSparkExitChain(base: chain, timings: timings) + + var recovering = connected + if recovering == nil { + let keylessStarted = now() + do { + record = try await makeDriver(chain, nil).tick(record) + } catch { + Logger.warn("Spark exit keyless pass failed: \(error)", context: "SparkExitBackgroundRun") + // The pass saved along the way: go on from the record on disk, never from the copy it started with. + if let stored = try? load() { + record = stored + } + } + timings.add(.keyless, now().timeIntervalSince(keylessStarted)) + + let budget = deadline.timeIntervalSince(now()) - Self.reserveSeconds + // Sweeping the leftover of an ended exit needs no SDK. + if !Task.isCancelled, budget > 0, record.isActive { + let connectStarted = now() + recovering = await connect(budget) + timings.add(.connect, now().timeIntervalSince(connectStarted)) + } + } + if let recovering, !Task.isCancelled { + outcome.isConnected = true + do { + record = try await makeDriver(chain, TimedSparkExitRecovering(base: recovering, timings: timings)).tick(record) + } catch { + Logger.error("Spark exit background tick failed: \(error)", context: "SparkExitBackgroundRun") + if let stored = try? load() { + record = stored + } + } + } + outcome.record = record + outcome.seconds = timings.seconds + return outcome + } +} + +/// Wall time per step of a background run, also marked as signpost intervals for Instruments. +final class SparkExitTimings: @unchecked Sendable { + static let signposter = OSSignposter(subsystem: "to.bitkit", category: "SparkExitBackground") + private let lock = NSLock() + private var values: [SparkExitBackgroundRun.Step: TimeInterval] = [:] + + var seconds: [SparkExitBackgroundRun.Step: TimeInterval] { + lock.withLock { values } + } + + func add(_ step: SparkExitBackgroundRun.Step, _ seconds: TimeInterval) { + lock.withLock { values[step, default: 0] += seconds } + } + + func time(_ step: SparkExitBackgroundRun.Step, _ body: () async throws -> T) async rethrows -> T { + let state = Self.signposter.beginInterval("step", "\(step.rawValue)") + let started = Date() + defer { + add(step, Date().timeIntervalSince(started)) + Self.signposter.endInterval("step", state) + } + return try await body() + } +} + +private struct TimedSparkExitRecovering: SparkExitRecovering { + let base: SparkExitRecovering + let timings: SparkExitTimings + + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse { + try await timings.time(.sign) { try await base.prepare(request) } + } + + func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse { + try await timings.time(.sign) { try await base.recover(request, signer: signer) } + } + + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { + try await timings.time(.check) { try await base.check(recovery) } + } + + func exportExitState() async throws -> String { + try await timings.time(.sign) { try await base.exportExitState() } + } + + func importExitState(_ exitState: String) async throws { + try await timings.time(.sign) { try await base.importExitState(exitState) } + } +} + +struct TimedSparkExitChain: SparkExitChain { + let base: SparkExitChain + let timings: SparkExitTimings + + func tipHeight() async throws -> UInt32 { + try await base.tipHeight() + } + + func utxos(address: String) async throws -> [EsploraUtxo] { + try await base.utxos(address: address) + } + + func isAddressUsed(address: String) async throws -> Bool { + try await base.isAddressUsed(address: address) + } + + func txStatus(txid: String) async throws -> EsploraTxStatus { + try await base.txStatus(txid: txid) + } + + func isKnown(txid: String) async throws -> Bool { + try await base.isKnown(txid: txid) + } + + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { + await timings.time(.broadcast) { await base.broadcast(tx) } + } + + func fastestFeeRate() async throws -> UInt64 { + try await base.fastestFeeRate() + } + + /// Forwarded, or the protocol's default (every tier at the fastest rate) would price background sweeps. + func networkFees() async throws -> SparkExitNetworkFees { + try await base.networkFees() + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift new file mode 100644 index 000000000..bc5ae86b6 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -0,0 +1,452 @@ +import BreezSdkSpark +import Foundation + +struct EsploraTxStatus: Codable, Equatable { + let confirmed: Bool + var blockHeight: UInt32? + + enum CodingKeys: String, CodingKey { + case confirmed + case blockHeight = "block_height" + } +} + +struct EsploraUtxo: Codable, Equatable { + let txid: String + let vout: UInt32 + let value: UInt64 + let status: EsploraTxStatus +} + +/// What a broadcast came to. Accepted includes a transaction the node already had. +enum SparkExitBroadcastOutcome: Equatable { + case accepted + /// Its relative timelock has not matured (`non-BIP68-final`): the next tick tries again. Never a rejection. + case notMature(String) + /// An input is missing or already spent: its parent is not visible yet, or another transaction spent it. + case inputsMissing(String) + /// The host or node could not take it now: the next tick tries again. + case retryLater(String) + /// Refused for good at this fee or policy; a later check or rebuild replaces it. + case rejected(String) + + var summary: String { + switch self { + case .accepted: "accepted" + case let .notMature(reason): "not mature: \(reason)" + case let .inputsMissing(reason): "inputs missing: \(reason)" + case let .retryLater(reason): "retry: \(reason)" + case let .rejected(reason): "rejected: \(reason)" + } + } +} + +/// Reads esplora's `/fee-estimates` (target in blocks → sat/vB). A host may answer `{}` or skip targets, as the local +/// regtest mempool does: a missing target falls back to the next one estimated, the nearest longer one first, and +/// with none at all to the 1 sat/vB relay floor, so no step fails for want of an estimate. +enum SparkExitFeeEstimates { + /// A host's answer with rates but none usable: the estimate is unavailable, as when the host can't be read. + struct Unusable: Error {} + + /// The estimates a host answered, less any rate that is not finite, not above 0 or above `maxRate`; throws when + /// it answered rates and none is usable. + static func validated(_ estimates: [String: Double]) throws -> [String: Double] { + let usable = estimates.filter { isUsable($0.value) } + guard usable.isEmpty, !estimates.isEmpty else { return usable } + Logger.warn("Spark exit fee estimates had no usable rate: \(estimates)", context: "SparkExitFeeEstimates") + throw Unusable() + } + + /// The most a rate may be, in sat/vB: anything above is a faulty host, never converted. + static let maxRate: Double = 10000 + + private static func isUsable(_ rate: Double) -> Bool { + rate.isFinite && rate > 0 && rate <= maxRate + } + + static func rate(_ estimates: [String: Double], target: Int) -> UInt64 { + let targets = parsed(estimates) + guard let value = (targets.first { $0.target >= target } ?? targets.last)?.rate else { return SparkExitMath.minRelayFeeRate } + return rounded(value) + } + + /// The slowest tier: the longest target estimated (mempool's `economyFee` is its day-long rate). + static func economy(_ estimates: [String: Double]) -> UInt64 { + parsed(estimates).last.map { rounded($0.rate) } ?? SparkExitMath.minRelayFeeRate + } + + private static func parsed(_ estimates: [String: Double]) -> [(target: Int, rate: Double)] { + estimates.filter { isUsable($0.value) } + .compactMap { key, value in Int(key).map { (target: $0, rate: value) } } + .sorted { $0.target < $1.target } + } + + private static func rounded(_ rate: Double) -> UInt64 { + max(SparkExitMath.minRelayFeeRate, UInt64(max(0, rate).rounded(.up))) + } +} + +/// The chain the Spark SDK reads, for what an exit does outside the SDK: broadcasting and the funding address. +protocol SparkExitChain: Sendable { + func tipHeight() async throws -> UInt32 + func utxos(address: String) async throws -> [EsploraUtxo] + /// Whether the address has any transaction, in a block or the mempool, from `/address/{address}`. + func isAddressUsed(address: String) async throws -> Bool + func txStatus(txid: String) async throws -> EsploraTxStatus + /// Whether the transaction is in a block or in the mempool; false once every host answers it has no such + /// transaction. + func isKnown(txid: String) async throws -> Bool + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome + /// The rate for the next block, rounded up, at least 1 sat/vB. + func fastestFeeRate() async throws -> UInt64 + /// The rates for the next block and for about half an hour, for a fee bump while the SDK is not connected. + func networkFees() async throws -> SparkExitNetworkFees +} + +extension SparkExitChain { + func networkFees() async throws -> SparkExitNetworkFees { + let fastest = try await fastestFeeRate() + return SparkExitNetworkFees(fastest: fastest, halfHour: fastest, economy: fastest) + } +} + +protocol SparkExitHTTP: Sendable { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +struct URLSessionSparkExitHTTP: SparkExitHTTP { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + return (data, http) + } +} + +/// esplora and mempool.space REST: a transaction with a CPFP child goes out as a package (JSON array of hex) through +/// the host's package paths in turn, one without as a single `/tx`. +struct EsploraSparkExitChain: SparkExitChain { + struct BroadcastHost: Equatable { + let url: URL + var packagePaths = ["/v1/txs/package", "/txs/package"] + } + + /// Reads go to these hosts in turn. + let readHosts: [URL] + /// Broadcasts try these hosts in turn. + let broadcastHosts: [BroadcastHost] + var http: SparkExitHTTP = URLSessionSparkExitHTTP() + + init(readHosts: [URL], broadcastHosts: [BroadcastHost], http: SparkExitHTTP = URLSessionSparkExitHTTP()) { + self.readHosts = readHosts + self.broadcastHosts = broadcastHosts + self.http = http + } + + /// Reads and broadcasts on the same hosts. + init(hosts: [URL], http: SparkExitHTTP = URLSessionSparkExitHTTP()) { + self.init(readHosts: hosts, broadcastHosts: hosts.map { BroadcastHost(url: $0) }, http: http) + } + + /// On mainnet the chain service Spark connects with (`Env.sparkChainServiceUrl`) answers every read and takes + /// broadcasts first, on its `/txs/package` (no `/v1`); the SDK's default hosts stay as broadcast fallbacks. On + /// regtest the chain service Spark connects with, `regtestChainServiceUrl` (`SPARK_CHAIN_SERVICE_URL`), does both, + /// else the SDK's default host (breez spark-sdk `default_rest_chain_services`). + static func network( + _ network: BreezSdkSpark.Network, + regtestChainServiceUrl: String? = Env.sparkChainServiceUrl(for: .regtest) + ) -> EsploraSparkExitChain { + switch network { + case .mainnet: + let electrs = URL(string: Env.sparkChainServiceUrl(for: .bitcoin)!)! + return EsploraSparkExitChain( + readHosts: [electrs], + broadcastHosts: [ + BroadcastHost(url: electrs, packagePaths: ["/txs/package"]), + BroadcastHost(url: URL(string: "https://mempool.space/api")!), + BroadcastHost(url: URL(string: "https://blockstream.info/api")!), + ] + ) + case .regtest: + let configured = regtestChainServiceUrl.flatMap(URL.init(string:)) + return EsploraSparkExitChain(hosts: [configured ?? URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!]) + default: + return EsploraSparkExitChain(hosts: []) + } + } + + func tipHeight() async throws -> UInt32 { + let data = try await get("/blocks/tip/height") + guard let height = UInt32(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw URLError(.cannotParseResponse) + } + return height + } + + func utxos(address: String) async throws -> [EsploraUtxo] { + try await JSONDecoder().decode([EsploraUtxo].self, from: get("/address/\(address)/utxo")) + } + + func isAddressUsed(address: String) async throws -> Bool { + struct Stats: Decodable { + let txCount: Int + + enum CodingKeys: String, CodingKey { + case txCount = "tx_count" + } + } + struct Address: Decodable { + let chainStats: Stats + let mempoolStats: Stats + + enum CodingKeys: String, CodingKey { + case chainStats = "chain_stats" + case mempoolStats = "mempool_stats" + } + } + let stats = try await JSONDecoder().decode(Address.self, from: get("/address/\(address)")) + return stats.chainStats.txCount + stats.mempoolStats.txCount > 0 + } + + func txStatus(txid: String) async throws -> EsploraTxStatus { + try await JSONDecoder().decode(EsploraTxStatus.self, from: get("/tx/\(txid)/status")) + } + + /// esplora's `/fee-estimates`, whose target of one block is mempool's `fastestFee`. + func fastestFeeRate() async throws -> UInt64 { + try await SparkExitFeeEstimates.rate(estimates(), target: 1) + } + + private func estimates() async throws -> [String: Double] { + try await SparkExitFeeEstimates.validated(JSONDecoder().decode([String: Double].self, from: get("/fee-estimates"))) + } + + /// esplora's targets of one and three blocks, mempool's `fastestFee` and `halfHourFee`. + func networkFees() async throws -> SparkExitNetworkFees { + let estimates = try await estimates() + return SparkExitNetworkFees( + fastest: SparkExitFeeEstimates.rate(estimates, target: 1), + halfHour: SparkExitFeeEstimates.rate(estimates, target: 3), + economy: SparkExitFeeEstimates.economy(estimates) + ) + } + + /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer + /// unconfirmed instead of not found. + func isKnown(txid: String) async throws -> Bool { + try await getIfFound("/tx/\(txid)/hex") != nil + } + + /// Tries each host until one accepts. Otherwise a node's refusal wins over a host that was only unavailable. + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { + var outcomes: [SparkExitBroadcastOutcome] = [] + for host in broadcastHosts { + var attempt: SparkExitBroadcastOutcome? = if let child = tx.cpfpTxHex { + await submitPackage(host: host, hexes: [tx.txHex, child], txid: tx.txid) + } else { + await submitSingle(host: host.url, hex: tx.txHex) + } + // Some hosts (Spark's hosted regtest) refuse every lone transaction with a bare RPC -1, yet give a real answer + // to the same hex sent as a package of one. + if tx.cpfpTxHex == nil, let refused = attempt, SparkExitBroadcastClassifier.isUnplaced(refused) { + attempt = await submitPackage(host: host, hexes: [tx.txHex], txid: tx.txid) ?? refused + } + // nil: this host cannot take it (no package endpoint, unreachable). + guard let attempt else { continue } + if attempt == .accepted { + return attempt + } + outcomes.append(attempt) + } + return outcomes.first { + if case .rejected = $0 { + true + } else { + false + } + } ?? outcomes.first ?? .retryLater("no chain host reachable") + } + + private func submitPackage(host: BroadcastHost, hexes: [String], txid: String) async -> SparkExitBroadcastOutcome? { + for path in host.packagePaths { + var request = URLRequest(url: host.url.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONEncoder().encode(hexes) + request.timeoutInterval = 30 + guard let (data, response) = try? await http.send(request) else { return nil } + if response.statusCode == 404 || response.statusCode == 405 { + continue + } + return SparkExitBroadcastClassifier.package(status: response.statusCode, body: data, txid: txid) + } + return nil + } + + private func submitSingle(host: URL, hex: String) async -> SparkExitBroadcastOutcome? { + var request = URLRequest(url: host.appendingPathComponent("/tx")) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(hex.utf8) + request.timeoutInterval = 30 + guard let (data, response) = try? await http.send(request) else { return nil } + if response.statusCode == 404 || response.statusCode == 405 { + return nil + } + return SparkExitBroadcastClassifier.single(status: response.statusCode, body: data) + } + + private func get(_ path: String) async throws -> Data { + guard let data = try await getIfFound(path) else { throw URLError(.fileDoesNotExist) } + return data + } + + /// nil when every host answers 404; a host that fails otherwise makes it throw instead. + private func getIfFound(_ path: String) async throws -> Data? { + var lastError: Error? + for host in readHosts { + var request = URLRequest(url: host.appendingPathComponent(path)) + request.timeoutInterval = 20 + do { + let (data, response) = try await http.send(request) + if response.statusCode == 404 { + continue + } + guard (200 ..< 300).contains(response.statusCode) else { throw URLError(.badServerResponse) } + return data + } catch { + lastError = error + } + } + if readHosts.isEmpty { + throw URLError(.cannotFindHost) + } + if let lastError { + throw lastError + } + return nil + } +} + +/// Reads node responses: mempool returns the RPC code only (`sendrawtransaction RPC error: {"code":-25}`), +/// esplora the message too, and a package the per-transaction errors. +enum SparkExitBroadcastClassifier { + private static let knownMessages = [ + "txn-already-in-mempool", + "txn-already-known", + "txn-same-nonwitness-data-in-mempool", + "already in block chain", + // Core's newer wording, which esplora hosts pass on as plain text. + "already in utxo set", + ] + private static let notMatureMessages = ["non-BIP68-final", "non-final"] + private static let missingInputMessages = ["bad-txns-inputs-missingorspent", "missing-inputs", "missingorspent"] + + static func single(status: Int, body: Data) -> SparkExitBroadcastOutcome { + let text = String(decoding: body, as: UTF8.self) + if (200 ..< 300).contains(status) { + return .accepted + } + if let unavailable = unavailable(status) { + return unavailable + } + return classify(error: text) + } + + /// `txid` is the package's parent, the step itself: an error on it decides the outcome. Missing inputs count as + /// such only when the step reports them; the CPFP child's own missing input (its funding or chained change) is + /// retried. + static func package(status: Int, body: Data, txid: String? = nil) -> SparkExitBroadcastOutcome { + let text = String(decoding: body, as: UTF8.self) + if let unavailable = unavailable(status) { + return unavailable + } + guard (200 ..< 300).contains(status), + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { + return classify(error: text) + } + if json["package_msg"] as? String == "success" { + return .accepted + } + let results = (json["tx-results"] as? [String: Any])?.values.compactMap { $0 as? [String: Any] } ?? [] + let errors = results.compactMap { result in (result["error"] as? String).map { (txid: result["txid"] as? String, error: $0) } } + if errors.isEmpty { + return results.isEmpty ? .retryLater(text) : .accepted + } + // The step's own error decides, whatever its child reports and in whatever order the results come. + if let txid, let own = errors.first(where: { $0.txid == txid }) { + let outcome = classify(error: own.error) + if outcome != .accepted { + return outcome + } + } + let outcomes = errors.map { result in + let outcome = classify(error: result.error) + if case .inputsMissing = outcome, let txid, let resultTxid = result.txid, resultTxid != txid { + return SparkExitBroadcastOutcome.retryLater(result.error) + } + return outcome + } + if outcomes.allSatisfy({ $0 == .accepted }) { + return .accepted + } + // A child of a parent not mature yet can fail with any error, so the timelock wins over them all. + let precedence: (SparkExitBroadcastOutcome) -> Int = { + switch $0 { + case .notMature: 0 + case .rejected: 1 + case .inputsMissing: 2 + case .retryLater: 3 + case .accepted: 4 + } + } + return outcomes.min { precedence($0) < precedence($1) } ?? .accepted + } + + /// A host that is down, rate limiting or behind a challenge page says nothing about the transaction. + private static func unavailable(_ status: Int) -> SparkExitBroadcastOutcome? { + status >= 500 || status == 429 || status == 403 ? .retryLater("HTTP \(status)") : nil + } + + /// A refusal that names nothing but RPC error -1, with no reason: the host refused how it was sent, not the + /// transaction, so the same hex goes again as a package of one. + static func isUnplaced(_ outcome: SparkExitBroadcastOutcome) -> Bool { + guard case let .rejected(text) = outcome else { return false } + return hasRpcCode(-1, in: text) && !text.contains("message") + } + + /// The step paid no fee of its own and its CPFP child did not pay for it (`min relay fee not met, 0 < 17`): the + /// child is missing or invalid, such as one spending the change of an earlier child that lost a race. + static func isUnpaid(_ outcome: SparkExitBroadcastOutcome) -> Bool { + guard case let .rejected(text) = outcome else { return false } + return text.range(of: #"min relay fee not met,\s*0\s*<"#, options: .regularExpression) != nil + } + + /// The step paid a fee, but less than the mempool takes now: the network is busier than the exit's rate. A rebuild + /// at the same rate would not help. A zero fee (`isUnpaid`) and a lost replacement race + /// (`insufficient fee, rejecting replacement`) are a broken fee chain instead. + static func isFeeRateTooLow(_ outcome: SparkExitBroadcastOutcome) -> Bool { + guard case let .rejected(text) = outcome else { return false } + return text.range(of: #"min relay fee not met,\s*[1-9]"#, options: .regularExpression) != nil + || text.contains("mempool min fee not met") + } + + static func classify(error text: String) -> SparkExitBroadcastOutcome { + if knownMessages.contains(where: text.contains) || hasRpcCode(-27, in: text) { + return .accepted + } + if notMatureMessages.contains(where: text.contains) { + return .notMature(text) + } + if missingInputMessages.contains(where: text.contains) || hasRpcCode(-25, in: text) { + return .inputsMissing(text) + } + return .rejected(text) + } + + /// The RPC code, also when the node's error sits JSON-escaped inside mempool's own error object, or as esplora's + /// plain text (`sendrawtransaction RPC error -27: …`). + private static func hasRpcCode(_ code: Int, in text: String) -> Bool { + text.range(of: #"(code\\?"\s*:\s*|RPC error )\#(code)\b"#, options: .regularExpression) != nil + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift new file mode 100644 index 000000000..7273b88f9 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -0,0 +1,1742 @@ +import BreezSdkSpark +import CryptoKit +import Foundation + +/// The SDK calls an exit makes. `BreezSdk` in the app, a fake in tests. +protocol SparkExitRecovering: Sendable { + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse + func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse + func exportExitState() async throws -> String + func importExitState(_ exitState: String) async throws +} + +enum SparkExitFeeBumpError: Error, Equatable { + case notConnected + /// At this rate a leaf could no longer pay its own exit and would stay on Spark: the rate is not offered. + case leavesBehind(sats: UInt64) +} + +/// Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, +/// rebuilds when the chain diverged, and finishes when everything confirmed. Without the SDK (a background launch, +/// or operators down at connect) it still broadcasts the stored, already signed transactions whose parents +/// confirmed. Every built recovery is saved before anything of it is broadcast, and every broadcast is saved as +/// soon as its outcome is known. Once the task running it is cancelled (a background run expiring) it starts no +/// SDK call and no broadcast, and saves what it did, so the next run resumes without sending anything twice. +struct SparkExitDriver { + let chain: SparkExitChain + /// nil while the Spark SDK is not connected. + let recovering: SparkExitRecovering? + /// The signer of the funding key at the record's index. + let makeSigner: (UInt32) throws -> CpfpSigner + let save: (SparkExitRecord) throws -> Void + var now: () -> Date = Date.init + /// The fast rate of Bitkit's own fee source, nil when it cannot be read: with the exit's own rate, the most a + /// leftover sweep pays, whatever the chain host asks. + var trustedFeeRate: () async -> UInt64? = { nil } + + /// A ready transaction accepted in this generation is sent again after this long, in case a node dropped it. + static let rebroadcastInterval: TimeInterval = 600 + /// After this many rejections of one transaction the progress screen shows the error. + static let rejectionsToSurface = 3 + /// The least time between two builds, so a divergence that persists does not rebuild every tick. + static let rebuildInterval: TimeInterval = 600 + /// Chain host reads in flight at once. + static let concurrentReads = 8 + /// Rebuilds in a row that reproduce a step whose input another transaction spent before its leaf counts as lost: + /// an older state of the wallet claimed it, and every rebuild would send the same refund again. + static let strikesToLoseLeaf = 2 + /// Fee chain failures in a row of one step, its parents confirmed, that rebuild the exit. + static let feeChainFailuresToRebuild = 3 + /// Rebuilds over stuck steps with no step confirming in between, after which the exit says it is not moving. + static let stallRebuildsToSurface = 3 + /// Blocks an accepted step may stay unconfirmed before the exit says the network is busier than its rate. + static let unconfirmedBlocksToSurface: UInt32 = 6 + /// A leaf the operators' watchtower stopped is relabelled promptly; one still stopped after this many blocks + /// never will be. + static let stoppedLeafBlocks: UInt32 = 6 + /// A build waiting for a leaf that may still move tries again after this many blocks, doubling up to a day. + static let retryBlocks: UInt32 = 6 + static let retryMaxBlocks: UInt32 = 144 + /// About a week: what is still waiting then is let go, so the exit can end. + static let releaseBlocks: UInt32 = 1008 + /// Blocks an accepted leftover sweep may stay unconfirmed before it is replaced at a higher rate. + static let sweepReplaceBlocks: UInt32 = 144 + /// While the fee change is not worth sending, the sweep checks the network's rate again after this many blocks. + static let sweepRecheckBlocks: UInt32 = 144 + /// About four weeks: then the change too small to send stays at the funding address. + static let sweepWaitBlocks: UInt32 = 4032 + /// A savings payment Bitkit sent that no host knows for this many blocks, or that the exit did not adopt within + /// `ownPaymentTimeoutBlocks`, is forgotten: the exit asks for the funding, and reminds, again. Long enough that a + /// host still indexing it, or one false miss, never asks for the funding twice. + static let ownPaymentMissingBlocks: UInt32 = 3 + static let ownPaymentTimeoutBlocks: UInt32 = 144 + + @discardableResult + func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord { + var record = record + switch record.phase { + case .done, .failed: + guard record.needsDriving, !Task.isCancelled else { return record } + let before = record + await followEndedPayment(&record) + if record.isLeftoverSweepPending { + try await sweepLeftover(&record) + try save(record) + return record + } + return try saveIfChanged(record, from: before) + case .awaitingFunding, .needsFunding: + guard !Task.isCancelled else { return record } + let before = record + await noteOwnPayment(&record) + // A higher fee's top-up leaves the current generation valid: it keeps being checked and sent meanwhile. + if record.isDrivingWhileFunding { + if let recovering { + try await driveWhileFunding(&record, recovering: recovering) + } else { + try await broadcastWithoutSdk(&record) + } + } + // Still waiting for funding (a generation that finished meanwhile is back to running). + if record.phase != .running { + // A skipped leaf's fee funding that never came is let go after the same week as its retries. It needs + // only the chain, and lands whole: while the address can't be read nothing changes, and a later tick + // lets go. + if record.isFundingWaitingLeaves, let since = record.retrySince, let tip = try? await chain.tipHeight(), + tip.saturatingSub(since) >= Self.releaseBlocks + { + var released = record + letGoOfWaitingLeaves(&released) + await finish(&released, phase: .done) + guard !released.isActive else { return try saveIfChanged(record, from: before) } + Logger.warn("Spark exit let go of skipped leaves whose fee funding never came", context: "SparkExitDriver") + record = released + try save(record) + return record + } + guard let recovering else { + await updateClaimableHeight(&record) + return try saveIfChanged(record, from: before) + } + // Nothing to fund when every leaf is recovered cooperatively. + var isRedoFromHeldFunding = false + if let shortfall = record.fundingShortfallSats { + let isAdopted = try await adoptFunding(&record, requiredSats: shortfall) + // A Redo while a top-up is awaited rebuilds from the funding the exit has: a top-up that is dropped + // never leaves it waiting, and one the rebuild needs is asked for again. Paced like any rebuild. + isRedoFromHeldFunding = !isAdopted && record.isRebuilding && record.isRedoRebuild == true + && !record.isFundingWaitingLeaves && isRebuildDue(record) + if isRedoFromHeldFunding { + record.lastBuildAt = now() + } + guard isAdopted || isRedoFromHeldFunding else { + await countShortOwnPayment(&record) + // The steps already out keep their countdown while the top-up is awaited. + await updateClaimableHeight(&record) + return try saveIfChanged(record, from: before) + } + } + if record.isFundingWaitingLeaves { + // Funding for leaves a partial build skipped: only they are built, now that it arrived. + record.phase = .running + let tip = try? await chain.tipHeight() + if try await !retryWaitingLeaves(&record, recovering: recovering, tip: tip, isFunded: true) { + await finish(&record, phase: .done) + } + } else if record.isUnverifiedAsk == true, !record.isRebuilding { + // Funding an unverified rebuild asked for resumes that rebuild, with its guard: the valid + // generation stays over a failed-only build, running again with the funding adopted. Once a + // block, like the check's rebuild: the valid generation's retry clock does not pace it. + let tip = try? await chain.tipHeight() + let unilateral = (record.recovery?.response.transactions ?? []).filter { $0.kind != .cooperative } + let unreadBefore = Set((before.recovery?.response.transactions ?? []).filter { $0.status == .unverified }.map(\.txid)) + if unilateral.contains(where: { $0.status == .ready }) || !unilateral.contains(where: { $0.status == .unverified }) { + // A clean check while the payment came can read the unread step again, and it is then sent: + // a rebuild now would conflict with it at the same fee. The ask ends, and the funding it + // adopted stays for a later rebuild or the leftover sweep. + Logger.info("Spark exit steps read again: the funded unverified rebuild is not needed", context: "SparkExitDriver") + record.phase = .running + record.isUnverifiedAsk = nil + } else if unilateral.contains(where: { step in step.status == .unverified && !unreadBefore.contains(step.txid) }) { + // A step this check just turned unverified may have been sent on an earlier clean check: the + // resume waits for the next check, as the check's own rebuild does. + } else if tip == nil || record.unverifiedRebuiltAtHeight != tip { + record.unverifiedRebuiltAtHeight = tip + var valid = record + valid.phase = .running + valid.isUnverifiedAsk = nil + try await build(&record, recovering: recovering) + keepRetryClock(&record, from: valid) + } + } else { + try await build(&record, recovering: recovering) + // Held funding that pays for less: the awaited top-up is still followed, as while waiting for it. + if isRedoFromHeldFunding, case .needsFunding = record.phase { + await countShortOwnPayment(&record) + } + } + } + case .running where record.isRebuilding: + // The diverged generation is never broadcast again; only a rebuild moves on from here. + guard let recovering, !Task.isCancelled, isRebuildDue(record) else { return record } + try await build(&record, recovering: recovering) + case .running: + if Task.isCancelled { + return record + } + if let recovering { + try await check(&record, recovering: recovering) + } else { + try await broadcastWithoutSdk(&record) + // Every step in a block, as the chain host shows: the exit ends without waiting for the SDK. Leaves the + // operators could not co-sign need the SDK's retry, so that exit waits for it. + if let recovery = record.recovery?.response, !recovery.transactions.isEmpty, recovery.failed.isEmpty, + record.waitingLeafIds == nil, recovery.transactions.allSatisfy(\.isConfirmed) + { + await finish(&record, phase: .done) + } + } + } + await updateClaimableHeight(&record) + try save(record) + return record + } + + /// Without the tip the countdown keeps its last target rather than jump to another measure. + private func updateClaimableHeight(_ record: inout SparkExitRecord) async { + if let tip = try? await chain.tipHeight() { + record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response, tip: tip) } + } else if record.claimableAtHeight == nil { + record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response) } + } + } + + private func saveIfChanged(_ record: SparkExitRecord, from before: SparkExitRecord) throws -> SparkExitRecord { + if record != before { + try save(record) + } + return record + } + + // MARK: Funding + + /// Forgets a savings payment the exit waits for once no host knows it for `ownPaymentMissingBlocks` (dropped or + /// replaced), or once it is older than `ownPaymentTimeoutBlocks` without being adopted, so the ask and the reminder + /// come back. Nothing changes without a tip. + private func noteOwnPayment(_ record: inout SparkExitRecord) async { + guard record.isAwaitingOwnPayment || record.isEndedPaymentPending, let paid = record.fundingPaymentTxid, + let tip = try? await chain.tipHeight() + else { return } + let seenAt = record.fundingPaymentSeenAtHeight ?? tip + record.fundingPaymentSeenAtHeight = seenAt + switch try? await chain.isKnown(txid: paid) { + case true?: record.fundingPaymentMissingSince = nil + case false?: record.fundingPaymentMissingSince = record.fundingPaymentMissingSince ?? tip + case nil: break + } + let isMissing = record.fundingPaymentMissingSince.map { tip.saturatingSub($0) >= Self.ownPaymentMissingBlocks } ?? false + let isTooOld = tip.saturatingSub(seenAt) >= Self.ownPaymentTimeoutBlocks + guard isMissing || isTooOld else { return } + Logger.warn("Spark exit forgets its funding payment \(paid) (missing: \(isMissing), too old: \(isTooOld))", context: "SparkExitDriver") + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + } + + /// A savings payment that confirmed at the funding address without covering the funding is no longer waited for: it + /// counts as funding seen there, and the rest is asked for and reminded about at once. + private func countShortOwnPayment(_ record: inout SparkExitRecord) async { + guard record.isAwaitingOwnPayment, let paid = record.fundingPaymentTxid, let address = record.funding.first?.address, + let utxos = try? await chain.utxos(address: address), utxos.contains(where: { $0.txid == paid && $0.status.confirmed }) + else { return } + Logger.info("Spark exit savings payment \(paid) confirmed short, asking for the rest", context: "SparkExitDriver") + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + record.isFundingPaymentShort = true + } + + /// An ended exit's savings payment that had not arrived: once at the funding address it joins the leftover and is + /// swept with it, after any sweep in flight settles (a confirmed one is kept in `sweptLeftover`). One no host + /// sees is forgotten by the own-payment expiry. + private func followEndedPayment(_ record: inout SparkExitRecord) async { + guard !record.isActive, let paid = record.fundingPaymentTxid else { return } + guard record.isEndedPaymentPending else { + // Already in the leftover (or adopted as funding): nothing more to follow. + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + return + } + guard let address = record.funding.first?.address, let tip = try? await chain.tipHeight(), + let listed = try? await chain.utxos(address: address) + else { return } + guard listed.contains(where: { $0.txid == paid }) else { + await noteOwnPayment(&record) + return + } + let sweep = record.leftoverSweep + var isSweepConfirmed = sweep?.isConfirmed == true + if let txid = sweep?.txid, !isSweepConfirmed, sweep?.rejection == nil { + // In flight: its coins are settled first, so the payment is never mixed into a sweep that may replace it. + guard let status = try? await chain.txStatus(txid: txid), status.confirmed else { return } + isSweepConfirmed = true + } else if sweep?.txid == nil, await isAnyKnown(sweep?.replacedTxids ?? []) { + return + } + if isSweepConfirmed, let sweep, let txid = sweep.txid { + if !(record.sweptLeftover ?? []).contains(where: { $0.txid == txid }) { + record.sweptLeftover = (record.sweptLeftover ?? []) + [.init(txid: txid, valueSats: sweep.valueSats)] + } + // A host behind the block can still list what the sweep spent. + let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" }) + record.leftover = listed.filter { !spent.contains("\($0.txid):\($0.vout)") } + .map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + } else { + let known = Set(record.leftover.map { "\($0.txid):\($0.vout)" }) + record.leftover += listed.filter { $0.txid == paid && !known.contains("\($0.txid):\($0.vout)") } + .map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + } + Logger.info("Spark exit funding payment \(paid) arrived after the exit ended; sweeping it back", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: tip, checkedAtHeight: tip, retryAtHeight: tip) + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + } + + /// While a higher fee's top-up is awaited: the current generation is checked and its ready steps sent. A + /// generation that finished in the meantime runs on to Done; one that diverged waits for the top-up's rebuild. + private func driveWhileFunding(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + guard let stored = record.recovery?.response else { return } + let checked = try await recovering.check(withConfirmations(stored)) + record.recovery = StoredRecovery(checked.recovery) + record.noteProgressAfterRebuildFromChain(checked.recovery) + switch checked.verdict { + case .valid: + // An unverified rebuild's ask ends once every step reads again: the valid generation needs nothing more. + // Funding that reached the address meanwhile stays there, for a later rebuild or the leftover sweep. + if record.isUnverifiedAsk == true, record.fundingPaymentTxid == nil, + !checked.recovery.transactions.contains(where: { $0.status == .unverified }) + { + Logger.info("Spark exit steps read again: the unverified rebuild's fee ask is withdrawn", context: "SparkExitDriver") + record.phase = .running + record.isUnverifiedAsk = nil + } + let tip = try? await chain.tipHeight() + try await broadcastReady(&record, tip: tip) + case .done: + // Nothing is left to pay more for: back to running, whose next check finishes it (failed leaves included). + // A payment still on its way is followed after the end and swept back with the leftover. + record.phase = .running + record.isUnverifiedAsk = nil + case .redo: + // The diverged generation is never sent again: its rebuild, a Redo one, waits for the top-up. + Logger.warn("Spark exit diverged on-chain while a top-up is awaited; rebuilding once it confirms", context: "SparkExitDriver") + record.isRebuilding = true + record.isRedoRebuild = true + record.isUnverifiedAsk = nil + } + } + + /// Adds every confirmed new UTXO at the funding address as a funding input once together they cover + /// `requiredSats`. + private func adoptFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async throws -> Bool { + guard let address = record.funding.first?.address, let pubkey = record.funding.first?.pubkey else { return false } + let utxos = try await chain.utxos(address: address) + guard let topUp = SparkExitMath.fundingTopUp( + SparkExitMath.newFunding(utxos, record: record), + requiredSats: requiredSats, + inputFeeSats: SparkExitMath.inputFeeSats(record) + ) else { + return false + } + let index = record.fundingIndex + if record.funding.count == 1, record.funding[0].txid == nil { + record.funding = [] + } + record.funding += topUp.map { + SparkExitRecord.Funding(index: index, address: address, pubkey: pubkey, txid: $0.txid, vout: $0.vout, valueSats: $0.value) + } + return true + } + + // MARK: Build + + /// Over a generation that did not diverge, however the build was reached (an unverified rebuild, a fee bump, a + /// rebuild after a leaf was left behind), that generation stays over a rebuild that leaves waiting a leaf it + /// recovers, before that rebuild is saved or any of it broadcast. + private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + let tip = try? await chain.tipHeight() + if record.isFeeBumpPending == true { + record.feeBumpBuiltAtHeight = tip + } + let valid = record.validGeneration + // Waiting for a leaf that may still move: tried again at growing gaps of blocks, not every tick. + if let since = record.retrySince { + guard let tip, Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) else { return } + } + await useExitStateSnapshot(&record, recovering: recovering) + guard let prepared = try await prepare(&record, recovering: recovering) else { + // The last leaf lost on a tick that could not read the funding address ends here, still as lost. + await finish(&record, phase: .failed(reason: record.isEveryLeafLost ? SparkExitRecord.lostReason : "No leaf can be exited")) + return + } + noteStoppedLeaves(prepared, tip: tip, in: &record) + guard !prepared.leaves.isEmpty else { + // A leaf the chain could not be read for, or one the operators' watchtower just stopped, may still move: + // the build waits for it, for about a week. Finished leaves are not listed. + if prepared.skipped.contains(where: { isProvisional($0, prepared: prepared, record: record, tip: tip) }) { + guard let tip else { return } + let since = record.retrySince ?? tip + if tip.saturatingSub(since) < Self.releaseBlocks { + record.retrySince = since + record.retriedAt = tip + return + } + Logger.warn("Spark exit lets go of the leaves it waited about a week for", context: "SparkExitDriver") + } + record.retrySince = nil + record.retriedAt = nil + record.skippedLeafCount = max(record.skippedLeafCount, prepared.skipped.count) + let isRecovered = record.recovery != nil || record.isRebuiltFromChain == true + await finish(&record, phase: isRecovered ? .done : .failed(reason: "Nothing left to recover")) + return + } + // A leaf that may still move is kept apart and tried again before the exit ends. + let isWaitReleased = record.retrySince.flatMap { since in tip.map { $0.saturatingSub(since) >= Self.releaseBlocks } } == true + let waitingLeafIds: [String] = isWaitReleased ? [] : prepared.skipped + .filter { isProvisional($0, prepared: prepared, record: record, tip: tip) } + .map(\.leafId) + record.retrySince = nil + record.retriedAt = nil + let built: RecoverFundsResponse + do { + built = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), + signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) + ) + } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { + // Built at the same fee rate, a leaf can still fail here: leave it behind and rebuild at once. + if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { + leaveBehind([leafId], reason: message, in: &record) + } + // Over a valid generation that recovers a leaf this build left waiting, that generation stays, rebuilt over + // again under its guard once a block. + if var valid, valid.recovery?.response.leaves.contains(where: { waitingLeafIds.contains($0.leafId) }) == true { + valid.leftBehindLeaves = record.leftBehindLeaves + record = valid + return + } + record.isRebuilding = record.recovery != nil + record.lastBuildAt = nil + return + } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { + await askForFunding(&record, requiredSats: requiredSats) + return + } + guard !built.transactions.isEmpty else { + // Only cooperative leaves the operators could not co-sign: kept as the exit's recovery, so the next check + // puts them through the bounded failed-leaf retry (about a week), with the operators-waiting line. + if !built.failed.isEmpty { + record.adopt(built, at: now()) + record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds + if let valid { + keepValidGeneration(&record, over: valid) + } + return + } + await finish(&record, phase: .failed(reason: "Nothing to recover")) + return + } + // A leaf the watchtower stopped moments ago is no older state's: it is never struck while it may still move. + let waiting = Set(built.transactions.compactMap(\.nodeId).filter { isStoppedRecently($0, prepared: prepared, record: record, tip: tip) }) + let strikes = record.recovery == nil || record.isRedoRebuild != true ? [:] : record.strikes(rebuiltInto: built).filter { txid, _ in + !built.transactions.contains { $0.txid == txid && $0.nodeId.map(waiting.contains) == true } + } + let lost = lostLeaves(built, strikes: strikes, record: record) + if !lost.isEmpty { + Logger.warn("Spark exit leaves claimed by an older state: \(lost.map(\.leafId))", context: "SparkExitDriver") + record.lostLeaves = (record.lostLeaves ?? []) + lost + record.spentStepStrikes = nil + // Not adopted: the next tick rebuilds at once without the lost leaves. + record.isRebuilding = true + record.lastBuildAt = nil + if record.exitLeafIds.isEmpty { + await finish(&record, phase: .failed(reason: SparkExitRecord.lostReason)) + } + return + } + record.adopt(built, at: now()) + record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds + if let valid, keepValidGeneration(&record, over: valid) { + return + } + record.spentStepStrikes = strikes.isEmpty ? nil : strikes + // Saved before any of it is broadcast, so a crash never leaves broadcast transactions without their record. + try save(record) + try await broadcastReady(&record) + } + + /// Prepares the exit's leaves. `Specific` fails as a whole when one fee-ladder leaf cannot pay its refund's fee: + /// that leaf is left behind and the rest prepared again, and when the error names no leaf, `RecoverableOnly` + /// prepares what it can and the leaves it leaves out stay behind. Nil once no leaf can be exited. + private func prepare(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws -> PrepareRecoverFundsResponse? { + var isRecoverableOnly = false + while !record.exitLeafIds.isEmpty { + do { + let prepared = try await recovering.prepare(record.prepareRequest( + isRecoverableOnly ? .recoverableOnly : .specific(leafIds: record.exitLeafIds) + )) + guard isRecoverableOnly else { return prepared } + let kept = Set(prepared.leaves.map(\.leafId)) + leaveBehind(record.exitLeafIds.filter { !kept.contains($0) }, reason: "Not recoverable at this fee rate", in: &record) + // It also takes the wallet's leaves outside this exit, such as one an older state claimed in an + // earlier exit: the exit's own are prepared again on their own. + guard prepared.leaves.contains(where: { !record.exitLeafIds.contains($0.leafId) }) else { return prepared } + guard !record.exitLeafIds.isEmpty else { return nil } + return try await recovering.prepare(record.prepareRequest(.specific(leafIds: record.exitLeafIds))) + } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { + if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { + leaveBehind([leafId], reason: message, in: &record) + } else if isRecoverableOnly { + throw SdkError.SparkError(message) + } else { + isRecoverableOnly = true + } + } + } + return nil + } + + private func leaveBehind(_ leafIds: [String], reason: String, in record: inout SparkExitRecord) { + guard !leafIds.isEmpty else { return } + // The first build has no recovery yet: the quote's values price its leaves. + let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + .merging(record.leafSats ?? [:]) { recovered, _ in recovered } + Logger.warn("Spark exit leaves left behind: \(leafIds): \(reason)", context: "SparkExitDriver") + record.leftBehindLeaves = (record.leftBehindLeaves ?? []) + leafIds.map { + SparkExitRecord.LeftBehindLeaf(leafId: $0, valueSats: values[$0] ?? 0, reason: reason) + } + } + + /// The SDK's exit data from just before the first build, so every rebuild sees the leaves as they were then. The + /// operators may stop reporting a leaf once it is being exited, and a later export would lack it. + private func useExitStateSnapshot(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async { + // An exit rebuilt from the chain is past its first build: an export now may already lack the moved leaves. + if record.recovery == nil, record.generation == 0, record.exitStateSnapshot == nil, record.isRebuiltFromChain != true { + do { + record.exitStateSnapshot = try await recovering.exportExitState() + } catch { + Logger.warn("Failed to export the Spark exit data before the first build: \(error)", context: "SparkExitDriver") + } + } else if let snapshot = record.exitStateSnapshot { + // Also a salvaged exit's, before its first rebuild: the SDK may no longer hold a leaf being exited. + do { + try await recovering.importExitState(snapshot) + } catch { + Logger.warn("Failed to import the Spark exit data before a rebuild: \(error)", context: "SparkExitDriver") + } + } + } + + private func noteStoppedLeaves(_ prepared: PrepareRecoverFundsResponse, tip: UInt32?, in record: inout SparkExitRecord) { + guard let tip else { return } + let own = Set(record.exitLeafIds) + let fresh = prepared.exitChainState.stoppedLeafIds.filter { own.contains($0) && record.stoppedLeafSince?[$0] == nil } + guard !fresh.isEmpty else { return } + var since = record.stoppedLeafSince ?? [:] + for leafId in fresh { + since[leafId] = tip + } + record.stoppedLeafSince = since + } + + /// Stopped by the operators' watchtower fewer than `stoppedLeafBlocks` ago, or with no tip to tell. + private func isStoppedRecently(_ leafId: String, prepared: PrepareRecoverFundsResponse, record: SparkExitRecord, tip: UInt32?) -> Bool { + guard prepared.exitChainState.stoppedLeafIds.contains(leafId) else { return false } + guard let tip, let since = record.stoppedLeafSince?[leafId] else { return true } + return tip.saturatingSub(since) < Self.stoppedLeafBlocks + } + + /// A skip of one of the exit's leaves that may still move: the chain could not be read for it, its funds were not + /// found yet, the operators could not be asked where they are, or the watchtower stopped it moments ago. + private func isProvisional(_ skip: SkippedLeaf, prepared: PrepareRecoverFundsResponse, record: SparkExitRecord, tip: UInt32?) -> Bool { + guard record.exitLeafIds.contains(skip.leafId) else { return false } + switch skip.reason { + case .unverified, .fundsNotFound, .operatorsUnavailable: return true + case .notRecoverable: return isStoppedRecently(skip.leafId, prepared: prepared, record: record, tip: tip) + case .feeExceedsValue: return false + } + } + + /// Six blocks after the wait starts, then at doubling gaps up to a day (Glow's `retryDue`). + static func isRetryDue(since: UInt32, retriedAt: UInt32, tip: UInt32) -> Bool { + let last = max(since, retriedAt) + let gap = min(retryMaxBlocks, max(retryBlocks, last - since)) + return tip.saturatingSub(last) >= gap + } + + /// The leaves whose refund a rebuild reproduced after its input was spent by another transaction in each of the + /// last generations. A refund's node id is its leaf. + private func lostLeaves(_ built: RecoverFundsResponse, strikes: [String: Int], record: SparkExitRecord) -> [SparkExitRecord.LostLeaf] { + let values = Dictionary( + ((record.recovery?.response.leaves ?? []) + built.leaves).map { ($0.leafId, $0.valueSats) }, + uniquingKeysWith: { first, _ in first } + ) + var lost: [SparkExitRecord.LostLeaf] = [] + for tx in built.transactions where tx.kind == .refund && strikes[tx.txid, default: 0] >= Self.strikesToLoseLeaf { + guard let leafId = tx.nodeId, record.exitLeafIds.contains(leafId), !lost.contains(where: { $0.leafId == leafId }) else { continue } + lost.append(.init(leafId: leafId, valueSats: values[leafId] ?? 0)) + } + return lost + } + + // MARK: Check + + private func check(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + guard let stored = record.recovery?.response else { + record.phase = .awaitingFunding(requiredSats: 0) + return + } + // Nothing to check on-chain: only cooperative leaves the operators have not co-signed yet. + if stored.transactions.isEmpty, !stored.failed.isEmpty { + let tip = try? await chain.tipHeight() + if try await retryFailedLeaves(&record, failed: stored.failed, recovering: recovering, tip: tip) { + return + } + if try await retryWaitingLeaves(&record, recovering: recovering, tip: tip) { + return + } + await finish(&record, phase: .done) + return + } + let checked = try await recovering.check(withConfirmations(stored)) + record.recovery = StoredRecovery(checked.recovery) + record.noteProgressAfterRebuildFromChain(checked.recovery) + let tip = try? await chain.tipHeight() + // A step that confirmed since the last rebuild over a stuck step shows the exit moves again. + if let count = record.stallConfirmedCount, SparkExitMath.confirmedCount(checked.recovery) > count { + record.stallRebuilds = nil + record.stallConfirmedCount = nil + } + switch checked.verdict { + case .valid: + // A fee bump whose build kept this generation over a waiting leaf is built at its rate again once a block, + // whatever the leaf waits for, until a build at it is adopted. That build also settles any unread step. + // A generation it keeps is still sent. + if record.isFeeBumpPending == true, let tip, record.feeBumpBuiltAtHeight != tip { + Logger.info("Spark exit builds again at its higher fee rate \(record.feeRateSatPerVbyte) sat/vB", context: "SparkExitDriver") + record.unverifiedRebuiltAtHeight = tip + let valid = record + try await build(&record, recovering: recovering) + keepRetryClock(&record, from: valid) + if record.generation == valid.generation, record.phase == .running, !record.isRebuilding { + try await broadcastReady(&record, tip: tip) + } + return + } + // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send: + // a build now would conflict with it at the same fee. A check marks a step unverified when one chain read + // fails and clears it on the next clean check, so only a step that was already unverified before this check + // rebuilds, and one this check just turned unverified holds it, as it may be in flight. A step that stays + // unread rebuilds at the first check, once a block at most, where no other step just turned unverified. + if let tip, isUnverifiedRebuildDue(checked.recovery, stored: stored, record: record, tip: tip) { + Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver") + record.unverifiedRebuiltAtHeight = tip + try save(record) + let valid = record + try await build(&record, recovering: recovering) + keepRetryClock(&record, from: valid) + if case .needsFunding = record.phase { + // Like a fee bump's, the valid generation keeps going while the funding it asks for comes. + record.isUnverifiedAsk = true + } + return + } + // The SDK sees no divergence when another wallet's CPFP child confirmed a node our leaves share. + let dead = record.isStalled ? [] : await deadCpfpChildren(checked.recovery, stored: stored) + if !dead.isEmpty { + Logger.warn("Spark exit fee chain lost a race (dead children \(dead)), rebuilding", context: "SparkExitDriver") + try await rebuildStalled(&record, recovering: recovering) + return + } + try await broadcastReady(&record, tip: tip) + if !record.isStalled, let stuck = record.stuckStep { + Logger.warn("Spark exit step \(stuck.txid) keeps being refused (\(stuck.lastOutcome)), rebuilding", context: "SparkExitDriver") + try await rebuildStalled(&record, recovering: recovering) + } + case .done: + if try await retryFailedLeaves(&record, failed: checked.recovery.failed, recovering: recovering, tip: tip) { + return + } + if try await retryWaitingLeaves(&record, recovering: recovering, tip: tip) { + return + } + await finish(&record, phase: .done) + case .redo: + Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver") + record.isRedoRebuild = true + try await rebuild(&record, recovering: recovering) + } + } + + /// Cooperative leaves a finished recovery lists as failed: the operators' watchtower moved them on-chain and the + /// operators could not co-sign their recovery. They stay part of the exit and are prepared again as a recovery of + /// their own, at once and then at Glow's doubling gaps (`isRetryDue`), until they land or about a week passed + /// (`releaseBlocks`). True while the exit keeps waiting for them, false once it can end. + private func retryFailedLeaves( + _ record: inout SparkExitRecord, + failed: [CooperativeRecoveryFailure], + recovering: SparkExitRecovering, + tip: UInt32? + ) async throws -> Bool { + let failedIds = failed.map(\.leafId) + guard !failedIds.isEmpty, record.isFailedLeavesReleased != true else { return false } + guard let tip else { return true } + let since = record.failedLeafRetrySince ?? tip + record.failedLeafRetrySince = since + if tip.saturatingSub(since) >= Self.releaseBlocks { + Logger.warn("Spark exit lets go of leaves the operators could not co-sign: \(failedIds)", context: "SparkExitDriver") + record.isFailedLeavesReleased = true + return false + } + if let retriedAt = record.failedLeafRetriedAt, !Self.isRetryDue(since: since, retriedAt: retriedAt, tip: tip) { + return true + } + record.failedLeafRetriedAt = tip + try save(record) + guard !Task.isCancelled else { return true } + await useExitStateSnapshot(&record, recovering: recovering) + do { + let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: failedIds))) + noteFailedLeafValues(prepared, failedIds: failedIds, in: &record) + guard !prepared.leaves.isEmpty else { return true } + // A failed leaf skipped while it may still move is kept and tried again on its own. + noteStoppedLeaves(prepared, tip: tip, in: &record) + let skipped = prepared.skipped.filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }.map(\.leafId) + let built = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), + signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) + ) + guard !built.transactions.isEmpty else { return true } + Logger.info("Spark exit recovers \(built.leaves.count) leaves the operators moved on-chain", context: "SparkExitDriver") + record.adopt(built, at: now()) + let waiting = Array(Set((record.waitingLeafIds ?? []) + skipped)).sorted() + record.waitingLeafIds = waiting.isEmpty ? nil : waiting + try save(record) + try await broadcastReady(&record, tip: tip) + } catch { + Logger.warn("Spark exit could not recover the leaves the operators moved on-chain yet: \(error)", context: "SparkExitDriver") + } + return true + } + + /// The SDK names the total all inputs must hold, not what is missing. + private func askForFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async { + var utxos: [EsploraUtxo]? + if let address = record.funding.first?.address { + utxos = try? await chain.utxos(address: address) + } + record.phase = .needsFunding(requiredSats: SparkExitMath.neededFunding(requiredSats: requiredSats, record: record, utxos: utxos)) + } + + /// Leaves a build skipped while they may still move (`waitingLeafIds`), prepared again on their own once the rest of + /// the exit is done: at once, then at Glow's doubling gaps (`isRetryDue`), until they build or about a week passed + /// (`releaseBlocks`). One that needs more fee funding asks for it and is built as soon as it arrives (`isFunded`). + /// True while the exit keeps waiting for them, false once it can end. + private func retryWaitingLeaves( + _ record: inout SparkExitRecord, + recovering: SparkExitRecovering, + tip: UInt32?, + isFunded: Bool = false + ) async throws -> Bool { + let ids = (record.waitingLeafIds ?? []).filter(record.exitLeafIds.contains) + guard !ids.isEmpty else { + record.waitingLeafIds = nil + return false + } + guard let tip else { return true } + let since = record.retrySince ?? tip + if tip.saturatingSub(since) >= Self.releaseBlocks { + Logger.warn("Spark exit lets go of the skipped leaves it waited about a week for: \(ids)", context: "SparkExitDriver") + letGoOfWaitingLeaves(&record) + return false + } + if !isFunded, record.retrySince != nil, !Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) { + return true + } + record.retrySince = since + record.retriedAt = tip + try save(record) + guard !Task.isCancelled else { return true } + await useExitStateSnapshot(&record, recovering: recovering) + do { + let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: ids))) + noteStoppedLeaves(prepared, tip: tip, in: &record) + let still = prepared.skipped.filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }.map(\.leafId) + guard !prepared.leaves.isEmpty else { + guard !still.isEmpty else { + letGoOfWaitingLeaves(&record) + return false + } + record.waitingLeafIds = still + return true + } + let built = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), + signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) + ) + guard !built.transactions.isEmpty else { return true } + Logger.info("Spark exit recovers \(built.leaves.count) leaves it skipped while they could still move", context: "SparkExitDriver") + record.adopt(built, at: now()) + record.waitingLeafIds = still.isEmpty ? nil : still + record.retrySince = nil + record.retriedAt = nil + try save(record) + try await broadcastReady(&record, tip: tip) + } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { + await askForFunding(&record, requiredSats: requiredSats) + } catch { + Logger.warn("Spark exit could not recover the leaves it skipped yet: \(error)", context: "SparkExitDriver") + } + return true + } + + private func letGoOfWaitingLeaves(_ record: inout SparkExitRecord) { + // The quote's skips never were exit leaves: the leaves let go here add to them. + record.skippedLeafCount += record.waitingLeafIds?.count ?? 0 + record.waitingLeafIds = nil + record.retrySince = nil + record.retriedAt = nil + } + + /// Keeps the value of each failed leaf from what the SDK reports for it, listed or skipped. + private func noteFailedLeafValues(_ prepared: PrepareRecoverFundsResponse, failedIds: [String], in record: inout SparkExitRecord) { + let reported = Dictionary( + prepared.leaves.map { ($0.leafId, $0.valueSats) } + prepared.skipped.map { ($0.leafId, $0.valueSats) }, + uniquingKeysWith: { first, _ in first } + ) + let known = Dictionary((record.failedLeaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + record.failedLeaves = failedIds.map { SparkExitRecord.FailedLeaf(leafId: $0, valueSats: reported[$0] ?? known[$0] ?? 0) } + } + + // MARK: Fee bump + + /// What the funding address still has to receive for the exit to rebuild at `rate`, 0 when what it holds covers + /// it. Builds the exit's leaves at that rate with the funding it has, the way a rebuild would, and broadcasts + /// nothing; a shortfall is sized like a rebuild's (`neededFunding`), the input fee of the payment to come included. + func feeBumpShortfall(_ record: SparkExitRecord, rate: UInt64) async throws -> UInt64 { + guard let recovering else { throw SparkExitFeeBumpError.notConnected } + // A copy at the new rate: a leaf the SDK refuses at that rate is left behind on the copy only, as a rebuild would. + var record = record + record.feeRateSatPerVbyte = rate + await useExitStateSnapshot(&record, recovering: recovering) + let leftBehindBefore = record.leftBehindSats + let leavesBefore = Set(record.exitLeafIds) + guard let prepared = try await prepare(&record, recovering: recovering) else { + throw SparkExitFeeBumpError.leavesBehind(sats: record.leftBehindSats.saturatingSub(leftBehindBefore)) + } + // A rate at which a leaf can't pay its own exit would leave it on Spark: never offered. + if Set(record.exitLeafIds) != leavesBefore { + let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { a, _ in a }) + let dropped = leavesBefore.subtracting(record.exitLeafIds).reduce(UInt64(0)) { $0 + (values[$1] ?? 0) } + throw SparkExitFeeBumpError.leavesBehind(sats: dropped) + } + // A prepare that succeeds can still skip an exiting leaf whose exit costs more than it holds at this rate. + let unpaid = prepared.skipped.filter { + guard case .feeExceedsValue = $0.reason else { return false } + return record.exitLeafIds.contains($0.leafId) + } + if !unpaid.isEmpty { + throw SparkExitFeeBumpError.leavesBehind(sats: unpaid.reduce(0) { $0 + $1.valueSats }) + } + guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } + do { + _ = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: record.fundingInputs), + signer: makeSigner(record.fundingIndex) + ) + return 0 + } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { + // The build refused a leaf at this rate: it would stay on Spark, so the rate is not offered. + let leafId = SparkExitMath.unexitableLeafId(message) + let value = (record.recovery?.response.leaves ?? []).first { $0.leafId == leafId }?.valueSats ?? 0 + throw SparkExitFeeBumpError.leavesBehind(sats: value) + } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { + var atRate = record + atRate.feeRateSatPerVbyte = rate + let utxos = try? await chain.utxos(address: record.funding.first?.address ?? "") + let needed = SparkExitMath.neededFunding(requiredSats: requiredSats, record: atRate, utxos: utxos) + return max(needed.saturatingSub(record.fundedSats), SparkExitMath.fundingDustSats) + } + } + + /// A rebuild over a chain that stopped moving: refused steps, missing inputs, unpaid children or a dead CPFP child. + /// Only `stallRebuildsToSurface` of them run while no step confirms, so a stale chain index or a step no build can + /// fix does not rebuild forever; the exit then says it is not moving. + private func rebuildStalled(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + record.isRedoRebuild = nil + record.stallRebuilds = (record.stallRebuilds ?? 0) + 1 + record.stallConfirmedCount = record.recovery.map { SparkExitMath.confirmedCount($0.response) } ?? 0 + try await rebuild(&record, recovering: recovering) + } + + /// Unilateral steps the SDK could not read, each one at this check and the one before, nothing unilateral ready to + /// send, and no such rebuild at this tip yet. A step this check just turned unverified may be in flight, so it holds + /// the rebuild as a ready one does. + private func isUnverifiedRebuildDue( + _ recovery: RecoverFundsResponse, + stored: RecoverFundsResponse, + record: SparkExitRecord, + tip: UInt32 + ) -> Bool { + let unilateral = recovery.transactions.filter { $0.kind != .cooperative } + let unreadBefore = Set(stored.transactions.filter { $0.status == .unverified }.map(\.txid)) + let unread = unilateral.filter { $0.status == .unverified } + return !unread.isEmpty && unread.allSatisfy { unreadBefore.contains($0.txid) } + && !unilateral.contains { $0.status == .ready } + && record.unverifiedRebuiltAtHeight != tip + } + + /// Saved as rebuilding first, so nothing of the replaced generation is broadcast again, then rebuilt once due. + private func rebuild(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + record.isRebuilding = true + try save(record) + guard !Task.isCancelled, isRebuildDue(record) else { return } + try await build(&record, recovering: recovering) + } + + /// Our CPFP children that a step still to confirm builds on, and that can never confirm: the node each pays for + /// is in a block, yet the child is neither in a block nor in the mempool. Another wallet's child won the race + /// for a node both exits share, so the change ours would leave never exists. A child the hosts could not be + /// asked about counts as alive. + private func deadCpfpChildren(_ recovery: RecoverFundsResponse, stored: RecoverFundsResponse) async -> [String] { + let confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var stepOfChild: [String: String] = [:] + for tx in stored.transactions { + if let child = tx.cpfpTxHex.flatMap(SparkExitTxid.txid(hex:)) { + stepOfChild[child] = tx.txid + } + } + var suspects = Set() + for tx in stored.transactions where !confirmed.contains(tx.txid) { + guard let inputs = tx.cpfpTxHex.flatMap(SparkExitTxid.inputTxids(hex:)) else { continue } + suspects.formUnion(inputs.filter { stepOfChild[$0].map(confirmed.contains) == true }) + } + var dead: [String] = [] + for child in suspects.sorted() where !Task.isCancelled { + if await (try? chain.isKnown(txid: child)) == false { + dead.append(child) + } + } + return dead + } + + /// Marks the next steps the chain host shows in a block as confirmed. The SDK asks the chain about a step only + /// once the caller saw it in a block; otherwise it looks for who spent its input, which an Electrum-backed + /// mempool, such as Spark's regtest, does not report. + private func withConfirmations(_ recovery: RecoverFundsResponse) async -> RecoverFundsResponse { + var recovery = recovery + var confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var asked = Set() + // One round per depth, each read at once: a 30-transaction exit takes a few rounds instead of 30 reads in a row. + while !Task.isCancelled { + let next = recovery.transactions.indices.filter { i in + let tx = recovery.transactions[i] + return !confirmed.contains(tx.txid) && !asked.contains(tx.txid) && tx.dependsOn.allSatisfy(confirmed.contains) + } + guard !next.isEmpty else { break } + let statuses = await statuses(next.map { recovery.transactions[$0].txid }) + asked.formUnion(next.map { recovery.transactions[$0].txid }) + for i in next { + guard let status = statuses[recovery.transactions[i].txid], status.confirmed else { continue } + confirmed.insert(recovery.transactions[i].txid) + recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight) + } + } + return recovery + } + + /// The chain host's status of each transaction, read concurrently; one it could not answer is left out. + private func statuses(_ txids: [String]) async -> [String: EsploraTxStatus] { + await withTaskGroup(of: (String, EsploraTxStatus?).self) { group in + var results: [String: EsploraTxStatus] = [:] + var pending = Set(txids).makeIterator() + func addNext() -> Bool { + guard let txid = pending.next() else { return false } + group.addTask { [chain] in await (txid, try? chain.txStatus(txid: txid)) } + return true + } + for _ in 0 ..< Self.concurrentReads { + guard addNext() else { break } + } + while let (txid, status) = await group.next() { + results[txid] = status + _ = addNext() + } + return results + } + } + + /// A rebuild that leaves waiting a leaf the valid generation recovers drops that leaf's signed steps, which a + /// matured step needs sent within the operators' window, whether it built only failed cooperative leaves or other + /// leaves: the valid generation stays, as `valid` holds it, and is what the tick saves. While the leaf stays + /// unreadable the rebuild repeats once a block. True when it stayed. + @discardableResult + private func keepValidGeneration(_ record: inout SparkExitRecord, over valid: SparkExitRecord) -> Bool { + guard record.generation != valid.generation, let waiting = record.waitingLeafIds, + valid.recovery?.response.leaves.contains(where: { waiting.contains($0.leafId) }) == true + else { return false } + Logger.warn("Spark exit keeps its generation: the rebuild left waiting a leaf it recovers", context: "SparkExitDriver") + record = valid + return true + } + + /// An unverified rebuild that adopted nothing keeps the valid generation's retry clock: one it set while waiting + /// for the unread leaf would let that leaf go after about a week, ending the exit before its signed steps are sent. + private func keepRetryClock(_ record: inout SparkExitRecord, from valid: SparkExitRecord) { + guard record.generation == valid.generation, record.isActive else { return } + record.retrySince = valid.retrySince + record.retriedAt = valid.retriedAt + } + + private func isRebuildDue(_ record: SparkExitRecord) -> Bool { + guard let last = record.lastBuildAt else { return true } + return now().timeIntervalSince(last) >= Self.rebuildInterval + } + + /// Ends the exit and notes what is left at the funding address, which the next step sweeps to savings. While the + /// address cannot be read the exit keeps its phase: one ended without its leftover read would never sweep it, so + /// the next tick ends it instead. + private func finish(_ record: inout SparkExitRecord, phase: SparkExitRecord.Phase) async { + guard let address = record.funding.first?.address else { + record.phase = phase + return + } + guard let utxos = try? await chain.utxos(address: address) else { return } + record.phase = phase + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + if record.isLeftoverSweepPending, !Task.isCancelled { + try? await sweepLeftover(&record) + } + } + + // MARK: Leftover + + /// Sends the confirmed leftover at the funding address to the exit's savings address, signed with the funding + /// key. Built and saved once before it is sent, then sent again on later ticks until it confirms. Waits while + /// the leftover is unconfirmed, and skips it when the fee would leave less than dust. One refused for its fee, or + /// accepted but unconfirmed for `sweepReplaceBlocks`, is built again over the same coins at the network's current + /// rate, which replaces it (RBF). + private func sweepLeftover(_ record: inout SparkExitRecord) async throws { + if record.leftoverSweep?.isRemainingUnread == true { + await followRemainingOutputs(&record) + return + } + // A sweep it replaced may confirm instead: that ends the sweep as well. + for txid in record.leftoverSweep?.replacedTxids ?? [] where record.leftoverSweep?.isConfirmed != true { + if let status = try? await chain.txStatus(txid: txid), status.confirmed { + // Its own amount and inputs are what reached savings, not the replacement's. + if let landed = record.acceptedSweeps?.first(where: { $0.txid == txid }) { + record.leftoverSweep?.txid = landed.txid + record.leftoverSweep?.txHex = landed.txHex + record.leftoverSweep?.valueSats = landed.valueSats + } + record.leftoverSweep?.isConfirmed = true + await followRemainingOutputs(&record) + return + } + } + // A sweep refused for its fee, before this rule or on the last tick, waits for the next block and a rate the + // node takes. + if let sweep = record.leftoverSweep, let rejection = sweep.rejection, sweep.isFeeRejection, !sweep.isAccepted { + await waitAfterFeeRefusal(&record, refusal: rejection) + try save(record) + } + if let sweep = record.leftoverSweep, let oldTxid = sweep.txid, sweep.isConfirmed != true, sweep.rejection == nil, + !Task.isCancelled, let address = record.funding.first?.address + { + let oldRate = sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte + var isReplaceDue = false + if sweep.isAccepted, let sentAt = sweep.sentAtHeight, let tip = try? await chain.tipHeight(), + tip.saturatingSub(sentAt) >= Self.sweepReplaceBlocks + { + // A node that dropped it answers not found: that is unconfirmed too. + isReplaceDue = await (try? chain.txStatus(txid: oldTxid))?.confirmed != true + } + // At the ceiling already: sent again as it is until it confirms. + let ceiling = isReplaceDue ? await sweepFeeCeiling(record) : 0 + if isReplaceDue, oldRate < ceiling { + // A host with no fee estimates still gets a replacement one sat/vB over the old rate. + let fastest = try? await chain.fastestFeeRate() + let rate = min(max(fastest ?? 0, oldRate + 1), ceiling) + let utxos = SparkExitMath.economicalUtxos(record.leftover.map { + EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.valueSats, status: EsploraTxStatus(confirmed: true, blockHeight: nil)) + }, rate: rate) + Logger.info("Spark exit rebuilds its leftover sweep at \(rate) sat/vB", context: "SparkExitDriver") + let built = try await SparkExitLeftoverSweep.build( + utxos: utxos, + fundingAddress: address, + destination: record.destination, + feeRateSatPerVbyte: rate, + signer: makeSigner(record.fundingIndex) + ) + if let built { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + txid: built.txid, + txHex: built.txHex, + valueSats: built.valueSats, + feeRateSatPerVbyte: rate, + replacedTxids: sweep.isAccepted ? (sweep.replacedTxids ?? []) + [oldTxid] : sweep.replacedTxids + ) + try save(record) + } else if !sweep.isAccepted, let tip = try? await chain.tipHeight() { + // Too small to send at the new rate, and nothing of it is in the mempool: it waits for fees to drop. + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: rate, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip + ) + try save(record) + } + } + } + if record.leftoverSweep == nil || record.leftoverSweep?.isWaitingForFees == true { + try await sweepWhenEconomical(&record) + } + guard let sweep = record.leftoverSweep, let txid = sweep.txid, let hex = sweep.txHex, sweep.rejection == nil, + sweep.isConfirmed != true, !Task.isCancelled + else { return } + if sweep.isAccepted, let status = try? await chain.txStatus(txid: txid), status.confirmed { + record.leftoverSweep?.isConfirmed = true + await followRemainingOutputs(&record) + return + } + // Built but never accepted for `sweepRecheckBlocks`: its coins may have changed, so it is built again from what + // the address holds now. + if !sweep.isAccepted, let builtAt = sweep.builtAtHeight, let tip = try? await chain.tipHeight(), + tip.saturatingSub(builtAt) >= Self.sweepRecheckBlocks + { + Logger.warn("Spark exit leftover sweep \(txid) never accepted, building it again", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip, + refusedFeeRate: sweep.refusedFeeRate, + retryAtHeight: tip + 1 + ) + return + } + let outcome = await chain.broadcast(RecoveryTransaction( + kind: .sweep, + nodeId: nil, + txid: txid, + txHex: hex, + cpfpTxHex: nil, + csvTimelockBlocks: nil, + dependsOn: [], + status: .ready + )) + Logger.info("Spark exit leftover sweep \(txid): \(outcome.summary)", context: "SparkExitDriver") + switch outcome { + case .accepted: + record.leftoverSweep?.isAccepted = true + if !(record.acceptedSweeps ?? []).contains(where: { $0.txid == txid }) { + record.acceptedSweeps = (record.acceptedSweeps ?? []) + [.init(txid: txid, txHex: hex, valueSats: sweep.valueSats)] + } + if record.leftoverSweep?.sentAtHeight == nil { + record.leftoverSweep?.sentAtHeight = try? await chain.tipHeight() + } + case let .rejected(reason) where !sweep.isAccepted: + if SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection { + await waitAfterFeeRefusal(&record, refusal: reason) + } else if await isAnyKnown(sweep.replacedTxids ?? []) { + // A replacement refused for another reason while the sweep it replaces may still confirm: that one is + // followed (the replaced txids end the sweep once in a block), and a new build is tried later. + if let tip = try? await chain.tipHeight() { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip + ) + } + } else { + record.leftoverSweep?.rejection = reason + // Final: shown once more with its address, even after the result was opened, as kept change is. + record.isResultSeen = nil + } + // Once accepted, a later refusal is mostly a node that already has it or a conflict with it confirming. A fee + // refusal of one no host holds any more means it was evicted when the mempool's floor rose: it waits and is + // built again over the same coins at the rate the node asks for, replacing it (RBF). + case let .rejected(reason) where SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection: + let isKnown = try? await chain.isKnown(txid: txid) + if isKnown == false { + Logger.warn("Spark exit leftover sweep \(txid) left the mempool unconfirmed", context: "SparkExitDriver") + await waitAfterFeeRefusal(&record, refusal: reason) + } + case .inputsMissing where !sweep.isAccepted: + // The coins it spends are gone or changed: built again from what the address holds, from the next block. + if let tip = try? await chain.tipHeight() { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip, + refusedFeeRate: sweep.refusedFeeRate, + retryAtHeight: tip + 1 + ) + } + default: break + } + } + + /// After a sweep confirms, reads the funding address again: outputs the sweep left out (too small for their fee, + /// or unconfirmed when it was built) start a new wait of their own, so they are swept once worth it or kept with + /// their path after `sweepWaitBlocks`. What the confirmed sweeps sent is kept for the result. While the tip or the + /// address cannot be read the sweep stays pending, and a later tick reads them again. + private func followRemainingOutputs(_ record: inout SparkExitRecord) async { + guard let sweep = record.leftoverSweep, let txid = sweep.txid, let address = record.funding.first?.address else { return } + guard let tip = try? await chain.tipHeight(), let listed = try? await chain.utxos(address: address) else { + record.leftoverSweep?.isRemainingUnread = true + return + } + record.leftoverSweep?.isRemainingUnread = nil + // A host behind the block can still list what the sweep spent. + let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" }) + let utxos = listed.filter { !spent.contains("\($0.txid):\($0.vout)") } + guard !utxos.isEmpty else { return } + Logger.info("Spark exit sweep \(txid) left \(utxos.count) outputs at the funding address; following them", context: "SparkExitDriver") + record.sweptLeftover = (record.sweptLeftover ?? []) + [.init(txid: txid, valueSats: sweep.valueSats)] + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: tip, checkedAtHeight: tip) + } + + private func isAnyKnown(_ txids: [String]) async -> Bool { + for txid in txids { + let isKnown = try? await chain.isKnown(txid: txid) + if isKnown == true { + return true + } + } + return false + } + + /// A sweep refused for its fee is not final: the leftover waits again, keeping when its wait began, and is built + /// again from the next block at the rate the refusal asks for (`refusedSweepRate`, capped by `refusedSweepFloor`), or + /// the economy rate if higher. + private func waitAfterFeeRefusal(_ record: inout SparkExitRecord, refusal: String) async { + // Without a tip no height is written: the refusal is kept, and the next tick turns it into the wait. + guard let sweep = record.leftoverSweep, let tip = try? await chain.tipHeight() else { + if record.leftoverSweep?.isAccepted == false { + record.leftoverSweep?.rejection = refusal + } + return + } + let rate = SparkExitMath.refusedSweepRate(refusal, paidRate: sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte) + // One a node accepted may still confirm: any of the replaced sweeps confirming ends the sweep. + let replaced = sweep.isAccepted ? (sweep.replacedTxids ?? []) + [sweep.txid].compactMap { $0 } : sweep.replacedTxids + Logger.info("Spark exit leftover sweep refused for its fee (\(refusal)), tries again at \(rate) sat/vB", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: replaced, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip, + refusedFeeRate: rate, + retryAtHeight: tip + 1 + ) + } + + /// Builds the leftover sweep at the network's economy rate (`sweepFeeRate`) from the UTXOs worth more than their + /// own input fee. While nothing is worth sending it waits, checking the rate again every `sweepRecheckBlocks`, + /// and after `sweepWaitBlocks` keeps the change at the funding address, shown with the path that spends it. + private func sweepWhenEconomical(_ record: inout SparkExitRecord) async throws { + guard let address = record.funding.first?.address else { return } + let waiting = record.leftoverSweep + // Every state below records a height: without the tip nothing changes this tick. + guard let tip = try? await chain.tipHeight() else { return } + if let waiting, let since = waiting.waitingSince { + if tip.saturatingSub(since) >= Self.sweepWaitBlocks { + Logger.info("Spark exit keeps its fee change at the funding address after waiting for fees", context: "SparkExitDriver") + record.leftoverSweep?.isDustKept = true + // Shown once more with its path, even after the result was opened; the earlier exit addresses keep + // listing it after that. + record.isResultSeen = nil + try save(record) + return + } + let due = waiting.retryAtHeight ?? (waiting.checkedAtHeight ?? since).addingReportingOverflow(Self.sweepRecheckBlocks).partialValue + guard tip >= due else { return } + } + let utxos = try await chain.utxos(address: address) + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + let confirmed = utxos.filter(\.status.confirmed) + guard !confirmed.isEmpty else { return } + // An unconfirmed output (a stuck payment, say) holds the sweep back for one recheck at most; then the confirmed + // ones go and the rest waits for the next sweep. + if confirmed.count < utxos.count { + let since = waiting?.waitingSince ?? tip + if tip.saturatingSub(since) < Self.sweepRecheckBlocks { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + replacedTxids: waiting?.replacedTxids, + waitingSince: since, + checkedAtHeight: tip, + refusedFeeRate: waiting?.refusedFeeRate, + retryAtHeight: tip + 1 + ) + try save(record) + return + } + } + let fees = try? await chain.networkFees() + // A rate a node refused stays refused: the least it asked for lifts the economy rate, up to the next block's or + // the exit's rate. A node asking more refuses again, and the sweep waits a block for fees to drop. + let ceiling = await sweepFeeCeiling(record) + let refusedFloor = SparkExitMath.refusedSweepFloor( + waiting?.refusedFeeRate ?? 0, + nextBlock: fees.map { min($0.fastest, ceiling) }, + exitRate: record.feeRateSatPerVbyte + ) + let rate = max(SparkExitMath.sweepFeeRate(economy: fees?.economy, exitRate: record.feeRateSatPerVbyte), refusedFloor) + let built = try await SparkExitLeftoverSweep.build( + utxos: SparkExitMath.economicalUtxos(confirmed, rate: rate), + fundingAddress: address, + destination: record.destination, + feeRateSatPerVbyte: rate, + signer: makeSigner(record.fundingIndex) + ) + if let built { + // The wait's start carries over, so a sweep refused again still ends at `sweepWaitBlocks`. + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + txid: built.txid, + txHex: built.txHex, + valueSats: built.valueSats, + feeRateSatPerVbyte: rate, + replacedTxids: waiting?.replacedTxids, + waitingSince: waiting?.waitingSince, + builtAtHeight: tip + ) + } else { + let since = waiting?.waitingSince ?? tip + Logger.info("Spark exit waits for lower fees to send its fee change (\(rate) sat/vB)", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: rate, + replacedTxids: waiting?.replacedTxids, + waitingSince: since, + checkedAtHeight: tip, + refusedFeeRate: waiting?.refusedFeeRate + ) + } + try save(record) + } + + /// The most a leftover sweep pays per vbyte: the exit's own rate, which the user confirmed, or Bitkit's own fast rate + /// when higher. The chain host's estimates and refusals are not trusted past it. + private func sweepFeeCeiling(_ record: SparkExitRecord) async -> UInt64 { + await max(record.feeRateSatPerVbyte, trustedFeeRate() ?? 0) + } + + // MARK: Broadcast + + /// A unilateral step the SDK could not read is never sent: the next clean check, or a rebuild once it stays unread, + /// settles it. A cooperative one is, since nodes refuse it if its funds already moved. + private func broadcastReady(_ record: inout SparkExitRecord, tip: UInt32? = nil) async throws { + guard let recovery = record.recovery?.response else { return } + let confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var tip = tip + for tx in recovery.transactions where tx.status == .ready || (tx.status == .unverified && tx.kind == .cooperative) { + guard !Task.isCancelled else { return } + guard isDue(tx.txid, in: record) else { continue } + if tip == nil { + tip = try? await chain.tipHeight() + } + let hasConfirmedParents = tx.status == .ready && tx.dependsOn.allSatisfy(confirmed.contains) + await broadcast(tx, into: &record, hasConfirmedParents: hasConfirmedParents, tip: tip) + try save(record) + } + } + + /// Without the SDK: each unconfirmed transaction whose parents are confirmed on the chain host and whose relative + /// timelocks matured, counted from the confirmation height of the parent each input spends. A step whose input + /// someone else spent, the operators' version of it, is not sent again; the next SDK check rebuilds. A stale one + /// is refused by nodes as a conflict, and only the current generation is stored. + private func broadcastWithoutSdk(_ record: inout SparkExitRecord) async throws { + guard var recovery = record.recovery?.response else { return } + let tip = try await chain.tipHeight() + let listed = Set(recovery.transactions.map(\.txid)) + let saved = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var confirmed = saved + var heights: [String: UInt32] = [:] + for tx in recovery.transactions { + if case let .confirmed(height?) = tx.status { + heights[tx.txid] = height + } + } + // A saved confirmation is asked again, since a reorg can undo it; it stands only while no host answers. + let unknown = recovery.transactions.map(\.txid) + Set(recovery.transactions.flatMap(\.dependsOn)).subtracting(listed) + let statuses = await statuses(unknown) + let reorged = saved.filter { statuses[$0]?.confirmed == false } + for txid in reorged { + confirmed.remove(txid) + heights[txid] = nil + } + if !reorged.isEmpty { + Self.undoReorg(reorged, in: recovery, record: &record) + } + for (txid, status) in statuses where status.confirmed { + confirmed.insert(txid) + heights[txid] = status.blockHeight ?? heights[txid] + } + for (i, tx) in recovery.transactions.enumerated() { + guard !Task.isCancelled else { break } + if let status = statuses[tx.txid], status.confirmed { + recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight ?? heights[tx.txid]) + continue + } + if reorged.contains(tx.txid) { + recovery.transactions[i].status = .waitingForDependencies + } else if tx.isConfirmed { + continue + } + guard tx.dependsOn.allSatisfy(confirmed.contains), isDue(tx.txid, in: record, withoutSdk: true) else { continue } + if case let .waitingForTimelock(height?) = tx.status, !SparkExitTimelock.isMature(height, tip: tip) { + continue + } + let locks = SparkExitTimelock.locks(of: tx) + for parent in Set(locks.map(\.parent)).subtracting(heights.keys) { + if let status = try? await chain.txStatus(txid: parent), status.confirmed, let height = status.blockHeight { + heights[parent] = height + } + } + switch SparkExitTimelock.maturity(of: tx, locks: locks, heights: heights) { + case .unknown: + continue + case let .at(height) where !SparkExitTimelock.isMature(height, tip: tip): + recovery.transactions[i].status = .waitingForTimelock(spendableAtHeight: height) + continue + case .none, .at: + break + } + await broadcast(tx, into: &record, hasConfirmedParents: true, tip: tip) + record.recovery = StoredRecovery(recovery) + try save(record) + } + record.recovery = StoredRecovery(recovery) + } + + /// What was concluded from confirmations a reorg undid, a spent input or a failed fee chain of a step or of a step + /// after it, no longer holds. + private static func undoReorg(_ reorged: Set, in recovery: RecoverFundsResponse, record: inout SparkExitRecord) { + Logger.warn("Spark exit steps \(reorged.sorted()) lost their confirmation; asking again", context: "SparkExitDriver") + var undone = reorged + while true { + let added = recovery.transactions + .filter { !undone.contains($0.txid) && $0.dependsOn.contains(where: undone.contains) } + .map(\.txid) + if added.isEmpty { + break + } + undone.formUnion(added) + } + for i in record.broadcastLog.indices where record.broadcastLog[i].generation == record.generation + && undone.contains(record.broadcastLog[i].txid) + { + record.broadcastLog[i].isInputSpent = nil + record.broadcastLog[i].feeChainFailures = nil + } + } + + /// Without the SDK, a step whose input someone else spent is never due: only a rebuild moves on from it. + private func isDue(_ txid: String, in record: SparkExitRecord, withoutSdk: Bool = false) -> Bool { + guard let last = record.broadcastLog.first(where: { $0.generation == record.generation && $0.txid == txid }) else { return true } + if withoutSdk, last.isInputSpent == true { + return false + } + return !last.isAccepted || now().timeIntervalSince(last.lastAttemptAt) >= Self.rebroadcastInterval + } + + /// With every parent of the step confirmed, missing inputs mean another transaction spent one. A failed fee + /// chain is no rejection: it counts on its own towards a rebuild. + private func broadcast(_ tx: RecoveryTransaction, into record: inout SparkExitRecord, hasConfirmedParents: Bool, tip: UInt32?) async { + let outcome = await chain.broadcast(tx) + Logger.info("Spark exit broadcast \(tx.kind) \(tx.txid): \(outcome.summary)", context: "SparkExitDriver") + let isAccepted = outcome == .accepted + let isInputsMissing = if case .inputsMissing = outcome { + true + } else { + false + } + let isInputSpent: Bool? = hasConfirmedParents && isInputsMissing ? true : nil + let isUnpaid = SparkExitBroadcastClassifier.isUnpaid(outcome) + let isFeeChainFailure = hasConfirmedParents && (isUnpaid || isInputsMissing) + // Neither an unpaid child nor a rate under the mempool's is a refusal to rebuild over: the first is a broken fee + // chain, the second needs lower fees, not a build at the same rate. + let isFeeRateRefused = SparkExitBroadcastClassifier.isFeeRateTooLow(outcome) + let isRejected = if case .rejected = outcome { + !isUnpaid && !isFeeRateRefused + } else { + false + } + if let i = record.broadcastLog.firstIndex(where: { $0.generation == record.generation && $0.txid == tx.txid }) { + record.broadcastLog[i].acceptedAtHeight = isAccepted ? record.broadcastLog[i].acceptedAtHeight ?? tip : nil + record.broadcastLog[i].attempts += 1 + record.broadcastLog[i].rejections += isRejected ? 1 : 0 + record.broadcastLog[i].lastAttemptAt = now() + record.broadcastLog[i].lastOutcome = outcome.summary + record.broadcastLog[i].isAccepted = isAccepted + record.broadcastLog[i].isInputSpent = isInputSpent + record.broadcastLog[i].feeChainFailures = isFeeChainFailure ? (record.broadcastLog[i].feeChainFailures ?? 0) + 1 : nil + record.broadcastLog[i].isFeeRateRefused = isFeeRateRefused ? true : nil + } else { + record.broadcastLog.append(.init( + generation: record.generation, + txid: tx.txid, + attempts: 1, + rejections: isRejected ? 1 : 0, + lastAttemptAt: now(), + lastOutcome: outcome.summary, + isAccepted: isAccepted, + isInputSpent: isInputSpent, + feeChainFailures: isFeeChainFailure ? 1 : nil, + acceptedAtHeight: isAccepted ? tip : nil, + isFeeRateRefused: isFeeRateRefused ? true : nil + )) + } + } +} + +private extension RecoveryTransaction { + var isConfirmed: Bool { + if case .confirmed = status { + return true + } + return false + } +} + +extension SparkExitRecord { + /// The exit running its current generation, when that generation did not diverge: what a rebuild that would drop a + /// waiting leaf's signed steps keeps. Funding adopted for the rebuild and a bumped rate stay the exit's. + var validGeneration: SparkExitRecord? { + guard recovery != nil, isRedoRebuild != true else { return nil } + var valid = self + valid.phase = .running + valid.isRebuilding = false + valid.isUnverifiedAsk = nil + return valid + } + + /// What keeps the exit from moving, once rebuilding over its stuck steps stopped helping. + var surfacedRejection: String? { + isStalled ? stuckStep?.lastOutcome : nil + } + + /// A prepare of `selection` at the exit's fee rate, or at `feeRate`, funded the way the exit was started. + func prepareRequest(_ selection: ExitLeafSelection, feeRate: UInt64? = nil) -> PrepareRecoverFundsRequest { + PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRate ?? feeRateSatPerVbyte, + fundingKind: .p2wpkh, + fundingStrategy: cpfpFundingStrategy, + destination: destination, + selection: selection + ) + } +} + +/// When a step's relative timelocks mature, as the SDK's `spendable_at_height` computes it: each input's BIP68 block +/// lock counts from the confirmation height of the transaction that input spends. +enum SparkExitTimelock { + struct Lock: Equatable { + let parent: String + let blocks: UInt32 + } + + enum Maturity: Equatable { + /// No relative timelock. + case none + /// Spendable from this height. + case at(UInt32) + /// A parent's confirmation height is not known yet. + case unknown + } + + /// The block locks of the step's inputs, from its hex; from `csvTimelockBlocks` over its parents when the hex + /// does not decode. Empty when it has no timelock. + static func locks(of tx: RecoveryTransaction) -> [Lock] { + guard let csv = tx.csvTimelockBlocks else { return [] } + return SparkExitTxid.relativeBlockLocks(hex: tx.txHex) ?? tx.dependsOn.map { Lock(parent: $0, blocks: csv) } + } + + static func maturity(of tx: RecoveryTransaction, locks: [Lock], heights: [String: UInt32]) -> Maturity { + guard tx.csvTimelockBlocks != nil else { return .none } + // The SDK leaves a step with a timelock but no lock it can count as unknown, and so does this. + guard !locks.isEmpty else { return .unknown } + var spendable: UInt32 = 0 + for lock in locks { + guard let height = heights[lock.parent] else { return .unknown } + spendable = max(spendable, height.addingReportingOverflow(lock.blocks).partialValue) + } + return .at(spendable) + } + + /// A transaction is accepted while it would be valid in the next block. + static func isMature(_ spendableAtHeight: UInt32, tip: UInt32) -> Bool { + UInt64(tip) + 1 >= UInt64(spendableAtHeight) + } +} + +/// Transaction ids from raw hex, for the CPFP children the SDK returns without one. +enum SparkExitTxid { + static func cpfpChildTxids(_ recovery: RecoverFundsResponse) -> [String] { + recovery.transactions.compactMap { $0.cpfpTxHex.flatMap(txid(hex:)) } + } + + /// The exit's own transactions that pay the funding script: the CPFP children's change and the fan-out's split. + static func chainedTxids(_ recovery: RecoverFundsResponse) -> [String] { + cpfpChildTxids(recovery) + recovery.transactions.filter { $0.kind == .fanOut }.map(\.txid) + } + + /// The outpoints a serialized transaction spends; nil when the hex does not decode. + static func inputOutpoints(hex: String) -> [(txid: String, vout: UInt32)]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + return reader.inputs()?.inputs.map { ($0.txid, $0.vout) } + } + + /// The txids a serialized transaction spends; nil when the hex does not decode. + static func inputTxids(hex: String) -> [String]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + return reader.inputs()?.inputs.map(\.txid) + } + + /// Each input's BIP68 block lock with the txid it spends; nil when the hex does not decode. Version 1 + /// transactions and inputs whose sequence disables the lock or counts time carry none. + static func relativeBlockLocks(hex: String) -> [SparkExitTimelock.Lock]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + guard let parsed = reader.inputs() else { return nil } + guard parsed.version >= 2 else { return [] } + return parsed.inputs.compactMap { input in + let sequence = input.sequence + let isDisabled = sequence & (1 << 31) != 0 + let isTime = sequence & (1 << 22) != 0 + let blocks = sequence & 0xFFFF + guard !isDisabled, !isTime, blocks > 0 else { return nil } + return SparkExitTimelock.Lock(parent: input.txid, blocks: blocks) + } + } + + /// Each output's value and script; nil when the hex does not decode. + static func outputs(hex: String) -> [(value: UInt64, script: Data)]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + return reader.outputs() + } + + /// The txid of a serialized transaction: double SHA-256 of it without witness data, byte-reversed. + static func txid(hex: String) -> String? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + guard let stripped = reader.strippedOfWitness() else { return nil } + let hash = Data(SHA256.hash(data: Data(SHA256.hash(data: stripped)))) + return Data(hash.reversed()).hex + } + + private struct Reader { + let bytes: [UInt8] + var position = 0 + + mutating func take(_ count: Int) -> [UInt8]? { + guard count >= 0, count <= bytes.count - position else { return nil } + defer { position += count } + return Array(bytes[position ..< position + count]) + } + + mutating func varInt() -> (value: Int, raw: [UInt8])? { + guard let first = take(1)?.first else { return nil } + let width = switch first { + case 0xFD: 2 + case 0xFE: 4 + case 0xFF: 8 + default: 0 + } + guard width > 0 else { return (Int(first), [first]) } + guard let rest = take(width) else { return nil } + let value = rest.enumerated().reduce(UInt64(0)) { $0 | UInt64($1.element) << (8 * $1.offset) } + // A count no transaction can hold reads as malformed rather than trapping. + guard value <= UInt64(bytes.count) else { return nil } + return (Int(value), [first] + rest) + } + + mutating func inputs() -> (version: Int32, inputs: [(txid: String, vout: UInt32, sequence: UInt32)])? { + guard let versionBytes = take(4) else { return nil } + let version = Int32(bitPattern: littleEndian(versionBytes)) + if bytes.count > 5, bytes[4] == 0, bytes[5] == 1 { + position += 2 + } + guard let count = varInt() else { return nil } + var inputs: [(txid: String, vout: UInt32, sequence: UInt32)] = [] + for _ in 0 ..< count.value { + guard let txid = take(32), let vout = take(4), let script = varInt(), take(script.value) != nil, let sequence = take(4) else { + return nil + } + inputs.append((Data(txid.reversed()).hex, littleEndian(vout), littleEndian(sequence))) + } + return (version, inputs) + } + + mutating func outputs() -> [(value: UInt64, script: Data)]? { + guard inputs() != nil, let count = varInt() else { return nil } + var outputs: [(value: UInt64, script: Data)] = [] + for _ in 0 ..< count.value { + guard let value = take(8), let script = varInt(), let scriptBytes = take(script.value) else { return nil } + outputs.append((value.enumerated().reduce(0) { $0 | UInt64($1.element) << (8 * $1.offset) }, Data(scriptBytes))) + } + return outputs + } + + private func littleEndian(_ bytes: [UInt8]) -> UInt32 { + bytes.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * $1.offset) } + } + + mutating func strippedOfWitness() -> Data? { + guard let version = take(4) else { return nil } + var out = version + let isSegwit = bytes.count > 5 && bytes[4] == 0 && bytes[5] == 1 + if isSegwit { + position += 2 + } + guard let inputs = varInt() else { return nil } + out += inputs.raw + for _ in 0 ..< inputs.value { + guard let outpoint = take(36), let script = varInt(), let scriptBytes = take(script.value), let sequence = take(4) else { return nil } + out += outpoint + script.raw + scriptBytes + sequence + } + guard let outputs = varInt() else { return nil } + out += outputs.raw + for _ in 0 ..< outputs.value { + guard let value = take(8), let script = varInt(), let scriptBytes = take(script.value) else { return nil } + out += value + script.raw + scriptBytes + } + if isSegwit { + for _ in 0 ..< inputs.value { + guard let items = varInt() else { return nil } + for _ in 0 ..< items.value { + guard let item = varInt(), take(item.value) != nil else { return nil } + } + } + } + guard let lockTime = take(4) else { return nil } + return Data(out + lockTime) + } + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift new file mode 100644 index 000000000..e28f6b374 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -0,0 +1,198 @@ +import BitkitCore +import Combine +import Foundation + +/// A funding address an earlier exit left coins at, or may still receive them at, with the path that spends it. Kept +/// apart from the record, so a later exit can start and replace the record while the address stays reachable. +struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { + enum Kind: String, Codable { + /// Fee funding left at the funding address for good: change too small to send, or a refused sweep's. + case keptChange + /// The funding address of a cancelled exit that was forgotten: a late payment can still land there. + case cancelledFunding + } + + let address: String + let path: String + let kind: Kind + /// What the address holds, when known. + var sats: UInt64? + let date: Date + + var id: String { + address + } + + /// About four weeks: how long a cancelled exit's address is still read for a late payment. + static let watchSeconds: TimeInterval = 28 * 24 * 3600 + + /// Read in the foreground for what it holds: kept change always, so change spent by hand shows as spent, and a + /// cancelled exit's address younger than `watchSeconds`, for a late payment. + func isWatched(now: Date) -> Bool { + kind == .keptChange || now.timeIntervalSince(date) < Self.watchSeconds + } + + /// The leftover an ended exit kept at its funding address. + static func keptChange(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? { + guard record.isLeftoverKept, let address = record.funding.first?.address else { return nil } + return SparkExitEarlierAddress( + address: address, + path: SparkExitFundingKey.path(index: record.fundingIndex, network: network), + kind: .keptChange, + sats: record.leftover.reduce(0) { $0 + $1.valueSats }, + date: date + ) + } + + /// The funding address of a cancelled exit being forgotten. + static func cancelledFunding(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? { + guard let address = record.funding.first?.address else { return nil } + return SparkExitEarlierAddress( + address: address, + path: SparkExitFundingKey.path(index: record.fundingIndex, network: network), + kind: .cancelledFunding, + sats: nil, + date: date + ) + } + + /// The entries with what each watched address holds now; one that can't be read keeps what it showed. + static func refreshed( + _ entries: [SparkExitEarlierAddress], + now: Date, + utxos: (String) async throws -> [EsploraUtxo] + ) async -> [SparkExitEarlierAddress] { + await updating(entries, balances: balances(entries, now: now, utxos: utxos)) + } + + /// What each watched address that could be read holds now, by address. + static func balances( + _ entries: [SparkExitEarlierAddress], + now: Date, + utxos: (String) async throws -> [EsploraUtxo] + ) async -> [String: UInt64] { + var balances: [String: UInt64] = [:] + for entry in entries where entry.isWatched(now: now) { + guard let held = try? await utxos(entry.address) else { continue } + balances[entry.address] = held.reduce(0) { $0 + $1.value } + } + return balances + } + + /// The entries with the `balances` read; the others keep what they showed. + static func updating(_ entries: [SparkExitEarlierAddress], balances: [String: UInt64]) -> [SparkExitEarlierAddress] { + entries.map { entry in + guard let sats = balances[entry.address] else { return entry } + var updated = entry + updated.sats = sats > 0 ? sats : nil + return updated + } + } +} + +/// The earlier exits' addresses of a wallet, across exits. A wipe deletes it with the record. +struct SparkExitEarlierAddressStore { + /// Sends on every write of any wallet's list, so the metadata backup that carries them runs again. + static let savedPublisher = savedSubject.eraseToAnyPublisher() + private static let savedSubject = PassthroughSubject() + + let url: URL + + init(url: URL) { + self.url = url + } + + init(walletIndex: Int) { + url = Env.sparkExitEarlierAddresses(walletIndex: walletIndex) + } + + func load() throws -> [SparkExitEarlierAddress] { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return try decoder.decode([SparkExitEarlierAddress].self, from: Data(contentsOf: url)) + } + + /// Adds `entry` unless its address is listed already. Kept change at a cancelled exit's address, which a later exit + /// reused, replaces that entry, so it is read for good. + @discardableResult + func add(_ entry: SparkExitEarlierAddress) throws -> [SparkExitEarlierAddress] { + let stored = (try? load()) ?? [] + if let index = stored.firstIndex(where: { $0.address == entry.address }) { + guard entry.kind == .keptChange, stored[index].kind != .keptChange else { return stored } + var entries = stored + entries[index] = entry + try save(entries) + return entries + } + let entries = stored + [entry] + try save(entries) + return entries + } + + func save(_ entries: [SparkExitEarlierAddress]) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + try encoder.encode(entries).write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + Self.savedSubject.send() + } + + func delete() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} + +/// The earlier exit addresses in the metadata backup. +enum SparkExitEarlierAddressBackup { + /// The entries of `store`, keyed `walletKey`; nil with none, or when the list can't be read, which then never + /// blocks the rest of the metadata. + static func backup(_ store: SparkExitEarlierAddressStore, walletKey: String) -> [String: [SparkExitEarlierAddressBackupV1]]? { + guard let entries = try? store.load(), !entries.isEmpty else { return nil } + return [walletKey: entries.map(\.backup)] + } + + /// Lists each backed-up address of `network` in its wallet's store, next to the entries already there. + static func restore( + _ backedUp: [String: [SparkExitEarlierAddressBackupV1]]?, + network: String, + store: (Int) -> SparkExitEarlierAddressStore + ) { + for (key, entries) in backedUp ?? [:] { + let prefix = "\(network)/wallet" + guard key.hasPrefix(prefix), let walletIndex = Int(key.dropFirst(prefix.count)) else { continue } + let walletStore = store(walletIndex) + for entry in entries.compactMap(SparkExitEarlierAddress.init(backup:)) { + do { + try walletStore.add(entry) + } catch { + Logger.warn("Failed to restore the earlier Spark exit address: \(error)", context: "SparkExitEarlierAddressBackup") + } + } + } + } +} + +extension SparkExitEarlierAddress { + var backup: SparkExitEarlierAddressBackupV1 { + SparkExitEarlierAddressBackupV1( + address: address, + path: path, + kind: kind.rawValue, + atMillis: Int64((date.timeIntervalSince1970 * 1000).rounded()) + ) + } + + /// The backed-up entry, with what it holds read again later; nil for a kind this app does not know. + init?(backup: SparkExitEarlierAddressBackupV1) { + guard let kind = Kind(rawValue: backup.kind) else { return nil } + self.init( + address: backup.address, + path: backup.path, + kind: kind, + sats: nil, + date: Date(timeIntervalSince1970: Double(backup.atMillis) / 1000) + ) + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift new file mode 100644 index 000000000..7657d0139 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -0,0 +1,756 @@ +import BitkitCore +import BreezSdkSpark +import CryptoKit +import Foundation +import LDKNode + +/// The key that pays a Spark exit's CPFP fees. It derives from the wallet seed on an account no wallet of Bitkit's +/// derives, so ldk-node never spends the funding UTXO or the CPFP change chained back to it. +enum SparkExitFundingKey { + static let account: UInt32 = 7797 + + struct Derived { + let address: String + /// Compressed public key, hex, as `CpfpInput.p2wpkh` takes it. + let pubkey: String + let secretKey: Data + } + + enum KeyError: Error, Equatable { + case invalidWif + } + + static func path(index: UInt32, network: BitkitCore.Network) -> String { + let coin = network == .bitcoin ? 0 : 1 + return "m/84'/\(coin)'/\(account)'/0/\(index)" + } + + static func derive(index: UInt32, mnemonic: String, passphrase: String?, network: BitkitCore.Network) throws -> Derived { + let path = path(index: index, network: network) + let address = try deriveBitcoinAddress(mnemonicPhrase: mnemonic, derivationPathStr: path, network: network, bip39Passphrase: passphrase) + let wif = try derivePrivateKey(mnemonicPhrase: mnemonic, derivationPathStr: path, network: network, bip39Passphrase: passphrase) + return try Derived(address: address.address, pubkey: address.publicKey, secretKey: secretKey(fromWif: wif)) + } + + /// The 32-byte secret of a compressed-key WIF: base58check, version byte first, `0x01` compression flag last. + static func secretKey(fromWif wif: String) throws -> Data { + guard let decoded = Base58.decode(wif), decoded.count == 38 else { throw KeyError.invalidWif } + let payload = decoded.prefix(34) + let checksum = Data(SHA256.hash(data: Data(SHA256.hash(data: payload)))).prefix(4) + guard Data(decoded.suffix(4)) == checksum, decoded[decoded.startIndex + 33] == 0x01 else { throw KeyError.invalidWif } + return Data(payload.dropFirst().prefix(32)) + } +} + +enum Base58 { + private static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func decode(_ string: String) -> Data? { + var bytes: [UInt8] = [] + for character in string { + guard let digit = alphabet.firstIndex(of: character) else { return nil } + var carry = digit + for i in bytes.indices.reversed() { + carry += Int(bytes[i]) * 58 + bytes[i] = UInt8(carry & 0xFF) + carry >>= 8 + } + while carry > 0 { + bytes.insert(UInt8(carry & 0xFF), at: 0) + carry >>= 8 + } + } + let leadingZeros = string.prefix { $0 == "1" }.count + return Data(repeating: 0, count: leadingZeros) + Data(bytes) + } +} + +/// What an exit costs and returns, from a `prepareRecoverFunds` quote. +struct SparkExitQuote: Equatable { + let prepared: PrepareRecoverFundsResponse + /// The values of the leaves an older state claimed that the operators still list, which the quote leaves out. + var lostLeafSats: [UInt64] = [] + + /// Prepares every leaf less those an older state claimed in an earlier exit, which the operators still list. The + /// rest is prepared again on its own, so the amount, fees and funding leave the lost leaves out. With none left + /// the quote has no leaf, as when no leaf can be exited. + static func prepare( + _ recovering: SparkExitRecovering, + feeRateSatPerVbyte: UInt64, + destination: String, + lostLeafIds: Set, + fundingStrategy: CpfpFundingStrategy = .perNode + ) async throws -> SparkExitQuote { + func request(_ selection: ExitLeafSelection) -> PrepareRecoverFundsRequest { + PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRateSatPerVbyte, + fundingKind: .p2wpkh, + fundingStrategy: fundingStrategy, + destination: destination, + selection: selection + ) + } + let all = try await recovering.prepare(request(.all)) + let lostLeafSats = all.leaves.filter { lostLeafIds.contains($0.leafId) }.map(\.valueSats) + + all.skipped.filter { lostLeafIds.contains($0.leafId) }.map(\.valueSats) + guard all.leaves.contains(where: { lostLeafIds.contains($0.leafId) }) else { + return SparkExitQuote(prepared: all, lostLeafSats: lostLeafSats) + } + let remaining = all.leaves.map(\.leafId).filter { !lostLeafIds.contains($0) } + var prepared = all + if remaining.isEmpty { + prepared.leaves = [] + prepared.recoverableValueSats = 0 + prepared.totalFeeSats = 0 + prepared.cooperativeFeeSats = 0 + prepared.cpfpFeeSats = 0 + prepared.fanoutFeeSats = 0 + prepared.refundFeeSats = 0 + prepared.sweepFeeSats = 0 + prepared.funding = nil + } else { + prepared = try await recovering.prepare(request(.specific(leafIds: remaining))) + } + // `Specific` lists only its own leaves as skipped; the leaves `All` could not exit stay left behind. + let listed = Set(prepared.skipped.map(\.leafId)) + prepared.skipped += all.skipped.filter { !lostLeafIds.contains($0.leafId) && !listed.contains($0.leafId) } + return SparkExitQuote(prepared: prepared, lostLeafSats: lostLeafSats) + } + + /// The value of the leaves the exit takes on-chain. + var exitSats: UInt64 { + prepared.recoverableValueSats + } + + var networkFeeSats: UInt64 { + prepared.totalFeeSats + } + + /// What savings gains overall: the exited value less every fee, those paid from the fee funding included. + var receiveSats: UInt64 { + exitSats.saturatingSub(networkFeeSats) + } + + /// The on-chain payment that funds the CPFP fees, or 0 when every leaf is recovered cooperatively. One payment, + /// from savings or another wallet: the SDK splits it into one output per fee-bumped transaction (`fundedSteps`). + var fundingSats: UInt64 { + prepared.funding?.singleUtxoSats ?? 0 + } + + /// The transactions the funding pays a fee for, each from its own share. + var fundedSteps: Int { + prepared.funding?.perBranch.count ?? 0 + } + + /// What the sweep and the cooperative recoveries pay to savings: `receiveSats` plus the fee funding the exit + /// did not spend, which the sweep returns. + var incomingSats: UInt64 { + receiveSats + fundingSats + } + + var leftBehindSats: UInt64 { + prepared.skipped.reduce(0) { $0 + $1.valueSats } + } + + var leafIds: [String] { + prepared.leaves.map(\.leafId) + } + + var leafTotalSats: UInt64 { + prepared.leaves.reduce(0) { $0 + $1.valueSats } + } + + /// Each leaf's value, kept with the exit for a leaf its first build leaves behind. + var leafSats: [String: UInt64] { + Dictionary(prepared.leaves.map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + } + + /// Breez's rule: only exit when the value is more than its fees. + var isWorthExiting: Bool { + !prepared.leaves.isEmpty && exitSats > networkFeeSats + } +} + +/// The network's fee rates now, in sat/vB: for the next block, and for about half an hour. +struct SparkExitNetworkFees: Equatable { + let fastest: UInt64 + let halfHour: UInt64 + /// The slowest tier: what the leftover sweep pays, since the change can wait. + var economy: UInt64 = 1 +} + +/// A higher fee rate an exit can move to, and what moving costs: the funding the exit lacks at that rate (`extraSats`, +/// nil while it is priced or when it could not be) and, when savings pays it, that payment's own fee. +struct SparkExitFeeBumpOption: Equatable, Identifiable { + let speed: TransactionSpeed + let rate: UInt64 + var extraSats: UInt64? + var paymentFeeSats: UInt64? + + var id: UInt64 { + rate + } + + /// What leaves savings: the extra funding and its payment's fee. Nothing when the funding already covers the rate. + var totalSats: UInt64? { + guard let extraSats else { return nil } + guard extraSats > 0 else { return 0 } + return paymentFeeSats.map { extraSats + $0 } + } + + /// The rates a fee bump offers, each strictly above the exit's: replacements must pay a higher rate. Fast is the + /// next-block rate, or one above the exit's when the network's estimate is no higher; Normal the half-hour rate, + /// when it lies between the two. + static func options(exitRate: UInt64, fees: SparkExitNetworkFees) -> [SparkExitFeeBumpOption] { + let fast = max(fees.fastest, exitRate + 1) + var options = [SparkExitFeeBumpOption(speed: .fast, rate: fast)] + if fees.halfHour > exitRate, fees.halfHour < fast { + options.append(SparkExitFeeBumpOption(speed: .normal, rate: fees.halfHour)) + } + return options + } +} + +/// A local notification that brings the user back to an exit; `id` is stable across runs for the same step. +struct SparkExitReminder: Equatable { + let id: String + let fireAt: Date + var isFunding = false + var isLostLeaves = false + var isFailed = false + var isNotMoving = false + /// Planned without the chain tip, so its date is a guess that never takes the place of one planned with it. + var isEstimated = false +} + +/// What the exit shows about its fee funding while it lacks some. +enum SparkExitFundingState: Equatable { + /// Ask for `sats` more at the funding address, with its QR. `receivedSats` is the new funding already there. + case ask(sats: UInt64, receivedSats: UInt64 = 0) + /// Bitkit paid it from savings in `txid`, which has not confirmed yet. + case paid(txid: String) + /// A payment covering the shortfall reached the address, and the exit waits for it to confirm or to build. + case seen(txid: String) +} + +enum SparkExitMath { + /// The countdown target: the tip plus the blocks the longest path of steps left still needs, so the countdown + /// does not jump when a step's parent confirms and its own timelock starts. Without the tip, the latest block at + /// which a timelocked step can confirm. + static func claimableAtHeight(_ recovery: RecoverFundsResponse, tip: UInt32? = nil) -> UInt32? { + if let tip { + return blocksToFinish(recovery.transactions, tip: tip).map { tip.addingReportingOverflow($0).partialValue } + } + return recovery.transactions.compactMap { tx -> UInt32? in + if case let .waitingForTimelock(height) = tx.status { + return height + } + return nil + }.max() + } + + /// Blocks until the last step could confirm: the longest path through the steps left, each costing its remaining + /// timelock plus a block to confirm in (Glow's `blocksToFinish`). Nil once every step confirmed. The SDK lists + /// steps in broadcast order, so a parent always comes before what depends on it. + static func blocksToFinish(_ transactions: [RecoveryTransaction], tip: UInt32) -> UInt32? { + var after: [String: UInt32] = [:] + var longest: UInt32? + for tx in transactions { + let timelock: UInt32 + switch tx.status { + case .confirmed: + after[tx.txid] = 0 + continue + case .ready: + timelock = 0 + case let .waitingForTimelock(height?): + timelock = height > tip ? height - tip : 0 + default: + timelock = tx.csvTimelockBlocks ?? 0 + } + let blocks = (tx.dependsOn.compactMap { after[$0] }.max() ?? 0) + timelock + 1 + after[tx.txid] = blocks + longest = max(longest ?? 0, blocks) + } + // Everything confirmed: nothing left to wait for. + return longest + } + + /// Blocks after a step matures before the operators' watchtower may publish their own version. + static let operatorWindowBlocks: UInt32 = 50 + /// Blocks after a refund matures before an older state of the wallet, with a refund 100 blocks shorter, can + /// claim the leaf while the operators are down. + static let olderStateLeadBlocks: UInt32 = 100 + /// Blocks after a step matures before its reminder fires: well inside the smaller of the two windows above, and + /// time for a background run to publish it first. + static let reminderGraceBlocks: UInt32 = 12 + /// Below the OS's 64 pending notifications per app. + static let maxTimelockReminders = 20 + + /// The reminders an exit needs now. Each timelocked step not yet published gets one at its maturity plus a + /// grace; a step ready to publish gets one after the grace; needing fee funding or a step rejected repeatedly + /// reminds at once. While a rebuild is pending, one reminder past its due time replaces the steps' own. A step + /// published or confirmed needs none, so a background run that publishes a step drops its reminder before it fires. + static func reminders(_ record: SparkExitRecord, tip: UInt32?, now: Date, network: LDKNode.Network) -> [SparkExitReminder] { + let lost = record.lostLeaves ?? [] + var lostReminder = lost.isEmpty ? [] : [SparkExitReminder(id: "lost-\(lost.count)", fireAt: now, isLostLeaves: true)] + // An exit stopped because every leaf was lost is told by the lost reminder alone. + if case .failed = record.phase, !record.isEveryLeafLost, record.isCancelled != true { + lostReminder.append(SparkExitReminder(id: "failed", fireAt: now, isFailed: true)) + } + // A payment Bitkit already sent from savings, such as a higher fee's, needs no reminder to fund until the exit + // forgets it (gone from every host, or not adopted in time). + if case let .awaitingFunding(requiredSats) = record.phase { + guard record.fundingShortfallSats != nil, !record.isAwaitingOwnPayment else { return lostReminder } + // Started but never funded: Spark stays detached until the user funds or cancels. A savings payment that + // confirmed short is reminded about at once. + let remindAt = record.isFundingPaymentShort == true ? now : record.createdAt.addingTimeInterval(reminderSeconds(network: network)) + let fireAt = max(now, remindAt) + return lostReminder + [SparkExitReminder(id: "await-funding-\(requiredSats)", fireAt: fireAt, isFunding: true)] + } + var reminders = lostReminder + if case let .needsFunding(requiredSats) = record.phase { + if !record.isAwaitingOwnPayment { + reminders.append(SparkExitReminder(id: "funding-\(requiredSats)", fireAt: now, isFunding: true)) + } + // The live generation keeps going while the top-up comes, and so do its step reminders. + guard record.isDrivingWhileFunding else { return reminders } + } else { + guard case .running = record.phase else { return lostReminder } + } + guard let recovery = record.recovery?.response else { return reminders } + // Waiting for a leaf that may still move is the background runs' job, for up to a week. + if record.isWaitingForLeaves || record.isWaitingForOperators { + return reminders + } + let generation = record.generation + let blockSeconds = secondsUntil(height: 1, tip: 0, network: network) + let grace = TimeInterval(reminderGraceBlocks) * blockSeconds + // A diverged generation is never sent again, so its steps need no reminder: only a rebuild that does not come. + if record.isRebuilding { + let due = (record.lastBuildAt ?? now).addingTimeInterval(SparkExitDriver.rebuildInterval) + reminders.append(SparkExitReminder(id: "\(generation)-rebuild", fireAt: max(due, now).addingTimeInterval(grace))) + return reminders + } + if record.isStalled, let stuck = record.stuckStep { + reminders.append(SparkExitReminder(id: "rejected-\(generation)-\(stuck.txid)", fireAt: now, isNotMoving: true)) + } + // Accepted but not confirming: rebuilding would not help, so its reminder is planned once and never moved. + if let slow = record.underpricedStep(tip: tip) { + reminders.append(SparkExitReminder(id: "\(generation)-slow-\(slow.txid)", fireAt: now.addingTimeInterval(grace))) + } + let accepted = Set(record.broadcastLog.filter { $0.generation == generation && $0.isAccepted }.map(\.txid)) + var heights = Set() + var readyTxid: String? + for tx in recovery.transactions where !accepted.contains(tx.txid) { + switch tx.status { + case let .waitingForTimelock(height?): + heights.insert(height) + case .ready: + readyTxid = readyTxid ?? tx.txid + default: + break + } + } + for height in heights.sorted().prefix(maxTimelockReminders) { + let wait = tip.map { secondsUntil(height: height, tip: $0, network: network) } ?? reminderSeconds(network: network) + reminders.append(SparkExitReminder(id: "\(generation)-h\(height)", fireAt: now.addingTimeInterval(wait + grace), isEstimated: tip == nil)) + } + if let readyTxid { + reminders.append(SparkExitReminder(id: "\(generation)-ready-\(readyTxid)", fireAt: now.addingTimeInterval(grace))) + } + // Steps waiting for parents have no height yet, which only a run learns: remind once progress stalls. Any + // confirmation or accepted broadcast changes the id, which starts the wait again. + let confirmed = confirmedCount(recovery) + let isAhead = heights.contains { height in tip.map { height > $0 } ?? true } + if !isAhead, confirmed < recovery.transactions.count { + let id = "\(generation)-idle-\(confirmed)-\(accepted.count)" + reminders.append(SparkExitReminder(id: id, fireAt: now.addingTimeInterval(reminderSeconds(network: network)))) + } + return reminders + } + + /// What the exit's sweep and cooperative recoveries paid to its savings address, and the sweep's txid, once it + /// ended done. Nil before, or when no output to savings can be read. + /// Generations a Redo replaced count too, so a cooperative recovery that landed before it is included. + static func received(_ record: SparkExitRecord) -> (sats: UInt64, sweepTxid: String?)? { + guard record.phase == .done, let transactions = record.recovery?.response.transactions else { return nil } + let paid = (record.earlierReceivedSats ?? [:]).merging(paidToDestination(transactions, destination: record.destination)) { $1 } + let sats = paid.values.reduce(0, +) + guard sats > 0 else { return nil } + return (sats, transactions.first { $0.kind == .sweep }?.txid ?? record.landedSweepTxid) + } + + /// The exit's sweeps to savings: the recovery's, else the one that landed before a retry replaced it, and those of + /// generations a Redo replaced. + static func sweepTxids(_ record: SparkExitRecord) -> [String] { + let current = (record.recovery?.response.transactions ?? []).filter { $0.kind == .sweep }.map(\.txid) + var txids = current.isEmpty ? [record.landedSweepTxid].compactMap { $0 } : current + for txid in (record.earlierReceivedSats ?? [:]).keys.sorted() where !txids.contains(txid) { + txids.append(txid) + } + return txids + } + + /// What each sweep or cooperative recovery among `transactions` pays to `destination`, by txid. + static func paidToDestination(_ transactions: [RecoveryTransaction], destination: String) -> [String: UInt64] { + guard let script = try? SparkExitAddress.scriptPubKey(destination) else { return [:] } + var paid: [String: UInt64] = [:] + for tx in transactions where tx.kind == .sweep || tx.kind == .cooperative { + let sats = (SparkExitTxid.outputs(hex: tx.txHex) ?? []).filter { $0.script == script }.reduce(0) { $0 + $1.value } + if sats > 0 { + paid[tx.txid] = sats + } + } + return paid + } + + /// Whether every refund's timelock is behind it: none still waits for its parents or its height. + static func areTimelocksMatured(_ transactions: [RecoveryTransaction]) -> Bool { + let refunds = transactions.filter { $0.kind == .refund } + return !refunds.isEmpty && !refunds.contains { + switch $0.status { + case .waitingForDependencies, .waitingForTimelock: true + default: false + } + } + } + + /// The active exit transfers no running exit owns: a record set aside as unreadable, or one written without its + /// transfer id before a crash, would otherwise count its amount as in transfer to savings for good. + static func orphanedTransferIds(_ activeTransfers: [Transfer], record: SparkExitRecord?) -> [String] { + let owned = record?.isActive == true ? record?.transferId : nil + return activeTransfers.filter { $0.type == .sparkExit && $0.id != owned }.map(\.id) + } + + /// The steps already in a block, for progress. + static func confirmedCount(_ recovery: RecoverFundsResponse) -> Int { + recovery.transactions.filter { + if case .confirmed = $0.status { + return true + } + return false + }.count + } + + /// UTXOs at the funding address that are new fee funding: neither a funding input yet nor an output any + /// generation of the exit made there, a CPFP child's change or a fan-out's split of the funding. + static func newFunding(_ utxos: [EsploraUtxo], record: SparkExitRecord) -> [EsploraUtxo] { + let known = Set(record.funding.compactMap { funding in funding.txid.map { "\($0):\(funding.vout ?? 0)" } }) + let chained = Set((record.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? []) + (record.earlierCpfpTxids ?? [])) + return utxos.filter { !known.contains("\($0.txid):\($0.vout)") && !chained.contains($0.txid) } + } + + /// The confirmed new funding, all of it, once it covers `requiredSats` together: the SDK spends every input it is + /// given, so several small payments fund the exit as well as one. `requiredSats` assumes one payment; each + /// further one adds its own input fee, `inputFeeSats`. + static func fundingTopUp(_ newFunding: [EsploraUtxo], requiredSats: UInt64, inputFeeSats: UInt64 = 0) -> [EsploraUtxo]? { + // Fewest coins, largest first; one worth no more than its own input fee stays for the leftover sweep. + let coins = newFunding.filter { $0.status.confirmed && $0.value > inputFeeSats }.sorted { $0.value > $1.value } + var taken: [EsploraUtxo] = [] + var total: UInt64 = 0 + for coin in coins { + taken.append(coin) + total += coin.value + if total >= requiredSats + inputFeeSats * UInt64(taken.count - 1) { + return taken + } + } + return nil + } + + /// A signed P2WPKH input: each payment to the funding address is one more input for the build to pay for. + static let inputVbytes: UInt64 = 68 + + /// The fee one more funding input adds at the exit's rate (Glow `exitFee.ts`). + static func inputFeeSats(_ record: SparkExitRecord) -> UInt64 { + inputVbytes * record.feeRateSatPerVbyte + } + + /// The funding total a rebuild asks for once the SDK refused it with `InsufficientCpfpFunds(requiredSats)`. + /// `requiredSats` is what all the given inputs must hold together now, and what they hold now is the confirmed + /// value at the funding address apart from new funding (spent inputs live on as chained CPFP change there). + /// The phase counts against `fundedSats`, so the shortfall it shows is `requiredSats` less what the inputs hold. + /// The SDK priced the inputs it had, so the payment still to come adds its own input fee. + static func neededFunding(requiredSats: UInt64, record: SparkExitRecord, utxos: [EsploraUtxo]?) -> UInt64 { + guard let utxos else { return max(requiredSats, record.fundedSats) + inputFeeSats(record) } + let fresh = Set(newFunding(utxos, record: record).map { "\($0.txid):\($0.vout)" }) + let held = utxos.filter { $0.status.confirmed && !fresh.contains("\($0.txid):\($0.vout)") }.reduce(0) { $0 + $1.value } + return record.fundedSats + max(requiredSats.saturatingSub(held), 1) + inputFeeSats(record) + } + + /// The funding state while the exit lacks fee funding, nil once it lacks none. A payment Bitkit made from savings + /// or one seen unconfirmed is waited for rather than asked for again; only what they leave short is asked for. + static func fundingState(_ record: SparkExitRecord, utxos: [EsploraUtxo]?) -> SparkExitFundingState? { + guard let shortfall = record.fundingShortfallSats else { return nil } + // Paid until it confirms or the exit adopted it; adopted, a later rebuild may have spent it already. + if let paid = record.fundingPaymentTxid, !record.funding.contains(where: { $0.txid == paid }), + !(utxos ?? []).contains(where: { $0.txid == paid && $0.status.confirmed }) + { + return .paid(txid: paid) + } + let fresh = utxos.map { newFunding($0, record: record) } ?? [] + let seenSats = fresh.reduce(0) { $0 + $1.value } + let total = fundingTotal(shortfall: shortfall, payments: fresh.count, receivedSats: seenSats, inputFeeSats: inputFeeSats(record)) + // Covered by confirmed funding alone, the next tick builds. + if seenSats >= total.covered, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first { + return .seen(txid: pending.txid) + } + return .ask(sats: max(total.toSend.saturatingSub(seenSats), fundingDustSats), receivedSats: seenSats) + } + + /// The one rule both funding screens state: the funding is priced for one payment, and every payment beyond it, + /// the one still to send included, adds its own input fee (68 vB at the exit's rate). `covered` is what the + /// payments seen must hold to build; `toSend` the full total to ask for while they hold less, which the + /// partial-payment hint names as its target. What is left to send is never asked under the 294-sat dust limit. + static func fundingTotal(shortfall: UInt64, payments: Int, receivedSats: UInt64, inputFeeSats: UInt64) -> (covered: UInt64, toSend: UInt64) { + let covered = shortfall + inputFeeSats * UInt64(max(0, payments - 1)) + return (covered, receivedSats >= covered || payments == 0 ? covered : covered + inputFeeSats) + } + + /// The total the Progress top-up's partial hint names: `fundingTotal`'s `toSend` for what reached the address. + static func fundingAskTotal(_ record: SparkExitRecord, utxos: [EsploraUtxo]?) -> UInt64? { + guard let shortfall = record.fundingShortfallSats, let utxos else { return nil } + let fresh = newFunding(utxos, record: record) + return fundingTotal( + shortfall: shortfall, + payments: fresh.count, + receivedSats: fresh.reduce(0) { $0 + $1.value }, + inputFeeSats: inputFeeSats(record) + ).toSend + } + + /// The least a P2WPKH funding output can hold: nodes refuse a payment below it. + static let fundingDustSats: UInt64 = 294 + + /// The lowest rate nodes relay. + static let minRelayFeeRate: UInt64 = 1 + + /// The leftover sweep's rate: the network's economy rate, never above the exit's own and never under the relay + /// floor. Per-node funding leaves each step's change of at least 294 at the funding address; at the exit's rate + /// the sweep can cost more per input than the input holds. + static func sweepFeeRate(economy: UInt64?, exitRate: UInt64) -> UInt64 { + max(minRelayFeeRate, min(economy ?? exitRate, exitRate)) + } + + /// The rate a sweep refused for its fee is built at next: what the node's minimum asks for (`min relay fee not + /// met, 450 < 674` is the fee paid and the least it takes, at `paidRate`), and at least one sat/vB more. + static func refusedSweepRate(_ refusal: String, paidRate: UInt64) -> UInt64 { + let bumped = max(paidRate, minRelayFeeRate - 1) + 1 + guard let match = refusal.firstMatch(of: #/(?:min relay fee|mempool min fee) not met[^0-9]*(\d+)\s*<\s*(\d+)/#), + let paid = UInt64(match.1), let least = UInt64(match.2), paid > 0 + else { return bumped } + // A host's number can be anything: one too big asks for more than any cap, which the caller applies. + let (scaled, isScaledOver) = least.multipliedReportingOverflow(by: max(paidRate, 1)) + let (rounded, isRoundedOver) = scaled.addingReportingOverflow(paid - 1) + guard !isScaledOver, !isRoundedOver else { return .max } + return max(rounded / paid, bumped) + } + + /// What a refused rate lifts the sweep to: never above the next block's rate or the exit's own, whichever is higher, + /// so a host's absurd ask never spends the leftover on fees. + static func refusedSweepFloor(_ refusedRate: UInt64, nextBlock: UInt64?, exitRate: UInt64) -> UInt64 { + min(refusedRate, max(nextBlock ?? 0, exitRate)) + } + + /// The UTXOs worth sending at `rate`: each holds more than the fee of its own input. + static func economicalUtxos(_ utxos: [EsploraUtxo], rate: UInt64) -> [EsploraUtxo] { + utxos.filter { $0.value > inputVbytes * rate } + } + + /// What the exit says it still needs: the ask, or, while a payment is awaited or before the funding address was + /// read, the shortfall. Never under the dust limit. + static func neededSats(_ record: SparkExitRecord, state: SparkExitFundingState?) -> UInt64? { + if case let .ask(sats, _) = state { + return sats + } + return record.fundingShortfallSats.map { max($0, fundingDustSats) } + } + + /// The fee funding the pre-start screen counts towards once `payments` reached the funding address: the quote's + /// funding, priced for one payment, plus the input fee of each further one. `toSend` adds the input of the + /// payment still to come while what arrived falls short, as the Progress top-up does. + static func prestartFunding(fundingSats: UInt64, payments: Int, receivedSats: UInt64, feeRate: UInt64) -> (covered: UInt64, toSend: UInt64) { + fundingTotal(shortfall: fundingSats, payments: payments, receivedSats: receivedSats, inputFeeSats: inputVbytes * feeRate) + } + + /// What a partial payment leaves to send, at least the dust limit: nil before any new funding arrives and once + /// it covers `requiredSats`. + static func partialFundingSats(receivedSats: UInt64, requiredSats: UInt64) -> UInt64? { + receivedSats > 0 && receivedSats < requiredSats ? max(requiredSats - receivedSats, fundingDustSats) : nil + } + + /// Unused funding addresses a scan reads past the last used one before it stops. + static let fundingScanGapLimit: UInt32 = 20 + /// The most funding addresses one scan reads from where it starts, even when a host reports every one used. + static let fundingScanMaxAddresses: UInt32 = 1000 + + /// The funding index past the last one `isUsed` reports used, read from `start` until `fundingScanGapLimit` unused + /// ones follow it: from 0 for a wallet that kept no index, such as one restored from the phrase or an older backup, + /// and from a restored index, which can predate the last exit's funding. + static func scanNextFundingIndex(from start: UInt32 = 0, isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 { + var next = start + var index = start + // In 64 bits, so a counter near the top of the index range ends the scan instead of overflowing. + let limit = min(UInt64(start) + UInt64(fundingScanMaxAddresses), UInt64(UInt32.max)) + while UInt64(index) < UInt64(next) + UInt64(fundingScanGapLimit), UInt64(index) < limit { + if try await isUsed(index) { + next = index + 1 + } + index += 1 + } + return next + } + + /// The funding key index of a new exit: past the counter the last start left and every index the stored record + /// used. Records from before per-exit indexes used 0. + static func nextFundingIndex(counter: Int, record: SparkExitRecord?) -> UInt32 { + let afterRecord = record?.funding.map(\.index).max().map { $0 + 1 } ?? 0 + return max(UInt32(clamping: max(0, counter)), afterRecord) + } + + /// What stays on Spark once a quote's exit took its leaves: the balance beyond them, skipped leaves included, less + /// the lost leaves it still counts until a sync drops them. Nil when nothing. + static func outsideSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? { + let outside = beyondLostLeaves(sparkBalanceSats.saturatingSub(quote.leafTotalSats), lostLeafSats: quote.lostLeafSats) + return outside > 0 ? outside : nil + } + + /// The Spark balance a quote cannot exit: the leaves it skips, and what the balance holds beyond every leaf the + /// SDK lists, such as a counter-swap still unclaimed. Nil when there is none. + static func unexitableSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? { + let listed = quote.leafTotalSats + quote.leftBehindSats + let beyond = beyondLostLeaves(sparkBalanceSats.saturatingSub(listed), lostLeafSats: quote.lostLeafSats) + let unexitable = quote.leftBehindSats + beyond + return unexitable > 0 ? unexitable : nil + } + + /// `sats` less the lost leaves it still holds. Spark's balance keeps a leaf an older state claimed until a later + /// sync drops it, so each lost leaf, largest first, is taken out only while what is left still covers it. + static func beyondLostLeaves(_ sats: UInt64, lostLeafSats: [UInt64]) -> UInt64 { + lostLeafSats.sorted(by: >).reduce(sats) { left, lost in left >= lost ? left - lost : left } + } + + /// Whether the SDK refused a `Specific` selection because a leaf in it cannot be exited. + static func isUnexitableLeafError(_ message: String) -> Bool { + message.contains("cannot be exited") + } + + /// The leaf an untyped "Leaf X cannot be exited: …" error names, if it names one. + static func unexitableLeafId(_ message: String) -> String? { + guard let match = message.firstMatch(of: #/Leaf ([^\s:]+) cannot be exited/#) else { return nil } + return String(match.1) + } + + /// What the exit still brings to savings: what it expects, less what already reached savings, less the first fee + /// funding nobody paid yet (the expected amount counts it as coming back), less leaves an older state claimed, + /// which never arrive, and leaves the exit left behind, which stay on Spark. Only confirmed steps count as reached: the sweep is in the plan from + /// its first build. + static func inTransferSats(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> UInt64 { + let confirmed = (record.recovery?.response.transactions ?? []).filter { + guard case .confirmed = $0.status else { return false } + return true + } + let current = paidToDestination(confirmed, destination: record.destination) + let received = (record.earlierReceivedSats ?? [:]).merging(current) { $1 }.values.reduce(0, +) + var unpaid: UInt64 = 0 + if case let .awaitingFunding(requiredSats) = record.phase, record.fundingPaymentTxid == nil { + unpaid = requiredSats.saturatingSub(max(record.fundedSats, fundingSeenSats)) + } + return record.expectedSats.saturatingSub(received).saturatingSub(unpaid).saturatingSub(record.lostSats) + .saturatingSub(record.leftBehindSats) + } + + /// What stays on Spark while the exit runs: the Spark balance beyond the exit's leaves, such as a counter-swap + /// claimed late, or, once published leaves left the balance, what the start saw beyond them (`outsideSats`), + /// whichever is more. Nil when nothing is beyond them. + static func outsideExitSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { + guard record.isActive else { return nil } + let live = staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) ?? 0 + // The start's snapshot predates any leaf the exit left behind, which stays on Spark too. + let outside = max(live, (record.outsideSats ?? 0) + record.leftBehindSats) + return outside > 0 ? outside : nil + } + + /// What the Spark balance holds beyond the exit's leaves, in any phase: what a failed exit leaves on Spark. Lost + /// leaves count only while the balance still holds them (`beyondLostLeaves`); a leaf the exit left behind stays on + /// Spark, so it counts here, not in the transfer. + static func staysOnSparkSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { + guard let leafTotal = record.leafTotalSats else { return nil } + let liveLeafTotal = leafTotal.saturatingSub(record.lostSats).saturatingSub(record.leftBehindSats) + guard sparkBalanceSats > liveLeafTotal else { return nil } + let stays = beyondLostLeaves(sparkBalanceSats - liveLeafTotal, lostLeafSats: (record.lostLeaves ?? []).map(\.valueSats)) + return stays > 0 ? stays : nil + } + + /// About how long until `height`: 10-minute blocks, and Spark's regtest mines one every ~30 seconds. + static func secondsUntil(height: UInt32, tip: UInt32, network: LDKNode.Network) -> TimeInterval { + let blockSeconds: TimeInterval = network == .regtest ? 30 : 600 + return TimeInterval(BlockTimeHelpers.blocksRemaining(until: height, currentHeight: tip)) * blockSeconds + } + + /// How far out to remind about a timelocked step when the tip could not be read, and about an exit whose steps + /// wait for parents with no progress. + static func reminderSeconds(network: LDKNode.Network) -> TimeInterval { + network == .regtest ? 10 * 60 : 4 * 60 * 60 + } + + /// Savings can pay the fee funding when Spark reads Bitkit's chain, which the payment goes out on + /// (`readsBitkitChain`), and its spendable balance covers the funding and the payment's own fee. + static func canFundFromSavings(spendableSats: UInt64, fundingSats: UInt64, paymentFeeSats: UInt64, readsBitkitChain: Bool) -> Bool { + readsBitkitChain && fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats) + } + + /// Savings can pay a higher fee's `totalSats` (its extra funding and that payment's fee) on the same terms: Spark + /// reads Bitkit's chain and the spendable balance covers it. A rate with nothing to pay needs no payment. + static func canPayFeeBump(totalSats: UInt64?, spendableSats: UInt64, readsBitkitChain: Bool) -> Bool { + guard let totalSats else { return false } + return totalSats == 0 || (readsBitkitChain && spendableSats >= totalSats) + } +} + +/// Each wallet's next exit funding index in the metadata backup, keyed `/wallet` as bitkit-android keys its +/// `sparkExitNextFundingIndexes`, so a backup from either app restores on the other. +enum SparkExitFundingIndexes { + private static let defaultsPrefix = "sparkExitNextFundingIndex-" + + /// How the metadata backup keys a wallet's exit state, as bitkit-android does. + static func walletKey(network: String, walletIndex: Int) -> String { + "\(network)/wallet\(walletIndex)" + } + + /// Set when a restore brought the counter back, so the exit looks once for funding addresses that still hold coins. + static func recoveryPendingKey(network: String, walletIndex: Int) -> String { + "sparkExitFundingRecoveryPending-\(network)-\(walletIndex)" + } + + /// The counter the last exit start left, per network and wallet. + static func defaultsKey(network: String, walletIndex: Int) -> String { + "\(defaultsPrefix)\(network)-\(walletIndex)" + } + + /// The counters kept in `defaults`, nil with none. + static func backup(_ defaults: UserDefaults) -> [String: Int]? { + let kept = defaults.dictionaryRepresentation().reduce(into: [String: Int]()) { kept, entry in + guard entry.key.hasPrefix(defaultsPrefix), let index = entry.value as? Int else { return } + let rest = entry.key.dropFirst(defaultsPrefix.count) + guard let dash = rest.lastIndex(of: "-"), let wallet = Int(rest[rest.index(after: dash)...]) else { return } + kept["\(rest[.. 0 || isLeftoverKept) && isResultSeen != true + } + + /// The fee change stays at the funding address for good, shown with the path that spends it. + var isChangeKept: Bool { + !leftover.isEmpty && leftoverSweep?.isDustKept == true + } + + /// The leftover stays at the funding address for good: change too small to send, or a sweep refused for a reason + /// other than its fee, which nothing retries. + var isLeftoverKept: Bool { + isChangeKept || (!leftover.isEmpty && leftoverSweep?.rejection != nil && !isLeftoverSweepPending) + } + + /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to + /// savings, or it failed, lost leaves or kept its leftover at the funding address and the user has not seen that + /// yet. A kept leftover's address and path stay in the earlier exit addresses (`SparkExitEarlierAddress`), so a + /// later exit may replace this record. + var showsResult: Bool { + needsDriving || hasUnseenResult + } + + /// The leaves builds select: the quote's, less those an older state claimed and those left behind. + var exitLeafIds: [String] { + let dropped = Set((lostLeaves ?? []).map(\.leafId) + (leftBehindLeaves ?? []).map(\.leafId)) + return leafIds.filter { !dropped.contains($0) } + } + + var leftBehindSats: UInt64 { + (leftBehindLeaves ?? []).reduce(0) { $0 + $1.valueSats } + } + + /// Every leaf of the exit was claimed by an older state, none left behind. + var isEveryLeafLost: Bool { + let lost = Set((lostLeaves ?? []).map(\.leafId)) + return !lost.isEmpty && leafIds.allSatisfy(lost.contains) + } + + var lostSats: UInt64 { + (lostLeaves ?? []).reduce(0) { $0 + $1.valueSats } + } + + /// The strikes of the steps a rebuild into `built` reproduces after their input was spent in this generation. + func strikes(rebuiltInto built: RecoverFundsResponse) -> [String: Int] { + let spent = Set(broadcastLog.filter { $0.generation == generation && $0.isInputSpent == true }.map(\.txid)) + var strikes: [String: Int] = [:] + for tx in built.transactions where spent.contains(tx.txid) { + strikes[tx.txid] = (spentStepStrikes?[tx.txid] ?? 0) + 1 + } + return strikes + } + + /// The funding key's index, which every funding entry of one exit shares. + var fundingIndex: UInt32 { + funding.first?.index ?? 0 + } + + /// Whether the driver still has work: the exit runs, its leftover fee funding is on its way to savings, or a + /// savings payment to the funding address is still to arrive and join that leftover. + var needsDriving: Bool { + isActive || isLeftoverSweepPending || isEndedPaymentPending + } + + /// The exit ended while a savings payment Bitkit sent to its funding address had not arrived: it is followed + /// until it lands (and joins the leftover) or the own-payment expiry forgets it. + var isEndedPaymentPending: Bool { + guard !isActive, let paid = fundingPaymentTxid else { return false } + return !funding.contains { $0.txid == paid } && !leftover.contains { $0.txid == paid } + } + + var fundingInputs: [CpfpInput] { + funding.compactMap(\.input) + } + + var fundedSats: UInt64 { + funding.reduce(0) { $0 + ($1.valueSats ?? 0) } + } + + /// What the funding address still has to receive before the exit can build, or nil when nothing is missing. + var fundingShortfallSats: UInt64? { + guard let required = phase.requiredFundingSats else { return nil } + let shortfall = required.saturatingSub(fundedSats) + return shortfall > 0 ? shortfall : nil + } + + /// Starts a new generation from a fresh build, forgetting what was broadcast for the previous one. + mutating func adopt(_ built: RecoverFundsResponse, at date: Date = Date()) { + if let recovery { + let txids = SparkExitTxid.chainedTxids(recovery.response) + if !txids.isEmpty { + earlierCpfpTxids = (earlierCpfpTxids ?? []) + txids + } + let landed = SparkExitMath.paidToDestination(recovery.response.transactions.filter { + if case .confirmed = $0.status { + return true + } + return false + }, destination: destination) + if !landed.isEmpty { + earlierReceivedSats = (earlierReceivedSats ?? [:]).merging(landed) { $1 } + } + if let sweep = recovery.response.transactions.first(where: { $0.kind == .sweep }), case .confirmed = sweep.status { + landedSweepTxid = sweep.txid + } + } + generation += 1 + recovery = StoredRecovery(built) + broadcastLog = [] + phase = .running + isRebuilding = false + lastBuildAt = date + failedLeafCount = built.failed.count + if isRebuiltFromChain == true { + rebuiltFromChainConfirmedCount = SparkExitMath.confirmedCount(built) + } + isRedoRebuild = nil + isUnverifiedAsk = nil + isFeeBumpPending = nil + feeBumpBuiltAtHeight = nil + } + + /// Ends the rebuilt-from-chain note once the rebuilt exit moved: a step confirmed beyond those already in a block. + mutating func noteProgressAfterRebuildFromChain(_ checked: RecoverFundsResponse) { + guard isRebuiltFromChain == true, let count = rebuiltFromChainConfirmedCount, SparkExitMath.confirmedCount(checked) > count else { + return + } + isRebuiltFromChain = nil + rebuiltFromChainConfirmedCount = nil + } + + /// Whether a build that found nothing to build is waiting for a leaf that may still move. + var isWaitingForLeaves: Bool { + retrySince != nil + } + + /// The SDK strategy for `fundingStrategy`. + var cpfpFundingStrategy: CpfpFundingStrategy { + fundingStrategy == Self.perNodeStrategy ? .perNode : .perBranch + } + + static let perNodeStrategy = "perNode" + + /// The exit's own steps all landed and it waits for the operators to co-sign leaves they moved on-chain. + var isRetryingFailedLeaves: Bool { + isActive && failedLeafRetrySince != nil + } + + /// Leaves are still on their way to savings outside the running recovery's steps: cooperative ones the operators + /// could not co-sign (the recovery lists them, or their retry runs), or ones a build skipped while they may move. + var hasLeavesOutstanding: Bool { + isRetryingFailedLeaves || (isActive && (!(recovery?.response.failed.isEmpty ?? true) || !(waitingLeafIds ?? []).isEmpty)) + } + + /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send or + /// confirm, so a retry whose steps still confirm keeps their reminders. + var isWaitingForOperators: Bool { + guard isRetryingFailedLeaves, let recovery = recovery?.response, !recovery.failed.isEmpty else { return false } + return recovery.transactions.allSatisfy { + if case .confirmed = $0.status { + return true + } + return false + } + } + + var failedLeavesSats: UInt64 { + (failedLeaves ?? []).reduce(0) { $0 + $1.valueSats } + } + + /// Takes a higher fee rate for the steps still to confirm. With `extraSats` to come at the funding address the + /// exit waits for it as for a top-up; with none it rebuilds at once at the new rate, replacing what is unconfirmed. + /// The bump stays pending until a build at the new rate is adopted. + mutating func applyFeeBump(rate: UInt64, extraSats: UInt64, paymentTxid: String?) { + feeRateSatPerVbyte = rate + isRedoRebuild = nil + isFeeBumpPending = true + feeBumpBuiltAtHeight = nil + fundingPaymentSeenAtHeight = nil + fundingPaymentMissingSince = nil + if extraSats > 0 { + phase = .needsFunding(requiredSats: fundedSats + extraSats) + fundingPaymentTxid = paymentTxid + } else { + isRebuilding = true + lastBuildAt = nil + } + } + + /// A step of this generation that keeps being refused, or whose fee chain keeps failing. One the chain shows + /// confirmed since, such as by the operators' copy of it, is not stuck. + var stuckStep: BroadcastAttempt? { + let confirmed = confirmedTxids + return broadcastLog.first { + $0.generation == generation && !$0.isAccepted && !confirmed.contains($0.txid) + && ($0.rejections >= SparkExitDriver.rejectionsToSurface || ($0.feeChainFailures ?? 0) >= SparkExitDriver.feeChainFailuresToRebuild) + } + } + + private var confirmedTxids: Set { + Set((recovery?.response.transactions ?? []).filter { + if case .confirmed = $0.status { + return true + } + return false + }.map(\.txid)) + } + + /// Whether rebuilding over a stuck step already failed to move the exit often enough to stop and say so. + var isStalled: Bool { + (stallRebuilds ?? 0) >= SparkExitDriver.stallRebuildsToSurface + } + + /// A savings payment Bitkit made to the funding address, such as a higher fee's, that the exit has not adopted yet: + /// nothing to ask the user for while it confirms. + var isAwaitingOwnPayment: Bool { + guard let paid = fundingPaymentTxid, fundingShortfallSats != nil else { return false } + return !funding.contains { $0.txid == paid } + } + + /// A top-up is awaited for a generation that is still valid (a higher fee, not a diverged rebuild): its steps keep + /// being checked, sent and reminded. + var isDrivingWhileFunding: Bool { + if case .needsFunding = phase { + return recovery != nil && !isRebuilding && !isFundingWaitingLeaves + } + return false + } + + /// Fee funding asked for `waitingLeafIds` once every step of the recovery confirmed: only those leaves are built + /// when it arrives. + var isFundingWaitingLeaves: Bool { + guard case .needsFunding = phase, !(waitingLeafIds ?? []).isEmpty, let recovery else { return false } + return recovery.response.transactions.allSatisfy { + if case .confirmed = $0.status { + return true + } + return false + } + } + + /// Progress says the exit is underpriced: it runs at its rate, nothing rebuilds it and no higher fee is on its way. + func showsFeeLow(tip: UInt32?) -> Bool { + phase == .running && !isRebuilding && underpricedStep(tip: tip) != nil + } + + /// A step the network is busier than the exit's rate for: refused for a fee under the mempool's minimum, or + /// accepted but unconfirmed for more blocks than a fair rate needs. Rebuilding at the same rate would not help. + func underpricedStep(tip: UInt32?) -> BroadcastAttempt? { + guard recovery != nil else { return nil } + let confirmed = confirmedTxids + return broadcastLog.first { + guard $0.generation == generation, !confirmed.contains($0.txid) else { return false } + if !$0.isAccepted { + return $0.isFeeRateRefused == true + } + guard let tip, let height = $0.acceptedAtHeight else { return false } + return tip > height && tip - height > SparkExitDriver.unconfirmedBlocksToSurface + } + } +} + +// MARK: - Persistence + +/// Reads and writes the exit record of one wallet. Writes are atomic and readable after the first unlock, so the +/// background task can drive the exit while the device is locked. +struct SparkExitStore { + let url: URL + + init(url: URL) { + self.url = url + } + + init(walletIndex: Int) { + url = Env.sparkExitRecord(walletIndex: walletIndex) + } + + /// The file exists but cannot be read now, such as before the first unlock after a restart: the record is left + /// as it is and read again later. + struct Unavailable: Error { + let underlying: Error + } + + func load() throws -> SparkExitRecord? { + guard let data = try loadData() else { return nil } + return try SparkExitRecordCoder.decode(data) + } + + func loadData() throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + do { + return try Data(contentsOf: url) + } catch { + throw Unavailable(underlying: error) + } + } + + /// Keeps a copy of the record beside it for support, leaving the record in place: a salvaged record then replaces it + /// in one atomic write, so a kill in between never leaves the exit without a record. + func keepCopyAside() throws { + let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json") + try FileManager.default.copyItem(at: url, to: aside) + } + + /// Moves an unreadable record out of the way, keeping it for support. + func setAside() throws { + let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json") + try FileManager.default.moveItem(at: url, to: aside) + } + + func save(_ record: SparkExitRecord) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try SparkExitRecordCoder.encode(record).write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func delete() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + /// Deletes the record and the copies kept aside for support (`keepCopyAside`, `setAside`), for a wallet being + /// wiped: they hold its addresses and signed transactions. + func deleteWithCopies() throws { + try delete() + let dir = url.deletingLastPathComponent() + guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return } + let prefix = url.deletingPathExtension().lastPathComponent + ".unreadable-" + for name in names where name.hasPrefix(prefix) { + try FileManager.default.removeItem(at: dir.appendingPathComponent(name)) + } + } +} + +/// The ids of the leaves an older state of the wallet claimed on-chain, across exits. The operators keep listing such +/// a leaf under the wallet, so without this a later exit would quote it and lose it again. Not backed up: a restored +/// wallet finds the loss again in one rebuild cycle. +struct SparkExitLostLeafStore { + let url: URL + + init(url: URL) { + self.url = url + } + + init(walletIndex: Int) { + url = Env.sparkExitLostLeaves(walletIndex: walletIndex) + } + + func load() throws -> Set { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + return try Set(JSONDecoder().decode([String].self, from: Data(contentsOf: url))) + } + + /// Adds the record's lost leaves, writing only when one is new. + func add(lostIn record: SparkExitRecord) throws { + let ids = (record.lostLeaves ?? []).map(\.leafId) + guard !ids.isEmpty else { return } + // An unreadable list is written anew: a leaf missing from it is found lost again in one rebuild cycle. + let stored = (try? load()) ?? [] + guard !stored.isSuperset(of: ids) else { return } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(stored.union(ids).sorted()) + .write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func delete() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} + +enum SparkExitRecordCoder { + struct UnsupportedVersion: Error, Equatable { + let version: Int + } + + private struct VersionProbe: Decodable { + let version: Int + } + + static func encode(_ record: SparkExitRecord) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .millisecondsSince1970 + return try encoder.encode(record) + } + + static func decode(_ data: Data) throws -> SparkExitRecord { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + let version = try decoder.decode(VersionProbe.self, from: data).version + var data = data + if version != SparkExitRecord.currentVersion { + guard let convert = SparkExitRecordMigration.converters[version] else { throw UnsupportedVersion(version: version) } + data = try convert(data) + } + return try decoder.decode(SparkExitRecord.self, from: data) + } + + /// What a record that cannot be decoded still tells about its exit: enough to build it again from the chain with + /// the funding it kept. Nil when it names no active exit, or lacks what a build needs. + static func salvage(_ data: Data) -> SparkExitRecord? { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + guard let kept = try? decoder.decode(Salvaged.self, from: data), !kept.leafIds.isEmpty, !kept.funding.isEmpty else { return nil } + let phase = kept.phase + var record = SparkExitRecord( + network: kept.network, + createdAt: kept.createdAt, + phase: .running, + feeRateSatPerVbyte: kept.feeRateSatPerVbyte, + destination: kept.destination, + leafIds: kept.leafIds, + leafTotalSats: kept.leafTotalSats, + funding: kept.funding, + transferId: kept.transferId, + expectedSats: kept.expectedSats + ) + switch phase { + case .done, .failed: + return nil + case .awaitingFunding, .needsFunding: + record.phase = phase ?? .running + case .running, nil: + record.isRebuilding = true + } + record.lostLeaves = kept.lostLeaves + record.leftBehindLeaves = kept.leftBehindLeaves + record.exitStateSnapshot = kept.exitStateSnapshot + record.fundingStrategy = kept.fundingStrategy + record.earlierReceivedSats = kept.earlierReceivedSats + record.outsideSats = kept.outsideSats + // Change and fan-out outputs of earlier generations at the funding address stay the exit's own, not new funding. + let chained = (kept.earlierCpfpTxids ?? []) + (kept.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? []) + record.earlierCpfpTxids = chained.isEmpty ? nil : chained + record.isRebuiltFromChain = true + return record + } + + private struct Salvaged: Decodable { + let network: String + let createdAt: Date + let phase: SparkExitRecord.Phase? + let feeRateSatPerVbyte: UInt64 + let destination: String + let leafIds: [String] + let leafTotalSats: UInt64? + let funding: [SparkExitRecord.Funding] + let transferId: String? + let expectedSats: UInt64 + let lostLeaves: [SparkExitRecord.LostLeaf]? + let leftBehindLeaves: [SparkExitRecord.LeftBehindLeaf]? + let exitStateSnapshot: String? + let earlierCpfpTxids: [String]? + let recovery: StoredRecovery? + let fundingStrategy: String? + let earlierReceivedSats: [String: UInt64]? + let outsideSats: UInt64? + + private enum CodingKeys: String, CodingKey { + case network, createdAt, phase, feeRateSatPerVbyte, destination, leafIds, leafTotalSats, funding, transferId, expectedSats + case lostLeaves, leftBehindLeaves, exitStateSnapshot, earlierCpfpTxids, recovery, fundingStrategy, earlierReceivedSats + case outsideSats + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + network = try c.decode(String.self, forKey: .network) + createdAt = (try? c.decode(Date.self, forKey: .createdAt)) ?? Date() + phase = try? c.decodeIfPresent(SparkExitRecord.Phase.self, forKey: .phase) + feeRateSatPerVbyte = try c.decode(UInt64.self, forKey: .feeRateSatPerVbyte) + destination = try c.decode(String.self, forKey: .destination) + leafIds = try c.decode([String].self, forKey: .leafIds) + leafTotalSats = try? c.decodeIfPresent(UInt64.self, forKey: .leafTotalSats) + funding = try c.decode([SparkExitRecord.Funding].self, forKey: .funding) + transferId = try? c.decodeIfPresent(String.self, forKey: .transferId) + expectedSats = (try? c.decode(UInt64.self, forKey: .expectedSats)) ?? 0 + lostLeaves = try? c.decodeIfPresent([SparkExitRecord.LostLeaf].self, forKey: .lostLeaves) + leftBehindLeaves = try? c.decodeIfPresent([SparkExitRecord.LeftBehindLeaf].self, forKey: .leftBehindLeaves) + exitStateSnapshot = try? c.decodeIfPresent(String.self, forKey: .exitStateSnapshot) + earlierCpfpTxids = try? c.decodeIfPresent([String].self, forKey: .earlierCpfpTxids) + recovery = try? c.decodeIfPresent(StoredRecovery.self, forKey: .recovery) + fundingStrategy = try? c.decodeIfPresent(String.self, forKey: .fundingStrategy) + earlierReceivedSats = try? c.decodeIfPresent([String: UInt64].self, forKey: .earlierReceivedSats) + outsideSats = try? c.decodeIfPresent(UInt64.self, forKey: .outsideSats) + } + } +} + +/// Converts a record written with another `version` to the current one before it is decoded. Add a converter here +/// before changing `SparkExitRecord.currentVersion`, so an exit written by the earlier version keeps running as it was. +enum SparkExitRecordMigration { + static let converters: [Int: (Data) throws -> Data] = [:] +} + +// MARK: - RecoverFundsResponse in spark-sdk's serde shape + +/// `RecoverFundsResponse` as spark-sdk serializes it with serde (unchanged from 0.27.2 to 0.28.0): snake_case fields and externally tagged +/// enums (`"Ready"`, `{"Confirmed":{"block_height":120}}`). A stored recovery is then readable by the Breez CLI's +/// `check-recover-funds` and shared with Android as one format. The SDK's Swift types are not `Codable`. +struct StoredRecovery: Codable, Equatable { + let response: RecoverFundsResponse + + init(_ response: RecoverFundsResponse) { + self.response = response + } + + private enum CodingKeys: String, CodingKey { + case recoverableValueSats = "recoverable_value_sats" + case totalFeeSats = "total_fee_sats" + case cooperativeFeeSats = "cooperative_fee_sats" + case cpfpFeeSats = "cpfp_fee_sats" + case fanoutFeeSats = "fanout_fee_sats" + case refundFeeSats = "refund_fee_sats" + case sweepFeeSats = "sweep_fee_sats" + case leaves + case failed + case transactions + case fundingInputs = "funding_inputs" + case feeRateSatPerVbyte = "fee_rate_sat_per_vbyte" + case destination + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + response = try RecoverFundsResponse( + recoverableValueSats: c.decode(UInt64.self, forKey: .recoverableValueSats), + totalFeeSats: c.decode(UInt64.self, forKey: .totalFeeSats), + cooperativeFeeSats: c.decode(UInt64.self, forKey: .cooperativeFeeSats), + cpfpFeeSats: c.decode(UInt64.self, forKey: .cpfpFeeSats), + fanoutFeeSats: c.decode(UInt64.self, forKey: .fanoutFeeSats), + refundFeeSats: c.decode(UInt64.self, forKey: .refundFeeSats), + sweepFeeSats: c.decode(UInt64.self, forKey: .sweepFeeSats), + leaves: c.decode([StoredLeaf].self, forKey: .leaves).map(\.value), + failed: c.decode([StoredFailure].self, forKey: .failed).map(\.value), + transactions: c.decode([StoredTransaction].self, forKey: .transactions).map(\.value), + fundingInputs: c.decode([StoredCpfpInput].self, forKey: .fundingInputs).map(\.value), + feeRateSatPerVbyte: c.decode(UInt64.self, forKey: .feeRateSatPerVbyte), + destination: c.decode(String.self, forKey: .destination) + ) + } + + func encode(to encoder: Encoder) throws { + let r = response + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(r.recoverableValueSats, forKey: .recoverableValueSats) + try c.encode(r.totalFeeSats, forKey: .totalFeeSats) + try c.encode(r.cooperativeFeeSats, forKey: .cooperativeFeeSats) + try c.encode(r.cpfpFeeSats, forKey: .cpfpFeeSats) + try c.encode(r.fanoutFeeSats, forKey: .fanoutFeeSats) + try c.encode(r.refundFeeSats, forKey: .refundFeeSats) + try c.encode(r.sweepFeeSats, forKey: .sweepFeeSats) + try c.encode(r.leaves.map(StoredLeaf.init), forKey: .leaves) + try c.encode(r.failed.map(StoredFailure.init), forKey: .failed) + try c.encode(r.transactions.map(StoredTransaction.init), forKey: .transactions) + try c.encode(r.fundingInputs.map(StoredCpfpInput.init), forKey: .fundingInputs) + try c.encode(r.feeRateSatPerVbyte, forKey: .feeRateSatPerVbyte) + try c.encode(r.destination, forKey: .destination) + } +} + +private struct StoredLeaf: Codable { + let value: RecoverFundsLeaf + + init(_ value: RecoverFundsLeaf) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case leafId = "leaf_id" + case valueSats = "value_sats" + case method + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let method: RecoveryMethod = switch try c.decode(String.self, forKey: .method) { + case "Cooperative": .cooperative + case "Unilateral": .unilateral + case let other: throw DecodingError.dataCorruptedError(forKey: .method, in: c, debugDescription: "Unknown method \(other)") + } + value = try RecoverFundsLeaf( + leafId: c.decode(String.self, forKey: .leafId), + valueSats: c.decode(UInt64.self, forKey: .valueSats), + method: method + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(value.leafId, forKey: .leafId) + try c.encode(value.valueSats, forKey: .valueSats) + try c.encode(value.method == .cooperative ? "Cooperative" : "Unilateral", forKey: .method) + } +} + +private struct StoredFailure: Codable { + let value: CooperativeRecoveryFailure + + init(_ value: CooperativeRecoveryFailure) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case leafId = "leaf_id" + case outputTxid = "output_txid" + case outputVout = "output_vout" + case error + } + + private enum ErrorKeys: String, CodingKey { + case requiredFeeSats = "required_fee_sats" + case requiredFeeRateSatPerVbyte = "required_fee_rate_sat_per_vbyte" + case message + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let error = try ExternallyTagged.decode(from: c.superDecoder(forKey: .error)) { tag, fields -> CooperativeRecoveryError in + let f = try fields?.container(keyedBy: ErrorKeys.self) + switch tag { + case "ReplacementFeeTooLow": + return try .replacementFeeTooLow( + requiredFeeSats: ExternallyTagged.require(f).decode(UInt64.self, forKey: .requiredFeeSats), + requiredFeeRateSatPerVbyte: ExternallyTagged.require(f).decode(UInt64.self, forKey: .requiredFeeRateSatPerVbyte) + ) + case "OperatorsUnavailable": + return try .operatorsUnavailable(message: ExternallyTagged.require(f).decode(String.self, forKey: .message)) + case "Generic": + return try .generic(message: ExternallyTagged.require(f).decode(String.self, forKey: .message)) + default: + throw ExternallyTagged.unknown(tag) + } + } + value = try CooperativeRecoveryFailure( + leafId: c.decode(String.self, forKey: .leafId), + outputTxid: c.decode(String.self, forKey: .outputTxid), + outputVout: c.decode(UInt32.self, forKey: .outputVout), + error: error + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(value.leafId, forKey: .leafId) + try c.encode(value.outputTxid, forKey: .outputTxid) + try c.encode(value.outputVout, forKey: .outputVout) + let errorEncoder = c.superEncoder(forKey: .error) + switch value.error { + case let .replacementFeeTooLow(requiredFeeSats, requiredFeeRateSatPerVbyte): + var f = ExternallyTagged.fields(errorEncoder, tag: "ReplacementFeeTooLow", keyedBy: ErrorKeys.self) + try f.encode(requiredFeeSats, forKey: .requiredFeeSats) + try f.encode(requiredFeeRateSatPerVbyte, forKey: .requiredFeeRateSatPerVbyte) + case let .operatorsUnavailable(message): + var f = ExternallyTagged.fields(errorEncoder, tag: "OperatorsUnavailable", keyedBy: ErrorKeys.self) + try f.encode(message, forKey: .message) + case let .generic(message): + var f = ExternallyTagged.fields(errorEncoder, tag: "Generic", keyedBy: ErrorKeys.self) + try f.encode(message, forKey: .message) + } + } +} + +private struct StoredTransaction: Codable { + let value: RecoveryTransaction + + init(_ value: RecoveryTransaction) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case kind + case nodeId = "node_id" + case txid + case txHex = "tx_hex" + case cpfpTxHex = "cpfp_tx_hex" + case csvTimelockBlocks = "csv_timelock_blocks" + case dependsOn = "depends_on" + case status + } + + private enum StatusKeys: String, CodingKey { + case blockHeight = "block_height" + case spendableAtHeight = "spendable_at_height" + } + + private static let kinds: [(RecoveryTxKind, String)] = [ + (.cooperative, "Cooperative"), (.fanOut, "FanOut"), (.node, "Node"), (.refund, "Refund"), (.sweep, "Sweep"), + ] + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let kindName = try c.decode(String.self, forKey: .kind) + guard let kind = Self.kinds.first(where: { $0.1 == kindName })?.0 else { throw ExternallyTagged.unknown(kindName) } + let status = try ExternallyTagged.decode(from: c.superDecoder(forKey: .status)) { tag, fields -> ExitTransactionStatus in + let f = try fields?.container(keyedBy: StatusKeys.self) + switch tag { + case "Confirmed": return try .confirmed(blockHeight: ExternallyTagged.require(f).decodeIfPresent(UInt32.self, forKey: .blockHeight)) + case "Ready": return .ready + case "WaitingForDependencies": return .waitingForDependencies + case "WaitingForTimelock": + return try .waitingForTimelock(spendableAtHeight: ExternallyTagged.require(f).decodeIfPresent( + UInt32.self, + forKey: .spendableAtHeight + )) + case "Unverified": return .unverified + default: throw ExternallyTagged.unknown(tag) + } + } + value = try RecoveryTransaction( + kind: kind, + nodeId: c.decodeIfPresent(String.self, forKey: .nodeId), + txid: c.decode(String.self, forKey: .txid), + txHex: c.decode(String.self, forKey: .txHex), + cpfpTxHex: c.decodeIfPresent(String.self, forKey: .cpfpTxHex), + csvTimelockBlocks: c.decodeIfPresent(UInt32.self, forKey: .csvTimelockBlocks), + dependsOn: c.decode([String].self, forKey: .dependsOn), + status: status + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(Self.kinds.first { $0.0 == value.kind }?.1, forKey: .kind) + try c.encode(value.nodeId, forKey: .nodeId) + try c.encode(value.txid, forKey: .txid) + try c.encode(value.txHex, forKey: .txHex) + try c.encode(value.cpfpTxHex, forKey: .cpfpTxHex) + try c.encode(value.csvTimelockBlocks, forKey: .csvTimelockBlocks) + try c.encode(value.dependsOn, forKey: .dependsOn) + let statusEncoder = c.superEncoder(forKey: .status) + switch value.status { + case let .confirmed(blockHeight): + var f = ExternallyTagged.fields(statusEncoder, tag: "Confirmed", keyedBy: StatusKeys.self) + try f.encode(blockHeight, forKey: .blockHeight) + case .ready: + try ExternallyTagged.unit(statusEncoder, tag: "Ready") + case .waitingForDependencies: + try ExternallyTagged.unit(statusEncoder, tag: "WaitingForDependencies") + case let .waitingForTimelock(spendableAtHeight): + var f = ExternallyTagged.fields(statusEncoder, tag: "WaitingForTimelock", keyedBy: StatusKeys.self) + try f.encode(spendableAtHeight, forKey: .spendableAtHeight) + case .unverified: + try ExternallyTagged.unit(statusEncoder, tag: "Unverified") + } + } +} + +private struct StoredCpfpInput: Codable { + let value: CpfpInput + + init(_ value: CpfpInput) { + self.value = value + } + + private enum Keys: String, CodingKey { + case txid + case vout + case valueSats = "value_sats" + case pubkey + case scriptPubkeyHex = "script_pubkey_hex" + case signedInputWeight = "signed_input_weight" + } + + init(from decoder: Decoder) throws { + value = try ExternallyTagged.decode(from: decoder) { tag, fields -> CpfpInput in + let f = try ExternallyTagged.require(fields?.container(keyedBy: Keys.self)) + let txid = try f.decode(String.self, forKey: .txid) + let vout = try f.decode(UInt32.self, forKey: .vout) + let valueSats = try f.decode(UInt64.self, forKey: .valueSats) + switch tag { + case "P2wpkh": return try .p2wpkh(txid: txid, vout: vout, valueSats: valueSats, pubkey: f.decode(String.self, forKey: .pubkey)) + case "P2tr": return try .p2tr(txid: txid, vout: vout, valueSats: valueSats, pubkey: f.decode(String.self, forKey: .pubkey)) + case "Custom": + return try .custom( + txid: txid, + vout: vout, + valueSats: valueSats, + scriptPubkeyHex: f.decode(String.self, forKey: .scriptPubkeyHex), + signedInputWeight: f.decode(UInt64.self, forKey: .signedInputWeight) + ) + default: throw ExternallyTagged.unknown(tag) + } + } + } + + func encode(to encoder: Encoder) throws { + switch value { + case let .p2wpkh(txid, vout, valueSats, pubkey), let .p2tr(txid, vout, valueSats, pubkey): + var f = ExternallyTagged.fields(encoder, tag: { + if case .p2wpkh = value { + "P2wpkh" + } else { + "P2tr" + } + }(), keyedBy: Keys.self) + try f.encode(txid, forKey: .txid) + try f.encode(vout, forKey: .vout) + try f.encode(valueSats, forKey: .valueSats) + try f.encode(pubkey, forKey: .pubkey) + case let .custom(txid, vout, valueSats, scriptPubkeyHex, signedInputWeight): + var f = ExternallyTagged.fields(encoder, tag: "Custom", keyedBy: Keys.self) + try f.encode(txid, forKey: .txid) + try f.encode(vout, forKey: .vout) + try f.encode(valueSats, forKey: .valueSats) + try f.encode(scriptPubkeyHex, forKey: .scriptPubkeyHex) + try f.encode(signedInputWeight, forKey: .signedInputWeight) + } + } +} + +/// serde's default enum representation: a unit variant is its name as a string, a variant with fields is an object +/// with the name as its only key. +private enum ExternallyTagged { + struct TagKey: CodingKey { + let stringValue: String + var intValue: Int? { + nil + } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue _: Int) { + nil + } + } + + static func decode(from decoder: Decoder, variant: (String, Decoder?) throws -> T) throws -> T { + if let tag = try? decoder.singleValueContainer().decode(String.self) { + return try variant(tag, nil) + } + let c = try decoder.container(keyedBy: TagKey.self) + guard c.allKeys.count == 1, let key = c.allKeys.first else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Expected one variant key")) + } + return try variant(key.stringValue, c.superDecoder(forKey: key)) + } + + static func fields(_ encoder: Encoder, tag: String, keyedBy _: Keys.Type) -> KeyedEncodingContainer { + var c = encoder.container(keyedBy: TagKey.self) + return c.nestedContainer(keyedBy: Keys.self, forKey: TagKey(stringValue: tag)) + } + + static func unit(_ encoder: Encoder, tag: String) throws { + var c = encoder.singleValueContainer() + try c.encode(tag) + } + + static func require(_ value: T?) throws -> T { + guard let value else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Variant is missing its fields")) + } + return value + } + + static func unknown(_ tag: String) -> Error { + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unknown variant \(tag)")) + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitSweep.swift b/Bitkit/Services/SparkExit/SparkExitSweep.swift new file mode 100644 index 000000000..b7ab0c272 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitSweep.swift @@ -0,0 +1,226 @@ +import BreezSdkSpark +import CryptoKit +import Foundation + +/// Sends what an ended exit left at its funding address (CPFP change, or fee funding no build spent) to savings in +/// one transaction. The funding key's `CpfpSigner` signs it, the same signer the exit's CPFP children use. +enum SparkExitLeftoverSweep { + struct Built: Equatable { + let txid: String + let txHex: String + /// What reaches savings: the swept value less the fee. + let valueSats: UInt64 + } + + enum SweepError: Error, Equatable { + case unsupportedAddress(String) + case unsigned + } + + /// Spends every given UTXO of the funding address to `destination` at `feeRateSatPerVbyte`. Nil when what is left + /// after the fee is under the destination's dust limit. + static func build( + utxos: [EsploraUtxo], + fundingAddress: String, + destination: String, + feeRateSatPerVbyte: UInt64, + signer: CpfpSigner + ) async throws -> Built? { + guard !utxos.isEmpty else { return nil } + let inputScript = try SparkExitAddress.scriptPubKey(fundingAddress) + let outputScript = try SparkExitAddress.scriptPubKey(destination) + let total = utxos.reduce(0) { $0 + $1.value } + let fee = feeSats(inputs: utxos.count, outputScriptLength: outputScript.count, feeRateSatPerVbyte: feeRateSatPerVbyte) + guard total > fee, total - fee >= dustLimit(outputScript) else { return nil } + let value = total - fee + + var inputs = Data() + inputs += varInt(utxos.count) + for utxo in utxos { + inputs += Data(utxo.txid.hexaBytes.reversed()) + le32(utxo.vout) + [0x00] + le32(0xFFFF_FFFD) + } + let outputs = varInt(1) + le64(value) + varInt(outputScript.count) + outputScript + let version = le32(2) + let lockTime = le32(0) + + var psbt = Data("psbt".utf8) + [0xFF] + let unsigned = version + inputs + outputs + lockTime + psbt += varInt(1) + [0x00] + varInt(unsigned.count) + unsigned + [0x00] + for utxo in utxos { + let witnessUtxo = le64(utxo.value) + varInt(inputScript.count) + inputScript + psbt += varInt(1) + [0x01] + varInt(witnessUtxo.count) + witnessUtxo + [0x00] + } + psbt += [0x00] + + let signed = try await signer.signPsbt(psbtBytes: psbt) + guard let witnesses = finalWitnesses(signed, inputs: utxos.count) else { throw SweepError.unsigned } + let tx = version + [0x00, 0x01] + inputs + outputs + witnesses.reduce(Data(), +) + lockTime + let hex = tx.hex + guard let txid = SparkExitTxid.txid(hex: hex) else { throw SweepError.unsigned } + return Built(txid: txid, txHex: hex, valueSats: value) + } + + /// The fee for P2WPKH inputs and one output, sized for 72-byte signatures. + static func feeSats(inputs: Int, outputScriptLength: Int, feeRateSatPerVbyte: UInt64) -> UInt64 { + let base = 4 + 1 + 41 * inputs + 1 + 8 + 1 + outputScriptLength + 4 + let witness = 2 + 108 * inputs + let vbytes = (4 * base + witness + 3) / 4 + return UInt64(vbytes) * feeRateSatPerVbyte + } + + /// Bitcoin Core's dust limit at its default 3 sat/vB: 294 for P2WPKH, 330 for P2TR, 540 for P2SH, 546 for P2PKH. + static func dustLimit(_ script: Data) -> UInt64 { + let isWitness = script.count >= 4 && (script[script.startIndex] == 0x00 || (0x51 ... 0x60).contains(script[script.startIndex])) + return UInt64(3 * (8 + 1 + script.count + (isWitness ? 67 : 148))) + } + + /// Each input's `final_script_witness` (PSBT input key 0x08), already in the transaction's witness encoding. + static func finalWitnesses(_ psbt: Data, inputs: Int) -> [Data]? { + var reader = PsbtReader(bytes: [UInt8](psbt)) + guard reader.take(5) == Array("psbt".utf8) + [0xFF], reader.skipMap() else { return nil } + var witnesses: [Data] = [] + for _ in 0 ..< inputs { + guard let map = reader.readMap(), let witness = map[0x08] else { return nil } + witnesses.append(Data(witness)) + } + return witnesses + } + + private static func varInt(_ value: Int) -> Data { + switch value { + case ..<0xFD: Data([UInt8(value)]) + case ...0xFFFF: Data([0xFD]) + le16(UInt16(value)) + default: Data([0xFE]) + le32(UInt32(value)) + } + } + + private static func le16(_ value: UInt16) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private static func le32(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private static func le64(_ value: UInt64) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private struct PsbtReader { + let bytes: [UInt8] + var position = 0 + + mutating func take(_ count: Int) -> [UInt8]? { + guard count >= 0, position + count <= bytes.count else { return nil } + defer { position += count } + return Array(bytes[position ..< position + count]) + } + + mutating func varInt() -> Int? { + guard let first = take(1)?.first else { return nil } + let width = switch first { + case 0xFD: 2 + case 0xFE: 4 + case 0xFF: 8 + default: 0 + } + guard width > 0 else { return Int(first) } + guard let rest = take(width) else { return nil } + return rest.enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } + } + + /// One key-value map up to its separator, by key type; keys with data after the type are kept by type too. + mutating func readMap() -> [UInt8: [UInt8]]? { + var map: [UInt8: [UInt8]] = [:] + while true { + guard let keyLength = varInt() else { return nil } + if keyLength == 0 { + return map + } + guard let key = take(keyLength), let valueLength = varInt(), let value = take(valueLength) else { return nil } + map[key[0]] = value + } + } + + mutating func skipMap() -> Bool { + readMap() != nil + } + } +} + +/// The output script of a Bitcoin address: bech32 and bech32m segwit, base58 P2PKH and P2SH, on any network. +enum SparkExitAddress { + private static let charset = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + + static func scriptPubKey(_ address: String) throws -> Data { + if let script = segwitScript(address) ?? base58Script(address) { + return script + } + throw SparkExitLeftoverSweep.SweepError.unsupportedAddress(address) + } + + private static func segwitScript(_ address: String) -> Data? { + let lower = address.lowercased() + guard lower == address || address.uppercased() == address, let separator = lower.lastIndex(of: "1") else { return nil } + let hrp = lower[..= 7 else { return nil } + let checksum = polymod(hrp.unicodeScalars.map { UInt8($0.value >> 5) } + [0] + hrp.unicodeScalars.map { UInt8($0.value & 31) } + data) + let version = data[0] + guard version <= 16, checksum == (version == 0 ? 1 : 0x2BC8_30A3), + let program = convertBits(Array(data[1 ..< data.count - 6])), + (2 ... 40).contains(program.count), version != 0 || program.count == 20 || program.count == 32 + else { return nil } + return Data([version == 0 ? 0x00 : 0x50 + version, UInt8(program.count)] + program) + } + + private static func base58Script(_ address: String) -> Data? { + guard let decoded = Base58.decode(address), decoded.count == 25 else { return nil } + let bytes = [UInt8](decoded) + let checksum = [UInt8](Data(SHA256Twice.hash(Data(bytes[0 ..< 21]))).prefix(4)) + guard Array(bytes[21...]) == checksum else { return nil } + let hash = Array(bytes[1 ..< 21]) + switch bytes[0] { + case 0x00, 0x6F: return Data([0x76, 0xA9, 0x14] + hash + [0x88, 0xAC]) + case 0x05, 0xC4: return Data([0xA9, 0x14] + hash + [0x87]) + default: return nil + } + } + + private static func polymod(_ values: [UInt8]) -> UInt32 { + let generator: [UInt32] = [0x3B6A_57B2, 0x2650_8E6D, 0x1EA1_19FA, 0x3D42_33DD, 0x2A14_62B3] + var checksum: UInt32 = 1 + for value in values { + let top = checksum >> 25 + checksum = (checksum & 0x1FFFFFF) << 5 ^ UInt32(value) + for i in 0 ..< 5 where (top >> i) & 1 == 1 { + checksum ^= generator[i] + } + } + return checksum + } + + /// 5-bit groups to bytes, without padding. + private static func convertBits(_ data: [UInt8]) -> [UInt8]? { + var accumulator = 0 + var bits = 0 + var result: [UInt8] = [] + for value in data { + accumulator = (accumulator << 5) | Int(value) + bits += 5 + while bits >= 8 { + bits -= 8 + result.append(UInt8((accumulator >> bits) & 0xFF)) + } + } + guard bits < 5, (accumulator << (8 - bits)) & 0xFF == 0 else { return nil } + return result + } +} + +private enum SHA256Twice { + static func hash(_ data: Data) -> Data { + Data(SHA256.hash(data: Data(SHA256.hash(data: data)))) + } +} diff --git a/Bitkit/Services/TransferStorage.swift b/Bitkit/Services/TransferStorage.swift index dc73fc283..59c6b701d 100644 --- a/Bitkit/Services/TransferStorage.swift +++ b/Bitkit/Services/TransferStorage.swift @@ -99,6 +99,29 @@ class TransferStorage { } } + /// Moves a transfer's countdown target, as a Spark exit learns when its timelocks mature. + func updateClaimableAtHeight(id: String, claimableAtHeight: UInt32?) throws { + var transfers = try getAll() + guard let index = transfers.firstIndex(where: { $0.id == id }), transfers[index].claimableAtHeight != claimableAtHeight else { return } + let transfer = transfers[index] + transfers[index] = Transfer( + id: transfer.id, + type: transfer.type, + amountSats: transfer.amountSats, + channelId: transfer.channelId, + fundingTxId: transfer.fundingTxId, + lspOrderId: transfer.lspOrderId, + isSettled: transfer.isSettled, + createdAt: transfer.createdAt, + settledAt: transfer.settledAt, + claimableAtHeight: claimableAtHeight, + txTotalSats: transfer.txTotalSats, + preTransferOnchainSats: transfer.preTransferOnchainSats + ) + try save(transfers) + transfersChangedSubject.send() + } + /// Delete old settled transfers func deleteOldSettled(expirationTimestamp: UInt64) throws { var transfers = try getAll() diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index fc2e584ec..76be1efa6 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -80,6 +80,7 @@ final class AppReset: WipeStepExecutor { await VssBackupClient.shared.reset() VssStoreIdProvider.shared.clearCache() OnChainHwService.shared.stopAllWatchers() + MoveToSparkManager.shared.resetForWipe() // Before the paired devices are wiped, so a reconnect still running cannot save one back. try await Self.bounded("hardware wallets") { await Self.hardwareWallets?.resetForWipe() } do { diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index 3e57c941a..acce738fc 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -22,8 +22,24 @@ enum HealthStatus: String { } } +/// The step of a running emergency exit, named like its App Status subtitle key. +enum ExitStatusStep: String { + case funding + case pending +} + @MainActor struct AppStatusHelper { + /// Nil while no emergency exit runs. Asks for fee funding only while no payment covers it, as the progress screen does. + nonisolated static func exitStep(record: SparkExitRecord?, fundingState: SparkExitFundingState? = nil) -> ExitStatusStep? { + guard let record, record.isActive else { return nil } + guard record.fundingShortfallSats != nil else { return .pending } + switch fundingState ?? SparkExitMath.fundingState(record, utxos: nil) { + case .paid, .seen: return .pending + default: return .funding + } + } + static func internetStatus(network: NetworkMonitor) -> HealthStatus { return network.isConnected ? .ready : .error } @@ -63,22 +79,67 @@ struct AppStatusHelper { } static func channelsStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus { - let isOnline = network.isConnected - let hasChannels = wallet.channelCount > 0 + channelsStatus( + isOnline: network.isConnected, + hasUsableChannels: wallet.hasUsableChannels, + hasChannels: wallet.channelCount > 0, + isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared) + ) + } + + static func isLightningViaSpark(from wallet: WalletViewModel, network: NetworkMonitor) -> Bool { + isLightningViaSpark( + isOnline: network.isConnected, + hasUsableChannels: wallet.hasUsableChannels, + isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared) + ) + } + /// Nil where Spark is unsupported. + static func sparkStatus(network: NetworkMonitor) -> HealthStatus? { + let spark = SparkManager.shared + return sparkStatus( + isOnline: network.isConnected, + isSupported: spark.isSupported, + isReady: spark.isReady, + hasStartFailed: spark.hasStartFailed + ) + } + + /// Spark carries Lightning when no channel can, so the connection is ready through it; red only when neither can pay. + nonisolated static func channelsStatus( + isOnline: Bool, + hasUsableChannels: Bool, + hasChannels: Bool, + isSparkUsable: Bool + ) -> HealthStatus { if !isOnline { return .error } - if !hasChannels { - return .error - } else if wallet.hasUsableChannels { + if hasUsableChannels || isSparkUsable { return .ready + } else if !hasChannels { + return .error } else { return .pending } } + /// No channel is usable, so Spark carries Lightning payments. + nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkUsable: Bool) -> Bool { + isOnline && !hasUsableChannels && isSparkUsable + } + + nonisolated static func sparkStatus(isOnline: Bool, isSupported: Bool, isReady: Bool, hasStartFailed: Bool) -> HealthStatus? { + guard isSupported else { return nil } + guard isOnline else { return .error } + if isReady { + return .ready + } + return hasStartFailed ? .error : .pending + } + static func combinedAppStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus { let internetState = internetStatus(network: network) let bitcoinNodeState = bitcoinNodeStatus(from: wallet, network: network) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index cf5fcab78..b7ca2c3c2 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -50,7 +50,7 @@ struct PaymentNavigationHelper { sheetViewModel.showSheet(.send, data: SendConfig(view: route)) return } - if app.contactPaymentContext?.requiresAssetSelection == true { + if app.contactPaymentPicksSource { sheetViewModel.showSheet(.send, data: SendConfig(view: .amount)) return } @@ -117,7 +117,7 @@ struct PaymentNavigationHelper { spendStore: QuickPaySpendStore = .shared, coordinator: QuickPayPaymentCoordinator? = nil ) -> SendRoute? { - if app.contactPaymentContext?.requiresAssetSelection == true, app.paykitPaymentTerms == nil { + if app.contactPaymentPicksSource, app.paykitPaymentTerms == nil { return .amount } if let lnurlWithdrawData = app.lnurlWithdrawData { @@ -218,7 +218,7 @@ struct PaymentNavigationHelper { spendStore: QuickPaySpendStore = .shared, coordinator: QuickPayPaymentCoordinator? = nil ) -> SendRoute? { - if app.contactPaymentContext?.requiresAssetSelection == true { + if app.contactPaymentPicksSource { if app.paykitPaymentTerms == nil { return .amount } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index f1552bb33..a51ac2ba0 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -69,10 +69,24 @@ final class QuickPayPaymentCoordinator { try await String(LightningService.shared.send(bolt11: bolt11)) } self.listRows = listRows ?? { - await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) + try await Self.reconcileRows( + railPayments: { try await LightningService.shared.channelFreeRailPayments() }, + nodePayments: { await LightningService.shared.listPayments() } + ) } } + /// Both rails' payments, nil while the node can't list its own, so reconciling waits rather than reading them as none. + static func reconcileRows( + railPayments: () async throws -> [ChannelFreeRailPayment], + nodePayments: () async -> [PaymentDetails]? + ) async throws -> [QuickPayReconcileRow]? { + // A Spark list failure throws, so reconciling waits rather than reading Spark's payments as none. + let railRows = try await railPayments().map(QuickPayReconcileRow.init(railPayment:)) + guard let ldkRows = await nodePayments()?.map(QuickPayReconcileRow.init) else { return nil } + return ldkRows + railRows + } + func hasOpen(_ paymentHash: String) -> Bool { guard !paymentHash.isEmpty else { return false } return operations[paymentHash] != nil || store.record(matching: paymentHash) != nil @@ -184,6 +198,10 @@ final class QuickPayPaymentCoordinator { } static func isHardReject(_ error: Error) -> Bool { + // Spark refuses only before anything leaves the wallet. + if error is ChannelFreeRailSendRefused || (error as? AppError)?.underlyingError is ChannelFreeRailSendRefused { + return true + } guard let nodeError = nodeError(from: error) else { return false } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index fceaf350f..ce7606c29 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -144,6 +144,17 @@ struct QuickPayReconcileRow { } } + init(railPayment: ChannelFreeRailPayment) { + paymentId = railPayment.paymentHash + invoicePaymentHash = railPayment.paymentHash + isOutboundBolt11 = railPayment.direction == .outbound + status = switch railPayment.status { + case .succeeded: .succeeded + case .failed: .failed + case .pending: .pending + } + } + init(paymentId: String, invoicePaymentHash: String, isOutboundBolt11: Bool, status: Status) { self.paymentId = paymentId self.invoicePaymentHash = invoicePaymentHash @@ -428,7 +439,9 @@ final class QuickPaySpendStore: @unchecked Sendable { if matches.isEmpty { return nil } - if let paymentId = record.paymentId, let exact = matches.first(where: { $0.paymentId == paymentId }) { + // LDK and Spark each list an attempt under the same payment id when an invoice was retried on the other rail, + // so a settled or pending attempt wins over an earlier failed one. + if let paymentId = record.paymentId, let exact = matches.filter({ $0.paymentId == paymentId }).max(by: { $0.status.rank < $1.status.rank }) { return exact } return matches.max { lhs, rhs in diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 91ebac239..fe00edadd 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -38,6 +38,12 @@ struct ContactPaymentContext: Equatable { incomingPaymentRequest.map { $0.amount.asset != .btc || $0.pricing.conversion != nil } == true } + /// An on-chain and a Lightning endpoint, between which the router picks, Spark's leg to the on-chain one included. + var hasMixedBitcoinEndpoints: Bool { + endpoints.contains { $0.methodId.onchainNetwork != nil } && + endpoints.contains { $0.methodId == .bitcoinLightningLnurl || $0.methodId == .bitcoinLightningBolt11 } + } + func initialUsdtSubscriptionPayment(at date: Date = Date()) -> PaykitRequestPricing.Payment? { guard isInitialSubscriptionPayment, let request = incomingPaymentRequest, request.billingPeriod != nil, Set(request.acceptedPaymentEndpointIdentifiers) == [PublicPaykitService.MethodId.usdtArbitrum.rawValue], @@ -107,12 +113,18 @@ struct ScanPaymentState { let totalLightningBalanceSats: UInt64 let hasChannels: Bool let hasUsableChannels: Bool + /// Spark sends Lightning without channels. + var hasChannelFreeRail = false + /// What the channels can send, usable or not, so a channel whose peer is briefly offline is waited for only when it + /// could pay. + var channelOutboundSats: UInt64 = 0 } struct ScanPaymentOperations { let state: () -> ScanPaymentState let canSendLightning: (_ amountSats: UInt64) -> Bool var decodeInvoice: (String) async throws -> BitkitCore.Scanner = { try await decode(invoice: $0) } + var canRouteThroughSpark: @MainActor () -> Bool = { PaykitRouteService.canRouteThroughSpark } @MainActor static func live(lightningService: LightningService) -> ScanPaymentOperations { @@ -122,9 +134,12 @@ struct ScanPaymentOperations { return ScanPaymentState( isNodeRunning: lightningService.status?.isRunning == true, spendableOnchainBalanceSats: lightningService.balances?.spendableOnchainBalanceSats ?? 0, - totalLightningBalanceSats: lightningService.balances?.totalLightningBalanceSats ?? 0, + totalLightningBalanceSats: (lightningService.balances?.totalLightningBalanceSats ?? 0) + + (lightningService.channelFreeRail?.balanceSats ?? 0), hasChannels: channels?.isEmpty == false, - hasUsableChannels: channels?.contains(where: \.isUsable) == true + hasUsableChannels: channels?.contains(where: \.isUsable) == true, + hasChannelFreeRail: lightningService.channelFreeRail?.isReady == true, + channelOutboundSats: (channels ?? []).map(\.outboundCapacityMsat).reduce(0, +) / 1000 ) }, canSendLightning: lightningService.canSend @@ -164,6 +179,9 @@ class AppViewModel: ObservableObject { @Published var contactPaymentContext: ContactPaymentContext? private(set) var didRejectScannedPayment = false @Published var paykitUsesUsdt = false + @Published var paykitRoute: PaykitRouteUi? + /// Bumped by each routing request, so only the latest one's answer lands. + var paykitRoutingGeneration = 0 @Published var paykitReviewedAmount: PaykitAmount? @Published var paykitPaymentTerms: PaykitRequestPricing.Payment? @@ -204,7 +222,10 @@ class AppViewModel: ObservableObject { @AppStorage("hasSeenProfileIntro") var hasSeenProfileIntro: Bool = false @AppStorage("hasSeenNotificationsIntro") var hasSeenNotificationsIntro: Bool = false @AppStorage("hasSeenQuickpayIntro") var hasSeenQuickpayIntro: Bool = false + @AppStorage("hasSeenMoveToSparkIntro") var hasSeenMoveToSparkIntro: Bool = false @AppStorage("hasSeenShopIntro") var hasSeenShopIntro: Bool = false + @AppStorage("hasSeenTransferIntro") var hasSeenTransferIntro: Bool = false + @AppStorage("hasSeenTransferToSpendingIntro") var hasSeenTransferToSpendingIntro: Bool = false @AppStorage("hasSeenTransferToSavingsIntro") var hasSeenTransferToSavingsIntro: Bool = false @AppStorage("hasSeenWidgetsIntro") var hasSeenWidgetsIntro: Bool = false @@ -557,7 +578,10 @@ class AppViewModel: ObservableObject { hasSeenProfileIntro = false hasSeenNotificationsIntro = false hasSeenQuickpayIntro = false + hasSeenMoveToSparkIntro = false hasSeenShopIntro = false + hasSeenTransferIntro = false + hasSeenTransferToSpendingIntro = false hasSeenTransferToSavingsIntro = false hasSeenWidgetsIntro = false hasDismissedWidgetsOnboardingHint = false @@ -766,9 +790,28 @@ extension AppViewModel { return paykitBitcoinMethod(for: selectedWalletToPayFrom) } + /// The method a send prices with. Spending with no Lightning payment to make funds the Spark leg to the on-chain + /// endpoint, so it takes that endpoint's price, the one the leg and its proof pay. + /// Amount picks the source and the asset: for a contact that lists USDT or is priced in another asset, and for a mixed + /// Bitcoin contact while Spark can carry a leg, so an amount no Lightning endpoint takes still reaches the router. + var contactPaymentPicksSource: Bool { + guard let context = contactPaymentContext else { return false } + return context.requiresAssetSelection || (context.hasMixedBitcoinEndpoints && paykitRoute?.offersSparkLeg == true) + } + func paykitBitcoinMethod(for wallet: WalletType) -> PublicPaykitService.MethodId { if wallet == .lightning { - return lnurlPayData != nil ? .bitcoinLightningLnurl : .bitcoinLightningBolt11 + // A routed Spark leg pays its on-chain endpoint, whatever Lightning endpoint the contact also has. + if let leg = paykitRoute?.serviceLeg, let method = PublicPaykitService.MethodId(rawValue: leg.endpoint.methodId) { + return method + } + if lnurlPayData != nil { + return .bitcoinLightningLnurl + } + if scannedLightningInvoice == nil, let address = scannedOnchainInvoice?.address { + return PublicPaykitService.onchainMethodId(for: address) + } + return .bitcoinLightningBolt11 } return PublicPaykitService.onchainMethodId(for: scannedOnchainInvoice?.address ?? "") } @@ -804,7 +847,9 @@ extension AppViewModel { } } - if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { + if let context = claimedContactPaymentContext, scope != .onchainPayments, + context.requiresAssetSelection || context.hasMixedBitcoinEndpoints && scanPaymentOperations.canRouteThroughSpark() + { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) expectedGeneration = sendStateGeneration for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { @@ -830,6 +875,20 @@ extension AppViewModel { isUsdtEnabled: SettingsViewModel.shared.isUsdtEnabled ) || context.incomingPaymentRequest.map { PaykitUsdtPaymentService.shared.hasBinding(for: $0.id) } == true selectedWalletToPayFrom = canUseLightning || (hasLightning && scannedOnchainInvoice == nil) ? .lightning : .onchain + // A Spark leg can pay the on-chain endpoint from Spending here too, whatever Lightning can pay now: an amount + // entered later can pass Lightning's limit, and the router still picks a direct payment that works. + if scannedOnchainInvoice != nil, + let route = PaykitRouteService.routeUi(for: context, canRouteThroughSpark: scanPaymentOperations.canRouteThroughSpark()), + route.offersSparkLeg + { + paykitRoute = route + // A request keeps its source here; its Confirm asks the router. + if !hasLightning, context.incomingPaymentRequest == nil, + max(state.spendableOnchainBalanceSats, alternativeOnchainBalanceSats) <= UInt64(Env.dustLimit) + { + selectedWalletToPayFrom = .lightning + } + } let canSelectUsdt = SettingsViewModel.shared.isUsdtEnabled && context.endpoints.contains { $0.methodId == .usdtArbitrum } if !paykitUsesUsdt, !canSelectUsdt, let request = context.incomingPaymentRequest { do { @@ -974,21 +1033,21 @@ extension AppViewModel { if nodeIsRunning { // Node is running → we have fresh balances; validate immediately. // Prefer lightning; if insufficient or no channels/capacity, fall back to onchain. - let canSendLightning = try scanPaymentOperations.canSendLightning( - contactPaymentContext?.incomingPaymentRequest?.payment(using: .bitcoinLightningBolt11).amount.atomic ?? - lightningInvoice.amountSatoshis - ) + let lightningAmount = try contactPaymentContext?.incomingPaymentRequest? + .payment(using: .bitcoinLightningBolt11).amount.atomic ?? lightningInvoice.amountSatoshis + let canSendLightning = scanPaymentOperations.canSendLightning(lightningAmount) if canSendLightning { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } - // Channels exist but none are usable (e.g. peer offline): still prefer - // lightning. The send sheet shows the sync overlay and either proceeds - // over lightning when the peer reconnects or falls back to onchain - // after its timeout. - if paymentState.hasChannels, !paymentState.hasUsableChannels { + // Channels exist but none are usable (e.g. peer offline) and they could pay once it is + // back: still prefer lightning. The send sheet shows the sync overlay and either proceeds + // over lightning when the peer reconnects or falls back to onchain after its timeout. + if paymentState.hasChannels, !paymentState.hasUsableChannels, + paymentState.channelOutboundSats >= max(1, lightningAmount) + { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } @@ -1024,6 +1083,20 @@ extension AppViewModel { // Fallback to on-chain if address is available guard !invoice.address.isEmpty, validateIncomingOnchainPaymentRequestAmount(requestedAmount ?? invoice.amountSatoshis) else { return } + // An on-chain contact payment opens with Spending as a source while Spark can carry it there, whether or + // not Savings covers it; Spending is the default when Savings holds no more than dust. + if let context = contactPaymentContext, scope != .onchainPayments, + let route = PaykitRouteService.routeUi(for: context, canRouteThroughSpark: scanPaymentOperations.canRouteThroughSpark()), + route.offersSparkLeg + { + handleScannedOnchainInvoice(invoice) + paykitRoute = route + if max(paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats) <= UInt64(Env.dustLimit) { + selectedWalletToPayFrom = .lightning + } + return + } + // If node is running, validate balance immediately if paymentState.isNodeRunning { let onchainBalance = max( @@ -1072,13 +1145,13 @@ extension AppViewModel { let paymentAmount = requestedAmount ?? invoice.amountSatoshis // If user has no channels at all, they can never pay a pure lightning invoice. // Show insufficient spending toast and do not navigate to the send flow. - if !paymentState.hasChannels { + if !paymentState.hasChannels, !paymentState.hasChannelFreeRail { showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return } - // If channels are usable, validate capacity immediately - if paymentState.hasUsableChannels { + // If channels are usable or Spark can pay, validate capacity immediately + if paymentState.hasUsableChannels || paymentState.hasChannelFreeRail { guard scanPaymentOperations.canSendLightning(paymentAmount) else { showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return @@ -1252,8 +1325,9 @@ extension AppViewModel { return } + // Spark receives without inbound liquidity, so only a channel-only wallet can lack the capacity. let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - if lightningBalance < max(1, data.minWithdrawableSat) { + if lightningService.channelFreeRail?.isReady != true, lightningBalance < max(1, data.minWithdrawableSat) { toast( type: .warning, title: t("other__lnurl_withdr_error"), @@ -1368,6 +1442,7 @@ extension AppViewModel { sendStateOwnerID = ownerID sendStateGeneration = UUID() paykitUsesUsdt = false + paykitRoute = nil paykitReviewedAmount = nil paykitPaymentTerms = nil scannedLightningInvoice = nil @@ -1681,6 +1756,12 @@ extension AppViewModel { return } + // Spark's withdrawal to Savings is a transfer from Spending, not a payment received. + if SparkManager.shared.isWithdrawal(txid: txid) { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + return + } + let shouldShow = await CoreService.shared.activity.shouldShowReceivedSheet(txid: txid, value: sats) guard shouldShow else { return } @@ -1752,7 +1833,11 @@ extension AppViewModel { func handleLdkNodeEvent(_ event: Event) { switch event { - case let .paymentReceived(paymentId, _, amountMsat, _): + case let .paymentReceived(paymentId, paymentHash, amountMsat, _): + // A move to Spark reports its outcome on its own screen. + if MoveToSparkPayments.contains(paymentHash) { + break + } Task { if let paymentId { if await CoreService.shared.activity.isActivitySeen(id: paymentId) { diff --git a/Bitkit/ViewModels/CurrencyViewModel.swift b/Bitkit/ViewModels/CurrencyViewModel.swift index e2472fff2..cfd569a64 100644 --- a/Bitkit/ViewModels/CurrencyViewModel.swift +++ b/Bitkit/ViewModels/CurrencyViewModel.swift @@ -169,6 +169,12 @@ extension CurrencyViewModel { Self.primaryAmountText(sats: sats, converted: convert(sats: sats), primaryDisplay: primaryDisplay, displayUnit: displayUnit) } + /// An amount the user sends from another wallet, always in bitcoin: it must be exact, which a fiat figure converted + /// at another wallet's rate is not. + func bitcoinAmountText(sats: UInt64) -> String { + Self.primaryAmountText(sats: sats, converted: convert(sats: sats), primaryDisplay: .bitcoin, displayUnit: displayUnit) + } + nonisolated static func primaryAmountText( sats: UInt64, converted: ConvertedAmount?, diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 0c5d5e457..e29761778 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -39,7 +39,17 @@ enum Route: Hashable { case subscriptions(showPayments: Bool) case paymentRequestDetail(PaykitPaymentRequest.ID) case subscriptionDetail(PaykitSubscription.ID) + case transferIntro case fundingOptions + case spendingIntro + case spendingIntroHw(walletId: String) + case spendingAmount + case spendingAmountHw(walletId: String) + case spendingHwSign(walletId: String) + case spendingHwSigned + case spendingConfirm + case transferLearnMore + case settingUp case fundManual(nodeUri: String?) case fundManualAmount(lnPeer: LnPeer) case fundManualConfirm(lnPeer: LnPeer, amountSats: UInt64) @@ -93,6 +103,14 @@ enum Route: Hashable { case addressTypePreference case connections case connectionDetail(channelId: String) + case sparkConnectionDetail + case moveToSpark + case sparkExitIntro + case sparkExitQuote + case sparkExitFunding + case sparkExitConfirm + case sparkExitProgress + case sparkExitFeeBump case closeConnection(channel: ChannelDetails) case node case electrumSettings @@ -206,6 +224,13 @@ class NavigationViewModel: ObservableObject { path.append(route) } + /// Pushes `route` only while the stack is still `expectedPath`, so a screen that awaited something before + /// navigating doesn't reopen a flow the user left with Back meanwhile. + func navigate(_ route: Route, from expectedPath: [Route]) { + guard path == expectedPath else { return } + path.append(route) + } + func navigateBack() { guard !path.isEmpty else { return } path.removeLast() diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index ac91d07db..a21e0b3bd 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -988,6 +988,8 @@ class SettingsViewModel: NSObject, ObservableObject { hasSeenNotificationsIntro: defaults.bool(forKey: "hasSeenNotificationsIntro"), hasSeenQuickpayIntro: defaults.bool(forKey: "hasSeenQuickpayIntro"), hasSeenShopIntro: defaults.bool(forKey: "hasSeenShopIntro"), + hasSeenTransferIntro: defaults.bool(forKey: "hasSeenTransferIntro"), + hasSeenTransferToSpendingIntro: defaults.bool(forKey: "hasSeenTransferToSpendingIntro"), hasSeenTransferToSavingsIntro: defaults.bool(forKey: "hasSeenTransferToSavingsIntro"), hasSeenWidgetsIntro: defaults.bool(forKey: "hasSeenWidgetsIntro"), hasDismissedWidgetsOnboardingHint: defaults.bool(forKey: "hasDismissedWidgetsOnboardingHint"), @@ -998,7 +1000,8 @@ class SettingsViewModel: NSObject, ObservableObject { dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [], lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], quickPayLedger: spend, - detachedActivityContacts: CoreService.shared.activity.detachedContactsBackupSnapshot() + detachedActivityContacts: CoreService.shared.activity.detachedContactsBackupSnapshot(), + sparkExitNextFundingIndexes: SparkExitFundingIndexes.backup(defaults) ) } @@ -1015,6 +1018,8 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") defaults.set(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") defaults.set(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") + defaults.set(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") + defaults.set(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") defaults.set(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") defaults.set(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") defaults.set(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") @@ -1026,5 +1031,6 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") QuickPaySpendStore.shared.restoreFromBackup(ledger: cache.quickPayLedger) CoreService.shared.activity.restoreDetachedContacts(cache.detachedActivityContacts) + SparkExitFundingIndexes.restore(cache.sparkExitNextFundingIndexes, into: defaults) } } diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index b94d45bfd..728459caf 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -17,6 +17,7 @@ enum SheetID: String, CaseIterable { case subscription case quickpay case usdtIntro + case moveToSpark case receive case receivedTx case btcpayConnection @@ -395,6 +396,18 @@ class SheetViewModel: ObservableObject { } } + var moveToSparkSheetItem: MoveToSparkSheetItem? { + get { + guard activeSheetConfiguration?.id == .moveToSpark else { return nil } + return MoveToSparkSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var quickpaySheetItem: QuickpaySheetItem? { get { guard let config = activeSheetConfiguration, config.id == .quickpay else { return nil } diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift new file mode 100644 index 000000000..7d8d1320f --- /dev/null +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -0,0 +1,505 @@ +import Foundation +import Observation + +/// How fast Spark adds a transfer to the spending balance. +enum SparkTransferSpeed: Equatable { + /// The provider adds it before confirmations, for a fee up to `SparkClaimFees.instantMaxSats`. + case instant + /// Spark adds it after 3 confirmations for about `SparkClaimFees.laterSats`. + case later +} + +/// What a transfer to spending costs, priced for the amount on the confirm screen. +struct SparkTransferQuote: Equatable { + let address: String + let satsPerVbyte: UInt32 + let networkFeeSats: UInt64 + /// What reaches the deposit address: the amount, or for a max transfer the balance less the network fee. + let depositSats: UInt64 + let claimFees: SparkClaimFees + + func claimFeeSats(for speed: SparkTransferSpeed) -> UInt64 { + switch speed { + case .instant: claimFees.instantMaxSats + case .later: claimFees.laterSats + } + } + + /// What reaches the spending balance once Spark claims the deposit. + func toSpendingSats(for speed: SparkTransferSpeed) -> UInt64 { + depositSats.saturatingSub(claimFeeSats(for: speed)) + } + + /// What leaves savings. + var totalSats: UInt64 { + depositSats.saturatingAdd(networkFeeSats) + } + + /// How much more this fresh quote asks than the approved one at `speed`, 0 when it asks no more, so a rise needs + /// another swipe: the larger of the rise in the Total leaving savings and the fall in what reaches spending. A lower + /// fee on one side doesn't offset a higher one on the other. + func increase(over approved: SparkTransferQuote, speed: SparkTransferSpeed) -> UInt64 { + max( + totalSats.saturatingSub(approved.totalSats), + approved.toSpendingSats(for: speed).saturatingSub(toSpendingSats(for: speed)) + ) + } + + /// Whether the deposit stays claimable at the most Spark may charge to claim it, at either speed: the mature claim + /// runs at that hour's rate under the same cap, so a deposit at or below `instantMaxSats` could be left unclaimed. + var coversClaimFee: Bool { + depositSats > claimFees.instantMaxSats + } + + /// The minimum transfer, for a quote that doesn't cover the claim fee. + var belowClaimFeeMessage: String { + t( + "lightning__spending_amount__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(claimFees.instantMaxSats.saturatingAdd(1))] + ) + } +} + +extension AppViewModel { + /// The toast for a transfer below the claim minimum, shown wherever the transfer is priced, as on Android. + func toastBelowClaimFee(_ quote: SparkTransferQuote) { + toast( + type: .warning, + title: t("lightning__spending_amount__error_min__title"), + description: quote.belowClaimFeeMessage + ) + } +} + +/// Where a transfer to spending stands once its deposit is broadcast. +enum SparkClaimState: Equatable { + /// Instant was chosen and Spark has not listed the deposit yet. + case looking + /// Accepting the early claim. + case claiming + /// The early claim is submitted, so Spark is adding the deposit to the spending balance. + case adding + /// The deposit reached the spending balance. + case added + /// The early claim now costs more than the user accepted. + case repriced(feeSats: UInt64) + /// No early claim is offered for this deposit, so Spark adds it after 3 confirmations. + case unavailable + /// Later was chosen (or the user chose to wait), so Spark adds it after 3 confirmations. + case later + /// Spark did not list the deposit within the polling window. + case notSeen + + var isDone: Bool { + self == .added + } +} + +/// The claim decision after a broadcast, kept free of the SDK so it can be tested. +enum SparkClaimDecision { + /// What to do once Spark lists the deposit. `.claiming` means accept the early claim now. + static func onOffer(_ kind: SparkClaimOffer.Kind, speed: SparkTransferSpeed, ceilingSats: UInt64) -> SparkClaimState { + guard speed == .instant else { return .later } + switch kind { + case .automatic: + // Spark claims it by itself; success shows once it lists the deposit as claimed (`noteSettled`). + return .adding + case .standard: + return .unavailable + case let .choice(earlyFeeSats, _, _, _): + return earlyFeeSats <= ceilingSats ? .claiming : .repriced(feeSats: earlyFeeSats) + } + } + + /// What accepting the early claim did; nil keeps polling, since the offer comes with a later confirmation. + static func onClaim(_ claim: SparkEarlyClaim) -> SparkClaimState? { + switch claim { + case .claimed: .added + case .submitted: .adding + case let .repriced(requiredFeeSats): .repriced(feeSats: requiredFeeSats) + case .declined: .unavailable + case .waiting: nil + } + } +} + +/// The Spark calls the deposit claim makes, injectable so the polling can be tested without the SDK. +struct SparkClaimOperations { + var offer: (String) async throws -> SparkClaimOffer? + var claimEarly: (SparkClaimOffer) async throws -> SparkEarlyClaim + var refresh: () async -> Void + var sleep: (Duration) async -> Void + /// Clears the early-claim fee cap `claimEarly` left on the deposit, so only the claim at maturity is made. False when + /// the cap may still be there, as while another claim of the deposit is running. + var releaseEarlyClaim: (SparkClaimOffer) async -> Bool = { _ in true } + + static var live: SparkClaimOperations { + SparkClaimOperations( + offer: { try await SparkManager.shared.depositClaimOffer(txid: $0) }, + claimEarly: { try await SparkManager.shared.claimEarly($0) }, + refresh: { await SparkManager.shared.refreshState() }, + sleep: { try? await Task.sleep(for: $0) }, + releaseEarlyClaim: { await SparkManager.shared.releaseEarlyClaim($0) } + ) + } +} + +/// Savings to Spending through Spark: the amount, speed and quote of the confirm screen, and the deposit's claim on the +/// setting-up screen. Lives for the navigation stack so the claim goes on after the user leaves the setting-up screen. +@Observable +@MainActor +final class SparkTransferFlow { + /// Looks for the deposit for 5 minutes, after which Spark still adds it after 3 confirmations. + nonisolated static let pollAttempts = 60 + static let pollInterval: Duration = .seconds(5) + /// Confirmations before Spark claims a deposit by itself, and roughly how long they take. + static let matureConfirmations = 3 + static let matureMinutes = 30 + /// Previews show the state they set up, so screens skip the SDK calls there. + static var isRunningInPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + var amountSats: UInt64 = 0 + var isMaxAmount = false + /// The paired hardware wallet that funds the transfer, or nil for savings. + private(set) var walletId: String? + /// Signs and broadcasts a transfer funded from a hardware wallet. + let hwSend = HwSendCoordinator() + var speed: SparkTransferSpeed = .instant + + var quote: SparkTransferQuote? + var isQuoting = false + var quoteError: String? + + private(set) var txid: String? + var claimState: SparkClaimState = .looking + /// Each broadcast deposit keeps its own claim: a later transfer neither stops an earlier one's early claim nor takes + /// its results, which only the current transfer's screen shows. + @ObservationIgnored private var offers: [String: SparkClaimOffer] = [:] + /// Offers whose early claim was attempted, so the fee cap it left on the deposit can be cleared on Wait. + @ObservationIgnored private var claimedOffers: [String: SparkClaimOffer] = [:] + @ObservationIgnored private var ceilings: [String: UInt64] = [:] + @ObservationIgnored private var approved: (ceilingSats: UInt64, speed: SparkTransferSpeed)? + @ObservationIgnored private var approvals: [UUID: (ceilingSats: UInt64, speed: SparkTransferSpeed)] = [:] + @ObservationIgnored private var watchTasks: [String: Task] = [:] + @ObservationIgnored private var quoteRequestId = 0 + /// The mining fee a refused fixed-amount funding needed, which this transfer's quotes show at least. + @ObservationIgnored private(set) var fixedFeeFloorSats: UInt64? + @ObservationIgnored private let operations: SparkClaimOperations + @ObservationIgnored private let pollAttempts: Int + + init( + amountSats: UInt64 = 0, + isMaxAmount: Bool = false, + speed: SparkTransferSpeed = .instant, + quote: SparkTransferQuote? = nil, + claimState: SparkClaimState = .looking, + operations: SparkClaimOperations = .live, + pollAttempts: Int = SparkTransferFlow.pollAttempts + ) { + self.operations = operations + self.pollAttempts = pollAttempts + self.amountSats = amountSats + self.isMaxAmount = isMaxAmount + self.speed = speed + self.quote = quote + self.claimState = claimState + } + + func start(amountSats: UInt64, isMaxAmount: Bool, walletId: String? = nil) { + if self.amountSats != amountSats || self.isMaxAmount != isMaxAmount || self.walletId != walletId { + quote = nil + fixedFeeFloorSats = nil + // A funding followed for the earlier transfer never stands in for this one's swipe. + txid = nil + } + self.amountSats = amountSats + self.isMaxAmount = isMaxAmount + self.walletId = walletId + quoteError = nil + } + + /// A new transfer after a broadcast one starts from Instant with nothing priced; the earlier claim goes on. + func beginIfBroadcast() { + guard txid != nil else { return } + txid = nil + amountSats = 0 + isMaxAmount = false + speed = .instant + quote = nil + quoteError = nil + fixedFeeFloorSats = nil + } + + // MARK: Quote + + /// Prices the transfer at the fast rate with fresh fee estimates, so the confirm screen never shows a stale fee. + @discardableResult + func refreshQuote( + wallet: WalletViewModel, + feeEstimatesManager: FeeEstimatesManager, + hwWalletManager: HwWalletManager? = nil + ) async -> SparkTransferQuote? { + quoteRequestId += 1 + let requestId = quoteRequestId + let amountSats = amountSats + let isMaxAmount = isMaxAmount + let walletId = walletId + let fixedFeeFloorSats = fixedFeeFloorSats ?? 0 + isQuoting = true + defer { + if requestId == quoteRequestId { + isQuoting = false + } + } + do { + let address = try await SparkManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: true) else { + throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "No fee estimates") + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + let networkFeeSats: UInt64 + let depositSats: UInt64 + if let walletId, let hwWalletManager { + networkFeeSats = try await hwWalletManager.estimateOfflineFundingMiningFee( + walletId: walletId, + address: address, + sats: amountSats, + satsPerVByte: UInt64(rate) + ) + depositSats = amountSats + } else if isMaxAmount { + networkFeeSats = try await wallet.estimateSendAllFee(address: address, satsPerVByte: rate) + depositSats = UInt64(max(0, wallet.spendableOnchainBalanceSats)).saturatingSub(networkFeeSats) + } else { + // A fixed amount's coin selection can need more fee than its quote, so after a refusal it shows at least that. + networkFeeSats = try await max( + wallet.calculateTotalFee(address: address, amountSats: amountSats, satsPerVByte: rate), + fixedFeeFloorSats + ) + depositSats = amountSats + } + let claimFees = try await SparkManager.shared.depositClaimFees(amountSats: depositSats) + let fresh = SparkTransferQuote( + address: address, + satsPerVbyte: rate, + networkFeeSats: networkFeeSats, + depositSats: depositSats, + claimFees: claimFees + ) + guard requestId == quoteRequestId else { return nil } + quote = fresh + quoteError = nil + return fresh + } catch { + Logger.error("Failed to price the transfer to spending: \(error)", context: "SparkTransferFlow") + guard requestId == quoteRequestId else { return nil } + quote = nil + quoteError = t("lightning__spending_confirm__error_quote") + return nil + } + } + + // MARK: Claim + + /// Records the broadcast deposit and starts its claim for the chosen speed. + func onBroadcast(txid: String, ceilingSats: UInt64? = nil) { + self.txid = txid + ceilings[txid] = ceilingSats ?? quote?.claimFees.instantMaxSats ?? 0 + offers[txid] = nil + claimState = speed == .instant ? .looking : .later + watchTasks[txid]?.cancel() + watchTasks[txid] = nil + if speed == .instant { + watch(txid: txid) + } + } + + /// A fixed-amount funding refused for needing `preparedFeeSats` keeps that fee in this transfer's quotes, so the next + /// swipe approves it. + func onFundingChanged(preparedFeeSats: UInt64?) { + guard !isMaxAmount, walletId == nil else { return } + fixedFeeFloorSats = preparedFeeSats + } + + /// Records the early-claim ceiling and speed the user swiped, as the confirm screen keeps requoting while a payment + /// whose outcome is unknown waits on the pending screen. + func approveSwipe() { + approved = (ceilingSats: quote?.claimFees.instantMaxSats ?? 0, speed: speed) + } + + /// Ties the last swipe's approval to the funding it left unresolved. A later swipe rejected while that funding is + /// still unresolved finds it already tied, so it can't change the ceiling or speed the user approved for it. + func bindApproval(to attemptId: UUID) { + guard let approved, approvals[attemptId] == nil else { return } + approvals[attemptId] = approved + } + + /// Ties the approval to the unresolved funding only when the swipe that failed with `error` created it. A swipe + /// refused before that, as blocked by or a duplicate of an earlier funding, finds the earlier one unresolved, and + /// after a relaunch nothing is tied to it yet, so its early claim must still ask. + func bindApproval(to attemptId: UUID, after error: Error) { + switch error { + case OnchainSendAttemptError.blocked, OnchainSendAttemptError.duplicate: return + default: bindApproval(to: attemptId) + } + } + + /// Follows a recovered funding a swipe of this flow approved, whichever screen shows when it resolves, so its claim + /// starts and a later swipe can't fund it again. False for one it doesn't own or already follows. + func followIfOwned(txid: String, attemptId: UUID) -> Bool { + guard approvals[attemptId] != nil, self.txid != txid else { return false } + onRecoveredBroadcast(txid: txid, attemptId: attemptId) + return true + } + + /// Starts the claim of a funding whose outcome was unknown at the swipe, at the ceiling and speed then approved; with + /// nothing approved for that funding, an early claim asks first. + func onRecoveredBroadcast(txid: String, attemptId: UUID) { + let approved = approvals.removeValue(forKey: attemptId) ?? (ceilingSats: 0, speed: speed) + speed = approved.speed + onBroadcast(txid: txid, ceilingSats: approved.ceilingSats) + } + + /// Ends the transfer on screen once Spark lists its deposit as claimed, whatever the speed, so Later or Wait + /// doesn't stay in progress after the deposit reached spending. + func noteSettled(_ deposits: [SparkDeposit]) { + guard let txid, !claimState.isDone, + deposits.contains(where: { $0.isClaimed && $0.txid.caseInsensitiveCompare(txid) == .orderedSame }) + else { return } + watchTasks[txid]?.cancel() + watchTasks[txid] = nil + claimState = .added + } + + /// Accepts the early claim at the repriced fee. + func payRepriced() { + guard case let .repriced(feeSats) = claimState, let txid else { return } + ceilings[txid] = feeSats + claimState = .claiming + watch(txid: txid) + } + + /// Leaves the deposit to the claim after 3 confirmations, clearing the fee cap an early claim attempt left on it, so + /// Spark doesn't still claim it early. + func waitForConfirmations() { + if let txid { + watchTasks[txid]?.cancel() + watchTasks[txid] = nil + if let offer = claimedOffers.removeValue(forKey: txid) { + let operations = operations + let attempts = max(pollAttempts, 1) + // Not awaited: the SDK call ignores cancellation and can take a while. Tried again while another claim of + // the deposit, such as Spark's own sync, leaves the cap in place. + Task { + for attempt in 1 ... attempts { + if await operations.releaseEarlyClaim(offer) { + return + } + if attempt < attempts { + await operations.sleep(Self.pollInterval) + } + } + Logger.warn("Gave up clearing the early claim cap of Spark deposit \(offer.txid)", context: "SparkTransferFlow") + } + } + } + claimState = .later + } + + /// Waits for the current transfer's claim to settle; for tests. + func waitForClaim() async { + guard let txid else { return } + await watchTasks[txid]?.value + } + + private func watch(txid: String) { + watchTasks[txid]?.cancel() + watchTasks[txid] = Task { [weak self] in + await self?.pollDeposit(txid: txid) + self?.watchTasks[txid] = nil + } + } + + /// Shows a claim's progress only while its deposit is the transfer on screen. + private func setClaimState(_ state: SparkClaimState, for txid: String) { + guard !Task.isCancelled, self.txid == txid else { return } + claimState = state + } + + private func pollDeposit(txid: String) async { + for _ in 0 ..< pollAttempts { + if Task.isCancelled { + return + } + do { + let found = try await operations.offer(txid) + guard !Task.isCancelled else { return } + if found == nil, offers[txid] != nil { + // Listed before and no longer unclaimed: the early claim went through. + setClaimState(.added, for: txid) + return + } + if let found { + offers[txid] = found + let next = SparkClaimDecision.onOffer(found.kind, speed: .instant, ceilingSats: ceilings[txid] ?? 0) + guard next == .claiming else { + setClaimState(next, for: txid) + return + } + setClaimState(.claiming, for: txid) + claimedOffers[txid] = found + let claim = try await operations.claimEarly(found) + guard !Task.isCancelled else { return } + if let state = SparkClaimDecision.onClaim(claim) { + setClaimState(state, for: txid) + return + } + } + } catch { + Logger.warn("Failed to claim Spark deposit \(txid) early: \(error)", context: "SparkTransferFlow") + } + await operations.refresh() + await operations.sleep(Self.pollInterval) + } + guard !Task.isCancelled else { return } + // A deposit Spark listed whose early claim never came is waiting for its confirmations, not unseen. + guard offers[txid] == nil else { + Logger.info("No early claim for Spark deposit \(txid) within the polling window", context: "SparkTransferFlow") + setClaimState(.unavailable, for: txid) + return + } + Logger.warn("Spark did not list deposit \(txid) in time", context: "SparkTransferFlow") + setClaimState(.notSeen, for: txid) + } +} + +/// A transfer to the Spark deposit address whose outcome was unknown, resolved later from the Send sheet's pending screen. +enum SparkFundingRecovery { + /// Set while Spending Confirm is open: it then owns a recovered funding, at the speed the user chose. + @MainActor static var confirmScreenOpen = false + + /// The Send sheet's pending screen goes home only when no open Spending Confirm owns the recovered funding, so one + /// path decides the claim speed and where the user lands. + static func pendingScreenGoesHome(confirmScreenOpen: Bool) -> Bool { + !confirmScreenOpen + } + + /// Lists the deposit as a transfer and tells Spark about it, so it shows as pending and is claimed. + static func handOff(txid: String, amountSats: UInt64) async { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + await SparkManager.shared.onDepositBroadcast(txid: txid, amountSats: amountSats) + } + + /// Hands a recovered Spark funding to Spark and returns true, so the caller leaves the funding flow whose confirm + /// screen would offer the sent transfer again; false for any other resolution. + static func follow( + _ resolution: OnchainSendLocalResolution, + handOff: (String, UInt64) async -> Void = { await handOff(txid: $0, amountSats: $1) } + ) async -> Bool { + guard resolution.isSparkFunding else { return false } + await handOff(resolution.txid, resolution.amountSats) + return true + } +} diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index e5388d1fa..bfef8b1a5 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -5,7 +5,19 @@ import SwiftUI @MainActor class TransferViewModel: ObservableObject { - @Published var selectedChannelIds: [String] = [] + /// Channels picked in the Advanced list of Transfer to Savings; nil transfers them all. + @Published var selectedChannelIds: Set? + /// Whether the Advanced list of Transfer to Savings picks Spark's balance. + @Published var isSparkSelected = true + /// Spark's fee to withdraw its balance to savings, for the confirm screen; nil until priced. + @Published var sparkWithdrawFeeSats: UInt64? + @Published var isPricingSparkWithdrawal = false + /// Spark's balance would leave less than the dust limit after its withdrawal fee, so it can't move to savings. + @Published private(set) var isSparkWithdrawTooSmall = false + /// While the swipe prices Spark's withdrawal again, nothing else may commit the transfer. + @Published private(set) var isRecheckingSparkFee = false + /// Why the last transfer's Spark withdrawal failed, so the progress screen shows it instead of success. + @Published private(set) var sparkWithdrawError: String? @Published var channelsToClose: [ChannelDetails] = [] /// How the LN -> onchain "transfer to savings" is executed. Closing a channel is the @@ -28,6 +40,8 @@ class TransferViewModel: ObservableObject { /// 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? + /// Whether the committed transfer withdraws Spark's balance to savings. + private var withdrawsSpark = false /// 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. @@ -36,6 +50,29 @@ class TransferViewModel: ObservableObject { 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 + /// Prices Spark's withdrawal for the confirm screen; tests replace it. + var priceSparkWithdrawal: @Sendable (String) async throws -> UInt64 = { try await SparkManager.shared.withdrawAllFeeSats(to: $0) } + var sparkBalanceSats: @MainActor () -> UInt64 = { SparkManager.shared.balanceSats } + /// Sends Spark's balance to the address, refusing above the shown fee; tests replace it. + var withdrawSpark: @MainActor (String, UInt64) async throws -> Void = { try await SparkManager.shared.withdrawAll(to: $0, maxFeeSats: $1) } + /// Whether Spark reads Savings' chain, so its withdrawal reaches Savings; tests replace it. + var sparkReadsSavingsChain: @MainActor () -> Bool = { + SparkManager.readsBitkitChain(network: Env.network, chainServiceUrl: Env.sparkChainServiceUrl(for: Env.network)) + } + + /// How long the confirm screen waits for Spark's withdrawal fee before offering to retry; a full-balance quote can + /// take minutes. + var sparkFeeTimeout: TimeInterval = 180 + private var sparkFeeTask: Task? + /// The Spark balance the fee was priced for, so it is priced once per balance. + private var sparkFeeBalanceSats: UInt64? + /// Counts each pricing of a new Spark balance, so a swipe's recheck can tell the fee shown changed under it. + private var sparkFeeGeneration = 0 + /// The Spark withdrawal fee shown when the transfer was confirmed, the most `withdrawSparkToSavings` pays. + private var confirmedSparkFeeSats: UInt64? + /// One Savings address for Spark's withdrawal, priced on the confirm screen and then paid to, so the withdrawal + /// reuses the quote the fee came from. + private var sparkWithdrawAddress: String? /// Minimum sats held back from a swap to cover Lightning routing fees. private static let minLnRoutingFeeReserveSats: UInt64 = 10 @@ -226,29 +263,268 @@ class TransferViewModel: ObservableObject { // MARK: - Savings Transfer Methods - func setSelectedChannelIds(_ ids: [String]) { - selectedChannelIds = ids + /// Opens Transfer to Savings with nothing left from an earlier visit that ended before the swipe. + func beginSavingsTransfer() { + selectAllForSavings() + } + + /// Back to transferring everything, the default. + func selectAllForSavings() { + selectedChannelIds = nil + isSparkSelected = true + } + + /// What the current pick moves, out of the ready channels and Spark's balance. + func savingsSelection(readyChannelIds: [String], sparkSats: UInt64) -> SavingsSelection { + SavingsSelection.resolve( + readyChannelIds: readyChannelIds, + selectedChannelIds: selectedChannelIds, + isSparkSelected: isSparkSelected, + sparkSats: sparkSats, + isSparkTooSmall: isSparkWithdrawTooSmall + ) + } + + /// Prices withdrawing Spark's `balanceSats` to `savingsAddress`, once per balance, as `withdrawSparkToSavings` + /// then sends it. Gives up after `sparkFeeTimeout`, leaving the fee unpriced. + func loadSparkWithdrawFee(balanceSats: UInt64, savingsAddress: String) { + let isPriced = sparkFeeTask != nil || sparkWithdrawFeeSats != nil + if balanceSats == sparkFeeBalanceSats, isPriced { + return + } + sparkFeeTask?.cancel() + sparkFeeTask = nil + sparkFeeGeneration += 1 + sparkFeeBalanceSats = balanceSats + sparkWithdrawFeeSats = nil + isSparkWithdrawTooSmall = false + isPricingSparkWithdrawal = false + if sparkWithdrawAddress == nil, !savingsAddress.isEmpty { + sparkWithdrawAddress = savingsAddress + } + guard balanceSats > 0, let address = sparkWithdrawAddress else { + sparkFeeBalanceSats = nil + return + } + + isPricingSparkWithdrawal = true + let price = priceSparkWithdrawal + let timeout = sparkFeeTimeout + sparkFeeTask = Task { [weak self] in + // Spark refuses to quote a balance that even its cheapest fee leaves as dust, which is too small, not unpriced. + let priced = await Self.withTimeout(seconds: timeout) { () -> SparkWithdrawalPrice in + do { + return try await .fee(price(address)) + } catch where SparkManager.isBelowMinimum(error) { + return .belowMinimum + } + } + guard let self, !Task.isCancelled else { return } + sparkFeeTask = nil + isPricingSparkWithdrawal = false + let fee: UInt64? = if case let .fee(sats) = priced { + sats + } else { + nil + } + if priced == .belowMinimum { + isSparkWithdrawTooSmall = true + sparkFeeBalanceSats = nil + } else if let fee, balanceSats < fee + UInt64(Env.dustLimit) { + isSparkWithdrawTooSmall = true + sparkFeeBalanceSats = nil + } else if let fee { + sparkWithdrawFeeSats = fee + } else { + sparkFeeBalanceSats = nil + Logger.warn("Spark withdrawal fee not priced within \(timeout)s or failed", context: "TransferViewModel") + } + } + } + + /// Whether the swipe may commit: a transfer that withdraws Spark waits until its fee is shown. + func isSparkWithdrawalPriced(withdrawsSpark: Bool) -> Bool { + !withdrawsSpark || sparkWithdrawFeeSats != nil + } + + /// Prices the withdrawal again at swipe time and returns the fee it approved. Throws `SparkWithdrawalFeeIncreased` + /// after showing the higher fee, so the user swipes again for it, `SavingsCommitRefused` when Spark's balance was + /// priced again meanwhile, and throws when it can't be priced. + @discardableResult + func recheckSparkWithdrawFee() async throws -> UInt64 { + guard let shownFeeSats = sparkWithdrawFeeSats, let address = sparkWithdrawAddress else { + throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") + } + let generation = sparkFeeGeneration + let price = priceSparkWithdrawal + isRecheckingSparkFee = true + defer { isRecheckingSparkFee = false } + guard let feeSats = await Self.withTimeout(seconds: sparkFeeTimeout, { try await price(address) }) else { + throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal re-quote failed") + } + guard sparkFeeGeneration == generation else { throw SavingsCommitRefused() } + do { + try SparkManager.checkWithdrawalFee(feeSats, shownFeeSats: shownFeeSats) + } catch { + sparkWithdrawFeeSats = feeSats + throw error + } + return shownFeeSats + } + + /// The result of `operation`, or nil when it throws, takes longer than `seconds` or the caller is cancelled. Returns + /// at the deadline even while `operation` ignores cancellation, as the Breez SDK calls do. + private nonisolated static func withTimeout( + seconds: TimeInterval, + _ operation: @escaping @Sendable () async throws -> T + ) async -> T? { + let result = FirstResult() + let work = Task { + do { + try await result.resume(operation()) + } catch { + Logger.error("Failed to price the Spark withdrawal", context: error.localizedDescription) + result.resume(nil) + } + } + let deadline = Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + result.resume(nil) + } + defer { + work.cancel() + deadline.cancel() + } + return await withTaskCancellationHandler { + await result.value() + } onCancel: { + result.resume(nil) + } + } + + /// The swipe: prices a Spark withdrawal again, then commits the transfer. Throws `SavingsCommitRefused` when the + /// commit is refused, as when Spark's balance changed during the recheck, so the swipe resets instead of opening + /// progress. + func confirmSavingsSwipe(channels: [ChannelDetails], withdrawsSpark: Bool) async throws { + var approvedSparkFeeSats: UInt64? + if withdrawsSpark { + approvedSparkFeeSats = try await recheckSparkWithdrawFee() + } + guard onTransferToSavingsConfirm(channels: channels, withdrawsSpark: withdrawsSpark, approvedSparkFeeSats: approvedSparkFeeSats) else { + throw SavingsCommitRefused() + } } - /// Commit the transfer and pick how it runs. A swap needs a priced quote, so without one + /// Commit the transfer and pick how it runs. A Spark withdrawal commits only at a fee shown and not being priced again, + /// returning false otherwise, and pays at most `approvedSparkFeeSats` when the swipe rechecked one. 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) { + @discardableResult + func onTransferToSavingsConfirm( + channels: [ChannelDetails], + withdrawsSpark: Bool, + mode: SavingsTransferMode? = nil, + approvedSparkFeeSats: UInt64? = nil + ) -> Bool { + guard !withdrawsSpark || (isSparkWithdrawalPriced(withdrawsSpark: true) && !isRecheckingSparkFee) else { return false } savingsTransferMode = mode ?? resolvedSavingsTransferMode - selectedChannelIds = [] + selectAllForSavings() channelsToClose = channels + self.withdrawsSpark = withdrawsSpark + // Spark withdraws at the fee shown now, even if the screen prices it again before the withdrawal runs. + confirmedSparkFeeSats = withdrawsSpark ? approvedSparkFeeSats ?? sparkWithdrawFeeSats : nil // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. savingsSwapRun = nil + return true } private var resolvedSavingsTransferMode: SavingsTransferMode { savingsSwapState.quote != nil ? .swap : .close } + /// Whether Transfer to Savings closes channels, rather than only withdrawing Spark's balance. + var hasChannelsToClose: Bool { + !channelsToClose.isEmpty + } + + /// Withdraws Spark's balance when selected, then closes the selected channels. A failed withdrawal doesn't stop + /// the closes; it is kept in `sparkWithdrawError` for the progress screen. func closeSelectedChannels() async throws -> [ChannelDetails] { + await withdrawSelectedSpark() return try await closeChannels(channels: channelsToClose) } + private func withdrawSelectedSpark() async { + sparkWithdrawError = nil + guard withdrawsSpark else { return } + do { + try await withdrawSparkToSavings() + } catch { + Logger.error("Failed to withdraw Spark to savings: \(error)", context: "TransferViewModel") + sparkWithdrawError = SparkErrorCopy.message(for: error, fallback: t("lightning__savings_spark_failed__text")) + } + } + + private func withdrawSparkToSavings() async throws { + sparkFeeTask?.cancel() + sparkFeeTask = nil + isPricingSparkWithdrawal = false + guard sparkBalanceSats() > 0, !SparkManager.shared.isExiting else { return } + guard let confirmedFeeSats = confirmedSparkFeeSats, let address = sparkWithdrawAddress else { + throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") + } + // On regtest Spark may read another chain than Savings, where Savings' address would take Spark's funds. + guard sparkReadsSavingsChain() else { throw SparkReadsAnotherChain() } + try await withdrawSpark(address, confirmedFeeSats) + sparkWithdrawAddress = nil + sparkFeeBalanceSats = nil + sparkWithdrawFeeSats = nil + } + + // MARK: - Spark Funding + + /// Sends `amountSats` of savings to the Spark deposit address and returns its txid. Spark claims the deposit + /// early when the provider offers it within the accepted fee, otherwise after 3 confirmations. + func fundSpark(address: String, amountSats: UInt64, satsPerVbyte: UInt32, txFee: UInt64, isMaxAmount: Bool) async throws -> String { + let walletId = OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex) + let attempts = OnchainSendAttemptService.shared + let result = try await attempts.send( + using: lightningService, + address: address, + amountSats: amountSats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: nil, + isMaxAmount: isMaxAmount, + followupContext: OnchainSendFollowupContext( + feeSats: txFee, + feeRate: satsPerVbyte, + tags: [], + contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ), + isSparkFunding: true + ) + let txid = try Self.fundedTxid(result) + _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) + await SparkFundingRecovery.handOff(txid: txid, amountSats: amountSats) + Logger.info("Funded Spark wallet with \(amountSats) sats in \(txid)", context: "TransferViewModel") + await onBalanceRefresh?() + return txid + } + + /// The txid of an accepted funding. A rejected or unknown one is kept and broadcast again, so it is unresolved, not + /// failed, and is never swiped a second time. + static func fundedTxid(_ result: OnchainSendResult) throws -> String { + switch result { + case let .accepted(txid): + return txid + case let .rejected(txid, reason): + throw SparkFundingUnresolved(txid: txid, rejectionReason: reason) + case let .unknown(txid): + throw SparkFundingUnresolved(txid: txid) + } + } + func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] { var failedChannels: [ChannelDetails] = [] var successfulChannels: [ChannelDetails] = [] @@ -528,6 +804,8 @@ class TransferViewModel: ObservableObject { } do { + // The swap moves only the channels' balance, so a selected Spark balance is withdrawn on its own. + await withdrawSelectedSpark() 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") @@ -539,7 +817,8 @@ class TransferViewModel: ObservableObject { // 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) + // Quoted against the channels; a deselected Spark balance must never pay it if the channels can't. + let paymentId = try await lightningService.send(bolt11: swap.invoice, channelsOnly: true) let result = await awaitSwapOutcome( swapId: swap.id, @@ -671,6 +950,54 @@ actor SwapPaymentFailureCapture { } } +/// What Transfer to Savings moves: the channels to close or swap out and whether Spark's balance is withdrawn. +struct SavingsSelection: Equatable { + let channelIds: [String] + let withdrawsSpark: Bool + + var isEmpty: Bool { + channelIds.isEmpty && !withdrawsSpark + } + + /// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a balance + /// its withdrawal fee leaves something of, so one too small to move never holds the channels back. Its balance is 0 + /// during an emergency exit. + static func resolve( + readyChannelIds: [String], + selectedChannelIds: Set?, + isSparkSelected: Bool, + sparkSats: UInt64, + isSparkTooSmall: Bool = false + ) -> SavingsSelection { + SavingsSelection( + channelIds: readyChannelIds.filter { selectedChannelIds?.contains($0) ?? true }, + withdrawsSpark: isSparkSelected && sparkSats > 0 && !isSparkTooSmall + ) + } +} + +/// What the progress screen shows once a transfer to savings ran. +enum SavingsTransferOutcome: Equatable { + case success + /// Channels failed to close cooperatively and are retried. + case channelsInterrupted + /// Spark's withdrawal failed; `channelsMoved` when the selected channels still moved. + case sparkFailed(channelsMoved: Bool) + + static func resolve(sparkFailed: Bool, movedChannels: Bool, channelsFailedToClose: Bool) -> SavingsTransferOutcome { + if channelsFailedToClose { + return .channelsInterrupted + } + if sparkFailed { + return .sparkFailed(channelsMoved: movedChannels) + } + return .success + } +} + +/// The swipe's commit was refused, as Spark's withdrawal fee is being priced again; nothing was committed. +struct SavingsCommitRefused: Error {} + /// Whether a transfer to savings swaps funds out or closes a channel (default). enum SavingsTransferMode { case swap @@ -727,3 +1054,53 @@ actor ChannelPendingCapture { return channelData } } + +/// The first value any of several racing tasks resumes with; later ones are dropped. +private final class FirstResult: @unchecked Sendable { + private let lock = NSLock() + private var result: T? + private var isResolved = false + private var continuation: CheckedContinuation? + + func resume(_ value: T) { + lock.lock() + guard !isResolved else { return lock.unlock() } + isResolved = true + let waiting = continuation + continuation = nil + if waiting == nil { + result = value + } + lock.unlock() + waiting?.resume(returning: value) + } + + func value() async -> T { + await withCheckedContinuation { continuation in + lock.lock() + if isResolved, let result { + lock.unlock() + continuation.resume(returning: result) + } else { + self.continuation = continuation + lock.unlock() + } + } + } +} + +/// A Spark withdrawal's fee, or Spark's refusal to quote a balance below its minimum. +private enum SparkWithdrawalPrice: Equatable { + case fee(UInt64) + case belowMinimum +} + +/// A Spark funding the network rejected or whose broadcast outcome is unknown. It is kept and broadcast again, so it is +/// never sent a second time. +struct SparkFundingUnresolved: Error { + let txid: String + var rejectionReason: String? +} + +/// Spark reads another chain than Savings, so a withdrawal to Savings would never arrive. +private struct SparkReadsAnotherChain: Error {} diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index b120b850c..be9e3d75d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1,4 +1,5 @@ import BitkitCore +import Combine import LDKNode import SwiftUI @@ -9,6 +10,7 @@ struct SwapUpdatesStopTimeoutError: Error {} class WalletViewModel: ObservableObject { let usdtWallet: UsdtWalletManager @Published var walletExists: Bool? = nil + private var cancellables = Set() @Published var isSyncingWallet = false // Syncing both LN and on chain @AppStorage("totalBalanceSats") var totalBalanceSats: Int = 0 // Combined onchain and LN @AppStorage("totalOnchainSats") var totalOnchainSats: Int = 0 // The total balance of our on-chain wallet @@ -24,6 +26,7 @@ class WalletViewModel: ObservableObject { @Published private(set) var isReceiveInvoiceUpdating = false @Published private(set) var receiveInvoiceError: String? private var receiveRefreshID = UUID() + private var sparkReadiness = SparkReadinessTransition() private var receiveCommitID = UUID() var hasCoherentReceiveInvoice: Bool { @@ -75,7 +78,7 @@ class WalletViewModel: ObservableObject { @Published var peers: [PeerDetails]? @Published var channels: [ChannelDetails]? private var eventHandlers: [String: (Event) -> Void] = [:] - private var watchSendContinuations: [String: CheckedContinuation] = [:] + private(set) var watchSendContinuations: [String: CheckedContinuation] = [:] private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false @@ -173,6 +176,26 @@ class WalletViewModel: ObservableObject { } lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } + lightningService.channelFreeRailSupported = { await MainActor.run { SparkManager.shared.isSupported } } + // A Spark funding whose outcome was unknown resolves later, from the pending screen or once the chain shows it. + OnchainSendAttemptService.localResolutionPublisher + .sink { resolution in + Task { _ = await SparkFundingRecovery.follow(resolution) } + } + .store(in: &cancellables) + SparkManager.shared.onStateChanged = { [weak self] in + Task { @MainActor in + guard let self else { return } + // Paykit may have published without a Lightning endpoint before Spark connected, so republish once + // it becomes ready; that refresh also updates the balance and the receive invoice. + if self.sparkReadiness.becameReady(SparkManager.shared.isReady) { + await self.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "spark-ready refresh", forceRefreshLightning: true) + return + } + await self.updateBalanceState() + try? await self.refreshBip21(reportsError: false) + } + } lightningService.onchainActivityWillPersist = { [weak self] in guard let self else { return nil } let isSessionCurrent = publicPaykitPublicationAdmission() @@ -195,10 +218,20 @@ class WalletViewModel: ObservableObject { walletExists = try Keychain.exists(key: .bip39Mnemonic(index: 0)) } + /// The event streams of moves still waiting for their outcome, by listener id: `wipe` ends them, since the wiped + /// node reports no late outcome. + private var moveEventSinks: [String: AsyncStream.Continuation] = [:] + func addOnEvent(id: String, handler: @escaping (Event) -> Void) { eventHandlers[id] = handler } + func notifyEventHandlers(_ event: Event) { + for handler in eventHandlers.values { + handler(event) + } + } + func removeOnEvent(id: String) { eventHandlers.removeValue(forKey: id) } @@ -261,6 +294,8 @@ class WalletViewModel: ObservableObject { } syncState() + // Spark needs no node, so it starts alongside it and keeps running while the node stops. + Task { await SparkManager.shared.start(walletIndex: walletIndex) } do { let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl let rgsServerUrl = rgsConfigService.getCurrentServerUrl() @@ -284,10 +319,7 @@ class WalletViewModel: ObservableObject { } try await nodeLifecycle.start(onEvent: { event in Task { @MainActor in - // Notify all event handlers - for handler in self.eventHandlers.values { - handler(event) - } + self.notifyEventHandlers(event) // Handle specific events for targeted UI updates switch event { @@ -643,9 +675,14 @@ class WalletViewModel: ObservableObject { } } - func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil, isPaykitEndpoint: Bool = false) async throws -> String { let finalExpirySecs = expirySecs ?? 60 * 60 * 24 - let invoice = try await lightningService.receive(amountSats: amountSats, description: note, expirySecs: finalExpirySecs) + let invoice = try await lightningService.receive( + amountSats: amountSats, + description: note, + expirySecs: finalExpirySecs, + isPaykitEndpoint: isPaykitEndpoint + ) return invoice.lowercased() } @@ -1180,6 +1217,104 @@ class WalletViewModel: ObservableObject { return try await watchSend(hash: String(hash)) } + /// Pays from the channels only, with the routing fee capped at `maxFeeMsat`, and waits for the payment to settle. + /// Pays `bolt11` from the channels only and waits up to `timeout` for its outcome; `nil` means it is still in flight. + /// It listens before sending, so an outcome LDK reports before the send returns is not missed, and it matches a + /// failure by payment id as well as by hash, since LDK can report one without its hash. After a timeout it keeps + /// listening: a failure that arrives later calls `onLateFailure`, and a success `onLateSuccess`. + func sendOverChannels( + bolt11: String, + sats: UInt64, + maxFeeMsat: UInt64, + timeout: Duration = .seconds(120), + onTimeout: @escaping @MainActor () -> Void = {}, + onLateFailure: @escaping @MainActor () -> Void = {}, + onLateSuccess: @escaping @MainActor (SettledLightningPayment) -> Void = { _ in } + ) async throws -> SettledLightningPayment? { + // LDK's default route parameters, with the routing fee capped. + let params = RouteParametersConfig( + maxTotalRoutingFeeMsat: maxFeeMsat, + maxTotalCltvExpiryDelta: 1008, + maxPathCount: 10, + maxChannelSaturationPowerOfHalf: 2 + ) + let listenerId = "move-to-spark-\(UUID().uuidString)" + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + addOnEvent(id: listenerId) { sink.yield($0) } + moveEventSinks[listenerId] = sink + let id: String + do { + id = try await String(lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true)) + } catch { + removeOnEvent(id: listenerId) + moveEventSinks.removeValue(forKey: listenerId) + sink.finish() + throw error + } + return try await Self.settle( + paymentId: id, + events: events, + timeout: timeout, + onTimeout: onTimeout, + onLateFailure: onLateFailure, + onLateSuccess: onLateSuccess, + onEnd: { [weak self] in + self?.removeOnEvent(id: listenerId) + self?.moveEventSinks.removeValue(forKey: listenerId) + sink.finish() + } + ) + } + + /// Waits for the payment `paymentId`'s outcome in `events`, or `nil` after `timeout`. `onTimeout` runs in the same + /// step that gives up waiting, so an outcome that arrives right after already finds the caller's in-flight state + /// and goes to `onLateFailure` or `onLateSuccess`. + static func settle( + paymentId id: String, + events: AsyncStream, + timeout: Duration, + onTimeout: @escaping @MainActor () -> Void, + onLateFailure: @escaping @MainActor () -> Void, + onLateSuccess: @escaping @MainActor (SettledLightningPayment) -> Void, + onEnd: @escaping @MainActor () -> Void = {} + ) async throws -> SettledLightningPayment? { + try await withCheckedThrowingContinuation { continuation in + var isAnswered = false + Task { @MainActor in + defer { onEnd() } + for await event in events { + let outcome: Result + switch event { + case let .paymentSuccessful(_, paymentHash, _, feePaidMsat) where paymentHash == id: + outcome = .success(SettledLightningPayment(paymentHash: paymentHash, feePaidSats: (feePaidMsat ?? 0) / 1000)) + case let .paymentFailed(paymentId, paymentHash, reason) where paymentHash == id || paymentId == id: + outcome = .failure(AppError(paymentFailureReason: reason)) + default: + continue + } + if isAnswered { + switch outcome { + case let .success(settled?): onLateSuccess(settled) + case .success: break + case .failure: onLateFailure() + } + } else { + isAnswered = true + continuation.resume(with: outcome) + } + return + } + } + Task { @MainActor in + try? await Task.sleep(for: timeout) + guard !isAnswered else { return } + isAnswered = true + onTimeout() + continuation.resume(returning: nil) + } + } + } + /// Observes task cancellation so `waitForLightningPayment`'s task group can unwind after its /// timeout instead of awaiting a continuation that only an LDK event would resume. private func watchSend(hash: String) async throws -> SettledLightningPayment { @@ -1211,6 +1346,9 @@ class WalletViewModel: ObservableObject { } syncState() + Task { [weak self] in + await self?.finishWatchSendIfRailAlreadySettled(hash: hash, eventId: eventId) + } } } onCancel: { Task { @MainActor [weak self] in @@ -1219,6 +1357,30 @@ class WalletViewModel: ObservableObject { } } + /// A Spark-to-Spark payment settles inside the send call, so its completion event can fire before `watchSend` + /// starts listening; the rail's own payment list still has the outcome. + private func finishWatchSendIfRailAlreadySettled(hash: String, eventId: String) async { + // A send skipped because the node already paid the invoice gets no new event. + if let paid = await lightningService.nodePayments()?.first(where: { + $0.direction == .outbound && $0.status == .succeeded && $0.id.caseInsensitiveCompare(hash) == .orderedSame + }) { + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: (paid.feePaidMsat ?? 0) / 1000))) + return + } + // A retry over a channel settles from LDK; an earlier Spark attempt's outcome doesn't decide it. + guard lightningService.attemptRail(paymentHash: hash) != .ldk else { return } + let payments = await (try? lightningService.channelFreeRailPayments()) ?? [] + let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: hash) + switch payment?.status { + case .succeeded: + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: payment?.feeSats ?? 0))) + case .failed: + finishWatchSend(id: eventId, result: .failure(AppError(paymentFailureReason: nil))) + case .pending, nil: + break + } + } + private func finishWatchSend(id: String, result: Result) { guard let continuation = watchSendContinuations.removeValue(forKey: id) else { return } removeOnEvent(id: id) @@ -1406,21 +1568,38 @@ class WalletViewModel: ObservableObject { } } + /// Spark's part of Spending: its balance, or while an exit runs only what it holds beyond the exit's leaves. + nonisolated static func spendingSparkSats(isExiting: Bool, balanceSats: UInt64, outsideExitSats: UInt64?) -> UInt64 { + isExiting ? (outsideExitSats ?? 0) : balanceSats + } + func updateBalanceState() async { // Ensure we have fresh data from LDK before computing balance await lightningService.refreshCache() do { try? await transferService.syncTransferStates() - let state = try await balanceManager.deriveBalanceState() + let state = try await balanceManager.deriveBalanceState(exitInTransfer: SparkExitManager.shared.inTransfer) + let spark = SparkManager.shared balanceInTransferToSavings = Int(state.balanceInTransferToSavings) - balanceInTransferToSpending = Int(state.balanceInTransferToSpending) + balanceInTransferToSpending = Int(state.balanceInTransferToSpending + spark.pendingDepositSats) + // During an emergency exit the exit's leaves are on their way to savings and counted there instead; what + // Spark holds beyond them stays in Spending. + let outsideExitSats = SparkExitManager.shared.record.flatMap { + SparkExitMath.outsideExitSats(sparkBalanceSats: spark.balanceSats, record: $0) + } + let sparkBalanceSats = Self.spendingSparkSats( + isExiting: spark.isExiting, + balanceSats: spark.shownBalanceSats, + outsideExitSats: outsideExitSats + ) // Update display values with adjusted balances totalOnchainSats = Int(state.totalOnchainSats) - totalLightningSats = Int(state.totalLightningSats) - totalBalanceSats = Int(state.totalBalanceSats) - maxSendLightningSats = Int(state.maxSendLightningSats) + totalLightningSats = Int(state.totalLightningSats + sparkBalanceSats) + totalBalanceSats = Int(state.totalBalanceSats + sparkBalanceSats + spark.pendingDepositSats) + // A payment goes over one rail, so the larger of the two balances caps it. + maxSendLightningSats = Int(max(state.maxSendLightningSats, spark.isExiting ? 0 : spark.sendableSats)) // Update channel fundable balance (excludes Legacy UTXOs and accounts for funding tx fee) let (selectedType, monitoredTypes) = LightningService.addressTypeStateFromUserDefaults() @@ -1435,6 +1614,8 @@ class WalletViewModel: ObservableObject { let activeTransfers = try? transferService.getActiveTransfers() let forceCloseTransfer = activeTransfers?.first { $0.type == .forceClose && !$0.isSettled + } ?? activeTransfers?.first { + $0.type == .sparkExit && !$0.isSettled } forceCloseClaimableAtHeight = forceCloseTransfer?.claimableAtHeight } catch { @@ -1476,13 +1657,18 @@ class WalletViewModel: ObservableObject { return channels?.contains(where: \.isUsable) ?? false } + /// Whether Lightning can be received, through a usable channel or Spark; Paykit publishes a Lightning endpoint only then. + var canReceiveLightning: Bool { + hasUsableChannels || SparkManager.shared.isReady + } + var canCreateReceiveLightningInvoice: Bool { let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil return canCreateReceiveLightningInvoice(amountSats: amountSats) } func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { - ReceiveLiquidityDecision.canCreateLightningInvoice( + SparkManager.shared.isReady || ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels: hasReadyChannels, inboundCapacitySats: totalReadyInboundLightningSats, invoiceAmountSats: amountSats @@ -1555,7 +1741,7 @@ class WalletViewModel: ObservableObject { if attempt > 0 { guard try await canCommitInvoice() else { continue } } - if includeLightning, hasUsableChannels { + if includeLightning, canReceiveLightning { let hasReusableInvoice = await hasReusablePublicPaykitInvoice() guard try await canCommitInvoice() else { continue } let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice @@ -1652,7 +1838,7 @@ class WalletViewModel: ObservableObject { if sharesPublicPaykitEndpoints { do { - try await publishPublicPaykitEndpoints(refreshIfNeeded: hasUsableChannels, isSessionCurrent: isSessionCurrent) + try await publishPublicPaykitEndpoints(refreshIfNeeded: canReceiveLightning, isSessionCurrent: isSessionCurrent) } catch { Logger.warn("Failed to refresh public Paykit endpoints after channel availability changed: \(error)", context: "WalletViewModel") } @@ -1689,7 +1875,7 @@ class WalletViewModel: ObservableObject { } private func refreshPublicPaykitBolt11(canCommitInvoice: () async throws -> Bool) async throws -> Bool { - let invoice = try await createInvoice(amountSats: nil, note: "") + let invoice = try await createInvoice(amountSats: nil, note: "", isPaykitEndpoint: true) let decodedInvoice = try await decode(invoice: invoice) guard try await canCommitInvoice() else { return false } guard case let .lightning(lightningInvoice) = decodedInvoice else { @@ -2083,7 +2269,14 @@ class WalletViewModel: ObservableObject { try await nodeLifecycle.wipeStorage(walletIndex: 0) } + try await SparkManager.shared.wipe(walletIndex: 0) + probeOutcomes.removeAll() + // A move's late outcome belongs to the wiped wallet, and its node is gone: stop waiting for it + for sink in moveEventSinks.values { + sink.finish() + } + moveEventSinks.removeAll() // Reset AppStorage display values totalBalanceSats = 0 @@ -2104,3 +2297,13 @@ class WalletViewModel: ObservableObject { Logger.warn("Wallet wipe completed", context: "WalletViewModel") } } + +/// Tells when Spark goes from not ready to ready, so work that ran without it (Paykit endpoints) runs once more. +struct SparkReadinessTransition { + private var wasReady = false + + mutating func becameReady(_ isReady: Bool) -> Bool { + defer { wasReady = isReady } + return isReady && !wasReady + } +} diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index c467b8b52..ef082e6dc 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -9,6 +9,10 @@ struct HomeScreen: View { @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel + private var canMoveToSpark: Bool { + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared)) + } + @State private var scrollPosition: Int? = 0 @State private var isEditingWidgets = false @State private var pullRefreshState = HomePullRefreshState() @@ -103,6 +107,12 @@ struct HomeScreen: View { .onDisappear { TimedSheetManager.shared.onPrimaryScreenExited() } + // Channels and Spark load after Home first checks its sheets, so the Move to Spark intro is checked again. + .onChange(of: canMoveToSpark) { _, canMove in + if canMove { + TimedSheetManager.shared.reevaluate() + } + } .onChange(of: app.requestedHomePage) { _, _ in consumeRequestedHomePage() } diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 9d3b9a3f4..b6b9e53cc 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -278,6 +278,8 @@ struct RestoreWalletView: View { // it on the Get Started tap left a window where replayed txs could still pop a sheet. #588 SettingsViewModel.shared.pendingRestoreActivitySeenSince = UInt64(Date().timeIntervalSince1970) SettingsViewModel.shared.restoreSyncedBlockHeight = 0 + // No backup keeps which invoices Spark paid, so node sends of older invoices wait for Spark's list. + LightningService.shared.markSparkHistoryUnseeded() // When restoring a wallet, monitor all address types to catch any existing funds SettingsViewModel.shared.monitorAllAddressTypes() @@ -288,6 +290,7 @@ struct RestoreWalletView: View { // The node never started, so no sync will lift the hold. Left in place it would silence // every later on-chain receive, across retries and relaunches. #588 SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 + LightningService.shared.clearSparkRestore() BackupService.shared.setRestoring(false) app.toast(error) } diff --git a/Bitkit/Views/OnboardingView.swift b/Bitkit/Views/OnboardingView.swift index de01d7dce..c3a0a6fc6 100644 --- a/Bitkit/Views/OnboardingView.swift +++ b/Bitkit/Views/OnboardingView.swift @@ -16,6 +16,8 @@ struct OnboardingView: View { let accentColor: Color let imagePosition: ImagePosition let titleDescriptionSpacing: CGFloat + let buttonHorizontalPadding: CGFloat + let maxImageSize: CGFloat? let testID: String? enum ImagePosition { @@ -36,6 +38,8 @@ struct OnboardingView: View { accentColor: Color = .brandAccent, imagePosition: ImagePosition = .bottom, titleDescriptionSpacing: CGFloat = 14, + buttonHorizontalPadding: CGFloat = 16, + maxImageSize: CGFloat? = nil, testID: String? = nil ) { self.navTitle = navTitle ?? "" @@ -50,6 +54,8 @@ struct OnboardingView: View { self.accentColor = accentColor self.imagePosition = imagePosition self.titleDescriptionSpacing = titleDescriptionSpacing + self.buttonHorizontalPadding = buttonHorizontalPadding + self.maxImageSize = maxImageSize self.testID = testID } @@ -66,7 +72,7 @@ struct OnboardingView: View { Image(imageName) .resizable() .scaledToFit() - .frame(maxWidth: .infinity) + .frame(maxWidth: maxImageSize ?? .infinity, maxHeight: maxImageSize) .aspectRatio(1, contentMode: .fit) .padding(.bottom, imagePosition == .center ? 0 : 48) @@ -77,6 +83,7 @@ struct OnboardingView: View { .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .layoutPriority(1) + .padding(.horizontal, 16) VStack(alignment: .leading, spacing: titleDescriptionSpacing) { DisplayText(title, textColor: titleColor, accentColor: accentColor) @@ -86,14 +93,15 @@ struct OnboardingView: View { .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 16) CustomButton(title: buttonText) { onButtonPress() } .padding(.top, 32) + .padding(.horizontal, buttonHorizontalPadding) .accessibilityIdentifierIfPresent(testID.map { "\($0)-button" }) } - .padding(.horizontal, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 16) diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 5f343e3e6..a5421446b 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -555,7 +555,7 @@ struct PaymentRequestDetailsView: View { } private var enabledEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) } private var selectedEnabledEndpoints: [String] { diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 58e3374ed..ea379846c 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -7,6 +7,11 @@ struct AdvancedSettingsView: View { @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault + private var connectionsRowRightText: String? { + let openChannelCount = wallet.channels?.filter(\.isChannelReady).count ?? 0 + return openChannelCount > 0 ? String(openChannelCount) : nil + } + private var electrumRowRightText: String { let currentServerUrl = settings.electrumCurrentServer.fullUrl @@ -83,7 +88,7 @@ struct AdvancedSettingsView: View { SettingsRow( title: t("settings__adv__lightning_connections"), iconName: "bolt-hollow", - rightText: String(wallet.channels?.count ?? 0) + rightText: connectionsRowRightText ) } .accessibilityIdentifier("Channels") diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 559e3f1b9..b6ceea820 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -54,7 +54,11 @@ struct LightningConnectionsView: View { .resizable() .frame(width: 22, height: 22) .foregroundColor(.white) - TitleText(formatNumber(receivingCapacity), textColor: .white) + // Spark receives without inbound liquidity, so no channel caps receiving. + TitleText( + SparkManager.shared.isReady ? t("lightning__spark_receiving_unlimited") : formatNumber(receivingCapacity), + textColor: .white + ) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -67,6 +71,12 @@ struct LightningConnectionsView: View { EmptyStateView() .padding(.top, 16) } else { + if SparkConnectionRow.isVisible { + SettingsSectionHeader(t("lightning__spark")) + SparkConnectionRow() + .padding(.bottom, 16) + } + // Pending Connections section if !pendingConnections.isEmpty { SettingsSectionHeader(t("lightning__conn_pending")) @@ -145,6 +155,17 @@ struct LightningConnectionsView: View { } } + if MoveToSparkMath.canMove( + channels: wallet.channels, + isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared) + ) { + CustomButton(title: t("lightning__move_to_spark__nav_title")) { + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSpark") + .padding(.vertical, 16) + } + // Closed Connections section if showClosedConnections && !closedChannels.isEmpty { SettingsSectionHeader(t("lightning__conn_closed")) @@ -230,8 +251,9 @@ struct LightningConnectionsView: View { // MARK: - Computed Properties private var spendingBalance: UInt64 { - guard let balanceDetails = wallet.balanceDetails else { return 0 } - return balanceDetails.totalLightningBalanceSats + let sparkSats = SparkManager.shared.shownSpendingSats + guard let balanceDetails = wallet.balanceDetails else { return sparkSats } + return balanceDetails.totalLightningBalanceSats + sparkSats } private var receivingCapacity: UInt64 { @@ -244,7 +266,7 @@ struct LightningConnectionsView: View { } private var shouldShowEmptyState: Bool { - openChannels.isEmpty && pendingConnections.isEmpty && closedChannels.isEmpty + !SparkConnectionRow.isVisible && openChannels.isEmpty && pendingConnections.isEmpty && closedChannels.isEmpty } private var buttonText: String { @@ -291,6 +313,10 @@ struct LightningConnectionsView: View { } } + group.addTask { + await SparkManager.shared.refreshState() + } + // Sync wallet channels group.addTask { do { diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift new file mode 100644 index 000000000..0204db68d --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -0,0 +1,223 @@ +import LDKNode +import SwiftUI + +/// Moves the channels' balance to the user's Spark wallet over Lightning, then offers to close the emptied channel. +struct MoveToSparkView: View { + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @State private var manager = MoveToSparkManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__move_to_spark__nav_title")) + .padding(.bottom, 16) + + switch manager.state { + case .quoting: + quoting + case let .confirm(quote, isMoving): + confirm(quote: quote, isMoving: isMoving) + case let .done(amountSats, feeSats, closeChannel, hasRest): + done(amountSats: amountSats, feeSats: feeSats, closeChannel: closeChannel, hasRest: hasRest) + case let .inFlight(amountSats): + inFlight(amountSats: amountSats) + case let .tooLarge(amountSats, smallerSats): + tooLarge(amountSats: amountSats, smallerSats: smallerSats) + case let .failed(failure): + failed(failure) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await manager.start(wallet: wallet) + } + .onDisappear { + // Unlocking rebuilds the screen with the route still in the path; only leaving it ends the move. + if !navigation.path.contains(.moveToSpark) { + manager.reset() + } + } + } + + private func header(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 16) { + DisplayText(title, accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + BodyMText(text, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func illustration(_ name: String) -> some View { + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .scaleEffect(Self.illustrationScale(name)) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + } + + /// Scales each illustration so its drawing fills its box like the zap; the others have wider transparent margins. + private static func illustrationScale(_ name: String) -> CGFloat { + switch name { + case "check": 1.25 + case "cross": 1.4 + case "exclamation-mark": 1.1 + default: 1 + } + } + + private var quoting: some View { + VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__confirm_title"), text: t("lightning__move_to_spark__quoting")) + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func confirm(quote: MoveToSparkQuote, isMoving: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__confirm_title"), text: t("lightning__move_to_spark__confirm_text")) + .padding(.bottom, 32) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: quote.amountSats) + .accessibilityIdentifier("MoveToSparkAmount") + MoveToSparkAmountRow(label: t("lightning__move_to_spark__fee"), sats: quote.feeSats) + .accessibilityIdentifier("MoveToSparkFee") + illustration("lightning") + SwipeButton(title: t("lightning__move_to_spark__swipe"), accentColor: .purpleAccent, isLoading: isMoving) { + await manager.move(wallet: wallet) + } + .accessibilityIdentifier("MoveToSparkSwipe") + } + } + + private func done(amountSats: UInt64, feeSats: UInt64, closeChannel: ChannelDetails?, hasRest: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + header( + title: t("lightning__move_to_spark__done_title"), + text: hasRest ? t("lightning__move_to_spark__done_rest_text") : t("lightning__move_to_spark__done_text") + ) + .padding(.bottom, 32) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) + .accessibilityIdentifier("MoveToSparkMoved") + MoveToSparkAmountRow(label: t("lightning__move_to_spark__fee"), sats: feeSats) + .accessibilityIdentifier("MoveToSparkFeePaid") + illustration("check") + HStack(spacing: 16) { + CustomButton(title: t("common__later"), variant: .secondary) { + navigation.navigateBack() + } + if hasRest { + CustomButton(title: t("lightning__move_to_spark__move_rest")) { + await manager.quote(wallet: wallet) + } + .accessibilityIdentifier("MoveToSparkMoveRest") + } else { + CustomButton(title: t("common__close")) { + // Closing returns to Lightning Connections through the channel's detail, as a close from there does. + navigation.navigateBack() + if navigation.path.last != .connections { + navigation.navigate(.connections) + } + if let closeChannel { + navigation.navigate(.connectionDetail(channelId: closeChannel.channelIdString)) + navigation.navigate(.closeConnection(channel: closeChannel)) + } + } + .accessibilityIdentifier("MoveToSparkClose") + } + } + } + } + + /// While the payment is in flight its amount may still return to the channel, so closing is not offered yet. + private func inFlight(amountSats: UInt64) -> some View { + VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__in_flight_title"), text: t("lightning__move_to_spark__in_flight_text")) + .padding(.bottom, 32) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) + .accessibilityIdentifier("MoveToSparkMoved") + illustration("lightning") + CustomButton(title: t("common__ok")) { + navigation.navigateBack() + } + .accessibilityIdentifier("MoveToSparkInFlightOk") + } + } + + private func tooLarge(amountSats: UInt64, smallerSats: UInt64?) -> some View { + let next = smallerSats == nil ? t("lightning__move_to_spark__too_large_none") : t("lightning__move_to_spark__too_large_smaller") + let amount = t( + "lightning__move_to_spark__too_large_text", + variables: ["amount": "\(currency.primaryAmountText(sats: amountSats))"] + ) + return VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__too_large_title"), text: "\(amount) \(next)") + illustration("exclamation-mark") + if let smallerSats { + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + navigation.navigateBack() + } + CustomButton(title: t("lightning__move_to_spark__move_half")) { + await manager.quote(wallet: wallet, targetSats: smallerSats) + } + .accessibilityIdentifier("MoveToSparkTrySmaller") + } + } else { + CustomButton(title: t("common__back")) { + navigation.navigateBack() + } + } + } + } + + private func failed(_ failure: MoveToSparkFailure) -> some View { + let text = switch failure { + case .nothingToMove: t("lightning__move_to_spark__failed_nothing") + case .sparkUnavailable: t("lightning__move_to_spark__failed_spark") + case .unknown: t("lightning__move_to_spark__failed_unknown") + } + return VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__failed_title"), text: text) + illustration("cross") + if failure == .nothingToMove { + CustomButton(title: t("common__back")) { + navigation.navigateBack() + } + } else { + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + navigation.navigateBack() + } + CustomButton(title: t("common__try_again")) { + await manager.quote(wallet: wallet) + } + .accessibilityIdentifier("MoveToSparkRetry") + } + } + } + } +} + +/// A labelled amount, as the Move to Spark screens list the amount and its routing fee. +struct MoveToSparkAmountRow: View { + let label: String + let sats: UInt64 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(sats), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift new file mode 100644 index 000000000..8e1c11aa0 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -0,0 +1,304 @@ +import SwiftUI + +/// The Spark wallet as a connection: shown once it holds a balance or a deposit, and always on regtest so testers +/// reach its deposit address to fund it from the faucet. +struct SparkConnectionRow: View { + @EnvironmentObject var navigation: NavigationViewModel + + private let spark = SparkManager.shared + + static var isVisible: Bool { + let spark = SparkManager.shared + // An exit's progress and result stay reachable while Spark is not ready, such as with the operators down. + let hasExit = spark.isExiting || spark.recoverableFundsSats > 0 + // An earlier exit's address stays reachable once Spark holds nothing. + return SparkExitManager.shared.showsResult || !SparkExitManager.shared.shownEarlierAddresses.isEmpty + || spark.isReady && (spark.shownBalanceSats > 0 || !spark.deposits.isEmpty || hasExit || Env.network == .regtest) + } + + var body: some View { + Button { + navigation.navigate(.sparkConnectionDetail) + } label: { + VStack(spacing: 0) { + HStack { + SubtitleText(t("lightning__spark_provider_value")) + Spacer() + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + } + .padding(.bottom, 6) + + LightningChannel( + capacity: spark.shownBalanceSats, + localBalance: spark.shownBalanceSats, + remoteBalance: 0, + status: .open, + showReceiving: false + ) + .padding(.bottom, 16) + + ForEach(spark.deposits.filter { !$0.isClaimed }) { deposit in + SparkDepositRow(deposit: deposit) + .padding(.bottom, 16) + } + + CustomDivider() + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkConnection") + } +} + +/// A deposit into Spark or a withdrawal out of it, with its amount, confirmations while pending, and a link to +/// its transaction. +struct SparkDepositRow: View { + @Environment(\.openURL) private var openURL + + let title: String + let txid: String + let amountSats: UInt64 + var confirmations: UInt32? + + init(deposit: SparkDeposit) { + title = t("lightning__spark_deposit") + txid = deposit.txid + amountSats = deposit.amountSats + confirmations = deposit.confirmations + } + + init(withdrawal: SparkWithdrawal) { + title = t("wallet__activity_transfer_to_savings") + txid = withdrawal.txid + amountSats = withdrawal.amountSats + } + + var body: some View { + Button { + if let url = URL(string: "\(Env.blockExplorerUrl)/tx/\(txid)") { + openURL(url) + } + } label: { + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + CaptionBText(title, textColor: .textPrimary) + CaptionBText(txid) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + MoneyText(sats: Int(amountSats), size: .captionB, symbol: true) + if let confirmations { + CaptionBText( + t("lightning__spark_confirmations", variables: ["count": "\(min(confirmations, 3))"]), + textColor: .purpleAccent + ) + } + } + } + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkDeposit") + } +} + +struct SparkConnectionDetailView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + @State private var depositAddress: String? + + private let spark = SparkManager.shared + + /// The emergency exit is offered while Spark holds funds, also a kept balance while it reads 0, and leads to the + /// exit's progress or result while it runs or its leftover fee funding is still on its way to savings. + private var showsExit: Bool { + spark.shownBalanceSats > 0 || spark.recoverableFundsSats > 0 || SparkExitManager.shared.showsResult + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + LightningChannel( + capacity: spark.shownBalanceSats, + localBalance: spark.shownBalanceSats, + remoteBalance: 0, + status: .open, + showReceiving: false + ) + .padding(.bottom, 28) + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { + CustomDivider() + SettingsSectionHeader(t("lightning__spark_provider")) + detailRow(label: t("lightning__spark_provider"), value: t("lightning__spark_provider_value")) + BodySText(t("lightning__spark_trust")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) + + if let depositAddress { + Button { + UIPasteboard.general.string = depositAddress + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: depositAddress) + } label: { + detailRow(label: t("wallet__activity_address"), value: depositAddress) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkDepositAddress") + } + } + + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__balance")) + amountRow(label: t("lightning__spending_label"), amount: spark.shownBalanceSats) + } + + let pending = spark.deposits.filter { !$0.isClaimed } + if !pending.isEmpty { + VStack(alignment: .leading, spacing: 16) { + SettingsSectionHeader(t("lightning__spark_deposits_pending")) + ForEach(pending) { SparkDepositRow(deposit: $0) } + } + } + + let claimed = spark.deposits.filter(\.isClaimed) + if !claimed.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__spark_deposits_claimed")) + ForEach(claimed) { deposit in + SparkDepositRow(deposit: deposit) + .padding(.bottom, 8) + amountRow(label: t("lightning__spark_claim_fee"), amount: deposit.feeSats) + } + } + } + + let earlier = SparkExitManager.shared.shownEarlierAddresses + if !earlier.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__spark_exit__earlier_addresses")) + BodySText(t("lightning__spark_exit__earlier_addresses_text")) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 8) + ForEach(earlier) { entry in + copyRow(label: t("wallet__activity_address"), value: entry.address) + .accessibilityIdentifier("SparkExitEarlierAddress") + copyRow(label: t("lightning__spark_exit__earlier_path"), value: entry.path) + .accessibilityIdentifier("SparkExitEarlierPath") + if let sats = entry.sats { + amountRow(label: t("lightning__balance"), amount: sats) + } + } + .padding(.bottom, 8) + } + } + + if !spark.withdrawals.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__savings_confirm__label")) + ForEach(spark.withdrawals) { withdrawal in + SparkDepositRow(withdrawal: withdrawal) + .padding(.bottom, 8) + amountRow(label: t("wallet__activity_fee"), amount: withdrawal.feeSats) + } + } + } + } + .padding(.bottom, 32) + } + } + + CustomButton(title: t("lightning__transfer_to_savings_button")) { + if app.hasSeenTransferToSavingsIntro { + navigation.navigate(.savingsAvailability) + } else { + navigation.navigate(.savingsIntro) + } + } + .disabled(spark.balanceSats == 0 || spark.isExiting) + .accessibilityIdentifier("SparkTransferToSavings") + + if showsExit { + CustomButton(title: t("lightning__spark_exit__button"), variant: .tertiary) { + navigation.navigate(SparkExitManager.shared.showsResult ? .sparkExitProgress : .sparkExitIntro) + } + .padding(.top, 8) + .accessibilityIdentifier("SparkEmergencyExit") + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .refreshable { + await spark.refreshState() + await SparkExitManager.shared.refreshEarlierAddresses() + } + .task { + await loadDepositAddress() + } + .task { + await SparkExitManager.shared.refreshEarlierAddresses() + } + } + + /// On regtest, shows Spark's deposit address so testers can fund Spark from Lightspark's regtest faucet. + private func loadDepositAddress() async { + guard Env.network == .regtest else { return } + do { + depositAddress = try await spark.depositAddress() + } catch { + Logger.warn("Failed to load Spark deposit address: \(error)", context: "SparkConnectionDetailView") + } + } + + private func detailRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 16) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + CaptionBText(value, textColor: .textPrimary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(height: 50) + CustomDivider() + } + } + + private func copyRow(label: String, value: String) -> some View { + Button { + UIPasteboard.general.string = value + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: value) + } label: { + detailRow(label: label, value: value) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + } + + private func amountRow(label: String, amount: UInt64) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(amount), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift new file mode 100644 index 000000000..e5800f5c3 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -0,0 +1,1162 @@ +import BitkitCore +import BreezSdkSpark +import SwiftUI + +/// When to use an emergency exit, what it costs and how long it takes. +struct SparkExitIntroView: View { + @EnvironmentObject var navigation: NavigationViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spark_exit__intro_title"), accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText(t("lightning__spark_exit__intro_text"), accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + + Spacer() + + Image("exclamation-mark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity) + + Spacer() + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + navigation.navigateBack() + } + + CustomButton(title: t("common__continue")) { + navigation.navigate(.sparkExitQuote) + } + .accessibilityIdentifier("SparkExitIntroContinue") + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } +} + +/// What the exit takes on-chain, what it costs, and what stays behind. +struct SparkExitQuoteView: View { + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + + private let exit = SparkExitManager.shared + + private var blockedReason: String? { + guard let quote = exit.quote else { return exit.quoteError } + if quote.prepared.leaves.isEmpty { + return t("lightning__spark_exit__nothing") + } + if !quote.isWorthExiting { + return t("lightning__spark_exit__uneconomic") + } + return nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__quote_title"), accentColor: .brandAccent) + .padding(.top, 32) + .padding(.bottom, 32) + + if let quote = exit.quote { + SparkExitQuoteRows(quote: quote) + + if let unexitableSats = SparkExitMath.unexitableSats(sparkBalanceSats: SparkManager.shared.balanceSats, quote: quote) { + BodySText(t( + "lightning__spark_exit__quote_outside", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: unexitableSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitQuoteOutside") + } + } else if exit.isQuoting { + let key = SparkManager.shared.isSettlingForExit ? "lightning__spark_exit__quote_settling" : "lightning__spark_exit__quote_loading" + BodyMText(t(key)) + .fixedSize(horizontal: false, vertical: true) + } + + if let blockedReason { + BodyMText(blockedReason, textColor: .redAccent) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitQuoteBlocked") + } + } + .padding(.bottom, 32) + } + + CustomButton(title: t("common__continue"), isLoading: exit.isQuoting) { + guard let quote = exit.quote else { return } + navigation.navigate(quote.fundingSats > 0 ? .sparkExitFunding : .sparkExitConfirm) + } + .disabled(exit.quote == nil || blockedReason != nil) + .accessibilityIdentifier("SparkExitQuoteContinue") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await exit.loadQuote() + } + } +} + +struct SparkExitQuoteRows: View { + let quote: SparkExitQuote + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SparkExitAmountRow(label: t("lightning__spark_exit__amount"), sats: quote.exitSats) + SparkExitAmountRow(label: t("lightning__spark_exit__fees"), sats: quote.networkFeeSats) + SparkExitAmountRow(label: t("lightning__spark_exit__receive"), sats: quote.receiveSats) + if quote.fundingSats > 0 { + SparkExitAmountRow(label: t("lightning__spark_exit__funding"), sats: quote.fundingSats) + } + if quote.leftBehindSats > 0 { + SparkExitAmountRow(label: t("lightning__spark_exit__left_behind"), sats: quote.leftBehindSats) + } + } + } +} + +struct SparkExitAmountRow: View { + let label: String + let sats: UInt64 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(sats), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} + +/// Pays the CPFP fee funding from savings, or shows the funding address to pay from another wallet. +struct SparkExitFundingView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var savingsFeeSats: UInt64? + /// Savings can't pay the funding, usually because it doesn't hold enough. + @State private var hasSavingsFeeFailed = false + @State private var showsAddress = false + + private let exit = SparkExitManager.shared + + private var fundingSats: UInt64 { + exit.quote?.fundingSats ?? 0 + } + + /// What savings pays: the funding less what another wallet already sent to the address, plus the savings + /// payment's own input fee then, as the start pays it. + private var savingsSats: UInt64 { + exit.savingsSats + } + + /// What the funding address has to hold for the payments seen so far, and what the next one should bring it to. + private var prestartFunding: (covered: UInt64, toSend: UInt64) { + SparkExitMath.prestartFunding( + fundingSats: fundingSats, + payments: exit.fundingSeenCount, + receivedSats: exit.fundingSeenSats, + feeRate: exit.quoteFeeRate ?? 0 + ) + } + + private var canFundFromSavings: Bool { + guard let savingsFeeSats else { return false } + return SparkExitMath.canFundFromSavings( + spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)), + fundingSats: savingsSats, + paymentFeeSats: savingsFeeSats, + readsBitkitChain: SparkManager.shared.canFundFromChain + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__funding_title"), accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText( + t("lightning__spark_exit__funding_text", variables: ["amount": currency.bitcoinAmountText(sats: fundingSats)]), + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + + if let steps = exit.quote?.fundedSteps, steps > 1 { + BodySText(t("lightning__spark_exit__funding_split", variables: ["count": "\(steps)"])) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFundingSplit") + } + + if showsAddress, let address = exit.fundingAddress { + SparkExitFundingAddress( + address: address, + requiredSats: fundingSats, + receivedSats: exit.fundingSeenSats, + targetSats: prestartFunding.toSend + ) + .padding(.top, 24) + } + } + .padding(.bottom, 32) + } + + if showsAddress { + CustomButton(title: t("common__continue")) { + exit.fundFromSavings = false + navigation.navigate(.sparkExitConfirm) + } + .disabled(exit.fundingSeenSats < prestartFunding.covered) + .accessibilityIdentifier("SparkExitFundingContinue") + } else { + // Savings on a chain Spark does not read is off without a balance line, as on the transfer screens. + if SparkManager.shared.canFundFromChain, hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) { + CaptionText(t("lightning__spark_exit__fund_insufficient")) + .padding(.bottom, 16) + } + + CustomButton(title: t("lightning__spark_exit__fund_savings"), isLoading: savingsFeeSats == nil && !hasSavingsFeeFailed) { + exit.fundFromSavings = true + navigation.navigate(.sparkExitConfirm) + } + .disabled(!canFundFromSavings) + .accessibilityIdentifier("SparkExitFundSavings") + + CustomButton(title: t("lightning__spark_exit__fund_external"), variant: .secondary) { + showsAddress = true + } + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFundExternal") + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task(id: savingsSats) { + await estimateSavingsFee() + } + .task(id: showsAddress) { + guard showsAddress else { return } + await exit.watchFundingAddress() + } + } + + private func estimateSavingsFee() async { + let wallet = wallet + let feeEstimatesManager = feeEstimatesManager + let priced = await Self.priceSavingsFee( + address: exit.fundingAddress, + fundingSats: savingsSats, + refusedFeeSats: exit.savingsFundingRefusedFeeSats, + estimates: { await feeEstimatesManager.getEstimates(refresh: true) }, + calculate: { try await wallet.calculateTotalFee(address: $0, amountSats: $1, satsPerVByte: $2) } + ) + switch priced { + case .unavailable: + break + case .failed: + hasSavingsFeeFailed = true + case let .priced(rate, feeSats): + exit.savingsFeeRate = rate + exit.savingsFeeSats = feeSats + savingsFeeSats = feeSats + } + } + + enum SavingsFee: Equatable { + /// Nothing to price: no funding address or amount yet. + case unavailable + case failed + case priced(rate: UInt32, feeSats: UInt64) + } + + /// What savings pays to send the fee funding at the fast rate. + nonisolated static func priceSavingsFee( + address: String?, + fundingSats: UInt64, + refusedFeeSats: UInt64? = nil, + estimates: () async -> FeeRates?, + calculate: (String, UInt64, UInt32) async throws -> UInt64 + ) async -> SavingsFee { + guard let address, fundingSats > 0 else { return .unavailable } + // Without rates there is no fee to confirm: Pay from savings shows its error rather than loading for good. + guard let feeRates = await estimates() else { + Logger.warn("Failed to read fee rates for the Spark exit funding from savings", context: "SparkExitFundingView") + return .failed + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + do { + // Coin selection can need more fee than the estimate: after a refused start, at least the fee it asked. + let feeSats = try await calculate(address, fundingSats, rate) + return .priced(rate: rate, feeSats: max(feeSats, refusedFeeSats ?? 0)) + } catch { + Logger.warn("Failed to price the Spark exit funding from savings: \(error)", context: "SparkExitFundingView") + return .failed + } + } +} + +struct SparkExitFundingAddress: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + + let address: String + /// What the address asks for. + let requiredSats: UInt64 + /// New funding already at the address, and the total it counts towards. + let receivedSats: UInt64 + let targetSats: UInt64 + /// Off where the caller shows what was seen itself. + var showsSeen = true + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // The receive screen's tile, at a fixed size with black space around it. + QR(content: address) + .frame(width: 200, height: 200) + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + + BodySText( + t("lightning__spark_exit__fund_external_text", + variables: ["amount": sparkExitAmountAccent(currency.bitcoinAmountText(sats: requiredSats))]), + accentColor: .white, + accentFont: Fonts.bold + ) + + Button { + UIPasteboard.general.string = address + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: address) + } label: { + CaptionBText(address, textColor: .textPrimary) + .lineLimit(2) + .truncationMode(.middle) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkExitFundingAddress") + + if let missingSats = SparkExitMath.partialFundingSats(receivedSats: receivedSats, requiredSats: targetSats) { + BodySText(t("lightning__spark_exit__fund_partial", variables: [ + "received": sparkExitAmountAccent(currency.bitcoinAmountText(sats: receivedSats)), + "required": sparkExitAmountAccent(currency.bitcoinAmountText(sats: targetSats)), + "missing": sparkExitAmountAccent(currency.bitcoinAmountText(sats: missingSats)), + ]), accentColor: .white, accentFont: Fonts.bold) + .accessibilityIdentifier("SparkExitFundingPartial") + } else if showsSeen, receivedSats >= targetSats, targetSats > 0 { + CaptionText(t("lightning__spark_exit__fund_seen"), textColor: .greenAccent) + } + } + } +} + +/// The amount marked as an inline accent: bold white on a grey line, bold red on a red one. +func sparkExitAmountAccent(_ amount: String) -> String { + "\(amount)" +} + +/// A fee funding payment on its way: no QR and no amount to send, only what is awaited and its transaction. +struct SparkExitWaitingPayment: View { + let text: String + let txid: String + let testId: String + + var body: some View { + SparkExitTxNote(text: text, txid: txid) + .accessibilityElement(children: .contain) + .accessibilityIdentifier(testId) + } +} + +/// A line of progress with the transaction it is about, in the activity explore cell whose id copies on tap. +struct SparkExitTxNote: View { + let text: String + let txid: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + BodySText(text, accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + ActivityInfoSection(title: t("wallet__activity_tx_id"), content: txid, usesCustomDivider: true, bottomSpacing: 0) + .padding(.top, 16) + } + } +} + +/// Where the ended exit's leftover fee funding is: on its way to savings, sent, or too small to send. +struct SparkExitLeftoverStatus: View { + @EnvironmentObject var currency: CurrencyViewModel + + let record: SparkExitRecord + let address: String + + private var path: String { + SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork) + } + + /// One line with the total of every UTXO at the funding address, never one per UTXO, after what earlier sweeps sent. + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(record.sweptLeftover ?? [], id: \.txid) { swept in + SparkExitTxNote( + text: t("lightning__spark_exit__leftover_swept", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: swept.valueSats))]), + txid: swept.txid + ) + } + switch record.leftoverLine { + case let .sending(sats, txid): + if let txid { + SparkExitTxNote( + text: t("lightning__spark_exit__leftover_sending", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]), + txid: txid + ) + } else { + BodySText( + t( + "lightning__spark_exit__leftover_sending", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))] + ), + accentColor: .white, + accentFont: Fonts.bold + ) + } + case let .swept(sats, txid): + SparkExitTxNote( + text: t("lightning__spark_exit__leftover_swept", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]), + txid: txid + ) + case let .waiting(sats): + BodySText( + t("lightning__spark_exit__leftover_waiting", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]), + accentColor: .white, + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) + case let .kept(sats): + BodySText(t("lightning__spark_exit__leftover_dust", variables: [ + "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), "address": address, "path": path, + ]), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + case let .rejected(sats): + // Refused for good: where the coins stay, and the path that spends them with the recovery phrase. + BodySText(t("lightning__spark_exit__leftover_rejected", variables: [ + "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), "address": address, "path": path, + ]), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + case let .stillAt(sats): + BodySText( + t("lightning__spark_exit__leftover", variables: [ + "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), + "address": address, + ]), + accentColor: .white, + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) + case nil: + EmptyView() + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkExitLeftover") + } +} + +/// The last look before the exit starts: what arrives and for how long the funds are out of reach. +struct SparkExitConfirmView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + private let exit = SparkExitManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__confirm_title"), accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText(t("lightning__spark_exit__confirm_text"), accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + .padding(.bottom, 32) + + if let quote = exit.quote { + SparkExitQuoteRows(quote: quote) + } + + if let savingsPayment = exit.savingsPayment { + SparkExitAmountRow(label: t("lightning__spark_exit__savings_fee"), sats: savingsPayment.feeSats) + SparkExitAmountRow(label: t("lightning__spark_exit__savings_total"), sats: savingsPayment.totalSats) + } + } + .padding(.bottom, 32) + } + + SwipeButton(title: t("lightning__spark_exit__swipe"), accentColor: .brandAccent) { + do { + try await exit.start() + navigation.navigate(.sparkExitProgress) + } catch let error as SparkExitStartCancelled { + // Cancelled while the start waited: nothing was paid, nothing to say. Rethrown so the swipe resets. + throw error + } catch { + if case let OnchainSendAttemptError.preDispatch(refused) = error, let changed = refused as? SparkFundingChanged { + // Savings never pays more than the fee confirmed: the next funding asks for the refused one. + exit.noteSavingsFundingRefused(preparedFeeSats: changed.preparedFeeSats) + app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title")) + } else { + app.toast(error) + } + // The exit started but paying its fee funding failed: the progress screen shows where to fund it. + if exit.isExiting { + navigation.navigate(.sparkExitProgress) + return + } + throw error + } + } + .accessibilityIdentifier("SparkExitSwipe") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + // Asked before the swipe: the reminders are how Bitkit reaches the user when a step needs the app. + PushNotificationManager.shared.requestPermissionIfUndecided() + } + } +} + +/// The running exit's steps and countdown, and its result once it ends. +struct SparkExitProgressView: View { + @EnvironmentObject var app: AppViewModel + @ObservedObject private var notifications = PushNotificationManager.shared + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var currency: CurrencyViewModel + + private let exit = SparkExitManager.shared + @State private var isConfirmingCancel = false + + private var record: SparkExitRecord? { + exit.record + } + + private var fundingState: SparkExitFundingState? { + guard let record, record.fundingShortfallSats != nil else { return nil } + return exit.fundingState ?? SparkExitMath.fundingState(record, utxos: nil) + } + + private var lostText: String? { + guard let record, record.lostSats > 0 else { return nil } + return t( + "lightning__spark_exit__lost_leaves", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.lostSats))] + ) + } + + /// Lost leaves are gone, so the text claims only what Spark still holds. + private var failedText: String { + if record?.isCancelled == true { + return t("lightning__spark_exit__cancelled_text") + } + let staysSats = record.flatMap { SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: $0) } + return [ + t("lightning__spark_exit__failed_text"), + staysSats.map { t( + "lightning__spark_exit__failed_stays", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0))] + ) }, + ].compactMap { $0 }.joined(separator: " ") + } + + /// A top-up already paid, such as a higher fee's from savings, or seen at the address: nothing to ask for. + private var isFundingOnItsWay: Bool { + switch fundingState { + case .paid, .seen: true + default: false + } + } + + private var title: String { + switch record?.phase { + case .done: t("lightning__spark_exit__done_title") + case .failed where record?.isCancelled == true: t("lightning__spark_exit__cancelled_title") + case .failed: t("lightning__spark_exit__failed_title") + case .needsFunding where !isFundingOnItsWay: t("lightning__spark_exit__needs_funding_title") + default: t("lightning__spark_exit__progress_title") + } + } + + private var text: String { + switch record?.phase { + case .done: + [ + // The amount that reached savings says it all; the generic line only stands in when it is unknown. + record.flatMap(SparkExitMath.received).map { + t("lightning__spark_exit__done_received", variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0.sats))]) + } ?? t("lightning__spark_exit__done_text"), + (record?.skippedLeafCount ?? 0) > 0 + ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil, + record.flatMap { $0.isFailedLeavesReleased == true && $0.failedLeavesSats > 0 ? $0.failedLeavesSats : nil }.map { + t("lightning__spark_exit__failed_leaves", variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0))]) + }, + ].compactMap { $0 }.joined(separator: " ") + case .failed: failedText + case .needsFunding where !isFundingOnItsWay: + t( + "lightning__spark_exit__needs_funding_text", + variables: ["amount": currency.bitcoinAmountText(sats: record.flatMap { SparkExitMath.neededSats($0, state: fundingState) } ?? 0)] + ) + default: t("lightning__spark_exit__progress_text") + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(title, accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText(text, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + .padding(.bottom, 24) + + if let record { + SparkExitSteps(record: record, tipHeight: exit.tipHeight) + + if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { + ActivityInfoSection( + title: t("wallet__activity_tx_id"), + content: sweepTxid, + testId: "SparkExitSweepTxid", + usesCustomDivider: true, + bottomSpacing: 0 + ) + .padding(.top, 24) + } + + if exit.isSavingsPaymentRefused { + BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) + .padding(.top, 24) + .accessibilityIdentifier("SparkExitSavingsRefused") + } + + if let address = record.funding.first?.address { + switch fundingState { + case let .ask(sats, receivedSats): + // The ask counts the input fee of each payment, so it is what is missing; the hint + // names the full total, not the dust-floored ask added to what arrived. + SparkExitFundingAddress( + address: address, + requiredSats: sats, + receivedSats: receivedSats, + targetSats: exit.fundingAskTotal ?? sats + receivedSats, + showsSeen: false + ) + .padding(.top, 24) + case let .paid(txid): + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid, testId: "SparkExitFundingPaid") + .padding(.top, 24) + case let .seen(txid): + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid, testId: "SparkExitFundingSeen") + .padding(.top, 24) + case nil: + EmptyView() + } + } + + if record.isActive, record.isRebuiltFromChain == true { + BodySText(t("lightning__spark_exit__rebuilt_from_chain")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitRebuiltFromChain") + } + + if record.isWaitingForOperators { + BodySText(t( + "lightning__spark_exit__waiting_operators", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingOperators") + } + + if record.isActive, record.isWaitingForLeaves { + BodySText(t("lightning__spark_exit__waiting_leaves")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingLeaves") + } + + if let rejection = record.surfacedRejection { + BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitNotMoving") + } + + if record.showsFeeLow(tip: exit.tipHeight) { + BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeLow") + } else if exit.canPayHigherFee { + Button { + navigation.navigate(.sparkExitFeeBump) + } label: { + BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(PlainButtonStyle()) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeRose") + } + + if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { + BodySText(t( + "lightning__spark_exit__outside_exit", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitOutsideExit") + } + + if let lostText { + BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLostLeaves") + } + + if record.leftBehindSats > 0 { + BodySText(t( + "lightning__spark_exit__left_behind_leaves", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLeftBehind") + } + + if record.isActive { + SparkExitBackgroundNotes() + .padding(.top, 16) + } + + if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty, + let address = record.funding.first?.address + { + SparkExitLeftoverStatus(record: record, address: address) + .padding(.top, 16) + } + } + } + .padding(.bottom, 32) + } + .refreshable { + await SparkManager.shared.refreshState() + await exit.tick() + } + // Over the bottom inset: content cut at the edge fades out above the buttons, and scrolled to the end the + // last line clears it. + .overlay(alignment: .bottom) { + LinearGradient(colors: [.customBlack.opacity(0), .customBlack], startPoint: .top, endPoint: .bottom) + .frame(height: 32) + .allowsHitTesting(false) + } + + if record?.isActive == true, notifications.authorizationStatus == .denied { + CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { + if let url = URL(string: UIApplication.openSettingsURLString) { + await UIApplication.shared.open(url) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitNotificationSettings") + } + + if exit.isSavingsPaymentRefused { + CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { + do { + try await exit.retrySavingsPayment() + } catch { + app.toast(error) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitRetrySavings") + } + + if exit.canPayHigherFee { + CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { + navigation.navigate(.sparkExitFeeBump) + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitFeeBump") + } + + if exit.canCancel { + CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { + isConfirmingCancel = true + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitCancel") + } + + CustomButton(title: t("common__ok")) { + navigation.reset() + } + .accessibilityIdentifier("SparkExitProgressOk") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .alert(t("lightning__spark_exit__cancel_title"), isPresented: $isConfirmingCancel) { + Button(t("lightning__spark_exit__cancel"), role: .destructive) { + Task { + // A funding address that cannot be read keeps the exit: a payment there is never orphaned. + switch await exit.cancel() { + case .keep: app.toast(type: .error, title: t("lightning__spark_exit__cancel_failed"), description: nil) + // Funding that reached the address is swept back on this screen, as the exit's leftover. + case .sweepFunding: break + case .forget: navigation.reset() + case .notCancellable: break + } + } + } + .accessibilityIdentifier("SparkExitCancelConfirm") + + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("lightning__spark_exit__cancel_text")) + } + .task { + await SparkManager.shared.refreshState() + await exit.tick() + } + .task(id: record?.hasUnseenResult) { + exit.markResultSeen() + } + } +} + +/// Moves a running exit to a higher fee rate: each rate with what it costs from savings, then pays the extra fee +/// funding and rebuilds what has not confirmed, which replaces it on the network. +struct SparkExitFeeBumpView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var selectedRate: UInt64? + @State private var savingsFeeRate: UInt32? + @State private var isPaying = false + + private let exit = SparkExitManager.shared + + private var selected: SparkExitFeeBumpOption? { + exit.feeBumpOptions.first { $0.rate == selectedRate } + } + + private func canPay(_ option: SparkExitFeeBumpOption) -> Bool { + SparkExitMath.canPayFeeBump( + totalSats: option.totalSats, + spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)), + readsBitkitChain: SparkManager.shared.canFundFromChain + ) + } + + /// Savings can't pay the selected rate, or, with none selected, any rate priced so far. Savings on a chain Spark + /// does not read is off without a balance line, as on the start's funding screen. + private var isInsufficient: Bool { + guard SparkManager.shared.canFundFromChain else { return false } + if let selected { + return selected.totalSats != nil && !canPay(selected) + } + let priced = exit.feeBumpOptions.filter { $0.totalSats != nil } + return !priced.isEmpty && !priced.contains(where: canPay) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__fee_bump_title"), accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText( + t("lightning__spark_exit__fee_bump_text", variables: ["rate": "\(exit.record?.feeRateSatPerVbyte ?? 0)"]), + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + .padding(.bottom, 24) + + if exit.isPricingFeeBump { + ProgressView() + .tint(.white32) + .frame(maxWidth: .infinity) + } else if exit.isFeeBumpLeavingLeaves { + BodyMText(t("lightning__spark_exit__fee_bump_leaves_behind")) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("SparkExitFeeBumpLeavesBehind") + } else if let error = exit.feeBumpError { + BodyMText(error) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("SparkExitFeeBumpError") + } else { + VStack(spacing: 0) { + ForEach(exit.feeBumpOptions) { option in + FeeItem( + title: option.speed.title, + subtitle: "\(option.speed.range) · \(option.rate) sat/vB", + iconName: option.speed.iconName, + iconColor: option.speed.iconColor, + amount: option.totalSats ?? 0, + isSelected: option.rate == selectedRate, + isDisabled: !canPay(option) + ) { + selectedRate = option.rate + } + .accessibilityIdentifier("SparkExitFeeBumpRate-\(option.rate)") + } + } + .padding(.horizontal, -16) + } + } + .padding(.bottom, 32) + } + + if isInsufficient { + CaptionText(t("lightning__spark_exit__fund_insufficient")) + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitFeeBumpInsufficient") + } + + if exit.feeBumpError != nil { + CustomButton(title: t("common__try_again")) { + await exit.loadFeeBumpOptions() + await priceSavingsPayments() + selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate + } + .accessibilityIdentifier("SparkExitFeeBumpRetry") + } else if !exit.isFeeBumpLeavingLeaves { + CustomButton( + title: t(selected?.extraSats == 0 ? "lightning__spark_exit__fee_bump_confirm" : "lightning__spark_exit__fund_savings"), + isLoading: isPaying || exit.isPricingFeeBump + ) { + await pay() + } + .disabled(selected.map { !canPay($0) } ?? true) + .accessibilityIdentifier("SparkExitFeeBumpPay") + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await exit.loadFeeBumpOptions() + await priceSavingsPayments() + selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate + } + } + + /// What savings pays in fees to send each option's extra funding, at its fast rate. + private func priceSavingsPayments() async { + guard let address = exit.record?.funding.first?.address, + let feeRates = await feeEstimatesManager.getEstimates(refresh: true) + else { return } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + savingsFeeRate = rate + for option in exit.feeBumpOptions { + guard let extraSats = option.extraSats, extraSats > 0 else { continue } + do { + let fee = try await wallet.calculateTotalFee(address: address, amountSats: extraSats, satsPerVByte: rate) + exit.setFeeBumpPaymentFee(rate: option.rate, sats: fee) + } catch { + // Savings can't send it, usually because it doesn't hold enough: the option stays unpriced and disabled. + Logger.warn("Failed to price a Spark exit fee bump payment: \(error)", context: "SparkExitFeeBumpView") + } + } + } + + private func pay() async { + guard let selected else { return } + isPaying = true + defer { isPaying = false } + do { + try await exit.payHigherFee(selected, savingsFeeRate: savingsFeeRate ?? 1) + navigation.navigateBack() + } catch let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged { + // Savings never pays more than the fee confirmed: the rates are priced again for another confirm. + app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title")) + exit.noteFeeBumpRefused(rate: selected.rate, preparedFeeSats: (error as? SparkFundingChanged)?.preparedFeeSats) + await exit.loadFeeBumpOptions() + await priceSavingsPayments() + selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate + } catch { + app.toast(error) + } + } +} + +/// What keeps the exit from moving or from reaching the user while Bitkit is closed: Background App Refresh off, +/// Low Power Mode, or notifications denied. Each note shows only while its setting is in the way. +struct SparkExitBackgroundNotes: View { + @ObservedObject private var notifications = PushNotificationManager.shared + @State private var refreshStatus = UIApplication.shared.backgroundRefreshStatus + @State private var isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled + + private var notes: [String] { + [ + refreshStatus == .available ? nil : t("lightning__spark_exit__bg_refresh_off"), + isLowPower ? t("lightning__spark_exit__low_power") : nil, + notifications.authorizationStatus == .denied ? t("lightning__spark_exit__notifications_off") : nil, + ].compactMap { $0 } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(notes, id: \.self) { note in + BodySText(note) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkExitBackgroundNotes") + .onAppear(perform: refresh) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.backgroundRefreshStatusDidChangeNotification)) { _ in refresh() } + .onReceive(NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)) { _ in refresh() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in refresh() } + } + + private func refresh() { + DispatchQueue.main.async { + refreshStatus = UIApplication.shared.backgroundRefreshStatus + isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled + } + notifications.updateNotificationPermission() + } +} + +struct SparkExitSteps: View { + let record: SparkExitRecord + let tipHeight: UInt32? + + private var transactions: [RecoveryTransaction] { + record.recovery?.response.transactions ?? [] + } + + private var isFunded: Bool { + if case .awaitingFunding = record.phase { + return false + } + return true + } + + private var remainingDuration: String? { + guard let target = record.claimableAtHeight, let tipHeight else { return nil } + let blocks = BlockTimeHelpers.blocksRemaining(until: target, currentHeight: tipHeight) + guard blocks > 0 else { return nil } + return BlockTimeHelpers.getDurationForBlocks(blocks) + } + + /// The exit's own steps all landed: done, or only leaves the operators moved on-chain are left. + private var isLanded: Bool { + record.phase == .done || record.isRetryingFailedLeaves + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + step(t("lightning__spark_exit__step_funding"), isDone: isFunded) + step( + t("lightning__spark_exit__step_publish"), + detail: record.recovery.map { "\(SparkExitMath.confirmedCount($0.response))/\(transactions.count)" }, + isDone: isLanded + ) + step( + t("lightning__spark_exit__step_timelock"), + detail: remainingDuration.map { t("lightning__spark_exit__ready_in", variables: ["duration": $0]) }, + isDone: isLanded || SparkExitMath.areTimelocksMatured(transactions) + ) + step(t("lightning__spark_exit__step_sweep"), isDone: isLanded) + } + } + + private func step(_ title: String, detail: String? = nil, isDone: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + Image(isDone ? "check-mark" : "hourglass-simple") + .resizable() + .foregroundColor(isDone ? .greenAccent : .textSecondary) + .frame(width: 20, height: 20) + CaptionBText(title, textColor: .textPrimary) + Spacer() + if let detail { + CaptionBText(detail) + } + } + .frame(height: 50) + CustomDivider() + } + } +} + +enum SparkExitLayout { + /// The gap under an exit headline: master's 8, plus 1 so it renders like Android's, whose Display line height + /// adds 9 dp under a headline. Measured on 2x media devices. + static let headlineGap: CGFloat = 9 +} diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index d7ea925bd..6c5ab3578 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -19,6 +19,8 @@ struct AppStatusView: View { bitcoinNodeStatusRow nodeStatusRow channelsStatusRow + sparkStatusRow + exitStatusRow backupStatusRow } } @@ -122,7 +124,8 @@ struct AppStatusView: View { private var channelsStatusRow: some View { let status = AppStatusHelper.channelsStatus(from: wallet, network: network) - let description = t("settings__status__lightning_connection__\(status.rawValue)") + let isViaSpark = AppStatusHelper.isLightningViaSpark(from: wallet, network: network) + let description = t("settings__status__lightning_connection__\(isViaSpark ? "spark" : status.rawValue)") return StatusRow( imageName: "status-lightning", @@ -136,6 +139,39 @@ struct AppStatusView: View { .accessibilityIdentifier("Status-lightning_connection") } + @ViewBuilder + private var sparkStatusRow: some View { + if let status = AppStatusHelper.sparkStatus(network: network) { + StatusRow( + imageName: "asterisk", + title: t("settings__status__spark__title"), + description: t("settings__status__spark__\(status.rawValue)"), + status: status, + // Spark's detail shows a zero balance until Spark has read its state. + onTap: status == .ready ? { navigation.navigate(.sparkConnectionDetail) } : nil + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("Status-spark") + } + } + + @ViewBuilder + private var exitStatusRow: some View { + if let step = AppStatusHelper.exitStep(record: SparkExitManager.shared.record, fundingState: SparkExitManager.shared.fundingState) { + StatusRow( + imageName: "warning", + title: t("settings__status__exit__title"), + description: t("settings__status__exit__\(step.rawValue)"), + status: .pending, + onTap: { + navigation.navigate(.sparkExitProgress) + } + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("Status-spark_exit") + } + } + private var backupStatusRow: some View { let description: String let status: HealthStatus diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift index 31b84c1a2..7b0715b72 100644 --- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift +++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift @@ -40,8 +40,13 @@ private struct StatusItemView: View { struct DataBackupsScreen: View { @StateObject private var viewModel = BackupViewModel() + /// Spark's exit data shows only where Spark runs. + private var categories: [BackupCategory] { + BackupCategory.allCases.filter { $0 != .sparkExit || SparkManager.shared.isSupported } + } + private var allSynced: Bool { - BackupCategory.allCases.allSatisfy { category in + categories.allSatisfy { category in let status = viewModel.getStatus(for: category) return !status.running && !status.isRequired } @@ -65,7 +70,7 @@ struct DataBackupsScreen: View { } .padding(.top, 16) - ForEach(BackupCategory.allCases, id: \.self) { category in + ForEach(categories, id: \.self) { category in let status = viewModel.getStatus(for: category) let statusText = viewModel.formatStatusText(for: category) let iconColor = viewModel.iconColor(for: status) diff --git a/Bitkit/Views/Sheets/MoveToSparkSheet.swift b/Bitkit/Views/Sheets/MoveToSparkSheet.swift new file mode 100644 index 000000000..5e2d41099 --- /dev/null +++ b/Bitkit/Views/Sheets/MoveToSparkSheet.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct MoveToSparkSheetItem: SheetItem { + let id: SheetID = .moveToSpark + let size: SheetSize = .large +} + +/// Tells channel holders that Blocktank closes its channels and that moving the balance to Spark costs least. +struct MoveToSparkSheet: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + let config: MoveToSparkSheetItem + + var body: some View { + Sheet(id: .moveToSpark, data: config) { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("lightning__move_to_spark__nav_title")) + // The illustration shrinks on small screens so the copy below it stays whole. + Image("lightning") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("lightning__move_to_spark__intro_title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + BodyMText(t("lightning__move_to_spark__intro_text")) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 32) + .layoutPriority(1) + let buttonLayout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(spacing: 16)) + : AnyLayout(HStackLayout(spacing: 16)) + buttonLayout { + CustomButton(title: t("common__later"), variant: .secondary, shouldExpand: true) { + sheets.hideSheet() + } + .accessibilityIdentifier("MoveToSparkIntroLater") + CustomButton(title: t("lightning__move_to_spark__move_now"), shouldExpand: true) { + sheets.hideSheet() + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSparkIntroMove") + } + .padding(.top, 32) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("MoveToSparkIntro") + } + } +} diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index edb0a4220..08718c2d1 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -120,7 +120,7 @@ struct CreateSubscriptionView: View { } private var availableEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) .filter { settings.isUsdtEnabled || $0 != PublicPaykitService.MethodId.usdtArbitrum.rawValue } } diff --git a/Bitkit/Views/Transfer/FundingOptions.swift b/Bitkit/Views/Transfer/FundingOptions.swift index 2cf7144c6..f6a3558b4 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -1,7 +1,32 @@ import SwiftUI struct FundingOptions: View { + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + + @State private var showNoFundsAlert = false + + /// Spark keeps Spending fundable from savings or another wallet without a channel. + private var canFundWithSpark: Bool { + SparkManager.shared.isReady + } + + /// Where the hardware-wallet option starts Transfer to Spending, from a funded wallet first; nil when none is paired. + static func hardwareFundingRoute(wallets: [HwWallet], hasSeenIntro: Bool) -> Route? { + guard let wallet = wallets.first(where: { $0.balanceSats > 0 }) ?? wallets.first else { return nil } + return hasSeenIntro ? .spendingAmountHw(walletId: wallet.id) : .spendingIntroHw(walletId: wallet.id) + } + + private func fundWithHardware() { + if let route = Self.hardwareFundingRoute(wallets: hwWalletManager.wallets, hasSeenIntro: app.hasSeenTransferToSpendingIntro) { + navigation.navigate(route) + } else { + sheets.showSheet(.hardwareConnect) + } + } var body: some View { VStack(spacing: 0) { @@ -14,11 +39,29 @@ struct FundingOptions: View { ) .padding(.bottom, 8) - BodyMText(t("lightning__transfer_unavailable__text")) + BodyMText(t(canFundWithSpark ? "lightning__funding__text" : "lightning__transfer_unavailable__text")) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 32) VStack(spacing: 8) { + if canFundWithSpark { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), testID: "FundTransfer") { + if !SparkManager.shared.canFundFromChain { + sheets.showSheet(.transferUnavailable) + } else if wallet.totalOnchainSats == 0 { + showNoFundsAlert = true + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmount) + } else { + navigation.navigate(.spendingIntro) + } + } + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), testID: "FundReceive") { + navigation.reset() + sheets.showSheet(.receive) + } + } + RectangleButton( icon: "pencil", title: t("lightning__funding__button3"), @@ -27,14 +70,21 @@ struct FundingOptions: View { navigation.navigate(.fundManual(nodeUri: nil)) } - RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} + if !canFundWithSpark { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} + } RectangleButton( icon: "device-tablet-speaker", title: t("lightning__funding__button_hw"), - isDisabled: true, + // A hardware wallet's deposit goes out on Bitkit's chain, which Spark must read. + isDisabled: !SparkManager.shared.canFundFromChain, testID: "FundHardware" - ) {} - RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + ) { + fundWithHardware() + } + if !canFundWithSpark { + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + } } Spacer() @@ -49,5 +99,18 @@ struct FundingOptions: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .alert( + t("lightning__no_funds__title"), + isPresented: $showNoFundsAlert + ) { + Button(t("lightning__no_funds__fund")) { + navigation.reset() + sheets.showSheet(.receive, data: ReceiveConfig(view: .qr(tab: .savings))) + } + Button(t("common__cancel"), role: .cancel) {} + } message: { + Text(t("lightning__no_funds__description")) + } + .tint(.purpleAccent) } } diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift new file mode 100644 index 000000000..c78f930d4 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -0,0 +1,197 @@ +import SwiftUI + +/// Amount entry for a transfer to spending funded from a paired hardware wallet. Mirrors `SpendingAmount`, but the +/// available/MAX/quarter limits come from the device's balance, and Continue advances to the on-device Sign step. +struct SpendingAmountHw: View { + let walletId: String + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(SparkTransferFlow.self) private var flow + + @State private var amountViewModel = AmountInputViewModel() + @State private var isPricingFailed = false + @State private var maxAttempt = 0 + @State private var isPricing = false + + private var amountSats: UInt64 { + amountViewModel.amountSats + } + + private var isLoading: Bool { + flow.hwSend.isFundingSourceLoading && !isPricingFailed + } + + private var maxAllowed: UInt64 { + flow.hwSend.availableSats + } + + private var isValidAmount: Bool { + !isLoading && !isPricingFailed && amountSats > 0 && amountSats <= maxAllowed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spending_amount__title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + + NumberPadTextField(viewModel: amountViewModel, showConversion: false) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.top, 32) + + Spacer() + + HStack(alignment: .bottom) { + availableAmount + + Spacer() + + actionButtons + } + .padding(.bottom, 12) + + Divider() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey, + isDisabled: isLoading || isPricingFailed + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton(title: t("common__continue"), isDisabled: !isValidAmount || isPricing, isLoading: isPricing) { + await onContinue() + } + .accessibilityIdentifier("HardwareTransferAmountContinue") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HardwareTransferAmount") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onAppear { flow.beginIfBroadcast() } + .task(id: maxAttempt) { + await loadAvailable() + } + .onChange(of: maxAllowed, initial: true) { updateInputCap() } + .onChange(of: isLoading) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } + } + + @ViewBuilder + private var availableAmount: some View { + if isLoading { + HStack(spacing: 4) { + CaptionMText(t("wallet__send_available")) + ProgressView() + .scaleEffect(0.7) + } + } else if isPricingFailed { + Button { + maxAttempt += 1 + } label: { + CaptionMText(t("lightning__spending_amount__error_fee"), textColor: .redAccent) + .multilineTextAlignment(.leading) + } + .accessibilityIdentifier("HardwareTransferAmountRetry") + } else { + AvailableAmount( + label: t("wallet__send_available"), + amount: Int(maxAllowed), + testIdentifier: "HardwareTransferAmountAvailable" + ) + } + } + + /// Prices the transfer first, so an amount below the claim minimum stays here with a toast, as on Android. + private func onContinue() async { + guard isValidAmount, !isPricing else { return } + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= maxAllowed, walletId: walletId) + isPricing = true + defer { isPricing = false } + let shownPath = navigation.path + if let quote = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager), + !quote.coversClaimFee + { + if navigation.path == shownPath { + app.toastBelowClaimFee(quote) + } + return + } + // Back while the quote was loading leaves this screen, so the transfer stays closed. + navigation.navigate(.spendingHwSign(walletId: walletId), from: shownPath) + } + + private func loadAvailable() async { + isPricingFailed = false + flow.hwSend.selectWallet(walletId, initialAvailableSats: 0, showsLoading: true) + do { + let address = try await SparkManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: false) else { + throw AppError(message: t("lightning__spending_amount__error_fee"), debugMessage: "No fee estimates") + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + await flow.hwSend.refreshAvailable(manager: hwWalletManager, destinationAddress: address, satsPerVByte: UInt64(rate)) + isPricingFailed = false + } catch { + Logger.warn("Failed to estimate the most a hardware transfer to spending can send: \(error)", context: "SpendingAmountHw") + isPricingFailed = true + } + } + + private func updateInputCap() { + guard !isLoading else { return } + amountViewModel.maxAmountOverride = maxAllowed + } + + private func onMaxExceeded() { + amountViewModel.updateFromSats(maxAllowed, currency: currency) + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxAllowed)] + ), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "HardwareTransferAmountExceededToast" + ) + } + + private var actionButtons: some View { + HStack(spacing: 8) { + NumberPadActionButton( + text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency, + imageName: "arrow-up-down" + ) { + withAnimation { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + } + + NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + let quarter = hwWalletManager.fundingBalance(walletId: walletId) / 4 + amountViewModel.updateFromSats(min(quarter, maxAllowed), currency: currency) + } + .disabled(isLoading || isPricingFailed) + .accessibilityIdentifier("HardwareTransferAmountQuarter") + + NumberPadActionButton(text: t("common__max")) { + amountViewModel.updateFromSats(maxAllowed, currency: currency) + } + .disabled(isLoading || isPricingFailed) + .accessibilityIdentifier("HardwareTransferAmountMax") + } + } +} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift new file mode 100644 index 000000000..7bf8d4bb5 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -0,0 +1,249 @@ +import SwiftUI + +/// Sign a transfer to spending on the paired hardware wallet: the device pays the Spark deposit address, then the +/// setting-up screen claims the deposit at the chosen speed. +struct SpendingHwSign: View { + let walletId: String + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(SparkTransferFlow.self) private var flow + + @State private var signingTask: Task? + @State private var passphraseTask: Task? + + private var hwSend: HwSendCoordinator { + flow.hwSend + } + + private struct QuoteInputs: Equatable { + let amountSats: UInt64 + let speed: SparkTransferSpeed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(!hwSend.canLeave) + .padding(.bottom, 16) + + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText( + t(hwSend.hasPendingBroadcast ? "lightning__transfer_hw__signed_title" : "lightning__transfer_hw__sign_title"), + accentColor: .purpleAccent + ) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) + .padding(.top, 16) + + if let error = flow.quoteError { + BodyMText(error, textColor: .redAccent) + .padding(.bottom, 16) + } + + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) + .disabled(isBusy || hwSend.hasPendingBroadcast) + .padding(.horizontal, -16) + .padding(.bottom, 16) + + CustomButton(title: t("common__learn_more"), size: .small, isDisabled: isBusy || hwSend.hasPendingBroadcast) { + navigation.navigate(.transferLearnMore) + } + .accessibilityIdentifier("HardwareTransferSignLearnMore") + + Spacer(minLength: 16) + + Image(vendor.signImageName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 200, maxHeight: 200) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + + Spacer(minLength: 16) + } + // Fills the free space on tall screens so the picture sits centred in it, and scrolls on short ones. + .frame(minHeight: geometry.size.height) + // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. + .padding(.horizontal, 16) + } + .padding(.horizontal, -16) + } + + CustomButton( + title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, + isDisabled: isBusy || flow.quote == nil, + isLoading: isBusy + ) { + startSigning() + } + .accessibilityIdentifier("HardwareTransferOpenTrezorConnect") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HardwareTransferSign") + .navigationBarHidden(true) + .allowSwipeBack(hwSend.canLeave) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .task(id: QuoteInputs(amountSats: flow.amountSats, speed: flow.speed)) { + guard !SparkTransferFlow.isRunningInPreview, !hwSend.hasPendingBroadcast else { return } + hwWalletManager.warmUpConnection(walletId: walletId) + await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager) + } + .sheet(isPresented: passphrasePromptBinding) { + HwPassphrasePromptSheet( + isVerifying: hwSend.isVerifyingPassphrase, + onSubmit: reconnectWithPassphrase, + onCancel: dismissPassphrase + ) + } + .onDisappear { + let stillInFlow = navigation.path.contains { + if case .spendingHwSign = $0 { + return true + } + return false + } + guard !stillInFlow, !hwSend.isBroadcastUnresolved else { return } + signingTask?.cancel() + signingTask = nil + passphraseTask?.cancel() + passphraseTask = nil + hwSend.cancel() + } + } + + private var isBusy: Bool { + hwSend.isSigning || flow.isQuoting + } + + private var vendor: HwWalletVendor { + hwWalletManager.wallets.first { $0.id == walletId }?.vendor ?? .trezor + } + + private var passphrasePromptBinding: Binding { + Binding( + get: { hwSend.isPassphraseRequired }, + set: { + if !$0 { + dismissPassphrase() + } + } + ) + } + + private func startSigning() { + guard signingTask == nil else { return } + signingTask = Task { @MainActor in + defer { signingTask = nil } + guard let quote = await quoteToSign() else { return } + do { + let result = try await PaykitPaymentProofService.shared.withHardwarePaymentOwnership(walletId: walletId) { + try await hwSend.signAndBroadcast( + manager: hwWalletManager, + address: quote.address, + sats: quote.depositSats, + satsPerVByte: UInt64(quote.satsPerVbyte) + ) + } + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: result.txId, + address: quote.address, + amount: quote.depositSats, + fee: result.miningFeeSats, + feeRate: UInt32(clamping: result.feeRate), + isTransfer: true, + walletId: walletId + ) + hwSend.completeBroadcast() + SparkManager.shared.onDepositBroadcast(txid: result.txId, amountSats: quote.depositSats) + flow.onBroadcast(txid: result.txId) + Logger.info("Funded Spark wallet with \(quote.depositSats) sats from hardware wallet in \(result.txId)", context: "SpendingHwSign") + navigation.navigate(.spendingHwSigned) + } catch is CancellationError { + return + } catch is HwPassphraseError { + hwSend.requestPassphrase() + } catch let error as HwTransferError { + app.toast(error) + } catch { + showHardwareError(error) + } + } + } + + /// A fresh quote to sign with, or nil when it failed or the fees went up since the screen showed them. A retry of a + /// signed transaction keeps the quote it was signed with. + private func quoteToSign() async -> SparkTransferQuote? { + guard let shown = flow.quote else { return nil } + if hwSend.hasPendingBroadcast { + return shown + } + guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager) + else { return nil } + guard fresh.coversClaimFee else { + app.toastBelowClaimFee(fresh) + return nil + } + let increase = fresh.increase(over: shown, speed: flow.speed) + guard increase == 0 else { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: increase)] + ) + ) + return nil + } + return fresh + } + + private func reconnectWithPassphrase(_ passphrase: String) { + guard passphraseTask == nil else { return } + passphraseTask = Task { @MainActor in + defer { passphraseTask = nil } + do { + try await hwSend.reconnectWithPassphrase(passphrase, manager: hwWalletManager) + startSigning() + } catch is CancellationError { + return + } catch HwPassphraseError.mismatch { + app.toast(HwTransferError.passphraseMismatch) + } catch { + showHardwareError(error) + } + } + } + + private func dismissPassphrase() { + passphraseTask?.cancel() + passphraseTask = nil + hwSend.dismissPassphrase() + } + + private func showHardwareError(_ error: Error) { + if error.isHwUserCancellation() { + return + } + if error is HwWalletMismatchError { + app.toast(HwTransferError.walletMismatch) + } else if let vendor = error.hwBusyVendor { + app.toast(HwTransferError.deviceBusy(vendor)) + } else if error.isHwFirmwareError() { + app.toast(HwTransferError.firmwareReconnect) + } else if hwSend.hasPendingBroadcast, error.isBroadcastConnectivityFailure() { + app.toast(HwTransferError.broadcastConnectivity) + } else { + app.toast(error) + } + } +} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift new file mode 100644 index 000000000..749df76ef --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// "Transaction signed" — a brief confirmation of the signed transfer before auto-forwarding to the shared +/// "Funds in Transfer" progress screen. +struct SpendingHwSigned: View { + @EnvironmentObject var navigation: NavigationViewModel + @Environment(SparkTransferFlow.self) private var flow + + /// Figma handoff delay before forwarding from the signed confirmation. + private let autoForwardDelay: Duration = .seconds(1) + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title"), showBackButton: false) + .padding(.bottom, 16) + + DisplayText(t("lightning__transfer_hw__signed_title"), accentColor: .purpleAccent) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed) + .padding(.top, 16) + + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("HardwareTransferSigned") + + Spacer() + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + guard !SparkTransferFlow.isRunningInPreview else { return } + try? await Task.sleep(for: autoForwardDelay) + guard !Task.isCancelled else { return } + navigation.navigate(.settingUp) + } + } +} + +#Preview { + NavigationStack { + SpendingHwSigned() + .environmentObject(CurrencyViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(SettingsViewModel.shared) + .environment(SparkTransferFlow(amountSats: 100_000)) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SavingsAdvancedView.swift b/Bitkit/Views/Transfer/SavingsAdvancedView.swift index d0719874f..b0216bbf5 100644 --- a/Bitkit/Views/Transfer/SavingsAdvancedView.swift +++ b/Bitkit/Views/Transfer/SavingsAdvancedView.swift @@ -8,15 +8,25 @@ struct SavingsAdvancedView: View { @EnvironmentObject var wallet: WalletViewModel @Environment(\.dismiss) var dismiss - private var totalSelectedBalance: UInt64 { - guard let channels = wallet.channels else { return 0 } + private var channels: [ChannelDetails] { + wallet.channels?.filter(\.isChannelReady) ?? [] + } - return - channels - .filter { transfer.selectedChannelIds.contains($0.channelId) } - .reduce(0) { total, channel in - return total + channel.balanceOnCloseSats - } + private var sparkSats: UInt64 { + SparkManager.shared.isExiting ? 0 : SparkManager.shared.balanceSats + } + + private var selection: SavingsSelection { + transfer.savingsSelection(readyChannelIds: channels.map(\.channelId), sparkSats: sparkSats) + } + + private var totalSelectedBalance: UInt64 { + let channelSats = channels + .filter { selection.channelIds.contains($0.channelId) } + .reduce(0) { total, channel in + return total + channel.balanceOnCloseSats + } + return channelSats + (selection.withdrawsSpark ? sparkSats : 0) } var body: some View { @@ -30,10 +40,12 @@ struct SavingsAdvancedView: View { BodyMText(t("lightning__savings_advanced__text")) .padding(.bottom, 16) - if let channels = wallet.channels { - ForEach(Array(channels.enumerated()), id: \.element.channelId) { index, channel in - channelView(channel: channel, index: index, isLast: index == channels.count - 1) - } + ForEach(Array(channels.enumerated()), id: \.element.channelId) { index, channel in + channelView(channel: channel, index: index, isLast: index == channels.count - 1) + } + + if sparkSats > 0 { + sparkView } Spacer() @@ -46,7 +58,7 @@ struct SavingsAdvancedView: View { CustomButton( title: t("common__continue"), - isDisabled: transfer.selectedChannelIds.isEmpty + isDisabled: selection.isEmpty ) { dismiss() } @@ -55,8 +67,8 @@ struct SavingsAdvancedView: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .task { - if transfer.selectedChannelIds.isEmpty, let channels = wallet.channels { - transfer.selectedChannelIds = channels.map(\.channelId) + if transfer.selectedChannelIds == nil { + transfer.selectedChannelIds = Set(channels.map(\.channelId)) } } } @@ -78,16 +90,16 @@ struct SavingsAdvancedView: View { "", isOn: Binding( get: { - transfer.selectedChannelIds.contains(channel.channelId) + selection.channelIds.contains(channel.channelId) }, set: { isSelected in + var ids = transfer.selectedChannelIds ?? Set(channels.map(\.channelId)) if isSelected { - if !transfer.selectedChannelIds.contains(channel.channelId) { - transfer.selectedChannelIds.append(channel.channelId) - } + ids.insert(channel.channelId) } else { - transfer.selectedChannelIds.removeAll { $0 == channel.channelId } + ids.remove(channel.channelId) } + transfer.selectedChannelIds = ids } ) ) @@ -98,6 +110,26 @@ struct SavingsAdvancedView: View { Divider() } + + private var sparkView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("lightning__spark")) + MoneyText(sats: Int(sparkSats), size: .bodySSB, symbol: true) + } + + Spacer() + + Toggle("", isOn: $transfer.isSparkSelected) + .toggleStyle(SwitchToggleStyle(tint: .brandAccent)) + .accessibilityIdentifier("SavingsSparkToggle") + } + .padding(.vertical, 16) + + Divider() + } + } } #Preview { diff --git a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift index 45d460109..3df0af74e 100644 --- a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift +++ b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SavingsAvailabilityView: View { @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -11,7 +12,7 @@ struct SavingsAvailabilityView: View { DisplayText(t("lightning__availability__title"), accentColor: .brandAccent) BodyMText(t("lightning__availability__text"), accentFont: Fonts.bold) - .padding(.top, 16) + .padding(.top, 8) Spacer() @@ -31,6 +32,7 @@ struct SavingsAvailabilityView: View { } CustomButton(title: t("common__continue")) { + transfer.beginSavingsTransfer() navigation.navigate(.savingsConfirm) } .accessibilityIdentifier("AvailabilityContinue") diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 8fcf963e2..435572447 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -10,36 +10,59 @@ struct SavingsConfirmView: View { @State private var hideSwipeButton = false - private var hasMultipleChannels: Bool { - guard let channels = wallet.channels else { return false } - return channels.filter(\.isChannelReady).count > 1 + private var readyChannels: [ChannelDetails] { + wallet.channels?.filter(\.isChannelReady) ?? [] } - private var hasSelectedChannels: Bool { - return !transfer.selectedChannelIds.isEmpty + private var sparkSats: UInt64 { + SparkManager.shared.isExiting ? 0 : SparkManager.shared.balanceSats } - private var channels: [ChannelDetails] { - guard let channels = wallet.channels else { return [] } - let usableChannels = channels.filter(\.isChannelReady) + /// Each ready channel and a Spark balance is a row of the Advanced list. + private var hasMultipleItems: Bool { + readyChannels.count + (sparkSats > 0 ? 1 : 0) > 1 + } - if transfer.selectedChannelIds.isEmpty { - return usableChannels - } else { - return usableChannels.filter { transfer.selectedChannelIds.contains($0.channelId) } - } + private var hasSelectedItems: Bool { + transfer.selectedChannelIds != nil || !transfer.isSparkSelected + } + + private var selection: SavingsSelection { + transfer.savingsSelection(readyChannelIds: readyChannels.map(\.channelId), sparkSats: sparkSats) + } + + /// Spark is priced while picked with a balance, also once too small to move, so a receive can bring it back in. + private var pricesSpark: Bool { + transfer.isSparkSelected && sparkSats > 0 + } + + private var channels: [ChannelDetails] { + let ids = selection.channelIds + return readyChannels.filter { ids.contains($0.channelId) } } private var totalSats: UInt64 { channels.reduce(0) { $0 + $1.balanceOnCloseSats } } + /// What the selected channels can send, so a swap never falls back to paying from Spark. + private var channelsSendableSats: UInt64 { + channels.filter(\.isUsable).reduce(0) { $0 + $1.nextOutboundHtlcLimitMsat } / 1000 + } + private var swapState: SavingsSwapState { transfer.savingsSwapState } private var headlineSats: UInt64 { - swapState.quote?.amountSat ?? totalSats + (swapState.quote?.amountSat ?? totalSats) + (selection.withdrawsSpark ? sparkSats : 0) + } + + /// What reaches savings: the swap's receive plus Spark's balance net of its withdrawal fee. + private func receiveSats(_ quote: SavingsSwapQuote) -> UInt64 { + guard selection.withdrawsSpark else { return quote.receiveSat } + let feeSats = transfer.sparkWithdrawFeeSats ?? 0 + return quote.receiveSat + (sparkSats > feeSats ? sparkSats - feeSats : 0) } var body: some View { @@ -47,65 +70,74 @@ struct SavingsConfirmView: View { NavigationBar(title: t("lightning__transfer__nav_title")) .padding(.bottom, 16) - DisplayText(t("lightning__transfer__confirm"), accentColor: .brandAccent) - .fixedSize(horizontal: false, vertical: true) + // The piggybank keeps its size; when the fees, slider and Spark row make the content tall, it scrolls above the swipe. + GeometryReader { geo in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__transfer__confirm"), accentColor: .brandAccent) + .fixedSize(horizontal: false, vertical: true) - CaptionMText(t("lightning__savings_confirm__label")) - .padding(.top, 32) - .padding(.bottom, 16) + CaptionMText(t("lightning__savings_confirm__label")) + .padding(.top, 32) + .padding(.bottom, 16) - MoneyText(sats: Int(headlineSats), size: .display, symbol: true) + MoneyText(sats: Int(headlineSats), size: .display, symbol: true) - if let quote = swapState.quote { - quoteSection(quote) - } + if let quote = swapState.quote { + quoteSection(quote) + } - if hasMultipleChannels { - HStack(spacing: 16) { - if hasSelectedChannels { - CustomButton(title: t("lightning__savings_confirm__transfer_all"), size: .small) { - transfer.setSelectedChannelIds([]) + sparkSection + + if hasMultipleItems { + HStack(spacing: 16) { + if hasSelectedItems { + CustomButton(title: t("lightning__savings_confirm__transfer_all"), size: .small) { + transfer.selectAllForSavings() + } + } else { + CustomButton(title: t("common__advanced"), size: .small) { + navigation.navigate(.savingsAdvanced) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 32) } - } else { - CustomButton(title: t("common__advanced"), size: .small) { - navigation.navigate(.savingsAdvanced) + + Spacer(minLength: 0) + + // A spinner stands in for the piggybank while the quote loads. + Group { + if swapState.quote == nil, swapState.isLoading { + ProgressView() + } else { + Image("piggybank-right") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + } } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + + Spacer(minLength: 0) } + .frame(minHeight: geo.size.height) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) } - Spacer() - - // 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, - isLoading: swapState.quote == nil && swapState.isLoading + isLoading: (swapState.quote == nil && swapState.isLoading) || transfer.isPricingSparkWithdrawal ) { // 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. + try await confirmSwipe() do { - transfer.onTransferToSavingsConfirm(channels: channels) - try await Task.sleep(nanoseconds: 300_000_000) navigation.navigate(.savingsProgress) @@ -118,12 +150,19 @@ struct SavingsConfirmView: View { throw error } } + .disabled(selection.isEmpty || !transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark)) } 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) + CustomButton( + title: t("lightning__savings_confirm__close_instead"), + variant: .tertiary, + isDisabled: !transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark) || transfer.isRecheckingSparkFee + ) { + guard transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark, mode: .close) else { + return + } navigation.navigate(.savingsProgress) } .padding(.top, 12) @@ -142,13 +181,82 @@ struct SavingsConfirmView: View { await wallet.syncStateAsync() await transfer.loadSavingsSwapQuote( requestedSat: totalSats, - spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) + spendableSats: channelsSendableSats + ) + } + .onChange(of: pricesSpark ? sparkSats : nil, initial: true) { _, balanceSats in + guard let balanceSats else { return } + transfer.loadSparkWithdrawFee(balanceSats: balanceSats, savingsAddress: wallet.onchainAddress) + } + } + + /// Spark's withdrawal fee, or why it could not be priced, below the swap's fees or the headline when there is no swap. + @ViewBuilder + private var sparkSection: some View { + if selection.withdrawsSpark, transfer.sparkWithdrawFeeSats != nil || transfer.isPricingSparkWithdrawal { + FeeDisplayRow( + label: t("lightning__savings_confirm__spark_fee"), + amount: transfer.sparkWithdrawFeeSats ?? 0, + isLoading: transfer.isPricingSparkWithdrawal ) + .padding(.top, swapState.quote == nil ? 40 : 16) + .accessibilityIdentifier("SavingsSparkFee") + } else if selection.withdrawsSpark || selection.isEmpty && pricesSpark && transfer.isSparkWithdrawTooSmall { + sparkFeeError + .padding(.top, 16) + } + } + + /// Spark's withdrawal fee could not be priced; the swipe waits for it, so it can be retried here. A balance too + /// small to move after its fee has nothing to retry. + @ViewBuilder + private var sparkFeeError: some View { + if transfer.isSparkWithdrawTooSmall { + BodyMText(t("lightning__savings_confirm__amount_too_low"), textColor: .redAccent) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SavingsSparkTooLow") + } else { + sparkFeeRetry + } + } + + private var sparkFeeRetry: some View { + HStack(alignment: .center, spacing: 16) { + BodyMText(t("lightning__spending_confirm__error_quote"), textColor: .redAccent) + .frame(maxWidth: .infinity, alignment: .leading) + CustomButton(title: t("common__retry"), size: .small) { + transfer.loadSparkWithdrawFee(balanceSats: sparkSats, savingsAddress: wallet.onchainAddress) + } + .fixedSize() + .accessibilityIdentifier("SavingsSparkFeeRetry") + } + } + + /// Commits the swipe. Asks for another swipe when Spark's withdrawal fee rose since it was shown, or is being priced + /// again for a new balance, which the swipe shows as loading. + private func confirmSwipe() async throws { + do { + try await transfer.confirmSavingsSwipe(channels: channels, withdrawsSpark: selection.withdrawsSpark) + } catch is SavingsCommitRefused { + throw SavingsCommitRefused() + } catch let increase as SparkWithdrawalFeeIncreased { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: increase.feeSats - increase.shownFeeSats)] + ) + ) + throw SpendingFeesIncreasedError() + } catch { + app.toast(error) + throw error } } private func quoteSection(_ quote: SavingsSwapQuote) -> some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top, spacing: 16) { FeeDisplayRow(label: t("lightning__savings_confirm__network_fee"), amount: quote.networkFeeSat) .frame(maxWidth: .infinity, alignment: .leading) @@ -156,9 +264,9 @@ struct SavingsConfirmView: View { .frame(maxWidth: .infinity, alignment: .leading) } HStack(alignment: .top, spacing: 16) { - FeeDisplayRow(label: t("lightning__savings_confirm__amount"), amount: quote.amountSat) + FeeDisplayRow(label: t("lightning__savings_confirm__amount"), amount: headlineSats) .frame(maxWidth: .infinity, alignment: .leading) - FeeDisplayRow(label: t("lightning__savings_confirm__receive"), amount: quote.receiveSat) + FeeDisplayRow(label: t("lightning__savings_confirm__receive"), amount: receiveSats(quote)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -172,10 +280,12 @@ struct SavingsConfirmView: View { minValue: swapState.minSat, maxValue: swapState.maxSat ) - .padding(.top, 16) + // Inset so the knob stays on screen at either end. + .padding(.horizontal, 16) + .padding(.top, 12) } } - .padding(.top, 24) + .padding(.top, 40) } } diff --git a/Bitkit/Views/Transfer/SavingsIntroView.swift b/Bitkit/Views/Transfer/SavingsIntroView.swift index 6d4a3e42d..681d64299 100644 --- a/Bitkit/Views/Transfer/SavingsIntroView.swift +++ b/Bitkit/Views/Transfer/SavingsIntroView.swift @@ -16,6 +16,9 @@ struct SavingsIntroView: View { navigation.navigate(.savingsAvailability) }, imagePosition: .center, + titleDescriptionSpacing: 8, + buttonHorizontalPadding: 0, + maxImageSize: 256, testID: "SavingsIntro" ) .navigationBarHidden(true) diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index b13b8e17c..51d932984 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -1,12 +1,14 @@ import SwiftUI -enum SavingsProgressState { +enum SavingsProgressState: Equatable { 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 + /// Spark's balance didn't move; `channelsMoved` when the selected channels still did. + case sparkFailed(channelsMoved: Bool) } struct SavingsProgressContentView: View { @@ -23,7 +25,8 @@ struct SavingsProgressContentView: View { switch progressState { 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") + case .success: return t("lightning__transfer_success__nav_title") + case .sparkFailed: return t("lightning__transfer__nav_title") } } @@ -33,6 +36,7 @@ struct SavingsProgressContentView: View { 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") + case .sparkFailed: return t("lightning__savings_spark_failed__title") } } @@ -42,6 +46,16 @@ struct SavingsProgressContentView: View { 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") + case let .sparkFailed(channelsMoved): + return t(channelsMoved ? "lightning__savings_spark_failed__text_partial" : "lightning__savings_spark_failed__text") + } + } + + private var imageIdentifier: String? { + switch progressState { + case .success: "TransferSuccess" + case .sparkFailed: "SavingsSparkFailed" + default: nil } } @@ -51,7 +65,7 @@ struct SavingsProgressContentView: View { .padding(.bottom, 16) DisplayText(title, accentColor: .brandAccent) - .padding(.bottom, 16) + .padding(.bottom, 8) BodyMText(text, accentFont: Fonts.bold) @@ -98,24 +112,23 @@ struct SavingsProgressContentView: View { } } else { - Image(progressState == .failed ? "exclamation-mark" : "check") + Image(progressState == .success ? "check" : "exclamation-mark") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 256, height: 256) .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess" : nil) + .accessibilityIdentifierIfPresent(imageIdentifier) } Spacer() - CustomButton( - title: t("common__ok"), - isLoading: progressState == .inProgress - ) { - navigation.reset() + if progressState != .inProgress { + CustomButton(title: t("common__ok")) { + navigation.reset() + } + .accessibilityIdentifierIfPresent(imageIdentifier.map { "\($0)-button" }) } - .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess-button" : nil) } .navigationBarHidden(true) .allowSwipeBack(false) @@ -168,12 +181,12 @@ struct SavingsProgressView: View { case .success: await wallet.syncStateAsync() withAnimation { - progressState = .success + progressState = sparkFailedState(channelsMoved: true) ?? .success } case .pending: await wallet.syncStateAsync() withAnimation { - progressState = .settling + progressState = sparkFailedState(channelsMoved: true) ?? .settling } case let .failure(message): app.toast( @@ -185,21 +198,48 @@ struct SavingsProgressView: View { } } + /// The Spark failure state, with its error as a toast, when the transfer's Spark withdrawal failed. + private func sparkFailedState(channelsMoved: Bool) -> SavingsProgressState? { + guard transfer.sparkWithdrawError != nil else { return nil } + toastSparkError() + return .sparkFailed(channelsMoved: channelsMoved) + } + + private func toastSparkError() { + guard let message = transfer.sparkWithdrawError else { return } + app.toast(type: .error, title: t("lightning__savings_spark_failed__toast"), description: message) + } + /// 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) + if transfer.hasChannelsToClose { + try await Task.sleep(nanoseconds: 2_000_000_000) + } + let hadChannels = transfer.hasChannelsToClose let channelsFailedToCoopClose = try await transfer.closeSelectedChannels() + let outcome = SavingsTransferOutcome.resolve( + sparkFailed: transfer.sparkWithdrawError != nil, + movedChannels: hadChannels, + channelsFailedToClose: !channelsFailedToCoopClose.isEmpty + ) - if channelsFailedToCoopClose.isEmpty { + switch outcome { + case .success: // Re-enable screen timeout when we're done UIApplication.shared.isIdleTimerDisabled = false withAnimation { progressState = .success } - } else { + case let .sparkFailed(channelsMoved): + UIApplication.shared.isIdleTimerDisabled = false + withAnimation { + progressState = sparkFailedState(channelsMoved: channelsMoved) ?? .success + } + case .channelsInterrupted: + toastSparkError() withAnimation { progressState = .failed } @@ -239,6 +279,15 @@ struct SavingsProgressView: View { .preferredColorScheme(.dark) } +#Preview("Spark Failed") { + NavigationStack { + SavingsProgressContentView(progressState: .sparkFailed(channelsMoved: true)) + .environmentObject(AppViewModel()) + .environmentObject(TransferViewModel()) + } + .preferredColorScheme(.dark) +} + #Preview("Failed") { NavigationStack { SavingsProgressContentView(progressState: .failed) diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift new file mode 100644 index 000000000..9b23da6ff --- /dev/null +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -0,0 +1,192 @@ +import SwiftUI + +struct SettingUpView: View { + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @Environment(SparkTransferFlow.self) private var flow + + /// Keep in state so we don't get a new random text on each render + @State private var randomOkText: String = localizedRandom("common__ok_random") + + private var state: SparkClaimState { + flow.claimState + } + + var isTransferring: Bool { + !state.isDone + } + + var navTitle: String { + isTransferring ? t("lightning__transfer__nav_title") : t("lightning__transfer_success__nav_title") + } + + var title: String { + isTransferring ? t("lightning__savings_progress__title") : t("lightning__transfer_success__title_spending") + } + + var text: String { + guard isTransferring else { return t("lightning__transfer_success__text_spending") } + return switch state { + case .looking: t("lightning__spark_deposit__looking") + case .claiming, .adding, .added: t("lightning__spark_deposit__claimed") + case let .repriced(feeSats): t("lightning__spark_deposit__repriced", variables: ["fee": currency.primaryAmountText(sats: feeSats)]) + case .unavailable: t("lightning__spark_deposit__instant_unavailable") + case .later: t("lightning__spark_deposit__standard", variables: [ + "standard": "\(SparkTransferFlow.matureConfirmations)", + "minutes": "\(SparkTransferFlow.matureMinutes)", + ]) + case .notSeen: t("lightning__spark_deposit__not_seen") + } + } + + /// The speed the user chose, or Later once they chose to wait. + var speedText: String { + t("lightning__setting_up_speed", variables: [ + "speed": Self.showsLater(speed: flow.speed, state: state) ? t("lightning__spending_confirm__later") : t("fee__instant__title"), + ]) + } + + /// Later too whenever the text says the deposit waits for confirmations. + static func showsLater(speed: SparkTransferSpeed, state: SparkClaimState) -> Bool { + speed == .later || state == .later || state == .unavailable || state == .notSeen + } + + var buttonTitle: String { + isTransferring ? t("lightning__setting_up_button") : randomOkText + } + + /// Sent, seen by Spark, added to spending. + let steps = [ + t("lightning__setting_up_step1"), + t("lightning__setting_up_step2"), + t("lightning__setting_up_step_spark"), + ] + + /// The index of the step in progress; every step is done once the deposit is added. + static func currentStep(for state: SparkClaimState) -> Int { + switch state { + case .looking, .notSeen, .later: 1 + case .claiming, .adding, .repriced, .unavailable: 2 + case .added: 3 + } + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: navTitle, showBackButton: false) + .padding(.bottom, 16) + + VStack(alignment: .leading, spacing: 0) { + DisplayText(title, accentColor: .purpleAccent) + .padding(.bottom, 8) + .layoutPriority(1) + + BodyMText(text, accentColor: .white, accentFont: Fonts.bold) + .layoutPriority(1) + .accessibilityIdentifier("SettingUpText") + + if isTransferring { + CaptionMText(speedText) + .padding(.top, 8) + .accessibilityIdentifier("SettingUpSpeed") + } + + if isTransferring { + EllipseLoader(variant: .transfer) + .padding(.top, 32) + .padding(.horizontal, 16) + .accessibilityIdentifier("LightningSettingUp") + } else { + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("TransferSuccess") + } + + Spacer() + + if isTransferring { + ProgressSteps(steps: steps, currentStep: Self.currentStep(for: state)) + .padding(.vertical, 16) + } + + if case let .repriced(feeSats) = state { + repricedButtons(feeSats: feeSats) + } else { + CustomButton(title: buttonTitle) { + navigation.reset() + } + .accessibilityIdentifier("TransferSuccess-button") + } + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + // Later and Wait run no claim of their own, so the deposit Spark claims at maturity ends the transfer. + .onChange(of: SparkManager.shared.deposits, initial: true) { _, deposits in + flow.noteSettled(deposits) + } + } + + private func repricedButtons(feeSats: UInt64) -> some View { + HStack(spacing: 16) { + CustomButton(title: t("lightning__spark_deposit__wait_button"), variant: .secondary) { + flow.waitForConfirmations() + } + .accessibilityIdentifier("SparkDepositWait") + + CustomButton(title: t("lightning__spark_deposit__pay", variables: ["fee": currency.primaryAmountText(sats: feeSats)])) { + flow.payRepriced() + } + .accessibilityIdentifier("SparkDepositPay") + } + } +} + +#Preview("Looking") { + settingUpPreview(.looking) +} + +#Preview("Claiming") { + settingUpPreview(.claiming) +} + +#Preview("Adding") { + settingUpPreview(.adding) +} + +#Preview("Done") { + settingUpPreview(.added) +} + +#Preview("Repriced") { + settingUpPreview(.repriced(feeSats: 2150)) +} + +#Preview("Instant unavailable") { + settingUpPreview(.unavailable) +} + +#Preview("Later") { + settingUpPreview(.later) +} + +#Preview("Not seen") { + settingUpPreview(.notSeen) +} + +@MainActor +private func settingUpPreview(_ state: SparkClaimState) -> some View { + NavigationStack { + SettingUpView() + .environmentObject(CurrencyViewModel()) + .environmentObject(NavigationViewModel()) + .environment(SparkTransferFlow(amountSats: 100_000, claimState: state)) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift new file mode 100644 index 000000000..215dedb0b --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -0,0 +1,222 @@ +import SwiftUI + +struct SpendingAmount: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(SparkTransferFlow.self) private var flow + + @State private var amountViewModel = AmountInputViewModel() + @State private var isCalculatingMax = true + @State private var maxTransferAmount: UInt64? + @State private var maxAttempt = 0 + @State private var isPricing = false + + private var amountSats: UInt64 { + amountViewModel.amountSats + } + + private var isValidAmount: Bool { + guard let max = maxTransferAmount else { return false } + return amountSats > 0 && amountSats <= max + } + + /// Inputs the max depends on: a new balance or a retry restarts the calculation. + private struct MaxCalcInputs: Equatable { + let spendableOnchainBalanceSats: Int + let attempt: Int + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spending_amount__title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + + NumberPadTextField( + viewModel: amountViewModel, + showConversion: false, + testIdentifier: "SpendingAmountNumberField" + ) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.top, 32) + + Spacer() + + HStack(alignment: .bottom) { + availableAmount + + Spacer() + + actionButtons + } + .padding(.bottom, 12) + + Divider() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey, + isDisabled: isCalculatingMax || maxTransferAmount == nil + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton( + title: t("common__continue"), + isDisabled: !isValidAmount, + isLoading: isPricing + ) { + await onContinue() + } + .accessibilityIdentifier("SpendingAmountContinue") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingAmount") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onAppear { flow.beginIfBroadcast() } + .task(id: MaxCalcInputs(spendableOnchainBalanceSats: wallet.spendableOnchainBalanceSats, attempt: maxAttempt)) { + isCalculatingMax = true + await calculateMaxTransferAmount() + if !Task.isCancelled { + isCalculatingMax = false + } + } + .onChange(of: maxTransferAmount) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } + } + + @ViewBuilder + private var availableAmount: some View { + if isCalculatingMax { + HStack(spacing: 4) { + CaptionMText(t("wallet__send_available")) + ProgressView() + .scaleEffect(0.7) + } + } else if let max = maxTransferAmount { + AvailableAmount( + label: t("wallet__send_available"), + amount: Int(max), + testIdentifier: "SpendingAmountAvailable" + ) + } else { + Button { + maxAttempt += 1 + } label: { + CaptionMText(t("lightning__spending_amount__error_fee"), textColor: .redAccent) + .multilineTextAlignment(.leading) + } + .accessibilityIdentifier("SpendingAmountRetry") + } + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = maxTransferAmount + } + + private func onMaxExceeded() { + if let max = maxTransferAmount { + amountViewModel.updateFromSats(max, currency: currency) + } + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxTransferAmount ?? 0)] + ), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "SpendingAmountExceededToast" + ) + } + + private var actionButtons: some View { + HStack(spacing: 8) { + NumberPadActionButton( + text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency, + imageName: "arrow-up-down" + ) { + withAnimation { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + } + + NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + guard let cap = maxTransferAmount else { return } + let quarter = UInt64(max(0, wallet.spendableOnchainBalanceSats)) / 4 + amountViewModel.updateFromSats(min(quarter, cap), currency: currency) + } + .disabled(maxTransferAmount == nil) + .accessibilityIdentifier("SpendingAmountQuarter") + + NumberPadActionButton(text: t("common__max")) { + guard let max = maxTransferAmount else { return } + amountViewModel.updateFromSats(max, currency: currency) + } + .disabled(maxTransferAmount == nil) + .accessibilityIdentifier("SpendingAmountMax") + } + } + + /// Prices the transfer first, so an amount below the claim minimum stays here with a toast, as on Android. + private func onContinue() async { + guard let max = maxTransferAmount, isValidAmount, !isPricing else { return } + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= max) + isPricing = true + defer { isPricing = false } + let shownPath = navigation.path + if let quote = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager), !quote.coversClaimFee { + if navigation.path == shownPath { + app.toastBelowClaimFee(quote) + } + return + } + // Back while the quote was loading leaves this screen, so the transfer stays closed. + navigation.navigate(.spendingConfirm, from: shownPath) + } + + /// The most savings can send to the Spark deposit address after the fast-rate network fee for sending all of it. + private func calculateMaxTransferAmount() async { + guard wallet.spendableOnchainBalanceSats > 0 else { + maxTransferAmount = 0 + return + } + do { + let address = try await SparkManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: false) else { + throw AppError(message: t("lightning__spending_amount__error_fee"), debugMessage: "No fee estimates") + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + let max = try await wallet.calculateMaxSendableAmount(address: address, satsPerVByte: rate) + guard !Task.isCancelled else { return } + maxTransferAmount = max + } catch { + Logger.warn("Failed to estimate the most a transfer to spending can send: \(error)", context: "SpendingAmount") + guard !Task.isCancelled else { return } + maxTransferAmount = nil + } + } +} + +#Preview { + NavigationStack { + SpendingAmount() + .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(NavigationViewModel()) + .environmentObject(WalletViewModel()) + .environment(SparkTransferFlow()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift new file mode 100644 index 000000000..1e433db8f --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -0,0 +1,382 @@ +import SwiftUI + +struct SpendingConfirm: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(SparkTransferFlow.self) private var flow + + @State private var isPaying = false + @State private var hideSwipeButton = false + + /// The coin drawing fills 72% of its square image, so this draws it about 134 pt wide, Android's third of the screen. + private static let coinsSize: CGFloat = 186 + + /// Fee rates move while the screen is open, so the quote is redone this often as well as on every change. + private static let requoteInterval: Duration = .seconds(60) + + private struct QuoteInputs: Equatable { + let amountSats: UInt64 + let isMaxAmount: Bool + let speed: SparkTransferSpeed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(isPaying) + .padding(.bottom, 16) + + // The coins sit in the space left above the swipe, as on Android; on a short screen the content scrolls. + GeometryReader { geo in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) + .padding(.top, 16) + + if let error = flow.quoteError { + quoteError(error) + .padding(.bottom, 16) + } + + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) + .disabled(isPaying) + .padding(.top, 8) + .padding(.horizontal, -16) + .padding(.bottom, 10) + + CustomButton(title: t("common__learn_more"), size: .small) { + navigation.navigate(.transferLearnMore) + } + .disabled(isPaying) + .accessibilityIdentifier("SpendingConfirmMore") + + Spacer(minLength: 0) + + Image("coin-stack-x") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: Self.coinsSize, height: Self.coinsSize) + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + } + // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. + .padding(.horizontal, 16) + .padding(.bottom, 16) + .frame(minHeight: geo.size.height) + } + } + .padding(.horizontal, -16) + + if !hideSwipeButton { + SwipeButton( + title: t("lightning__transfer__swipe"), + accentColor: .purpleAccent, + isLoading: isPaying + ) { + try await onConfirm() + } + .disabled(!canConfirm) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingConfirm") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onAppear { SparkFundingRecovery.confirmScreenOpen = true } + .onDisappear { SparkFundingRecovery.confirmScreenOpen = false } + .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + guard Self.followsRecovered(resolution, isPaying: isPaying), !hideSwipeButton else { return } + // This screen owns the recovered funding: follow it at the chosen speed, as a swipe would have, unless the + // flow already follows it. + if flow.txid != resolution.txid { + flow.onRecoveredBroadcast(txid: resolution.txid, attemptId: resolution.attemptId) + } + hideSwipeButton = true + navigation.navigate(.settingUp) + } + .task(id: QuoteInputs(amountSats: flow.amountSats, isMaxAmount: flow.isMaxAmount, speed: flow.speed)) { + guard !SparkTransferFlow.isRunningInPreview else { return } + while !Task.isCancelled { + if !isPaying { + await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) + } + try? await Task.sleep(for: Self.requoteInterval) + } + } + } + + private var canConfirm: Bool { + guard let quote = flow.quote, !isPaying, !flow.isQuoting else { return false } + return quote.networkFeeSats > 0 && quote.toSpendingSats(for: flow.speed) > 0 + } + + private func quoteError(_ message: String) -> some View { + HStack(alignment: .center, spacing: 16) { + BodyMText(message, textColor: .redAccent) + .frame(maxWidth: .infinity, alignment: .leading) + CustomButton(title: t("common__retry"), size: .small, isLoading: flow.isQuoting) { + await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) + } + .fixedSize() + .accessibilityIdentifier("SpendingConfirmRetry") + } + } + + private func onConfirm() async throws { + // A funding of this transfer resolved while another screen showed, so it is followed, not sent again. + if flow.txid != nil { + navigation.navigate(.settingUp) + return + } + guard let shown = flow.quote, !isPaying else { return } + isPaying = true + defer { isPaying = false } + + guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { + throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote before sending failed") + } + guard fresh.coversClaimFee else { + app.toastBelowClaimFee(fresh) + throw SpendingFeesIncreasedError() + } + let increase = fresh.increase(over: shown, speed: flow.speed) + if increase > 0 { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: increase)] + ) + ) + throw SpendingFeesIncreasedError() + } + + flow.approveSwipe() + do { + let txid = try await transfer.fundSpark( + address: fresh.address, + amountSats: fresh.depositSats, + satsPerVbyte: fresh.satsPerVbyte, + txFee: fresh.networkFeeSats, + isMaxAmount: flow.isMaxAmount + ) + flow.onBroadcast(txid: txid) + await wallet.updateBalanceState() + navigation.navigate(.settingUp) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + hideSwipeButton = true + } + } catch let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged { + // The max quote reads the cached savings balance, so it is read again before pricing the new transfer. + await wallet.syncStateAsync() + // The next swipe approves the fee this one was refused for. + flow.onFundingChanged(preparedFeeSats: (error as? SparkFundingChanged)?.preparedFeeSats) + guard let repriced = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { + throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote after refusal failed") + } + let toSpendingSats = repriced.toSpendingSats(for: flow.speed) + if flow.isMaxAmount, toSpendingSats != fresh.toSpendingSats(for: flow.speed) { + // A max transfer's amount moves with the balance and fees, so a fee increase would mostly be that move. + app.toast( + type: .info, + title: t("lightning__spending_confirm__max_changed_title"), + description: t( + "lightning__spending_confirm__max_changed", + variables: ["amount": currency.primaryAmountText(sats: toSpendingSats)] + ) + ) + } else { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: repriced.increase(over: fresh, speed: flow.speed))] + ) + ) + } + throw SpendingFeesIncreasedError() + } catch let unresolved as SparkFundingUnresolved { + // The funding may be on the network and is kept, so Payment Pending shows it, as for any on-chain send. + if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { + flow.bindApproval(to: attemptId) + } + Logger.warn("Spark funding \(unresolved.txid) is unresolved", context: "SpendingConfirm") + if let reason = unresolved.rejectionReason { + app.toast(type: .error, title: t("wallet__onchain_broadcast_rejected"), + description: t("wallet__onchain_broadcast_rejected_note", variables: ["reason": reason])) + } else { + app.toast(type: .warning, title: t("wallet__onchain_broadcast_unconfirmed"), + description: t("wallet__onchain_broadcast_unconfirmed_note")) + } + let route = await SendConfirmationView.onchainPendingRoute(txid: unresolved.txid, requestId: nil) + sheets.showSheet(.send, data: SendConfig(view: route)) + throw unresolved + } catch { + if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { + flow.bindApproval(to: attemptId, after: error) + } + Logger.error("Failed to transfer to spending: \(error)", context: "SpendingConfirm") + app.toast( + type: .error, + title: t("wallet__send_error_tx_failed"), + description: AppError(error: error).message + ) + throw error + } + } +} + +extension SpendingConfirm { + /// A Spark funding that resolved on its own while this screen stayed open, after an unknown outcome the user left + /// pending, would let the swipe send the transfer again; the swipe's own funding resolves while it is paying. + static func followsRecovered(_ resolution: OnchainSendLocalResolution, isPaying: Bool) -> Bool { + resolution.isSparkFunding && !isPaying + } +} + +/// The swipe reset after the fees went up while the screen was open; the toast says by how much. +struct SpendingFeesIncreasedError: Error {} + +/// Network fee, spending fee, what reaches spending and what leaves savings, in the confirm screen's grid. +struct SpendingConfirmFees: View { + let quote: SparkTransferQuote? + let speed: SparkTransferSpeed + var isLoading = false + + var body: some View { + VStack(spacing: 16) { + HStack { + FeeDisplayRow( + label: t("lightning__spending_confirm__network_fee"), + amount: quote?.networkFeeSats ?? 0, + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + + FeeDisplayRow( + label: t("lightning__spending_confirm__spending_fee"), + amount: quote?.claimFeeSats(for: speed) ?? 0, + prefix: SpendingSpeedPicker.feePrefix(for: speed), + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + } + + HStack { + FeeDisplayRow( + label: t("lightning__spending_confirm__amount"), + amount: quote?.toSpendingSats(for: speed) ?? 0, + // What reaches spending is net of the estimated claim fee, so it is an estimate too. + prefix: SpendingSpeedPicker.feePrefix(for: speed), + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + + FeeDisplayRow( + label: t("lightning__spending_confirm__total"), + amount: quote?.totalSats ?? 0, + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingConfirmFees") + } +} + +/// Instant or Later, in the style of the send flow's fee-speed rows. +struct SpendingSpeedPicker: View { + let quote: SparkTransferQuote? + @Binding var speed: SparkTransferSpeed + + /// Both claim fees are estimates: Instant's is its ceiling, Later's the current rate. + static func feePrefix(for _: SparkTransferSpeed) -> String { + "±" + } + + var body: some View { + VStack(spacing: 0) { + FeeItem( + title: t("fee__instant__title"), + subtitle: t("lightning__spending_confirm__instant_text"), + iconName: TransactionSpeed.fast.iconName, + iconColor: .purpleAccent, + amount: quote?.claimFees.instantMaxSats ?? 0, + amountPrefix: Self.feePrefix(for: .instant), + isSelected: speed == .instant, + isDisabled: false, + dividerAtBottom: true + ) { + speed = .instant + } + .accessibilityIdentifier("SpendingConfirmInstant") + + FeeItem( + title: t("lightning__spending_confirm__later"), + subtitle: t("lightning__spending_confirm__later_text"), + iconName: TransactionSpeed.normal.iconName, + iconColor: .purpleAccent, + amount: quote?.claimFees.laterSats ?? 0, + amountPrefix: Self.feePrefix(for: .later), + isSelected: speed == .later, + isDisabled: false, + dividerAtBottom: true + ) { + speed = .later + } + .accessibilityIdentifier("SpendingConfirmLater") + } + } +} + +private extension SparkTransferQuote { + static let preview = SparkTransferQuote( + address: "bcrt1qpreview", + satsPerVbyte: 6, + networkFeeSats: 577, + depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: 1093, laterSats: 198) + ) +} + +#Preview("Instant") { + spendingConfirmPreview(SparkTransferFlow(amountSats: 100_000, speed: .instant, quote: .preview)) +} + +#Preview("Later") { + spendingConfirmPreview(SparkTransferFlow(amountSats: 100_000, speed: .later, quote: .preview)) +} + +#Preview("Loading") { + spendingConfirmPreview(SparkTransferFlow(amountSats: 100_000)) +} + +@MainActor +private func spendingConfirmPreview(_ flow: SparkTransferFlow) -> some View { + NavigationStack { + SpendingConfirm() + .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(NavigationViewModel()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) + .environmentObject(TransferViewModel()) + .environmentObject(WalletViewModel()) + .environment(flow) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SpendingIntroView.swift b/Bitkit/Views/Transfer/SpendingIntroView.swift new file mode 100644 index 000000000..5b9ea5f00 --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingIntroView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SpendingIntroView: View { + /// When set, this intro is for a hardware-wallet transfer; Continue routes to the HW amount flow. + var walletId: String? + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + var body: some View { + OnboardingView( + navTitle: t("lightning__transfer__nav_title"), + title: t("lightning__spending_intro__title"), + description: t("lightning__spending_intro__text"), + imageName: "coin-stack-x", + buttonText: t("lightning__spending_intro__button"), + onButtonPress: { + app.hasSeenTransferToSpendingIntro = true + if let walletId { + navigation.navigate(.spendingAmountHw(walletId: walletId)) + } else { + navigation.navigate(.spendingAmount) + } + }, + accentColor: .purpleAccent, + imagePosition: .center, + testID: "SpendingIntro" + ) + .navigationBarHidden(true) + } +} + +#Preview { + NavigationStack { + SpendingIntroView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .preferredColorScheme(.dark) + } +} diff --git a/Bitkit/Views/Transfer/TransferIntroView.swift b/Bitkit/Views/Transfer/TransferIntroView.swift new file mode 100644 index 000000000..d5d077c08 --- /dev/null +++ b/Bitkit/Views/Transfer/TransferIntroView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct TransferIntroView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + var body: some View { + OnboardingView( + navTitle: t("lightning__transfer__nav_title"), + title: t("lightning__transfer_intro__title"), + description: t("lightning__transfer_intro__text"), + imageName: "lightning", + buttonText: t("lightning__transfer_intro__button"), + onButtonPress: { + app.hasSeenTransferIntro = true + navigation.navigate(.fundingOptions) + }, + accentColor: .purpleAccent, + imagePosition: .center, + testID: "TransferIntro" + ) + .navigationBarHidden(true) + } +} + +#Preview { + NavigationStack { + TransferIntroView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .preferredColorScheme(.dark) + } +} diff --git a/Bitkit/Views/Transfer/TransferLearnMoreView.swift b/Bitkit/Views/Transfer/TransferLearnMoreView.swift new file mode 100644 index 000000000..e03490281 --- /dev/null +++ b/Bitkit/Views/Transfer/TransferLearnMoreView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct TransferLearnMoreView: View { + @Environment(SparkTransferFlow.self) private var flow + @Environment(\.dismiss) var dismiss + + private let spark = SparkManager.shared + + /// The spending balance once Spark adds this transfer at the selected speed. + private var spendingAfterSats: UInt64 { + spark.balanceSats.saturatingAdd(flow.quote?.toSpendingSats(for: flow.speed) ?? flow.amountSats) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_learn__title"), accentColor: .purpleAccent) + .padding(.bottom, 16) + + BodyMText(t("lightning__spark_learn__text")) + .padding(.bottom, 16) + + BodyMText(t("lightning__spark_trust")) + } + .padding(.bottom, 16) + } + + VStack(alignment: .leading, spacing: 16) { + SubtitleText(t("lightning__spark_learn__label")) + LightningChannel( + capacity: spendingAfterSats, + localBalance: spendingAfterSats, + remoteBalance: 0, + status: .open, + showLabels: true, + showReceiving: false + ) + } + + CustomButton(title: t("common__understood")) { + dismiss() + } + .padding(.top, 32) + .accessibilityIdentifier("LiquidityContinue") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } +} + +#Preview { + NavigationStack { + TransferLearnMoreView() + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environment(SparkTransferFlow(amountSats: 100_000)) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 52466387d..635813029 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -118,38 +118,6 @@ struct ActivityExplorerView: View { } } - private struct InfoSection: View { - let title: String - let content: String - let testId: String? - @EnvironmentObject var app: AppViewModel - - init(title: String, content: String, testId: String? = nil) { - self.title = title - self.content = content - self.testId = testId - } - - var body: some View { - Button { - UIPasteboard.general.string = content - app.toast(type: .success, title: t("common__copied"), description: content) - } label: { - VStack(alignment: .leading, spacing: 0) { - CaptionMText(title) - .padding(.bottom, 8) - BodySSBText(content) - .padding(.bottom, 16) - - Divider() - .padding(.bottom, 16) - } - } - .buttonStyle(.plain) - .accessibilityIdentifierIfPresent(testId) - } - } - private var navTitle: String { switch item { case let .lightning(activity) where activity.status == .pending: @@ -181,7 +149,7 @@ struct ActivityExplorerView: View { .padding(.bottom, 32) if let onchain { - InfoSection( + ActivityInfoSection( title: t("wallet__activity_tx_id"), content: onchain.txId, testId: "TXID" @@ -227,7 +195,7 @@ struct ActivityExplorerView: View { .padding(.bottom, 16) ForEach(Array(onchain.boostTxIds.enumerated()), id: \.offset) { index, boostTxId in let isRBF = onchain.txType == .sent || !(boostTxDoesExist[boostTxId] ?? true) - InfoSection( + ActivityInfoSection( title: t(isRBF ? "wallet__activity_boosted_rbf" : "wallet__activity_boosted_cpfp", variables: ["num": String(index + 1)]), content: boostTxId, testId: isRBF ? "RBFBoosted" : "CPFPBoosted" @@ -236,20 +204,20 @@ struct ActivityExplorerView: View { } } else if let lightning { if let preimage = lightning.preimage { - InfoSection( + ActivityInfoSection( title: t("wallet__activity_preimage"), content: preimage ) } if let paymentHash { - InfoSection( + ActivityInfoSection( title: t("wallet__activity_payment_hash"), content: paymentHash ) } - InfoSection( + ActivityInfoSection( title: t("wallet__activity_invoice"), content: lightning.invoice ) @@ -346,3 +314,48 @@ struct ActivityExplorer_Previews: PreviewProvider { .preferredColorScheme(.dark) } } + +/// A detail of an activity, its title above its value, that copies the value on tap. +struct ActivityInfoSection: View { + let title: String + let content: String + let testId: String? + /// The step lists' divider instead of the system one, for screens whose rows use it. + let usesCustomDivider: Bool + /// Space under the divider; a screen whose next row brings its own top spacing passes 0. + let bottomSpacing: CGFloat + @EnvironmentObject var app: AppViewModel + + init(title: String, content: String, testId: String? = nil, usesCustomDivider: Bool = false, bottomSpacing: CGFloat = 16) { + self.title = title + self.content = content + self.testId = testId + self.usesCustomDivider = usesCustomDivider + self.bottomSpacing = bottomSpacing + } + + var body: some View { + Button { + UIPasteboard.general.string = content + app.toast(type: .success, title: t("common__copied"), description: content) + } label: { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(title) + .padding(.bottom, 8) + BodySSBText(content) + .padding(.bottom, 16) + + Group { + if usesCustomDivider { + CustomDivider() + } else { + Divider() + } + } + .padding(.bottom, bottomSpacing) + } + } + .buttonStyle(.plain) + .accessibilityIdentifierIfPresent(testId) + } +} diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index ce4422c4d..821dd7d42 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -19,6 +19,8 @@ struct ActivityItemView: View { @StateObject private var viewModel: ActivityItemViewModel @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist @State private var isCpfpChild: Bool = false + /// Spark sent this transaction, so LDK holds no input of it to boost. + @State private var isSparkWithdrawal = false private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled @@ -148,7 +150,7 @@ struct ActivityItemView: View { case .lightning: return true case let .onchain(activity): - if isCpfpChild { + if isCpfpChild || isSparkWithdrawal { return true } if !activity.doesExist { @@ -257,6 +259,7 @@ struct ActivityItemView: View { // Check if this is a CPFP child transaction if case let .onchain(activity) = viewModel.activity { isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: activity.txId, walletId: activity.walletId) + isSparkWithdrawal = await SparkManager.shared.isSparkWithdrawal(txid: activity.txId, isSent: activity.txType == .sent) } // Load boostTxIds doesExist status to determine RBF vs CPFP diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 9034dceda..750c54a96 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -127,7 +127,13 @@ struct HardwareWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.canFundFromChain { + sheets.showSheet(.transferUnavailable) + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmountHw(walletId: walletId)) + } else { + navigation.navigate(.spendingIntroHw(walletId: walletId)) + } } .accessibilityIdentifier("HardwareTransferToSpending") } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 61433e842..135bd9ae2 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -231,7 +231,11 @@ struct ReceiveEdit: View { } private func additionalLiquidityAction() -> ReceiveAdditionalLiquidityAction { - ReceiveLiquidityDecision.additionalLiquidityAction( + // Spark receives any amount without inbound liquidity, so no channel is needed. + if SparkManager.shared.isReady { + return .none + } + return ReceiveLiquidityDecision.additionalLiquidityAction( source: liquiditySource, invoiceAmountSats: amountViewModel.amountSats, inboundCapacitySats: wallet.totalReadyInboundLightningSats diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index e666abb9f..e46384349 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -252,7 +252,6 @@ struct ReceiveQr: View { } else { CustomButton( title: t("common__show_details"), - variant: .tertiary, isDisabled: selectedTab == .hardware && displayedHardwareAddress == nil ) { showDetails.toggle() @@ -510,7 +509,7 @@ struct ReceiveQr: View { var cjitOnboarding: some View { VStack(alignment: .leading, spacing: 0) { DisplayText(t("wallet__receive_spending_title"), accentColor: .purpleAccent) - .padding(.bottom, 12) + .padding(.bottom, 8) BodyMText(t("wallet__receive_spending_text")) @@ -526,9 +525,9 @@ struct ReceiveQr: View { } } .padding(32) + .frame(maxHeight: .infinity) .background(Color.black) .cornerRadius(8) - .aspectRatio(1, contentMode: .fit) .overlay(alignment: .bottomLeading) { if !UIScreen.main.isSmall { Image("arrow-cjit") @@ -538,6 +537,10 @@ struct ReceiveQr: View { .offset(x: 70, y: 110) } } + // Figma leaves 122 pt between the card and the button; the spacer above the button adds the last 8. + .padding(.bottom, UIScreen.main.isSmall ? 0 : 114) + // Ahead of the tab's bottom spacer, so the card takes the rest of the page. + .layoutPriority(1) } func detailsContent(for tab: ReceiveTab) -> some View { diff --git a/Bitkit/Views/Wallets/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index b24062915..c7767d2a4 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -117,7 +117,13 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.canFundFromChain { + sheets.showSheet(.transferUnavailable) + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmount) + } else { + navigation.navigate(.spendingIntro) + } } .accessibilityIdentifier("TransferToSpending") } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 07e14e413..bd182f2ef 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -17,7 +17,8 @@ struct LnurlPayAmount: View { var maxAmount: UInt64 { // TODO: subtract fee guard let data = app.lnurlPayData else { return 0 } - return min(data.maxSendableSat, UInt64(wallet.totalLightningSats)) + // One payment goes over one rail, so the larger rail balance caps it. + return min(data.maxSendableSat, UInt64(wallet.maxSendLightningSats)) } var amount: UInt64 { @@ -46,9 +47,9 @@ struct LnurlPayAmount: View { Spacer() HStack(alignment: .bottom) { - AvailableAmount(label: t("wallet__send_available_spending"), amount: wallet.totalLightningSats) + AvailableAmount(label: t("wallet__send_available_spending"), amount: wallet.maxSendLightningSats) .onTapGesture { - amountViewModel.updateFromSats(UInt64(wallet.totalLightningSats), currency: currency) + amountViewModel.updateFromSats(UInt64(wallet.maxSendLightningSats), currency: currency) } Spacer() @@ -98,7 +99,7 @@ struct LnurlPayAmount: View { private func showMaxExceededToast() { guard let data = app.lnurlPayData else { return } // The cap is min(invoice max, spending balance); word the message for whichever bound is hit. - let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.totalLightningSats) + let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.maxSendLightningSats) app.toast( type: .warning, diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index ee0895511..5bf522324 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -1,3 +1,4 @@ +import BitkitCore import SwiftUI enum SendFundingSource: Equatable { @@ -44,7 +45,8 @@ struct SendAmountView: View { return [.usdt] } var sources: [SendFundingSource] = [] - if app.scannedLightningInvoice != nil || app.lnurlPayData != nil { + // A Spark leg can carry an on-chain contact payment from Spending. + if app.scannedLightningInvoice != nil || app.lnurlPayData != nil || app.paykitRoute?.offersSparkLeg == true { sources.append(.spending) } if app.scannedOnchainInvoice != nil { @@ -119,6 +121,9 @@ struct SendAmountView: View { } let minAmount = app.selectedWalletToPayFrom == .lightning ? 1 : Env.dustLimit + if !hwSend.isActive, app.selectedWalletToPayFrom == .lightning, let valid = sparkWithdrawalValidity { + return valid + } if app.selectedWalletToPayFrom == .lightning, let data = app.lnurlPayData, !data.accepts(amountSats: amountSats) { @@ -127,6 +132,26 @@ struct SendAmountView: View { return amountSats >= minAmount && amountSats <= availableAmount } + /// Spark's whole balance pays a withdrawal to the contact, so Lightning's limit never caps it. + private var sparkWithdrawalBalanceSats: UInt64 { + guard app.contactPaymentContext != nil, app.paykitRoute?.offersSparkLeg == true, SparkManager.shared.isReady else { return 0 } + return SparkManager.shared.balanceSats + } + + private var sparkWithdrawalValidity: Bool? { + PaykitRouteService.sparkWithdrawalValidity( + route: app.paykitRoute, + amountSats: amountSats, + sparkBalanceSats: sparkWithdrawalBalanceSats + ) + } + + /// The pad's cap: what Spending can send, or Spark's whole balance when a withdrawal can carry more. + private var inputCapSats: UInt64 { + guard !hwSend.isActive, app.selectedWalletToPayFrom == .lightning else { return availableAmount } + return max(availableAmount, sparkWithdrawalBalanceSats) + } + /// Determines if the current amount is a max amount send var isMaxAmountSend: Bool { guard app.selectedWalletToPayFrom == .onchain, app.contactPaymentContext?.incomingPaymentRequest == nil else { return false } @@ -337,7 +362,7 @@ struct SendAmountView: View { } } } - .onChange(of: availableAmount, initial: true) { updateInputCap() } + .onChange(of: inputCapSats, initial: true) { updateInputCap() } .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } @@ -389,6 +414,20 @@ struct SendAmountView: View { return } + if !app.paykitUsesUsdt, let context = app.contactPaymentContext, + let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark) + { + switch await routePaykitPayment(route, request: context.incomingPaymentRequest) { + case .leg: + navigationPath.append(.confirm) + return + case .refused, .stale: + return + case .direct: + break + } + } + // Lightning payment if app.selectedWalletToPayFrom == .lightning { if UInt64(wallet.maxSendLightningSats) < amountSats { @@ -400,7 +439,9 @@ struct SendAmountView: View { return } - navigationPath.append(app.lnurlPayData != nil && app.scannedLightningInvoice == nil ? .lnurlPayConfirm : .confirm) + let paysLnurl = app.lnurlPayData != nil && app.scannedLightningInvoice == nil + guard !paysLnurl || app.validateRoutedLnurlAmount(amountSats: amountSats) else { return } + navigationPath.append(paysLnurl ? .lnurlPayConfirm : .confirm) return } @@ -448,9 +489,21 @@ struct SendAmountView: View { } } + private func routePaykitPayment(_ route: PaykitRouteUi, request: PaykitPaymentRequest?) async -> PaykitRouteOutcome { + let source: PaykitRouteService.Source = app.selectedWalletToPayFrom == .lightning ? .spending : .savings + return await app.routeContactPayment( + route, + amountSats: amountSats, + source: source, + lightningSpendableSats: source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0, + onchainSpendableSats: source == .savings ? maxSendableAmount ?? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, + request: request + ) + } + private func updateInputCap() { // Don't cap when nothing is sendable, so the pad stays usable (Continue stays disabled instead). - amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil + amountViewModel.maxAmountOverride = inputCapSats > 0 ? inputCapSats : nil } private var fundingSourceMenu: some View { @@ -571,14 +624,8 @@ struct SendAmountView: View { conversionError = nil app.paykitUsesUsdt = source == .usdt switch source { - case .usdt: + case .usdt, .spending, .savings: hwSend.selectWallet(nil) - case .spending: - hwSend.selectWallet(nil) - app.selectedWalletToPayFrom = .lightning - case .savings: - hwSend.selectWallet(nil) - app.selectedWalletToPayFrom = .onchain case let .hardware(walletId): let balance = hwWalletManager.fundingBalance(walletId: walletId) let reserve = HwFundingSigner.feeReserve( @@ -590,11 +637,27 @@ struct SendAmountView: View { initialAvailableSats: balance > reserve ? balance - reserve : 0, showsLoading: true ) - app.selectedWalletToPayFrom = .onchain } + Self.selectWallet(of: source, app: app) updateFixedLightningAmount() } + /// Points the send at the wallet `source` pays from. A hardware wallet skips the router on Continue, so a Spark leg + /// routed for Spending is dropped here, as Confirm's own switch does. + static func selectWallet(of source: SendFundingSource, app: AppViewModel) { + switch source { + case .usdt: + break + case .spending: + app.selectedWalletToPayFrom = .lightning + case .savings: + app.selectedWalletToPayFrom = .onchain + case .hardware: + app.paykitRoute = SendConfirmationView.route(app.paykitRoute, afterSelecting: source) + app.selectedWalletToPayFrom = .onchain + } + } + private func updateFixedLightningAmount() { guard app.contactPaymentContext?.incomingPaymentRequest == nil, let amount = app.fixedLightningAmountSats else { return } amountViewModel.updateFromSats(amount, currency: currency) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index dfd78bd1e..7e9f632d8 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -28,6 +28,10 @@ struct SendConfirmationView: View { var isSetupPending = false @State private var showDetails = false + @State private var routedIncomingRequestId: PaykitPaymentRequest.ID? + /// Set when Confirm picks a request's source itself, so that switch isn't routed twice. + @State private var skipNextSourceReroute = false + @State private var refusedRouteSource: WalletType? @State private var hasShownAmountReview = false @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @@ -128,18 +132,34 @@ struct SendConfirmationView: View { } private var fundingSources: [SendFundingSource] { + Self.fundingSources( + hasLightningInvoice: app.scannedLightningInvoice != nil, + hasOnchainInvoice: app.scannedOnchainInvoice != nil, + offersSparkLeg: app.paykitRoute?.offersSparkLeg == true, + hardwareWalletIds: hwWalletManager.wallets.compactMap { hardwareWallet in + guard hardwareWallet.fundingBalanceSats > 0 || hardwareWallet.walletId == hwSend.walletId else { + return nil + } + return hardwareWallet.walletId + } + ) + } + + /// The sources the amount screen offers: a Spark leg can carry an on-chain contact payment from Spending, whether or not + /// Savings covers it. + static func fundingSources( + hasLightningInvoice: Bool, + hasOnchainInvoice: Bool, + offersSparkLeg: Bool, + hardwareWalletIds: [String] + ) -> [SendFundingSource] { var sources: [SendFundingSource] = [] - if app.scannedLightningInvoice != nil { + if hasLightningInvoice || offersSparkLeg { sources.append(.spending) } - if app.scannedOnchainInvoice != nil { + if hasOnchainInvoice { sources.append(.savings) - sources.append(contentsOf: hwWalletManager.wallets.compactMap { hardwareWallet in - guard hardwareWallet.fundingBalanceSats > 0 || hardwareWallet.walletId == hwSend.walletId else { - return nil - } - return .hardware(walletId: hardwareWallet.walletId) - }) + sources.append(contentsOf: hardwareWalletIds.map { .hardware(walletId: $0) }) } return sources } @@ -181,7 +201,23 @@ struct SendConfirmationView: View { } private var canSwitchFundingSource: Bool { - retainedHardwareRetry == nil && fundingSources.count > 1 + Self.canSwitchFundingSource(hasRetainedHardwareRetry: retainedHardwareRetry != nil, fundingSources: fundingSources) + } + + static func canSwitchFundingSource(hasRetainedHardwareRetry: Bool, fundingSources: [SendFundingSource]) -> Bool { + !hasRetainedHardwareRetry && fundingSources.count > 1 + } + + /// What Confirm's FROM control shows and does. + struct FromControl: Equatable { + let titleKey: String + let isSwitchable: Bool + } + + /// A Spark leg pays from Spending, and the payer can still move a fixed request to Savings or a hardware wallet, + /// which reroutes it. + static func sparkRouteFromControl(canSwitchFundingSource: Bool) -> FromControl { + FromControl(titleKey: "wallet__spending__title", isSwitchable: canSwitchFundingSource) } var canSwitchWallet: Bool { @@ -345,6 +381,13 @@ struct SendConfirmationView: View { PaykitAmountText(amount: preparingRequest.amount, size: .display) .accessibilityIdentifier("ReviewAmount") } + } else if let deliveredSat = app.paykitRoute?.deliveredSat { + MoneyStack( + sats: Int(wallet.sendAmountSats ?? deliveredSat), + showSymbol: true, + testIdPrefix: "ReviewAmount", + onTap: navigateToAmount + ) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), @@ -366,7 +409,9 @@ struct SendConfirmationView: View { .padding(.bottom, oneOffPaymentRequest == nil ? 44 : 24) if showDetails { - if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { + if let route = app.paykitRoute, let totalFeeSat = route.totalFeeSat { + paykitRouteView(route, totalFeeSat: totalFeeSat, note: oneOffPaymentRequestNote) + } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { lightningView(invoice) @@ -455,6 +500,7 @@ struct SendConfirmationView: View { } guard preparingRequest == nil, !isRequestSetupPending, !returnToAmountReviewIfNeeded() else { return } ensureSendAmountFromScannedInvoicesIfNeeded() + await routeContactPaymentOnConfirm() if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { showManualConfirmation(reason: "not automatic when opened") } @@ -494,6 +540,11 @@ struct SendConfirmationView: View { !returnToAmountReviewIfNeeded(rebindMethod: !hwSend.isActive) else { return } ensureSendAmountFromScannedInvoicesIfNeeded() walletSwitchContext = sendContext + if skipNextSourceReroute { + skipNextSourceReroute = false + } else if Self.sourceSwitchNeedsReroute(route: app.paykitRoute, walletType: app.selectedWalletToPayFrom) { + Task { await routeContactPaymentOnConfirm(sourceChanged: true) } + } } .onChange(of: navigationPath) { previous, path in guard preparingRequest == nil, previous.last == .amount, path.isEmpty, requiresAmountReview, !amountIsRoot else { return } @@ -570,7 +621,8 @@ struct SendConfirmationView: View { shouldAutomaticallyPay: shouldAutomaticallyPay, hasStartedAutomaticPayment: hasStartedAutomaticPayment, isSetupPending: isSetupPending, - isPreparingWallet: walletSwitchContext != nil + isPreparingWallet: walletSwitchContext != nil, + isRouteRefused: refusedRouteSource == app.selectedWalletToPayFrom ) else { return } hasStartedAutomaticPayment = true do { @@ -717,6 +769,74 @@ struct SendConfirmationView: View { } } + func paykitRouteView(_ route: PaykitRouteUi, totalFeeSat: UInt64, note: String?) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + SendSectionView(t("wallet__send_from")) { + let control = Self.sparkRouteFromControl(canSwitchFundingSource: canSwitchFundingSource) + NumberPadActionButton( + text: t(control.titleKey), + imageName: control.isSwitchable ? "arrow-up-down" : nil, + color: .purpleAccent, + variant: control.isSwitchable ? .primary : .secondary, + disabled: !control.isSwitchable + ) { + selectNextFundingSource() + } + .accessibilityIdentifier("SendConfirmAssetButton") + } + + Spacer(minLength: 16) + + if let contact = contactPaymentContact { + SendSectionView(contactSectionTitle) { + contactRecipient(contact) + } + } + } + + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 0) { + Image("bolt-hollow") + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + BodySSBText("\(t("fee__instant__title")) (") + MoneyText(sats: Int(totalFeeSat), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + } + + SendSectionView(t("wallet__send_swap_route")) { + BodySSBText(Self.routeName(route)) + .lineLimit(2) + .accessibilityIdentifier("ReviewSwapRoute") + } + } + + SendSectionView(t("wallet__tags")) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(tagManager.selectedTagsArray, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { tagManager.removeTagFromSelection(tag) }) + } + AddTagButton(onPress: { navigationPath.append(.tag) }) + .accessibilityIdentifier("TagsAddSend") + } + } + } + + if let note { + PaymentRequestInvoiceNote(note: note) + } + } + } + + private static func routeName(_ route: PaykitRouteUi) -> String { + route.serviceLeg?.service == SparkManager.routeService ? "Spark" : route.serviceLeg?.service ?? "" + } + func lightningView(_ invoice: LightningInvoice) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -838,6 +958,14 @@ struct SendConfirmationView: View { } } + /// A hardware wallet signs the on-chain send itself and the router never runs for it, so picking one drops the Spark + /// leg; Savings and Spending ask the router again, which settles the leg. + static func route(_ route: PaykitRouteUi?, afterSelecting source: SendFundingSource) -> PaykitRouteUi? { + guard case .hardware = source, var route else { return route } + route.serviceLeg = nil + return route + } + private func selectNextFundingSource() { guard canSwitchFundingSource else { return } let currentIndex = fundingSources.firstIndex(of: selectedFundingSource) @@ -857,6 +985,7 @@ struct SendConfirmationView: View { hwSend.selectWallet(nil) app.selectedWalletToPayFrom = .onchain case let .hardware(walletId): + app.paykitRoute = Self.route(app.paykitRoute, afterSelecting: source) let balance = hwWalletManager.fundingBalance(walletId: walletId) let reserve = HwFundingSigner.feeReserve( balanceSats: balance, @@ -874,6 +1003,7 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard !isSubmittingPayment else { return } guard !requiresAmountReview, preparingRequest == nil, !isRequestSetupPending, walletSwitchContext == nil, + refusedRouteSource != app.selectedWalletToPayFrom, isAutomatic || !prepareRequestCoinsIfNeeded() else { throw CancellationError() } // An automatic payment settles the fee itself below. if !isAutomatic, isPreparingOnchainSend { @@ -973,19 +1103,34 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) + } else if !app.paykitUsesUsdt, let leg = app.paykitRoute?.serviceLeg { + await performServiceRoutedPayment(leg) } else { try await performPayment(isAutomatic: isAutomatic) } } - /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. + /// A source switch on Confirm needs the router only when it moves a Spark leg's send to Savings, or any send to + /// Spending, where a Spark leg can carry it: a mixed contact paid from Savings keeps its decoded Lightning payment, + /// which can't pay past Lightning's limit. The app's own switch to a leg's source asks nothing. + static func sourceSwitchNeedsReroute(route: PaykitRouteUi?, walletType: WalletType) -> Bool { + guard let route else { return false } + if route.serviceLeg != nil { + return walletType != .lightning + } + return walletType == .lightning && route.offersSparkLeg + } + + /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. A source the + /// router refused has nothing to pay, so it never starts. static func canStartAutomaticPayment( shouldAutomaticallyPay: Bool, hasStartedAutomaticPayment: Bool, isSetupPending: Bool, - isPreparingWallet: Bool + isPreparingWallet: Bool, + isRouteRefused: Bool = false ) -> Bool { - shouldAutomaticallyPay && !hasStartedAutomaticPayment && !isSetupPending && !isPreparingWallet + shouldAutomaticallyPay && !hasStartedAutomaticPayment && !isSetupPending && !isPreparingWallet && !isRouteRefused } static func requiresManualConfirmation(isAutomatic: Bool, isHardwarePayment: Bool) -> Bool { @@ -1166,9 +1311,10 @@ struct SendConfirmationView: View { isSetupPending: Bool = false, isPreparingWallet: Bool = false, isAmountReviewRequired: Bool = false, - isPreparingOnchainSend: Bool = false + isPreparingOnchainSend: Bool = false, + isRouteRefused: Bool = false ) -> Bool { - isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingOnchainSend + isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingOnchainSend || isRouteRefused || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -1191,7 +1337,8 @@ struct SendConfirmationView: View { isSetupPending: isRequestSetupPending, isPreparingWallet: walletSwitchContext != nil, isAmountReviewRequired: requiresAmountReview, - isPreparingOnchainSend: isPreparingOnchainSend + isPreparingOnchainSend: isPreparingOnchainSend, + isRouteRefused: refusedRouteSource == app.selectedWalletToPayFrom ) } @@ -1292,6 +1439,238 @@ struct SendConfirmationView: View { return note } + /// A request's Confirm opens with fixed terms and no amount screen, so the router is asked here: Direct switches the + /// send to the endpoint it picks, and a Spark leg pays an on-chain request from Spending. A source switch on Confirm + /// asks it again for any contact payment, so a leg never pays a send the payer moved to Savings. + private func routeContactPaymentOnConfirm(sourceChanged: Bool = false) async { + guard let context = app.contactPaymentContext, !app.paykitUsesUsdt, !hwSend.isActive, !isSubmittingPayment else { return } + let request = context.incomingPaymentRequest + if !sourceChanged { + guard let request, routedIncomingRequestId != request.id else { return } + } + guard let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark) + else { return } + let amountSats: UInt64 + if let request { + let amount = app.paykitPaymentTerms?.amount ?? request.amount + guard amount.asset == .btc else { return } + amountSats = amount.atomic + } else { + amountSats = route.deliveredSat ?? wallet.sendAmountSats ?? 0 + } + guard amountSats > 0 else { return } + if let request { + routedIncomingRequestId = request.id + // A request has no amount screen to pick its source, so pick it as that screen would: Savings when it covers + // the amount and fee, otherwise Spending, where the Spark leg can carry it. + if !sourceChanged, app.selectedWalletToPayFrom == .onchain, app.scannedLightningInvoice == nil, app.lnurlPayData == nil, + await Self.requestStartsOnSpending( + savingsSendableSats: savingsSendableSats(), + amountSats: amountSats, + offersSparkLeg: route.offersSparkLeg + ) + { + skipNextSourceReroute = true + app.selectedWalletToPayFrom = .lightning + } + } + let walletType = app.selectedWalletToPayFrom + let source: PaykitRouteService.Source = walletType == .lightning ? .spending : .savings + let outcome = await app.routeContactPayment( + route, + amountSats: amountSats, + source: source, + lightningSpendableSats: source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0, + onchainSpendableSats: source == .savings ? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, + request: request + ) + // The first call when Confirm opens keeps a refusal too, so a request with no route offers no swipe. + refusedRouteSource = Self.refusedRouteSource(after: outcome, on: walletType, previous: refusedRouteSource) + if Self.reroutePaysLnurl( + outcome: outcome, + walletType: app.selectedWalletToPayFrom, + hasInvoice: app.scannedLightningInvoice != nil, + hasLnurl: app.lnurlPayData != nil + ) { + navigationPath.append(.lnurlPayConfirm) + } + } + + /// Confirm has no LNURL send, so a Direct LNURL pick from Spending pays on the LNURL's own confirm, as one picked on + /// Continue does. + static func reroutePaysLnurl(outcome: PaykitRouteOutcome, walletType: WalletType, hasInvoice: Bool, hasLnurl: Bool) -> Bool { + outcome == .direct && walletType == .lightning && hasLnurl && !hasInvoice + } + + /// A source the router refused on Confirm keeps the swipe off while it's selected, so nothing consumes the contact's + /// private list for a send with nothing to pay; another source is still payable. A late outcome for a source never + /// clears another one's refusal. + static func refusedRouteSource(after outcome: PaykitRouteOutcome, on walletType: WalletType, previous: WalletType?) -> WalletType? { + if outcome == .refused { + return walletType + } + if outcome == .stale { + return previous + } + return previous == walletType ? nil : previous + } + + /// What Savings can send to the request's address after the on-chain fee; its balance while no fee rate is known. + private func savingsSendableSats() async -> UInt64 { + let balance = UInt64(max(0, wallet.spendableOnchainBalanceSats)) + guard let address = app.scannedOnchainInvoice?.address, let rate = wallet.selectedFeeRateSatsPerVByte else { return balance } + return await (try? wallet.calculateMaxSendableAmount(address: address, satsPerVByte: rate)) ?? balance + } + + static func requestStartsOnSpending(savingsSendableSats: UInt64, amountSats: UInt64, offersSparkLeg: Bool) -> Bool { + offersSparkLeg && savingsSendableSats < amountSats + } + + /// Where a failed leg goes: a leg Spark refused before sending paid nothing, so its failure offers Try Again; any + /// other failure may have sent it, so it stays pending until Spark's journal settles it. + static func serviceLegFailureRoute( + _ error: Error, + attempt: SparkWithdrawalAttempt, + routingCacheResetAttempted: Bool + ) -> SendRoute { + guard let refusal = error as? SparkWithdrawalNotSent else { + return .sparkWithdrawalPending(attempt) + } + return .failure(SendFailureContext( + error: refusal.underlying, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + )) + } + + /// Withdraws from Spark to the contact's on-chain address. A request it pays gets an on-chain proof with the + /// withdrawal's txid; the proof is started before the send, and Spark's journal settles it if the app stops mid-send. + private func performServiceRoutedPayment(_ leg: ServiceRouteLeg) async { + let paymentActivity = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(paymentActivity) } + isSubmittingPayment = true + defer { isSubmittingPayment = false } + let context = app.contactPaymentContext + let request = context?.incomingPaymentRequest + var proofRequest: SparkContactWithdrawal.PaykitRequest? + var markProofStarted: (() async throws -> Void)? + do { + // The selected conversion quote's expiry bounds the send too, as on a direct send. + let paymentDeadline = try request?.submissionDeadline(quoteId: app.paykitPaymentTerms?.quoteId) + if let request { + guard let privateContext = context?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } + let identity = try await PaykitRouteService.startLegRequestProof( + prepareProof: { + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentAppId: privateContext.paymentAppId(for: leg.endpoint.methodId), + paymentEndpointIdentifier: leg.endpoint.methodId, + kind: .onchain, + paymentTerms: app.paykitPaymentTerms + ) + }, + prepareRequest: { try await prepareIncomingPaymentRequest() }, + paymentIdentity: { try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: request.id) }, + cancelProof: { await PaykitPaymentProofService.shared.cancelPreparation(request) }, + releasePaymentList: { await context?.resolvePrivatePaymentListConsumption(.definitePreBroadcastFailure) } + ) + // The proof and the journal keep the list version this send consumed, so dropping the withdrawal as + // unsent gives the list back, also after a kill before the proof started. + let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion( + publicKey: request.counterparty, + attemptId: context?.id + ) + proofRequest = SparkContactWithdrawal.PaykitRequest( + id: request.id, + identity: identity, + paymentList: privateContext.paymentListVersion.map { + SparkContactWithdrawal.PaymentList(version: $0, previousVersion: previousVersion) + } + ) + // Spark's withdrawal runs this once its journal is written, so recovery owns the started proof. + markProofStarted = { + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + request, + address: leg.endpoint.value, + paymentIdentity: identity, + privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion, + sparkWithdrawal: true + ) + } + } + let txid: String + do { + txid = try await PaykitRouteService.payReportingAllowance( + beginAllowance: { + guard let request else { return nil } + return try await PaykitAllowanceExecutor.shared.beginManualPayment( + request, + paymentEndpointIdentifier: leg.endpoint.methodId + ) + }, + authorize: { + if let request { + try await paykitPaymentRequestManager.ensurePaymentAllowed(request) + } + }, + markSending: { await PaykitAllowanceExecutor.shared.manualOnchainPaymentSending(attemptId: $0) }, + finishAllowance: { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: $0, outcome: $1, transactionId: $2) + } + ) { beforeSend in + try await withoutActuallyEscaping(beforeSend) { beforeSend in + try await PaykitRouteService.pay( + leg, + contact: context?.publicKey, + request: proofRequest, + tags: tagManager.selectedTagsArray, + paymentDeadline: paymentDeadline, + beforeSend: { + try await markProofStarted?() + try await beforeSend() + } + ) + } + } + } catch { + // Refused before Spark's send: nothing left, so this call's started proof goes. After it, the journal settles it. + let refusedBeforeSend = error is SparkWithdrawalNotSent + if let proofRequest, PaykitRouteService.legFailureReleasesProof( + error, + openJournalRequest: SparkManager.pendingContactWithdrawal?.request, + proofRequest: proofRequest + ) { + await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: proofRequest.id, identity: proofRequest.identity) + } + if refusedBeforeSend, let request { + // A refusal before the proof was marked started leaves it prepared; started proofs stay as they are. + await PaykitPaymentProofService.shared.cancelPreparation(request) + } + await context?.resolvePrivatePaymentListConsumption(refusedBeforeSend ? .definitePreBroadcastFailure : .uncertain) + Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(Self.serviceLegFailureRoute( + error, + attempt: SparkWithdrawalAttempt(address: leg.endpoint.value, amountSats: leg.deliveredSat, requestId: request?.id), + routingCacheResetAttempted: routingCacheResetAttempted + )) + return + } + await context?.resolvePrivatePaymentListConsumption(.succeeded) + Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") + navigationPath.append(.success(paymentId: txid)) + } catch { + Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + ))) + } + } + private func performPayment(isAutomatic: Bool) async throws { let paymentActivity = PaykitPaymentActivity.shared.begin() defer { PaykitPaymentActivity.shared.end(paymentActivity) } @@ -1369,6 +1748,10 @@ struct SendConfirmationView: View { } createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) + // Kept before the payment goes out, so a send recorded after the app was killed still names its contact. + if let contactPublicKey { + ActivityService.sentPaymentContacts.remember(contactPublicKey, forPaymentHash: paymentHash) + } // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) // For invoices with a built-in amount, pass sats: nil so LDK uses the invoice's @@ -1718,7 +2101,11 @@ struct SendConfirmationView: View { private func validatePayment() async -> [WarningType] { var warnings: [WarningType] = [] - let amount: UInt64 = if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { + // A Spark leg delivers its amount on-chain from Spending, so its amount and fee get the on-chain checks. + let leg = app.paykitRoute?.serviceLeg + let amount: UInt64 = if let leg { + leg.deliveredSat + } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { wallet.sendAmountSats ?? invoice.amountSatoshis } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { wallet.sendAmountSats ?? invoice.amountSatoshis @@ -1745,13 +2132,14 @@ struct SendConfirmationView: View { } // Check if fee > $10 (only for onchain) - if app.selectedWalletToPayFrom == .onchain { - if let feeUsd = currency.convert(sats: UInt64(transactionFee), to: "USD"), feeUsd.value > 10.0 { + if leg != nil || app.selectedWalletToPayFrom == .onchain { + let fee = leg.map { $0.debitSat.saturatingSub($0.deliveredSat) } ?? UInt64(max(0, transactionFee)) + if let feeUsd = currency.convert(sats: fee, to: "USD"), feeUsd.value > 10.0 { warnings.append(.fee) } // Check if fee > 50% of send amount - if transactionFee > 0 && UInt64(transactionFee) > amount / 2 { + if fee > 0 && fee > amount / 2 { warnings.append(.feePercentage) } @@ -1872,7 +2260,8 @@ struct SendConfirmationView: View { } guard request.pricing.conversion != nil else { return false } let terms = try? request.payment(using: app.paykitPaymentMethod, quoteId: app.paykitPaymentTerms?.quoteId) - let minimumAmount = app.selectedWalletToPayFrom == .lightning ? (app.lnurlPayData?.minSendableSat ?? 1) : UInt64(Env.dustLimit) + let paysLightning = app.selectedWalletToPayFrom == .lightning && app.paykitRoute?.serviceLeg == nil + let minimumAmount = paysLightning ? (app.lnurlPayData?.minSendableSat ?? 1) : UInt64(Env.dustLimit) let meetsMinimum = terms.map { $0.amount.atomic >= minimumAmount } == true if rebindMethod, !app.paykitUsesUsdt, !isSubmittingPayment, let previous = app.paykitPaymentTerms, previous.amount.asset == .btc, app.paykitReviewedAmount == previous.amount, @@ -2249,3 +2638,44 @@ struct SendConfirmationView: View { } } } + +#Preview("Spark leg with a request note") { + let onchain = PaykitEndpoint(methodId: "btc-regtest-p2wpkh", value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x") + let leg = ServiceRouteLeg( + service: SparkManager.routeService, + endpoint: onchain, + deliveredSat: 20000, + debitSat: 22190, + atomicity: "trusted", + validUntilUnix: 0 + ) + ScrollView { + SendConfirmationView( + navigationPath: .constant([]), + isSubmittingPayment: .constant(false), + hwSend: HwSendCoordinator(walletId: nil), + requestPinCheck: { true }, + prepareIncomingPaymentRequest: {}, + routingCacheResetAttempted: false, + isAmountReviewRequired: .constant(false) + ) + .paykitRouteView( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true, serviceLeg: leg), + totalFeeSat: 2190, + note: "Dinner on Friday" + ) + .padding(16) + } + .background(Color.black) + .environmentObject(AppViewModel()) + .environmentObject(ActivityListViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) + .environmentObject(WalletViewModel()) + .environmentObject(TagManager()) + .environment(HwWalletManager()) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Wallets/Send/SendFailure.swift b/Bitkit/Views/Wallets/Send/SendFailure.swift index 243eaf406..9d139f0d4 100644 --- a/Bitkit/Views/Wallets/Send/SendFailure.swift +++ b/Bitkit/Views/Wallets/Send/SendFailure.swift @@ -4,6 +4,10 @@ import SwiftUI func sendFailureMessage(for error: Error) -> String { let fallbackMessage = t("wallet__payment_failed_description") + if isRestoredInvoiceRefusal(error) { + return t("wallet__payment_restored_invoice") + } + if error is QuickPayConversionError { return t("wallet__send_quickpay__currency_conversion") } @@ -19,6 +23,23 @@ func sendFailureMessage(for error: Error) -> String { return fallbackMessage } +/// Also when the refusal is wrapped in another error. +func isRestoredInvoiceRefusal(_ error: Error) -> Bool { + var current: Error? = error + for _ in 0 ..< 8 { + guard let error = current else { return false } + if let refusal = error as? ChannelFreeRailSendRefused { + if refusal.isRestoredInvoiceUnverified { + return true + } + current = refusal.underlying + } else { + current = (error as? AppError)?.underlyingError + } + } + return false +} + func shouldResetRoutingCachesOnRetry(for error: Error) -> Bool { guard let reason = (error as? AppError)?.paymentFailureReason else { return false diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 3e80b6374..957936962 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -38,6 +38,8 @@ struct SendPendingScreen: View { var hardwareWalletId: String? var hardwareTransactionId: String? var hardwarePaymentIdentity: String? + /// The Spark withdrawal this screen waits on, whose journal settles it. + var sparkWithdrawal: SparkWithdrawalAttempt? var proofService: PaykitPaymentProofService = .shared var requestPinCheck: () async -> Bool = { false } @Binding var navigationPath: [SendRoute] @@ -107,7 +109,10 @@ struct SendPendingScreen: View { } private var pendingAmountSats: UInt64? { - pendingHardwareWalletId == nil ? onchainAttempt?.amountSats ?? pendingOnchainProof?.onchainAmountSats ?? wallet + if let sparkWithdrawal { + return sparkWithdrawal.amountSats + } + return pendingHardwareWalletId == nil ? onchainAttempt?.amountSats ?? pendingOnchainProof?.onchainAmountSats ?? wallet .sendAmountSats : pendingOnchainProof?.onchainAmountSats } @@ -279,7 +284,7 @@ struct SendPendingScreen: View { { applyOnchainPaymentResolution(resolution) } - } else if !onchainStateUnavailable { + } else if !onchainStateUnavailable, Self.readsNodeOnchainAttempts(sparkWithdrawal: sparkWithdrawal) { do { let loaded = try await Self.loadOrdinaryPending( using: attemptService, context: ordinaryPendingContext, @@ -316,7 +321,7 @@ struct SendPendingScreen: View { await searchForActivity() // Completion events are one-shot. Keep the visible original operation in sync // with its durable result even when an event preceded subscription or initialization. - while paymentHash == nil, paykitPaymentRequestId == nil, pendingHardwareWalletId == nil, + while paymentHash == nil, paykitPaymentRequestId == nil, pendingHardwareWalletId == nil, sparkWithdrawal == nil, !ordinarySendResolved, !Task.isCancelled { do { try await Task.sleep(for: .seconds(2)) } @@ -326,7 +331,9 @@ struct SendPendingScreen: View { } .task { // The request can end, or a failed check recover, while this screen stays open. - while paymentHash == nil, abandonableAttempt == nil, !Task.isCancelled { + while paymentHash == nil, abandonableAttempt == nil, Self.readsNodeOnchainAttempts(sparkWithdrawal: sparkWithdrawal), + !Task.isCancelled + { if pendingHardwareWalletId == nil { guard await loadAbandonableAttempt() else { return } } @@ -347,6 +354,43 @@ struct SendPendingScreen: View { .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in applyOnchainPaymentResolution(resolution) } + .onReceive(SparkManager.contactWithdrawalResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + applySparkWithdrawalResolution(resolution) + } + .task { + // The journal may have closed before this screen subscribed. + guard let attempt = sparkWithdrawal, SparkManager.pendingContactWithdrawal?.attempt != attempt, + let resolution = SparkManager.lastContactWithdrawalResolution + else { return } + applySparkWithdrawalResolution(resolution) + } + } + + private func applySparkWithdrawalResolution(_ resolution: SparkContactWithdrawalResolution) { + guard !ordinarySendResolved, let attempt = sparkWithdrawal, + let route = Self.sparkWithdrawalRoute(for: resolution, attempt: attempt) + else { return } + ordinarySendResolved = true + navigationPath.append(route) + } + + /// Whether the screen reads the node wallet's on-chain send attempts: never for a Spark withdrawal, which creates + /// none, so an unrelated unresolved send never shows as this one; its journal settles it instead. + static func readsNodeOnchainAttempts(sparkWithdrawal: SparkWithdrawalAttempt?) -> Bool { + sparkWithdrawal == nil + } + + /// Where the journal's close takes the screen waiting on `attempt`: a success once Spark sent it, a failure that can + /// be tried again once it never did, and nowhere for another withdrawal. + static func sparkWithdrawalRoute(for resolution: SparkContactWithdrawalResolution, attempt: SparkWithdrawalAttempt) -> SendRoute? { + guard resolution.attempt == attempt else { return nil } + if let txId = resolution.txId { + return .success(paymentId: txId) + } + return .failure(SendFailureContext( + error: AppError(message: "wallet__payment_failed_description", debugMessage: "Spark never sent the contact withdrawal"), + retryRoute: .confirm + )) } @MainActor @@ -542,6 +586,9 @@ struct SendPendingScreen: View { } private var onchainPendingMessage: String { + if sparkWithdrawal != nil { + return t("wallet__onchain_unknown_pending") + } if onchainStateUnavailable { return t("wallet__onchain_state_unavailable") } @@ -616,6 +663,13 @@ struct SendPendingScreen: View { foundActivity = .onchain(resolution.activity) onchainAttempt?.status = .accepted onchainAttempt?.localFollowupComplete = true + if resolution.isSparkFunding { + sheets.hideSheet(reason: "Recovered Spark funding") + // An open Spending Confirm follows it at the chosen speed; otherwise leave the funding flow for home. + if SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: SparkFundingRecovery.confirmScreenOpen) { + navigation.reset() + } + } } static func matchesLocalResolution(_ resolution: OnchainSendLocalResolution, attempt: OnchainSendAttempt?, diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index fae36ac21..45ea98437 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -69,6 +69,7 @@ enum SendRoute: Hashable { paykitPaymentRequestId: PaykitPaymentRequest.ID? = nil ) case onchainPending(OnchainSendPendingContext) + case sparkWithdrawalPending(SparkWithdrawalAttempt) case onchainOperationPending(OnchainSendPendingContext, requestId: PaykitPaymentRequest.ID?) case hardwarePending(requestId: PaykitPaymentRequest.ID, walletId: String, transactionId: String, paymentIdentity: String?) case success(paymentId: String, walletId: String = WalletScope.default) @@ -299,31 +300,47 @@ struct SendSheet: View { /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { if isPreparingRequest || hwSend.isActive || flow.isResumingAcceptedOrdinarySend || app.paykitUsesUsdt || - (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && flow.navigationPath.isEmpty) + (app.contactPaymentPicksSource && currentRoot == .amount && flow.navigationPath.isEmpty) { return false } - // Node must be running - guard wallet.nodeLifecycleState == .running else { return true } - - // For lightning payments, also need usable channels (peer connected) let isLightningPayment = app.scannedLightningInvoice != nil || app.lnurlPayData != nil || app.selectedWalletToPayFrom == .lightning + return Self.waitsForNode( + isNodeRunning: wallet.nodeLifecycleState == .running, + isLightningPayment: isLightningPayment, + hasAnyChannels: (wallet.channels?.isEmpty == false) || wallet.channelCount > 0, + hasUsableChannels: wallet.hasUsableChannels, + sparkCanPay: sparkCanPayScannedInvoice + ) + } - if isLightningPayment { - // If there are no channels at all, don't show the sync overlay – - // there is nothing to \"sync into\". Let validation/UX handle this as - // an \"insufficient capacity / no channels\" case instead of a sync wait. - let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - guard hasAnyChannels else { return false } - - // We have channels but none are usable yet → show sync overlay - return !wallet.hasUsableChannels + /// A Lightning payment Spark can pay goes ahead without the node. Otherwise the node must run, and a Lightning + /// payment over channels waits for a usable one. With no channels at all there is nothing to sync into, so + /// validation reports the missing capacity instead. + static func waitsForNode( + isNodeRunning: Bool, isLightningPayment: Bool, hasAnyChannels: Bool, hasUsableChannels: Bool, sparkCanPay: Bool + ) -> Bool { + if isLightningPayment, sparkCanPay { + return false } + guard isNodeRunning else { return true } + guard isLightningPayment, hasAnyChannels, !sparkCanPay else { return false } + return !hasUsableChannels + } - return false + /// Spark pays without channels, so a channel that is still reconnecting is no reason to wait. + private var sparkCanPayScannedInvoice: Bool { + let spark = SparkManager.shared + let amountSats = wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis ?? 0 + return !spark.isExiting && Self.sparkCanPay(amountSats: amountSats, isReady: spark.isReady, balanceSats: spark.balanceSats) + } + + /// Leaves room for Spark's fee, as `LightningService.canSend` does. + static func sparkCanPay(amountSats: UInt64, isReady: Bool, balanceSats: UInt64) -> Bool { + isReady && amountSats > 0 && ChannelFreeRailFees.sendableSats(balanceSats: balanceSats) >= amountSats } /// Identity for the sync timeout task. The timer only counts down while the overlay is visible @@ -748,7 +765,7 @@ struct SendSheet: View { return .ready } if app - .paykitUsesUsdt || (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && flow.navigationPath.isEmpty) + .paykitUsesUsdt || (app.contactPaymentPicksSource && currentRoot == .amount && flow.navigationPath.isEmpty) { flow.hasValidatedAfterSync = true return .ready @@ -760,7 +777,7 @@ struct SendSheet: View { return .failed } - if let lnurlPayData = app.lnurlPayData, let requestedAmount { + if let lnurlPayData = app.lnurlPayData, app.paykitRoute?.serviceLeg == nil, let requestedAmount { let minimumAmount = max(1, lnurlPayData.minSendableSat) guard requestedAmount >= minimumAmount else { showPaymentSetupFailure( @@ -784,7 +801,8 @@ struct SendSheet: View { return .failed } guard LightningService.shared.canSend(amountSats: requestedAmount) else { - let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + let spendingBalance = (LightningService.shared.balances?.totalLightningBalanceSats ?? 0) + + (LightningService.shared.channelFreeRail?.balanceSats ?? 0) showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) flow.hasValidatedAfterSync = true return .failed @@ -801,7 +819,7 @@ struct SendSheet: View { // usable, proceed with validation/fallback. // Use channelCount as fallback in case channels array is nil but count is cached let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait { + if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait, !sparkCanPayScannedInvoice { // We have channels but none usable yet → wait return .waiting } @@ -851,7 +869,8 @@ struct SendSheet: View { return .ready } else { // For pure lightning invoices, show error toast and dismiss sheet - let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + let spendingBalance = (LightningService.shared.balances?.totalLightningBalanceSats ?? 0) + + (LightningService.shared.channelFreeRail?.balanceSats ?? 0) showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) flow.hasValidatedAfterSync = true return .failed @@ -863,8 +882,8 @@ struct SendSheet: View { } } - // Validate onchain payment balance (for pure onchain invoices) - if let onchainInvoice = app.scannedOnchainInvoice { + // Validate onchain payment balance (for pure onchain invoices); a Spark route can pay it from spending + if app.paykitRoute?.offersSparkLeg != true, let onchainInvoice = app.scannedOnchainInvoice { let onchainBalance = max( LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0, hwWalletManager.maximumFundingBalanceSats @@ -919,6 +938,12 @@ struct SendSheet: View { } } + /// Whether a request Savings and the hardware wallets can't cover is rejected before Confirm. One Spark can carry + /// reaches Confirm, which routes it from Spending, as a pure on-chain payment's check does. + static func rejectsUnfundedOnchainRequest(route: PaykitRouteUi?) -> Bool { + route?.offersSparkLeg != true + } + private func selectHardwareFundingSourceIfNeeded( amountSats: UInt64, requestId: PaykitPaymentRequest.ID @@ -1030,7 +1055,7 @@ struct SendSheet: View { // A failed estimate is not proof of insufficient funds. Keep the sheet open so the normal // confirmation path can retry instead of rejecting a payable request. - guard !hasUnavailableSource else { return true } + guard !hasUnavailableSource, Self.rejectsUnfundedOnchainRequest(route: app.paykitRoute) else { return true } let maximumAvailable = max(savingsAvailable ?? 0, hardwareSources.map(\.available).max() ?? 0) _ = validateOnchainBalanceAndDismissIfInsufficient( invoiceAmount: amountSats, @@ -1162,6 +1187,12 @@ struct SendSheet: View { routingCacheResetAttempted: flow.routingCacheResetAttempted, navigationPath: $flow.navigationPath ) + case let .sparkWithdrawalPending(attempt): + SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: attempt.requestId, + routingCacheResetAttempted: flow.routingCacheResetAttempted, sparkWithdrawal: attempt, + navigationPath: $flow.navigationPath + ) case let .onchainPending(context): SendPendingScreen( paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 242c958ba..77d290d09 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -7,6 +7,10 @@ struct SpendingWalletScreen: View { @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + private var canMoveToSpark: Bool { + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared)) + } + private var shouldShowOnboarding: Bool { let hasLightningActivities = activity.lightningActivities?.isEmpty == false return wallet.totalLightningSats == 0 && !hasLightningActivities @@ -41,7 +45,15 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { - if let channels = wallet.channels, !channels.isEmpty { + if canMoveToSpark { + CustomButton(title: t("lightning__move_to_spark__nav_title")) { + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSpark") + .padding(.top, 28) + } + + if wallet.totalLightningSats > 0, wallet.channels?.isEmpty == false || SparkManager.shared.shownSpendingSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) @@ -102,7 +114,7 @@ struct SpendingWalletScreen: View { var transferFromSavingsButton: some View { CustomButton( - title: t("lightning__transfer_to_spending_button"), + title: t("lightning__funding__button1"), variant: .secondary, icon: Image("arrow-up-down") .resizable() @@ -110,7 +122,13 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.canFundFromChain { + sheets.showSheet(.transferUnavailable) + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmount) + } else { + navigation.navigate(.spendingIntro) + } } .accessibilityIdentifier("TransferFromSavings") } diff --git a/BitkitTests/AppCacheDataTests.swift b/BitkitTests/AppCacheDataTests.swift index 3b8061c75..d4506ccda 100644 --- a/BitkitTests/AppCacheDataTests.swift +++ b/BitkitTests/AppCacheDataTests.swift @@ -6,8 +6,6 @@ final class AppCacheDataTests: XCTestCase { let json = #""" { "hasSeenShopIntro": true, - "hasSeenTransferIntro": true, - "hasSeenTransferToSpendingIntro": true, "blocktankRefundAddress": {"address": "bcrt1refund", "index": 7}, "lastUsedTags": ["coffee"] } @@ -18,4 +16,14 @@ final class AppCacheDataTests: XCTestCase { XCTAssertTrue(cache.hasSeenShopIntro) XCTAssertEqual(cache.lastUsedTags, ["coffee"]) } + + func testTransferIntroFlagsRoundTrip() throws { + let json = #"{"hasSeenTransferIntro": true, "hasSeenTransferToSpendingIntro": true}"# + + let cache = try JSONDecoder().decode(AppCacheData.self, from: Data(json.utf8)) + let restored = try JSONDecoder().decode(AppCacheData.self, from: JSONEncoder().encode(cache)) + + XCTAssertTrue(restored.hasSeenTransferIntro) + XCTAssertTrue(restored.hasSeenTransferToSpendingIntro) + } } diff --git a/BitkitTests/AppStatusExitTests.swift b/BitkitTests/AppStatusExitTests.swift new file mode 100644 index 000000000..56ddf2beb --- /dev/null +++ b/BitkitTests/AppStatusExitTests.swift @@ -0,0 +1,45 @@ +@testable import Bitkit +import XCTest + +final class AppStatusExitTests: XCTestCase { + func testExitStepIsNilWithoutARunningExit() { + XCTAssertNil(AppStatusHelper.exitStep(record: nil)) + XCTAssertNil(AppStatusHelper.exitStep(record: record(.done))) + XCTAssertNil(AppStatusHelper.exitStep(record: record(.failed(reason: "reason")))) + } + + func testExitStepMapsRunningAndFundingPhases() { + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.running)), .pending) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 1000))), .funding) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000))), .funding) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000)), fundingState: .ask(sats: 1000)), .funding) + } + + func testExitStepIsPendingOnceAPaymentCoversTheFunding() { + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 1000)), fundingState: .paid(txid: "txid")), .pending) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000)), fundingState: .seen(txid: "txid")), .pending) + } + + func testExitStepIsPendingWithNothingToFund() { + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 0))), .pending) + } + + func testExitStepIsPendingWhileASavingsPaymentConfirms() { + var paid = record(.awaitingFunding(requiredSats: 1000)) + paid.fundingPaymentTxid = "txid" + XCTAssertEqual(AppStatusHelper.exitStep(record: paid), .pending) + } + + private func record(_ phase: SparkExitRecord.Phase) -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: Date(), + phase: phase, + feeRateSatPerVbyte: 1, + destination: "bcrt1qdestination", + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02")], + expectedSats: 1000 + ) + } +} diff --git a/BitkitTests/AppStatusHelperTests.swift b/BitkitTests/AppStatusHelperTests.swift new file mode 100644 index 000000000..989d358c2 --- /dev/null +++ b/BitkitTests/AppStatusHelperTests.swift @@ -0,0 +1,42 @@ +@testable import Bitkit +import XCTest + +final class AppStatusHelperTests: XCTestCase { + func testSparkStatusIsNilWhenSparkIsUnsupported() { + XCTAssertNil(AppStatusHelper.sparkStatus(isOnline: true, isSupported: false, isReady: true, hasStartFailed: false)) + } + + func testSparkStatusMapsToReadyPendingAndError() { + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: true, hasStartFailed: false), .ready) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: false, hasStartFailed: false), .pending) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: false, isSupported: true, isReady: true, hasStartFailed: false), .error) + } + + func testSparkStatusIsErrorWhenItsStartFailed() { + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: false, hasStartFailed: true), .error) + } + + func testChannelsStatusIsReadyThroughSparkWhenNoChannelIsUsable() { + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: true, isSparkUsable: true) + XCTAssertEqual(status, .ready) + XCTAssertTrue(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkUsable: true)) + } + + func testChannelsStatusIsOpenWhenAChannelIsUsableBesideSpark() { + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: true, hasChannels: true, isSparkUsable: true) + XCTAssertEqual(status, .ready) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: true, isSparkUsable: true)) + } + + func testChannelsStatusIsErrorWhenNeitherAChannelNorSparkCanPay() { + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: false, isSparkUsable: false) + XCTAssertEqual(status, .error) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkUsable: false)) + } + + func testChannelsStatusIsErrorOfflineEvenWithSpark() { + let status = AppStatusHelper.channelsStatus(isOnline: false, hasUsableChannels: false, hasChannels: false, isSparkUsable: true) + XCTAssertEqual(status, .error) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: false, hasUsableChannels: false, isSparkUsable: true)) + } +} diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift new file mode 100644 index 000000000..74edbb889 --- /dev/null +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -0,0 +1,1706 @@ +@testable import Bitkit +import BreezSdkSpark +import LDKNode +import XCTest + +@MainActor +final class ChannelFreeRailTests: XCTestCase { + /// A 20 000 sat regtest invoice, so a payment can be looked up by its hash. + static let invoice = + "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" + + private var rail: FakeRail! + private var previousRail: Bitkit.ChannelFreeLightningRail? + private var previousAttemptsStore: UserDefaults! + private var previousRailSupported: (() async -> Bool)! + private var previousNow: (() -> Date)! + + override func setUp() { + super.setUp() + rail = FakeRail() + let service = Bitkit.LightningService.shared + previousRail = service.channelFreeRail + service.channelFreeRail = rail + previousAttemptsStore = service.railAttemptsStore + service.railAttemptsStore = UserDefaults(suiteName: "ChannelFreeRailTests")! + service.forgetRailAttempts() + service.forgetLdkAttempts() + service.clearSparkRestore() + previousRailSupported = service.channelFreeRailSupported + service.channelFreeRailSupported = { true } + previousNow = service.now + } + + override func tearDown() { + let service = Bitkit.LightningService.shared + service.forgetRailAttempts() + service.forgetLdkAttempts() + service.clearSparkRestore() + service.railAttemptsStore = previousAttemptsStore + service.channelFreeRailSupported = previousRailSupported + service.now = previousNow + service.clearEventCallback() + service.channelFreeRail = previousRail + rail = nil + super.tearDown() + } + + func testRailEventsBeforeTheNodeCallbackAreDeliveredOnceItIsSet() { + let hash = "aa" + Bitkit.LightningService.shared.emitChannelFreeRailEvent( + .paymentReceived(paymentId: hash, paymentHash: hash, amountMsat: 1000, customRecords: []) + ) + var received: [String] = [] + + Bitkit.LightningService.shared.setEventCallback { event in + if case let .paymentReceived(_, paymentHash, _, _) = event { + received.append(paymentHash) + } + } + + XCTAssertEqual(received, [hash]) + } + + func testPaykitProofSettlesFromTheRailWhenTheEventWasMissed() async { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "bb", direction: .outbound, status: .succeeded, preimage: "cc")] + + let status = await PaykitLightningPaymentProofLookup().status(paymentHash: "BB") + + guard case let .succeeded(preimage) = status else { return XCTFail("Expected a settled proof, got \(status)") } + XCTAssertEqual(preimage, "cc") + } + + func testPrivatePaykitSeesRailReceivesAndSends() async throws { + rail.payments = [ + Bitkit.ChannelFreeRailPayment(paymentHash: "in", direction: .inbound, status: .succeeded, preimage: nil), + Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil), + Bitkit.ChannelFreeRailPayment(paymentHash: "failed", direction: .outbound, status: .failed, preimage: nil), + ] + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [] } + defer { service.nodePayments = nodePayments } + + let received = await PrivatePaykitService.shared.receivedSettledPaymentHashes() + let attempted = try await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() + + XCTAssertTrue(received.contains("in")) + XCTAssertEqual(attempted.intersection(["out", "failed"]), ["out"]) + } + + func testAContactListInvoicePaidOnSparkReadsAsSettled() async { + // The private payment list's invoice was created on Spark, so only Spark's list shows it paid. + let hash = String(repeating: "ab", count: 32) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .inbound, status: .pending, preimage: nil)] + let pending = await PrivatePaykitService.shared.isReceivedInvoiceSettled(paymentHash: hash) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .inbound, status: .succeeded, preimage: nil)] + + let paid = await PrivatePaykitService.shared.isReceivedInvoiceSettled(paymentHash: hash) + + XCTAssertFalse(pending) + XCTAssertTrue(paid, "A paid Spark invoice would leave the private list unrotated") + } + + func testLightningWaitFinishesWhenTheRailSettledBeforeWatching() async throws { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "ff", direction: .outbound, status: .succeeded, preimage: nil)] + + let settled = try await WalletViewModel().waitForLightningPayment(hash: "ff", timeoutSeconds: 5) + + XCTAssertEqual(settled.paymentHash, "ff") + } + + func testQuickPayReconcilesRailPayments() { + let row = QuickPayReconcileRow(railPayment: Bitkit.ChannelFreeRailPayment( + paymentHash: "dd", direction: .outbound, status: .succeeded, preimage: nil + )) + + XCTAssertEqual(row.invoicePaymentHash, "dd") + XCTAssertTrue(row.isOutboundBolt11) + XCTAssertEqual(row.status, QuickPayReconcileRow.Status.succeeded) + } + + func testRailSendHonoursTheRoutingFeeLimit() async throws { + rail.routingFeeSats = 5 + let limit = RouteParametersConfig( + maxTotalRoutingFeeMsat: 1000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: limit) + XCTFail("Expected the fee limit to stop the payment") + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused, "A refusal before sending must read as never left: \(error)") + XCTAssertNil(rail.sentBolt11) + } + + let roomy = RouteParametersConfig( + maxTotalRoutingFeeMsat: 10000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: roomy) + XCTAssertEqual(rail.sentBolt11, "lnbcrt1rail") + XCTAssertEqual(rail.sentFeeLimitSats, 10, "The rail checks its sent quote against the limit too") + } +} + +@MainActor +extension ChannelFreeRailTests { + func testFailedFeeEstimateIsARefusalBeforeSending() async { + rail.estimateError = NSError(domain: "spark", code: 1) + let limit = RouteParametersConfig( + maxTotalRoutingFeeMsat: 1000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: limit) + XCTFail("Expected the failed estimate to stop the payment") + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused) + XCTAssertNil(rail.sentBolt11) + } + } + + func testAnInvoiceTheNodeIsStillPayingIsNotSentAgainThroughSpark() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let inFlight = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .pending, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [inFlight] } + defer { service.nodePayments = nodePayments } + // The HTLC in flight leaves the channel too little outbound, so the retry falls to Spark. + rail.balanceSats = 100_000 + + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Expected the pending node payment to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + func testAnInvoiceSentOverTheNodeIsNotSentThroughSparkWhileTheNodeCantListIt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + rail.balanceSats = 100_000 + + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Expected the node attempt it can't list to refuse the Spark send") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + func testSparkStillPaysWhileTheNodeCantListWhenTheNodeNeverTriedTheInvoice() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice) + } + + func testSparkWaitsForTheNodesFirstListingWhenItsEarlierPaymentsWereNeverKept() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + // Node payments made before their hashes were kept: none is held until the node lists them. + service.railAttemptsStore.removeObject(forKey: Bitkit.LightningService.ldkAttemptsSeededKey) + rail.balanceSats = 100_000 + + service.nodePayments = { nil } + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Spark paid while the node's earlier payments were unknown") + } catch {} + XCTAssertNil(rail.sentBolt11) + + service.nodePayments = { [] } + _ = try await service.send(bolt11: Self.invoice) + rail.sentBolt11 = nil + service.nodePayments = { nil } + _ = try await service.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice, "Spark stayed held after the node listed its payments") + } + + func testANodePaymentMadeBeforeHashesWereKeptHoldsSparkOnceTheNodeListedIt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let pending = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .pending, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.railAttemptsStore.removeObject(forKey: Bitkit.LightningService.ldkAttemptsSeededKey) + rail.balanceSats = 100_000 + + service.nodePayments = { [pending] } + do { + _ = try await service.send(bolt11: Self.invoice) + } catch {} + service.nodePayments = { nil } + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Expected the node's pending payment to hold Spark while the node can't list it") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + func testANodeAttemptTheNodeListsAsFailedNoLongerHoldsSpark() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let failed = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .failed, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + rail.balanceSats = 100_000 + + service.nodePayments = { [failed] } + _ = try await service.send(bolt11: Self.invoice) + rail.sentBolt11 = nil + service.nodePayments = { nil } + _ = try await service.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice, "A failed node attempt kept holding the invoice") + } + + func testAPendingNodePaymentBehindTheCapOfSettledOnesStillHoldsSparkWhileTheNodeCantList() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let pending = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .pending, latestUpdateTimestamp: 0 + ) + let settled = (1 ... 500).map { index in + LDKNode.PaymentDetails( + id: Self.otherHash(index), kind: .bolt11(hash: Self.otherHash(index), preimage: nil, secret: nil, description: nil, bolt11: ""), + amountMsat: 1000, feePaidMsat: nil, direction: .outbound, status: .succeeded, latestUpdateTimestamp: UInt64(index) + ) + } + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.railAttemptsStore.removeObject(forKey: Bitkit.LightningService.ldkAttemptsSeededKey) + rail.balanceSats = 100_000 + + service.nodePayments = { [pending] + settled } + _ = try? await service.send(bolt11: Self.invoice) + service.nodePayments = { nil } + + await assertRefusedAsDuplicate() + } + + func testANodeSendStillPendingBehindTheCapOfLaterSendsStillHoldsSparkWhileTheNodeCantList() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + for index in 1 ... 500 { + service.recordLdkAttempt(paymentHash: Self.otherHash(index)) + } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + } + + func testASparkSendStillPendingBehindTheCapOfLaterSendsStillHoldsTheNode() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + for index in 1 ... 500 { + service.recordRailAttempt(paymentHash: Self.otherHash(index)) + } + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .pending, preimage: nil)] + + (1 ... 500).map { + Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash($0), direction: .outbound, status: .succeeded, preimage: nil) + } + rail.balanceSats = 0 + await assertRefusedAsDuplicate() + + rail.listError = NSError(domain: "spark", code: 1) + + await assertRefusedAsDuplicate() + } + + func testANodeHashListedOnlyAsFailedIsDropped() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: Self.otherHash(1)) + service.nodePayments = { [Self.nodePayment(Self.otherHash(1), status: .failed)] } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertFalse(kept.contains(Self.otherHash(1)), "A node hash listed only as failed was kept") + } + + func testNodeHashesListedOnlyAsFailedAreDroppedPastTheCapToo() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let pending = Self.nodePayment(Self.otherHash(0), status: .pending) + let failed = (1 ... 501).map { Self.nodePayment(Self.otherHash($0), status: .failed) } + ([pending] + failed).forEach { service.recordLdkAttempt(paymentHash: $0.id) } + service.nodePayments = { [pending] + failed } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept, [Self.otherHash(0)], "Failed node hashes stayed, or the pending one was dropped") + } + + func testANodeRetryRecordedDuringAnOlderListingSurvivesItsTrim() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let retried = Self.otherHash(0) + let failed = (0 ... 500).map { Self.nodePayment(Self.otherHash($0), status: .failed) } + failed.forEach { service.recordLdkAttempt(paymentHash: $0.id) } + // The node retries the oldest failed invoice while this listing, which still shows it failed, is read. + service.nodePayments = { + service.recordLdkAttempt(paymentHash: retried) + return failed + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept, [retried], "Failed node hashes stayed, or an older listing dropped the retry in flight") + } + + func testANodeRetryOfTheSameInvoiceRecordedDuringAnOlderListingKeepsItsHold() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + // The node retries the invoice Spark is about to pay, and the retry returns, while this listing is read. + service.nodePayments = { + service.recordLdkAttempt(paymentHash: hash) + return [Self.nodePayment(hash, status: .failed)] + } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept, [hash], "An older listing released the node retry's hold") + } + + func testASparkRetryRecordedDuringAnOlderListingKeepsItsHold() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + service.recordRailAttempt(paymentHash: hash) + service.recordRailAttempt(paymentHash: Self.otherHash(1)) + // Spark retries both invoices, and the retries return, while this listing, which still shows them failed, is read. + rail.payments = [hash, Self.otherHash(1)].map { + Bitkit.ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .failed, preimage: nil) + } + rail.onList = { [hash] in + service.recordRailAttempt(paymentHash: hash) + service.recordRailAttempt(paymentHash: Self.otherHash(1)) + } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [hash, Self.otherHash(1)], "An older listing dropped the hold of a Spark retry") + } + + func testASparkRetryInFlightWhenAnOlderListingIsReadKeepsItsHoldAfterItEnds() async { + let service = Bitkit.LightningService.shared + let retried = Self.otherHash(1) + service.recordRailAttempt(paymentHash: retried) + XCTAssertTrue(service.beginRailSend(paymentHash: retried)) + // A Spark retry recorded before this listing ends while the listing, which still shows it failed, is read. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: retried, direction: .outbound, status: .failed, preimage: nil)] + rail.onList = { service.endRailSend(paymentHash: retried) } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [retried], "An older listing dropped the hold of a Spark retry that ended while it was read") + } + + func testANodeRetryInFlightWhenAnOlderListingIsReadKeepsItsHoldAfterItEnds() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let retried = Self.otherHash(0) + service.recordLdkAttempt(paymentHash: retried) + service.beginLdkSend(paymentHash: retried) + // A node retry recorded before this listing ends while the listing, which still shows it failed, is read. + service.nodePayments = { + service.endLdkSend(paymentHash: retried) + return [Self.nodePayment(retried, status: .failed)] + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept, [retried], "An older listing dropped the hold of a node retry that ended while it was read") + } + + func testANodeSendAdmittedWhileSparkPreparesRefusesSparksSubmission() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + defer { service.endLdkSend(paymentHash: hash) } + // The node starts sending the invoice after Spark's checks passed and before Spark submits it. + rail.beforeSubmit = { service.beginLdkSend(paymentHash: hash) } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + } + + func testASparkAttemptRefusedAtAdmissionKeepsTheNodeAttemptAsTheLatest() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + // The node's send of the invoice went out, and Spark's checks don't see it before Spark's submission. + service.noteAttempt(on: .ldk, paymentHash: hash) + service.beginLdkSend(paymentHash: hash) + defer { service.endLdkSend(paymentHash: hash) } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + + XCTAssertEqual(service.attemptRail(paymentHash: hash), .ldk, "a refused Spark attempt took the node's place") + } + + func testSendsNoListingShowsAreDroppedOnceTheirInvoiceExpiredPastTheSettleWindow() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let window = 14 * 24 * 60 * 60 + let now = 10 * window + service.now = { Date(timeIntervalSince1970: TimeInterval(now)) } + for key in [Self.railAttemptsKey, Self.ldkAttemptsKey] { + let record = key == Self.railAttemptsKey ? service.recordRailAttempt : service.recordLdkAttempt + record(Self.otherHash(1), now - window) + record(Self.otherHash(2), now - window + 1) + record(Self.otherHash(3), nil) + } + // A send recorded while the listings are read is not in them yet. + rail.onList = { service.recordRailAttempt(paymentHash: Self.otherHash(4), expiresAt: now - window) } + service.nodePayments = { + service.recordLdkAttempt(paymentHash: Self.otherHash(4), expiresAt: now - window) + return [] + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + for key in [Self.railAttemptsKey, Self.ldkAttemptsKey] { + let kept = service.railAttemptsStore.stringArray(forKey: key) ?? [] + XCTAssertFalse(kept.contains(Self.otherHash(1)), "\(key) kept a send no listing shows past the settle window") + XCTAssertTrue( + Set([2, 3, 4].map(Self.otherHash)).isSubset(of: kept), + "\(key) dropped a send that may be pending or has no known expiry" + ) + } + } + + func testASendWhoseInvoiceNeverExpiresKeepsItsHold() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + // An invoice whose expiry is past what an Int holds is kept at Int.max. + service.recordRailAttempt(paymentHash: Self.otherHash(1), expiresAt: .max) + service.recordLdkAttempt(paymentHash: Self.otherHash(1), expiresAt: .max) + service.nodePayments = { [] } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + for key in [Self.railAttemptsKey, Self.ldkAttemptsKey] { + let kept = service.railAttemptsStore.stringArray(forKey: key) ?? [] + XCTAssertTrue(kept.contains(Self.otherHash(1)), "\(key) dropped a send whose invoice never expires") + } + } + + func testSendsNoListingShowsWithNoKnownExpiryAreDroppedOldestFirstPastTheCap() async { + let service = Bitkit.LightningService.shared + (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, (2 ... 501).map(Self.otherHash), "Spark hashes no listing shows grew past the cap") + } + + func testAPendingNodePaymentBehindTheCapOfFailedOnesStillHoldsSparkWhileTheNodeCantList() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let failed = (1 ... 501).map { Self.nodePayment(Self.otherHash($0), status: .failed) } + service.recordLdkAttempt(paymentHash: hash) + failed.forEach { service.recordLdkAttempt(paymentHash: $0.id) } + rail.balanceSats = 100_000 + + service.nodePayments = { [Self.nodePayment(hash, status: .pending)] + failed } + await assertRefusedAsDuplicate() + service.nodePayments = { nil } + + await assertRefusedAsDuplicate() + } + + func testAPaidSparkSendWhoseInvoiceIsUnexpiredStillHoldsTheNodeBehindTheCapWhileSparkCantList() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: TimeInterval(invoice.secondsSinceEpoch())) } + try await sendOverSpark() + try await listPaidSparkSendBehindTheCap(String(invoice.paymentHash())) + + rail.listError = NSError(domain: "spark", code: 1) + + await assertRefusedAsDuplicate() + } + + func testAPaidSparkSendWhoseInvoiceExpiredIsTrimmedPastTheCap() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let hash = String(invoice.paymentHash()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: TimeInterval(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds())) } + try await sendOverSpark() + + try await listPaidSparkSendBehindTheCap(hash) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept.count, 500, "Paid Spark hashes stayed past the cap") + XCTAssertFalse(kept.contains(hash), "The paid Spark hash whose invoice expired was kept") + } + + func testSparkHashesListedFailedOnlyOrPaidWithAnExpiredInvoiceAreDroppedUnderTheCap() async { + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: 1000) } + service.recordRailAttempt(paymentHash: Self.otherHash(1), expiresAt: 1000) + service.recordRailAttempt(paymentHash: Self.otherHash(2), expiresAt: 1001) + service.recordRailAttempt(paymentHash: Self.otherHash(3)) + service.recordRailAttempt(paymentHash: Self.otherHash(4)) + rail.payments = (1 ... 3).map { + Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash($0), direction: .outbound, status: .succeeded, preimage: nil) + } + [Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash(4), direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [Self.otherHash(2), Self.otherHash(3)], "Expired or failed Spark hashes stayed, or live ones went") + } + + /// Records 500 newer Spark sends after `hash` and lists them all as paid, so a node send trims past the cap. + private func listPaidSparkSendBehindTheCap(_ hash: String) async throws { + let service = Bitkit.LightningService.shared + (1 ... 500).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } + rail.payments = ([hash] + (1 ... 500).map(Self.otherHash)).map { + Bitkit.ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .succeeded, preimage: nil) + } + rail.balanceSats = 0 + _ = try? await service.send(bolt11: Self.invoice) + rail.sentBolt11 = nil + } + + func testSparkHashesListedAsFailedAreDroppedPastTheCapToo() async { + let service = Bitkit.LightningService.shared + (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } + rail.payments = (1 ... 501).map { + Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash($0), direction: .outbound, status: .failed, preimage: nil) + } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [], "Failed Spark hashes stayed") + } + + func testASendSparkRefusedAfterItWasRecordedIsNotHeldWhileSparkCannotList() async { + rail.balanceSats = 100_000 + rail.refusalAfterSubmission = Bitkit.ChannelFreeRailSendRefused(reason: "insufficient funds") + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.refusalAfterSubmission = nil + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testASparkSendWithAnUnknownOutcomeHoldsTheInvoiceUntilSparkSyncsAgain() async throws { + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + // The connection drops inside Spark's send, after it may have committed the payment. + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark last synced before the send failed, so its list, which lacks the send, proves nothing yet. + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + + // A sync after the failure reached Spark and the invoice expired, so the send can no longer go out. + rail.syncedAfter = { _ in true } + let expiresAt = try Self.invoiceExpiresAt() + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt) } + await assertReachesTheNode() + } + + func testAnUnknownSparkSendMissingAfterAFreshSyncIsHeldUntilItsInvoiceExpires() async throws { + let expiresAt = try Self.invoiceExpiresAt() + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt - 600) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Every sync after the failure reaches Spark, yet Spark lists no such send: resuming it may have failed. + rail.syncedAfter = { _ in true } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt) } + await assertReachesTheNode() + } + + func testASyncEndingWhileSparksListIsReadKeepsAnUnknownSendHeld() async throws { + let expiresAt = try Self.invoiceExpiresAt() + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt - 600) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt) } + // Spark synced only before the failure, and a sync after it ends while the listing that lacks the send is read. + rail.syncedAfter = { $0 == nil } + let fake: FakeRail = rail + fake.onList = { + fake.onList = nil + fake.syncedAfter = { _ in true } + } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + + func testAnUnlistedSparkSendKeptFromAnEarlierLaunchHoldsTheNodeUntilItsInvoiceExpires() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let hash = String(invoice.paymentHash()) + let expiresAt = Int(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds()) + let service = Bitkit.LightningService.shared + // After a relaunch only the kept hash is left, and Spark synced since but lists no row for the send. + service.recordRailAttempt(paymentHash: hash, expiresAt: expiresAt) + service.now = { Date(timeIntervalSince1970: TimeInterval(expiresAt - 600)) } + rail.balanceSats = 0 + + let status = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: hash) + + XCTAssertEqual(status, .pending, "a send Spark can still resume was read as never made") + await assertRefusedAsDuplicate() + service.now = { Date(timeIntervalSince1970: TimeInterval(expiresAt)) } + await assertReachesTheNode() + } + + private static func invoiceExpiresAt() throws -> TimeInterval { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: invoice) + return TimeInterval(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds()) + } + + func testPaykitReadsSparkBeforeItsFirstSyncAsUnavailable() async { + // A restored wallet fills Spark's list on its first sync, so the list before it lacks sends Spark made. + rail.syncedAfter = { _ in false } + + let status = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: "aa") + + XCTAssertEqual(status, .unavailable, "Spark's unsynced list was read as complete") + } + + func testPaykitReadsAnOlderFailedAttemptOfAnUnknownSparkSendAsPending() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark lists only an earlier attempt that failed, and has not synced since this send failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { ($0 ?? 0) < 1000 } + + let status = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: hash) + + XCTAssertEqual(status, .pending, "an earlier attempt's failure ended a send Spark may still finish") + } + + func testARefusedRetryKeepsAnUnknownSparkSendHeldPastAnOlderFailedAttempt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark lists only an earlier attempt that failed, and has not synced since this send failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + // A refused retry must not end the hold: Spark may still finish the unknown send. + await assertRefusedAsDuplicate() + } + + func testASparkSendCutOffByTheProcessEndingStaysHeldPastAnOlderFailedAttempt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + // The process ends inside Spark's send, after submission: only what was stored by then survives. + var stored: [String: Any] = [:] + let store = service.railAttemptsStore + rail.sendGate = { stored = store.dictionaryRepresentation().filter { $0.key.hasPrefix("channelFreeRail") } } + _ = try? await service.send(bolt11: Self.invoice) + rail.sendGate = nil + rail.sentBolt11 = nil + service.forgetRailAttempts() + stored.forEach { service.railAttemptsStore.set($0.value, forKey: $0.key) } + // After the relaunch Spark has synced and lists only an earlier attempt of the invoice that failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { _ in true } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + + func testASparkFailureEventOfAnUnknownSendLetsTheNodeRetry() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark's syncs keep failing, but it resumes the send and reports that it failed. + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testAnUnknownSparkSendStaysHeldAfterARelaunchWhenSparkListsOnlyAnOlderFailedAttempt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await service.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + try simulateRelaunch() + + // The launch's sync reads Spark's list, which shows only an earlier attempt of the invoice that failed. + await SparkManager.releaseFailedSendHolds { [Self.sparkPayment(hash: hash, status: .failed)] } + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertTrue(kept.contains(hash), "an earlier attempt's failure released a send Spark may still resume") + // Spark has not synced since the send failed, so it may still finish it. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.balanceSats = 0 + await assertRefusedAsDuplicate() + } + + func testASparkSendThatReturnedLetsTheNodeRetryOnceSparkListsItFailed() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + // Spark returned the send, then reports that it failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + /// Keeps only what the service stores across launches: the rail's kept keys survive, everything in memory goes. + private func simulateRelaunch() throws { + let service = Bitkit.LightningService.shared + let kept = service.railAttemptsStore.dictionaryRepresentation().filter { $0.key.hasPrefix("channelFreeRail") } + XCTAssertNotNil(kept[Self.railAttemptsKey], "the send left no kept hash to survive the relaunch") + service.forgetRailAttempts() + kept.forEach { service.railAttemptsStore.set($0.value, forKey: $0.key) } + } + + private static func sparkPayment(hash: String, status: BreezSdkSpark.PaymentStatus) -> Payment { + Payment( + id: hash, + paymentType: .send, + status: status, + amount: 20000, + fees: 0, + timestamp: 1, + method: .lightning, + details: .lightning( + description: nil, + invoice: invoice, + destinationPubkey: "pubkey", + htlcDetails: SparkHtlcDetails(paymentHash: hash, preimage: nil, expiryTime: 0, status: .returned), + lnurlPayInfo: nil, + lnurlWithdrawInfo: nil, + lnurlReceiveMetadata: nil, + conversionInfo: nil + ), + conversionDetails: nil + ) + } + + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" + private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" + + private static func nodePayment(_ hash: String, status: LDKNode.PaymentStatus) -> LDKNode.PaymentDetails { + LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: ""), + amountMsat: 1000, feePaidMsat: nil, direction: .outbound, status: status, latestUpdateTimestamp: 0 + ) + } + + private static func otherHash(_ index: Int) -> String { + String(format: "%064x", index) + } + + func testPaykitReadsAnUnlistableNodeAsUnknownNotAsNeverPaid() async { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + + let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") + + XCTAssertNil(attempted) + } + + func testQuickPayWaitsWhileTheNodeCantListItsPayments() async throws { + let sparkFailure = Bitkit.ChannelFreeRailPayment(paymentHash: "aa", direction: .outbound, status: .failed, preimage: nil) + + let rows = try await QuickPayPaymentCoordinator.reconcileRows(railPayments: { [sparkFailure] }, nodePayments: { nil }) + + XCTAssertNil(rows, "Spark's rows alone can release a record the node is still paying") + } + + func testAnInvoiceSparkIsStillPayingIsNotSentAgainThroughTheNode() async { + let hash = try? String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash ?? "", direction: .outbound, status: .pending, preimage: nil)] + // Spark can't cover the retry, so it goes to the node. + rail.balanceSats = 0 + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the pending Spark payment to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + /// An emergency exit takes Spark off Lightning: an invoice Spark was paying when it started is still not paid again + /// over a channel. + func testAnInvoiceSparkWasPayingIsNotSentAgainThroughTheNodeDuringAnExit() async { + let hash = try? String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash ?? "", direction: .outbound, status: .pending, preimage: nil)] + let previousHistory = Bitkit.LightningService.shared.channelFreePaymentHistory + Bitkit.LightningService.shared.channelFreePaymentHistory = rail + Bitkit.LightningService.shared.channelFreeRail = nil + defer { Bitkit.LightningService.shared.channelFreePaymentHistory = previousHistory } + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the pending Spark payment to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + func testUserSendOverTheRailHasAFeeLimit() async throws { + // WalletViewModel.send and sendWithTimeout pass no route parameters, so LDK applies its default + // limit (1% + 50 sats); the rail must not be left without one. + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 1000) + + XCTAssertEqual(rail.sentBolt11, "lnbcrt1rail") + XCTAssertNotNil(rail.sentFeeLimitSats, "A user send over Spark went out with no fee limit") + } + + func testASparkSendStillInFlightIsNotSentAgainThroughTheNode() async throws { + var release: CheckedContinuation? + rail.sendGate = { await withCheckedContinuation { release = $0 } } + rail.balanceSats = 100_000 + let first = Task { try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) } + var waited = 0 + while release == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(release, "The Spark send never started") + // Spark lists the send only once it returns; the retry falls to the node meanwhile. + rail.balanceSats = 0 + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the Spark send in flight to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + release?.resume() + _ = try await first.value + } + + func testAnInvoiceSparkTriedIsNotSentThroughTheNodeWhileSparkCannotListPayments() async throws { + try await sendOverSpark() + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + + func testAnInvoiceSparkTriedIsNotSentThroughTheNodeWhileSparkIsNotConnected() async throws { + try await sendOverSpark() + Bitkit.LightningService.shared.channelFreeRail = nil + + await assertRefusedAsDuplicate() + } + + func testAnInvoiceSparkTriedGoesToTheNodeOnceSparkListsNoLiveAttempt() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testAnInvoiceSparkNeverTriedGoesToTheNodeWhileSparkCannotListPayments() async { + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testAnInvoiceGoesToTheNodeWhenSparkIsUnsupported() async { + let service = Bitkit.LightningService.shared + service.channelFreeRail = nil + service.channelFreeRailSupported = { false } + + await assertReachesTheNode() + } + + func testASendSparkReportedFailedIsNotHeldFromTheNodeWhileSparkCannotList() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testASendSparkListedAsFailedIsNotHeldOnceSparkCannotList() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + await assertReachesTheNode() + + rail.listError = NSError(domain: "spark", code: 1) + + await assertReachesTheNode() + } + + func testASendSparkRefusedBeforeDispatchIsNotHeldWhileSparkCannotList() async { + rail.balanceSats = 100_000 + rail.refusal = Bitkit.ChannelFreeRailSendRefused(reason: "refused") + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.refusal = nil + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testSparkHistoryReadsAsUnavailableWhileSparkIsDisconnected() async { + Bitkit.LightningService.shared.channelFreeRail = nil + + do { + _ = try await Bitkit.LightningService.shared.channelFreeRailPayments() + XCTFail("A disconnected Spark must not read as holding no payments") + } catch {} + let proof = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: "out") + guard case .unavailable = proof else { return XCTFail("Expected an unavailable proof, got \(proof)") } + // After a restart an older failed channel attempt must not settle a Spark retry Spark can't list yet. + XCTAssertEqual(PaykitLightningPaymentProofLookup.combined(ldk: .failed, rail: proof, latestAttempt: nil), .unavailable) + } + + func testSparkHistoryReadsAsEmptyWhereSparkDoesNotRun() async throws { + let service = Bitkit.LightningService.shared + service.channelFreeRail = nil + service.channelFreeRailSupported = { false } + + let payments = try await service.channelFreeRailPayments() + + XCTAssertTrue(payments.isEmpty) + } + + func testAFixedWithdrawalWithAPartOfASatKeepsItsExactAmount() { + typealias Route = Bitkit.LightningService.ReceiveRoute + // A fixed LNURL withdrawal of 222 538 msat: channels that can take it get the exact amount; Spark can't, so + // without such a channel it is refused rather than invoicing 222 000 msat, below the endpoint's minimum. + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 222_538, railTakes: false), Route.node) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 222_538, railTakes: true), Route.refused) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 222_000, railTakes: true), Route.rail(amountSats: 222)) + } + + func testAnAmountUnderASatIsNeverInvoicedAsAmountless() { + typealias Route = Bitkit.LightningService.ReceiveRoute + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 500, railTakes: true), Route.refused) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 500, railTakes: false), Route.node) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: nil, railTakes: true), Route.rail(amountSats: nil)) + } + + func testAChannelsOnlyPaymentNeverFallsBackToSpark() async { + // The channels can't pay it (there are none in tests) and Spark could, as after a quote whose channel went away. + rail.balanceSats = 100_000 + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice, channelsOnly: true) + XCTFail("Expected the payment to fail on the channels") + } catch { + XCTAssertTrue("\(error)".contains("nodeNotSetup"), "\(error)") + } + XCTAssertNil(rail.sentBolt11, "A deselected Spark balance paid a channel-funded payment") + } + + func testAHeldInvoiceIsNotReleasedWhileItsSparkRetryIsInFlight() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + var release: CheckedContinuation? + rail.sendGate = { await withCheckedContinuation { release = $0 } } + rail.balanceSats = 100_000 + let retry = Task { try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) } + var waited = 0 + while release == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(release, "The Spark retry never started") + + // What a launch's sync does with an earlier failed send of the same invoice. + Bitkit.LightningService.shared.releaseRailAttempt(paymentHash: hash) + release?.resume() + _ = try await retry.value + rail.sentBolt11 = nil + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + + func testASparkFailureReportedWhileItsSendIsInFlightStillReleasesTheHold() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + var release: CheckedContinuation? + rail.sendGate = { await withCheckedContinuation { release = $0 } } + rail.balanceSats = 100_000 + let send = Task { try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) } + var waited = 0 + while release == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(release, "The Spark send never started") + + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + release?.resume() + _ = try await send.value + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + // MARK: - Restore + + /// Marks the wallet restored `offset` seconds after the fixture invoice was created. + private func restore(secondsAfterTheInvoice offset: Int) throws { + let createdAt = try Int(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: TimeInterval(createdAt + offset)) } + service.markSparkHistoryUnseeded() + service.now = previousNow + } + + /// Spark can't list its sends and has no balance to pay the invoice, so the send goes to the node. + private func leaveSparkUnsyncedAndEmpty() { + rail.syncedAfter = { _ in false } + rail.balanceSats = 0 + } + + private func assertRefusedUntilSparkLists(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("A send of an invoice made before the restore went out before Spark listed its sends", file: file, line: line) + } catch { + XCTAssertTrue((error as? Bitkit.ChannelFreeRailSendRefused)?.isRestoredInvoiceUnverified == true, "\(error)", file: file, line: line) + } + XCTAssertNil(rail.sentBolt11, file: file, line: line) + } + + func testARestoreKeepsWhenItHappened() throws { + let createdAt = try Int(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + + try restore(secondsAfterTheInvoice: 60) + + XCTAssertEqual(Bitkit.LightningService.shared.sparkRestoredAt, createdAt + 60) + } + + func testAPreRestoreInvoiceSparksSyncedListLacksIsRefusedOverTheNodeUntilItExpires() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let createdAt = TimeInterval(invoice.secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + try restore(secondsAfterTheInvoice: 60) + rail.balanceSats = 0 + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + // Spark reports a sync while its payment history failed to sync, so its list lacks the pre-restore send. + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + XCTAssertNil(service.sparkHistoryUnseededSince, "Spark's own list still ends the unseeded hold") + + await assertRefusedUntilSparkLists() + // A relaunch, before Spark syncs that run. + rail.syncedAfter = { _ in false } + await assertRefusedUntilSparkLists() + + service.now = { Date(timeIntervalSince1970: createdAt + TimeInterval(invoice.expiryTimeSeconds())) } + await assertReachesTheNode() + } + + func testASyncedSparkWhoseListLacksAPreRestoreInvoiceRefusesToSendItUntilItExpires() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let createdAt = TimeInterval(invoice.secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + try restore(secondsAfterTheInvoice: 60) + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + // Spark reports a sync while its payment history failed to sync, so its list lacks the pre-restore send. + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + rail.balanceSats = 100_000 + + await assertRefusedUntilSparkLists() + + service.now = { Date(timeIntervalSince1970: createdAt + TimeInterval(invoice.expiryTimeSeconds())) } + _ = try await service.send(bolt11: Self.invoice) + XCTAssertEqual(rail.sentBolt11, Self.invoice, "Spark stayed held after the invoice expired") + } + + func testASparkThatCantListItsSendsAfterARestoreRefusesToSendAPreRestoreInvoice() async throws { + let createdAt = try TimeInterval(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + try restore(secondsAfterTheInvoice: 60) + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + XCTAssertNil(service.sparkHistoryUnseededSince) + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 100_000 + + await assertRefusedUntilSparkLists() + } + + func testAPreRestoreInvoiceTheNodeAlreadyPaidReadsAsPaidInsteadOfRefused() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let hash = String(invoice.paymentHash()) + let createdAt = TimeInterval(invoice.secondsSinceEpoch()) + let paid = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: 0, direction: .outbound, status: .succeeded, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [paid] } + defer { service.nodePayments = nodePayments } + try restore(secondsAfterTheInvoice: 60) + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + rail.balanceSats = 100_000 + + // Spark's list lacks the invoice, then Spark can't list at all: the node's paid row answers both. + for listError in [nil, NSError(domain: "spark", code: 1)] { + rail.listError = listError + let sent = try await service.send(bolt11: Self.invoice) + XCTAssertEqual(sent, hash) + XCTAssertNil(rail.sentBolt11, "Spark paid an invoice the node already paid") + } + } + + func testAPreRestoreInvoiceThatOutlastsAWeekIsStillHeldOnDayEight() { + let restoredAt = 1_000_000 + let day = 24 * 60 * 60 + let createdAt = UInt64(restoredAt - 60) + let dayEight = restoredAt + 8 * day + + XCTAssertTrue( + Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, invoiceCreatedAt: createdAt, invoiceExpiresAt: restoredAt + 30 * day, now: dayEight + ), + "A pre-restore invoice valid for 30 days stopped being held after a week" + ) + XCTAssertFalse( + Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, invoiceCreatedAt: createdAt, invoiceExpiresAt: restoredAt + 30 * day, now: restoredAt + 30 * day + ) + ) + XCTAssertTrue(Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, + invoiceCreatedAt: nil, + invoiceExpiresAt: nil, + now: dayEight + )) + XCTAssertFalse( + Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, invoiceCreatedAt: UInt64(restoredAt + 60), invoiceExpiresAt: nil, now: dayEight + ) + ) + } + + func testARefusedPreRestoreInvoiceAsksForANewInvoice() { + let refusal = Bitkit.ChannelFreeRailSendRefused.restoredInvoiceUnverified() + // The test target compiles its own `AppError`, so the app's is named. + let wrapped = Bitkit.AppError(message: "wallet__payment_failed_description", debugMessage: nil, underlyingError: refusal) + + XCTAssertEqual(sendFailureMessage(for: refusal), t("wallet__payment_restored_invoice")) + XCTAssertEqual(sendFailureMessage(for: wrapped), t("wallet__payment_restored_invoice")) + } + + func testAnInvoiceMadeBeforeARestoreIsRefusedOverTheNodeUntilSparkSyncs() async throws { + try restore(secondsAfterTheInvoice: 60) + leaveSparkUnsyncedAndEmpty() + + await assertRefusedUntilSparkLists() + } + + func testAnInvoiceMadeAfterARestorePaysOverTheNodeWhileSparkIsUnsynced() async throws { + try restore(secondsAfterTheInvoice: -60) + leaveSparkUnsyncedAndEmpty() + + await assertReachesTheNode() + } + + func testAWalletNeverRestoredIsNeverHeldWhileSparkIsUnsynced() async { + leaveSparkUnsyncedAndEmpty() + + await assertReachesTheNode() + XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) + } + + func testSparksListingAfterASyncEndsTheRestoreHold() async throws { + try restore(secondsAfterTheInvoice: 60) + leaveSparkUnsyncedAndEmpty() + await assertRefusedUntilSparkLists() + + // What Spark's synced event does once its sync reached it. + rail.syncedAfter = { _ in true } + await Bitkit.LightningService.shared.listRailSendsIfUnseeded() + // A later outage no longer holds the invoice: Spark's list showed no send of it, and it has expired. + rail.syncedAfter = { _ in false } + + XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) + await assertReachesTheNode() + } + + func testAnInvoiceSparkListedAsPaidAfterARestoreStaysHeldOverTheNodeAfterARelaunch() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + try restore(secondsAfterTheInvoice: 60) + leaveSparkUnsyncedAndEmpty() + // Spark's first sync since the restore lists the send the other device made. + rail.syncedAfter = { _ in true } + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .succeeded, preimage: nil)] + await Bitkit.LightningService.shared.listRailSendsIfUnseeded() + XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) + + // A relaunch: Spark hasn't synced this run. + rail.syncedAfter = { _ in false } + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("A node send of an invoice Spark listed as paid went out after a relaunch") + } catch { + XCTAssertFalse("\(error)".contains("nodeNotSetup"), "Reached the node: \(error)") + } + } + + func testAnInvoiceMadeBeforeARestoreIsRefusedOverAFundedSparkUntilSparkSyncs() async throws { + try restore(secondsAfterTheInvoice: 60) + // Spark is ready and funded, but no sync since the restore has listed its sends. + rail.syncedAfter = { _ in false } + rail.balanceSats = 100_000 + + await assertRefusedUntilSparkLists() + } + + private func sendOverSpark() async throws { + rail.balanceSats = 100_000 + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTAssertEqual(rail.sentBolt11, Self.invoice) + rail.sentBolt11 = nil + } + + private func assertRefusedAsDuplicate(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the send to be refused as a duplicate", file: file, line: line) + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)", file: file, line: line) + } + XCTAssertNil(rail.sentBolt11, file: file, line: line) + } + + /// Tests run without a node, so a payment that gets past the duplicate check fails as no node set up. + private func assertReachesTheNode(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the send to reach the missing node", file: file, line: line) + } catch { + XCTAssertFalse(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)", file: file, line: line) + XCTAssertTrue("\(error)".contains("nodeNotSetup"), "\(error)", file: file, line: line) + } + } + + func testCanSendLeavesRoomForTheSparkFee() { + rail.balanceSats = 100_000 + let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000) + + XCTAssertEqual(rail.sendableSats, sendable) + XCTAssertTrue(Bitkit.LightningService.shared.canSend(amountSats: sendable)) + XCTAssertFalse(Bitkit.LightningService.shared.canSend(amountSats: 100_000)) + } + + func testAnAmountlessInvoiceNeedsSparkToHoldFunds() { + rail.balanceSats = 0 + XCTAssertFalse(Bitkit.LightningService.shared.canSend(amountSats: 0), "An empty Spark wallet offered Spending for an amountless invoice") + + rail.balanceSats = 10000 + XCTAssertTrue(Bitkit.LightningService.shared.canSend(amountSats: 0)) + } + + func testSendableSatsFitsTheFeeBesideTheAmount() { + for balance: UInt64 in [0, 1, 400, 401, 9572, 100_000, 21_000_000] { + let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: balance) + let fee = (sendable * Bitkit.ChannelFreeRailFees.serviceFeePpm + 999_999) / 1_000_000 + XCTAssertLessThanOrEqual(sendable + fee, balance, "\(balance)") + let next = sendable + 1 + let nextFee = (next * Bitkit.ChannelFreeRailFees.serviceFeePpm + 999_999) / 1_000_000 + XCTAssertGreaterThan(next + nextFee, balance, "\(balance) leaves room for more") + } + XCTAssertEqual(Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000), 99750) + } + + func testDuplicatePaymentCheckFailsClosedWhenTheRailCannotListItsPayments() async { + rail.listError = NSError(domain: "spark", code: 1) + + let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") + let proof = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: "out") + + XCTAssertNil(attempted, "An unknown attempt must not read as never paid") + guard case .unavailable = proof else { return XCTFail("Expected an unavailable proof, got \(proof)") } + } + + func testDuplicatePaymentCheckSeesARailAttempt() async { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil)] + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [] } + defer { service.nodePayments = nodePayments } + + let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") + let fresh = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "new") + + XCTAssertEqual(attempted, true) + XCTAssertEqual(fresh, false) + } + + func testRetriedInvoiceSettlesFromItsLiveAttempt() { + let failed = Bitkit.ChannelFreeRailPayment(paymentHash: "aa", direction: .outbound, status: .failed, preimage: nil, timestamp: 20) + let retry = Bitkit.ChannelFreeRailPayment( + paymentHash: "AA", direction: .outbound, status: .succeeded, preimage: "pp", timestamp: 10, feeSats: 3 + ) + let received = Bitkit.ChannelFreeRailPayment(paymentHash: "aa", direction: .inbound, status: .pending, preimage: nil, timestamp: 30) + + let latest = Bitkit.ChannelFreeRailPayment.latestOutboundAttempt([failed, retry, received], paymentHash: "aa") + + XCTAssertEqual(latest, retry) + XCTAssertEqual(Bitkit.ChannelFreeRailPayment.latestOutboundAttempt([failed], paymentHash: "aa"), failed) + } + + func testTheSendSheetWaitsForTheChannelWhenSparkCanCoverOnlyTheAmountNotItsFee() { + XCTAssertFalse(SendSheet.sparkCanPay(amountSats: 100_000, isReady: true, balanceSats: 100_000)) + XCTAssertTrue(SendSheet.sparkCanPay(amountSats: 99750, isReady: true, balanceSats: 100_000)) + } + + func testTheSendSheetLetsSparkPayWhileTheNodeIsDown() { + let payable = SendSheet.sparkCanPay(amountSats: 99750, isReady: true, balanceSats: 100_000) + XCTAssertFalse( + SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: payable + ), + "A Lightning payment Spark can pay waited for the node to start" + ) + XCTAssertFalse(SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: true, hasAnyChannels: false, hasUsableChannels: false, sparkCanPay: payable + )) + + let unpayable = [ + SendSheet.sparkCanPay(amountSats: 99750, isReady: false, balanceSats: 100_000), + SendSheet.sparkCanPay(amountSats: 100_000, isReady: true, balanceSats: 100_000), + SendSheet.sparkCanPay(amountSats: 0, isReady: true, balanceSats: 100_000), + ] + for sparkCanPay in unpayable { + XCTAssertTrue(SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: sparkCanPay + )) + } + XCTAssertTrue(SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: false, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: payable + ), "An on-chain payment went ahead without the node") + XCTAssertTrue(SendSheet.waitsForNode( + isNodeRunning: true, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: false + )) + XCTAssertFalse(SendSheet.waitsForNode( + isNodeRunning: true, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: payable + )) + } + + func testInsufficientFundsInsideTheSparkSendIsARefusal() { + let failure = Bitkit.SparkManager.sendPaymentFailure(SdkError.InsufficientFunds(tokenIdentifier: nil)) + + XCTAssertTrue(failure is Bitkit.ChannelFreeRailSendRefused, "\(failure)") + XCTAssertFalse(Bitkit.SparkManager.sendPaymentFailure(SdkError.SparkError("operators")) is Bitkit.ChannelFreeRailSendRefused) + } + + func testSendableSatsDoesNotOverflowOnAHugeBalance() { + let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: UInt64.max) + + XCTAssertLessThan(sendable, UInt64.max) + XCTAssertGreaterThan(sendable, UInt64.max / 2) + } + + func testSparkFailureDoesNotEndAChannelRetryOfTheSameInvoice() async throws { + let hash = "a1a1" + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + Bitkit.LightningService.shared.noteAttempt(on: .ldk, paymentHash: hash) + let wallet = WalletViewModel() + var failures = 0 + wallet.addOnEvent(id: "failures") { event in + if case .paymentFailed = event { + failures += 1 + } + } + Bitkit.LightningService.shared.setEventCallback { wallet.notifyEventHandlers($0) } + + let wait = Task { try await wallet.waitForLightningPayment(hash: hash, timeoutSeconds: 5) } + // The event must reach the watcher, so it is emitted only once the watch has started. + var waited = 0 + while wallet.watchSendContinuations[hash] == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(wallet.watchSendContinuations[hash], "The send watch never started") + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: .unexpectedError)) + wallet.notifyEventHandlers(.paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 2000)) + let settled = try await wait.value + + XCTAssertEqual(settled.paymentHash, hash) + XCTAssertEqual(failures, 0, "No failure may reach the send screen") + wallet.removeOnEvent(id: "failures") + } + + func testChannelFailureDoesNotEndASparkRetryOfTheSameInvoice() async throws { + let hash = "b2b2" + Bitkit.LightningService.shared.noteAttempt(on: .channelFree, paymentHash: hash) + let wallet = WalletViewModel() + Bitkit.LightningService.shared.setEventCallback { wallet.notifyEventHandlers($0) } + let staleLdkFailure = LDKNode.Event.paymentFailed(paymentId: hash, paymentHash: hash, reason: .retriesExhausted) + + XCTAssertTrue(Bitkit.LightningService.shared.isStaleFailure(staleLdkFailure, from: .ldk), "The node loop drops it") + let wait = Task { try await wallet.waitForLightningPayment(hash: hash, timeoutSeconds: 5) } + try await Task.sleep(nanoseconds: 200_000_000) + Bitkit.LightningService.shared.emitChannelFreeRailEvent( + .paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 1000) + ) + let settled = try await wait.value + + XCTAssertEqual(settled.paymentHash, hash) + } + + func testSettledActivityKeepsItsStatusAgainstAStaleFailure() { + XCTAssertTrue(ActivityService.keepsLightningStatus(existing: .succeeded, update: .failed, isStaleRailFailure: false)) + XCTAssertTrue(ActivityService.keepsLightningStatus(existing: .pending, update: .failed, isStaleRailFailure: true)) + XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .pending, update: .failed, isStaleRailFailure: false)) + XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .failed, update: .succeeded, isStaleRailFailure: false)) + XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .pending, update: .succeeded, isStaleRailFailure: true)) + } + + func testRailRunsTheSubmissionCheckBeforeSending() async { + struct Expired: Error {} + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100) { throw Expired() } + XCTFail("Expected the submission check to stop the payment") + } catch { + XCTAssertTrue(error is Expired) + XCTAssertNil(rail.sentBolt11) + } + } + + func testProofSettlesFromTheRailOfTheLatestAttempt() { + typealias Lookup = PaykitLightningPaymentProofLookup + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .pending, latestAttempt: .channelFree), .pending) + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .succeeded(preimage: "pp"), latestAttempt: .channelFree), .succeeded(preimage: "pp")) + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .succeeded(preimage: "pp"), latestAttempt: nil), .succeeded(preimage: "pp")) + XCTAssertEqual(Lookup.combined(ldk: .pending, rail: .failed, latestAttempt: .ldk), .pending) + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .pending, latestAttempt: nil), .pending, "A retry may still be in flight") + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .unavailable, latestAttempt: nil), .unavailable, "Spark could hold a retry") + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .unknown, latestAttempt: nil), .failed) + XCTAssertEqual(Lookup.combined(ldk: .unknown, rail: .unknown, latestAttempt: nil), .unknown) + } + + func testQuickPayPrefersTheLiveAttemptAcrossRails() { + let record = QuickPayLedgerRecord(amountCents: 100, dayKey: "2026-11-06", invoicePaymentHash: "cc", paymentId: "cc") + let ldkFailed = QuickPayReconcileRow(paymentId: "cc", invoicePaymentHash: "cc", isOutboundBolt11: true, status: .failed) + let sparkPaid = QuickPayReconcileRow(railPayment: Bitkit.ChannelFreeRailPayment( + paymentHash: "cc", direction: .outbound, status: .succeeded, preimage: nil + )) + + XCTAssertEqual(QuickPaySpendStore.ledgerMatch(record: record, rows: [ldkFailed, sparkPaid])?.status, .succeeded) + } + + func testAmountlessPaykitEndpointGoesToSparkBelowAMillionInbound() { + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 50000, isPaykitEndpoint: true)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 999_999, isPaykitEndpoint: true)) + XCTAssertFalse(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 1_000_000, isPaykitEndpoint: true)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: nil, isPaykitEndpoint: true)) + } + + func testPlainReceiveKeepsTheChannelWhileItHasInbound() { + XCTAssertFalse(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 50000, isPaykitEndpoint: false)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 0, isPaykitEndpoint: false)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: nil, isPaykitEndpoint: false)) + XCTAssertFalse(Bitkit.LightningService.receivesOnRail(amountSats: 40000, readyInboundSats: 50000, isPaykitEndpoint: false)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: 60000, readyInboundSats: 50000, isPaykitEndpoint: true)) + } + + func testAllowanceAutoPayGoesOverSparkWhenTheNodeCantPay() throws { + let small = try PaykitAllowanceFixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440011", amount: "0.00001") + let large = try PaykitAllowanceFixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440012", amount: "0.001") + + let nodeCanPay = PaykitAllowanceManager.payable([small, large], nodeCanPay: true, sparkSendableSats: nil) + let sparkOnly = PaykitAllowanceManager.payable([small, large], nodeCanPay: false, sparkSendableSats: 5000) + let neither = PaykitAllowanceManager.payable([small, large], nodeCanPay: false, sparkSendableSats: nil) + + XCTAssertEqual(nodeCanPay.map(\.id), [small.id, large.id]) + XCTAssertEqual(sparkOnly.map(\.id), [small.id], "Spark pays what it covers; the rest waits") + XCTAssertTrue(neither.isEmpty) + } + + func testSparkReadyTriggersOnceUntilItDropsAgain() { + var transition = SparkReadinessTransition() + XCTAssertFalse(transition.becameReady(false)) + XCTAssertTrue(transition.becameReady(true)) + XCTAssertFalse(transition.becameReady(true)) + XCTAssertFalse(transition.becameReady(false)) + XCTAssertTrue(transition.becameReady(true)) + } +} + +private final class FakeRail: Bitkit.ChannelFreeLightningRail { + var isReady = true + var balanceSats: UInt64 = 9572 + var payments: [Bitkit.ChannelFreeRailPayment] = [] + var routingFeeSats: UInt64 = 0 + var estimateError: Error? + var listError: Error? + var sentBolt11: String? + var sentFeeLimitSats: UInt64? + var sendGate: (() async -> Void)? + var refusal: Bitkit.ChannelFreeRailSendRefused? + var refusalAfterSubmission: Bitkit.ChannelFreeRailSendRefused? + /// Runs while a listing is read, before it returns `payments`. + var onList: (() -> Void)? + /// Runs as a send is about to be submitted, before `beforeSubmission`. + var beforeSubmit: (() -> Void)? + /// Thrown after the send was submitted, as when the connection drops inside Spark's send. + var errorAfterSubmission: Error? + /// Whether the rail's last sync reached it, after the given seconds when there are any. + var syncedAfter: (UInt64?) -> Bool = { _ in true } + + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + "lnbcrt1fake" + } + + func send(bolt11: String, sats: UInt64?, maxFeeSats: UInt64?, beforeSubmission: @escaping () throws -> Void) async throws -> PaymentHash { + if let refusal { + throw refusal + } + beforeSubmit?() + try beforeSubmission() + if let refusalAfterSubmission { + throw refusalAfterSubmission + } + if let errorAfterSubmission { + throw errorAfterSubmission + } + await sendGate?() + sentFeeLimitSats = maxFeeSats + sentBolt11 = bolt11 + return "ee" + } + + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + if let estimateError { + throw estimateError + } + return routingFeeSats + } + + func hasSynced(after seconds: UInt64?) async -> Bool { + syncedAfter(seconds) + } + + func lightningPayments() async throws -> [Bitkit.ChannelFreeRailPayment] { + onList?() + if let listError { + throw listError + } + return payments + } +} diff --git a/BitkitTests/FeeItemAmountTests.swift b/BitkitTests/FeeItemAmountTests.swift new file mode 100644 index 000000000..4b56ce244 --- /dev/null +++ b/BitkitTests/FeeItemAmountTests.swift @@ -0,0 +1,21 @@ +@testable import Bitkit +import SwiftUI +import XCTest + +/// The Later row pairs a long subtitle with "≈ ₿ 99", so the amount must keep its full width. +@MainActor +final class FeeItemAmountTests: XCTestCase { + func testTheAmountKeepsItsWidthWhenTheRowIsNarrow() { + let view = FeeItemAmount(amount: 99, prefix: "≈") + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + let controller = UIHostingController(rootView: view) + + let ideal = controller.sizeThatFits(in: CGSize(width: 1000, height: 1000)) + let squeezed = controller.sizeThatFits(in: CGSize(width: 20, height: 1000)) + + XCTAssertGreaterThan(ideal.width, 20) + XCTAssertEqual(squeezed.width, ideal.width, accuracy: 0.5, "The amount was clipped or wrapped") + XCTAssertEqual(squeezed.height, ideal.height, accuracy: 0.5, "The amount wrapped onto more lines") + } +} diff --git a/BitkitTests/FundingHardwareTests.swift b/BitkitTests/FundingHardwareTests.swift new file mode 100644 index 000000000..36c466c19 --- /dev/null +++ b/BitkitTests/FundingHardwareTests.swift @@ -0,0 +1,26 @@ +@testable import Bitkit +import XCTest + +/// Where the Funding screen's hardware-wallet option starts Transfer to Spending once Spark is ready. +final class FundingHardwareTests: XCTestCase { + private func wallet(_ id: String, sats: UInt64) -> HwWallet { + HwWallet(id: id, walletId: id, name: id, model: nil, isConnected: false, balanceSats: sats) + } + + func testNoPairedWalletHasNoRoute() { + XCTAssertNil(FundingOptions.hardwareFundingRoute(wallets: [], hasSeenIntro: true)) + } + + func testFundedWalletIsChosenFirst() { + let wallets = [wallet("empty", sats: 0), wallet("funded", sats: 5000)] + XCTAssertEqual(FundingOptions.hardwareFundingRoute(wallets: wallets, hasSeenIntro: true), .spendingAmountHw(walletId: "funded")) + XCTAssertEqual(FundingOptions.hardwareFundingRoute(wallets: wallets, hasSeenIntro: false), .spendingIntroHw(walletId: "funded")) + } + + func testUnfundedWalletStillOpensItsTransfer() { + XCTAssertEqual( + FundingOptions.hardwareFundingRoute(wallets: [wallet("empty", sats: 0)], hasSeenIntro: true), + .spendingAmountHw(walletId: "empty") + ) + } +} diff --git a/BitkitTests/MoveToSparkActivityTests.swift b/BitkitTests/MoveToSparkActivityTests.swift new file mode 100644 index 000000000..322dbda73 --- /dev/null +++ b/BitkitTests/MoveToSparkActivityTests.swift @@ -0,0 +1,146 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +/// A move pays the wallet's own Spark invoice from the channels: the LDK send and the Spark receive share one hash. +final class MoveToSparkActivityTests: XCTestCase { + private let database = FileManager.default.temporaryDirectory + .appendingPathComponent("MoveToSparkActivityTests-\(UUID().uuidString)", isDirectory: true) + private let paymentHash = String(repeating: "ab", count: 32) + private let invoice = "lnbcrt50u1movetospark" + private let movedSats: UInt64 = 5000 + private let routingFeeSats: UInt64 = 3 + + override func setUp() async throws { + try await super.setUp() + snapshotAppDefaults("activityDetachedContacts") + _ = Bitkit.CoreService.shared + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: database, withIntermediateDirectories: true) + _ = try initDb(basePath: database.path) + } + + override func tearDown() async throws { + await drainCoreServiceQueue() + await repointCoreToAppStorage() + try? FileManager.default.removeItem(at: database) + try await super.tearDown() + } + + func testSparkReceiveAfterTheLdkSendKeepsOneOutgoingRow() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1000)]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testLdkSendAfterTheSparkReceiveReplacesItWithOneOutgoingRow() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1000)]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1002)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testSparkReceiveWaitingOnItsContactLookupKeepsTheLdkSendStoredMeanwhile() async throws { + let activity = Bitkit.CoreService.shared.activity + let send = ldkSend(timestamp: 1000) + // The receive's contact lookup is where it waits; the send lands in that gap. + activity.setPrivatePaykitContactResolvers( + invoice: { _ in + try? await Bitkit.CoreService.shared.activity.syncLdkNodePayments([send]) + return nil + }, + onchainAddresses: nil + ) + defer { activity.setPrivatePaykitContactResolvers(invoice: nil, onchainAddresses: nil) } + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testSucceededSparkReceiveSettlesAPendingLdkSend() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1000, status: .pending)]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testSparkReceiveSettlingTheSendTakesTheRoutingFeeFromTheLaterLdkUpdate() async throws { + let activity = Bitkit.CoreService.shared.activity + // A pending send has no fee yet; the receive's settle stamps the row after the node's later update. + try await activity.syncLdkNodePayments([ + payment(direction: .outbound, feeSats: nil, timestamp: 1000, status: .pending), + ]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1002)]) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testPendingLdkSendKeepsTheSucceededSparkReceiveUntilTheSendSucceeds() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1000)) + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1001, status: .pending)]) + + let rows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + XCTAssertEqual(rows.count, 1) + guard case let .lightning(row) = try XCTUnwrap(rows.first) else { + return XCTFail("Expected a lightning activity") + } + XCTAssertEqual(row.txType, .received) + XCTAssertEqual(row.status, .succeeded) + + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1002)]) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + private func assertOneOutgoingRowWithTheRoutingFee(file: StaticString = #filePath, line: UInt = #line) async throws { + let rows = try await Bitkit.CoreService.shared.activity.get(filter: .lightning, walletId: WalletScope.default) + XCTAssertEqual(rows.count, 1, file: file, line: line) + guard case let .lightning(row) = try XCTUnwrap(rows.first, file: file, line: line) else { + return XCTFail("Expected a lightning activity", file: file, line: line) + } + XCTAssertEqual(row.id, paymentHash, file: file, line: line) + XCTAssertEqual(row.txType, .sent, file: file, line: line) + XCTAssertEqual(row.status, .succeeded, file: file, line: line) + XCTAssertEqual(row.value, movedSats, file: file, line: line) + XCTAssertEqual(row.fee, routingFeeSats, file: file, line: line) + } + + private func ldkSend(timestamp: UInt64, status: LDKNode.PaymentStatus = .succeeded) -> LDKNode.PaymentDetails { + payment(direction: .outbound, feeSats: routingFeeSats, timestamp: timestamp, status: status) + } + + private func sparkReceive(timestamp: UInt64) -> LDKNode.PaymentDetails { + payment(direction: .inbound, feeSats: 0, timestamp: timestamp) + } + + private func payment( + direction: LDKNode.PaymentDirection, + feeSats: UInt64?, + timestamp: UInt64, + status: LDKNode.PaymentStatus = .succeeded + ) -> LDKNode.PaymentDetails { + LDKNode.PaymentDetails( + id: paymentHash, + kind: .bolt11( + hash: paymentHash, + preimage: String(repeating: "cd", count: 32), + secret: nil, + description: "Move to Spark", + bolt11: invoice + ), + amountMsat: movedSats * 1000, + feePaidMsat: feeSats.map { $0 * 1000 }, + direction: direction, + status: status, + latestUpdateTimestamp: timestamp + ) + } +} diff --git a/BitkitTests/MoveToSparkLateOutcomeTests.swift b/BitkitTests/MoveToSparkLateOutcomeTests.swift new file mode 100644 index 000000000..d63fab83d --- /dev/null +++ b/BitkitTests/MoveToSparkLateOutcomeTests.swift @@ -0,0 +1,106 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class MoveToSparkLateOutcomeTests: XCTestCase { + func testLateSuccessOfTheInFlightMoveShowsItMovedWithItsFee() { + let state = MoveToSparkState.inFlight(amountSats: 40000) + XCTAssertTrue(MoveToSparkManager.showsInFlightMove(state, generation: 3, moveGeneration: 3)) + + let emptied = ChannelDetails.mock(outboundCapacityMsat: 500_000) + let done = MoveToSparkManager.doneState(amountSats: 40000, feeSats: 12, channels: [emptied]) + XCTAssertEqual(done, .done(amountSats: 40000, feeSats: 12, closeChannel: emptied, hasRest: false)) + } + + func testLateSuccessOfAPartialMoveOffersTheRest() { + let channel = ChannelDetails.mock(outboundCapacityMsat: 50_000_000) + let done = MoveToSparkManager.doneState(amountSats: 25000, feeSats: 7, channels: [channel]) + XCTAssertEqual(done, .done(amountSats: 25000, feeSats: 7, closeChannel: channel, hasRest: true)) + } + + func testLateOutcomeOfAnOlderMoveLeavesTheScreen() { + XCTAssertFalse(MoveToSparkManager.showsInFlightMove(.inFlight(amountSats: 40000), generation: 4, moveGeneration: 3)) + XCTAssertFalse(MoveToSparkManager.showsInFlightMove(.quoting, generation: 3, moveGeneration: 3)) + } + + @MainActor + func testFailureRightAfterTheTimeoutFindsTheMoveInFlight() async throws { + let hash = "move-hash" + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + var steps: [String] = [] + let failed = expectation(description: "the late failure is reported") + let settled = try await WalletViewModel.settle( + paymentId: hash, + events: events, + timeout: .milliseconds(10), + onTimeout: { + steps.append("inFlight") + // The failure arrives in the gap before the caller resumes. + sink.yield(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + }, + onLateFailure: { + steps.append("failed") + failed.fulfill() + }, + onLateSuccess: { _ in steps.append("moved") } + ) + XCTAssertNil(settled) + await fulfillment(of: [failed], timeout: 2) + XCTAssertEqual(steps, ["inFlight", "failed"]) + } + + @MainActor + func testSuccessBeforeTheTimeoutSettlesTheMove() async throws { + let hash = "move-hash" + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + sink.yield(.paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 12000)) + var timedOut = false + let settled = try await WalletViewModel.settle( + paymentId: hash, + events: events, + timeout: .seconds(5), + onTimeout: { timedOut = true }, + onLateFailure: {}, + onLateSuccess: { _ in } + ) + XCTAssertEqual(settled?.feePaidSats, 12) + XCTAssertFalse(timedOut) + } + + @MainActor + func testEndedStreamStopsTheLateWatch() async throws { + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + var lateOutcomes = 0 + let ended = expectation(description: "the watch ends with its stream") + let settled = try await WalletViewModel.settle( + paymentId: "move-hash", + events: events, + timeout: .milliseconds(10), + onTimeout: {}, + onLateFailure: { lateOutcomes += 1 }, + onLateSuccess: { _ in lateOutcomes += 1 }, + onEnd: { ended.fulfill() } + ) + XCTAssertNil(settled) + // A wipe ends the stream of a move still waiting for its late outcome. + sink.finish() + await fulfillment(of: [ended], timeout: 2) + XCTAssertEqual(lateOutcomes, 0) + } + + @MainActor + func testWipeForgetsAnUnsettledMove() { + let manager = MoveToSparkManager() + let moveGeneration = manager.beginMove() + manager.showInFlight(amountSats: 40000, moveGeneration: moveGeneration) + manager.reset() + XCTAssertEqual(manager.state, .inFlight(amountSats: 40000), "leaving the screen keeps the unsettled move") + + manager.resetForWipe() + XCTAssertEqual(manager.state, .quoting) + manager.showInFlight(amountSats: 40000, moveGeneration: moveGeneration) + XCTAssertEqual(manager.state, .quoting, "the wiped move's late result leaves the next wallet's screen alone") + manager.reset() + XCTAssertFalse(MoveToSparkManager.showsInFlightMove(manager.state, generation: manager.generation, moveGeneration: moveGeneration)) + } +} diff --git a/BitkitTests/MoveToSparkMathTests.swift b/BitkitTests/MoveToSparkMathTests.swift new file mode 100644 index 000000000..dde5552d8 --- /dev/null +++ b/BitkitTests/MoveToSparkMathTests.swift @@ -0,0 +1,52 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class MoveToSparkMathTests: XCTestCase { + func testFirstCandidateLeavesRoomForAGuessedFee() { + let amount = MoveToSparkMath.firstCandidate(wantedSats: 100_000, limitSats: 100_000) + XCTAssertLessThan(amount, 100_000) + XCTAssertEqual(amount, 100_000 - 500 - 2) + } + + func testFirstCandidateKeepsASmallerTarget() { + XCTAssertEqual(MoveToSparkMath.firstCandidate(wantedSats: 50000, limitSats: 100_000), 50000) + } + + func testAmountAfterFeeFitsUnderTheLimit() { + XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 1_000_000, feeSats: 9000), 990_998) + XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 5, feeSats: 9000), 0) + } + + func testFeeCapIsTheQuotedFee() { + let feeSats = MoveToSparkMath.quotedFeeSats(estimateSats: 30) + XCTAssertEqual(feeSats, 32) + XCTAssertEqual(MoveToSparkMath.feeCapMsat(feeSats: feeSats), 32000) + // Amount and quoted fee together fit under the limit. + XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 1_000_000, feeSats: 30) + feeSats, 1_000_000) + } + + func testSmallerAmountHalvesUntilTheMinimum() { + XCTAssertEqual(MoveToSparkMath.smallerAmount(100_000), 50000) + XCTAssertNil(MoveToSparkMath.smallerAmount(MoveToSparkMath.minMoveSats)) + } + + func testMoveIsOfferedOnlyWhenTheMinimumFitsBesideTheFeeReserve() { + XCTAssertFalse(MoveToSparkMath.hasMovableBalance(limitSats: 1011)) + XCTAssertTrue(MoveToSparkMath.hasMovableBalance(limitSats: 1012)) + } + + func testMoveIsOfferedOnlyWithAUsableSpark() { + XCTAssertFalse(MoveToSparkMath.canMove(channels: [], isSparkUsable: true)) + XCTAssertFalse(MoveToSparkMath.canMove(channels: nil, isSparkUsable: false)) + let channel = ChannelDetails.mock(outboundCapacityMsat: 50_000_000) + XCTAssertFalse(MoveToSparkMath.canMove(channels: [channel], isSparkUsable: false)) + XCTAssertTrue(MoveToSparkMath.canMove(channels: [channel], isSparkUsable: true)) + } + + func testSparkTakesNoMoveDuringAnExit() { + XCTAssertTrue(MoveToSparkMath.isSparkUsable(isReady: true, isExiting: false)) + XCTAssertFalse(MoveToSparkMath.isSparkUsable(isReady: true, isExiting: true)) + XCTAssertFalse(MoveToSparkMath.isSparkUsable(isReady: false, isExiting: false)) + } +} diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 1fb261c21..1bd24052a 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -349,6 +349,61 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testSparkFundingThatGrewAfterApprovalIsRefusedBeforeBroadcast() async throws { + // The approved quote: 99000 reaches Spark for a 500 sat mining fee. + for (isMax, amount, fee, valid): (Bool, UInt64, UInt64?, Bool) in [ + (true, 99000, 500, true), (true, 99000, 400, true), + (true, 99500, 500, false), (true, 98500, 500, false), + (true, 99000, 501, false), (true, 99000, nil, false), + // A fixed amount must not spend more Savings than the confirmed Total either, so its fee is bound too. + (false, 99000, 500, true), (false, 99000, 400, true), + (false, 99000, 501, false), (false, 99000, nil, false), + ] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.amount = amount + sender.miningFee = fee + let context = OnchainSendFollowupContext(feeSats: 500, feeRate: 2, tags: [], contact: nil, createdAt: 0) + do { + _ = try await service.send(using: sender, address: "spark", amountSats: 99000, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: isMax, followupContext: context, + isSparkFunding: true) + XCTAssertTrue(valid, "A funding that sends \(amount) for a \(String(describing: fee)) fee needs a new approval") + XCTAssertEqual(sender.broadcasts, 1) + } catch let OnchainSendAttemptError.preDispatch(error) { + XCTAssertFalse(valid, "The approved funding must still go out") + XCTAssertEqual((error as? SparkFundingChanged)?.preparedFeeSats, fee, "Refused with \(error)") + XCTAssertEqual(sender.broadcasts, 0) + XCTAssertTrue(store.snapshot().isEmpty) + } + } + } + + /// The emergency exit's fee funding from savings never pays more mining fee than the user confirmed: a changed + /// input set asks again instead. + func testSparkExitFundingThatGrewAfterApprovalIsRefusedBeforeBroadcast() async throws { + for (fee, valid): (UInt64?, Bool) in [(500, true), (400, true), (501, false), (nil, false)] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.amount = 20000 + sender.miningFee = fee + sender.result = .accepted(txid: sender.txid) + do { + _ = try await SparkExitManager.sendFunding( + using: sender, attempts: service, address: "bcrt1qfunding", sats: 20000, satsPerVbyte: 2, feeSats: 500 + ) + XCTAssertTrue(valid, "A funding for a \(String(describing: fee)) fee needs a new approval") + XCTAssertEqual(sender.broadcasts, 1) + } catch let OnchainSendAttemptError.preDispatch(error) { + XCTAssertFalse(valid, "The approved funding must still go out") + XCTAssertEqual((error as? SparkFundingChanged)?.preparedFeeSats, fee, "Refused with \(error)") + XCTAssertEqual(sender.broadcasts, 0) + } + } + } + func testPreparedReceiptIsDurableBeforeNativeDispatch() async throws { for max in [false, true] { let store = MemoryAttemptStore() diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 5a532e8a0..b1a7fc8cd 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -154,7 +154,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest() @@ -182,7 +182,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) await harness.sdk.setBeforeAcceptance { await MainActor.run { manager.pause(identity: Fixtures.identityKey) } } @@ -202,7 +202,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { ready } + payableNow: { ready ? $0 : [] } ) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest() @@ -230,7 +230,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let covered = try Fixtures.paymentRequest() @@ -256,7 +256,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { false } + payableNow: { _ in [] } ) await manager.activate(identity: Fixtures.identityKey) let aboveLimit = try Fixtures.paymentRequest(amount: "0.0001") @@ -311,7 +311,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest() @@ -528,7 +528,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let pauseWithoutPayerAllowance = manager.automaticPaymentPause @@ -554,7 +554,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let pauseShown = manager.automaticPaymentPause @@ -581,7 +581,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) await harness.sdk.setAccountingError(AllowanceMockError.unsupported) @@ -607,7 +607,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) @@ -1837,6 +1837,26 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertNil(blocked) } + func testALaterSettlementClosesTheOpenManualAttemptOfItsRequest() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.onchainIdentifier) + let openAttempt = try XCTUnwrap(attemptId) + await harness.executor.finishManualPayment(attemptId: openAttempt, outcome: .unknown) + + await harness.executor.settleManualPayment(requestId: request.id, outcome: .failed) + await harness.executor.settleManualPayment(requestId: request.id, outcome: .succeeded) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [ + Paykit.PaymentOutcomeReport(attemptId: openAttempt, outcome: .unknown), + Paykit.PaymentOutcomeReport(attemptId: openAttempt, outcome: .failed), + ], "Only the open attempt settles, once") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.failed]) + } + func testRecoverySettlesAManualLightningPaymentRestoredFromABackupTakenBeforeTheSend() async throws { let harness = AllowanceHarness() await harness.executor.activate(identity: Fixtures.identityKey) @@ -2546,7 +2566,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { allowedPaymentEndpointIdentifiers: nil ) await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: uncapped, proposedByMe: false)]) - let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, payableNow: { $0 }) await manager.activate(identity: Fixtures.identityKey) XCTAssertEqual(manager.entries, []) @@ -2558,7 +2578,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { func testManagerLeavesARequestWithUnknownCreationTimeManual() async throws { let harness = AllowanceHarness() try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) - let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, payableNow: { $0 }) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest(createdAt: "not-a-timestamp") XCTAssertNil(request.createdAt) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index f76144cf9..5552f93fa 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1778,6 +1778,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { NodeError.InvalidInvoice(message: "invalid"), NodeError.InvalidAmount(message: "invalid"), Bitkit.AppError(error: NodeError.PaymentSendingFailed(message: "no route")), + Bitkit.ChannelFreeRailSendRefused(reason: "Spark routing fee exceeds the limit"), + Bitkit.AppError(error: Bitkit.ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment")), ] for error in errors { let record = try paymentRequestRecord() @@ -1932,6 +1934,211 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testSparkWithdrawalSubmitsItsTransactionIdAsTheOnchainProof() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "cd", count: 32) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) + let completed = await service.completeSparkOnchainPayment(requestId: request.id, identity: identity, txid: txid) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + XCTAssertTrue(completed, "Spark's withdrawal is no node send attempt, yet its txid completes the proof") + let retried = await service.completeSparkOnchainPayment(requestId: request.id, identity: identity, txid: txid) + XCTAssertTrue(retried, "A journal retry after the proof was saved must not keep the withdrawal open forever") + let lastSubmission = await sdk.lastSubmission() + let submission = try XCTUnwrap(lastSubmission) + XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + } + + func testSparkWithdrawalThatNeverWentOutDropsItsStartedProof() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) + await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + } + + func testAnUnsentSparkWithdrawalKeepsItsProofUntilItsAllowanceAttemptIsSettled() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) + + let unsettled = await service.failSparkOnchainPayment(requestId: request.id, identity: identity, settleAllowance: { false }) + XCTAssertEqual(unsettled, .failed, "A failed allowance write keeps the journal open for a retry") + let kept = await store.snapshot() + XCTAssertEqual(kept.count, 1, "The proof stays until its allowance attempt is settled, so a restart still settles it") + + var proofsWhileSettling: Int? + let released = await service.failSparkOnchainPayment(requestId: request.id, identity: identity, settleAllowance: { + proofsWhileSettling = await store.snapshot().count + return true + }) + XCTAssertEqual(released, .released) + XCTAssertEqual(proofsWhileSettling, 1, "The allowance attempt is settled before its proof is removed") + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + } + + func testAnUnstartedSparkWithdrawalSettlesNoAllowanceAttempt() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: PaymentProofMemoryStore()) + var settled = false + + let release = await service.failSparkOnchainPayment(requestId: request.id, identity: identity, settleAllowance: { + settled = true + return true + }) + + XCTAssertEqual(release, .nothingStarted) + XCTAssertFalse(settled, "Only a started Spark proof has an allowance attempt to settle") + } + + func testClosingASparkJournalLeavesADirectOnchainProofForTheSameRequest() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + // A kill left the Spark journal open with only a prepared proof, and the request was then paid directly. + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + + let remaining = await store.snapshot() + XCTAssertEqual(remaining.count, 1, "The direct send's proof is not the Spark withdrawal's to drop") + } + + func testAnUnsentSparkWithdrawalProofThatCantBeDroppedSaysSo() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) + await store.failNextSave() + let failed = await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + + XCTAssertEqual(failed, .failed, "The journal stays open only when the caller learns the proof is still there") + let remaining = await store.snapshot() + XCTAssertEqual(remaining.count, 1) + let retried = await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + XCTAssertEqual(retried, .released) + let afterRetry = await store.snapshot() + XCTAssertTrue(afterRetry.isEmpty) + } + + func testAnUnsentSparkWithdrawalGivesBackItsPrivateListAfterARelaunch() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let privatePaykit = PrivatePaykitService() + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: sdk, store: store) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 6), attemptId: UUID()) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: 6, sparkWithdrawal: true + ) + // Spark's journal closes the withdrawal as never sent after the app restarted. + let relaunched = paymentProofService(sdk: sdk, store: store) + await relaunched.failSparkOnchainPayment(requestId: request.id, identity: identity, privatePaykitService: privatePaykit) + + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + let released = await privatePaykit.consumedPaymentListVersion(publicKey: request.counterparty) + XCTAssertEqual(released, 6) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + } + + func testAKillBeforeTheSparkProofStartsGivesBackTheListAfterARelaunch() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let privatePaykit = PrivatePaykitService() + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: sdk, store: store) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 6), attemptId: UUID()) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + + // Killed after the journal was written, before the proof was marked started: only the journal knows the list. + let relaunched = paymentProofService(sdk: sdk, store: store) + let release = await relaunched.failSparkOnchainPayment( + requestId: request.id, + identity: identity, + unstartedPaymentList: SparkContactWithdrawal.PaymentList(version: 7, previousVersion: 6), + privatePaykitService: privatePaykit + ) + + XCTAssertEqual(release, .nothingStarted) + let released = await privatePaykit.consumedPaymentListVersion(publicKey: request.counterparty) + XCTAssertEqual(released, 6, "The unsent request can be paid from its private list again") + } + + func testAnUnsentSparkWithdrawalLeavesAListAnotherPaymentConsumedSince() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let privatePaykit = PrivatePaykitService() + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: nil, sparkWithdrawal: true + ) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 8), attemptId: UUID()) + await service.failSparkOnchainPayment(requestId: request.id, identity: identity, privatePaykitService: privatePaykit) + + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + let consumed = await privatePaykit.consumedPaymentListVersion(publicKey: request.counterparty) + XCTAssertEqual(consumed, 8) + } + func testOnchainCompletionDoesNotWaitForProofDelivery() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -3448,6 +3655,10 @@ final class PaykitPaymentProofServiceTests: XCTestCase { return (format(start), format(end)) } + private func listContext(version: UInt64) -> PrivatePaykitPaymentContext { + PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: version) + } + private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index c723c26d4..ed48c2f7b 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5133,6 +5133,40 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestPresentation(request)) } + func testResolutionFailureEndsARequestedPresentationWithAToast() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + let feedback = try XCTUnwrap( + IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: manager + ) + ) + + XCTAssertNil(manager.requestedPresentationId) + XCTAssertFalse(manager.isWaitingForPresentationRetry(request)) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertNotNil(feedback.toast) + } + + func testResolutionFailureLeavesAutomaticPresentationToRetry() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + + XCTAssertNil( + IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: manager + ) + ) + } + func testPendingPrivateLinkRecoveryStopsAutomaticPresentationWithoutToast() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -7146,6 +7180,24 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertFalse(manager.isApprovedForPayment(request)) } + func testDismissedRequestIsNotPresentedAgainWhenTheSdkListsItAsProposedAgain() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + XCTAssertTrue(manager.markPresentedIfPending(request)) + + try await manager.dismiss(request) + try await sdk.setRecords([paymentRequestRecord(state: .rejected)]) + await manager.refresh() + XCTAssertTrue(manager.pendingRequests.isEmpty) + + try await sdk.setRecords([paymentRequestRecord()]) + await manager.refresh() + XCTAssertEqual(manager.pendingRequests.map(\.id), [request.id]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + func testFailedRejectionDropsRequestRemovedFromAuthoritativeQueue() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 39ec92f12..1c58431b3 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -927,6 +927,25 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertThrowsError(try restored(object)) } + func testSparkFundingBackupKeepsItsTransferFlag() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) + let wire = try XCTUnwrap(envelope.paykitPaymentState?.activeOnchainAttempt) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + object["requestId"] = nil + object["payerIdentity"] = nil + object["status"] = "pending" + let plain = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object)) + var funding = try plain.restored(wallet: Self.goldenWallet(), proofs: []).0 + XCTAssertFalse(funding.isSparkFunding) + funding.sparkFunding = true + + let backup = try PaykitPaymentStateBackup.ActiveOnchainAttempt(funding, wallet: Self.goldenWallet()) + let decoded = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, from: JSONEncoder().encode(backup)) + + XCTAssertTrue(try decoded.restored(wallet: Self.goldenWallet(), proofs: []).0.isSparkFunding) + } + func testCandidateFeeRatesRoundtripAndRejectForeignOrMalformedRates() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) diff --git a/BitkitTests/PaykitRouteSettlingTests.swift b/BitkitTests/PaykitRouteSettlingTests.swift new file mode 100644 index 000000000..b6c6dc4e3 --- /dev/null +++ b/BitkitTests/PaykitRouteSettlingTests.swift @@ -0,0 +1,87 @@ +@testable import Bitkit +import BitkitCore +import Paykit +import XCTest + +final class PaykitRouteSettlingTests: XCTestCase { + private let onchain = PaykitEndpoint(methodId: "btc-regtest-p2wpkh", value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x") + + @MainActor + func testASparkLegIsRefusedWhileAnEarlierContactWithdrawalSettles() async { + snapshotAppDefaultsDomain() + SparkManager.pendingContactWithdrawal = SparkContactWithdrawal( + address: "bcrt1qearlier", + amountSats: 5000, + contact: "earlier", + startedAt: 1 + ) + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + let onchain = onchain + var quoted = false + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true), + amountSats: 9000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil + ) { route, amountSats, source, lightningSats, onchainSats in + try await PaykitRouteService.route( + route, amountSats: amountSats, source: source, lightningSpendableSats: lightningSats, onchainSpendableSats: onchainSats + ) { _, amountSats in + quoted = true + return [ServiceRouteLeg( + service: SparkManager.routeService, + endpoint: onchain, + deliveredSat: amountSats, + debitSat: amountSats + 300, + atomicity: "trusted", + validUntilUnix: 0 + )] + } + } + + XCTAssertEqual(outcome, .refused, "Spark takes one contact withdrawal at a time, so Confirm must not offer a second") + XCTAssertFalse(quoted, "No Spark leg is quoted while the earlier withdrawal's journal is open") + XCTAssertEqual(ToastWindowManager.shared.currentToast?.accessibilityIdentifier, "PaykitRouteSettlingToast") + } + + @MainActor + func testAShortBalanceNamesItselfBeforeASettlingWithdrawal() async { + snapshotAppDefaultsDomain() + SparkManager.pendingContactWithdrawal = SparkContactWithdrawal( + address: "bcrt1qearlier", + amountSats: 5000, + contact: "earlier", + startedAt: 1 + ) + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true), + amountSats: 20000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil + ) { route, amountSats, source, lightningSats, onchainSats in + try await PaykitRouteService.route( + route, amountSats: amountSats, source: source, lightningSpendableSats: lightningSats, onchainSpendableSats: onchainSats + ) { _, _ in [] } + } + + XCTAssertEqual(outcome, .refused) + XCTAssertEqual( + ToastWindowManager.shared.currentToast?.accessibilityIdentifier, + "PaykitRouteBalanceToast", + "No balance covers 20,000, whatever an earlier withdrawal does, as on Android" + ) + } +} diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift new file mode 100644 index 000000000..e5664cba6 --- /dev/null +++ b/BitkitTests/PaykitRouteTests.swift @@ -0,0 +1,1491 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import Paykit +import XCTest + +final class PaykitRouteTests: XCTestCase { + private let onchain = PaykitEndpoint(methodId: "btc-regtest-p2wpkh", value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x") + private let lightning = PaykitEndpoint( + methodId: "btc-lightning-lnurl", + value: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns" + ) + + private func sparkLeg(amountSats: UInt64, feeSats: UInt64) -> ServiceRouteLeg { + ServiceRouteLeg( + service: SparkManager.routeService, + endpoint: onchain, + deliveredSat: amountSats, + debitSat: amountSats + feeSats, + atomicity: "trusted", + validUntilUnix: UInt64(Date().timeIntervalSince1970) + 600 + ) + } + + // MARK: - Request + + func testSpendingOffersEveryEndpointAndSavingsOnlyTheOnchainOnes() { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + let leg = sparkLeg(amountSats: 20000, feeSats: 300) + + let spending = PaykitRouteService.request( + route: route, amountSats: 20000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 90000, + serviceLegs: [leg] + ) + let savings = PaykitRouteService.request( + route: route, amountSats: 20000, source: .savings, lightningSpendableSats: 50000, onchainSpendableSats: 90000, + serviceLegs: [leg] + ) + + XCTAssertEqual(spending.contactEndpoints, [lightning, onchain]) + XCTAssertEqual(spending.lightningSpendableSat, 50000) + XCTAssertEqual(spending.onchainSpendableSat, 0) + XCTAssertEqual(spending.serviceLegs, [leg]) + XCTAssertEqual(savings.contactEndpoints, [onchain]) + XCTAssertEqual(savings.lightningSpendableSat, 0) + XCTAssertEqual(savings.onchainSpendableSat, 90000) + XCTAssertTrue(savings.serviceLegs.isEmpty) + } + + // MARK: - Router outcomes + + func testRouterPaysTheLnurlEndpointItPicksFromSpending() throws { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: false) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20, source: .spending, lightningSpendableSats: 5000, onchainSpendableSats: 0, + serviceLegs: [] + ), + network: .regtest + ) + + XCTAssertEqual(picked, .direct(endpoint: lightning)) + } + + func testRouterPaysTheOnchainEndpointItPicksFromSavings() throws { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: false) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20000, source: .savings, lightningSpendableSats: 0, onchainSpendableSats: 90000, + serviceLegs: [] + ), + network: .regtest + ) + + XCTAssertEqual(picked, .direct(endpoint: onchain)) + } + + func testRouterTakesTheSparkLegToAnOnchainOnlyContactFromSpending() throws { + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + let leg = sparkLeg(amountSats: 20000, feeSats: 300) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0, + serviceLegs: [leg] + ), + network: .regtest + ) + + XCTAssertEqual(picked, .service(leg: leg)) + var routed = route + routed.serviceLeg = leg + XCTAssertEqual(routed.totalFeeSat, 300) + XCTAssertEqual(routed.deliveredSat, 20000) + } + + func testRouterFindsNoneWithoutABalanceThatReachesTheContact() throws { + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: false) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20000, source: .savings, lightningSpendableSats: 0, onchainSpendableSats: 5000, + serviceLegs: [] + ), + network: .regtest + ) + + guard case .none = picked else { return XCTFail("Expected no route, got \(picked)") } + XCTAssertTrue(PaykitRouteService.isBalanceShort(amountSats: 20000, lightningSpendableSats: 0, onchainSpendableSats: 5000)) + XCTAssertFalse(PaykitRouteService.isBalanceShort(amountSats: 20000, lightningSpendableSats: 0, onchainSpendableSats: 90000)) + } + + // MARK: - Spark leg offer + + func testSparkLegIsOfferedForAnOnchainContactWhileSparkCanRoute() { + let onchainOnly = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + + XCTAssertEqual( + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: true)?.offersSparkLeg, + true + ) + XCTAssertEqual( + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: false)?.offersSparkLeg, + false + ) + let usdtOnly = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: [ + Self.endpoint(.usdtArbitrum, "0x0000000000000000000000000000000000000001"), + ]) + XCTAssertNil(PaykitRouteService.routeUi(for: usdtOnly, canRouteThroughSpark: true)) + } + + func testASparkWithdrawalIsWeighedAgainstSparksBalanceNotTheLightningLimit() { + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + var quoted = route + quoted.serviceLeg = sparkLeg(amountSats: 999_000, feeSats: 500) + + // Lightning's limit of 997,506 sats never caps a withdrawal from Spark's 1,000,000. + XCTAssertEqual( + PaykitRouteService.sparkWithdrawalValidity(route: route, amountSats: 999_000, sparkBalanceSats: 1_000_000), + true, + "continue asks spark for a quote" + ) + XCTAssertEqual( + PaykitRouteService.sparkWithdrawalValidity(route: quoted, amountSats: 999_000, sparkBalanceSats: 1_000_000), + true, + "the quoted debit fits spark's balance" + ) + XCTAssertEqual( + PaykitRouteService.sparkWithdrawalValidity(route: quoted, amountSats: 999_000, sparkBalanceSats: 999_499), + false, + "a debit past spark's balance can't be sent" + ) + XCTAssertNil( + PaykitRouteService.sparkWithdrawalValidity(route: route, amountSats: 1_000_001, sparkBalanceSats: 1_000_000), + "past spark's balance, lightning's limit decides" + ) + XCTAssertNil( + PaykitRouteService.sparkWithdrawalValidity( + route: PaykitRouteUi(endpoints: [onchain], offersSparkLeg: false), + amountSats: 999_000, + sparkBalanceSats: 1_000_000 + ), + "without a spark leg, lightning's limit decides" + ) + } + + // MARK: - Contact withdrawal journal + + func testJournaledWithdrawalMatchesOnlyItsOwnAttempt() { + let other = Self.withdrawal(id: "other", to: "bcrt1qother", amountSats: 20000, timestamp: 1001) + let earlier = Self.withdrawal(id: "earlier", to: onchain.value, amountSats: 20000, timestamp: 2000) + let sent = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + var pending = SparkContactWithdrawal( + address: onchain.value, + amountSats: 20000, + contact: "contact", + startedAt: 1000, + earlierPaymentIds: SparkManager.withdrawalIds(to: onchain.value, amountSats: 20000, in: [other, earlier]) + ) + + XCTAssertEqual(pending.earlierPaymentIds, ["earlier"]) + XCTAssertEqual(SparkManager.matchingWithdrawal(pending, in: [other, earlier, sent])?.id, "sent") + XCTAssertNil( + SparkManager.matchingWithdrawal(pending, in: [other, earlier]), + "An earlier withdrawal to the same address is never this attempt" + ) + + pending.paymentId = "sent" + XCTAssertNil(SparkManager.matchingWithdrawal(pending, in: [other, earlier]), "Once Spark returned the attempt, only that payment settles it") + XCTAssertEqual(SparkManager.matchingWithdrawal(pending, in: [earlier, sent])?.id, "sent") + } + + func testUnlistedWithdrawalIsDroppedOnlyAfterTheExpiry() { + var pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000) + let expiry = SparkManager.contactWithdrawalExpirySecs + + XCTAssertFalse( + SparkManager.canCloseUnlisted(pending, now: 1001), + "A sync right after the send may not list the withdrawal yet" + ) + XCTAssertTrue(SparkManager.canCloseUnlisted(pending, now: 1000 + expiry)) + + pending.paymentId = "sent" + XCTAssertFalse( + SparkManager.canCloseUnlisted(pending, now: 1000 + expiry * 10), + "A withdrawal Spark returned went out and is never dropped as unsent" + ) + } + + func testAWithdrawalSparkListsWithoutDetailsIsMatchedByItsKeyAndKeptOpen() { + let pending = SparkContactWithdrawal( + address: onchain.value, + amountSats: 20000, + contact: "contact", + startedAt: 1000, + idempotencyKey: "key" + ) + var listed = Self.withdrawal(id: "key", to: onchain.value, amountSats: 20000, timestamp: 1001) + listed.status = .pending + listed.details = nil + let past = 1000 + SparkManager.contactWithdrawalExpirySecs * 10 + + let matched = SparkManager.matchingWithdrawal(pending, in: [listed]) + XCTAssertEqual(matched?.id, "key", "Spark keeps the send's idempotency key as the payment's id") + XCTAssertFalse( + SparkManager.canCloseUnlisted(pending, listed: matched, now: past), + "A withdrawal Spark lists without its details may still pay out, so its journal stays open" + ) + + listed.status = .failed + XCTAssertTrue( + SparkManager.canCloseUnlisted(pending, listed: SparkManager.matchingWithdrawal(pending, in: [listed]), now: past), + "One Spark failed before it had withdrawal details never pays out" + ) + } + + func testAFailedWithdrawalStaysOpenAfterASyncThatNeverReachedSpark() async { + var allowedClears: [Bool] = [] + // The sync call returns, but Spark keeps its failed sync in `lastSyncError`, so it never synced after the failure. + await SparkManager.settleAfterFailedWithdrawal( + failedAt: 1000, + syncWallet: {}, + hasSynced: { _ in false }, + settle: { allowedClears.append($0) } + ) + XCTAssertEqual(allowedClears, [false], "A sync that never reached Spark can't show the withdrawal was never sent") + + var syncedAfter: UInt64? + await SparkManager.settleAfterFailedWithdrawal( + failedAt: 1000, + syncWallet: {}, + hasSynced: { syncedAfter = $0; return true }, + settle: { allowedClears.append($0) } + ) + XCTAssertEqual(syncedAfter, 1000) + XCTAssertEqual(allowedClears, [false, true]) + + // Spark's `.synced` event also follows a sync that never reached it. + await SparkManager.settleAfterSync(since: 900, hasSynced: { syncedAfter = $0; return false }, settle: { allowedClears.append($0) }) + XCTAssertEqual(syncedAfter, 900) + XCTAssertEqual(allowedClears, [false, true, false], "A synced event alone can't show the withdrawal was never sent") + } + + func testJournalStaysOpenUntilEveryLocalRecordIsSaved() async { + let saved = await SparkManager.recordWithdrawalLocally(saveProof: { true }, saveActivity: { true }, saveTags: { true }) + XCTAssertTrue(saved) + + var tagsTried = false + let activityFailed = await SparkManager.recordWithdrawalLocally( + saveProof: { true }, + saveActivity: { false }, + saveTags: { tagsTried = true; return true } + ) + XCTAssertFalse(activityFailed, "A failed activity write keeps the journal for a retry") + XCTAssertFalse(tagsTried, "Tags wait for the activity they belong to") + + var activityTried = false + let proofFailed = await SparkManager.recordWithdrawalLocally( + saveProof: { false }, + saveActivity: { activityTried = true; return true }, + saveTags: { true } + ) + XCTAssertFalse(proofFailed, "A proof that wasn't saved keeps the journal for a retry") + XCTAssertTrue(activityTried, "The activity is still written when the proof save fails") + + let tagsFailed = await SparkManager.recordWithdrawalLocally(saveProof: { true }, saveActivity: { true }, saveTags: { false }) + XCTAssertFalse(tagsFailed) + } + + func testUnsentWithdrawalKeepsItsJournalUntilTheProofIsReleased() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + SparkManager.pendingContactWithdrawal = pending + + var failedAllowances: [SparkContactWithdrawal.PaykitRequest] = [] + await SparkManager.closeUnsentWithdrawal( + pending, + releaseProof: { _, _ in .failed }, + failAllowance: { failedAllowances.append($0); return true } + ) + XCTAssertEqual(SparkManager.pendingContactWithdrawal, pending, "A proof that wasn't released keeps the journal, so the next sync retries it") + XCTAssertEqual(failedAllowances, [], "The attempt stays unknown while the journal is still open") + + var released: SparkContactWithdrawal.PaykitRequest? + await SparkManager.closeUnsentWithdrawal( + pending, + releaseProof: { request, failAllowance in + released = request + return await failAllowance() ? .released : .failed + }, + failAllowance: { failedAllowances.append($0); return true } + ) + XCTAssertEqual(released, request) + XCTAssertEqual(failedAllowances, [request], "An unsent withdrawal fails its allowance attempt, so the ledger lets the request be paid again") + XCTAssertNil(SparkManager.pendingContactWithdrawal) + + // A kill before the proof started leaves no Spark attempt, so a direct send's attempt for the request stays. + SparkManager.pendingContactWithdrawal = pending + failedAllowances = [] + await SparkManager.closeUnsentWithdrawal( + pending, + releaseProof: { _, _ in .nothingStarted }, + failAllowance: { failedAllowances.append($0); return true } + ) + XCTAssertEqual(failedAllowances, [], "Only the Spark withdrawal's own allowance attempt fails") + XCTAssertNil(SparkManager.pendingContactWithdrawal) + } + + func testLegReportsItsAllowanceAttemptWithTheSendsOutcome() async { + struct Refused: Error {} + struct Lost: Error {} + var events: [String] = [] + func pay( + authorize: @escaping () async throws -> Void = {}, + send: @escaping (() async throws -> Void) async throws -> String + ) async -> String? { + try? await PaykitRouteService.payReportingAllowance( + beginAllowance: { events.append("begin"); return "attempt" }, + authorize: { events.append("authorize"); try await authorize() }, + markSending: { events.append("sending \($0)") }, + finishAllowance: { events.append("finish \($0) \($1) \($2 ?? "-")") }, + pay: send + ) + } + + let txid = await pay { beforeSend in try await beforeSend(); return "txid" } + XCTAssertEqual(txid, "txid") + XCTAssertEqual(events, ["begin", "authorize", "sending attempt", "finish attempt succeeded txid"]) + + events = [] + _ = await pay(authorize: { throw Refused() }) { beforeSend in + do { try await beforeSend() } catch { throw SparkWithdrawalNotSent(underlying: error) } + return "txid" + } + XCTAssertEqual(events, ["begin", "authorize", "finish attempt failed -"], "A send Spark refused paid nothing") + + events = [] + _ = await pay { beforeSend in + try await beforeSend() + throw SparkWithdrawalNotSent(underlying: Refused()) + } + XCTAssertEqual( + events, + ["begin", "authorize", "sending attempt", "finish attempt failed -"], + "A withdrawal Spark returned as failed paid nothing either" + ) + + events = [] + _ = await pay { beforeSend in try await beforeSend(); throw Lost() } + XCTAssertEqual( + events, + ["begin", "authorize", "sending attempt", "finish attempt unknown -"], + "A send that may have gone out stays unknown until recovery settles it" + ) + } + + func testAWithdrawalSparkReturnsAsFailedEndsAsARefusal() throws { + let sent = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + XCTAssertEqual(try SparkManager.sentWithdrawalId(sent), "tx-sent") + + var failed = sent + failed.status = .failed + XCTAssertThrowsError(try SparkManager.sentWithdrawalId(failed), "A failed withdrawal paid nothing, so it never shows success") { + XCTAssertTrue($0 is SparkWithdrawalNotSent, "A refusal releases the request's proof, private list and allowance attempt") + } + } + + @MainActor + func testAClosedJournalTellsThePendingScreenHowItsWithdrawalEnded() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + SparkManager.pendingContactWithdrawal = pending + var resolutions: [SparkContactWithdrawalResolution] = [] + let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } + defer { subscription.cancel() } + + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _, _ in .released }, failAllowance: { _ in true }) + + let unsent = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: nil) + XCTAssertEqual(resolutions, [unsent]) + XCTAssertEqual(SparkManager.lastContactWithdrawalResolution, unsent, "A screen that opens later still learns it") + guard case .failure = SendPendingScreen.sparkWithdrawalRoute(for: unsent, attempt: pending.attempt) else { + return XCTFail("A withdrawal that never went out can be tried again") + } + XCTAssertEqual( + SendPendingScreen.sparkWithdrawalRoute( + for: SparkContactWithdrawalResolution(attempt: pending.attempt, txId: "txid"), + attempt: pending.attempt + ), + .success(paymentId: "txid") + ) + let other = SparkWithdrawalAttempt(address: onchain.value, amountSats: 30000, requestId: request.id) + XCTAssertNil(SendPendingScreen.sparkWithdrawalRoute(for: unsent, attempt: other), "Another withdrawal's close leaves the screen") + } + + @MainActor + func testAWithdrawalsJournalIsWrittenBeforeItsProofStarts() async { + struct Refused: Error {} + var steps: [String] = [] + + try? await SparkManager.startJournaledWithdrawal( + writeJournal: { steps.append("journal") }, + beforeSend: { steps.append("proof started") }, + closeUnsent: { steps.append("closed") } + ) + XCTAssertEqual(steps, ["journal", "proof started"], "A kill after the proof starts leaves a journal that releases it") + + steps = [] + do { + try await SparkManager.startJournaledWithdrawal( + writeJournal: { steps.append("journal") }, + beforeSend: { steps.append("proof started"); throw Refused() }, + closeUnsent: { steps.append("closed") } + ) + XCTFail("A refusal before the send must reach the caller") + } catch { + XCTAssertTrue(error is SparkWithdrawalNotSent) + } + XCTAssertEqual(steps, ["journal", "proof started", "closed"], "A refusal closes the journal, releasing what it started") + } + + @MainActor + func testAWithdrawalWhoseQuoteExpiresBeforeItSendsNeverLeaves() async { + var now = Date(timeIntervalSince1970: 1_800_000_000) + var steps: [String] = [] + do { + try await SparkManager.startJournaledWithdrawal( + writeJournal: { steps.append("journal") }, + beforeSend: { + // The quote expires while the allowance and the request are checked. + now = now.addingTimeInterval(120) + steps.append("proof started") + }, + closeUnsent: { steps.append("closed") }, + paymentDeadline: PaykitPreciseInstant(date: now.addingTimeInterval(60)), + now: { now } + ) + XCTFail("An expired quote must stop the send") + } catch let error as SparkWithdrawalNotSent { + XCTAssertEqual(error.underlying as? PaykitPaymentRequestError, .requestExpired) + } catch { + XCTFail("Unexpected error \(error)") + } + XCTAssertEqual(steps, ["journal", "proof started", "closed"], "The refusal closes the journal, releasing the started proof") + } + + func testAPendingWithdrawalStaysOpenUntilSparkAcceptsItsPayout() { + XCTAssertEqual( + SparkManager.settlement(of: .pending), + .wait, + "Spark keeps a withdrawal pending until the provider accepts its payout, and it can still fail" + ) + XCTAssertEqual(SparkManager.settlement(of: .completed), .record) + XCTAssertEqual(SparkManager.settlement(of: .failed), .closeUnsent) + } + + @MainActor + func testADefiniteSparkRefusalClosesTheWithdrawalAtOnce() async { + var steps: [String] = [] + let refused = await SparkManager.withdrawalSendFailure( + SdkError.InsufficientFunds(tokenIdentifier: nil), + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTAssertTrue(refused is SparkWithdrawalNotSent, "Spark picks leaves before anything reaches the operators") + XCTAssertEqual(steps, ["closed"], "A refusal releases the journal at once instead of after the expiry") + + steps = [] + let feeRose = await SparkManager.withdrawalSendFailure( + SdkError.InvalidInput( + "The onchain fee rose to 900 sats at the slowest speed, above the 500 sats quoted for this payment. Please re-prepare the payment." + ), + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTAssertTrue(feeRose is SparkWithdrawalNotSent, "Spark checks a refreshed quote's fee before it withdraws") + XCTAssertEqual(steps, ["closed"]) + + steps = [] + let unknown = await SparkManager.withdrawalSendFailure( + SdkError.SparkError("operators"), + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTAssertFalse(unknown is SparkWithdrawalNotSent) + XCTAssertEqual(steps, ["settle later"], "An outcome that may have sent it settles from the sync after it") + } + + @MainActor + func testAContactWithdrawalIsRefusedOnceAnExitHoldsSpark() async { + let spends = SparkSpends { true } + var steps: [String] = [] + do { + _ = try await SparkManager.sendJournaledWithdrawal( + spends: spends, + send: { steps.append("sent") }, + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTFail("A contact withdrawal went out while an exit held Spark") + } catch { + XCTAssertTrue(error is SparkWithdrawalNotSent, "Nothing left, so the allowance attempt fails instead of staying unknown") + } + XCTAssertEqual(steps, ["closed"], "The exit's leaves stay unspent and the journal closes at once") + XCTAssertEqual(spends.inFlight, 0) + } + + @MainActor + func testASecondContactWithdrawalIsRefusedWhileTheFirstPrepares() throws { + snapshotAppDefaultsDomain() + SparkManager.pendingContactWithdrawal = nil + let spark = SparkManager.shared + try spark.claimContactWithdrawal() + defer { spark.releaseContactWithdrawalClaim() } + + XCTAssertThrowsError(try spark.claimContactWithdrawal(), "A second send must never overwrite the first one's journal") + spark.releaseContactWithdrawalClaim() + XCTAssertNoThrow(try spark.claimContactWithdrawal()) + } + + func testAnUnfundedRequestReachesConfirmWhileSparkCanCarryIt() throws { + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true)) + let direct = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false)) + + XCTAssertFalse(SendSheet.rejectsUnfundedOnchainRequest(route: offered), "Confirm routes it from Spending through Spark") + XCTAssertTrue(SendSheet.rejectsUnfundedOnchainRequest(route: direct)) + XCTAssertTrue(SendSheet.rejectsUnfundedOnchainRequest(route: nil)) + } + + @MainActor + func testAHardwareWalletPickOnAmountDropsTheSparkLeg() { + snapshotAppDefaultsDomain() + let app = AppViewModel() + var routed = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) + app.paykitRoute = routed + app.selectedWalletToPayFrom = .lightning + + SendAmountView.selectWallet(of: .hardware(walletId: "trezor"), app: app) + + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain) + XCTAssertNil(app.paykitRoute?.serviceLeg, "Confirm must never describe the Spark leg for a hardware wallet's send") + XCTAssertEqual(app.paykitRoute?.endpoints, routed.endpoints) + + app.paykitRoute = routed + SendAmountView.selectWallet(of: .savings, app: app) + XCTAssertEqual(app.paykitRoute, routed, "Savings asks the router again on Continue") + } + + func testAnLnurlOutsideItsLimitsLeavesTheSparkLegToTheRouter() { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true, lnurl: lnurlData(minSendableMsats: 5_000_000)) + let leg = sparkLeg(amountSats: 1000, feeSats: 2190) + + let below = PaykitRouteService.request( + route: route, + amountSats: 1000, + source: .spending, + lightningSpendableSats: 4000, + onchainSpendableSats: 0, + serviceLegs: [leg] + ) + XCTAssertEqual(below.contactEndpoints, [onchain], "An LNURL that refuses the amount must never win over the Spark leg") + + let within = PaykitRouteService.request( + route: route, + amountSats: 6000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + serviceLegs: [] + ) + XCTAssertEqual(within.contactEndpoints, [lightning, onchain]) + } + + /// A route built after the scan, once Spark became ready, holds a contact's LNURL that was never decoded; the router + /// can still pick it, so the pick decodes it. + @MainActor + func testAnLnurlTheRouterPicksWithoutDecodedDataIsDecoded() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + let decoded = lnurlData(minSendableMsats: 1000) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + let lightning = lightning + var decodedValues: [String] = [] + + let outcome = await app.routeContactPayment( + route, + amountSats: 2000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil, + router: { _, _, _, _, _ in .direct(endpoint: lightning) }, + decodeEndpoint: { value in + decodedValues.append(value) + return .lnurlPay(data: decoded) + } + ) + + XCTAssertEqual(outcome, .direct) + XCTAssertEqual(decodedValues, [lightning.value]) + XCTAssertEqual(app.lnurlPayData, decoded) + XCTAssertEqual(app.paykitRoute?.lnurl, decoded, "a later pick reuses the decoded LNURL") + } + + @MainActor + func testTheRouterIsAskedWithTheLnurlItsLimitsComeFrom() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.lnurlPayData = lnurlData(minSendableMsats: 5_000_000) + app.selectedWalletToPayFrom = .lightning + var asked: PaykitRouteUi? + + _ = await app.routeContactPayment( + route, + amountSats: 1000, + source: .spending, + lightningSpendableSats: 4000, + onchainSpendableSats: 0, + request: nil + ) { route, _, _, _, _ in + asked = route + return .service(leg: self.sparkLeg(amountSats: 1000, feeSats: 2190)) + } + + XCTAssertEqual(asked?.lnurl, app.lnurlPayData, "The router must see the LNURL's limits") + } + + @MainActor + func testAContactWhoseLnurlMinimumLightningPaysStillOffersSparkPastTheLightningLimit() async throws { + snapshotAppDefaultsDomain() + let lnurl = LnurlPayData( + uri: lightning.value, + callback: "https://example.com/pay", + minSendable: 1_000_000, + maxSendable: 2_000_000_000, + metadataStr: "[]", + commentAllowed: nil, + allowsNostr: false, + nostrPubkey: nil + ) + let address = onchain.value + let app = AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 0, + totalLightningBalanceSats: 997_506, + hasChannels: false, + hasUsableChannels: false + ) + }, + canSendLightning: { $0 <= 997_506 }, + decodeInvoice: { value in + value == address + ? .onChain(invoice: OnChainInvoice(address: address, amountSatoshis: 0, label: nil, message: nil, params: nil)) + : .lnurlPay(data: lnurl) + }, + canRouteThroughSpark: { true } + ) + ) + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [ + Self.endpoint(.bitcoinLightningLnurl, lightning.value), + Self.endpoint(.regtestOnchainP2wpkh, address), + Self.endpoint(.usdtArbitrum, "0x1111111111111111111111111111111111111111"), + ] + ) + XCTAssertTrue(app.claimContactPaymentContext(context)) + + try await app.handleScannedData(lightning.value, claimedContactPaymentContext: context) + + XCTAssertEqual(app.lnurlPayData, lnurl) + XCTAssertEqual( + app.paykitRoute?.offersSparkLeg, + true, + "Lightning pays the LNURL's minimum, but a 999,000-sat payment past its 997,506 limit still needs Spark" + ) + } + + func testALightningPayableContactRoutesDirectWithoutASparkQuote() async throws { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + var quotes = 0 + + let direct = try await PaykitRouteService.route( + route, amountSats: 2000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0 + ) { _, _ in quotes += 1; return [] } + XCTAssertEqual(direct, .direct(endpoint: lightning)) + XCTAssertEqual(quotes, 0, "Spark's quote is never fetched for a payment Lightning makes") + + let leg = sparkLeg(amountSats: 60000, feeSats: 300) + let routed = try await PaykitRouteService.route( + route, amountSats: 60000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0 + ) { endpoints, amountSats in + quotes += 1 + XCTAssertEqual(endpoints, [self.onchain]) + XCTAssertEqual(amountSats, 60000) + return [leg] + } + XCTAssertEqual(routed, .service(leg: leg), "An amount past Lightning's limit still takes the quoted Spark leg") + XCTAssertEqual(quotes, 1) + } + + @MainActor + func testANoneRouteNamesTheLnurlLimitTheAmountMissed() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.lnurlPayData = lnurlData(minSendableMsats: 1_000_000) + app.selectedWalletToPayFrom = .lightning + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [lightning], offersSparkLeg: false), + amountSats: 500, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) + + XCTAssertEqual(outcome, .refused) + XCTAssertEqual( + ToastWindowManager.shared.currentToast?.accessibilityIdentifier, + "LnurlPayAmountTooLowToast", + "A contact with a working LNURL is payable, only not at this amount" + ) + } + + @MainActor + func testASparkLegItsFeeTakesPastTheBalanceShowsTheBalanceCopy() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + let fee = SendOnchainSpeedFeeQuote(userFeeSat: 750, l1BroadcastFeeSat: 1440) + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true), + amountSats: 9000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil + ) { route, amountSats, source, lightningSats, onchainSats in + try await PaykitRouteService.route( + route, amountSats: amountSats, source: source, lightningSpendableSats: lightningSats, onchainSpendableSats: onchainSats + ) { endpoints, amountSats in + try await PaykitRouteService.sparkLegs(to: endpoints, amountSats: amountSats) { endpoint, amountSats in + try SparkManager.withdrawalRouteLeg( + endpoint: endpoint, amountSats: amountSats, fee: fee, validUntilUnix: 0, balanceSats: 10000 + ) + } + } + } + + XCTAssertEqual(outcome, .refused) + XCTAssertEqual( + ToastWindowManager.shared.currentToast?.accessibilityIdentifier, + "PaykitRouteBalanceToast", + "Spark holds 10,000 but the 9,000 withdrawal takes 11,190 with its fee, so the balance is what falls short" + ) + } + + @MainActor + func testAPendingWithdrawalSendIsHeldUntilItsJournalSettles() { + var pending = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + pending.status = .pending + + XCTAssertThrowsError(try SparkManager.sentWithdrawalId(pending), "A pending withdrawal can still fail, so it never shows success") { + XCTAssertFalse($0 is SparkWithdrawalNotSent, "It may still pay, so its proof and allowance attempt stay") + XCTAssertEqual( + SendConfirmationView.serviceLegFailureRoute( + $0, + attempt: SparkWithdrawalAttempt(address: self.onchain.value, amountSats: 20000, requestId: nil), + routingCacheResetAttempted: false + ), + .sparkWithdrawalPending(SparkWithdrawalAttempt(address: self.onchain.value, amountSats: 20000, requestId: nil)) + ) + } + } + + @MainActor + func testAMixedBitcoinContactWithoutUsdtOpensOnAmountWithTheSparkLeg() async throws { + snapshotAppDefaultsDomain() + let lnurl = lnurlData(minSendableMsats: 5_000_000) + let address = onchain.value + let sheets = SheetViewModel() + let app = AppViewModel( + sheetViewModel: sheets, + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 0, + totalLightningBalanceSats: 10000, + hasChannels: false, + hasUsableChannels: false + ) + }, + canSendLightning: { $0 <= 10000 }, + decodeInvoice: { value in + value == address + ? .onChain(invoice: OnChainInvoice(address: address, amountSatoshis: 0, label: nil, message: nil, params: nil)) + : .lnurlPay(data: lnurl) + }, + canRouteThroughSpark: { true } + ) + ) + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.bitcoinLightningLnurl, lightning.value), Self.endpoint(.regtestOnchainP2wpkh, address)] + ) + XCTAssertFalse(context.requiresAssetSelection) + XCTAssertTrue(app.claimContactPaymentContext(context)) + + try await app.handleScannedData(lightning.value, claimedContactPaymentContext: context) + PaymentNavigationHelper.openPaymentSheet( + app: app, + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, + sheetViewModel: sheets + ) + + XCTAssertEqual(app.paykitRoute?.offersSparkLeg, true, "2,000 sats below the LNURL's 5,000 minimum can still go through Spark") + XCTAssertEqual(app.scannedOnchainInvoice?.address, address) + XCTAssertEqual(sheets.sendSheetItem?.initialRoute, .amount, "Amount picks the source, so the amount reaches the router") + } + + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { + LnurlPayData( + uri: lightning.value, + callback: "https://example.com/pay", + minSendable: minSendableMsats, + maxSendable: 100_000_000, + metadataStr: "[]", + commentAllowed: nil, + allowsNostr: false, + nostrPubkey: nil + ) + } + + /// Contact A's LNURL decode (a network fetch) is slow; the user dismisses A and opens contact B before it returns. + @MainActor + func testAnLnurlDecodeThatReturnsAfterThePaymentChangedNeverLandsOnTheNextOne() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let contactA = ContactPaymentContext(publicKey: "contacta", privatePaymentContext: nil, endpoints: []) + let contactB = ContactPaymentContext(publicKey: "contactb", privatePaymentContext: nil, endpoints: []) + let routeA = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + let routeB = PaykitRouteUi(endpoints: [lightning], offersSparkLeg: false) + var lnurlA = lnurlData(minSendableMsats: 1000) + lnurlA.callback = "https://contact-a.example/pay" + var lnurlB = lnurlData(minSendableMsats: 1000) + lnurlB.callback = "https://contact-b.example/pay" + app.contactPaymentContext = contactA + app.selectedWalletToPayFrom = .lightning + let lightning = lightning + + let outcome = await app.routeContactPayment( + routeA, + amountSats: 2000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil, + router: { _, _, _, _, _ in .direct(endpoint: lightning) }, + decodeEndpoint: { _ in + // While A's LNURL is fetched, A's sheet is dismissed and contact B's send opens with B's LNURL. + app.resetSendState() + app.contactPaymentContext = contactB + app.paykitRoute = routeB + app.selectedWalletToPayFrom = .lightning + app.lnurlPayData = lnurlB + return .lnurlPay(data: lnurlA) + } + ) + + XCTAssertEqual(outcome, .stale) + XCTAssertEqual(app.lnurlPayData?.callback, lnurlB.callback, "contact B's send must never pay contact A's LNURL") + XCTAssertNil(app.paykitRoute?.lnurl, "contact A's LNURL must not land on contact B's route") + } + + @MainActor + func testARouteThatReturnsAfterItsPaymentChangedNeverLandsOnTheNextOne() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let contactA = ContactPaymentContext(publicKey: "contacta", privatePaymentContext: nil, endpoints: []) + let contactB = ContactPaymentContext(publicKey: "contactb", privatePaymentContext: nil, endpoints: []) + let routeA = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + let routeB = PaykitRouteUi(endpoints: [lightning], offersSparkLeg: false) + app.contactPaymentContext = contactA + app.paykitRoute = routeA + + let outcome = await app.routeContactPayment( + routeA, + amountSats: 20000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in + // Contact A's sheet is dismissed and contact B's opens while Spark quotes A's leg. + app.contactPaymentContext = contactB + app.paykitRoute = routeB + return .service(leg: self.sparkLeg(amountSats: 20000, feeSats: 300)) + } + + XCTAssertEqual(outcome, .stale) + XCTAssertEqual(app.paykitRoute, routeB, "contact A's leg must never pay contact B") + } + + @MainActor + func testARouteThatReturnsAfterTheSourceChangedNeverRestoresIt() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.paykitRoute = route + app.selectedWalletToPayFrom = .lightning + + let outcome = await app.routeContactPayment( + route, + amountSats: 20000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in + // The user goes back to Savings while Spark quotes the Spending leg. + app.selectedWalletToPayFrom = .onchain + return .service(leg: self.sparkLeg(amountSats: 20000, feeSats: 300)) + } + + XCTAssertEqual(outcome, .stale) + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain, "a superseded Spending quote must not take the source back") + XCTAssertNil(app.paykitRoute?.serviceLeg) + } + + @MainActor + func testARouteSupersededByANewerOneIsDropped() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.paykitRoute = route + app.selectedWalletToPayFrom = .lightning + var newer: PaykitRouteOutcome? + + let older = await app.routeContactPayment( + route, + amountSats: 20000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in + // A new amount asks the router again before the first quote returns. + newer = await app.routeContactPayment( + route, + amountSats: 30000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in .service(leg: self.sparkLeg(amountSats: 30000, feeSats: 300)) } + return .service(leg: self.sparkLeg(amountSats: 20000, feeSats: 300)) + } + + XCTAssertEqual(newer, .leg) + XCTAssertEqual(older, .stale) + XCTAssertEqual(app.paykitRoute?.serviceLeg?.deliveredSat, 30000, "the older amount's leg must not replace the newer one") + } + + @MainActor + func testADirectRerouteFromBolt11BackToLnurlPaysTheLnurl() async { + let (app, route, bolt11, lnurl) = openLightningContact() + + let first = await routeDirectly(app, route, to: bolt11) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.lnurlPayData) + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value, "the LNURL pick must pay the LNURL, not the skipped invoice") + XCTAssertNil(app.scannedLightningInvoice) + } + + @MainActor + func testADirectRerouteFromLnurlBackToBolt11PaysTheInvoice() async { + let (app, route, bolt11, lnurl) = openLightningContact() + + let first = await routeDirectly(app, route, to: lnurl) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.scannedLightningInvoice, "the LNURL pick must not leave the invoice to pay") + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: bolt11) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.scannedLightningInvoice?.bolt11, bolt11.value) + XCTAssertNil(app.lnurlPayData) + } + + @MainActor + func testARerouteFromBolt11ToLnurlBelowItsMinimumShowsTheMinimumToast() async { + let (app, route, bolt11, lnurl) = openLightningContact(lnurlMinSendableMsats: 1_000_000) + defer { ToastWindowManager.shared.hideToast() } + + let first = await routeDirectly(app, route, to: bolt11) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.lnurlPayData) + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl, amountSats: 500) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value) + XCTAssertFalse(app.validateRoutedLnurlAmount(amountSats: 500), "the restored LNURL's minimum must stop Confirm") + let toast = ToastWindowManager.shared.currentToast + XCTAssertEqual(toast?.accessibilityIdentifier, "LnurlPayAmountTooLowToast") + XCTAssertEqual(toast?.title, t("wallet__lnurl_pay__error_min__title")) + XCTAssertEqual(toast?.description, t( + "wallet__lnurl_pay__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(1000)] + )) + } + + @MainActor + func testARerouteFromBolt11ToLnurlAboveItsMaximumShowsTheMaximumToast() async { + let (app, route, bolt11, lnurl) = openLightningContact(lnurlMaxSendableMsats: 3_000_000) + defer { ToastWindowManager.shared.hideToast() } + + let first = await routeDirectly(app, route, to: bolt11) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.lnurlPayData) + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl, amountSats: 4000) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value) + XCTAssertFalse(app.validateRoutedLnurlAmount(amountSats: 4000), "the restored LNURL's maximum must stop Confirm") + let toast = ToastWindowManager.shared.currentToast + XCTAssertEqual(toast?.accessibilityIdentifier, "LnurlPayAmountTooHighToast") + XCTAssertEqual(toast?.title, t("wallet__lnurl_pay__error_max__title")) + XCTAssertEqual(toast?.description, t("wallet__lnurl_pay__error_max__description")) + } + + @MainActor + private func openLightningContact( + lnurlMinSendableMsats: UInt64 = 1000, + lnurlMaxSendableMsats: UInt64 = 100_000_000 + ) -> (AppViewModel, PaykitRouteUi, PaykitEndpoint, PaykitEndpoint) { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let bolt11 = PaykitEndpoint( + methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + value: "lnbcrt1contactreroute" + ) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.scannedLightningInvoice = LightningInvoice( + bolt11: bolt11.value, + paymentHash: Data(), + amountSatoshis: 0, + timestampSeconds: 1, + expirySeconds: 3600, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + app.lnurlPayData = LnurlPayData( + uri: lightning.value, + callback: "https://example.com/pay", + minSendable: lnurlMinSendableMsats, + maxSendable: lnurlMaxSendableMsats, + metadataStr: "[]", + commentAllowed: nil, + allowsNostr: false, + nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + return (app, PaykitRouteUi(endpoints: [bolt11, lightning], offersSparkLeg: false), bolt11, lightning) + } + + @MainActor + private func routeDirectly( + _ app: AppViewModel, + _ route: PaykitRouteUi, + to endpoint: PaykitEndpoint, + amountSats: UInt64 = 2000 + ) async -> PaykitRouteOutcome { + await app.routeContactPayment( + route, + amountSats: amountSats, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in .direct(endpoint: endpoint) } + } + + @MainActor + func testAStaleRecoveryNeverClearsANewerWithdrawalsJournal() async { + snapshotAppDefaultsDomain() + let older = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000) + let newer = SparkContactWithdrawal(address: onchain.value, amountSats: 30000, contact: "contact", startedAt: 2000) + // A recovery captured the older withdrawal; another one closed it and the newer withdrawal wrote its journal. + SparkManager.pendingContactWithdrawal = newer + + await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _, _ in .nothingStarted }, failAllowance: { _ in true }) + + XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer, "Losing the newer journal could pay it twice") + } + + @MainActor + func testAStaleCloseNeverTellsTheNewerWithdrawalsScreenItEnded() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + // A retry of the same request: same address, amount and request, so both share one attempt. + let older = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + let newer = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 2000, request: request) + XCTAssertEqual(older.attempt, newer.attempt) + SparkManager.pendingContactWithdrawal = newer + var resolutions: [SparkContactWithdrawalResolution] = [] + let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } + defer { subscription.cancel() } + + await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _, _ in .nothingStarted }, failAllowance: { _ in true }) + + XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer) + XCTAssertEqual(resolutions, [], "An unsent resolution for the newer send's attempt would offer Try Again while it is in flight") + } + + @MainActor + func testAStaleCloseDuringARetryKeepsTheNewerProofAndAllowanceAttempt() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + let older = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + let newer = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 2000, request: request) + SparkManager.pendingContactWithdrawal = newer + var released: [SparkContactWithdrawal.PaykitRequest] = [] + var failedAllowances: [SparkContactWithdrawal.PaykitRequest] = [] + + await SparkManager.closeUnsentWithdrawal( + older, + releaseProof: { request, failAllowance in + released.append(request) + return await failAllowance() ? .released : .failed + }, + failAllowance: { failedAllowances.append($0); return true } + ) + + XCTAssertEqual(released, [], "The proof matches by request, so a stale close would release the retry's started proof") + XCTAssertEqual(failedAllowances, [], "The retry's allowance attempt stays open while it is in flight") + XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer) + } + + @MainActor + func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { + let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) + + XCTAssertFalse( + SendPendingScreen.readsNodeOnchainAttempts(sparkWithdrawal: attempt), + "A Spark withdrawal never creates a node on-chain attempt, so an unrelated one must not show as this send" + ) + XCTAssertTrue(SendPendingScreen.readsNodeOnchainAttempts(sparkWithdrawal: nil)) + } + + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { + var steps: [String] = [] + let identity = try await PaykitRouteService.startLegRequestProof( + prepareProof: { steps.append("proof") }, + prepareRequest: { steps.append("request") }, + paymentIdentity: { steps.append("identity"); return "payer" }, + cancelProof: { steps.append("cancel") }, + releasePaymentList: { steps.append("release") } + ) + + XCTAssertEqual(identity, "payer") + XCTAssertEqual(steps, ["proof", "request", "identity"], "The request is approved before Spark's payment check") + } + + func testLegThatFailsBeforeSendingFreesTheRequest() async { + struct Refused: Error {} + for failing in ["proof", "request"] { + var steps: [String] = [] + do { + _ = try await PaykitRouteService.startLegRequestProof( + prepareProof: { + if failing == "proof" { + throw Refused() + } + }, + prepareRequest: { + if failing == "request" { + throw Refused() + } + }, + paymentIdentity: { "payer" }, + cancelProof: { steps.append("cancel") }, + releasePaymentList: { steps.append("release") } + ) + XCTFail("\(failing) must fail the leg") + } catch {} + + XCTAssertEqual(steps, failing == "proof" ? ["release"] : ["cancel", "release"], failing) + } + } + + func testSourceSwitchOnConfirmReroutesOnlyWhenTheLegNoLongerFits() throws { + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true)) + var routed = offered + routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) + + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .onchain)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .lightning)) + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .lightning)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .onchain)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: nil, walletType: .onchain)) + } + + func testADirectLnurlPickOnConfirmPaysOnTheLnurlConfirm() { + XCTAssertTrue(SendConfirmationView.reroutePaysLnurl(outcome: .direct, walletType: .lightning, hasInvoice: false, hasLnurl: true)) + XCTAssertFalse(SendConfirmationView.reroutePaysLnurl(outcome: .direct, walletType: .lightning, hasInvoice: true, hasLnurl: true)) + XCTAssertFalse(SendConfirmationView.reroutePaysLnurl(outcome: .leg, walletType: .lightning, hasInvoice: false, hasLnurl: true)) + XCTAssertFalse(SendConfirmationView.reroutePaysLnurl(outcome: .direct, walletType: .onchain, hasInvoice: false, hasLnurl: true)) + } + + /// A mixed contact paid directly from Savings keeps its decoded Lightning payment, so a switch to Spending must still + /// ask the router: over Lightning's limit only a Spark withdrawal pays it. + func testSourceSwitchToSpendingReroutesAMixedContactRoutedDirectFromSavings() { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: route, walletType: .lightning)) + } + + func testASparkRoutedConfirmCanMoveToAnotherSource() throws { + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + var routed = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true)) + routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) + + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: ["hw"]), + [.spending, .savings, .hardware(walletId: "hw")], + "Confirm on a Spark leg still offers Savings and the hardware wallet" + ) + // The router never runs for a hardware wallet, so its pick drops the leg for the on-chain send it signs. + XCTAssertNil(SendConfirmationView.route(routed, afterSelecting: .hardware(walletId: "hw"))?.serviceLeg) + XCTAssertEqual(SendConfirmationView.route(routed, afterSelecting: .hardware(walletId: "hw"))?.endpoints, routed.endpoints) + // Savings and Spending ask the router again, which settles the leg. + XCTAssertEqual(SendConfirmationView.route(routed, afterSelecting: .savings), routed) + XCTAssertEqual(SendConfirmationView.route(routed, afterSelecting: .spending), routed) + XCTAssertNil(SendConfirmationView.route(nil, afterSelecting: .hardware(walletId: "hw"))) + } + + func testTheFromControlStaysSwitchableOnASparkRoute() { + let sources = SendConfirmationView.fundingSources( + hasLightningInvoice: false, + hasOnchainInvoice: true, + offersSparkLeg: true, + hardwareWalletIds: [] + ) + let canSwitch = SendConfirmationView.canSwitchFundingSource(hasRetainedHardwareRetry: false, fundingSources: sources) + + let control = SendConfirmationView.sparkRouteFromControl(canSwitchFundingSource: canSwitch) + + XCTAssertTrue(control.isSwitchable, "After picking Spark, FROM still moves a fixed request to Savings") + XCTAssertEqual(control.titleKey, "wallet__spending__title") + // A retained hardware retry pins the source, on a Spark route too. + let pinned = SendConfirmationView.canSwitchFundingSource(hasRetainedHardwareRetry: true, fundingSources: sources) + XCTAssertFalse(SendConfirmationView.sparkRouteFromControl(canSwitchFundingSource: pinned).isSwitchable) + } + + func testARefusedRerouteOnConfirmKeepsTheSwipeOffUntilAnotherSourceIsPicked() { + let refused = SendConfirmationView.refusedRouteSource(after: .refused, on: .lightning, previous: nil) + + XCTAssertEqual(refused, .lightning) + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: .lightning, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isRouteRefused: refused == .lightning + ), "a refused route must keep the swipe off, so no private list is consumed for it") + XCTAssertFalse(SendConfirmationView.isSwipeDisabled( + walletType: .onchain, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isRouteRefused: refused == .onchain + ), "switching back to Savings pays the contact again") + XCTAssertNil(SendConfirmationView.refusedRouteSource(after: .leg, on: .lightning, previous: refused)) + } + + func testALateRerouteOutcomeKeepsAnotherSourcesRefusal() { + XCTAssertEqual(SendConfirmationView.refusedRouteSource(after: .direct, on: .onchain, previous: .lightning), .lightning) + XCTAssertEqual(SendConfirmationView.refusedRouteSource(after: .refused, on: .onchain, previous: .lightning), .onchain) + } + + func testOnchainRequestStartsOnSpendingWhenSavingsCantCoverIt() { + // Savings 5,000 can't cover a 20,000 request, so Spending (Spark 50,000) pays it through the leg. + XCTAssertTrue(SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 5000, amountSats: 20000, offersSparkLeg: true)) + XCTAssertFalse(SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 48000, amountSats: 20000, offersSparkLeg: true)) + XCTAssertFalse( + SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 5000, amountSats: 20000, offersSparkLeg: false), + "Without a Spark leg the request stays on Savings" + ) + } + + func testConfirmOffersSpendingWhileASparkLegCanCarryAnOnchainRequest() { + // Savings covers the request, so its Confirm opens on Savings; Spending through Spark is still a source. + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: []), + [.spending, .savings] + ) + // Savings is short, so the request opens on Spending, which must be in the list to switch back to. + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: ["hw"]), + [.spending, .savings, .hardware(walletId: "hw")] + ) + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: false, hardwareWalletIds: []), + [.savings], + "Without a Spark leg an on-chain request has no Spending source" + ) + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: true, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: []), + [.spending, .savings] + ) + } + + func testRefusalWhileAnOlderJournalIsOpenReleasesThisCallsProof() { + struct Refused: Error {} + let older = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "older", counterparty: "payee"), + identity: "payer" + ) + let this = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "this", counterparty: "payee"), + identity: "payer" + ) + let notSent = SparkWithdrawalNotSent(underlying: Refused()) + + XCTAssertTrue( + PaykitRouteService.legFailureReleasesProof(notSent, openJournalRequest: older, proofRequest: this), + "An older journal still open must not keep a refused send's proof started" + ) + XCTAssertTrue(PaykitRouteService.legFailureReleasesProof(notSent, openJournalRequest: nil, proofRequest: this)) + XCTAssertFalse( + PaykitRouteService.legFailureReleasesProof(notSent, openJournalRequest: this, proofRequest: this), + "An earlier attempt of the same request still owns its proof" + ) + XCTAssertFalse( + PaykitRouteService.legFailureReleasesProof(Refused(), openJournalRequest: this, proofRequest: this), + "A failure once Spark's send started is settled by the journal" + ) + } + + func testSparkLegDebitsTheL1BroadcastFeeToo() { + let fee = SendOnchainSpeedFeeQuote(userFeeSat: 750, l1BroadcastFeeSat: 1440) + + XCTAssertEqual(SparkManager.withdrawalDebitSats(amountSats: 2000, fee: fee), 4190, "Confirm and the fee check use 2,190, not 750") + XCTAssertThrowsError(try SparkManager.checkWithdrawalFee(2190, shownFeeSats: 750)) + } + + func testContactWithdrawalStaysASendNotATransfer() { + let toContact = Self.withdrawal(id: "contact", to: onchain.value, amountSats: 20000, timestamp: 1) + let toSavings = Self.withdrawal(id: "savings", to: "bcrt1qsavings", amountSats: 5000, timestamp: 1) + + let transfers = SparkManager.savingsTransferTxIds(unclaimed: [], payments: [toContact, toSavings], contactWithdrawals: ["tx-contact"]) + + XCTAssertEqual(transfers, ["tx-savings"]) + } + + func testSparkScreenWithdrawalsLeaveOutContactPayments() { + let saved = SparkManager.contactWithdrawalTxIds + defer { SparkManager.contactWithdrawalTxIds = saved } + SparkManager.contactWithdrawalTxIds = ["tx-contact"] + let toContact = Self.withdrawal(id: "contact", to: onchain.value, amountSats: 20000, timestamp: 1) + let toSavings = Self.withdrawal(id: "savings", to: "bcrt1qsavings", amountSats: 5000, timestamp: 1) + + XCTAssertEqual( + SparkManager.withdrawals([toContact, toSavings]).map(\.txid), + ["tx-savings"], + "A contact payment is not a transfer to savings" + ) + } + + func testSparkScreenWithdrawalsLeaveOutAPendingContactPayment() { + snapshotAppDefaultsDomain() + SparkManager.contactWithdrawalTxIds = [] + var toContact = Self.withdrawal(id: "contact", to: onchain.value, amountSats: 20000, timestamp: 1) + toContact.status = .pending + let toSavings = Self.withdrawal(id: "savings", to: "bcrt1qsavings", amountSats: 5000, timestamp: 1) + var journal = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1) + SparkManager.pendingContactWithdrawal = journal + + XCTAssertEqual( + SparkManager.withdrawals([toContact, toSavings]).map(\.txid), + ["tx-savings"], + "Before Spark returns it, the open journal's address and amount tell the contact payment apart" + ) + journal.paymentId = "contact" + SparkManager.pendingContactWithdrawal = journal + XCTAssertEqual( + SparkManager.withdrawals([toContact, toSavings]).map(\.txid), + ["tx-savings"], + "A contact payment Spark keeps pending is not a transfer to savings" + ) + } + + private static func endpoint(_ methodId: PublicPaykitService.MethodId, _ value: String) -> PublicPaykitService.Endpoint { + PublicPaykitService.Endpoint(methodId: methodId, value: value, min: nil, max: nil, rawPayload: value) + } + + private static func withdrawal(id: String, to address: String, amountSats: UInt64, timestamp: UInt64) -> Payment { + Payment( + id: id, + paymentType: .send, + status: .completed, + amount: U128(amountSats), + fees: 300, + timestamp: timestamp, + method: .withdraw, + details: .withdraw(txId: "tx-\(id)", vout: 0, destinationAddress: address), + conversionDetails: nil + ) + } +} diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 738141ff1..60213958d 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -591,7 +591,12 @@ private final class EndpointPublicationWallet: WalletViewModel { true } - override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + override func createInvoice( + amountSats: UInt64? = nil, + note: String, + expirySecs: UInt32? = nil, + isPaykitEndpoint: Bool = false + ) async throws -> String { Self.invoice } } diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index 7c3857e2a..fd327bbf6 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -103,6 +103,22 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { XCTAssertEqual(store.spentCentsToday(), 0) } + func testSparkRefusalReleasesSpendAndFails() async throws { + let invoiceHash = try Self.invoiceHash + // Spark refuses only before anything leaves the wallet, as when its balance can't cover the payment. + let route = await firstRoute( + sendBolt11: { _ in + throw Bitkit.ChannelFreeRailSendRefused(reason: "Spark balance can't cover the payment") + } + ) + + guard case .failure = route else { + return XCTFail("Expected failure, got \(String(describing: route))") + } + XCTAssertNil(store.record(matching: invoiceHash)) + XCTAssertEqual(store.spentCentsToday(), 0) + } + func testWrappedDuplicateDispatchWithSucceededLdkReleasesFreshReserveAndFails() async throws { let invoiceHash = try Self.invoiceHash let route = await firstRoute( diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 89e4ed25e..ab3712056 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -1754,7 +1754,12 @@ private final class GatedReceiveWallet: WalletViewModel { return "bcrt1qreceive" } - override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + override func createInvoice( + amountSats: UInt64? = nil, + note: String, + expirySecs: UInt32? = nil, + isPaykitEndpoint: Bool = false + ) async throws -> String { if let invoiceCreation { return try await invoiceCreation() } @@ -1844,7 +1849,12 @@ private final class TaggedReceiveWallet: WalletViewModel { true } - override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + override func createInvoice( + amountSats: UInt64? = nil, + note: String, + expirySecs: UInt32? = nil, + isPaykitEndpoint: Bool = false + ) async throws -> String { invoiceCount += 1 return invoiceCount == 1 ? "ln-first" : "ln-second" } diff --git a/BitkitTests/SavingsSelectionTests.swift b/BitkitTests/SavingsSelectionTests.swift new file mode 100644 index 000000000..b7a4b5f02 --- /dev/null +++ b/BitkitTests/SavingsSelectionTests.swift @@ -0,0 +1,81 @@ +@testable import Bitkit +import XCTest + +final class SavingsSelectionTests: XCTestCase { + private let channels = ["a", "b"] + + func testDefaultMovesEveryChannelAndSpark() { + let selection = resolve(selected: nil, spark: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["a", "b"], withdrawsSpark: true)) + } + + func testSparkOnlyWithoutChannels() { + let selection = SavingsSelection.resolve(readyChannelIds: [], selectedChannelIds: nil, isSparkSelected: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: [], withdrawsSpark: true)) + XCTAssertFalse(selection.isEmpty) + } + + func testChannelsOnlyWithoutSparkBalance() { + let selection = resolve(selected: nil, spark: true, sparkSats: 0) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["a", "b"], withdrawsSpark: false)) + } + + func testDeselectedSparkIsNotWithdrawn() { + let selection = resolve(selected: ["a", "b"], spark: false, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["a", "b"], withdrawsSpark: false)) + } + + func testSparkAloneClosesNoChannel() { + let selection = resolve(selected: [], spark: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: [], withdrawsSpark: true)) + } + + func testPartialChannelSelection() { + let selection = resolve(selected: ["b"], spark: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["b"], withdrawsSpark: true)) + } + + func testSelectedChannelThatIsNoLongerReadyIsDropped() { + let selection = resolve(selected: ["b", "gone"], spark: false, sparkSats: 5000) + + XCTAssertEqual(selection.channelIds, ["b"]) + } + + func testNothingSelectedIsEmpty() { + XCTAssertTrue(resolve(selected: [], spark: false, sparkSats: 5000).isEmpty) + } + + @MainActor + func testCommittingResetsThePickToAll() { + let transfer = TransferViewModel() + transfer.selectedChannelIds = ["a"] + transfer.isSparkSelected = false + + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false) + + XCTAssertNil(transfer.selectedChannelIds) + XCTAssertTrue(transfer.isSparkSelected) + } + + @MainActor + func testEnteringTheFlowDropsAPickLeftFromAnAbandonedVisit() { + let transfer = TransferViewModel() + transfer.selectedChannelIds = ["closed-since"] + transfer.isSparkSelected = false + + transfer.beginSavingsTransfer() + + XCTAssertNil(transfer.selectedChannelIds) + XCTAssertTrue(transfer.isSparkSelected) + } + + private func resolve(selected: Set?, spark: Bool, sparkSats: UInt64) -> SavingsSelection { + SavingsSelection.resolve(readyChannelIds: channels, selectedChannelIds: selected, isSparkSelected: spark, sparkSats: sparkSats) + } +} diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index 0720817a2..e4f92dc2f 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -52,7 +52,7 @@ final class SavingsSwapTests: XCTestCase { await transfer.loadSavingsSwapQuote(requestedSat: 100_000, spendableSats: 100_000) XCTAssertNil(transfer.savingsSwapState.quote) - transfer.onTransferToSavingsConfirm(channels: []) + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false) XCTAssertEqual(transfer.savingsTransferMode, .close) } @@ -66,10 +66,10 @@ final class SavingsSwapTests: XCTestCase { maxSat: 100_000 ) - transfer.onTransferToSavingsConfirm(channels: []) + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false) XCTAssertEqual(transfer.savingsTransferMode, .swap) - transfer.onTransferToSavingsConfirm(channels: [], mode: .close) + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false, mode: .close) XCTAssertEqual(transfer.savingsTransferMode, .close) } diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 0d4702ce1..bdfc9a82f 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -29,6 +29,50 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(wallet.sendAmountSats, 10000) } + func testASparkFundedOnchainRequestKeepsItsOnchainPriceOnSpending() throws { + snapshotAppDefaultsDomain() + let request = try onchainRequest(rates: [ + ConversionRate(asset: "btc", value: "0.000002188"), + ConversionRate(asset: "btc-lightning", value: "0.00002"), + ]) + let app = AppViewModel() + app.scannedOnchainInvoice = invoice + let onchainTerms = try request.payment(using: app.paykitPaymentMethod) + + // Spending funds the Spark leg, which still pays the request's on-chain endpoint. + app.selectedWalletToPayFrom = .lightning + + XCTAssertEqual(try request.payment(using: app.paykitPaymentMethod), onchainTerms) + } + + func testASparkLegIgnoresTheUnselectedLnurlsPrice() throws { + snapshotAppDefaultsDomain() + let request = try onchainRequest(acceptsLightning: true, rates: [ + ConversionRate(asset: "btc", value: "0.000002188"), + ConversionRate(asset: "btc-lightning", value: "0.00002"), + ]) + let app = AppViewModel() + app.scannedOnchainInvoice = invoice + let onchainTerms = try request.payment(using: app.paykitPaymentMethod) + let endpoint = PaykitEndpoint(methodId: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, value: invoice.address) + app.lnurlPayData = LnurlPayData( + uri: "lnurl1contact", callback: "https://example.com/callback", minSendable: 5_000_000, maxSendable: 100_000_000, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + app.paykitRoute = PaykitRouteUi( + endpoints: [endpoint], + offersSparkLeg: true, + serviceLeg: ServiceRouteLeg( + service: SparkManager.routeService, endpoint: endpoint, deliveredSat: 5000, debitSat: 5300, + atomicity: "trusted", validUntilUnix: 0 + ) + ) + + app.selectedWalletToPayFrom = .lightning + + XCTAssertEqual(try request.payment(using: app.paykitPaymentMethod), onchainTerms, "The leg pays the on-chain endpoint") + } + func testQuotedWalletSwitchRebindsAmountOnConfirmation() async throws { snapshotAppDefaultsDomain() snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 9ee79a26b..a1aefad92 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -68,6 +68,26 @@ final class SendConfirmationViewTests: XCTestCase { )) } + func testARouteRefusedWhenConfirmOpensKeepsTheSwipeAndTheAutomaticPaymentOff() { + // Confirm's first routing call keeps a refusal the way a source switch does. + let refused = SendConfirmationView.refusedRouteSource(after: .refused, on: .lightning, previous: nil) + + XCTAssertFalse(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: true, + hasStartedAutomaticPayment: false, + isSetupPending: false, + isPreparingWallet: false, + isRouteRefused: refused == .lightning + ), "an automatic attempt must not pay a source the router refused") + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: .lightning, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isRouteRefused: refused == .lightning + )) + } + func testAutomaticPaymentAwaitsFeeRateOnlyWhileItIsLoading() { XCTAssertTrue(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: false)) XCTAssertFalse(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: true)) @@ -189,4 +209,28 @@ final class SendConfirmationViewTests: XCTestCase { .uncertain ) } + + func testASparkLegThatMayHaveSentStaysPendingInsteadOfOfferingTryAgain() { + struct Refused: Error {} + struct Lost: Error {} + let attempt = SparkWithdrawalAttempt( + address: "bcrt1qcontact", + amountSats: 20000, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee") + ) + + let unresolved = SendConfirmationView.serviceLegFailureRoute(Lost(), attempt: attempt, routingCacheResetAttempted: false) + XCTAssertEqual( + unresolved, + .sparkWithdrawalPending(attempt), + "Spark may have sent it, so the screen follows its journal until it settles" + ) + + let refused = SendConfirmationView.serviceLegFailureRoute( + SparkWithdrawalNotSent(underlying: Refused()), + attempt: attempt, + routingCacheResetAttempted: false + ) + guard case .failure = refused else { return XCTFail("A leg Spark refused before sending can be tried again") } + } } diff --git a/BitkitTests/SparkClaimFeeTests.swift b/BitkitTests/SparkClaimFeeTests.swift new file mode 100644 index 000000000..4c3223166 --- /dev/null +++ b/BitkitTests/SparkClaimFeeTests.swift @@ -0,0 +1,10 @@ +@testable import Bitkit +import XCTest + +final class SparkClaimFeeTests: XCTestCase { + func testClaimFeeEstimateCoversTheClaimsSeenOnMainnet() { + // Mainnet claims so far took 198 and 297 sats; the cap at 1 sat/vB stays above both. + XCTAssertEqual(SparkManager.maxDepositClaimFeeSats(fastestFeeRate: 1, amountSats: 2500), 302) + XCTAssertEqual(SparkManager.maxDepositClaimFeeSats(fastestFeeRate: 1, amountSats: 15000), 327) + } +} diff --git a/BitkitTests/SparkClaimOfferTests.swift b/BitkitTests/SparkClaimOfferTests.swift new file mode 100644 index 000000000..a970d64bc --- /dev/null +++ b/BitkitTests/SparkClaimOfferTests.swift @@ -0,0 +1,55 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +final class SparkClaimOfferTests: XCTestCase { + func testOffersOnlyTheStandardClaimWhenNoEarlyClaimIsQuoted() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: nil)) + + XCTAssertEqual(offer.kind, .standard(feeSats: 907, confirmations: 3)) + } + + func testLeavesAnEarlyClaimWithinTheCapToTheSdk() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: early(feeSats: 900, maxFeeSats: 1000))) + + XCTAssertEqual(offer.kind, .automatic(feeSats: 900, confirmations: 0)) + } + + func testOffersAChoiceWhenTheEarlyClaimCostsMoreThanTheCap() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: early(feeSats: 1350, maxFeeSats: 1000, confirmations: 1))) + + XCTAssertEqual(offer.kind, .choice(earlyFeeSats: 1350, earlyConfirmations: 1, standardFeeSats: 907, standardConfirmations: 3)) + } + + func testOffersAChoiceWhenAutomaticClaimingIsOff() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: early(feeSats: 900, maxFeeSats: nil))) + + XCTAssertEqual(offer.kind, .choice(earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 907, standardConfirmations: 3)) + } + + func testMapsEachClaimOutcome() { + XCTAssertEqual(SparkEarlyClaim(.submitted), .submitted) + XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .noEarlyClaimAvailable)), .waiting) + XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .maxFeeExceeded(requiredFeeSats: 1500, maxFeeSats: 1350))), .repriced(requiredFeeSats: 1500)) + XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .providerDeclined(message: "busy"))), .declined) + } + + private func quote(instant: ClaimDepositQuote?) -> FetchClaimDepositQuoteResponse { + FetchClaimDepositQuoteResponse( + amountSats: 15000, + confirmations: 0, + instant: instant, + mature: ClaimDepositQuote(confirmationsRequired: 3, creditAmountSats: 14093, feeSats: 907, maxFeeSats: 1000, isEstimate: false) + ) + } + + private func early(feeSats: UInt64, maxFeeSats: UInt64?, confirmations: UInt32 = 0) -> ClaimDepositQuote { + ClaimDepositQuote( + confirmationsRequired: confirmations, + creditAmountSats: 15000 - feeSats, + feeSats: feeSats, + maxFeeSats: maxFeeSats, + isEstimate: false + ) + } +} diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift new file mode 100644 index 000000000..d153d0ac6 --- /dev/null +++ b/BitkitTests/SparkConfigTests.swift @@ -0,0 +1,29 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +final class SparkConfigTests: XCTestCase { + func testConnectsWithoutBreezDataSyncOrLightningAddressServer() { + for network in [BreezSdkSpark.Network.mainnet, .regtest] { + let config = SparkManager.sdkConfig(network: network, apiKey: "test-key", isExiting: false) + + XCTAssertNil(config.realTimeSyncServerUrl, "\(network)") + XCTAssertNil(config.lnurlDomain, "\(network)") + XCTAssertFalse(config.useDefaultExternalInputParsers, "Parsing input must not look it up with a third party") + XCTAssertEqual(config.apiKey, "test-key") + XCTAssertTrue(config.leafOptimizationConfig.autoEnabled) + } + XCTAssertFalse(SparkManager.sdkConfig(network: .regtest, apiKey: nil, isExiting: true).leafOptimizationConfig.autoEnabled) + } + + func testInvoicesCarryNoSparkAddressOnAnyNetworkSoEveryPaymentIsALightningPaymentBitkitRecords() { + for network in [BreezSdkSpark.Network.mainnet, .regtest] { + XCTAssertFalse(SparkManager.sdkConfig(network: network, apiKey: nil, isExiting: false).preferSparkOverLightning, "\(network)") + } + } + + func testChainServiceIsBitkitElectrsOnMainnetOnly() { + XCTAssertEqual(Env.sparkChainServiceUrl(for: .bitcoin), "https://bitkit.to/electrs") + XCTAssertNil(Env.sparkChainServiceUrl(for: .regtest)) + } +} diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift new file mode 100644 index 000000000..cf102eb52 --- /dev/null +++ b/BitkitTests/SparkExitBackupTests.swift @@ -0,0 +1,197 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class SparkExitBackupTests: XCTestCase { + private let walletIndex = 99 + + override func tearDown() { + try? FileManager.default.removeItem(at: Env.sparkExitRestore(walletIndex: walletIndex)) + super.tearDown() + } + + func testUploadsTheExportedExitStateUnderItsOwnKey() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "{\"pedigrees\":[]}") + var uploads: [(String, Data)] = [] + let service = BackupService( + defaults: defaults, + backupData: { _ in try JSONEncoder().encode(backup) }, + uploadBackup: { uploads.append(($0, $1)) } + ) + + await service.triggerBackup(category: .sparkExit) + + let upload = try XCTUnwrap(uploads.first) + XCTAssertEqual(upload.0, "SPARK_EXIT") + let restored = try JSONDecoder().decode(SparkExitBackupV1.self, from: upload.1) + XCTAssertEqual(restored.exitState, backup.exitState) + XCTAssertEqual(restored.network, backup.network) + } + + /// R4: while an exit runs, a fresh export could lack the leaves it moves, so the backup from before it stays. + func testDoesNotRefreshTheExitDataWhileAnExitRuns() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var uploads: [String] = [] + var exports = 0 + let service = BackupService( + defaults: defaults, + backupData: { _ in + exports += 1 + return Data("{}".utf8) + }, + isSparkExitActive: { true }, + uploadBackup: { key, _ in uploads.append(key) } + ) + + await service.triggerBackup(category: .sparkExit) + + XCTAssertTrue(uploads.isEmpty) + XCTAssertEqual(exports, 0) + } + + func testAFailedExitRestoreBlocksReplacingTheRemoteBackupUntilItRecovers() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + var remoteIsReadable = false + var uploads: [String] = [] + let service = BackupService( + defaults: defaults, + backupData: { _ in export }, + uploadBackup: { key, _ in uploads.append(key) }, + waitForTemporaryFailureDelay: { _ in }, + fetchBackup: { _ in + guard remoteIsReadable else { throw URLError(.notConnectedToInternet) } + return nil + } + ) + + await service.restoreSparkExit() + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertTrue(uploads.isEmpty, "A fresh export replaced a remote exit backup the restore couldn't read") + + // The next attempt finds no older backup, so the fresh export may go up. + remoteIsReadable = true + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertEqual(uploads, ["SPARK_EXIT"]) + } + + /// Both rules at once: a pending restore during an exit uploads nothing; once the restore completes and the exit + /// ends, the upload goes through. + func testAPendingRestoreDuringAnExitUploadsNothingUntilBothClear() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + var remoteIsReadable = false + var isExiting = true + var uploads: [String] = [] + let service = BackupService( + defaults: defaults, + backupData: { _ in export }, + isSparkExitActive: { isExiting }, + uploadBackup: { key, _ in uploads.append(key) }, + waitForTemporaryFailureDelay: { _ in }, + fetchBackup: { _ in + guard remoteIsReadable else { throw URLError(.notConnectedToInternet) } + return nil + } + ) + + await service.restoreSparkExit() + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertTrue(uploads.isEmpty, "the restore is pending and an exit runs") + + // The restore completes, but the exit still runs: still nothing. + remoteIsReadable = true + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertTrue(uploads.isEmpty, "an exit still runs") + + isExiting = false + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertEqual(uploads, ["SPARK_EXIT"]) + } + + func testAnExitStateSparkHasNotImportedYetBlocksReplacingTheRemoteBackup() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let staged = Env.sparkExitRestore(walletIndex: LightningService.shared.currentWalletIndex) + try FileManager.default.createDirectory(at: staged.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("restored".utf8).write(to: staged) + defer { try? FileManager.default.removeItem(at: staged) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + var uploads: [String] = [] + let service = BackupService(defaults: defaults, backupData: { _ in export }, uploadBackup: { key, _ in uploads.append(key) }) + + _ = await service.triggerBackup(category: .sparkExit) + + XCTAssertTrue(uploads.isEmpty, "A fresh export replaced the remote backup whose exit data Spark hasn't imported") + } + + func testAnUnreadableRemoteExitBackupBacksOffInsteadOfRetryingEveryFewSeconds() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + let service = BackupService( + defaults: defaults, + backupData: { _ in export }, + uploadBackup: { _, _ in }, + fetchBackup: { _ in Data("not an exit backup".utf8) } + ) + + await service.restoreSparkExit() + let outcome = await service.triggerBackup(category: .sparkExit) + + XCTAssertEqual(outcome, .temporarilyUnavailable) + } + + func testTheLatestBackupTimeSkipsTheExitData() { + XCTAssertFalse(BackupService.latestBackupTimeCategories.contains(.sparkExit), "Reading the time downloads the exit data") + XCTAssertTrue(BackupService.latestBackupTimeCategories.contains(.wallet)) + } + + func testStagesARestoredExitStateUntilSparkConnects() async throws { + guard Env.network == .regtest else { throw XCTSkip("Spark's test network is regtest") } + let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "state") + + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: walletIndex) + + let staged = try String(contentsOf: Env.sparkExitRestore(walletIndex: walletIndex), encoding: .utf8) + XCTAssertEqual(staged, "state") + } + + func testSkipsAnExitStateFromAnotherNetwork() async throws { + let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "testnet", exitState: "state") + + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: walletIndex) + + XCTAssertFalse(FileManager.default.fileExists(atPath: Env.sparkExitRestore(walletIndex: walletIndex).path)) + } + + func testObservingRestartedOnAForegroundDoesNotMarkTheSameConnectionAgain() { + var mark = SparkExitBackupMark() + + XCTAssertFalse(mark.claim(version: nil), "Nothing to back up while Spark is disconnected") + XCTAssertTrue(mark.claim(version: 1)) + XCTAssertFalse(mark.claim(version: 1), "Spark stayed connected while observing restarted") + XCTAssertTrue(mark.claim(version: 2), "A changed exit state is backed up again") + } + + func testAnExitStateChangeMissedWhileObservingWasStoppedIsBackedUpOnRestart() { + var mark = SparkExitBackupMark() + _ = mark.claim(version: SparkManager.exitStateGeneration) + + // Spark reports a change while the node, and so backup observing, is stopped. + SparkManager.notifyExitStateChanged() + + XCTAssertTrue(mark.claim(version: SparkManager.exitStateGeneration), "The restart kept the stale exit data backup") + } +} diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift new file mode 100644 index 000000000..f571c2d4f --- /dev/null +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -0,0 +1,493 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +/// SDK 0.28: per-node funding, the fee bump and the retry of cooperative leaves the operators moved on-chain. +final class SparkExitFeeBumpTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private var saved: [SparkExitRecord] = [] + private let clock = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + saved = [] + } + + private func driver() -> SparkExitDriver { + SparkExitDriver( + chain: chain, + recovering: sdk, + makeSigner: { _ in StubCpfpSigner() }, + save: { [unowned self] in saved.append($0) }, + now: { [unowned self] in clock } + ) + } + + /// A per-node exit, running on generation 1 at 2 sat/vB, funded by one 20,000 sat payment. + private func runningRecord(strategy: String? = SparkExitRecord.perNodeStrategy) -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.fundingStrategy = strategy + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c1")]), at: .distantPast) + return record + } + + // MARK: Per-node funding + + func testTheQuotePreparesPerNode() async throws { + _ = try await SparkExitQuote.prepare(sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: []) + + XCTAssertEqual(sdk.prepareRequests.map(\.fundingStrategy), [.perNode]) + } + + /// The funding ask is one figure: one payment of `single_utxo_sats`, which the SDK splits per node. + func testTheQuoteAsksForOnePaymentAndCountsTheStepsItFunds() async throws { + var prepared = SparkExitFixtures.prepared(funding: 12000) + prepared.funding = RecoveryFunding( + singleUtxoSats: 12000, + perBranch: [ + PerBranchFunding(leafId: "leaf-1", fundingSats: 4000), + PerBranchFunding(leafId: "leaf-1", fundingSats: 3000), + PerBranchFunding(leafId: "leaf-1", fundingSats: 3000), + ], + strategy: .perNode + ) + sdk.preparedResponse = prepared + + let quote = try await SparkExitQuote.prepare(sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: []) + + XCTAssertEqual(quote.fundingSats, 12000) + XCTAssertEqual(quote.fundedSteps, 3) + } + + /// Every rebuild resumes with the strategy the exit was prepared with; one started before 0.28 stays per branch. + func testRebuildsKeepTheRecordsStrategy() async throws { + for (strategy, expected) in [(SparkExitRecord.perNodeStrategy, CpfpFundingStrategy.perNode), (nil, .perBranch)] { + sdk = FakeSparkExitRecovering() + var record = runningRecord(strategy: strategy) + record.isRebuilding = true + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + + _ = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.map(\.fundingStrategy), [expected]) + } + } + + /// With a single payment the SDK's fan-out splits it at the funding address: those outputs are the exit's own. + func testFanOutOutputsAreNotNewFunding() { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("fan", kind: .fanOut), + SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex, dependsOn: ["fan"]), + ])) + let utxos = [ + SparkExitFixtures.utxo("fan", vout: 0, value: 5000), + SparkExitFixtures.utxo("fan", vout: 1, value: 6000), + SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 900), + SparkExitFixtures.utxo("topup", value: 3000), + ] + + XCTAssertEqual(SparkExitMath.newFunding(utxos, record: record).map(\.txid), ["topup"]) + + // A rebuild keeps the replaced fan-out among the exit's own transactions. + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])) + XCTAssertEqual(SparkExitMath.newFunding(utxos, record: record).map(\.txid), ["topup"]) + } + + /// Case 4: a first payment short of the quote's funding. The hint asks for the rest plus the input fee of the + /// payment still to send (68 vB at 2 sat/vB), as the Progress top-up does. + func testThePrestartHintIncludesTheNextInputsFee() { + let funding = SparkExitMath.prestartFunding(fundingSats: 10000, payments: 1, receivedSats: 6000, feeRate: 2) + + XCTAssertEqual(funding.covered, 10000) + XCTAssertEqual(funding.toSend, 10136) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 6000, requiredSats: funding.toSend), 4136) + + // Two payments covering the quote are still short of the second input's fee. + let two = SparkExitMath.prestartFunding(fundingSats: 10000, payments: 2, receivedSats: 10000, feeRate: 2) + XCTAssertEqual(two.covered, 10136) + XCTAssertEqual(two.toSend, 10272) + + // One payment covering the quote is enough: nothing more to send. + let one = SparkExitMath.prestartFunding(fundingSats: 10000, payments: 1, receivedSats: 10000, feeRate: 2) + XCTAssertEqual(one.covered, 10000) + XCTAssertEqual(one.toSend, 10000) + XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 10000, requiredSats: one.toSend)) + } + + // MARK: Fee bump + + func testFeeBumpOffersOnlyRatesAboveTheExits() { + let options = SparkExitFeeBumpOption.options(exitRate: 5, fees: SparkExitNetworkFees(fastest: 12, halfHour: 8)) + XCTAssertEqual(options.map(\.rate), [12, 8]) + XCTAssertEqual(options.map(\.speed), [.fast, .normal]) + + let calm = SparkExitFeeBumpOption.options(exitRate: 5, fees: SparkExitNetworkFees(fastest: 4, halfHour: 3)) + XCTAssertEqual(calm.map(\.rate), [6], "a step stuck while estimates look low still gets one higher rate") + } + + func testFeeBumpCostIsTheExtraFundingAndItsPayment() { + var option = SparkExitFeeBumpOption(speed: .fast, rate: 12, extraSats: 5000) + XCTAssertNil(option.totalSats, "not priced until the savings payment fee is known") + option.paymentFeeSats = 300 + XCTAssertEqual(option.totalSats, 5300) + XCTAssertEqual(SparkExitFeeBumpOption(speed: .fast, rate: 12, extraSats: 0).totalSats, 0) + } + + /// The funding the exit holds covers the higher rate: nothing more to pay. + func testFeeBumpShortfallIsZeroWhenTheFundingCoversTheRate() async throws { + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1b")])] + + let shortfall = try await driver().feeBumpShortfall(runningRecord(), rate: 10) + + XCTAssertEqual(shortfall, 0) + XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) + XCTAssertEqual(sdk.prepareRequests.first?.fundingStrategy, .perNode) + XCTAssertEqual(sdk.prepareRequests.first?.selection, .specific(leafIds: ["leaf-1"])) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")]) + XCTAssertTrue(chain.broadcasts.isEmpty, "pricing broadcasts nothing") + } + + /// A rate at which the prepare succeeds but skips an exiting leaf for costing more than it holds is never offered. + func testFeeBumpRefusesARateAtWhichThePrepareSkipsAnExitingLeafForItsFee() async throws { + var record = runningRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(funding: nil) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 2000, reason: .feeExceedsValue)] + sdk.preparedResponse = prepared + + do { + _ = try await driver().feeBumpShortfall(record, rate: 10) + XCTFail("leaf-2 would stay on Spark at this rate") + } catch let error as SparkExitFeeBumpError { + XCTAssertEqual(error, .leavesBehind(sats: 2000)) + } + } + + /// A failed-leaf retry with steps of its own still to confirm keeps their reminders: it is not waiting only on the + /// operators. + func testAFailedLeafRetryWithStepsToConfirmIsNotWaitingOnlyOnTheOperators() { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)], failed: [failure])) + record.failedLeafRetrySince = 1000 + XCTAssertFalse(record.isWaitingForOperators, "c1 still has to confirm") + + record.recovery = StoredRecovery(SparkExitFixtures.recovery( + [SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1001))], + failed: [failure] + )) + XCTAssertTrue(record.isWaitingForOperators) + } + + /// Short at the higher rate: what the inputs lack, plus the input fee of the payment to come at the new rate + /// (68 vB at 10 sat/vB). + func testFeeBumpShortfallIsWhatTheInputsLackPlusTheNextInput() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 20000)] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25000, docsUrl: "") + + let shortfall = try await driver().feeBumpShortfall(runningRecord(), rate: 10) + + XCTAssertEqual(shortfall, 5000 + 680) + } + + /// Paid from savings: the exit waits for the payment as for a top-up, then rebuilds at the new rate. The rebuild is + /// neither a Redo nor a stall, so it adds no strike and no stall count. + func testAPaidFeeBumpRebuildsAtTheNewRateOnceTheFundingConfirms() async throws { + var record = runningRecord() + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680)) + XCTAssertEqual(record.fundingShortfallSats, 5680) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "bump")) + + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("bump", value: 5680)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1b", cpfp: "03c2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.feeRateSatPerVbyte, 10) + XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n1b"], "the live generation's ready step keeps going until the rebuild") + XCTAssertNil(record.stallRebuilds) + XCTAssertNil(record.isRedoRebuild) + } + + /// Right after savings paid a higher fee, the step that was underpriced is still in the log: Progress no longer + /// says the exit is underpriced, and no "needs more fees" reminder fires for a payment already on its way. + func testAPaidFeeBumpNeitherShowsFeeLowNorRemindsToFund() { + var record = runningRecord() + record.broadcastLog = [.init( + generation: record.generation, txid: "n1", attempts: 3, rejections: 0, lastAttemptAt: clock, + lastOutcome: "accepted", isAccepted: true, acceptedAtHeight: 990 + )] + XCTAssertTrue(record.showsFeeLow(tip: 1000)) + + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + + XCTAssertTrue(record.isAwaitingOwnPayment) + XCTAssertFalse(record.showsFeeLow(tip: 1000)) + XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains { $0.isFunding }) + + // Adopted, the payment no longer waits; a shortfall left after it reminds as before. + record.funding.append(.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "bump", vout: 0, valueSats: 5000)) + XCTAssertFalse(record.isAwaitingOwnPayment) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).filter(\.isFunding).count, 1) + } + + /// The funding already covers the higher rate: the exit rebuilds at once and replaces what has not confirmed. + func testAFeeBumpWithNothingToPayRebuildsAtOnce() async throws { + var record = runningRecord() + record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.isRebuilding) + + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1b", cpfp: "03c2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1b"]) + } + + // MARK: Leaves the operators moved on-chain + + private let failure = CooperativeRecoveryFailure( + leafId: "coop-1", + outputTxid: "watchtower", + outputVout: 0, + error: .operatorsUnavailable(message: "operators unreachable") + ) + + private func doneWithFailedLeaf() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)], failed: [failure])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 990))], failed: [failure]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: done, verdict: .done), count: 20) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)] + sdk.preparedResponse = prepared + return record + } + + /// Done with a failed cooperative leaf: the exit keeps it, tries it at once and then at Glow's doubling gaps + /// from 6 blocks, as a recovery of its own, and shows the wait instead of Done. + func testDoneWithAFailedLeafKeepsRetryingIt() async throws { + var record = doneWithFailedLeaf() + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.isWaitingForOperators) + XCTAssertEqual(record.failedLeaves, [.init(leafId: "coop-1", valueSats: 5000)]) + XCTAssertEqual(record.failedLeafRetrySince, 1000) + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.specific(leafIds: ["coop-1"])]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [], "a cooperative recovery needs no funding") + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty) + + chain.tip = 1005 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 1, "not before 6 blocks") + + chain.tip = 1006 + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.count, 2) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.landedSweepTxid, "s", "Done still names the sweep the retry replaced") + XCTAssertFalse(record.isWaitingForOperators) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["c1"]) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1007))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + chain.tip = 1008 + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.isFailedLeavesReleased) + } + + /// A retry of failed leaves that builds one and skips another while it may still move keeps the skipped one, + /// and tries it again once the built one landed. + func testAFailedLeafRetryKeepsALeafItSkipped() async throws { + let other = CooperativeRecoveryFailure( + leafId: "coop-2", + outputTxid: "watchtower", + outputVout: 1, + error: .operatorsUnavailable(message: "down") + ) + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["coop-1", "coop-2"] + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)], failed: [failure, other])) + let done = SparkExitFixtures.recovery( + [SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 990))], + failed: [failure, other] + ) + sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)] + prepared.skipped = [SkippedLeaf(leafId: "coop-2", valueSats: 4000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] + + record = try await driver().tick(record) + XCTAssertEqual(record.waitingLeafIds, ["coop-2"]) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1001))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + prepared.leaves = [] + sdk.preparedResponse = prepared + chain.tip = 1001 + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running, "coop-2 may still move") + XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["coop-2"])) + } + + /// A failed-leaf retry that built a recovery of its own can be sped up like any live recovery; its timing stays. + /// Coin selection needed more than the estimate: the next confirm approves the fee the refused one asked. + func testTheNextConfirmAfterARefusedHigherFeeApprovesTheFeeItWasRefusedFor() { + XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 420, refusedFeeSats: 600), 600, "the same refused fee was confirmed again") + XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 700, refusedFeeSats: 600), 700) + XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 420, refusedFeeSats: nil), 420) + } + + func testAFailedLeafRetryThatBuiltCanPayAHigherFee() async throws { + var record = doneWithFailedLeaf() + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertNotNil(record.failedLeafRetrySince) + + XCTAssertTrue(SparkExitManager.canPayHigherFee(record, tip: 1000, isNetworkAboveExitRate: true)) + var waiting = doneWithFailedLeaf() + waiting.failedLeafRetrySince = 1000 + waiting.recovery = StoredRecovery(SparkExitFixtures.recovery([], failed: [failure])) + XCTAssertFalse(SparkExitManager.canPayHigherFee(waiting, tip: 1000, isNetworkAboveExitRate: true), "nothing of its own is left to speed up") + } + + /// After about a week (1,008 blocks) the exit lets the leaf go and ends, saying what is left. + func testAFailedLeafIsReleasedAfterAWeek() async throws { + var record = doneWithFailedLeaf() + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + record = try await driver().tick(record) + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.isFailedLeavesReleased, true) + XCTAssertEqual(record.failedLeavesSats, 5000) + XCTAssertFalse(record.isRetryingFailedLeaves) + } + + func testIsRetryingFailedLeavesSurvivesTheRecord() throws { + var record = doneWithFailedLeaf() + record.failedLeaves = [.init(leafId: "coop-1", valueSats: 5000)] + record.failedLeafRetrySince = 1000 + record.failedLeafRetriedAt = 1006 + record.isFailedLeavesReleased = false + record.fundingStrategy = SparkExitRecord.perNodeStrategy + record.landedSweepTxid = "s" + // Whole milliseconds, as the record stores dates. + record.lastBuildAt = clock + + let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) + + XCTAssertEqual(decoded, record) + XCTAssertEqual(decoded.cpfpFundingStrategy, .perNode) + } + + // MARK: Copy + + /// The released leaves are on-chain under the operators' output, not on Spark. + func testFailedLeavesCopySaysTheyAreNotOnSpark() { + let text = t("lightning__spark_exit__failed_leaves", variables: ["amount": "₿ 5 000"]) + + XCTAssertTrue(text.contains("₿ 5 000")) + XCTAssertTrue(text.contains("not on Spark")) + XCTAssertFalse(text.contains("stayed on Spark")) + XCTAssertTrue(t("lightning__spark_exit__waiting_operators", variables: ["amount": "₿ 5 000"]).contains("up to a week")) + XCTAssertTrue(t("lightning__spark_exit__fee_low", variables: ["rate": "2"]).contains("Pay a higher fee")) + } +} + +/// Findings of spark-exit's rerun of the Glow hardening (case 4 and R8). +final class SparkExitRerunTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + } + + private func driver() -> SparkExitDriver { + SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { _ in StubCpfpSigner() }, save: { _ in }) + } + + /// Case 4: an ask of 2,283 at 2 sat/vB. The first hint already counts the second payment's input (136), and once + /// two payments are in, the hint and Progress name one full total, with the third payment's input too. + func testThePartialHintAsksForTheFullTotalWithEveryExtraInputsFee() { + let first = SparkExitMath.prestartFunding(fundingSats: 2283, payments: 1, receivedSats: 1283, feeRate: 2) + XCTAssertEqual(first.toSend, 2419) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 1283, requiredSats: first.toSend), 1136) + + let second = SparkExitMath.prestartFunding(fundingSats: 2283, payments: 2, receivedSats: 2283, feeRate: 2) + XCTAssertEqual(second.covered, 2419, "what two payments must hold to build") + XCTAssertEqual(second.toSend, 2555) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 2283, requiredSats: second.toSend), 294, "272, floored at dust") + + // Progress names the same total: "₿ 2 283 of ₿ 2 555 received, send ₿ 294 more." + let record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 2283)) + let utxos = [SparkExitFixtures.utxo("p1", value: 1283), SparkExitFixtures.utxo("p2", value: 1000)] + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: utxos), .ask(sats: 294, receivedSats: 2283)) + XCTAssertEqual(SparkExitMath.fundingAskTotal(record, utxos: utxos), 2555) + + // Paying the first hint's 1,136 builds without a second round: 2,419 covers two payments. + let paid = [SparkExitFixtures.utxo("p1", value: 1283), SparkExitFixtures.utxo("p2", value: 1136)] + XCTAssertNotNil(SparkExitMath.fundingTopUp(paid, requiredSats: 2283, inputFeeSats: 136)) + } + + /// R8: a record rebuilt from the chain keeps saying so after the rebuild is adopted, until a step confirms beyond + /// those already in a block. + func testTheRebuiltFromChainNoteStaysUntilTheRebuildMoves() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.isRebuiltFromChain = true + record.isRebuilding = true + let built = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]), + ]) + sdk.builds = [built] + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(record.isRebuiltFromChain, true, "still shown once the rebuild is adopted") + XCTAssertEqual(record.rebuiltFromChainConfirmedCount, 1) + + sdk.checks = [CheckRecoverFundsResponse(recovery: built, verdict: .valid)] + record = try await driver().tick(record) + XCTAssertEqual(record.isRebuiltFromChain, true, "nothing new confirmed") + + let moved = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"], status: .confirmed(blockHeight: 901)), + ]) + sdk.checks = [CheckRecoverFundsResponse(recovery: moved, verdict: .valid)] + record = try await driver().tick(record) + XCTAssertNil(record.isRebuiltFromChain) + XCTAssertNil(record.rebuiltFromChainConfirmedCount) + } +} diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift new file mode 100644 index 000000000..a3535d4f6 --- /dev/null +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -0,0 +1,348 @@ +@testable import Bitkit +import XCTest + +/// The next exit funding index across a restore: carried by the metadata backup, else found by a scan of the funding +/// addresses. +@MainActor +final class SparkExitFundingIndexTests: XCTestCase { + private let suiteName = "SparkExitFundingIndexTests" + private var defaults: UserDefaults! + private let chain = FakeSparkExitChain() + private let key = SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 0) + private let pendingKey = SparkExitFundingIndexes.recoveryPendingKey(network: "regtest", walletIndex: 0) + private var earlierAddressStore: SparkExitEarlierAddressStore! + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = UserDefaults(suiteName: suiteName) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("SparkExitFundingIndexTests-\(UUID().uuidString).json") + earlierAddressStore = SparkExitEarlierAddressStore(url: url) + } + + override func tearDown() { + UserDefaults().removePersistentDomain(forName: suiteName) + try? earlierAddressStore.delete() + super.tearDown() + } + + private func address(_ index: UInt32) -> String { + "bcrt1qfunding\(index)" + } + + private func path(_ index: UInt32) -> String { + "m/84'/1'/7797'/0/\(index)" + } + + private var recovery: SparkExitManager.FundingRecovery { + SparkExitManager.FundingRecovery( + defaults: defaults, + key: key, + pendingKey: pendingKey, + record: nil, + chain: chain, + earlierAddressStore: earlierAddressStore, + funding: { [unowned self] in (address($0), path($0)) } + ) + } + + private func nextFundingIndex() async throws -> UInt32 { + try await SparkExitManager.nextFundingIndex(recovery, now: now) + } + + private func keptCoins(_ index: UInt32, sats: UInt64) -> SparkExitEarlierAddress { + SparkExitEarlierAddress(address: address(index), path: path(index), kind: .keptChange, sats: sats, date: now) + } + + /// What a new device holds after a restore: the app's data is gone, and only the backed-up cache comes back. + private func restore(_ cache: AppCacheData) { + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = UserDefaults(suiteName: suiteName) + SparkExitFundingIndexes.restore(cache.sparkExitNextFundingIndexes, into: defaults) + } + + private func roundTrip(_ cache: AppCacheData) throws -> AppCacheData { + try JSONDecoder().decode(AppCacheData.self, from: JSONEncoder().encode(cache)) + } + + private func cache(sparkExitNextFundingIndexes: [String: Int]?) -> AppCacheData { + AppCacheData( + hasSeenContactsIntro: false, + hasSeenProfileIntro: false, + hasSeenNotificationsIntro: false, + hasSeenQuickpayIntro: false, + hasSeenShopIntro: false, + hasSeenTransferToSavingsIntro: false, + hasSeenWidgetsIntro: false, + hasDismissedWidgetsOnboardingHint: false, + appUpdateIgnoreTimestamp: 0, + backupIgnoreTimestamp: 0, + highBalanceIgnoreCount: 0, + highBalanceIgnoreTimestamp: 0, + dismissedSuggestions: [], + lastUsedTags: [], + sparkExitNextFundingIndexes: sparkExitNextFundingIndexes + ) + } + + /// Funding indexes 0 to 2 were used before the restore; index 2 still holds what a lost exit received. + func testARestoreFromABackupWithoutTheIndexScansPastTheFundingItsLastExitUsed() async throws { + defaults.set(3, forKey: key) + let oldBackup = try JSONDecoder().decode(AppCacheData.self, from: Data("{}".utf8)) + restore(oldBackup) + chain.usedAddresses = [address(0), address(1)] + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + let next = try await nextFundingIndex() + + XCTAssertEqual(next, 3) + XCTAssertTrue(chain.addressReads.contains(address(2)), "the funding at index 2 goes unseen") + XCTAssertEqual(chain.addressReads, (0 ... 2 + SparkExitMath.fundingScanGapLimit).map(address)) + XCTAssertEqual(defaults.object(forKey: key) as? Int, 3, "the next quote scans again") + } + + func testAFundingIndexRestoredFromTheBackupIsOnlyScannedForwardFrom() async throws { + defaults.set(3, forKey: key) + let backup = try roundTrip(cache(sparkExitNextFundingIndexes: SparkExitFundingIndexes.backup(defaults))) + restore(backup) + chain.usedAddresses = [address(0), address(1), address(2)] + + let next = try await nextFundingIndex() + + XCTAssertEqual(backup.sparkExitNextFundingIndexes, ["regtest/wallet0": 3], "keyed as bitkit-android keys it") + XCTAssertEqual(next, 3) + XCTAssertEqual(chain.addressReads, (3 ..< 3 + SparkExitMath.fundingScanGapLimit).map(address), "a backed-up index needs no scan below it") + } + + func testAQuoteWhileARestoreIsPendingScansPastTheRestoredCounterFirst() async throws { + // The backup carrying counter 4 never landed: address 3 still holds the lost exit's coins. + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1), address(2), address(3)] + chain.utxosByAddress[address(3)] = [SparkExitFixtures.utxo("lost", value: 7000)] + + let next = try await nextFundingIndex() + + XCTAssertEqual(next, 4, "the next exit funds from the lost exit's address") + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(3, sats: 7000)]) + XCTAssertFalse(defaults.bool(forKey: pendingKey)) + } + + func testAQuoteWhileARestoreIsPendingFailsWhenTheScanFails() async { + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.isAddressReadUnreadable = true + + do { + _ = try await nextFundingIndex() + XCTFail("a quote could fund from the address a lost exit used") + } catch {} + + XCTAssertEqual(defaults.integer(forKey: key), 3) + XCTAssertTrue(defaults.bool(forKey: pendingKey), "the restore no longer runs again") + } + + func testARestoreKeepsTheHigherIndexOfTheDeviceAndTheBackup() { + defaults.set(5, forKey: key) + + SparkExitFundingIndexes.restore(["regtest/wallet0": 3, "regtest/wallet1": 2], into: defaults) + + XCTAssertEqual(defaults.integer(forKey: key), 5, "an exit would fund from an address an earlier exit used") + XCTAssertEqual(defaults.integer(forKey: SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 1)), 2) + } + + func testAMetadataBackupWrittenBeforeTheIndexDecodesWithNone() throws { + let json = #"{"version":1,"createdAt":1,"tagMetadata":[],"cache":{"hasSeenContactsIntro":true}}"# + + let backup = try JSONDecoder().decode(MetadataBackupV1.self, from: Data(json.utf8)) + + XCTAssertTrue(backup.cache.hasSeenContactsIntro) + XCTAssertNil(backup.cache.sparkExitNextFundingIndexes) + } + + func testAFundingScanTheChainFailsKeepsNoIndex() async { + chain.isAddressReadUnreadable = true + + do { + _ = try await nextFundingIndex() + XCTFail("an exit could fund from an address an earlier exit used") + } catch {} + + XCTAssertNil(defaults.object(forKey: key)) + } + + func testAFundingScanReadsTheGapLimitPastTheLastUsedAddress() async { + var reads: [UInt32] = [] + + let next = await SparkExitMath.scanNextFundingIndex { index in + reads.append(index) + return [0, 2, 5].contains(index) + } + + XCTAssertEqual(next, 6) + XCTAssertEqual(reads, Array(0 ... 5 + SparkExitMath.fundingScanGapLimit)) + } + + func testAFundingScanFromTheTopOfTheIndexRangeEndsInsteadOfOverflowing() async { + let start = UInt32.max - 5 + + let next = await SparkExitMath.scanNextFundingIndex(from: start) { _ in true } + + XCTAssertEqual(next, UInt32.max) + } + + func testAFundingScanStopsAtItsCeilingWhenEveryAddressReadsAsUsed() async { + var reads: UInt32 = 0 + + let next = await SparkExitMath.scanNextFundingIndex { _ in + reads += 1 + return true + } + + XCTAssertEqual(next, SparkExitMath.fundingScanMaxAddresses) + XCTAssertEqual(reads, SparkExitMath.fundingScanMaxAddresses) + } + + func testAnAddressWithATransactionInABlockOrTheMempoolReadsAsUsed() async throws { + let http = SparkExitBroadcasterTests.FakeHTTP() + let stats = { (chain: Int, mempool: Int) in + #"{"chain_stats":{"tx_count":\#(chain)},"mempool_stats":{"tx_count":\#(mempool)}}"# + } + http.responses = [ + ("a.example/api/address/bcrt1qmempool", 200, stats(0, 1)), + ("a.example/api/address/bcrt1qspent", 200, stats(2, 0)), + ("a.example/api/address/bcrt1qfresh", 200, stats(0, 0)), + ] + let esplora = try EsploraSparkExitChain(hosts: [XCTUnwrap(URL(string: "https://a.example/api"))], http: http) + + let isMempoolUsed = try await esplora.isAddressUsed(address: "bcrt1qmempool") + let isSpentUsed = try await esplora.isAddressUsed(address: "bcrt1qspent") + let isFreshUsed = try await esplora.isAddressUsed(address: "bcrt1qfresh") + + XCTAssertTrue(isMempoolUsed) + XCTAssertTrue(isSpentUsed) + XCTAssertFalse(isFreshUsed) + } + + // MARK: Earlier exit addresses after a restore + + func testARestoredWalletWithNoCounterWatchesTheFundingAddressesThatStillHoldCoins() async throws { + // Index 2 still holds what a lost exit received; 0 and 1 were spent by earlier exits. + chain.usedAddresses = [address(0), address(1)] + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, 3) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched") + XCTAssertEqual(defaults.object(forKey: key) as? Int, 3) + } + + func testAFundingAddressWithHistoryButNoCoinsIsNotWatched() async throws { + chain.usedAddresses = [address(0)] + chain.utxosByAddress[address(1)] = [SparkExitFixtures.utxo("lost", value: 700)] + + try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(1, sats: 700)]) + } + + func testARestoredCounterBringsBackTheFundingAddressesThatStillHoldCoins() async throws { + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1)] + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched") + XCTAssertEqual(chain.addressReads, (3 ..< 23).map(address), "a restored counter is scanned forward from, once") + XCTAssertNil(defaults.object(forKey: pendingKey), "every start reads the funding addresses again") + XCTAssertEqual(defaults.integer(forKey: key), 3) + } + + func testARestoredCounterThatPredatesTheLastExitsFundingFindsItsCoins() async throws { + // The backup carrying counter 4 never landed: the exit that funded address 3 is lost with it. + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1), address(2), address(3)] + chain.utxosByAddress[address(3)] = [SparkExitFixtures.utxo("lost", value: 7000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, 4) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(3, sats: 7000)], "address 3's coins stay hidden") + XCTAssertEqual(defaults.integer(forKey: key), 4, "the next exit reuses address 3") + } + + func testARestoredCounterAtAForgottenExitsAddressListsItsLatePaymentAsKeptChange() async throws { + // The exit at address 3 was forgotten before funding, so the counter went back to 3; a payment came late. + try earlierAddressStore.add(SparkExitEarlierAddress(address: address(3), path: path(3), kind: .cancelledFunding, sats: nil, date: now)) + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1), address(2), address(3)] + chain.utxosByAddress[address(3)] = [SparkExitFixtures.utxo("late", value: 4000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, 4) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(3, sats: 4000)], "the late payment is watched only until its cancel expires") + } + + func testARestoredCounterPastTheScanCeilingStillScansForward() async throws { + let ceiling = Int(SparkExitMath.fundingScanMaxAddresses) + SparkExitFundingIndexes.restore(["regtest/wallet0": ceiling], into: defaults) + chain.usedAddresses = [address(UInt32(ceiling))] + chain.utxosByAddress[address(UInt32(ceiling))] = [SparkExitFixtures.utxo("lost", value: 7000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, UInt32(ceiling) + 1) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(UInt32(ceiling), sats: 7000)]) + } + + func testAKeptCounterWithNoRestorePendingReadsNoFundingAddress() async throws { + defaults.set(3, forKey: key) + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertTrue(chain.addressReads.isEmpty) + XCTAssertEqual(try earlierAddressStore.load(), []) + } + + func testTheMetadataBackupCarriesTheEarlierExitAddresses() throws { + try earlierAddressStore.add(keptCoins(2, sats: 600)) + try earlierAddressStore.add(SparkExitEarlierAddress(address: address(0), path: path(0), kind: .cancelledFunding, sats: nil, date: now)) + + let backup = SparkExitEarlierAddressBackup.backup(earlierAddressStore, walletKey: "regtest/wallet0") + + let millis: Int64 = 1_800_000_000_000 + XCTAssertEqual(backup, ["regtest/wallet0": [ + SparkExitEarlierAddressBackupV1(address: address(2), path: path(2), kind: "keptChange", atMillis: millis), + SparkExitEarlierAddressBackupV1(address: address(0), path: path(0), kind: "cancelledFunding", atMillis: millis), + ]]) + } + + func testARestoreListsTheBackedUpEarlierAddressesAndKeepsTheDevices() throws { + let onDevice = keptCoins(1, sats: 600) + try earlierAddressStore.add(onDevice) + let backedUp = [ + SparkExitEarlierAddressBackupV1(address: address(1), path: path(1), kind: "cancelledFunding", atMillis: 1000), + SparkExitEarlierAddressBackupV1(address: address(2), path: path(2), kind: "keptChange", atMillis: 2000), + SparkExitEarlierAddressBackupV1(address: address(3), path: path(3), kind: "someLaterKind", atMillis: 3000), + ] + + SparkExitEarlierAddressBackup.restore(["regtest/wallet0": backedUp, "bitcoin/wallet0": backedUp], network: "regtest") { _ in + self.earlierAddressStore + } + + let restored = SparkExitEarlierAddress(address: address(2), path: path(2), kind: .keptChange, sats: nil, date: Date(timeIntervalSince1970: 2)) + XCTAssertEqual(try earlierAddressStore.load(), [onDevice, restored]) + } + + func testAMetadataBackupWithAnUnreadableEarlierAddressListKeepsTheRest() throws { + let json = #"{"version":1,"createdAt":1,"tagMetadata":[],"cache":{"hasSeenContactsIntro":true},"sparkExitEarlierAddresses":{"regtest/wallet0":[{"address":1}]}}"# + + let backup = try JSONDecoder().decode(MetadataBackupV1.self, from: Data(json.utf8)) + + XCTAssertTrue(backup.cache.hasSeenContactsIntro, "one bad entry fails the whole metadata restore") + XCTAssertNil(backup.sparkExitEarlierAddresses) + } +} diff --git a/BitkitTests/SparkExitRegtestTests.swift b/BitkitTests/SparkExitRegtestTests.swift new file mode 100644 index 000000000..16135a8b6 --- /dev/null +++ b/BitkitTests/SparkExitRegtestTests.swift @@ -0,0 +1,174 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +/// Runs the exit's SDK and chain calls against Spark's hosted regtest. It needs the network, so it only runs with +/// `TEST_RUNNER_SPARK_EXIT_REGTEST=1` in the environment of `xcodebuild test`. +final class SparkExitRegtestTests: XCTestCase { + private var storageDir: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + guard ProcessInfo.processInfo.environment["SPARK_EXIT_REGTEST"] == "1" else { + throw XCTSkip("Set TEST_RUNNER_SPARK_EXIT_REGTEST=1 to run against Spark's regtest") + } + storageDir = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-regtest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) + } + + override func tearDown() { + if let storageDir { + try? FileManager.default.removeItem(at: storageDir) + } + super.tearDown() + } + + func testQuotesAnEmptyWalletAndReadsTheChain() async throws { + let mnemonic = try generateMnemonic(wordCount: .words12) + let sdk = try await BreezSdkSpark.connect(request: ConnectRequest( + config: defaultConfig(network: .regtest), + seed: .mnemonic(mnemonic: mnemonic, passphrase: nil), + storageDir: storageDir.path + )) + defer { Task { try? await sdk.disconnect() } } + let funding = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + let recovering = BreezSparkExitRecovering(sdk: sdk) + let feeRate = try await max(1, sdk.recommendedFees().fastestFee) + + let prepared = try await recovering.prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRate, + fundingKind: .p2wpkh, + destination: funding.address, + selection: .all + )) + + XCTAssertTrue(prepared.leaves.isEmpty) + XCTAssertFalse(SparkExitQuote(prepared: prepared).isWorthExiting) + + let chain = EsploraSparkExitChain.network(.regtest) + let tip = try await chain.tipHeight() + XCTAssertGreaterThan(tip, 0) + let utxos = try await chain.utxos(address: funding.address) + XCTAssertTrue(utxos.isEmpty) + } + + /// A parent spending a missing input with its child, as a package: the live node reads the body and answers + /// per transaction, which the classifier reads as retry later rather than a rejection. + func testLiveNodeAnswersAPackageTheClassifierRetries() async { + let parent = SparkExitFixtures.parentHex + let child = "0200000001" + Data(SparkExitFixtures.parentTxid.hexaBytes.reversed()).hex + + "0000000000ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000" + let tx = RecoveryTransaction( + kind: .node, + nodeId: "n", + txid: SparkExitFixtures.parentTxid, + txHex: parent, + cpfpTxHex: child, + csvTimelockBlocks: nil, + dependsOn: [], + status: .ready + ) + let chain = EsploraSparkExitChain.network(.regtest) + + let outcome = await chain.broadcast(tx) + + guard case let .retryLater(reason) = outcome else { return XCTFail("Expected retry later, got \(outcome)") } + XCTAssertTrue(reason.contains("missingorspent"), reason) + } + + /// The background tasks' entry point from a cold start, in a process with no UI and no node: the keyless pass + /// sends a stored package to the live node, then the Spark SDK connects, checks the stored exit and rebuilds it. + /// Logs the wall time per step and the process footprint, for the refresh and processing budgets. + @MainActor + func testBackgroundRunFromAColdStart() async throws { + let walletIndex = 7 + let mnemonic = try generateMnemonic(wordCount: .words12) + try Keychain.upsert(key: .bip39Mnemonic(index: walletIndex), data: Data(mnemonic.utf8)) + let store = SparkExitStore(walletIndex: walletIndex) + defer { + try? Keychain.delete(key: .bip39Mnemonic(index: walletIndex)) + try? store.delete() + try? FileManager.default.removeItem(at: Env.sparkStorage(walletIndex: walletIndex)) + } + let funding = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + var record = SparkExitRecord( + network: "regtest", + createdAt: Date(), + phase: .running, + feeRateSatPerVbyte: 1, + destination: funding.address, + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: funding.address, pubkey: funding.pubkey)], + expectedSats: 1000 + ) + let child = "0200000001" + Data(SparkExitFixtures.parentTxid.hexaBytes.reversed()).hex + + "0000000000ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000" + // The shape of the live regtest exit: a fan-out, 24 node packages, 4 refunds and the sweep. Only the fan-out is + // a real transaction; the rest wait for parents the chain host has never seen. + func txid(_ i: Int) -> String { + String(repeating: String(format: "%02x", i), count: 32) + } + let nodes = (1 ... 24).map { SparkExitFixtures.tx( + txid($0), + cpfp: "03c\($0)", + dependsOn: [SparkExitFixtures.parentTxid], + status: .waitingForDependencies + ) } + let refunds = (25 ... 28).map { SparkExitFixtures.tx(txid($0), kind: .refund, dependsOn: [txid($0 - 24)], status: .waitingForDependencies) } + let sweep = SparkExitFixtures.tx(txid(29), kind: .sweep, dependsOn: (25 ... 28).map(txid), status: .waitingForDependencies) + var stored = SparkExitFixtures.recovery([SparkExitFixtures.tx(SparkExitFixtures.parentTxid, cpfp: child)] + nodes + refunds + [sweep]) + stored.transactions[0].txHex = SparkExitFixtures.parentHex + record.adopt(stored, at: Date(timeIntervalSinceNow: -3600)) + try store.save(record) + UserDefaults.standard.set(walletIndex, forKey: "sparkExitWalletIndex") + + let baseline = SparkExitManager.footprintMegabytes() + let peak = PeakFootprint() + let sampler = Task.detached { + while !Task.isCancelled { + peak.sample() + try? await Task.sleep(nanoseconds: 20_000_000) + } + } + let cold = await SparkExitManager.shared.runInBackground(deadline: Date(timeIntervalSinceNow: 25)) + let warm = await SparkExitManager.shared.runInBackground(deadline: Date(timeIntervalSinceNow: 25)) + let prepareStarted = Date() + let prepared = try await XCTUnwrap(SparkManager.shared.exitRecovering).prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: 1, + fundingKind: .p2wpkh, + destination: funding.address, + selection: .all + )) + let prepareSeconds = Date().timeIntervalSince(prepareStarted) + sampler.cancel() + peak.sample() + + func line(_ outcome: SparkExitBackgroundRun.Outcome) -> String { + SparkExitBackgroundRun.Step.allCases.compactMap { step in + outcome.seconds[step].map { "\(step.rawValue)=\(String(format: "%.2f", $0))s" } + }.joined(separator: " ") + } + print("SPARK_EXIT_BG cold: \(line(cold)) phase=\(String(describing: cold.record?.phase))") + print("SPARK_EXIT_BG warm: \(line(warm)) phase=\(String(describing: warm.record?.phase))") + print("SPARK_EXIT_BG prepare(all): \(String(format: "%.2f", prepareSeconds))s leaves=\(prepared.leaves.count)") + print("SPARK_EXIT_BG footprint: baseline=\(baseline)MB peak=\(peak.value)MB end=\(SparkExitManager.footprintMegabytes())MB") + XCTAssertTrue(cold.isConnected) + XCTAssertNotNil(cold.seconds[.broadcast]) + XCTAssertFalse(LightningService.shared.hasNode) + } +} + +private final class PeakFootprint: @unchecked Sendable { + private let lock = NSLock() + private var peak = 0 + + var value: Int { + lock.withLock { peak } + } + + func sample() { + let now = SparkExitManager.footprintMegabytes() + lock.withLock { peak = max(peak, now) } + } +} diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift new file mode 100644 index 000000000..b05084581 --- /dev/null +++ b/BitkitTests/SparkExitReviewTests.swift @@ -0,0 +1,1265 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +/// Findings of the independent review of the exit: cancel, heights without a tip, funding waits, reminders, sweep +/// bounds and salvage. +final class SparkExitReviewTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private let clock = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + } + + private func driver(withSdk: Bool = true) -> SparkExitDriver { + SparkExitDriver(chain: chain, recovering: withSdk ? sdk : nil, makeSigner: { _ in StubCpfpSigner() }, save: { _ in }) + } + + // MARK: Review 518bf31e5 + + /// Funding sent from another wallet before the user picked savings is not paid twice. + func testSavingsPaysOnlyWhatTheFundingAddressDoesNotHoldYet() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 25000, seenCount: 1, feeRate: 2), 0) + } + + /// Savings completing a partial payment from another wallet is one more funding input: it pays that input's fee + /// too, so both payments together are adopted. A 20,000 quote at 2 sat/vB with 8,000 received needs 20,136. + func testSavingsCompletingPartialFundingPaysTheExtraInputsFee() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + let sats = SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 8000, seenCount: 1, feeRate: 2) + + XCTAssertEqual(sats, 12136, "savings left the exit short of the second input's fee") + let paid = [ + SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 8000), + SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: sats), + ] + XCTAssertNotNil(SparkExitMath.fundingTopUp(paid, requiredSats: 20000, inputFeeSats: SparkExitMath.inputVbytes * 2)) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 0, seenCount: 0, feeRate: 2), 20000) + } + + /// Savings never pays under the 294-sat dust limit, which the onchain wallet refuses: a 20,000 quote at 2 sat/vB + /// with 19,900 received misses 236 sats and pays 294. + func testSavingsCompletingAlmostFullFundingPaysAtLeastTheDustLimit() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + let sats = SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 19900, seenCount: 1, feeRate: 2) + XCTAssertEqual(sats, SparkExitMath.fundingDustSats, "savings paid a dust amount") + } + + /// A tapped notification for an exit whose result was seen already is let go once the record loaded, so a later + /// exit's result never jumps to Progress. + func testATappedNotificationIsLetGoOnceTheLoadedExitShowsNoResult() { + XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: false), .wait) + XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: true, hasLoadedRecord: true), .open) + XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: true), .drop) + } + + /// A cancel of an exit that can no longer be cancelled says so, not that the address could not be read. + @MainActor func testACancelOfAnExitNoLongerCancellableIsNotAFailedRead() async { + let exit = SparkExitManager(walletIndex: 9999) + let outcome = await exit.cancel() + XCTAssertEqual(outcome, .notCancellable) + } + + /// A forgotten exit gives its funding address back, so a payment that lands there late is seen by the next exit. + func testAForgottenExitGivesItsFundingAddressBack() { + XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(4, index: 3), 3) + XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(6, index: 3), 6, "a later address is in use") + } + + /// A wipe deletes the copies of an unreadable record kept aside, which hold the wiped wallet's addresses. + func testAWipeDeletesTheRecordsCopiesKeptAside() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + try store.save(SparkExitFixtures.record()) + try store.keepCopyAside() + + try store.deleteWithCopies() + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: dir.path), []) + } + + // MARK: H1 cancel + + func testCancelIsOffAfterAPaymentFromSavings() { + var record = SparkExitFixtures.record() + XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 1000), "a payment was seen") + record.fundingPaymentTxid = "paid" + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0), "paid from savings, not seen yet") + } + + /// Cancel reads the address first: funding that arrived since the last tick goes back to savings, and an address + /// that cannot be read keeps the exit. + func testCancelNeverOrphansFundingAtTheAddress() { + XCTAssertEqual(SparkExitManager.cancelOutcome(utxos: []), .forget) + XCTAssertEqual(SparkExitManager.cancelOutcome(utxos: nil), .keep) + XCTAssertEqual( + SparkExitManager.cancelOutcome(utxos: [SparkExitFixtures.utxo("late", value: 5000, confirmed: false)]), + .sweepFunding([.init(txid: "late", vout: 0, valueSats: 5000)]) + ) + } + + /// A cancelled exit with funding sweeps it like any ended exit's leftover, and posts no "stopped" notification. + func testACancelledExitSweepsItsFundingAndPostsNoFailure() { + var record = SparkExitFixtures.record() + record.phase = .failed(reason: SparkExitRecord.cancelledReason) + record.isCancelled = true + record.isResultSeen = true + record.leftover = [.init(txid: "late", vout: 0, valueSats: 5000)] + + XCTAssertTrue(record.isLeftoverSweepPending) + XCTAssertTrue(record.showsResult) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty) + } + + // MARK: H2 no height without a tip + + /// A tip read that fails while the sweep would wait writes no height, so the next tick does not read a 0 as four + /// weeks gone and keep the change. + func testASweepWithoutATipWritesNoHeightAndKeepsNothing() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.feeRateSatPerVbyte = 20 + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 300)] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c1", value: 300)] + chain.isTipUnreadable = true + + record = try await driver().tick(record) + XCTAssertNil(record.leftoverSweep, "nothing changes without the tip") + + chain.isTipUnreadable = false + chain.economyFee = 20 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000) + + chain.isTipUnreadable = true + chain.tip = 900_000 + record = try await driver().tick(record) + XCTAssertNotEqual(record.leftoverSweep?.isDustKept, true) + XCTAssertTrue(record.isLeftoverSweepPending) + } + + // MARK: H3 driving while a top-up is awaited + + private func bumpedRecord() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]), + ]), at: .distantPast) + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + return record + } + + func testTheLiveGenerationKeepsGoingWhileAHigherFeesTopUpIsAwaited() async throws { + var record = bumpedRecord() + chain.mempoolTxids = ["bump"] + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680)) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "the ready step still goes out") + XCTAssertTrue(sdk.recoverRequests.isEmpty, "no rebuild before the top-up confirms") + let reminders = SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest) + XCTAssertFalse(reminders.contains { $0.isFunding }, "the payment is on its way") + XCTAssertTrue(reminders.contains { $0.id.hasPrefix("1-") }, "the live steps keep their reminders") + } + + /// A Redo while a higher fee's top-up is awaited: the diverged generation is never sent again, and when the funding + /// held pays for less, its rebuild waits for the top-up as a Redo rebuild. + func testARedoWhileAHigherFeesTopUpIsAwaitedStopsDrivingTheDivergedGeneration() async throws { + var record = bumpedRecord() + chain.mempoolTxids = ["bump"] + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25680, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 46360)) + XCTAssertEqual(record.fundingPaymentTxid, "bump", "the awaited top-up was dropped") + XCTAssertTrue(record.isRebuilding) + XCTAssertEqual(record.isRedoRebuild, true) + XCTAssertFalse(record.isDrivingWhileFunding, "the diverged generation would be sent again") + } + + /// A payment no host knows for three blocks is forgotten: the ask and the funding reminder come back. + func testADroppedSavingsPaymentBringsTheAskBack() async throws { + var record = bumpedRecord() + sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 3) + + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentMissingSince, 1000) + XCTAssertEqual(record.fundingPaymentTxid, "bump", "one read is not enough") + + chain.tip = 1003 + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertFalse(record.isAwaitingOwnPayment) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1003, now: clock, network: .regtest).contains { $0.isFunding }) + } + + /// A payment one host misses just before a block and just after it, seconds apart, may still be indexing: it is + /// kept. A read that finds it starts the count over; only a miss that lasts three blocks forgets it. + func testASavingsPaymentMissedForABlockOrASingleReadIsKept() async throws { + var record = bumpedRecord() + sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 6) + + record = try await driver().tick(record) + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, "bump", "a one-block miss asked for the funding again") + + chain.mempoolTxids = ["bump"] + chain.tip = 1002 + record = try await driver().tick(record) + chain.mempoolTxids = [] + chain.tip = 1003 + record = try await driver().tick(record) + chain.tip = 1005 + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, "bump") + chain.tip = 1006 + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + } + + /// One still unadopted after a day is forgotten too, known or not. + func testASavingsPaymentNotAdoptedInADayIsForgotten() async throws { + var record = bumpedRecord() + chain.mempoolTxids = ["bump"] + sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 2) + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentSeenAtHeight, 1000) + + chain.tip = 1000 + SparkExitDriver.ownPaymentTimeoutBlocks + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + } + + /// A savings payment that confirms short of a higher re-quoted funding is no longer waited for: the rest is asked + /// for, and reminded about at once. + func testASavingsPaymentConfirmedShortRemindsForTheRestAtOnce() async throws { + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 35000)) + record.fundingPaymentTxid = "paid" + chain.mempoolTxids = ["paid"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("paid", value: 20000, confirmed: false)] + + record = try await driver().tick(record) + XCTAssertTrue(record.isAwaitingOwnPayment, "still confirming") + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest).isEmpty) + + chain.confirmedTxids = ["paid"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("paid", value: 20000)] + record = try await driver().tick(record) + + XCTAssertFalse(record.isAwaitingOwnPayment) + let reminders = SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest) + XCTAssertEqual(reminders.map(\.isFunding), [true]) + XCTAssertEqual(reminders.first?.fireAt, record.createdAt, "at once, not 4 hours after the start") + } + + /// A short rebuild after a Redo stays parked: its generation diverged and is never sent again. + func testADivergedRebuildsTopUpSendsNothing() async throws { + var record = bumpedRecord() + record.isRebuilding = true + chain.mempoolTxids = ["bump"] + + record = try await driver().tick(record) + + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertTrue(sdk.checkedRecoveries.isEmpty) + } + + // MARK: M2 snapshot before pricing and the failed-leaf retry + + func testFeeBumpPricingImportsTheExitDataSnapshotFirst() async throws { + var record = bumpedRecord() + record.phase = .running + record.exitStateSnapshot = "snapshot-1" + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])] + + _ = try await driver().feeBumpShortfall(record, rate: 12) + + XCTAssertEqual(sdk.imports, ["snapshot-1"]) + } + + // MARK: M4 a started exit never funded + + func testAnExitNeverFundedReminds() { + let record = SparkExitFixtures.record() + let reminders = SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest) + XCTAssertEqual(reminders.map(\.id), ["await-funding-20000"]) + XCTAssertEqual(reminders.first?.isFunding, true) + XCTAssertEqual(reminders.first?.fireAt, record.createdAt.addingTimeInterval(SparkExitMath.reminderSeconds(network: .regtest))) + + var paid = record + paid.fundingPaymentTxid = "paid" + XCTAssertTrue(SparkExitMath.reminders(paid, tip: 1000, now: record.createdAt, network: .regtest).isEmpty) + } + + // MARK: M3 fired reminders + + func testAFiredReminderStaysRememberedWhenItLeavesThePlanForARun() { + let now = clock + let remembered = ["spark-exit-1-slow-n1": now.addingTimeInterval(-60).timeIntervalSince1970] + let kept = SparkExitManager.keepingFired([:], remembered: remembered, now: now) + XCTAssertEqual(kept, remembered) + + let old = ["spark-exit-1-slow-n1": now.addingTimeInterval(-SparkExitManager.firedReminderMemory - 1).timeIntervalSince1970] + XCTAssertTrue(SparkExitManager.keepingFired([:], remembered: old, now: now).isEmpty, "forgotten after 30 days") + let ahead = ["spark-exit-x": now.addingTimeInterval(600).timeIntervalSince1970] + XCTAssertTrue(SparkExitManager.keepingFired([:], remembered: ahead, now: now).isEmpty, "one not fired yet follows the plan") + } + + // MARK: M6 sweep bounds + + /// One unconfirmed output holds the sweep for one recheck at most; then the confirmed ones go. + func testAnUnconfirmedOutputHoldsTheSweepBackForOneRecheckAtMost() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)] + chain.economyFee = 1 + chain.utxosByAddress["bcrt1qfunding"] = [ + SparkExitFixtures.utxo("c1", value: 5000), + SparkExitFixtures.utxo("stuck", value: 400, confirmed: false), + ] + var signed = 0 + let driver = SparkExitDriver(chain: chain, recovering: nil, makeSigner: { _ in + signed += 1 + return StubCpfpSigner() + }, save: { _ in }) + + record = try await driver.tick(record) + XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000) + XCTAssertEqual(signed, 0) + + chain.tip = 1000 + SparkExitDriver.sweepRecheckBlocks + _ = try? await driver.tick(record) + XCTAssertEqual(signed, 1, "the confirmed output is swept") + } + + /// A built sweep a node answers with missing inputs is built again from the address from the next block. + func testABuiltSweepWithMissingInputsIsBuiltAgain() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)] + record.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4700, feeRateSatPerVbyte: 2, builtAtHeight: 1000) + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + + record = try await driver(withSdk: false).tick(record) + + XCTAssertNil(record.leftoverSweep?.txid) + XCTAssertEqual(record.leftoverSweep?.retryAtHeight, 1001) + XCTAssertTrue(record.isLeftoverSweepPending) + } + + /// A replacement refused for another reason while the sweep it replaced is still held is not final. + func testARefusedReplacementKeepsFollowingTheSweepItReplaced() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)] + record.leftoverSweep = .init(txid: "s2", txHex: "02", valueSats: 4700, feeRateSatPerVbyte: 3, replacedTxids: ["s1"], builtAtHeight: 1000) + chain.mempoolTxids = ["s1"] + chain.outcome = .rejected("non-mandatory-script-verify-flag") + + record = try await driver(withSdk: false).tick(record) + + XCTAssertNil(record.leftoverSweep?.rejection) + XCTAssertEqual(record.leftoverSweep?.replacedTxids, ["s1"]) + XCTAssertTrue(record.isLeftoverSweepPending) + } + + // MARK: M7 salvage + + func testASalvagedRecordKeepsTheChainedTxidsAndReplacesTheOriginalInOneWrite() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + let lost = SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")) + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.earlierCpfpTxids = ["child-1"] + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(record)) as? [String: Any]) + json["version"] = 99 + try JSONSerialization.data(withJSONObject: json).write(to: store.url) + + let loaded = try XCTUnwrap(SparkExitManager.loadRecord(store: store, lostLeafStore: lost)) + + XCTAssertEqual(loaded.earlierCpfpTxids, ["child-1"]) + XCTAssertEqual(try store.load()?.isRebuiltFromChain, true, "the salvaged record is in place") + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + XCTAssertTrue(files.contains { $0.contains("unreadable") }, "the original is kept aside") + } + + // MARK: Bot review threads + + /// The background connect's timeout returns on time, without waiting for the connect it gives up on. + func testTheConnectTimeoutReturnsWithoutWaitingForTheConnect() async { + let started = Date() + let result: Bool? = await SparkManager.firstOf(timeout: 0.02) { + try? await Task.sleep(nanoseconds: 600_000_000) + return true + } + XCTAssertNil(result) + XCTAssertLessThan(Date().timeIntervalSince(started), 0.3) + let quick: Bool? = await SparkManager.firstOf(timeout: 5) { true } + XCTAssertEqual(quick, true) + } + + /// A tick that built or published meanwhile leaves nothing cancel can act on: the reloaded record is revalidated. + func testCancelRevalidatesARecordATickBuiltMeanwhile() { + var record = SparkExitFixtures.record() + XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + } + + /// A sweep that confirms while it left outputs out (too small, or unconfirmed when built) keeps following them. + func testAConfirmedPartialSweepKeepsFollowingWhatItLeftOut() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000), .init(txid: "c2", vout: 0, valueSats: 300)] + record.leftoverSweep = .init(txid: "s1", txHex: "02", valueSats: 4700, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + chain.confirmedTxids = ["s1"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c2", value: 300)] + + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(record.sweptLeftover, [.init(txid: "s1", valueSats: 4700)]) + XCTAssertEqual(record.leftover, [.init(txid: "c2", vout: 0, valueSats: 300)]) + XCTAssertEqual(record.leftoverSweep?.isWaitingForFees, true) + XCTAssertTrue(record.isLeftoverSweepPending) + XCTAssertEqual(record.leftoverLine, .waiting(sats: 300)) + } + + /// A partial sweep that confirms while the funding address cannot be read stays driven, and follows what it left + /// out once the address reads again. + func testAConfirmedPartialSweepStaysDrivenUntilTheAddressReads() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000), .init(txid: "c2", vout: 0, valueSats: 300)] + record.leftoverSweep = .init(txid: "s1", txHex: "02", valueSats: 4700, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + chain.confirmedTxids = ["s1"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c2", value: 300)] + + chain.isTipUnreadable = true + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(record.needsDriving, "the tip could not be read") + + chain.isTipUnreadable = false + chain.isUtxosUnreadable = true + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(record.needsDriving, "the funding address could not be read") + XCTAssertNil(record.sweptLeftover) + record = try JSONDecoder().decode(SparkExitRecord.self, from: JSONEncoder().encode(record)) + XCTAssertTrue(record.needsDriving, "after a restart") + + chain.isUtxosUnreadable = false + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(record.sweptLeftover, [.init(txid: "s1", valueSats: 4700)]) + XCTAssertEqual(record.leftover, [.init(txid: "c2", vout: 0, valueSats: 300)]) + XCTAssertEqual(record.leftoverSweep?.isWaitingForFees, true) + XCTAssertTrue(record.needsDriving) + } + + /// A first build holding only cooperative leaves the operators could not co-sign enters the bounded retry. + func testAFirstBuildOfOnlyFailedCooperativeLeavesWaitsForTheOperators() async throws { + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down")) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure]), SparkExitFixtures.recovery([], failed: [failure])] + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 0)) + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.recovery?.response.failed.count, 1) + + record = try await driver().tick(record) + XCTAssertEqual(record.failedLeafRetrySince, 1000) + XCTAssertTrue(record.isWaitingForOperators) + XCTAssertTrue(sdk.checkedRecoveries.isEmpty, "nothing to check on-chain") + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.isFailedLeavesReleased, true) + } + + /// A rate at which a leaf can no longer pay its own exit would leave it on Spark: it is not offered (Android review). + func testFeeBumpPricingRefusesARateThatLeavesALeafBehind() async throws { + var record = bumpedRecord() + record = SparkExitRecord( + network: record.network, createdAt: record.createdAt, phase: .running, feeRateSatPerVbyte: 2, + destination: record.destination, leafIds: ["leaf-1", "small"], funding: record.funding, + transferId: record.transferId, expectedSats: record.expectedSats + ) + sdk.prepareErrors = [SdkError.SparkError("Leaf small cannot be exited: its refund cannot pay its own fee")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])] + + do { + _ = try await driver().feeBumpShortfall(record, rate: 12) + XCTFail("a rate that leaves a leaf behind is not priced") + } catch let SparkExitFeeBumpError.leavesBehind(sats) { + XCTAssertEqual(sats, 0, "the small leaf's value is unknown before the first build") + } + XCTAssertTrue(sdk.recoverRequests.isEmpty) + + // A build that refuses a leaf the prepare kept is not offered either. + sdk = FakeSparkExitRecovering() + sdk.recoverError = SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee") + do { + _ = try await driver().feeBumpShortfall(bumpedRecord(), rate: 12) + XCTFail("expected leavesBehind") + } catch let SparkExitFeeBumpError.leavesBehind(sats) { + XCTAssertEqual(sats, 100_000) + } + } + + /// The keyless pass finding every step in a block ends the exit without waiting for the SDK (Android review). + func testAKeylessPassThatSeesEveryStepConfirmedEndsTheExit() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)), + SparkExitFixtures.tx("s", kind: .sweep, dependsOn: ["n1"]), + ])) + chain.confirmedTxids = ["n1", "s"] + chain.utxosByAddress["bcrt1qfunding"] = [] + + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertTrue(chain.broadcasts.isEmpty) + } + + /// A wipe closes the exit's writes: a tick still running can't write the deleted record back (Android review). + func testAClosedWriteGateDropsTheSaveOfATickStillRunning() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + let gate = SparkExitWriteGate() + let save = SparkExitManager.gatedSave( + store: store, + lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")), + gate: gate + ) + + try save(SparkExitFixtures.record()) + XCTAssertNotNil(try store.load()) + try store.delete() + + gate.close() + XCTAssertThrowsError(try save(SparkExitFixtures.record())) + XCTAssertNil(try store.load()) + } + + /// Reminder ids are scoped per exit, so a later exit's `failed` is never taken for an earlier exit's. + func testReminderIdsAreScopedPerExit() { + let reminder = SparkExitReminder(id: "failed", fireAt: clock, isFailed: true) + let first = SparkExitFixtures.record() + let second = SparkExitRecord( + network: "regtest", createdAt: first.createdAt.addingTimeInterval(86400), phase: .done, feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", leafIds: ["leaf-2"], funding: first.funding, transferId: nil, expectedSats: 0 + ) + XCTAssertNotEqual(SparkExitManager.notificationId(reminder, record: first), SparkExitManager.notificationId(reminder, record: second)) + XCTAssertTrue(SparkExitManager.notificationId(reminder, record: first).hasPrefix("spark-exit-")) + } + + /// Salvage keeps the funding strategy and what earlier generations paid to savings. + func testSalvageKeepsTheFundingStrategyAndEarlierReceived() throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.fundingStrategy = SparkExitRecord.perNodeStrategy + record.earlierReceivedSats = ["sweep-1": 40000] + record.outsideSats = 14167 + let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record))) + XCTAssertEqual(salvaged.fundingStrategy, SparkExitRecord.perNodeStrategy) + XCTAssertEqual(salvaged.earlierReceivedSats, ["sweep-1": 40000]) + XCTAssertEqual(salvaged.outsideSats, 14167) + } + + /// A salvaged exit imports the exit data it kept before it is prepared again: the SDK may no longer hold a leaf + /// being exited. + func testASalvagedExitImportsItsSnapshotBeforeItsFirstRebuild() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 25000) + record.exitStateSnapshot = "snapshot-1" + let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record))) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + var importsAtPrepare: [[String]] = [] + sdk.preparedByRequest = { [sdk] _ in + importsAtPrepare.append(sdk?.imports ?? []) + return SparkExitFixtures.prepared() + } + + _ = try await driver().tick(salvaged) + + XCTAssertFalse(importsAtPrepare.isEmpty, "the salvaged exit is prepared again") + XCTAssertEqual(importsAtPrepare.first, ["snapshot-1"], "its kept exit data is imported before it is prepared") + XCTAssertEqual(sdk.exports, 0) + } + + /// Background runs wrap the chain to time it: the wrapper forwards the economy tier, so their sweeps pay it. + func testTheTimedBackgroundChainForwardsTheNetworkFees() async throws { + chain.fastestFee = 9 + chain.economyFee = 1 + let timed = TimedSparkExitChain(base: chain, timings: SparkExitTimings()) + + let fees = try await timed.networkFees() + + XCTAssertEqual(fees.economy, 1) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: fees.economy, exitRate: 5), 1) + } + + // MARK: Unknown funding broadcast (review thread r4498875767) + + /// A savings broadcast with an unknown outcome is kept as paid (pending): it may still have gone out. + func testAnUnknownFundingBroadcastIsKeptAsPaid() { + XCTAssertEqual(SparkExitManager.fundingTxid(.unknown(txid: "unk")), "unk") + XCTAssertEqual(SparkExitManager.fundingTxid(.accepted(txid: "ok")), "ok") + XCTAssertNil(SparkExitManager.fundingTxid(.rejected(txid: "no", reason: "insufficient funds"))) + } + + /// With that payment pending, or any unresolved savings send to the exit's address, cancel is off: the record stays. + func testAPendingFundingSendKeepsTheExit() { + var record = SparkExitFixtures.record() + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0, unresolvedSendAddress: "bcrt1qfunding")) + XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0, unresolvedSendAddress: "bcrt1qelsewhere")) + record.fundingPaymentTxid = "unk" + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + XCTAssertTrue(record.isAwaitingOwnPayment) + } + + /// The unknown payment confirming late is adopted like any funding, and the exit builds. + func testALateConfirmationOfAnUnknownFundingSendIsAdopted() async throws { + var record = SparkExitFixtures.record() + record.fundingPaymentTxid = "unk" + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + + record = try await driver().tick(record) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "unk")) + XCTAssertNil(record.recovery) + + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("unk", value: 20000)] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.funding.first?.txid, "unk") + XCTAssertNotNil(record.recovery) + } + + // MARK: Wipe (review thread 4511769434) + + /// A quote taken before a wipe names the wiped wallet's address: the next wallet's exit must never pay to it. + @MainActor + func testAWipeForgetsTheQuotesDestinationAndFunding() async { + let exit = SparkExitManager(walletIndex: 9999) + exit.useQuote( + SparkExitQuote(prepared: SparkExitFixtures.prepared()), + destination: "bcrt1qwalleta", + feeRate: 4, + fundingIndex: 3, + fundingAddress: "bcrt1qfundinga" + ) + exit.noteFundingSeen([SparkExitFixtures.utxo("seen", value: 5000)]) + exit.fundFromSavings = true + exit.savingsFeeRate = 7 + exit.savingsFeeSats = 300 + + await exit.wipe() + + XCTAssertNil(exit.quote) + XCTAssertNil(exit.pendingDestination) + XCTAssertNil(exit.quoteFeeRate) + XCTAssertNil(exit.pendingFundingIndex) + XCTAssertNil(exit.fundingAddress) + XCTAssertFalse(exit.fundFromSavings) + XCTAssertEqual(exit.savingsFeeRate, 0) + XCTAssertEqual(exit.savingsFeeSats, 0) + XCTAssertEqual(exit.fundingSeenSats, 0) + XCTAssertEqual(exit.fundingSeenCount, 0) + } + + // MARK: Wallet totals (pwltr, android#1782) + + /// Funding seen at the address changes what is in transfer to savings: the wallet recomputes its totals. + @MainActor func testAFundingSeenOnlyChangeRefreshesTheWalletBalance() { + var refreshes = 0 + let watch = SparkExitBalanceWatch { refreshes += 1 } + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 20000)) + record.transferId = "transfer-1" + watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0) + let before = refreshes + + watch.update(record: record, fundingSeenSats: 5000, sparkBalanceSats: 0) + + XCTAssertEqual(refreshes, before + 1, "the wallet kept the in-transfer amount from before the funding was seen") + } + + /// A record write that leaves the totals' amounts alone does not recompute them. + @MainActor func testAnExitWriteThatLeavesTheTotalsAloneDoesNotRefreshTheWalletBalance() { + var refreshes = 0 + let watch = SparkExitBalanceWatch { refreshes += 1 } + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 20000)) + record.transferId = "transfer-1" + watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0) + let before = refreshes + + record.isResultSeen = true + record.lastBuildAt = Date(timeIntervalSince1970: 1_800_000_000) + watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0) + + XCTAssertEqual(refreshes, before) + } + + // MARK: Round 3 (Corey 5859257501) + + /// A quote for a new funding address starts with nothing seen there: an earlier exit's funding never lowers what + /// savings pays. + @MainActor func testAQuoteForANewFundingAddressStartsWithNothingSeen() { + let exit = SparkExitManager(walletIndex: 9999) + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared()) + exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 3, fundingAddress: "bcrt1qearlier") + exit.noteFundingSeen([SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 8000)]) + exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 3, fundingAddress: "bcrt1qearlier") + XCTAssertEqual(exit.fundingSeenSats, 8000, "a quote again for the same address keeps what reached it") + + exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 4, fundingAddress: "bcrt1qnext") + + XCTAssertEqual(exit.fundingSeenSats, 0, "the earlier exit's funding was taken as already sent") + XCTAssertEqual(exit.fundingSeenCount, 0) + } + + /// Quoted leaves A (65,536) and B (32,768) and C (1,696) outside; the first build leaves B behind. Once A is + /// published and leaves the balance (34,464), Spending still holds B and C. + func testALeafLeftBehindStaysInSpendingOnceThePublishedLeavesLeftTheBalance() { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["A", "B"] + record.leafTotalSats = 98304 + record.outsideSats = 1696 + record.leftBehindLeaves = [.init(leafId: "B", valueSats: 32768, reason: "its refund cannot pay")] + + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 34464, record: record), 34464, "B was in neither total") + } + + /// A Redo while a top-up is awaited rebuilds from the funding the exit already has, so a top-up that is dropped + /// never leaves it waiting. + func testARedoWhileATopUpIsAwaitedRebuildsWithoutWaitingForADroppedTopUp() async throws { + var record = bumpedRecord() + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1")])] + + record = try await driver().tick(record) + + XCTAssertFalse(sdk.recoverRequests.isEmpty, "the rebuild waited for a top-up that was dropped") + XCTAssertEqual(record.phase, .running) + XCTAssertFalse(record.isRebuilding) + } + + /// A Redo rebuild the held funding pays too little for still follows the awaited top-up: one that confirmed short + /// asks for the rest, as while waiting for it. + func testARedoRebuildShortOfFundingAsksForTheRestOfAShortTopUp() async throws { + var record = bumpedRecord() + chain.utxosByAddress["bcrt1qfunding"] = [ + SparkExitFixtures.utxo("f0", vout: 1, value: 20000), + SparkExitFixtures.utxo("bump", value: 3000), + ] + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25680, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertFalse(sdk.recoverRequests.isEmpty) + XCTAssertNil(record.fundingPaymentTxid, "the short top-up was still awaited") + XCTAssertEqual(record.isFundingPaymentShort, true) + } + + /// A cancelled exit's funding address stays listed, and for four weeks the foreground reads it: a late payment + /// shows there with its balance. Kept change is read every time, so change the user spent by hand shows as spent. + func testACancelledExitsAddressShowsALatePaymentForFourWeeks() async throws { + let record = SparkExitFixtures.record() + let cancelled = try XCTUnwrap(SparkExitEarlierAddress.cancelledFunding(record, network: .regtest, at: clock.addingTimeInterval(-86400))) + XCTAssertEqual(cancelled.address, record.funding[0].address) + XCTAssertEqual(cancelled.kind, .cancelledFunding) + let old = SparkExitEarlierAddress(address: "bcrt1qold", path: "m/84'/1'/7797'/0/1", kind: .cancelledFunding, sats: nil, + date: clock.addingTimeInterval(-29 * 86400)) + let kept = SparkExitEarlierAddress(address: "bcrt1qkept", path: "m/84'/1'/7797'/0/2", kind: .keptChange, sats: 600, + date: clock.addingTimeInterval(-86400)) + + let refreshed = await SparkExitEarlierAddress.refreshed([cancelled, old, kept], now: clock) { address in + address == cancelled.address ? [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5000, confirmed: false)] + : [SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 9)] + } + + XCTAssertEqual(refreshed.map(\.sats), [5000, nil, 9], "a late payment, or kept change spent by hand, showed nowhere") + } + + /// Kept change whose result was seen stays listed with its address and path: the ended record still holding that + /// address hides it only while its result is what Emergency exit opens. + func testASeenKeptChangeResultStillListsItsAddress() throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 600)] + record.leftoverSweep = .init() + record.leftoverSweep?.isDustKept = true + record.isResultSeen = true + let kept = try XCTUnwrap(SparkExitEarlierAddress.keptChange(record, network: .regtest, at: clock)) + XCTAssertFalse(record.showsResult) + + XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [kept], "the kept change's address and path are out of reach") + record.isResultSeen = nil + XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [], "shown twice, on the result and in the list") + } + + /// An address added while a refresh reads another stays listed: the refresh writes only the balances it read. + func testAnAddressRefreshKeepsAnAddressAddedDuringItsReads() async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json")) + let now = Date() + let first = SparkExitEarlierAddress(address: "bcrt1qfirst", path: "m/84'/1'/7797'/0/1", kind: .cancelledFunding, sats: nil, date: now) + let added = SparkExitEarlierAddress(address: "bcrt1qadded", path: "m/84'/1'/7797'/0/2", kind: .cancelledFunding, sats: nil, date: now) + try store.add(first) + + let refreshed = await SparkExitManager.refreshEarlierAddresses(store: store, fallback: [], now: now) { _ in + try store.add(added) + return [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5000)] + } + + XCTAssertEqual(try store.load().map(\.address), [first.address, added.address], "the refresh erased the address added meanwhile") + XCTAssertEqual(try store.load().map(\.sats), [5000, nil]) + XCTAssertEqual(refreshed?.map(\.address), [first.address, added.address]) + } + + /// Kept change at a cancelled exit's address, which a later exit reused, is read for good: it replaces the + /// cancel's entry, which is read only for about four weeks. + func testKeptChangeAtACancelledExitsReusedAddressIsReadPastTheCancelsFourWeeks() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json")) + let cancelledAt = Date(timeIntervalSince1970: 1_800_000_000) + let keptAt = cancelledAt.addingTimeInterval(20 * 24 * 3600) + let path = "m/84'/1'/7797'/0/0" + try store.add(SparkExitEarlierAddress(address: "bcrt1qreused", path: path, kind: .cancelledFunding, sats: nil, date: cancelledAt)) + + try store.add(SparkExitEarlierAddress(address: "bcrt1qreused", path: path, kind: .keptChange, sats: 600, date: keptAt)) + + let listed = try XCTUnwrap(store.load().first) + XCTAssertEqual(try store.load().count, 1) + XCTAssertEqual(listed.kind, .keptChange, "the kept change stays a cancel's entry") + XCTAssertEqual(listed.date, keptAt) + XCTAssertEqual(listed.sats, 600) + XCTAssertTrue(listed.isWatched(now: cancelledAt.addingTimeInterval(60 * 24 * 3600)), "the kept change is no longer read") + } + + /// The foreground tick keeps the driver's reset of a seen result: change kept after the result was opened shows + /// that result again with its path. + @MainActor func testTheForegroundTickKeepsTheDriversResetOfASeenResult() async throws { + let walletIndex = 9996 + let store = SparkExitStore(walletIndex: walletIndex) + defer { + try? store.delete() + try? SparkExitEarlierAddressStore(walletIndex: walletIndex).delete() + try? SparkExitLostLeafStore(walletIndex: walletIndex).delete() + } + var record = SparkExitFixtures.record(phase: .failed(reason: "Nothing could be recovered")) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 9000) + record.leftover = [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 600)] + record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: 1000, checkedAtHeight: 1000) + record.isResultSeen = true + XCTAssertTrue(record.needsDriving) + XCTAssertFalse(record.hasUnseenResult) + try store.save(record) + chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks + let exit = SparkExitManager(walletIndex: walletIndex, chain: chain) + exit.load(walletIndex: walletIndex) + + await exit.tick() + + XCTAssertEqual(exit.record?.isChangeKept, true) + XCTAssertEqual(exit.record?.hasUnseenResult, true, "the tick hid the kept change's result again") + XCTAssertEqual(try store.load()?.hasUnseenResult, true, "the stored record hid the kept change's result again") + } + + // MARK: What stays on Spark (Android 124d66170, caa349d4b) + + /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats + /// selected, 32,768 lost and 1,696 skipped save 1,696. + func testAStartSavesWhatStaysOnSparkWithoutTheLostLeavesSparkStillCounts() throws { + var prepared = SparkExitFixtures.prepared() + prepared.skipped = [SkippedLeaf(leafId: "dust", valueSats: 1696, reason: .feeExceedsValue)] + let quote = SparkExitQuote(prepared: prepared, lostLeafSats: [32768]) + XCTAssertEqual(quote.leafTotalSats, 100_000) + + let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: quote, sparkBalanceSats: 134_464)) + + XCTAssertEqual(record.outsideSats, 1696, "the lost leaf was kept as staying on Spark") + } + + /// A leaf the exit left behind stays on Spark: Spending counts it while it is out of the transfer. + func testALeafTheExitLeftBehindCountsOnSpark() { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.leafTotalSats = 108_192 + record.leftBehindLeaves = [.init(leafId: "leaf-2", valueSats: 8192, reason: "its refund cannot pay")] + + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 108_192, record: record), 8192, "the leaf is in neither total") + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 108_192, record: record), 8192) + } + + // MARK: Requote at the start (review 1ac10966a, Android 64004d7) + + /// An optimization swapped the leaves while the start waited: the exit is quoted again over the leaves Spark holds now. + func testAStartQuotesAgainWhenTheLeavesChanged() async throws { + let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared()) + sdk.preparedResponse = SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-2", "leaf-3"]) + + let requote = await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: []) + let fresh = try XCTUnwrap(requote) + let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: fresh, sparkBalanceSats: 214_167)) + + XCTAssertEqual(record.leafIds, ["leaf-2", "leaf-3"]) + XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 35000)) + XCTAssertEqual(record.expectedSats, fresh.incomingSats) + XCTAssertEqual(record.leafTotalSats, 200_000) + XCTAssertEqual(record.outsideSats, 14167, "what stays on Spark beyond the fresh leaves") + XCTAssertEqual(record.leafSats?.keys.sorted(), ["leaf-2", "leaf-3"]) + XCTAssertEqual(sdk.prepareRequests.last?.destination, "bcrt1qdestination") + } + + /// The same leaves in another order, no leaf left, or a quote that fails: the exit goes on with the confirmed quote. + func testAStartKeepsTheConfirmedQuoteWhenTheLeavesDidNotChange() async { + let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(leaves: ["leaf-1", "leaf-2"])) + func requote() async -> SparkExitQuote? { + await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: []) + } + + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2", "leaf-1"]) + let reordered = await requote() + XCTAssertNil(reordered) + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: []) + let empty = await requote() + XCTAssertNil(empty) + sdk.prepareErrors = [URLError(.timedOut)] + let failed = await requote() + XCTAssertNil(failed) + } + + /// A requote whose write fails leaves the start going on with the confirmed quote, its transfer kept and no new + /// one left counted. + func testAFailedRequoteWriteGoesOnWithTheConfirmedQuote() async throws { + struct WriteFailed: Error {} + let started = SparkExitFixtures.record() + let fresh = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 25000, leaves: ["leaf-2"])) + let requoted = try XCTUnwrap(SparkExitManager.requoted(started, fresh: fresh, sparkBalanceSats: 0)) + var settled: [String] = [] + + let noTransfer = await SparkExitManager.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { _ in throw WriteFailed() }, + save: { _ in }, + markSettled: { settled.append($0) } + ) + XCTAssertNil(noTransfer) + + let notSaved = await SparkExitManager.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { _ in "transfer-2" }, + save: { _ in throw WriteFailed() }, + markSettled: { settled.append($0) } + ) + XCTAssertNil(notSaved) + XCTAssertEqual(settled, ["transfer-2"], "the confirmed quote's transfer stays; the unsaved one is settled") + + let written = await SparkExitManager.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { _ in "transfer-3" }, + save: { _ in }, + markSettled: { settled.append($0) } + ) + XCTAssertEqual(written?.transferId, "transfer-3") + XCTAssertEqual(written?.leafIds, ["leaf-2"]) + XCTAssertEqual(settled, ["transfer-2", "transfer-1"]) + } + + /// A start killed before its transfer is written stores no exit: none runs on without the transfer counting it. + func testAStartCutShortBeforeItsTransferStoresNoExit() async { + struct WriteFailed: Error {} + var record = SparkExitFixtures.record() + record.transferId = nil + var saved: [SparkExitRecord] = [] + + let started = try? await SparkExitManager.writeStart( + record, + incomingSats: 111_000, + createTransfer: { _ in throw WriteFailed() }, + save: { saved.append($0) }, + markSettled: { _ in } + ) + + XCTAssertNil(started) + XCTAssertTrue(saved.isEmpty, "no exit is stored before its transfer exists") + } + + /// A start killed after its transfer is written leaves only that transfer, settled as an orphan on the next load; + /// a failed save settles it at once. Every exit stored carries its transfer. + func testAStartCutShortAfterItsTransferLeavesOnlyAnOrphanToSettle() async throws { + struct WriteFailed: Error {} + var record = SparkExitFixtures.record() + record.transferId = nil + var saved: [SparkExitRecord] = [] + var settled: [String] = [] + + let notSaved = try? await SparkExitManager.writeStart( + record, + incomingSats: 111_000, + createTransfer: { _ in "transfer-1" }, + save: { _ in throw WriteFailed() }, + markSettled: { settled.append($0) } + ) + XCTAssertNil(notSaved) + XCTAssertEqual(settled, ["transfer-1"]) + + let started = try await SparkExitManager.writeStart( + record, + incomingSats: 111_000, + createTransfer: { _ in "transfer-2" }, + save: { saved.append($0) }, + markSettled: { settled.append($0) } + ) + XCTAssertEqual(started.transferId, "transfer-2") + XCTAssertEqual(saved.map(\.transferId), ["transfer-2"], "every exit stored carries its transfer") + } + + /// No tick drives an exit while its start still settles its leaves: the optimization wait, the requote and the + /// reconnect. + func testNoTickDrivesAnExitWhileItsStartSettles() { + let record = SparkExitFixtures.record() + XCTAssertFalse(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt)) + XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: nil)) + XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt.addingTimeInterval(-60))) + } + + /// Funding read for a cancelled exit, such as its savings payment, never shows for the next one. + func testACancelledExitsFundingNeverShowsForTheNextExit() { + let cancelled = SparkExitFixtures.record() + var next = SparkExitFixtures.record() + next = SparkExitRecord( + network: next.network, + createdAt: cancelled.createdAt.addingTimeInterval(60), + phase: next.phase, + feeRateSatPerVbyte: next.feeRateSatPerVbyte, + destination: next.destination, + leafIds: next.leafIds, + funding: next.funding, + expectedSats: next.expectedSats + ) + XCTAssertTrue(SparkExitManager.isFundingOfExit(cancelled.createdAt, record: cancelled)) + XCTAssertFalse(SparkExitManager.isFundingOfExit(cancelled.createdAt, record: next)) + XCTAssertFalse(SparkExitManager.isFundingOfExit(nil, record: next)) + } + + private func refusedPayment(status: OnchainSendAttempt.Status = .rejected, address: String = "bcrt1qfunding") -> OnchainSendAttempt { + var attempt = OnchainSendAttempt( + id: UUID(), walletId: "wallet", requestId: nil, orderId: nil, address: address, amountSats: 9000, isMaxAmount: false, + status: status, txid: "refused" + ) + attempt.recoveryContext = OnchainSendRecoveryContext( + inputs: [OnchainSendInput(txid: String(repeating: "a", count: 64), vout: 0)], + satsPerVbyte: 9, + paymentIdentity: nil, + candidateTxids: ["refused"] + ) + return attempt + } + + /// Progress says a savings payment was refused only while the exit still waits for its first funding. + func testARefusedSavingsPaymentIsShownUntilAPaymentIsKept() { + let record = SparkExitFixtures.record() + XCTAssertTrue(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment())) + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment(status: .unknown)), "it may still go out") + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment(address: "bcrt1qother"))) + var paid = record + paid.fundingPaymentTxid = "paid" + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(paid, attempt: refusedPayment())) + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(SparkExitFixtures.record(phase: .running), attempt: refusedPayment())) + } + + /// Paying again re-signs the refused attempt itself, over the inputs it already spends, so at most one confirms. + func testPayingAgainRetriesOnlyTheRefusedAttempt() async throws { + let refused = refusedPayment() + var contexts: [OnchainSendPendingContext] = [] + let txid = try await SparkExitManager.payRefusedAgain(refused, record: SparkExitFixtures.record()) { context in + contexts.append(context) + return .accepted(txid: "again") + } + XCTAssertEqual(txid, "again") + XCTAssertEqual(contexts, [OnchainSendPendingContext(attemptId: refused.id, walletId: "wallet", txid: "refused")]) + + do { + _ = try await SparkExitManager.payRefusedAgain(refusedPayment(status: .unknown), record: SparkExitFixtures.record()) { context in + contexts.append(context) + return .accepted(txid: "again") + } + XCTFail("a payment that may still go out is never paid again") + } catch {} + XCTAssertEqual(contexts.count, 1) + } + + /// A wipe closes Spark's connects first, then lets exit work (a tick that may reconnect) finish, and only then + /// disconnects and deletes: nothing can reconnect the wiped wallet after its SDK is cleared. + @MainActor + func testAWipeClosesConnectsAndDrainsExitWorkBeforeItDisconnects() async throws { + var steps: [String] = [] + try await SparkManager.wipeInOrder( + closeAdmission: { steps.append("close") }, + drainExitWork: { steps.append("drain") }, + disconnect: { steps.append("disconnect") }, + deleteData: { steps.append("delete") } + ) + XCTAssertEqual(steps, ["close", "drain", "disconnect", "delete"]) + } + + /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading. + func testSavingsFeeFailsWithoutFeeRates() async { + let rates = FeeRates(fast: 9, mid: 6, slow: 1) + func price(address: String? = "bcrt1qfunding", fundingSats: UInt64 = 20000, rates: FeeRates?) async + -> SparkExitFundingView.SavingsFee + { + await SparkExitFundingView.priceSavingsFee( + address: address, + fundingSats: fundingSats, + estimates: { rates }, + calculate: { _, _, _ in 300 } + ) + } + + let noRates = await price(rates: nil) + XCTAssertEqual(noRates, .failed) + let noAddress = await price(address: nil, rates: rates) + XCTAssertEqual(noAddress, .unavailable) + let noFunding = await price(fundingSats: 0, rates: rates) + XCTAssertEqual(noFunding, .unavailable) + let priced = await price(rates: rates) + XCTAssertEqual(priced, .priced(rate: TransactionSpeed.fast.getFeeRate(from: rates), feeSats: 300)) + } + + /// A start whose savings payment was refused for a higher prepared fee: the next funding prices at least that fee, + /// so Funding shows it and the next swipe approves it. + @MainActor + func testTheNextFundingAfterAStartRefusedForAHigherSavingsFeePricesAtLeastThatFee() async { + let rates = FeeRates(fast: 9, mid: 6, slow: 1) + let exit = SparkExitManager(walletIndex: 9999) + func price() async -> SparkExitFundingView.SavingsFee { + await SparkExitFundingView.priceSavingsFee( + address: "bcrt1qfunding", + fundingSats: 20000, + refusedFeeSats: exit.savingsFundingRefusedFeeSats, + estimates: { rates }, + calculate: { _, _, _ in 420 } + ) + } + let rate = TransactionSpeed.fast.getFeeRate(from: rates) + let first = await price() + XCTAssertEqual(first, .priced(rate: rate, feeSats: 420)) + + exit.noteSavingsFundingRefused(preparedFeeSats: 600) + let next = await price() + + // Savings' coin selection needs 600 sats where the estimate says 420. + XCTAssertEqual(next, .priced(rate: rate, feeSats: 600), "the same refused estimate was confirmed again") + } + + /// Savings never pays more than the funding the user confirmed; the rest is asked for on Progress once the payment + /// confirms. + func testSavingsPaysAtMostTheConfirmedFundingAndProgressAsksForTheRest() throws { + let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + let higher = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-1", "leaf-2"])) + let lower = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 15000, leaves: ["leaf-2"])) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher, seenSats: 0, seenCount: 0, feeRate: 2), 20000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower, seenSats: 0, seenCount: 0, feeRate: 2), 15000) + + var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher, sparkBalanceSats: 0)) + record.fundingPaymentTxid = "paid" + let paid = [SparkExitFixtures.utxo("paid", value: 20000)] + guard case let .ask(sats, receivedSats) = SparkExitMath.fundingState(record, utxos: paid) else { + return XCTFail("the shortfall is asked for") + } + XCTAssertEqual(receivedSats, 20000) + XCTAssertGreaterThanOrEqual(sats, 15000) + } + + // MARK: Savings payment on Confirm (review thread 4511769453) + + /// Pay from savings shows its own fee and the total that leaves savings; funding from another wallet shows neither. + @MainActor + func testConfirmShowsTheSavingsPaymentFeeAndTotal() { + let exit = SparkExitManager(walletIndex: 9999) + exit.useQuote( + SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 6000)), + destination: "bcrt1qdestination", + feeRate: 2, + fundingIndex: 0, + fundingAddress: "bcrt1qfunding" + ) + exit.savingsFeeSats = 453 + XCTAssertNil(exit.savingsPayment, "paid from another wallet") + + exit.fundFromSavings = true + XCTAssertEqual(exit.savingsPayment?.feeSats, 453) + XCTAssertEqual(exit.savingsPayment?.totalSats, 6453) + + exit.useQuote( + SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: nil)), + destination: "bcrt1qdestination", + feeRate: 2, + fundingIndex: 0, + fundingAddress: "bcrt1qfunding" + ) + XCTAssertNil(exit.savingsPayment, "nothing to fund") + } + + // MARK: Transfer while failed leaves are retried (pwltr's Android finding on c554a0185) + + /// A confirmed sweep never ends the transfer while failed cooperative leaves are still retried: their value stays + /// in transfer to savings until the exit ends. + func testTheTransferStaysWhileFailedLeavesAreRetried() { + var record = SparkExitFixtures.record(phase: .running) + record.failedLeafRetrySince = 1000 + XCTAssertTrue(record.isRetryingFailedLeaves) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true)) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: false)) + + record.failedLeafRetrySince = nil + XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "the sweep reached savings") + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: false)) + + record.phase = .done + XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: false), "the exit ended") + } + + /// A sweep seen before the failed-leaf retry starts never ends the transfer: the running recovery already lists + /// cooperative leaves the operators could not co-sign, and their value stays in transfer. + func testTheTransferStaysWhileTheRecoveryListsFailedLeavesBeforeTheirRetry() { + var record = SparkExitFixtures.record(phase: .running) + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down")) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")], failed: [failure])) + XCTAssertNil(record.failedLeafRetrySince) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true)) + + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "every leaf is on its way to savings") + } +} diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift new file mode 100644 index 000000000..a48db3b05 --- /dev/null +++ b/BitkitTests/SparkExitSweepTests.swift @@ -0,0 +1,1065 @@ +@testable import Bitkit +import BreezSdkSpark +import CryptoKit +import libsecp256k1 +import XCTest + +final class SparkExitSweepTests: XCTestCase { + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + private var key: SparkExitFundingKey.Derived! + private var destination: String! + private var chain: FakeSparkExitChain! + private var saved: [SparkExitRecord] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + key = try SparkExitFundingKey.derive(index: 0, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest) + destination = try SparkExitFundingKey.derive(index: 1, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest).address + chain = FakeSparkExitChain() + saved = [] + } + + private func signer() throws -> CpfpSigner { + try singleKeyCpfpSigner(secretKeyBytes: key.secretKey) + } + + private func driver(trustedFeeRate: UInt64? = 10) -> SparkExitDriver { + SparkExitDriver( + chain: chain, + recovering: nil, + makeSigner: { [unowned self] _ in try signer() }, + save: { [unowned self] in saved.append($0) }, + trustedFeeRate: { trustedFeeRate } + ) + } + + private func endedRecord(phase: SparkExitRecord.Phase = .done) -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + phase: phase, + feeRateSatPerVbyte: 2, + destination: destination, + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: key.address, pubkey: key.pubkey, txid: "f0", vout: 1, valueSats: 9000)], + expectedSats: 90000, + leftover: [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 5035)] + ) + } + + // MARK: Building + + func testSignsEveryLeftoverInputForTheFundingKeyAndPaysSavingsLessTheFee() async throws { + let utxos = [ + SparkExitFixtures.utxo(String(repeating: "a", count: 64), vout: 0, value: 5035), + SparkExitFixtures.utxo(String(repeating: "b", count: 64), vout: 3, value: 1200), + ] + + let sweep = try await SparkExitLeftoverSweep.build( + utxos: utxos, + fundingAddress: key.address, + destination: destination, + feeRateSatPerVbyte: 2, + signer: signer() + ) + let built = try XCTUnwrap(sweep) + + let fee = SparkExitLeftoverSweep.feeSats(inputs: 2, outputScriptLength: 22, feeRateSatPerVbyte: 2) + XCTAssertEqual(fee, 2 * 178) + XCTAssertEqual(built.valueSats, 6235 - fee) + XCTAssertEqual(SparkExitTxid.txid(hex: built.txHex), built.txid) + try assertValidP2wpkhSpend(built, utxos: utxos) + } + + func testSkipsALeftoverThatIsDustAfterTheFee() async throws { + let fee = SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 2) + + let dust = try await SparkExitLeftoverSweep.build( + utxos: [SparkExitFixtures.utxo("aa", value: fee + 293)], + fundingAddress: key.address, + destination: destination, + feeRateSatPerVbyte: 2, + signer: signer() + ) + let enough = try await SparkExitLeftoverSweep.build( + utxos: [SparkExitFixtures.utxo(String(repeating: "c", count: 64), value: fee + 294)], + fundingAddress: key.address, + destination: destination, + feeRateSatPerVbyte: 2, + signer: signer() + ) + + XCTAssertNil(dust) + XCTAssertEqual(enough?.valueSats, 294) + } + + func testReadsTheOutputScriptOfEachAddressKind() throws { + let cases = [ + "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4": "0014751e76e8199196d454941c45d1b3a323f1433bd6", + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0": + "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2": "76a91477bff20c60e522dfaa3350c39b030a5d004e839a88ac", + "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy": "a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb87", + ] + for (address, script) in cases { + XCTAssertEqual(try SparkExitAddress.scriptPubKey(address).hex, script, address) + } + XCTAssertEqual(SparkExitLeftoverSweep.dustLimit(Data(hex: "0014751e76e8199196d454941c45d1b3a323f1433bd6")), 294) + XCTAssertEqual(SparkExitLeftoverSweep.dustLimit(Data(hex: "a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb87")), 540) + // Bad checksum, and a version 0 program sent as bech32m. + XCTAssertThrowsError(try SparkExitAddress.scriptPubKey("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5")) + XCTAssertThrowsError(try SparkExitAddress.scriptPubKey("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh")) + } + + // MARK: Driving + + func testAnEndedExitSweepsItsLeftoverToSavingsOnceANodeAcceptsIt() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + chain.outcome = .retryLater("HTTP 503") + + var record = try await driver().tick(endedRecord()) + let sweepTxid = try XCTUnwrap(record.leftoverSweep?.txid) + XCTAssertTrue(record.isLeftoverSweepPending) + XCTAssertTrue(record.needsDriving) + XCTAssertEqual(saved.first?.leftoverSweep?.txid, sweepTxid, "saved before it is sent") + + chain.outcome = .accepted + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + XCTAssertTrue(record.needsDriving, "driven until the sweep confirms") + XCTAssertTrue(record.showsResult) + + chain.confirmedTxids = [sweepTxid] + record = try await driver().tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), [sweepTxid, sweepTxid]) + XCTAssertEqual(record.leftoverSweep?.isConfirmed, true) + XCTAssertFalse(record.needsDriving) + XCTAssertFalse(record.showsResult, "a new exit may start") + XCTAssertEqual(record.phase, .done) + } + + /// qa-03: Done keeps the result reachable while the sweep is unconfirmed or refused, and sends an accepted sweep + /// again until it confirms, in case a node dropped it. + func testTheResultStaysReachableUntilTheSweepConfirms() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + var record = try await driver().tick(endedRecord()) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + + record = try await driver().tick(record) + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.count, 3) + XCTAssertTrue(record.showsResult) + + var refused = endedRecord() + refused.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag") + XCTAssertFalse(refused.needsDriving) + XCTAssertTrue(refused.showsResult, "the leftover waits at the funding address") + + var dust = endedRecord() + dust.leftoverSweep = .init() + XCTAssertFalse(dust.showsResult) + } + + /// qa-03: after a Redo that needed no funding, 3,514 sats of generation 1's change in five UTXOs stayed at the + /// funding address. Done sweeps them all in one transaction. + func testDoneAfterARedoWithoutFundingSweepsEveryLeftoverOfTheFirstGeneration() async throws { + var record = endedRecord(phase: .running) + record.leftover = [] + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c1")]) + record.adopt(first, at: .distantPast) + let second = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)]) + let sdk = FakeSparkExitRecovering() + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil) + sdk.builds = [second] + sdk.checks = [ + CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged)), + CheckRecoverFundsResponse( + recovery: SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]), + verdict: .done + ), + ] + let utxos = [1200, 900, 700, 414, 300].enumerated().map { i, value in + SparkExitFixtures.utxo(String(repeating: "\(i + 1)", count: 64), vout: UInt32(i), value: UInt64(value)) + } + chain.utxosByAddress[key.address] = utxos + let driver = SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { [unowned self] _ in try signer() }, save: { _ in }) + + record = try await driver.tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, []) + + record = try await driver.tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.leftover.count, 5) + let sweep = try XCTUnwrap(record.leftoverSweep) + let fee = SparkExitLeftoverSweep.feeSats(inputs: 5, outputScriptLength: 22, feeRateSatPerVbyte: 2) + XCTAssertEqual(sweep.valueSats, 3514 - fee) + XCTAssertEqual(sweep.isAccepted, true) + try assertValidP2wpkhSpend(.init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats), utxos: utxos) + XCTAssertTrue(record.showsResult) + } + + /// R7: a sweep refused for its fee is built again at the network's current rate; any other refusal is final. + func testASweepRefusedForItsFeeIsRebuiltAtTheRateTheNodeTakes() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + chain.outcome = .rejected("min relay fee not met, 300 < 330") + chain.fastestFee = 5 + + var record = try await driver().tick(endedRecord()) + let refused = try XCTUnwrap(record.leftoverSweep) + XCTAssertTrue(refused.isWaitingForFees, "a fee refusal is not final") + XCTAssertEqual(refused.refusedFeeRate, 3, "330 over the 300 paid at 2 sat/vB, and at least one more") + XCTAssertTrue(record.needsDriving) + + chain.outcome = .accepted + chain.tip = 1001 + record = try await driver().tick(record) + + let rebuilt = try XCTUnwrap(record.leftoverSweep) + XCTAssertNotNil(rebuilt.txid) + XCTAssertEqual(rebuilt.feeRateSatPerVbyte, 3) + XCTAssertEqual(rebuilt.valueSats, 5035 - SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 3)) + XCTAssertTrue(rebuilt.isAccepted) + XCTAssertNil(rebuilt.rejection) + XCTAssertEqual(rebuilt.sentAtHeight, 1001) + } + + /// R7: an accepted sweep still unconfirmed 144 blocks later is replaced (RBF) at a higher rate, at least one + /// sat/vB over its own. + func testAnUnconfirmedSweepIsReplacedAfter144Blocks() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + chain.fastestFee = 1 + var record = try await driver().tick(endedRecord()) + let first = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(first.sentAtHeight, 1000) + XCTAssertEqual(first.feeRateSatPerVbyte, 1, "the network's economy rate of 1, under the exit's 2") + + chain.tip = 1143 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.txid, first.txid) + + chain.tip = 1144 + record = try await driver().tick(record) + let replacement = try XCTUnwrap(record.leftoverSweep) + XCTAssertNotEqual(replacement.txid, first.txid) + XCTAssertEqual(replacement.feeRateSatPerVbyte, 2, "the old rate of 1 plus one, over the network's 1") + XCTAssertEqual(replacement.sentAtHeight, 1144) + try assertValidP2wpkhSpend( + .init(txid: XCTUnwrap(replacement.txid), txHex: XCTUnwrap(replacement.txHex), valueSats: replacement.valueSats), + utxos: chain.utxosByAddress[key.address] ?? [] + ) + } + + /// R7: a replacement never loses the sweep it replaces. One that would be dust keeps the accepted sweep; the + /// replaced sweep confirming ends it; a sweep the hosts no longer know counts as unconfirmed. + func testAReplacementKeepsTrackOfTheSweepItReplaces() async throws { + let utxo = SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035) + chain.utxosByAddress[key.address] = [utxo] + var record = try await driver().tick(endedRecord()) + let first = try XCTUnwrap(record.leftoverSweep?.txid) + + // The hosts dropped it: replaced, and the replacement remembers it. + chain.droppedTxids = [first] + chain.tip = 1144 + record = try await driver().tick(record) + let second = try XCTUnwrap(record.leftoverSweep?.txid) + XCTAssertNotEqual(second, first) + XCTAssertEqual(record.leftoverSweep?.replacedTxids, [first]) + + // The first one confirms after all: the sweep is done. + chain.droppedTxids = [] + chain.confirmedTxids = [first] + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.isConfirmed, true) + XCTAssertFalse(record.needsDriving) + + // A rate that would leave dust keeps the accepted sweep instead. + var accepted = endedRecord() + accepted.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 5035)] + accepted.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4800, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + chain.confirmedTxids = [] + chain.fastestFee = 50 + accepted = try await driver(trustedFeeRate: 50).tick(accepted) + XCTAssertEqual(accepted.leftoverSweep?.txid, "s") + XCTAssertTrue(accepted.needsDriving) + } + + /// The replaced sweep that confirms is the one shown and followed: its txid, amount and inputs, not the replacement's. + func testAReplacedSweepThatConfirmsIsTheOneRecorded() async throws { + let utxo = SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035) + chain.utxosByAddress[key.address] = [utxo] + chain.fastestFee = 1 + var record = try await driver().tick(endedRecord()) + let first = try XCTUnwrap(record.leftoverSweep) + chain.tip = 1144 + record = try await driver().tick(record) + let replacement = try XCTUnwrap(record.leftoverSweep) + XCTAssertLessThan(replacement.valueSats, first.valueSats) + + // The first confirms, with a later payment still at the address: that one is followed on its own. + let late = SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 7000) + chain.utxosByAddress[key.address] = [utxo, late] + chain.confirmedTxids = try [XCTUnwrap(first.txid)] + record = try await driver().tick(record) + + XCTAssertEqual(record.sweptLeftover, try [.init(txid: XCTUnwrap(first.txid), valueSats: first.valueSats)]) + XCTAssertEqual(record.leftover.map(\.txid), [late.txid]) + let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) + XCTAssertEqual(decoded, record) + } + + /// R11: the Done screen reads what the sweep paid to savings, and its txid. + func testDoneShowsWhatReachedSavingsAndTheSweepTxid() throws { + let script = try SparkExitAddress.scriptPubKey(destination) + let hex = "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff" + + "02" + "a086010000000000" + String(format: "%02x", script.count) + script.hex + + "e803000000000000" + "16" + "0014" + String(repeating: "22", count: 20) + "00000000" + var record = endedRecord() + record.recovery = StoredRecovery(SparkExitFixtures.recovery([RecoveryTransaction( + kind: .sweep, + nodeId: nil, + txid: "sweep-txid", + txHex: hex, + cpfpTxHex: nil, + csvTimelockBlocks: nil, + dependsOn: [], + status: .confirmed(blockHeight: 50) + )])) + + let received = try XCTUnwrap(SparkExitMath.received(record)) + XCTAssertEqual(received.sats, 100_000) + XCTAssertEqual(received.sweepTxid, "sweep-txid") + record.phase = .running + XCTAssertNil(SparkExitMath.received(record)) + } + + /// A cooperative recovery that landed before a Redo still counts in what reached savings. + func testDoneCountsWhatLandedBeforeARedo() throws { + let script = try SparkExitAddress.scriptPubKey(destination) + func paying(_ sats: String) -> String { + "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff" + + "01" + sats + String(format: "%02x", script.count) + script.hex + "00000000" + } + func tx(_ txid: String, _ kind: RecoveryTxKind, _ hex: String) -> RecoveryTransaction { + RecoveryTransaction(kind: kind, nodeId: nil, txid: txid, txHex: hex, cpfpTxHex: nil, csvTimelockBlocks: nil, dependsOn: [], + status: .confirmed(blockHeight: 50)) + } + var record = endedRecord(phase: .running) + // 50,000 and 100,000 sats, little-endian. + record.adopt(SparkExitFixtures.recovery([tx("coop", .cooperative, paying("50c3000000000000"))])) + record.adopt(SparkExitFixtures.recovery([tx("sweep", .sweep, paying("a086010000000000"))])) + record.phase = .done + + let received = try XCTUnwrap(SparkExitMath.received(record)) + XCTAssertEqual(received.sats, 150_000) + XCTAssertEqual(received.sweepTxid, "sweep") + } + + /// Each exit signs with the key at its own funding index, so a later exit never spends an earlier one's address. + func testSignsWithTheKeyAtTheRecordsFundingIndex() async throws { + let second = try SparkExitFundingKey.derive(index: 4, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest) + XCTAssertNotEqual(second.address, key.address) + var record = endedRecord() + record.funding = [.init(index: 4, address: second.address, pubkey: second.pubkey, txid: "f0", vout: 1, valueSats: 9000)] + chain.utxosByAddress[second.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + var indexes: [UInt32] = [] + let driver = SparkExitDriver(chain: chain, recovering: nil, makeSigner: { index in + indexes.append(index) + return try singleKeyCpfpSigner(secretKeyBytes: second.secretKey) + }, save: { _ in }) + + record = try await driver.tick(record) + + XCTAssertEqual(indexes, [4]) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + } + + func testWaitsForTheLeftoverToConfirmAndLeavesDustAlone() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035, confirmed: false)] + + var record = try await driver().tick(endedRecord()) + XCTAssertNil(record.leftoverSweep) + XCTAssertTrue(chain.broadcasts.isEmpty) + + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 400)] + record = try await driver().tick(record) + + // Too small to send now: it waits for fees to drop instead of being kept at once. + XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000) + XCTAssertTrue(record.leftoverSweep?.isWaitingForFees == true) + XCTAssertTrue(record.needsDriving) + XCTAssertTrue(chain.broadcasts.isEmpty) + + // A record from before the wait, with nothing sent and no wait, stays kept as dust. + var legacy = endedRecord() + legacy.leftoverSweep = SparkExitRecord.LeftoverSweep() + XCTAssertFalse(legacy.needsDriving) + XCTAssertFalse(legacy.showsResult, "dust is kept, and a new exit may start") + } + + // MARK: Fee change at the economy rate + + func testTheSweepPaysTheEconomyRateNeverAboveTheExitsOrUnderTheRelayFloor() { + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: 1, exitRate: 5), 1) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: 9, exitRate: 5), 5) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: 0, exitRate: 5), 1) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: nil, exitRate: 5), 5) + } + + func testOnlyInputsWorthMoreThanTheirOwnFeeAreSwept() { + let utxos = [ + SparkExitFixtures.utxo("a", value: 294), + SparkExitFixtures.utxo("b", value: 341), + SparkExitFixtures.utxo("c", value: 5000), + ] + // 68 vB at 5 sat/vB is 340. + XCTAssertEqual(SparkExitMath.economicalUtxos(utxos, rate: 5).map(\.txid), ["b", "c"]) + XCTAssertEqual(SparkExitMath.economicalUtxos(utxos, rate: 1).count, 3) + } + + /// Per-node change at 20 sat/vB: every 600-sat output costs more to spend than it holds. The sweep waits, checks + /// the rate again after 144 blocks, and sends the change once the economy rate makes it worth it. + func testChangeWaitsForFeesToDropThenSweepsAtTheEconomyRate() async throws { + var record = endedRecord() + record.feeRateSatPerVbyte = 20 + chain.economyFee = 20 + chain.utxosByAddress[key.address] = (0 ..< 3).map { SparkExitFixtures.utxo(String(repeating: "\($0 + 1)", count: 64), value: 600) } + + record = try await driver().tick(record) + XCTAssertTrue(record.leftoverSweep?.isWaitingForFees == true) + XCTAssertEqual(record.leftoverSweep?.checkedAtHeight, 1000) + + chain.economyFee = 1 + chain.tip = 1143 + record = try await driver().tick(record) + XCTAssertTrue(record.leftoverSweep?.isWaitingForFees == true, "not checked again before 144 blocks") + + chain.tip = 1144 + record = try await driver().tick(record) + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertNotNil(sweep.txid) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 1) + XCTAssertEqual(sweep.isAccepted, true) + try assertValidP2wpkhSpend( + .init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats), + utxos: chain.utxosByAddress[key.address] ?? [] + ) + } + + /// Run E (case 6): six 294-sat change outputs, swept at 1 sat/vB, refused "min relay fee not met, 450 < 674". The + /// sweep is not given up: it waits for the next block, then goes again at the rate the refusal asks for. + func testAFeeChangeSweepRefusedForItsFeeIsRetriedAtTheRateTheNodeAsksFor() async throws { + var record = endedRecord() + record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: String(repeating: "\($0)", count: 64), vout: 0, valueSats: 294) } + chain.economyFee = 1 + chain.utxosByAddress[key.address] = (1 ... 6).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 294) } + chain.outcome = .rejected("min relay fee not met, 450 < 674") + + record = try await driver().tick(record) + + let waiting = try XCTUnwrap(record.leftoverSweep) + XCTAssertNil(waiting.txid) + XCTAssertNil(waiting.rejection, "a fee refusal is never final") + XCTAssertTrue(waiting.isWaitingForFees) + XCTAssertEqual(waiting.refusedFeeRate, 2, "674 sats over the 450 vB paid at 1 sat/vB") + XCTAssertEqual(waiting.retryAtHeight, 1001) + XCTAssertTrue(record.needsDriving, "launch and background runs keep trying") + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764)) + XCTAssertEqual(chain.broadcasts.count, 1) + + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.count, 1, "not again before the next block") + + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 2) + XCTAssertEqual(sweep.isAccepted, true) + XCTAssertEqual(sweep.valueSats, 1764 - 900) + XCTAssertEqual(sweep.waitingSince, 1000, "the 4-week bound still counts from the first wait") + try assertValidP2wpkhSpend( + .init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats), + utxos: chain.utxosByAddress[key.address] ?? [] + ) + } + + /// A record from Run E's build, with the fee refusal stored as final, waits and retries on the next run too. + func testAStoredFeeRefusalWaitsAndRetries() async throws { + var record = endedRecord() + record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: String(repeating: "\($0)", count: 64), vout: 0, valueSats: 294) } + record.leftoverSweep = .init( + txid: "d770cd89", + txHex: "02", + valueSats: 1314, + rejection: "min relay fee not met, 450 < 674", + feeRateSatPerVbyte: 1 + ) + chain.utxosByAddress[key.address] = (1 ... 6).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 294) } + chain.economyFee = 1 + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764), "one line with the total, never one per output") + + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.refusedFeeRate, 2) + XCTAssertTrue(chain.broadcasts.isEmpty) + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.feeRateSatPerVbyte, 2) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + } + + /// Run F (case 6): the fee-change sweep went out at 2 sat/vB and was accepted, then the relay floor rose to 3 and + /// evicted it. Its rebroadcast is refused "492 < 738": it waits, goes again at 3 from the next block (RBF), and the + /// result never says it reached savings before it confirms. + func testASweepEvictedFromTheMempoolIsRetriedAtTheNewFloor() async throws { + var record = endedRecord() + let utxos = (1 ... 3).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 600) } + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: 0, valueSats: 600) } + chain.utxosByAddress[key.address] = utxos + chain.economyFee = 2 + + record = try await driver().tick(record) + let first = try XCTUnwrap(record.leftoverSweep) + let firstTxid = try XCTUnwrap(first.txid) + XCTAssertEqual(first.feeRateSatPerVbyte, 2) + XCTAssertEqual(first.valueSats, 1800 - 492) + XCTAssertTrue(first.isAccepted) + XCTAssertEqual(record.leftoverLine, .sending(sats: 1308, txid: firstTxid), "accepted is not yet in savings") + + // Still in the mempool: a fee refusal from another node changes nothing. + chain.mempoolTxids = [firstTxid] + chain.outcome = .rejected("min relay fee not met, 492 < 738") + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.txid, firstTxid) + + // Evicted: no host holds it, and the rebroadcast is refused for its fee. + chain.mempoolTxids = [] + record = try await driver().tick(record) + let waiting = try XCTUnwrap(record.leftoverSweep) + XCTAssertTrue(waiting.isWaitingForFees) + XCTAssertEqual(waiting.refusedFeeRate, 3, "738 over the 492 paid at 2 sat/vB") + XCTAssertEqual(waiting.retryAtHeight, 1001) + XCTAssertEqual(waiting.replacedTxids, [firstTxid], "the evicted sweep confirming would still end it") + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1800)) + + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + let retried = try XCTUnwrap(record.leftoverSweep) + let retriedTxid = try XCTUnwrap(retried.txid) + XCTAssertNotEqual(retriedTxid, firstTxid) + XCTAssertEqual(retried.feeRateSatPerVbyte, 3) + XCTAssertEqual(retried.valueSats, 1800 - 738) + XCTAssertEqual(record.leftoverLine, .sending(sats: 1062, txid: retriedTxid)) + + chain.confirmedTxids = [retriedTxid] + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverLine, .swept(sats: 1062, txid: retriedTxid)) + XCTAssertFalse(record.needsDriving) + } + + func testTheRefusedRateFollowsTheNodesMinimum() { + XCTAssertEqual(SparkExitMath.refusedSweepRate("min relay fee not met, 450 < 674", paidRate: 1), 2) + XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 900 < 2700", paidRate: 2), 6) + XCTAssertEqual(SparkExitMath.refusedSweepRate("insufficient fee", paidRate: 3), 4, "no numbers: one sat/vB more") + // A host's number past any real rate asks for more than every cap, and never traps. + XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 450 < 10000000000000000000", paidRate: 2), .max) + let absurd = SparkExitMath.refusedSweepRate("min relay fee not met, 110 < 22000", paidRate: 1) + XCTAssertEqual(absurd, 200) + XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 2), 5, "capped at the next block's rate") + XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 8), 8, "or the exit's, if higher") + XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: nil, exitRate: 2), 2, "the exit's without a next-block rate") + XCTAssertEqual(SparkExitMath.refusedSweepFloor(2, nextBlock: 5, exitRate: 2), 2, "a modest ask is kept") + } + + /// A host answers a 1 sat/vB sweep with an absurd ask, 22,000 sats for ~110 vB: the retry pays the next block's + /// rate at most, never the 200 sat/vB the refusal names. + func testARefusalCannotPushTheSweepAboveTheNextBlockRate() async throws { + var record = endedRecord() + let utxo = SparkExitFixtures.utxo(String(repeating: "1", count: 64), value: 25000) + record.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 25000)] + chain.utxosByAddress[key.address] = [utxo] + chain.economyFee = 1 + chain.fastestFee = 5 + chain.outcome = .rejected("min relay fee not met, 110 < 22000") + + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.refusedFeeRate, 200) + + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(sweep.isAccepted, true) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 5) + } + + /// The host's next block asks 5,000 sat/vB and its refusal 550,000 sats for ~110 vB of a 2M-sat leftover: the sweep + /// pays Bitkit's own fast rate at most, and is not replaced above it. + func testAHostQuotingAnAbsurdFeeRateCannotPushTheSweepPastBitkitsOwnRate() async throws { + var record = endedRecord() + let utxo = SparkExitFixtures.utxo(String(repeating: "1", count: 64), value: 2_000_000) + record.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 2_000_000)] + chain.utxosByAddress[key.address] = [utxo] + chain.economyFee = 1 + chain.fastestFee = 5000 + chain.outcome = .rejected("min relay fee not met, 110 < 550000") + + record = try await driver().tick(record) + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(sweep.isAccepted, true) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 10, "the refused sweep went out at the host's rate") + + // A day unconfirmed: no replacement above the ceiling, the same sweep is sent again. + chain.tip = 1145 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.txid, sweep.txid) + XCTAssertEqual(record.leftoverSweep?.feeRateSatPerVbyte, 10) + } + + /// The result names the leftover once, with its total, whatever state it is in. + func testTheLeftoverIsOneLineWithTheTotal() { + var record = endedRecord() + record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: "u\($0)", vout: 0, valueSats: 294) } + XCTAssertEqual(record.leftoverLine, .stillAt(sats: 1764)) + record.leftoverSweep = .init(waitingSince: 1000, checkedAtHeight: 1000) + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764)) + record.leftoverSweep?.isDustKept = true + XCTAssertEqual(record.leftoverLine, .kept(sats: 1764)) + record.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag") + XCTAssertEqual(record.leftoverLine, .rejected(sats: 1764)) + } + + /// The local regtest mempool answers `{}`: every rate falls back, never failing the step. + func testFeeEstimatesFallBackToTheNextTargetThenTheRelayFloor() { + XCTAssertEqual(SparkExitFeeEstimates.rate([:], target: 1), 1) + XCTAssertEqual(SparkExitFeeEstimates.economy([:]), 1) + let sparse = ["2": 7.2, "6": 3.0, "144": 1.4] + XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 1), 8, "the next target up, rounded up") + XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 3), 3) + XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 1008), 2, "none longer: the longest there is") + XCTAssertEqual(SparkExitFeeEstimates.economy(sparse), 2) + XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 0.2], target: 1), 1) + } + + /// A host's rate out of range is never converted: such a rate is skipped, and an answer with nothing usable is + /// unavailable. + func testAnOutOfRangeFeeEstimateIsUnavailable() { + XCTAssertThrowsError(try SparkExitFeeEstimates.validated(["1": 1e30]), "an out-of-range rate was taken as an estimate") + XCTAssertThrowsError(try SparkExitFeeEstimates.validated(["1": .nan, "3": -2, "6": 0])) + XCTAssertEqual(try SparkExitFeeEstimates.validated(["1": 1e30, "6": 4]), ["6": 4]) + XCTAssertEqual(try SparkExitFeeEstimates.validated([:]), [:], "an empty answer still falls back to the floor") + XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 1e30, "6": 4], target: 1), 4) + XCTAssertEqual(SparkExitFeeEstimates.economy(["1": 4, "144": .infinity]), 4) + } + + func testAnEmptyEstimateMapStillPricesEveryStep() async throws { + let http = SparkExitBroadcasterTests.FakeHTTP() + http.responses = [("a.example/api/fee-estimates", 200, "{}"), ("a.example/api/fee-estimates", 200, "{}")] + let chain = try EsploraSparkExitChain(hosts: [XCTUnwrap(URL(string: "https://a.example/api"))], http: http) + + let fees = try await chain.networkFees() + let fastest = try await chain.fastestFeeRate() + + XCTAssertEqual(fees, SparkExitNetworkFees(fastest: 1, halfHour: 1, economy: 1)) + XCTAssertEqual(fastest, 1) + XCTAssertEqual(SparkExitFeeBumpOption.options(exitRate: 2, fees: fees).map(\.rate), [3], "a bump still offers a higher rate") + } + + /// Change kept at the funding address shows its result once, then lets a later exit start: its address and path + /// stay in the earlier exit addresses, which no new exit replaces. + func testKeptChangeShowsOnceAndItsAddressStaysListed() async throws { + var record = endedRecord() + record.isResultSeen = true + record.feeRateSatPerVbyte = 20 + chain.economyFee = 20 + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 600)] + record = try await driver().tick(record) + chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks + + record = try await driver().tick(record) + + XCTAssertEqual(record.leftoverLine, .kept(sats: 600)) + XCTAssertTrue(record.showsResult, "the funding address and its path were out of reach") + record.isResultSeen = true + XCTAssertFalse(record.showsResult, "the kept change blocked every later exit") + + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let earlier = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json")) + let save = SparkExitManager.gatedSave( + store: SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")), + lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")), + earlierAddressStore: earlier, + gate: SparkExitWriteGate() + ) + try save(record) + try save(record) + + let listed = try earlier.load() + XCTAssertEqual(listed.map(\.address), [key.address], "the kept change's address is in no list") + XCTAssertEqual(listed.first?.kind, .keptChange) + XCTAssertEqual(listed.first?.sats, 600) + XCTAssertEqual(listed.first?.path, SparkExitFundingKey.path(index: 0, network: Env.bitkitCoreNetwork)) + } + + /// A leftover sweep refused for good shows its result once, then lets a later exit start: the funding address and + /// its path stay in the earlier exit addresses. + func testARefusedLeftoverSweepShowsOnceAndItsAddressStaysListed() throws { + var record = endedRecord() + record.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag") + XCTAssertTrue(record.showsResult, "the leftover's address and path were out of reach") + XCTAssertTrue(record.hasUnseenResult, "opening the result never lets the refused sweep go") + record.isResultSeen = true + XCTAssertFalse(record.showsResult, "the refused sweep blocked every later exit") + + let listed = try XCTUnwrap(SparkExitEarlierAddress.keptChange(record, network: .regtest, at: Date()), "the leftover's address is in no list") + XCTAssertEqual(listed.address, key.address) + XCTAssertEqual(listed.kind, .keptChange) + XCTAssertEqual(listed.sats, 5035) + } + + /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. + func testChangeTooSmallForFourWeeksIsKept() async throws { + var record = endedRecord() + record.feeRateSatPerVbyte = 20 + chain.economyFee = 20 + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 600)] + record = try await driver().tick(record) + XCTAssertTrue(record.needsDriving) + XCTAssertTrue(record.showsResult) + + chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks + record = try await driver().tick(record) + + XCTAssertEqual(record.leftoverSweep?.isDustKept, true) + XCTAssertFalse(record.isLeftoverSweepPending) + XCTAssertFalse(record.needsDriving) + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertTrue(t("lightning__spark_exit__leftover_waiting", variables: ["amount": "₿ 600"]).contains("when network fees drop")) + XCTAssertTrue(t("lightning__spark_exit__leftover_dust", variables: ["amount": "₿ 600", "address": "a", "path": "m/84'"]) + .contains("derivation path m/84'")) + } + + func testFinishingAnExitSweepsTheLeftoverInTheSameTick() async throws { + var record = endedRecord(phase: .running) + record.leftover = [] + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]) + let sdk = FakeSparkExitRecovering() + sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)] + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + let driver = SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { [unowned self] _ in try signer() }, save: { _ in }) + + record = try await driver.tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + XCTAssertEqual(chain.broadcasts.map(\.txid), [record.leftoverSweep?.txid]) + } + + func testABackgroundRunSweepsAnEndedExitWithoutStartingSpark() async throws { + let store = SparkExitStore(url: FileManager.default.temporaryDirectory + .appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json")) + defer { try? store.delete() } + try store.save(endedRecord()) + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + var connects = 0 + let run = SparkExitBackgroundRun( + load: { try store.load() }, + makeDriver: { [unowned self] chain, recovering in + SparkExitDriver(chain: chain, recovering: recovering, makeSigner: { _ in try self.signer() }, save: { try store.save($0) }) + }, + chain: chain, + connected: nil, + connect: { _ in + connects += 1 + return nil + }, + deadline: Date(timeIntervalSinceNow: 25) + ) + + let outcome = await run.run() + + XCTAssertEqual(connects, 0) + XCTAssertEqual(outcome.record?.leftoverSweep?.isAccepted, true) + XCTAssertEqual(try store.load()?.leftoverSweep?.isAccepted, true) + } + + // MARK: Signature check + + /// Checks each input's witness is a valid BIP143 signature by the funding key over the sweep. + private func assertValidP2wpkhSpend(_ built: SparkExitLeftoverSweep.Built, utxos: [EsploraUtxo]) throws { + let tx = [UInt8](Data(hex: built.txHex)) + let outputScript = try [UInt8](SparkExitAddress.scriptPubKey(destination)) + let pubkeyHash = try [UInt8](SparkExitAddress.scriptPubKey(key.address))[2...] + let outpoints = utxos.map { [UInt8]($0.txid.hexaBytes.reversed()) + le32($0.vout) } + let sequence = le32(0xFFFF_FFFD) + let outputs = le64(built.valueSats) + [UInt8(outputScript.count)] + outputScript + var offset = 4 + 2 + 1 + 41 * utxos.count + 1 + outputs.count + XCTAssertEqual(Array(tx[(offset - outputs.count) ..< offset]), outputs) + + let context = try XCTUnwrap(secp256k1_context_create(UInt32(SECP256K1_CONTEXT_VERIFY))) + defer { secp256k1_context_destroy(context) } + var pubkey = secp256k1_pubkey() + let pubkeyBytes = key.pubkey.hexaBytes + XCTAssertEqual(secp256k1_ec_pubkey_parse(context, &pubkey, pubkeyBytes, pubkeyBytes.count), 1) + + for (i, utxo) in utxos.enumerated() { + XCTAssertEqual(tx[offset], 2) + let signatureLength = Int(tx[offset + 1]) + let signature = Array(tx[(offset + 2) ..< (offset + 2 + signatureLength)]) + offset += 2 + signatureLength + XCTAssertEqual(Array(tx[(offset + 1) ..< (offset + 34)]), pubkeyBytes) + offset += 34 + XCTAssertEqual(signature.last, 0x01, "SIGHASH_ALL") + + let preimage = le32(2) + dsha(outpoints.flatMap { $0 }) + dsha(Array(repeating: sequence, count: utxos.count).flatMap { $0 }) + + outpoints[i] + [0x19, 0x76, 0xA9, 0x14] + pubkeyHash + [0x88, 0xAC] + le64(utxo.value) + sequence + + dsha(outputs) + le32(0) + le32(1) + let digest = dsha(preimage) + var parsed = secp256k1_ecdsa_signature() + let der = Array(signature.dropLast()) + XCTAssertEqual(secp256k1_ecdsa_signature_parse_der(context, &parsed, der, der.count), 1) + XCTAssertEqual(secp256k1_ecdsa_verify(context, &parsed, digest, &pubkey), 1, "input \(i)") + } + XCTAssertEqual(Array(tx[offset...]), le32(0)) + } + + private func dsha(_ bytes: [UInt8]) -> [UInt8] { + [UInt8](Data(SHA256.hash(data: Data(SHA256.hash(data: Data(bytes)))))) + } + + private func le32(_ value: UInt32) -> [UInt8] { + withUnsafeBytes(of: value.littleEndian) { [UInt8]($0) } + } + + private func le64(_ value: UInt64) -> [UInt8] { + withUnsafeBytes(of: value.littleEndian) { [UInt8]($0) } + } + + // MARK: Ended with a savings payment on its way + + private func endedWithPayment(_ paid: String) -> SparkExitRecord { + var record = endedRecord() + record.leftover = [] + record.leftoverSweep = nil + record.fundingPaymentTxid = paid + return record + } + + /// An exit that ended before its savings payment arrived keeps being driven; once it lands it is swept back. + func testEndedExitSweepsASavingsPaymentThatArrivesLater() async throws { + let paid = String(repeating: "d", count: 64) + chain.mempoolTxids = [paid] + var record = endedWithPayment(paid) + XCTAssertTrue(record.needsDriving) + + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, paid) + XCTAssertTrue(record.needsDriving) + XCTAssertNil(record.leftoverSweep) + + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(paid, value: 20000)] + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertEqual(record.leftover.map(\.txid), [paid]) + XCTAssertNotNil(record.leftoverSweep?.txid) + XCTAssertTrue(record.needsDriving) + } + + /// A fee bump's savings payment that went out after the exit ended (pwltr's Android finding on c554a0185): its txid + /// is kept as pending, so the exit is driven until the payment reaches the funding address and is swept back, even + /// after the leftover sweep confirmed. + func testAFeeBumpPaymentSentAfterTheExitEndedIsFollowedUntilItArrives() async throws { + let late = String(repeating: "e", count: 64) + var record = endedRecord() + record.leftover = [.init(txid: String(repeating: "1", count: 64), vout: 0, valueSats: 600)] + record.leftoverSweep = .init(txid: "swept", txHex: "02", valueSats: 400, isAccepted: true, isConfirmed: true) + XCTAssertFalse(record.needsDriving, "the leftover sweep confirmed") + + record = try XCTUnwrap(SparkExitManager.followingLatePayment(record, txid: late)) + XCTAssertTrue(record.needsDriving) + chain.mempoolTxids = [late] + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, late, "still on its way") + XCTAssertTrue(record.needsDriving) + + chain.confirmedTxids = ["swept"] + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(late, value: 5000)] + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertTrue(record.leftover.contains { $0.txid == late }) + XCTAssertTrue(record.isLeftoverSweepPending, "swept back to savings") + } + + func testOnlyAnEndedExitFollowsALateFeeBumpPayment() { + XCTAssertNil(SparkExitManager.followingLatePayment(SparkExitFixtures.record(phase: .running), txid: "late"), "funding adopts it") + XCTAssertNil(SparkExitManager.followingLatePayment(endedRecord(), txid: nil)) + var following = endedRecord() + following.leftover = [] + following.fundingPaymentTxid = "earlier" + XCTAssertNil(SparkExitManager.followingLatePayment(following, txid: "late"), "an earlier payment is still followed") + } + + /// A payment no host sees is forgotten by the own-payment expiry, and the ended exit stops being driven. + func testEndedExitForgetsASavingsPaymentNoHostSees() async throws { + var record = endedWithPayment(String(repeating: "d", count: 64)) + + record = try await driver().tick(record) + XCTAssertNotNil(record.fundingPaymentTxid) + chain.tip = 1003 + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertFalse(record.needsDriving) + } + + /// A payment already in the leftover needs no following. + func testEndedPaymentAlreadyInTheLeftoverIsNotFollowed() { + var record = endedRecord() + record.fundingPaymentTxid = record.leftover[0].txid + XCTAssertFalse(record.isEndedPaymentPending) + } + + /// With a sweep in flight the payment waits for it; once it confirms, the sweep is kept and the payment swept. + func testEndedPaymentWaitsForTheSweepInFlight() async throws { + let paid = String(repeating: "d", count: 64) + var record = endedRecord() + record.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4800, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + record.fundingPaymentTxid = paid + chain.mempoolTxids = [paid, "s"] + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(paid, value: 20000)] + + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, paid) + XCTAssertEqual(record.leftoverSweep?.txid, "s") + + chain.confirmedTxids = ["s"] + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertEqual(record.sweptLeftover?.map(\.txid), ["s"]) + XCTAssertEqual(record.leftover.map(\.txid), [paid]) + XCTAssertNotEqual(record.leftoverSweep?.txid, "s") + } + + /// The exit's sweeps whose activity is marked as a transfer: the recovery's, else the one that landed before a + /// retry, and those of replaced generations. + func testSweepTxidsIncludeTheLandedSweep() { + var record = endedRecord() + record.landedSweepTxid = "landed" + record.earlierReceivedSats = ["coop": 1000] + XCTAssertEqual(SparkExitMath.sweepTxids(record), ["landed", "coop"]) + record.recovery = StoredRecovery(SparkExitFixtures.recovery([SparkExitFixtures.tx("sweep", kind: .sweep)])) + XCTAssertEqual(SparkExitMath.sweepTxids(record), ["sweep", "coop"]) + } + + // MARK: Home balances + + /// The exit's own transfer counts what is still to come: a planned sweep counts only once it confirmed. + func testInTransferCountsOnlyWhatConfirmedToSavings() throws { + let script = try SparkExitAddress.scriptPubKey(destination) + // Pays 50,000 sats to savings, little-endian. + let hex = "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff" + + "01" + "50c3000000000000" + String(format: "%02x", script.count) + script.hex + "00000000" + func sweep(_ status: ExitTransactionStatus) -> RecoveryTransaction { + RecoveryTransaction(kind: .sweep, nodeId: nil, txid: "sweep", txHex: hex, cpfpTxHex: nil, csvTimelockBlocks: nil, + dependsOn: [], status: status) + } + var record = endedRecord(phase: .running) + record.recovery = StoredRecovery(SparkExitFixtures.recovery([sweep(.waitingForTimelock(spendableAtHeight: 900))])) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000) + + record.recovery = StoredRecovery(SparkExitFixtures.recovery([sweep(.confirmed(blockHeight: 50))])) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 40000) + record.earlierReceivedSats = ["coop": 30000] + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 10000) + } + + /// A leaf the exit left behind stays on Spark: it leaves the transfer to savings. + func testInTransferLeavesOutALeafLeftBehind() { + var record = endedRecord(phase: .running) + record.leftBehindLeaves = [.init(leafId: "leaf-2", valueSats: 30000, reason: "fee rate")] + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 60000, "the left-behind leaf is still counted in transfer") + } + + /// The first fee funding nobody paid yet is not in transfer; once paid, or once the exit pays it, it is. + func testInTransferLeavesOutUnpaidFirstFunding() { + var record = endedRecord(phase: .awaitingFunding(requiredSats: 20000)) + record.funding = [.init(index: 0, address: key.address, pubkey: key.pubkey)] + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 70000) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 5000), 75000) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 20000), 90000) + + record.fundingPaymentTxid = "paid" + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000) + record.fundingPaymentTxid = nil + record.phase = .running + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000) + } + + /// Only the running exit's own transfer swaps its amount; any other exit transfer keeps it. + func testOnlyTheExitsOwnTransferCountsItsInTransferAmount() { + let own = Bitkit.Transfer(id: "own", type: .sparkExit, amountSats: 90000, createdAt: 0) + let other = Bitkit.Transfer(id: "other", type: .sparkExit, amountSats: 12000, createdAt: 0) + + XCTAssertEqual(BalanceManager.sparkExitInTransferSats(own, exitInTransfer: ("own", 40000)), 40000) + XCTAssertEqual(BalanceManager.sparkExitInTransferSats(other, exitInTransfer: ("own", 40000)), 12000) + XCTAssertEqual(BalanceManager.sparkExitInTransferSats(own, exitInTransfer: nil), 90000) + } + + /// While an exit runs, Spending keeps what Spark holds beyond the exit's leaves. + func testSpendingKeepsSparkValueOutsideTheExit() { + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: false, balanceSats: 50000, outsideExitSats: nil), 50000) + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: 32768), 32768) + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: nil), 0) + } + + /// While an exit runs, Spark counts as 0 where Spending and its connections show it, as on Android. + func testSparkShowsZeroInSpendingDuringAnExit() { + XCTAssertEqual(SparkManager.shownSpendingSats(isExiting: false, shownBalanceSats: 50000), 50000) + XCTAssertEqual(SparkManager.shownSpendingSats(isExiting: true, shownBalanceSats: 50000), 0, "Spending showed Spark during an exit") + } + + /// A start goes on only with its own exit still stored and active: one cancelled while it waited stops it. + func testStartGoesOnOnlyWithItsExitStillRunning() throws { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000.123_456) + let record = SparkExitRecord( + network: "regtest", + createdAt: createdAt, + phase: .awaitingFunding(requiredSats: 20000), + feeRateSatPerVbyte: 2, + destination: destination, + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: key.address, pubkey: key.pubkey)], + expectedSats: 90000 + ) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-start-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + let store = SparkExitStore(url: url) + try store.save(record) + let stored = try XCTUnwrap(store.load()) + + XCTAssertNotNil(SparkExitManager.startedExit(stored: stored, createdAt: createdAt)) + XCTAssertNil(SparkExitManager.startedExit(stored: nil, createdAt: createdAt)) + XCTAssertNil(SparkExitManager.startedExit(stored: stored, createdAt: createdAt.addingTimeInterval(1))) + var cancelled = stored + cancelled.phase = .done + XCTAssertNil(SparkExitManager.startedExit(stored: cancelled, createdAt: createdAt)) + } +} + +private extension Data { + init(hex: String) { + self.init(hex.hexaBytes) + } +} diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift new file mode 100644 index 000000000..d21315235 --- /dev/null +++ b/BitkitTests/SparkExitTests.swift @@ -0,0 +1,3575 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +// MARK: - Fixtures + +enum SparkExitFixtures { + static let parentHex = "020000000111111111111111111111111111111111111111111111111111111111111111110000000000ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000" + /// The id Spark's regtest node reported for `parentHex`. + static let parentTxid = "b91b720103788b9ae50dbff918bc375d185df91025b9f6e861efe208909b6adf" + + static func tx( + _ txid: String, + kind: RecoveryTxKind = .node, + cpfp: String? = nil, + dependsOn: [String] = [], + status: ExitTransactionStatus = .ready, + csv: UInt32? = nil + ) -> RecoveryTransaction { + RecoveryTransaction( + kind: kind, + nodeId: kind == .sweep ? nil : "node-\(txid)", + txid: txid, + txHex: "02\(txid)", + cpfpTxHex: cpfp, + csvTimelockBlocks: csv, + dependsOn: dependsOn, + status: status + ) + } + + static func recovery(_ transactions: [RecoveryTransaction], failed: [CooperativeRecoveryFailure] = []) -> RecoverFundsResponse { + RecoverFundsResponse( + recoverableValueSats: 100_000, + totalFeeSats: 9000, + cooperativeFeeSats: 0, + cpfpFeeSats: 7000, + fanoutFeeSats: 500, + refundFeeSats: 1000, + sweepFeeSats: 500, + leaves: [RecoverFundsLeaf(leafId: "leaf-1", valueSats: 100_000, method: .unilateral)], + failed: failed, + transactions: transactions, + fundingInputs: [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")], + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination" + ) + } + + static func prepared(funding: UInt64? = 20000, leaves: [String] = ["leaf-1"]) -> PrepareRecoverFundsResponse { + PrepareRecoverFundsResponse( + leaves: leaves.map { RecoverFundsLeaf(leafId: $0, valueSats: 100_000, method: .unilateral) }, + skipped: [SkippedLeaf(leafId: "dust", valueSats: 300, reason: .feeExceedsValue)], + recoverableValueSats: 100_000, + totalFeeSats: 9000, + cooperativeFeeSats: 0, + cpfpFeeSats: 7000, + fanoutFeeSats: 500, + refundFeeSats: 1000, + sweepFeeSats: 500, + funding: funding.map { RecoveryFunding(singleUtxoSats: $0, perBranch: [], strategy: .perBranch) }, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + exitChainState: ExitChainState( + confirmedNodes: [], + refunds: [], + stoppedLeafIds: [], + unverifiedNodeIds: [], + unverifiableConfirmedNodeIds: [] + ) + ) + } + + static func record(phase: SparkExitRecord.Phase = .awaitingFunding(requiredSats: 20000)) -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + phase: phase, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab")], + transferId: "transfer-1", + expectedSats: 111_000 + ) + } + + /// A version 3 transaction with one input spending `csvParentTxid`:0 under a 10-block BIP68 lock. + static let csvParentTxid = String(repeating: "11", count: 32) + static let csvHex = "03000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "0a000000" + + "01" + "e803000000000000" + "16" + "0014" + String(repeating: "22", count: 20) + "00000000" + + static func refund(_ txid: String, leafId: String, dependsOn: [String], status: ExitTransactionStatus = .ready) -> RecoveryTransaction { + RecoveryTransaction( + kind: .refund, + nodeId: leafId, + txid: txid, + txHex: "02\(txid)", + cpfpTxHex: "03\(txid)", + csvTimelockBlocks: nil, + dependsOn: dependsOn, + status: status + ) + } + + /// A version 3 transaction spending output 0 of each of `inputs`, with one output. + static func rawTx(spending inputs: [String]) -> String { + "03000000" + String(format: "%02x", inputs.count) + + inputs.map { Data($0.hexaBytes.reversed()).hex + "00000000" + "00" + "ffffffff" }.joined() + + "01" + "e803000000000000" + "16" + "0014" + String(repeating: "22", count: 20) + "00000000" + } + + static func utxo(_ txid: String, vout: UInt32 = 0, value: UInt64, confirmed: Bool = true) -> EsploraUtxo { + EsploraUtxo(txid: txid, vout: vout, value: value, status: EsploraTxStatus(confirmed: confirmed, blockHeight: confirmed ? 100 : nil)) + } +} + +final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { + private let lock = NSLock() + var tip: UInt32 = 1000 + var utxosByAddress: [String: [EsploraUtxo]] = [:] + var confirmedTxids: Set = [] + var mempoolTxids: Set = [] + var outcome: SparkExitBroadcastOutcome = .accepted + var isUtxosUnreadable = false + private(set) var broadcasts: [RecoveryTransaction] = [] + var onBroadcast: (() -> Void)? + + /// The tip read fails, as on a host timeout. + var isTipUnreadable = false + + func tipHeight() async throws -> UInt32 { + if isTipUnreadable { + throw URLError(.timedOut) + } + return tip + } + + func utxos(address: String) async throws -> [EsploraUtxo] { + if isUtxosUnreadable { + throw URLError(.timedOut) + } + return lock.withLock { utxosByAddress[address] ?? [] } + } + + /// Addresses with a transaction though they hold nothing now. + var usedAddresses: Set = [] + /// Every address read for its transactions, in order. + private(set) var addressReads: [String] = [] + /// The address read fails, as on a host timeout. + var isAddressReadUnreadable = false + + func isAddressUsed(address: String) async throws -> Bool { + if isAddressReadUnreadable { + throw URLError(.timedOut) + } + return lock.withLock { + addressReads.append(address) + return usedAddresses.contains(address) || !(utxosByAddress[address] ?? []).isEmpty + } + } + + /// Transactions every host answers not found for, as for one a node dropped. + var droppedTxids: Set = [] + /// Block heights of confirmed transactions; 900 for any other. + var heights: [String: UInt32] = [:] + /// Confirmed transactions whose host answers no block height. + var unknownHeightTxids: Set = [] + + func txStatus(txid: String) async throws -> EsploraTxStatus { + if droppedTxids.contains(txid) { + throw URLError(.fileDoesNotExist) + } + return lock.withLock { + let isConfirmed = confirmedTxids.contains(txid) + let height = isConfirmed && !unknownHeightTxids.contains(txid) ? heights[txid] ?? 900 : nil + return EsploraTxStatus(confirmed: isConfirmed, blockHeight: height) + } + } + + func isKnown(txid: String) async throws -> Bool { + lock.withLock { confirmedTxids.contains(txid) || mempoolTxids.contains(txid) } + } + + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { + onBroadcast?() + return lock.withLock { + broadcasts.append(tx) + return outcome + } + } + + var fastestFee: UInt64 = 5 + /// The network's economy rate; nil reads as the fastest one. + var economyFee: UInt64? + + func fastestFeeRate() async throws -> UInt64 { + fastestFee + } + + func networkFees() async throws -> SparkExitNetworkFees { + SparkExitNetworkFees(fastest: fastestFee, halfHour: fastestFee, economy: economyFee ?? fastestFee) + } +} + +final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { + var preparedResponse = SparkExitFixtures.prepared() + var builds: [RecoverFundsResponse] = [] + var checks: [CheckRecoverFundsResponse] = [] + var recoverError: Error? + /// Errors the next recovers throw, in order, before `recoverError`. + var recoverErrors: [Error] = [] + /// Thrown by the next prepares, in order. + var prepareErrors: [Error] = [] + var preparedByRequest: ((PrepareRecoverFundsRequest) -> PrepareRecoverFundsResponse)? + private(set) var prepareRequests: [PrepareRecoverFundsRequest] = [] + private(set) var recoverRequests: [RecoverFundsRequest] = [] + private(set) var checkedRecoveries: [RecoverFundsResponse] = [] + + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse { + prepareRequests.append(request) + if !prepareErrors.isEmpty { + throw prepareErrors.removeFirst() + } + return preparedByRequest?(request) ?? preparedResponse + } + + func recover(_ request: RecoverFundsRequest, signer _: CpfpSigner?) async throws -> RecoverFundsResponse { + recoverRequests.append(request) + if !recoverErrors.isEmpty { + throw recoverErrors.removeFirst() + } + if let recoverError { + throw recoverError + } + return builds.removeFirst() + } + + /// Answers the queued checks in order, then `Valid` with the recovery unchanged. + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { + checkedRecoveries.append(recovery) + return checks.isEmpty ? CheckRecoverFundsResponse(recovery: recovery, verdict: .valid) : checks.removeFirst() + } + + var exitState = "exit-state-1" + private(set) var exports = 0 + private(set) var imports: [String] = [] + + func exportExitState() async throws -> String { + exports += 1 + return exitState + } + + func importExitState(_ exitState: String) async throws { + imports.append(exitState) + } +} + +final class StubCpfpSigner: CpfpSigner, @unchecked Sendable { + func signPsbt(psbtBytes: Data) async throws -> Data { + psbtBytes + } +} + +// MARK: - Serializer + +final class SparkExitRecordTests: XCTestCase { + func testRecoveryRoundTripsThroughTheRecord() throws { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery( + [ + SparkExitFixtures.tx("fan", kind: .fanOut, status: .confirmed(blockHeight: 120)), + SparkExitFixtures.tx("node", cpfp: "03cpfp", dependsOn: ["fan"], status: .ready), + SparkExitFixtures.tx( + "refund", + kind: .refund, + cpfp: "03cpfp2", + dependsOn: ["node"], + status: .waitingForTimelock(spendableAtHeight: 2100), + csv: 2000 + ), + SparkExitFixtures.tx("wait", dependsOn: ["node"], status: .waitingForDependencies), + SparkExitFixtures.tx("unknown", status: .unverified), + SparkExitFixtures.tx("sweep", kind: .sweep, dependsOn: ["refund"], status: .confirmed(blockHeight: nil)), + ], + failed: [ + CooperativeRecoveryFailure( + leafId: "a", + outputTxid: "o1", + outputVout: 0, + error: .replacementFeeTooLow(requiredFeeSats: 5, requiredFeeRateSatPerVbyte: 6) + ), + CooperativeRecoveryFailure(leafId: "b", outputTxid: "o2", outputVout: 1, error: .operatorsUnavailable(message: "down")), + CooperativeRecoveryFailure(leafId: "c", outputTxid: "o3", outputVout: 2, error: .generic(message: "boom")), + ] + ) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + record.broadcastLog = [.init( + generation: 1, + txid: "node", + attempts: 2, + rejections: 0, + lastAttemptAt: Date(timeIntervalSince1970: 1_700_000_100), + lastOutcome: "accepted", + isAccepted: true + )] + record.phase = .needsFunding(requiredSats: 4321) + + let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) + + XCTAssertEqual(decoded, record) + XCTAssertEqual(decoded.recovery?.response, recovery) + } + + /// A record written before the qa-03 fields reads as before; the new fields round-trip. + func testReadsARecordWithoutTheNewFieldsAndRoundTripsThem() throws { + let json = #""" + {"version":1,"network":"regtest","createdAt":1700000000000,"phase":{"done":{}},"feeRateSatPerVbyte":2, + "destination":"bcrt1qdestination","leafIds":["leaf-1"],"funding":[{"index":0,"address":"bcrt1qfunding","pubkey":"02ab"}], + "generation":1,"broadcastLog":[{"generation":1,"txid":"n1","attempts":1,"rejections":0,"lastAttemptAt":1700000000000, + "lastOutcome":"accepted","isAccepted":true}],"expectedSats":1000,"leftover":[{"txid":"c","vout":0,"valueSats":700}], + "leftoverSweep":{"txid":"t","txHex":"02","valueSats":400,"isAccepted":true},"skippedLeafCount":0,"failedLeafCount":0, + "isRebuilding":false} + """# + + var record = try SparkExitRecordCoder.decode(Data(json.utf8)) + + XCTAssertEqual(record.fundingIndex, 0) + XCTAssertNil(record.broadcastLog[0].isInputSpent) + XCTAssertNil(record.leftoverSweep?.isConfirmed) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertTrue(record.showsResult, "an accepted sweep not known to be confirmed keeps the result") + + record.broadcastLog[0].isInputSpent = true + record.leftoverSweep?.isConfirmed = true + record.fundingPaymentTxid = "pay" + record.spentStepStrikes = ["r1": 1] + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 5000)] + record.outsideSats = 14167 + XCTAssertEqual(try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)), record) + } + + func testRecoveryUsesSparkSdkSerdeShape() throws { + let recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("a", cpfp: "03c", status: .confirmed(blockHeight: 7)), + SparkExitFixtures.tx("b", kind: .fanOut, status: .ready), + SparkExitFixtures.tx("c", status: .waitingForTimelock(spendableAtHeight: nil)), + ]) + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(StoredRecovery(recovery))) as? [String: Any]) + + XCTAssertEqual(json["fee_rate_sat_per_vbyte"] as? Int, 2) + let transactions = try XCTUnwrap(json["transactions"] as? [[String: Any]]) + XCTAssertEqual(transactions[0]["kind"] as? String, "Node") + XCTAssertEqual(transactions[0]["cpfp_tx_hex"] as? String, "03c") + XCTAssertEqual((transactions[0]["status"] as? [String: [String: Int]])?["Confirmed"]?["block_height"], 7) + XCTAssertEqual(transactions[1]["kind"] as? String, "FanOut") + XCTAssertEqual(transactions[1]["status"] as? String, "Ready") + XCTAssertTrue(transactions[1]["cpfp_tx_hex"] is NSNull) + let inputs = try XCTUnwrap(json["funding_inputs"] as? [[String: [String: Any]]]) + XCTAssertEqual(inputs[0]["P2wpkh"]?["value_sats"] as? Int, 20000) + } + + /// A recovery as the Breez CLI's `recover-funds --output-file` writes it (serde_json, spark-sdk 0.27.2). + func testReadsARecoveryWrittenBySparkSdk() throws { + let json = """ + {"recoverable_value_sats":90112,"total_fee_sats":8832,"cooperative_fee_sats":0,"cpfp_fee_sats":8388, + "fanout_fee_sats":0,"refund_fee_sats":0,"sweep_fee_sats":444, + "leaves":[{"leaf_id":"l1","value_sats":90112,"method":"Unilateral"}], + "failed":[{"leaf_id":"l2","output_txid":"t","output_vout":3,"error":{"OperatorsUnavailable":{"message":"timeout"}}}], + "transactions":[ + {"kind":"Node","node_id":"n1","txid":"aa","tx_hex":"02aa","cpfp_tx_hex":"03bb","csv_timelock_blocks":null, + "depends_on":[],"status":"Ready"}, + {"kind":"Refund","node_id":"l1","txid":"cc","tx_hex":"02cc","cpfp_tx_hex":"03dd","csv_timelock_blocks":2000, + "depends_on":["aa"],"status":{"WaitingForTimelock":{"spendable_at_height":3100}}}, + {"kind":"Sweep","node_id":null,"txid":"ee","tx_hex":"02ee","cpfp_tx_hex":null,"csv_timelock_blocks":null, + "depends_on":["cc"],"status":"WaitingForDependencies"}], + "funding_inputs":[{"P2wpkh":{"txid":"ff","vout":0,"value_sats":10000,"pubkey":"02cd"}}], + "fee_rate_sat_per_vbyte":1,"destination":"bc1qexample"} + """ + + let recovery = try JSONDecoder().decode(StoredRecovery.self, from: Data(json.utf8)).response + + XCTAssertEqual(recovery.transactions.map(\.kind), [.node, .refund, .sweep]) + XCTAssertEqual(recovery.transactions[1].status, .waitingForTimelock(spendableAtHeight: 3100)) + XCTAssertEqual(recovery.transactions[1].csvTimelockBlocks, 2000) + XCTAssertNil(recovery.transactions[2].nodeId) + XCTAssertEqual(recovery.failed[0].error, .operatorsUnavailable(message: "timeout")) + XCTAssertEqual(recovery.fundingInputs, [.p2wpkh(txid: "ff", vout: 0, valueSats: 10000, pubkey: "02cd")]) + } + + func testRejectsAnUnknownRecordVersion() throws { + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(SparkExitFixtures.record())) as? [String: Any]) + object["version"] = 2 + let data = try JSONSerialization.data(withJSONObject: object) + + XCTAssertThrowsError(try SparkExitRecordCoder.decode(data)) { error in + XCTAssertEqual(error as? SparkExitRecordCoder.UnsupportedVersion, .init(version: 2)) + } + } + + /// R8: a record of a version this build cannot read does not make a running exit disappear. What it still tells + /// is kept, the exit rebuilds from the chain with its funding and transfer, and the original stays aside. + func testARecordOfAnUnknownVersionKeepsItsExitToRebuildFromTheChain() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + let lostLeafStore = SparkExitLostLeafStore(url: dir.appendingPathComponent("spark-exit-lost-leaves.json")) + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 3, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(record)) as? [String: Any]) + object["version"] = 2 + object["recovery"] = ["a format": "from the future"] + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: object).write(to: store.url) + + let loaded = try XCTUnwrap(SparkExitManager.loadRecord(store: store, lostLeafStore: lostLeafStore)) + + XCTAssertEqual(loaded.phase, .running) + XCTAssertTrue(loaded.isRebuilding) + XCTAssertEqual(loaded.isRebuiltFromChain, true) + XCTAssertNil(loaded.recovery) + XCTAssertEqual(loaded.transferId, "transfer-1") + XCTAssertEqual(loaded.fundingInputs, record.fundingInputs) + XCTAssertEqual(loaded.fundingIndex, 3) + XCTAssertEqual(loaded.exitLeafIds, ["leaf-1"]) + XCTAssertEqual(try store.load(), loaded) + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + XCTAssertTrue(files.contains { $0.contains("unreadable") }, "the original is kept for support") + XCTAssertTrue(SparkExitMath.orphanedTransferIds([], record: loaded).isEmpty) + } + + /// Before the first unlock the record file exists but cannot be read: that is not an unreadable record, so it is + /// neither salvaged nor set aside. + func testARecordThatCannotBeReadYetIsLeftAlone() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + // A directory where the file should be reads like a protected file: it exists and cannot be read. + try FileManager.default.createDirectory(at: store.url, withIntermediateDirectories: true) + + XCTAssertThrowsError(try SparkExitManager.loadRecord( + store: store, + lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")) + )) { error in + XCTAssertTrue(error is SparkExitStore.Unavailable) + } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: dir.path), ["spark-exit.json"]) + } + + func testAnEndedOrUnusableRecordIsNotRevived() throws { + XCTAssertNil(try SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(SparkExitFixtures.record(phase: .done)))) + XCTAssertNil(SparkExitRecordCoder.salvage(Data("{\"version\":2}".utf8))) + XCTAssertNil(SparkExitRecordCoder.salvage(Data("not json".utf8))) + let funding = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(SparkExitFixtures.record()))) + XCTAssertEqual(funding.phase, .awaitingFunding(requiredSats: 20000)) + XCTAssertFalse(funding.isRebuilding) + } + + func testStoreSavesAndLoads() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json") + let store = SparkExitStore(url: url) + defer { try? store.delete() } + XCTAssertNil(try store.load()) + + try store.save(SparkExitFixtures.record()) + + XCTAssertEqual(try store.load(), SparkExitFixtures.record()) + } + + /// qa-03 rerun: the operators keep listing a leaf an older state claimed, so its loss has to outlive the record. + func testLostLeavesOutliveTheExitRecord() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + let store = SparkExitStore(url: folder.appendingPathComponent("spark-exit.json")) + let lostLeafStore = SparkExitLostLeafStore(url: folder.appendingPathComponent("spark-exit-lost-leaves.json")) + defer { try? FileManager.default.removeItem(at: folder) } + XCTAssertEqual(try lostLeafStore.load(), []) + var lost = SparkExitFixtures.record(phase: .failed(reason: SparkExitRecord.lostReason)) + lost.lostLeaves = [.init(leafId: "leaf-1", valueSats: 65536)] + try store.save(lost) + try lostLeafStore.add(lostIn: lost) + + let next = SparkExitFixtures.record() + try store.save(next) + try lostLeafStore.add(lostIn: next) + + XCTAssertEqual(try lostLeafStore.load(), ["leaf-1"]) + try lostLeafStore.delete() + XCTAssertEqual(try lostLeafStore.load(), []) + } + + /// qa-03 rerun of fix 10: a leaf an exit lost before the lost-leaf list existed joins it once the record loads, + /// so the first quote after the update leaves it out. + func testLoadingTheRecordAddsItsLostLeavesToTheList() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + let store = SparkExitStore(url: folder.appendingPathComponent("spark-exit.json")) + let lostLeafStore = SparkExitLostLeafStore(url: folder.appendingPathComponent("spark-exit-lost-leaves.json")) + defer { try? FileManager.default.removeItem(at: folder) } + + try store.save(SparkExitFixtures.record()) + XCTAssertEqual(try SparkExitManager.loadRecord(store: store, lostLeafStore: lostLeafStore), SparkExitFixtures.record()) + XCTAssertFalse(FileManager.default.fileExists(atPath: lostLeafStore.url.path)) + + var ended = SparkExitFixtures.record(phase: .failed(reason: SparkExitRecord.lostReason)) + ended.lostLeaves = [.init(leafId: "lost-before", valueSats: 65536)] + try store.save(ended) + XCTAssertEqual(try SparkExitManager.loadRecord(store: store, lostLeafStore: lostLeafStore), ended) + XCTAssertEqual(try lostLeafStore.load(), ["lost-before"]) + } +} + +// MARK: - Driver + +final class SparkExitDriverTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private var saved: [SparkExitRecord] = [] + private var clock = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + saved = [] + } + + private func driver(withSdk: Bool = true) -> SparkExitDriver { + SparkExitDriver( + chain: chain, + recovering: withSdk ? sdk : nil, + makeSigner: { _ in StubCpfpSigner() }, + save: { [unowned self] in saved.append($0) }, + now: { [unowned self] in clock } + ) + } + + func testWaitsForAConfirmedFundingUtxo() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", value: 20000, confirmed: false)] + + let record = try await driver().tick(SparkExitFixtures.record()) + + XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 20000)) + XCTAssertTrue(sdk.prepareRequests.isEmpty) + } + + func testBuildsOnceFundedAndSavesBeforeBroadcasting() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: "03c1"), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .waitingForDependencies), + ])] + var savesAtFirstBroadcast: Int? + chain.onBroadcast = { [unowned self] in savesAtFirstBroadcast = savesAtFirstBroadcast ?? saved.count } + + let record = try await driver().tick(SparkExitFixtures.record()) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(savesAtFirstBroadcast, 1) + XCTAssertEqual(saved.first?.recovery, record.recovery) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 25000, pubkey: "02ab")]) + guard case let .specific(leafIds) = sdk.prepareRequests.first?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-1"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertEqual(record.broadcastLog.map(\.txid), ["n1"]) + } + + func testValidBroadcastsReadyStepsOnceUntilTheyAreDue() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n2"], status: .waitingForTimelock(spendableAtHeight: 3000)), + ]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: checked, verdict: .valid), count: 3) + + record = try await driver().tick(record) + record = try await driver().tick(record) + clock = clock.addingTimeInterval(SparkExitDriver.rebroadcastInterval) + record = try await driver().tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) + XCTAssertEqual(record.broadcastLog.first?.attempts, 2) + // Tip 1000, critical path n2 (1 block) then r1 (2000 blocks of timelock, then 1): Glow's `blocksToFinish`. + XCTAssertEqual(record.claimableAtHeight, 3002) + XCTAssertEqual(record.recovery?.response, checked) + } + + /// R1: a step refused three times rebuilds the exit, at most three times while no step confirms. Then the exit + /// stops rebuilding and says it is not moving. + func testRefusedStepsRebuildThreeTimesThenTheExitIsNotMoving() async throws { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected("min relay fee not met") + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 30) + sdk.builds = Array(repeating: recovery, count: 3) + + for _ in 0 ..< 20 { + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + if (record.stallRebuilds ?? 0) < SparkExitDriver.stallRebuildsToSurface { + XCTAssertNil(record.surfacedRejection) + } + } + + XCTAssertEqual(sdk.recoverRequests.count, 3) + XCTAssertEqual(record.generation, 4) + XCTAssertEqual(record.stallRebuilds, 3) + XCTAssertEqual(record.surfacedRejection, "rejected: min relay fee not met") + } + + /// The mempool refusing a step's fee rate is no broken chain: the exit says the network is busier than its rate + /// at once and does not rebuild. A zero fee and a lost replacement race stay on the broken-chain path. + func testAFeeRateUnderTheMempoolsShowsAtOnceWithoutRebuilding() async throws { + let lowFee = ["min relay fee not met, 5 < 17", "mempool min fee not met, 120 < 300"] + let brokenChain = ["min relay fee not met, 0 < 17", "insufficient fee, rejecting replacement 1234, less fees than conflicting txs; 200 < 300"] + for text in lowFee { + XCTAssertTrue(SparkExitBroadcastClassifier.isFeeRateTooLow(.rejected(text)), text) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.rejected(text)), text) + } + for text in brokenChain { + XCTAssertFalse(SparkExitBroadcastClassifier.isFeeRateTooLow(.rejected(text)), text) + } + XCTAssertTrue(SparkExitBroadcastClassifier.isUnpaid(.rejected(brokenChain[0]))) + + for text in lowFee { + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected(text) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 4) + + record = try await driver().tick(record) + XCTAssertEqual(record.underpricedStep(tip: nil)?.txid, "n1", "shown after the first refusal") + + for _ in 0 ..< 3 { + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + } + XCTAssertTrue(sdk.recoverRequests.isEmpty, text) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0, text) + XCTAssertNil(record.surfacedRejection, text) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains { $0.id == "1-slow-n1" }) + } + + // The lost race rebuilds over the stalled chain. + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected(brokenChain[1]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 3) + sdk.builds = [recovery] + for _ in 0 ..< 3 { + record = try await driver().tick(record) + } + XCTAssertEqual(record.stallRebuilds, 1) + XCTAssertNil(record.underpricedStep(tip: nil)) + } + + /// A refused step the chain shows confirmed since, such as by the operators' copy, is not stuck. + func testAConfirmedStepIsNotStuck() { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900))])) + record.broadcastLog = [.init( + generation: 1, + txid: "n1", + attempts: 3, + rejections: 3, + lastAttemptAt: clock, + lastOutcome: "rejected: bad", + isAccepted: false + )] + + XCTAssertNil(record.stuckStep) + } + + func testAStepConfirmingResetsTheRebuildCap() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1"), SparkExitFixtures.tx("n2", dependsOn: ["n1"])])) + record.stallRebuilds = 2 + record.stallConfirmedCount = 0 + sdk.checks = [CheckRecoverFundsResponse(recovery: SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"]), + ]), verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertNil(record.stallRebuilds) + XCTAssertNil(record.stallConfirmedCount) + } + + /// R1: an accepted step that stays unconfirmed for more than six blocks is underpriced. Nothing rebuilds. + func testAnAcceptedStepThatDoesNotConfirmIsUnderpricedAndNotRebuilt() async throws { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 2) + chain.tip = 1000 + + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first?.acceptedAtHeight, 1000) + XCTAssertNil(record.underpricedStep(tip: 1006)) + XCTAssertEqual(record.underpricedStep(tip: 1007)?.txid, "n1") + + chain.tip = 1007 + clock = clock.addingTimeInterval(SparkExitDriver.rebroadcastInterval) + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first?.acceptedAtHeight, 1000, "a rebroadcast keeps the first acceptance") + XCTAssertEqual(record.generation, 1) + XCTAssertTrue(sdk.recoverRequests.isEmpty) + let reminders = SparkExitMath.reminders(record, tip: 1007, now: clock, network: .regtest) + XCTAssertTrue(reminders.contains { $0.id == "1-slow-n1" }) + } + + func testDoneFinishesAndReadsLeftoverFunding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("change", value: 700)] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertFalse(record.isActive) + XCTAssertEqual(record.leftover, [.init(txid: "change", vout: 0, valueSats: 700)]) + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertEqual(saved.last, record) + } + + func testDoneWaitsForTheFundingAddressToBeReadBeforeEnding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: done, verdict: .done), count: 2) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("change", value: 700)] + chain.isUtxosUnreadable = true + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.leftover.isEmpty) + + chain.isUtxosUnreadable = false + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.leftover, [.init(txid: "change", vout: 0, valueSats: 700)]) + } + + func testRedoRebuildsAsANewGenerationAndNeverSendsTheOldOne() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + let old = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(old) + record.broadcastLog = [.init( + generation: 1, + txid: "old", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "accepted", + isAccepted: true + )] + sdk.checks = [CheckRecoverFundsResponse(recovery: old, verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new", cpfp: "03n")])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["new"]) + XCTAssertEqual(record.broadcastLog.map(\.txid), ["new"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["new"]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")]) + } + + func testRebuildThatNeedsMoreFeesAsksForFunding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")])) + sdk.checks = [CheckRecoverFundsResponse(recovery: SparkExitFixtures.recovery([]), verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + XCTAssertTrue(record.isActive) + } + + /// The headless regtest run: a quote for 8,541 and five payments of 9,035 together. The SDK priced each extra + /// input and asked for 9,085, a second funding round. With 68 vB per extra input at 2 sat/vB, 8,541 + 4 x 136 is + /// that same 9,085, so the exit now waits and asks for the rest before it builds. + func testEachExtraPaymentAddsItsInputFee() async throws { + let values: [UInt64] = [5000, 2000, 1000, 535, 500] + chain.utxosByAddress["bcrt1qfunding"] = values.enumerated().map { SparkExitFixtures.utxo("u\($0.offset)", value: $0.element) } + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 8541)) + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: chain.utxosByAddress["bcrt1qfunding"]), .ask(sats: 294, receivedSats: 9035)) + + chain.utxosByAddress["bcrt1qfunding", default: []].append(SparkExitFixtures.utxo("u5", value: 300)) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 6) + } + + /// A rebuild the SDK finds short: the shortfall is what the inputs lack plus the input fee of the payment still to + /// come (68 vB at 2 sat/vB), never under the dust limit. + func testInsufficientFundsAsksForWhatTheInputsLackAndTheNextInput() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("u0", value: 9035)] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 9085, docsUrl: "") + + var record = try await driver().tick(SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 8541))) + + XCTAssertEqual(record.fundedSats, 9035) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9221)) + XCTAssertEqual(record.fundingShortfallSats, 186) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: chain.utxosByAddress["bcrt1qfunding"]), .ask(sats: 294, receivedSats: 0)) + + chain.utxosByAddress["bcrt1qfunding", default: []] += [SparkExitFixtures.utxo("t1", value: 150), SparkExitFixtures.utxo("t2", value: 150)] + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9221), "300 is short of 186 plus a second input's 136") + + chain.utxosByAddress["bcrt1qfunding", default: []].append(SparkExitFixtures.utxo("t3", value: 300)) + sdk.recoverError = nil + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + // The fewest coins that cover: t3 alone; t1 and t2 stay for the leftover sweep. + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 2) + XCTAssertEqual(record.funding.compactMap(\.txid), ["u0", "t3"]) + } + + /// After a Redo the first inputs are partly spent: what they hold now is the CPFP change at the address. + func testRebuildShortfallCountsWhatTheSpentInputsStillHold() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + let old = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex)]) + record.adopt(old) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 13000)] + sdk.checks = [CheckRecoverFundsResponse(recovery: old, verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 15000, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 22136)) + XCTAssertEqual(record.fundingShortfallSats, 2136) + } + + /// Gen 1's change outlives a rebuild that needed no funding; a later shortfall still counts it as held. + func testChangeOfAnEarlierGenerationIsNotNewFunding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex)])) + let second = SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")]) + record.adopt(second) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 13000)] + sdk.checks = [CheckRecoverFundsResponse(recovery: second, verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 15000, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.earlierCpfpTxids, [SparkExitFixtures.parentTxid]) + XCTAssertEqual(record.fundingShortfallSats, 2136) + record = try await driver().tick(record) + XCTAssertEqual(record.funding.count, 1, "the change is not adopted as a top-up") + } + + func testTopUpIgnoresCpfpChangeChainedToTheFundingAddress() async throws { + var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 23000)) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.recovery = StoredRecovery(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex)])) + chain.utxosByAddress["bcrt1qfunding"] = [ + SparkExitFixtures.utxo("f0", vout: 1, value: 20000), + SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 9000), + ] + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 23000)) + XCTAssertEqual(record.fundingShortfallSats, 3000) + XCTAssertTrue(sdk.prepareRequests.isEmpty) + + chain.utxosByAddress["bcrt1qfunding", default: []].append(SparkExitFixtures.utxo("topup", value: 3000)) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 2) + } + + func testRedoIsSavedBeforeRebuildingSoTheOldGenerationIsNeverSentWithoutTheSdk() async throws { + var record = SparkExitFixtures.record(phase: .running) + let old = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(old, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: old, verdict: .redo(reason: .onChainStateDiverged))] + + // Built a minute ago: the rebuild waits, but the diverged state is already saved. + clock = clock.addingTimeInterval(60) + record = try await driver().tick(record) + XCTAssertTrue(record.isRebuilding) + XCTAssertTrue(saved.first?.isRebuilding == true) + XCTAssertTrue(sdk.recoverRequests.isEmpty) + + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(chain.broadcasts.isEmpty) + + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + record = try await driver().tick(record) + XCTAssertFalse(record.isRebuilding) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["new"]) + } + + func testCooperativeLeavesTheOperatorsCouldNotSignAreTriedAgain() async throws { + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil) + sdk.builds = [SparkExitFixtures.recovery( + [], + failed: [CooperativeRecoveryFailure(leafId: "leaf-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down"))] + )] + + let record = try await driver().tick(SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 0))) + + // Kept as the exit's recovery, so the next check puts the leaf through the bounded failed-leaf retry. + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.failedLeafCount, 1) + XCTAssertEqual(record.recovery?.response.failed.map(\.leafId), ["leaf-1"]) + } + + func testRebuildWithNothingLeftEndsTheExit() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.isRebuilding = true + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: []) + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + } + + func testRebuildWaitsWhileALeafIsUnverified() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.isRebuilding = true + var prepared = SparkExitFixtures.prepared(leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.isRebuilding) + } + + func testResumesAfterARestartWithoutRepeatingBroadcasts() async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json") + let store = SparkExitStore(url: url) + defer { try? store.delete() } + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", value: 20000)] + let built = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + sdk.builds = [built] + let persisting = SparkExitDriver( + chain: chain, + recovering: sdk, + makeSigner: { _ in StubCpfpSigner() }, + save: { try store.save($0) }, + now: { [unowned self] in clock } + ) + try await persisting.tick(SparkExitFixtures.record()) + + let reloaded = try XCTUnwrap(store.load()) + sdk.checks = [CheckRecoverFundsResponse(recovery: built, verdict: .valid)] + try await persisting.tick(reloaded) + + XCTAssertEqual(reloaded.phase, .running) + XCTAssertEqual(reloaded.generation, 1) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertEqual(sdk.recoverRequests.count, 1) + } + + func testWithoutTheSdkBroadcastsStepsWhoseParentsConfirmedAndTimelocksMatured() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .ready), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"], status: .waitingForDependencies), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n2"], status: .waitingForTimelock(spendableAtHeight: 990)), + SparkExitFixtures.tx("r2", kind: .refund, dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 1500)), + ])) + chain.tip = 1000 + chain.confirmedTxids = ["n1"] + + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.recovery?.response.transactions.first?.status, .confirmed(blockHeight: 900)) + XCTAssertEqual(record.phase, .running) + } + + func testCheckSeesStepsTheChainHostShowsInABlock() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("fanout", status: .ready), + SparkExitFixtures.tx("n1", cpfp: "03c1", dependsOn: ["fanout"], status: .waitingForDependencies), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"], status: .waitingForDependencies), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n3"], status: .waitingForDependencies), + ])) + chain.confirmedTxids = ["fanout", "n1", "r1"] + let stored = try XCTUnwrap(record.recovery?.response) + sdk.checks = [CheckRecoverFundsResponse(recovery: stored, verdict: .valid)] + + _ = try await driver().tick(record) + + let statuses = sdk.checkedRecoveries.first?.transactions.map(\.status) + XCTAssertEqual(statuses, [ + .confirmed(blockHeight: 900), + .confirmed(blockHeight: 900), + .waitingForDependencies, + .waitingForDependencies, + ]) + } + + // MARK: qa-03 + + /// The keyless pass counts a relative timelock from the confirmation height of the parent each input spends, + /// and sends a step only once it would be valid in the next block. + func testWithoutTheSdkWaitsForEachRelativeTimelockFromItsParentsHeight() async throws { + var record = SparkExitFixtures.record(phase: .running) + let fromHex = RecoveryTransaction( + kind: .refund, + nodeId: "leaf-1", + txid: "r1", + txHex: SparkExitFixtures.csvHex, + cpfpTxHex: nil, + csvTimelockBlocks: 10, + dependsOn: [SparkExitFixtures.csvParentTxid], + status: .waitingForDependencies + ) + let fallback = SparkExitFixtures.tx("r2", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 5) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 995)), + fromHex, + fallback, + ])) + chain.confirmedTxids = [SparkExitFixtures.csvParentTxid, "n1"] + chain.heights["n1"] = 995 + + chain.tip = 908 + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertEqual(record.recovery?.response.transactions[1].status, .waitingForTimelock(spendableAtHeight: 910)) + XCTAssertEqual(record.recovery?.response.transactions[2].status, .waitingForTimelock(spendableAtHeight: 1000)) + + chain.tip = 909 + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1"]) + + chain.tip = 999 + _ = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1", "r2"]) + } + + func testWithoutTheSdkSkipsATimelockedStepWhoseParentHeightIsUnknown() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: nil)), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 5), + ])) + chain.confirmedTxids = ["n1"] + chain.unknownHeightTxids = ["n1"] + chain.tip = 5000 + + _ = try await driver(withSdk: false).tick(record) + + XCTAssertTrue(chain.broadcasts.isEmpty) + } + + /// A timelock not yet final is retried and never counts towards the rejection alert. + func testNotMatureIsNeverARejection() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + chain.outcome = .notMature("non-BIP68-final") + + for _ in 0 ..< 4 { + record = try await driver(withSdk: false).tick(record) + } + + XCTAssertEqual(chain.broadcasts.count, 4) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0) + XCTAssertNil(record.broadcastLog.first?.isInputSpent) + XCTAssertNil(record.surfacedRejection) + } + + /// The operators went first: with its parents confirmed, missing inputs mean another transaction spent the + /// step's input. The keyless pass stops sending it; the SDK path still follows its check. + func testWithoutTheSdkStopsAStepWhoseInputSomeoneElseSpent() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), + ])) + chain.confirmedTxids = ["n1"] + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + + record = try await driver(withSdk: false).tick(record) + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.broadcastLog.first?.isInputSpent, true) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0) + + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid)] + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) + } + + private func reorgedParentRecord() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("r1", dependsOn: ["n1"], status: .waitingForDependencies), + ])) + return record + } + + /// A reorg undid a confirmation the record saved: the keyless pass asks the chain again before sending its child. + func testWithoutTheSdkASavedConfirmationAReorgUndidIsAskedAgain() async throws { + var record = reorgedParentRecord() + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + + record = try await driver(withSdk: false).tick(record) + + XCTAssertFalse(chain.broadcasts.contains { $0.txid == "r1" }) + if case .confirmed = record.recovery?.response.transactions.first(where: { $0.txid == "n1" })?.status { + XCTFail("the reorged parent stayed confirmed") + } + XCTAssertFalse(record.broadcastLog.contains { $0.txid == "r1" && $0.isInputSpent == true }) + + chain.confirmedTxids = ["n1"] + chain.outcome = .accepted + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid).filter { $0 == "r1" }, ["r1"]) + } + + /// A child refused as spent while its parent's confirmation was a reorged one is sent again once it reconfirms. + func testWithoutTheSdkAReorgClearsTheSpentInputItsChildWasRefusedFor() async throws { + var record = reorgedParentRecord() + record.broadcastLog = [.init( + generation: 1, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + + record = try await driver(withSdk: false).tick(record) + XCTAssertNotEqual(record.broadcastLog.first { $0.txid == "r1" }?.isInputSpent, true) + + chain.confirmedTxids = ["n1"] + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid).filter { $0 == "r1" }, ["r1"]) + } + + /// No host answering for a saved confirmation keeps it. + func testWithoutTheSdkASavedConfirmationStaysWhileNoHostAnswers() async throws { + chain.droppedTxids = ["n1"] + + let record = try await driver(withSdk: false).tick(reorgedParentRecord()) + + XCTAssertEqual(record.recovery?.response.transactions.first?.status, .confirmed(blockHeight: 900)) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1"]) + } + + /// qa-03 shared-ancestor race, with the operators down so nothing else diverges: another wallet's CPFP child + /// confirmed node n1, ours never will, and n2's child spends our child's change. `checkRecoverFunds` answers + /// Valid, so the exit rebuilds from the same funding and never sends the dead chain again. + func testAFeeChainThatLostARaceRebuildsWithoutResendingIt() async throws { + let funding = String(repeating: "f0", count: 32) + let ourChild = SparkExitFixtures.rawTx(spending: [funding]) + let chained = try SparkExitFixtures.rawTx(spending: [XCTUnwrap(SparkExitTxid.txid(hex: ourChild))]) + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: funding, vout: 0, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"], status: .waitingForDependencies), + ]), at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.confirmedTxids = ["n1"] + sdk.checks = [CheckRecoverFundsResponse(recovery: SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild, status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]), + ]), verdict: .valid)] + let fresh = SparkExitFixtures.rawTx(spending: [funding]) + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: fresh, dependsOn: ["n1"]), + ])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(chain.broadcasts.map(\.cpfpTxHex), [fresh]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: funding, vout: 0, valueSats: 20000, pubkey: "02ab")]) + XCTAssertNil(record.surfacedRejection) + } + + func testAChildStillInTheMempoolIsNotDead() async throws { + let ourChild = SparkExitFixtures.rawTx(spending: [String(repeating: "f0", count: 32)]) + let childTxid = try XCTUnwrap(SparkExitTxid.txid(hex: ourChild)) + let chained = SparkExitFixtures.rawTx(spending: [childTxid]) + var record = SparkExitFixtures.record(phase: .running) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild, status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]), + ]) + record.adopt(checked, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.confirmedTxids = ["n1"] + chain.mempoolTxids = [childTxid] + sdk.checks = [CheckRecoverFundsResponse(recovery: checked, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(chain.broadcasts.map(\.cpfpTxHex), [chained]) + } + + /// The safety net: a step whose parents confirmed and whose child keeps failing to pay for it rebuilds the exit, + /// counting towards the rebuild cap and never towards rejections. + func testAStepWhoseFeeChainKeepsFailingRebuilds() async throws { + var record = SparkExitFixtures.record(phase: .running) + let first = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), + ]) + let second = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03d", dependsOn: ["n1"]), + ]) + record.adopt(first, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected("min relay fee not met, 0 < 17") + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: first, verdict: .valid), count: 3) + + Array(repeating: CheckRecoverFundsResponse(recovery: second, verdict: .valid), count: 2) + sdk.builds = [second] + + record = try await driver().tick(record) + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first?.feeChainFailures, 2) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0) + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.stallRebuilds, 1) + + record = try await driver().tick(record) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0, "an unpaid child is never a rejection") + XCTAssertNil(record.surfacedRejection) + } + + /// qa-03 repro: an older state's refund took the leaf, so every rebuild reproduces the same refund, whose input + /// is spent. After two such rebuilds the leaf is lost and the exit goes on with the rest. + func testALeafAnOlderStateClaimedIsLostAfterTwoRebuildsThatReproduceItsRefund() async throws { + var record = SparkExitRecord( + network: "regtest", + createdAt: clock, + phase: .running, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: ["leaf-1", "leaf-2"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)], + expectedSats: 150_000 + ) + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + SparkExitFixtures.refund("r2", leafId: "leaf-2", dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 5000)), + ]) + record.adopt(stuck, at: clock) + let redo = CheckRecoverFundsResponse(recovery: stuck, verdict: .redo(reason: .onChainStateDiverged)) + sdk.checks = [CheckRecoverFundsResponse(recovery: stuck, verdict: .valid), redo, redo] + sdk.builds = [stuck, stuck] + chain.outcome = .inputsMissing(#"sendrawtransaction RPC error: {"code":-25}"#) + + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first { $0.txid == "r1" }?.isInputSpent, true) + + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.spentStepStrikes, ["r1": 1]) + XCTAssertNil(record.lostLeaves) + + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2, "the build that found the lost leaf is not adopted") + XCTAssertEqual(record.lostLeaves, [.init(leafId: "leaf-1", valueSats: 100_000)]) + XCTAssertEqual(record.lostSats, 100_000) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 50000, "the lost leaf leaves the transfer") + XCTAssertTrue(record.isRebuilding) + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).first?.id, "lost-1") + + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), SparkExitFixtures.refund( + "r2", + leafId: "leaf-2", + dependsOn: ["n1"], + status: .waitingForTimelock(spendableAtHeight: 5000) + )])] + record = try await driver().tick(record) + guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-2"]) + XCTAssertEqual(record.generation, 3) + XCTAssertFalse(record.isRebuilding) + } + + func testAnExitWhoseEveryLeafAnOlderStateClaimedFails() async throws { + var record = SparkExitFixtures.record(phase: .running) + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + record.adopt(stuck, at: .distantPast) + record.spentStepStrikes = ["r1": 1] + record.broadcastLog = [.init( + generation: 1, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + sdk.checks = [CheckRecoverFundsResponse(recovery: stuck, verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [stuck] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .failed(reason: "Claimed by an older state")) + XCTAssertEqual(record.lostSats, 100_000) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["lost-1"]) + } + + /// A rebuild that replaces the spent step, the operators having gone first, is no strike. + func testARebuildThatReplacesTheSpentStepIsNoStrike() { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: [])])) + record.broadcastLog = [.init( + generation: 1, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + + XCTAssertEqual( + record.strikes(rebuiltInto: SparkExitFixtures.recovery([SparkExitFixtures.refund("r1b", leafId: "leaf-1", dependsOn: [])])), + [:] + ) + XCTAssertEqual( + record.strikes(rebuiltInto: SparkExitFixtures.recovery([SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: [])])), + ["r1": 1] + ) + } + + /// Top-ups join the exit under its own funding index, and the build signs with that index's key. + func testFundingAndSigningUseTheRecordsFundingIndex() async throws { + var record = SparkExitFixtures.record() + record.funding = [.init(index: 3, address: "bcrt1qfunding3", pubkey: "03cd")] + chain.utxosByAddress["bcrt1qfunding3"] = [SparkExitFixtures.utxo("f0", value: 12000), SparkExitFixtures.utxo("f1", value: 9000)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + var indexes: [UInt32] = [] + let driver = SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { index in + indexes.append(index) + return StubCpfpSigner() + }, save: { _ in }) + + record = try await driver.tick(record) + + XCTAssertEqual(record.funding.map(\.index), [3, 3]) + XCTAssertEqual(indexes, [3]) + } + + /// A fee-ladder leaf whose refund cannot pay its fee fails a `Specific` rebuild as a whole: that leaf stays + /// behind and the rest are prepared again. + func testARebuildLeavesBehindTheLeafThatCannotPayItsRefund() async throws { + var record = twoLeafRecord() + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.specific(leafIds: ["leaf-1", "leaf-2"]), .specific(leafIds: ["leaf-2"])]) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.leftBehindSats, 100_000) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.exitLeafIds, ["leaf-2"]) + } + + /// The first build has no recovery to price a leaf it leaves behind from: the quote's value is kept. + func testALeafTheFirstBuildLeavesBehindKeepsItsValue() async throws { + var record = twoLeafRecord() + record.leafSats = ["leaf-1": 100_000, "leaf-2": 50000] + record.isRebuilding = true + sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.leftBehindSats, 100_000, "the leaf left behind counted as 0 sats") + } + + /// An error that names no leaf falls back to `RecoverableOnly`, and what it leaves out stays behind. + func testARebuildFallsBackToRecoverableOnlyWhenTheErrorNamesNoLeaf() async throws { + var record = twoLeafRecord() + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("A leaf cannot be exited")] + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.last?.selection, .recoverableOnly) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.generation, 2) + } + + /// `RecoverableOnly` also lists a leaf an older state claimed in an earlier exit, which this exit would rebuild + /// forever and never find lost: the exit's own leaves are prepared again on their own. + func testRecoverableOnlyLeavesOutTheLeavesOfOtherExits() async throws { + var record = twoLeafRecord() + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("A leaf cannot be exited")] + sdk.preparedByRequest = { request in + request.selection == .recoverableOnly + ? SparkExitFixtures.prepared(leaves: ["leaf-2", "lost-earlier"]) + : SparkExitFixtures.prepared(leaves: ["leaf-2"]) + } + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection).suffix(2), [.recoverableOnly, .specific(leafIds: ["leaf-2"])]) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.generation, 2) + } + + /// qa-03 rerun: the next exit quoted the leaf an earlier one lost, lost it again and told the user twice. + func testALeafLostInAnEarlierExitIsNotReportedAgain() async throws { + sdk.preparedByRequest = { request in + request.selection == .all ? SparkExitFixtures.prepared(leaves: ["leaf-1", "lost-earlier"]) : SparkExitFixtures.prepared() + } + let quote = try await SparkExitQuote.prepare( + sdk, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + lostLeafIds: ["lost-earlier"] + ) + var record = SparkExitRecord( + network: "regtest", + createdAt: clock, + phase: .running, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: quote.leafIds, + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)], + expectedSats: quote.incomingSats + ) + let stuck = SparkExitFixtures.recovery([SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: [])]) + record.adopt(stuck, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: stuck, verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.refund("r1b", leafId: "leaf-1", dependsOn: [])])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.leafIds, ["leaf-1"]) + XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["leaf-1"])) + XCTAssertNil(record.lostLeaves) + XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains(where: \.isLostLeaves)) + } + + func testAnExitWithNoLeafLeftToExitFails() async throws { + var record = SparkExitFixtures.record(phase: .running) + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .failed(reason: "No leaf can be exited")) + XCTAssertEqual(record.leftBehindSats, 100_000) + XCTAssertTrue(sdk.recoverRequests.isEmpty) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["failed"]) + } + + /// The last leaf lost on a tick that could not read the funding address: the next tick ends the exit through the + /// no-leaf path, still as lost, and only the lost notification fires. + func testASingleLostLeafEndedThroughTheNoLeafPathNotifiesOnlyTheLoss() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 100_000)] + record.isRebuilding = true + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .failed(reason: SparkExitRecord.lostReason)) + XCTAssertTrue(sdk.prepareRequests.isEmpty) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["lost-1"]) + record.phase = .failed(reason: "No leaf can be exited") + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["lost-1"]) + } + + /// qa-03: a failed or lost-leaf exit with nothing left to sweep ended at once and Emergency exit opened a new + /// intro, so the user never saw why. + func testAFailedOrLostLeafResultStaysReachableUntilSeen() { + var failed = SparkExitFixtures.record(phase: .failed(reason: "No leaf can be exited")) + XCTAssertFalse(failed.needsDriving) + XCTAssertTrue(failed.showsResult) + failed.isResultSeen = true + XCTAssertFalse(failed.showsResult) + + var partlyLost = SparkExitFixtures.record(phase: .done) + XCTAssertFalse(partlyLost.showsResult) + partlyLost.lostLeaves = [.init(leafId: "leaf-1", valueSats: 4000)] + XCTAssertTrue(partlyLost.showsResult) + partlyLost.isResultSeen = true + XCTAssertFalse(partlyLost.showsResult) + + var running = SparkExitFixtures.record(phase: .running) + running.lostLeaves = [.init(leafId: "leaf-1", valueSats: 4000)] + XCTAssertFalse(running.hasUnseenResult) + } + + private func twoLeafRecord() -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: clock, + phase: .running, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: ["leaf-1", "leaf-2"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)], + expectedSats: 150_000 + ) + } + + // MARK: Glow-informed hardening + + /// R2: a unilateral step the SDK could not read is never sent; with nothing unilateral ready, the exit rebuilds + /// over it once a block, with the same funding. + func testAnUnverifiedUnilateralStepRebuildsInsteadOfBeingSent() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"], status: .unverified), + ]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2b", cpfp: "03d", dependsOn: ["n1"]), + ])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.unverifiedRebuiltAtHeight, 1000) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2b"]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")]) + XCTAssertNil(record.stallRebuilds, "not a rebuild over a stalled chain") + + // Once a block: the same tip does not rebuild again. + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + record = try await driver().tick(record) + XCTAssertEqual(sdk.recoverRequests.count, 1) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2b"], "the unverified step is still not sent") + } + + /// A ready step that one failed chain read turns unverified may already be sent, so it is not rebuilt over: the + /// exit waits for the next check, which reads it again, and rebuilds only if it stays unread. + func testAStepAFailedReadTurnsUnverifiedWaitsForTheNextCheckInsteadOfRebuilding() async throws { + var record = SparkExitFixtures.record(phase: .running) + let ready = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), + ]) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"], status: .unverified), + ]) + record.adopt(ready, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty, "rebuilt over a step one failed read turned unverified") + XCTAssertNil(record.unverifiedRebuiltAtHeight) + XCTAssertNil(record.isUnverifiedAsk) + XCTAssertEqual(record.generation, 1) + XCTAssertTrue(chain.broadcasts.isEmpty, "the unverified step was sent") + + // Still unread at the next check: now it rebuilds. + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2b", cpfp: "03d", dependsOn: ["n1"]), + ])] + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, 1) + XCTAssertEqual(record.unverifiedRebuiltAtHeight, 1000) + XCTAssertEqual(record.generation, 2) + } + + /// A step one failed read turns unverified holds the rebuild over another leaf's step that was already unread: the + /// rebuild would replace it while it may be in flight. + func testAStepAFailedReadTurnsUnverifiedHoldsTheRebuildOverAnotherUnreadLeaf() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + let stored = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"]), + ]) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .unverified), + ]) + record.adopt(stored, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: checked, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty, "rebuilt over m2, which one failed read turned unverified") + XCTAssertNil(record.unverifiedRebuiltAtHeight) + XCTAssertEqual(record.generation, 1) + } + + /// An unverified rebuild that builds only failed cooperative leaves, beside the leaf it could not read, keeps the + /// valid generation: its signed steps are still sent once the chain reads again. + func testAnUnverifiedRebuildOfOnlyFailedLeavesKeepsTheValidGeneration() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "coop-1"] + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + ], failed: [failure]) + let readable = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"]), + ], failed: [failure]) + record.adopt(unread, at: clock) + sdk.checks = [ + CheckRecoverFundsResponse(recovery: unread, verdict: .valid), + CheckRecoverFundsResponse(recovery: readable, verdict: .valid), + ] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = Array(repeating: SparkExitFixtures.recovery([], failed: [failure]), count: 2) + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "rebuild adopted a recovery without leaf-1's signed steps") + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") + } + + /// An unverified rebuild that skips the leaf it could not read but builds another unilateral leaf keeps the valid + /// generation: the unread leaf's signed steps are still sent once the chain reads again. + func testAnUnverifiedRebuildBesideAnotherUnilateralLeafKeepsTheValidGeneration() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = Array(repeating: SparkExitFixtures.recovery([ + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2b", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ]), count: 2) + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "rebuild adopted a recovery without leaf-1's signed steps") + + // The chain reads again: whatever the record holds, its unverified steps now read as ready. + var reread = try XCTUnwrap(record.recovery?.response) + reread.transactions = reread.transactions.map { + var tx = $0 + if tx.status == .unverified { + tx.status = .ready + } + return tx + } + sdk.checks = [CheckRecoverFundsResponse(recovery: reread, verdict: .valid)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") + } + + /// The adopted rebuild without leaf-1 never reaches the store, even for the moment before the guard restores it: + /// a process killed in that window relaunches from what was saved. + func testAnUnverifiedRebuildBesideAnotherUnilateralLeafNeverSavesTheRebuild() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2b", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1) + let rebuilt = saved.filter { $0.generation != 1 } + XCTAssertTrue( + rebuilt.isEmpty, + "saved generation \(rebuilt.map(\.generation)) without leaf-1's steps: \(rebuilt.first?.recovery?.response.transactions.map(\.txid) ?? [])" + ) + } + + /// Funding for an unverified rebuild's ask: a rebuild without leaf-1 is neither saved nor broadcast. + func testFundingAnUnverifiedRebuildsAskNeverSavesNorSendsARebuildWithoutTheUnreadLeaf() async throws { + var (record, _, _) = try await unverifiedAsk() + let unread = try XCTUnwrap(record.recovery?.response) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 50000)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2b", cpfp: "03c1", dependsOn: ["m1"]), + ])] + chain.tip = 1001 + saved = [] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "funding adopted a rebuild without leaf-1's signed steps") + let rebuilt = saved.filter { $0.generation != 1 } + XCTAssertTrue(rebuilt.isEmpty, "saved generation \(rebuilt.map(\.generation)) without leaf-1's steps") + XCTAssertFalse( + chain.broadcasts.map(\.txid).contains("m2b"), + "broadcast m2b of a generation the record does not keep: \(chain.broadcasts.map(\.txid))" + ) + } + + /// The valid generation beside a step the SDK could not read, whose rebuild of only failed cooperative leaves asks + /// for fee funding first. + private func unverifiedAsk() async throws -> (record: SparkExitRecord, readable: RecoverFundsResponse, failure: CooperativeRecoveryFailure) { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "coop-1"] + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + ], failed: [failure]) + let readable = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"]), + ], failed: [failure]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + + record = try await driver().tick(record) + guard case .needsFunding = record.phase else { + XCTFail("the unverified rebuild asked for no funding: \(record.phase)") + return (record, readable, failure) + } + XCTAssertEqual(record.generation, 1) + sdk.recoverError = nil + return (record, readable, failure) + } + + /// A savings payment keeps an unverified rebuild's ask open; a clean check while it confirms reads the unread step + /// as ready, and it is sent. The funded rebuild then never runs: it would conflict with that step at the same fee. + func testAFundedUnverifiedAskWhoseStepReadsAgainEndsWithoutARebuild() async throws { + var (record, readable, _) = try await unverifiedAsk() + let paid = String(repeating: "f", count: 64) + record.fundingPaymentTxid = paid + let builds = sdk.recoverRequests.count + sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(paid, value: 50000)] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, builds, "rebuilt over n2, which the clean check made ready and sent") + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.phase, .running) + XCTAssertNil(record.isUnverifiedAsk) + XCTAssertEqual(record.generation, 1) + } + + /// A step that a clean check sent while the payment was unconfirmed holds the funded rebuild when the tick that adopts + /// the payment fails to read it again: the resume waits for the next check. + func testAFundedUnverifiedAskHoldsItsRebuildOverAStepSentOnAnEarlierCleanCheck() async throws { + var (record, readable, _) = try await unverifiedAsk() + let unread = try XCTUnwrap(record.recovery?.response) + let paid = String(repeating: "f", count: 64) + record.fundingPaymentTxid = paid + let builds = sdk.recoverRequests.count + sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(paid, value: 50000, confirmed: false)] + + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.isUnverifiedAsk, true) + + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(paid, value: 50000)] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertTrue(record.funding.contains { $0.txid == paid }, "the payment was not adopted, so the hold never ran") + XCTAssertEqual(sdk.recoverRequests.count, builds, "rebuilt over n2, sent on the previous tick") + XCTAssertEqual(record.isUnverifiedAsk, true) + XCTAssertEqual(record.generation, 1) + } + + /// Confirmed funding for an unverified rebuild's fee ask resumes that rebuild with its guard: a build of only failed + /// cooperative leaves beside the waiting leaf is never adopted, and the exit runs on with the valid generation. + func testFundingAnUnverifiedRebuildsAskKeepsTheValidGenerationOverAFailedOnlyBuild() async throws { + var (record, _, failure) = try await unverifiedAsk() + XCTAssertEqual(record.isUnverifiedAsk, true) + let unread = try XCTUnwrap(record.recovery?.response) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 50000)] + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "funding adopted a failed-only build without leaf-1's signed steps") + XCTAssertEqual(record.phase, .running) + XCTAssertNil(record.isUnverifiedAsk) + } + + /// An unverified rebuild's fee ask ends once every step reads again and nothing was paid: the exit runs again. + func testAnUnverifiedRebuildsFeeAskEndsOnceItsStepsReadAgain() async throws { + var (record, readable, _) = try await unverifiedAsk() + let asked = record + sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running, "the fee ask stayed after every step read again") + XCTAssertNil(record.isUnverifiedAsk) + XCTAssertTrue(SparkExitManager.isFundingAskWithdrawn(record, previous: asked)) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + } + + /// The steps of leaf-1 as the chain reads them: n2 unread, waiting for its timelock, then ready. + private func leafOneSteps(_ status: ExitTransactionStatus) -> RecoverFundsResponse { + SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: status), + ]) + } + + /// A prepare that skips leaf-1, as the SDK does while it cannot read leaf-1's chain. + private var skipsUnreadLeaf: PrepareRecoverFundsResponse { + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + return prepared + } + + /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf: the valid generation keeps + /// no retry clock from it, so a week on the exit still sends leaf-1's signed step once it matures. + func testAFundedUnverifiedRebuildThatWaitsForTheUnreadLeafLeavesNoStaleRetryClock() async throws { + let unread = leafOneSteps(.unverified) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 30000, docsUrl: "") + record = try await driver().tick(record) + guard case .needsFunding = record.phase else { return XCTFail("the unverified rebuild asked for no funding: \(record.phase)") } + XCTAssertEqual(record.isUnverifiedAsk, true) + + sdk.recoverError = nil + sdk.preparedResponse = skipsUnreadLeaf + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 100_000)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + XCTAssertNil(record.retrySince, "the funded unverified rebuild saved a retry clock at '\(record.retrySince.map(String.init) ?? "")'") + + try await runsUntilLeafOneMatures(&record) + } + + /// A check's unverified rebuild that waits for the unread leaf keeps the valid generation without a retry clock. + func testAnUnverifiedRebuildThatWaitsForTheUnreadLeafLeavesNoStaleRetryClock() async throws { + let unread = leafOneSteps(.unverified) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.preparedResponse = skipsUnreadLeaf + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + XCTAssertNil(record.retrySince, "the unverified rebuild saved a retry clock at '\(record.retrySince.map(String.init) ?? "")'") + + try await runsUntilLeafOneMatures(&record) + } + + /// A paid fee bump whose build skips leaf-1 as unread and builds only failed cooperative leaves keeps the valid + /// generation: adopted, the build would drop leaf-1's signed refund n2. + func testAPaidFeeBumpKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "coop-1"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(leafOneSteps(.unverified), at: clock) + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("bump", value: 5680)] + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "the paid bump adopted a recovery without leaf-1's refund n2") + XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1") + XCTAssertEqual(record.phase, .running) + XCTAssertFalse(record.isRebuilding) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"]) + } + + /// A fee bump with nothing to pay whose build skips leaf-1 as unread and builds leaf-2 keeps the valid generation, + /// which goes on being checked and sends leaf-1's refund n2 once the chain reads it. + func testAFeeBumpWithNothingToPayKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(leafOneSteps(.unverified), at: clock) + record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + // The second build is the bump's again, at the next block. + sdk.builds = Array(repeating: SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")]), count: 2) + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "the bump adopted a recovery without leaf-1's refund n2") + XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1") + XCTAssertEqual(record.phase, .running) + XCTAssertFalse(record.isRebuilding, "the valid generation is no longer checked or sent") + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent") + } + + /// A fee bump kept over a waiting leaf stays to be built: once a block it rebuilds at the bumped rate, the valid + /// generation sending leaf-1's refund n2 meanwhile, and once leaf-1 reads that rebuild is adopted. + func testAFeeBumpKeptOverAWaitingLeafBuildsAtTheBumpedRateOnceTheLeafReads() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(leafOneSteps(.unverified), at: clock) + record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil) + var skipsLeafOne = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + skipsLeafOne.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = skipsLeafOne + sdk.builds = Array(repeating: SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")]), count: 2) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "the rebuild that drops leaf-1's refund n2 was adopted") + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent while the bump waits") + + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-1", "leaf-2"]) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("b1"), SparkExitFixtures.tx("b2")])] + let preparesBefore = sdk.prepareRequests.count + chain.tip = 1002 + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2, "the bump was never built at its rate") + XCTAssertEqual(sdk.prepareRequests.dropFirst(preparesBefore).map(\.feeRateSatPerVbyte), [10]) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["b1", "b2"]) + } + + /// A fee chain that lost a race beside a leaf the rebuild leaves waiting: the valid generation stays, so leaf-1's + /// signed steps are not dropped, its rebuilds stop once stalled, and once leaf-1's step confirms the exit rebuilds + /// without the dead chain. + func testADeadFeeChainBesideAWaitingLeafRebuildsOnceTheLeafsStepConfirms() async throws { + let funding = String(repeating: "f0", count: 32) + let ourChild = SparkExitFixtures.rawTx(spending: [funding]) + let chained = try SparkExitFixtures.rawTx(spending: [XCTUnwrap(SparkExitTxid.txid(hex: ourChild))]) + let bothLeaves = [ + RecoverFundsLeaf(leafId: "leaf-1", valueSats: 100_000, method: .unilateral), + RecoverFundsLeaf(leafId: "leaf-2", valueSats: 100_000, method: .unilateral), + ] + func steps(_ l1: ExitTransactionStatus, n1: ExitTransactionStatus = .confirmed(blockHeight: 10)) -> RecoverFundsResponse { + var recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild, status: n1), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]), + SparkExitFixtures.tx("l1", status: l1), + ]) + recovery.leaves = bothLeaves + return recovery + } + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: funding, vout: 0, valueSats: 20000) + record.adopt(steps(.unverified, n1: .ready), at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.confirmedTxids = ["n1"] + var skipsLeafOne = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + skipsLeafOne.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = skipsLeafOne + let fresh = SparkExitFixtures.rawTx(spending: [funding]) + var rebuilt = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: fresh, dependsOn: ["n1"]), + ]) + rebuilt.leaves = [bothLeaves[1]] + sdk.builds = Array(repeating: rebuilt, count: SparkExitDriver.stallRebuildsToSurface + 1) + + for i in 0 ... SparkExitDriver.stallRebuildsToSurface { + sdk.checks = [CheckRecoverFundsResponse(recovery: steps(.unverified), verdict: .valid)] + chain.tip = 1000 + UInt32(i) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "a rebuild dropped leaf-1's signed step l1") + } + XCTAssertTrue(record.isStalled) + let rebuildsWhileStalled = sdk.recoverRequests.count + + // leaf-1 reads again and its step confirms: the exit moves, so the dead chain is rebuilt over. + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + chain.confirmedTxids.insert("l1") + sdk.checks = [CheckRecoverFundsResponse(recovery: steps(.confirmed(blockHeight: 1004)), verdict: .valid)] + chain.tip = 1005 + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, rebuildsWhileStalled + 1) + XCTAssertEqual(record.generation, 2, "the dead fee chain was never rebuilt over") + XCTAssertEqual(chain.broadcasts.last?.cpfpTxHex, fresh) + } + + /// An unverified rebuild that cannot exit leaf-3 leaves it behind and rebuilds on the next tick: with leaf-1 still + /// unread, that rebuild keeps the valid generation and leaf-1's refund n2. + func testARebuildAfterAnUnverifiedRebuildLeftALeafBehindKeepsLeafOnesRefund() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2", "leaf-3"] + record.adopt(leafOneSteps(.unverified), at: clock) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2", "leaf-3"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.recoverErrors = [SdkError.SparkError("Leaf leaf-3 cannot be exited: its refund cannot pay its own fee at this fee rate")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")])] + + record = try await driver().tick(record) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-3"]) + chain.tip = 1001 + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "the rebuild adopted a recovery without leaf-1's refund n2") + XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1") + XCTAssertFalse(record.isRebuilding, "the valid generation is no longer checked or sent") + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"]) + } + + /// An unverified rebuild that cannot exit leaf-3 leaves it behind and keeps the valid generation running, with + /// leaf-1 still unread: leaf-1's refund n2 stays and nothing rebuilds without the guard. + func testAnUnverifiedRebuildThatCannotExitALeafKeepsTheValidGenerationRunning() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2", "leaf-3"] + record.adopt(leafOneSteps(.unverified), at: clock) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2", "leaf-3"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.recoverErrors = [SdkError.SparkError("Leaf leaf-3 cannot be exited: its refund cannot pay its own fee at this fee rate")] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1) + XCTAssertFalse(record.isRebuilding, "the next tick rebuilds without the valid generation's guard") + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"], "leaf-1's refund n2 is gone") + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-3"]) + } + + /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf once a block, as the + /// check's rebuild does: a second tick at the same tip prepares nothing. + func testAFundedUnverifiedRebuildWaitingForTheUnreadLeafPreparesOnceABlock() async throws { + let unread = leafOneSteps(.unverified) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(unread, at: clock) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 30000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.isUnverifiedAsk, true) + sdk.recoverError = nil + sdk.preparedResponse = skipsUnreadLeaf + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 100_000)] + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2) + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2, "the funded resume prepared again at the same tip") + + chain.tip = 1002 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 3, "the funded resume stopped trying at the next block") + XCTAssertEqual(record.isUnverifiedAsk, true) + } + + /// Leaf-1 reads as waiting for its timelock, unread again a week later, then ready: n2 goes out. + private func runsUntilLeafOneMatures(_ record: inout SparkExitRecord) async throws { + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.waitingForTimelock(spendableAtHeight: 3000)), verdict: .valid)] + chain.tip = 1002 + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + XCTAssertNil(record.retrySince, "the exit runs with a stale retry clock '\(record.retrySince.map(String.init) ?? "")'") + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.unverified), verdict: .valid)] + chain.tip = 2100 + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running, "the exit ended before leaf-1's signed step n2 was sent") + XCTAssertEqual(record.generation, 1) + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)] + chain.tip = 3000 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") + } + + /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step + /// the SDK could not read is still sent. + func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws { + var record = SparkExitFixtures.record(phase: .running) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("coop", kind: .cooperative, status: .unverified), + SparkExitFixtures.tx("n1", status: .unverified), + SparkExitFixtures.tx("n2"), + ]) + record.adopt(checked) + sdk.checks = [CheckRecoverFundsResponse(recovery: checked, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty) + XCTAssertNil(record.unverifiedRebuiltAtHeight) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["coop", "n2"]) + } + + private func rebuildingRecord() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.isRebuilding = true + return record + } + + private func preparedSkipping(_ reason: SkippedLeafReason, stopped: [String] = []) -> PrepareRecoverFundsResponse { + var prepared = SparkExitFixtures.prepared(leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: reason)] + prepared.exitChainState.stoppedLeafIds = stopped + return prepared + } + + /// R3: funds not found yet is no end: the build waits, tries again after 6 blocks, at doubling gaps, and lets go + /// after about a week (1,008 blocks). + func testABuildWaitsForFundsNotFoundAndLetsGoAfterAWeek() async throws { + var record = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.fundsNotFound) + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.retrySince, 1000) + XCTAssertTrue(record.isWaitingForLeaves) + XCTAssertEqual(sdk.prepareRequests.count, 1) + + chain.tip = 1005 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 1, "not before 6 blocks") + + chain.tip = 1006 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2) + XCTAssertEqual(record.retriedAt, 1006) + + chain.tip = 1011 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2) + chain.tip = 1012 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 3) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1012, now: clock, network: .regtest).isEmpty, "the wait needs no reminder") + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.retrySince) + } + + /// R3 on SDK 0.28: a leaf the SDK could not ask the operators about waits like one whose funds were not found. + func testABuildWaitsForOperatorsUnavailable() async throws { + var record = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.operatorsUnavailable) + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.retrySince, 1000) + XCTAssertTrue(record.isWaitingForLeaves) + } + + /// R3 with a leaf that builds: one skipped while it may still move is kept, and tried again before the exit ends. + func testAPartialBuildRetriesTheSkippedLeafBeforeTheExitEnds() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 3) + sdk.preparedResponse.leaves = [] + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running, "leaf-2 may still move") + XCTAssertTrue(record.isWaitingForLeaves) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "leaf-2 is still in transfer") + guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-2"]) + + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(record.phase, .running, "the exit waits for the SDK to retry leaf-2") + + chain.tip = 1006 + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + record = try await driver().tick(record) + XCTAssertNil(record.retrySince) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + } + + /// A skipped leaf whose retry needs more fee funding asks for it, and is built on its own once it arrives. + func testASkippedLeafWhoseRetryNeedsMoreFeesAsksForThem() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2) + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) + + sdk.recoverError = nil + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f9", value: 3136)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-2"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) + } + + /// A skipped leaf's fee ask nobody pays is let go after the same week as its retries, and the exit ends. + func testASkippedLeafsFeeAskNobodyPaysIsLetGoAfterAWeek() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + + chain.tip = 999 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.waitingLeafIds) + } + + /// A partial build whose only built leaf fails cooperatively still keeps the leaf it skipped. + func testAPartialBuildOfOnlyFailedLeavesKeepsTheSkippedLeaf() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + let failure = CooperativeRecoveryFailure(leafId: "leaf-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) + } + + /// A waiting leaf let go after a week adds to the leaves that stayed on Spark, the quote's skips included. + func testAWaitingLeafLetGoAddsToTheLeavesThatStayedOnSpark() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + record.skippedLeafCount = 13 + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .fundsNotFound)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2) + sdk.preparedResponse.leaves = [] + record = try await driver().tick(record) + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.skippedLeafCount, 14) + } + + private func fundingAskForASkippedLeaf() async throws -> SparkExitRecord { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + record.skippedLeafCount = 3 + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + return record + } + + /// The week's let-go of an unpaid fee ask needs only the chain: it comes while the SDK can't connect too. + func testAnUnpaidFeeAskIsLetGoWithoutTheSdk() async throws { + var record = try await fundingAskForASkippedLeaf() + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(record.phase, .done) + } + + /// A let-go tick that can't read the funding address changes nothing; the next one lets go once. + func testAnUnpaidFeeAskLetGoIsAllOrNothing() async throws { + var record = try await fundingAskForASkippedLeaf() + chain.tip = 1000 + SparkExitDriver.releaseBlocks + chain.isUtxosUnreadable = true + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) + XCTAssertEqual(record.skippedLeafCount, 3) + + chain.isUtxosUnreadable = false + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.skippedLeafCount, 4) + } + + /// A skipped leaf still unverified after about a week is let go, and the exit ends. + func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .fundsNotFound)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2) + sdk.preparedResponse.leaves = [] + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.retrySince) + } + + func testIsRetryDueDoublesUpToADay() { + XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 105)) + XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 106)) + XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 124, tip: 147)) + XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 124, tip: 148)) + XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 500, tip: 643)) + XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 500, tip: 644)) + } + + /// R3: a leaf the watchtower stopped quotes as not recoverable until the operators relabel it. It waits 6 blocks + /// from first sight, then the exit lets it go; one not listed as stopped ends the exit at once. + func testAStoppedLeafWaitsSixBlocksThenIsLetGo() async throws { + var record = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.notRecoverable(message: "stopped"), stopped: ["leaf-1"]) + + record = try await driver().tick(record) + XCTAssertEqual(record.stoppedLeafSince, ["leaf-1": 1000]) + XCTAssertEqual(record.phase, .running) + + chain.tip = 1006 + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + + var other = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.notRecoverable(message: "no")) + other = try await driver().tick(other) + XCTAssertEqual(other.phase, .done) + } + + /// R3 with the 2-strike rule: a refund rebuilt over a spent input is no strike while its leaf is stopped and + /// still waiting. + func testAStoppedLeafThatStillWaitsIsNeverStruck() async throws { + var record = rebuildingRecord() + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + record.adopt(stuck, at: .distantPast) + record.isRebuilding = true + record.isRedoRebuild = true + record.spentStepStrikes = ["r1": 1] + record.broadcastLog = [.init( + generation: record.generation, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + var prepared = SparkExitFixtures.prepared() + prepared.exitChainState.stoppedLeafIds = ["leaf-1"] + sdk.preparedResponse = prepared + sdk.builds = [stuck] + + record = try await driver().tick(record) + + XCTAssertNil(record.lostLeaves) + XCTAssertNil(record.spentStepStrikes) + XCTAssertEqual(record.generation, 3) + } + + /// A rebuild over a stalled chain is no divergence the SDK reported: it reproduces a refund whose input is spent + /// without striking its leaf, so only `Redo` rebuilds can find a leaf lost. + func testARebuildOverAStalledChainStrikesNoLeaf() async throws { + var record = SparkExitFixtures.record(phase: .running) + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + record.adopt(stuck, at: .distantPast) + record.spentStepStrikes = ["r1": 1] + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: stuck, verdict: .valid), count: 3) + sdk.builds = [stuck] + + for _ in 0 ..< 3 { + record = try await driver().tick(record) + } + + XCTAssertEqual(record.stallRebuilds, 1) + XCTAssertEqual(record.generation, 2) + XCTAssertNil(record.lostLeaves) + XCTAssertNil(record.spentStepStrikes) + } + + /// R4: the SDK's exit data is exported once, right before the first build, and imported before every rebuild. + func testExportsTheExitDataBeforeTheFirstBuildAndImportsItBeforeEveryRebuild() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")]) + sdk.builds = [first, SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + + var record = try await driver().tick(SparkExitFixtures.record()) + XCTAssertEqual(sdk.exports, 1) + XCTAssertEqual(record.exitStateSnapshot, "exit-state-1") + XCTAssertTrue(sdk.imports.isEmpty) + + sdk.exitState = "exit-state-later" + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(sdk.exports, 1) + XCTAssertEqual(sdk.imports, ["exit-state-1"]) + XCTAssertEqual(record.exitStateSnapshot, "exit-state-1") + } + + /// R11: the countdown follows the longest path of steps left, so it holds still as a parent confirms and its + /// child's timelock starts. + func testTheCountdownHoldsWhenAParentConfirms() { + let before = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1"), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 100), + ]) + let after = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 1001)), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 1101), csv: 100), + ]) + + XCTAssertEqual(SparkExitMath.claimableAtHeight(before, tip: 1000), 1102) + XCTAssertEqual(SparkExitMath.claimableAtHeight(after, tip: 1001), 1102) + XCTAssertEqual(SparkExitMath.claimableAtHeight(before), nil, "without a tip, only timelocks already counting") + XCTAssertNil(SparkExitMath.blocksToFinish(after.transactions.map { + var tx = $0 + tx.status = .confirmed(blockHeight: 1200) + return tx + }, tip: 1200)) + } + + func testDoesNothingOnceFinished() async throws { + let record = try await driver().tick(SparkExitFixtures.record(phase: .done)) + + XCTAssertEqual(record.phase, .done) + XCTAssertTrue(saved.isEmpty) + } +} + +// MARK: - Fees and funding + +/// The entry point the background tasks call, from a cold launch with no UI. +final class SparkExitBackgroundRunTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private var store: SparkExitStore! + private var connects: [TimeInterval] = [] + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + store = SparkExitStore(url: FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json")) + connects = [] + } + + override func tearDown() { + try? store.delete() + super.tearDown() + } + + private func backgroundRun(deadline: TimeInterval = 25, connects sdkConnects: Bool = true) -> SparkExitBackgroundRun { + let store = store! + return SparkExitBackgroundRun( + load: { try store.load() }, + makeDriver: { chain, recovering in + SparkExitDriver(chain: chain, recovering: recovering, makeSigner: { _ in StubCpfpSigner() }, save: { try store.save($0) }) + }, + chain: chain, + connected: nil, + connect: { [unowned self] timeout in + connects.append(timeout) + return sdkConnects ? sdk : nil + }, + deadline: Date(timeIntervalSinceNow: deadline) + ) + } + + private func savedRunningExit(_ transactions: [RecoveryTransaction]) throws -> RecoverFundsResponse { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery(transactions) + record.adopt(recovery) + try store.save(record) + return recovery + } + + /// A tick that saved progress and then threw (a Redo saves the rebuild flag, then the rebuild's prepare fails): the + /// run hands back the record on disk, never the copy it started with. + func testAFailedTickHandsBackTheRecordItSaved() async throws { + let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c", status: .waitingForDependencies)]) + sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.Generic("operators unreachable")] + + let outcome = await backgroundRun().run() + + XCTAssertEqual(outcome.record?.isRebuilding, true) + XCTAssertEqual(try store.load()?.isRebuilding, true) + } + + func testColdStartSendsStoredStepsThenChecksWithTheSdkOnly() async throws { + let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c")]) + sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .valid)] + + let outcome = await backgroundRun().run() + + XCTAssertTrue(outcome.isConnected) + XCTAssertEqual(connects.count, 1) + XCTAssertEqual(sdk.checkedRecoveries.count, 1) + // The keyless pass sent it; the SDK pass found it accepted and did not send it again. + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + let saved = try XCTUnwrap(store.load()) + XCTAssertEqual(saved.broadcastLog.map(\.txid), ["n1"]) + XCTAssertEqual(saved.broadcastLog.first?.isAccepted, true) + XCTAssertNotNil(outcome.seconds[.keyless]) + XCTAssertNotNil(outcome.seconds[.check]) + XCTAssertNotNil(outcome.seconds[.broadcast]) + } + + func testColdStartSignsTheBuildOnceFundingConfirmed() async throws { + try store.save(SparkExitFixtures.record()) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c1")])] + + let outcome = await backgroundRun().run() + + XCTAssertEqual(outcome.record?.phase, .running) + XCTAssertEqual(sdk.recoverRequests.count, 1) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertEqual(try store.load()?.generation, 1) + XCTAssertNotNil(outcome.seconds[.sign]) + } + + func testExpiryMidStepLeavesAWholeRecordAndTheNextRunSendsNothingTwice() async throws { + let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c1"), SparkExitFixtures.tx("n2", cpfp: "03c2")]) + // The OS expires the run while the first package is in flight. + chain.onBroadcast = { withUnsafeCurrentTask { $0?.cancel() } } + let first = backgroundRun() + _ = await Task { await first.run() }.value + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertTrue(connects.isEmpty) + let saved = try XCTUnwrap(store.load()) + XCTAssertEqual(saved.broadcastLog.map(\.txid), ["n1"]) + + chain.onBroadcast = nil + sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .valid)] + _ = await backgroundRun().run() + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) + XCTAssertEqual(try store.load()?.broadcastLog.map(\.txid), ["n1", "n2"]) + } + + func testStaysKeylessWhenSparkDoesNotConnectOrTimeIsShort() async throws { + _ = try savedRunningExit([SparkExitFixtures.tx("n1")]) + + let late = await backgroundRun(deadline: SparkExitBackgroundRun.reserveSeconds - 1).run() + XCTAssertTrue(connects.isEmpty) + XCTAssertFalse(late.isConnected) + + let offline = await backgroundRun(connects: false).run() + XCTAssertEqual(connects.count, 1) + XCTAssertFalse(offline.isConnected) + XCTAssertTrue(sdk.checkedRecoveries.isEmpty) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + } + + func testDoesNothingWithoutARunningExit() async { + let outcome = await backgroundRun().run() + + XCTAssertNil(outcome.record) + XCTAssertTrue(connects.isEmpty) + XCTAssertTrue(chain.broadcasts.isEmpty) + } +} + +final class SparkExitMathTests: XCTestCase { + func testQuoteSplitsWhatArrivesFromWhatItCosts() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + + XCTAssertEqual(quote.exitSats, 100_000) + XCTAssertEqual(quote.networkFeeSats, 9000) + XCTAssertEqual(quote.receiveSats, 91000) + XCTAssertEqual(quote.fundingSats, 20000) + XCTAssertEqual(quote.incomingSats, 111_000) + XCTAssertEqual(quote.leftBehindSats, 300) + XCTAssertTrue(quote.isWorthExiting) + } + + func testQuoteIsNotWorthExitingWhenFeesExceedTheValue() { + var prepared = SparkExitFixtures.prepared() + prepared.totalFeeSats = 100_000 + XCTAssertFalse(SparkExitQuote(prepared: prepared).isWorthExiting) + XCTAssertEqual(SparkExitQuote(prepared: prepared).receiveSats, 0) + XCTAssertFalse(SparkExitQuote(prepared: SparkExitFixtures.prepared(leaves: [])).isWorthExiting) + } + + func testCooperativeOnlyQuoteNeedsNoFunding() { + XCTAssertEqual(SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: nil)).fundingSats, 0) + } + + func testQuoteLeavesOutTheLeavesEarlierExitsLost() async throws { + let sdk = FakeSparkExitRecovering() + sdk.preparedByRequest = { request in + guard request.selection == .all else { + var prepared = SparkExitFixtures.prepared(funding: 12000, leaves: ["leaf-1"]) + prepared.skipped = [] + return prepared + } + var prepared = SparkExitFixtures.prepared(funding: 24000, leaves: ["leaf-1", "lost"]) + prepared.recoverableValueSats = 200_000 + prepared.skipped += [SkippedLeaf(leafId: "lost", valueSats: 1000, reason: .feeExceedsValue)] + return prepared + } + + let quote = try await SparkExitQuote.prepare( + sdk, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + lostLeafIds: ["lost"] + ) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.all, .specific(leafIds: ["leaf-1"])]) + XCTAssertEqual(quote.leafIds, ["leaf-1"]) + XCTAssertEqual(quote.exitSats, 100_000) + XCTAssertEqual(quote.fundingSats, 12000) + XCTAssertEqual(quote.prepared.skipped.map(\.leafId), ["dust"], "what All could not exit stays left behind") + } + + func testQuoteOfOnlyLostLeavesHasNothingToExit() async throws { + let sdk = FakeSparkExitRecovering() + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["lost"]) + + let quote = try await SparkExitQuote.prepare( + sdk, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + lostLeafIds: ["lost"] + ) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.all]) + XCTAssertTrue(quote.prepared.leaves.isEmpty) + XCTAssertEqual(quote.exitSats, 0) + XCTAssertEqual(quote.fundingSats, 0) + XCTAssertFalse(quote.isWorthExiting) + XCTAssertEqual(quote.lostLeafSats, [100_000]) + } + + func testClaimableHeightIsTheLatestTimelock() { + let recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("a", status: .waitingForTimelock(spendableAtHeight: 2000)), + SparkExitFixtures.tx("b", status: .waitingForTimelock(spendableAtHeight: 2400)), + SparkExitFixtures.tx("c", status: .confirmed(blockHeight: 5)), + ]) + + XCTAssertEqual(SparkExitMath.claimableAtHeight(recovery), 2400) + XCTAssertEqual(SparkExitMath.confirmedCount(recovery), 1) + XCTAssertNil(SparkExitMath.claimableAtHeight(SparkExitFixtures.recovery([SparkExitFixtures.tx("d")]))) + } + + func testRemindsAheadOfEachTimelockUntilItsStepIsPublished() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let grace = TimeInterval(SparkExitMath.reminderGraceBlocks) * 30 + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("a", status: .waitingForTimelock(spendableAtHeight: 900)), + SparkExitFixtures.tx("b", status: .waitingForTimelock(spendableAtHeight: 2400)), + SparkExitFixtures.tx("c", status: .waitingForTimelock(spendableAtHeight: 1500)), + SparkExitFixtures.tx("d", status: .confirmed(blockHeight: 800)), + ])) + + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .regtest), [ + SparkExitReminder(id: "1-h900", fireAt: now.addingTimeInterval(grace)), + SparkExitReminder(id: "1-h1500", fireAt: now.addingTimeInterval(500 * 30 + grace)), + SparkExitReminder(id: "1-h2400", fireAt: now.addingTimeInterval(1400 * 30 + grace)), + ]) + XCTAssertEqual(try SparkExitMath.claimableAtHeight(XCTUnwrap(record.recovery?.response)), 2400) + + record.broadcastLog = [.init( + generation: 1, + txid: "a", + attempts: 1, + rejections: 0, + lastAttemptAt: now, + lastOutcome: "accepted", + isAccepted: true + )] + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .regtest).map(\.id), ["1-h1500", "1-h2400"]) + } + + func testRemindsWhenStepsWaitingForParentsStopMoving() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("f", kind: .fanOut), + SparkExitFixtures.tx("n1", dependsOn: ["f"], status: .waitingForDependencies), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies), + ])) + record.broadcastLog = [.init( + generation: 1, + txid: "f", + attempts: 1, + rejections: 0, + lastAttemptAt: now, + lastOutcome: "accepted", + isAccepted: true + )] + + let reminders = SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin) + + XCTAssertEqual(reminders, [SparkExitReminder(id: "1-idle-0-1", fireAt: now.addingTimeInterval(4 * 60 * 60))]) + } + + func testRemindsAtOnceForFeeFundingAndRepeatedRejections() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let funding = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 5000)) + XCTAssertEqual( + SparkExitMath.reminders(funding, tip: 1000, now: now, network: .bitcoin), + [SparkExitReminder(id: "funding-5000", fireAt: now, isFunding: true)] + ) + + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + record.broadcastLog = [.init( + generation: 1, + txid: "n1", + attempts: 3, + rejections: 3, + lastAttemptAt: now, + lastOutcome: "rejected", + isAccepted: false + )] + // Refused steps rebuild first; only an exit that stopped moving after its rebuilds reminds at once. + XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin).contains { $0.id == "rejected-1-n1" }) + record.stallRebuilds = SparkExitDriver.stallRebuildsToSurface + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin), [ + SparkExitReminder(id: "rejected-1-n1", fireAt: now, isNotMoving: true), + SparkExitReminder(id: "1-ready-n1", fireAt: now.addingTimeInterval(TimeInterval(SparkExitMath.reminderGraceBlocks) * 600)), + SparkExitReminder(id: "1-idle-0-0", fireAt: now.addingTimeInterval(4 * 60 * 60)), + ]) + XCTAssertTrue(SparkExitMath.reminders(SparkExitFixtures.record(phase: .done), tip: 1000, now: now, network: .bitcoin).isEmpty) + } + + /// qa-03 case 5 replayed on regtest: a single-leaf exit whose leaf an older state claimed. Generation 2's idle + /// reminder, planned when it was built, fired a second before the rebuild that found the leaf lost, so two + /// notifications arrived at once. A pending rebuild now replaces its generation's reminders, and the lost exit + /// gives only the lost one. + func testALostLeafExitGivesOneNotificationAcrossItsRebuilds() { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + var record = SparkExitFixtures.record(phase: .running) + var scheduled: [String: Date] = [:] + var fired: [String: Date] = [:] + var delivered: [String] = [] + // As `SparkExitManager.scheduleReminders`: one that fired is not posted again, one ahead keeps its earliest + // date, and every reminder no longer planned is removed. + func schedule(at seconds: TimeInterval) { + let now = start.addingTimeInterval(seconds) + for (id, fireAt) in scheduled.sorted(by: { $0.value < $1.value }) where fireAt <= now { + delivered.append(id) + fired[id] = fireAt + } + var next: [String: Date] = [:] + for reminder in SparkExitMath.reminders(record, tip: 1000, now: now, network: .regtest) where fired[reminder.id] == nil { + next[reminder.id] = min(reminder.fireAt, scheduled[reminder.id] ?? reminder.fireAt) + } + scheduled = next + } + + record.adopt(recovery, at: start) + schedule(at: 0) + XCTAssertEqual(Set(scheduled.keys), ["1-ready-r1", "1-idle-1-0"]) + // A check says Redo; the rebuild waits for its interval. + record.isRebuilding = true + schedule(at: 284) + XCTAssertEqual(Array(scheduled.keys), ["1-rebuild"]) + record.adopt(recovery, at: start.addingTimeInterval(645)) + schedule(at: 645) + record.isRebuilding = true + schedule(at: 705) + // The next rebuild reproduces the spent refund a second time: the leaf is lost and the exit ends. + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 32768)] + record.isRebuilding = true + record.phase = .failed(reason: SparkExitRecord.lostReason) + schedule(at: 1246) + schedule(at: 1247) + + XCTAssertEqual(delivered, ["lost-1"]) + } + + func testTimelocksMatureOnlyOnceNoRefundWaits() { + let node = SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 5)) + let waitingForParent = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies) + let waitingForHeight = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 2000)) + let ready = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .ready) + + XCTAssertFalse(SparkExitMath.areTimelocksMatured([])) + XCTAssertFalse(SparkExitMath.areTimelocksMatured([node])) + XCTAssertFalse(SparkExitMath.areTimelocksMatured([node, waitingForParent])) + XCTAssertFalse(SparkExitMath.areTimelocksMatured([node, waitingForHeight, ready])) + XCTAssertTrue(SparkExitMath.areTimelocksMatured([node, ready])) + } + + func testSettlesExitTransfersNoRunningExitOwns() { + let transfers = [ + Bitkit.Transfer(id: "transfer-1", type: .sparkExit, amountSats: 1000, createdAt: 0), + Bitkit.Transfer(id: "orphan", type: .sparkExit, amountSats: 1000, createdAt: 0), + Bitkit.Transfer(id: "channel", type: .toSavings, amountSats: 1000, createdAt: 0), + ] + var unowned = SparkExitFixtures.record(phase: .running) + unowned.transferId = nil + + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: SparkExitFixtures.record(phase: .running)), ["orphan"]) + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: unowned), ["transfer-1", "orphan"]) + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: SparkExitFixtures.record(phase: .done)), ["transfer-1", "orphan"]) + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: nil), ["transfer-1", "orphan"]) + } + + func testFundingTopUpTakesTheFewestConfirmedPaymentsThatCover() { + let utxos = [ + SparkExitFixtures.utxo("a", value: 999), + SparkExitFixtures.utxo("pending", value: 50000, confirmed: false), + SparkExitFixtures.utxo("b", value: 1000), + ] + + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999)?.map(\.txid), ["b", "a"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1000)?.map(\.txid), ["b"]) + XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 2000)) + XCTAssertNil(SparkExitMath.fundingTopUp([], requiredSats: 0)) + // Each payment beyond the first is one more input to pay for: 68 vB at the exit's rate. + XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999, inputFeeSats: 136)) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1863, inputFeeSats: 136)?.map(\.txid), ["b", "a"]) + XCTAssertEqual(SparkExitMath.inputFeeSats(SparkExitFixtures.record()), 136) + } + + /// Largest first, and never a coin worth no more than its own input fee: that one stays for the leftover sweep. + func testFundingTopUpLeavesUneconomicalCoins() { + let utxos = [ + SparkExitFixtures.utxo("dust", value: 136), + SparkExitFixtures.utxo("small", value: 500), + SparkExitFixtures.utxo("big", value: 3000), + SparkExitFixtures.utxo("mid", value: 1500), + ] + + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 2500, inputFeeSats: 136)?.map(\.txid), ["big"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 4300, inputFeeSats: 136)?.map(\.txid), ["big", "mid"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 4700, inputFeeSats: 136)?.map(\.txid), ["big", "mid", "small"]) + // A coin worth only its input fee never counts. + XCTAssertNil(SparkExitMath.fundingTopUp([utxos[0]], requiredSats: 1, inputFeeSats: 136)) + } + + func testShowsTheSparkBalanceBeyondTheExitsLeaves() { + var record = SparkExitFixtures.record(phase: .running) + record.leafTotalSats = 17097 + + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record), 32768) + XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 17097, record: record)) + record.phase = .done + XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record)) + } + + /// The hosted-regtest run: 14,167 sats stay on Spark, and the exit's 100,000 sats of leaves are published, so they + /// left the balance. + func testSpendingKeepsWhatStaysOnSparkOnceTheExitsPublishedLeavesLeftItsBalance() { + var record = SparkExitFixtures.record(phase: .running) + record.leafTotalSats = 100_000 + record.outsideSats = 14167 + + let outside = SparkExitMath.outsideExitSats(sparkBalanceSats: 14167, record: record) + + XCTAssertEqual(outside, 14167) + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 14167, outsideExitSats: outside), 14167) + // While the leaves still count in the balance, a late claim beyond them still shows. + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 120_000, record: record), 20000) + } + + /// A failed exit claims only what Spark still holds beyond its leaves, never the leaves it lost. + func testFailedExitNamesWhatStaysOnSpark() { + var record = SparkExitFixtures.record(phase: .failed(reason: "Claimed by an older state")) + record.leafTotalSats = 17097 + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 17097)] + + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 49865, record: record), 32768) + XCTAssertNil(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 17097, record: record)) + XCTAssertNil(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 0, record: record)) + XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record)) + } + + /// qa-03 rerun of fix 10: right after an exit lost a leaf, Spark's balance still counted it for about 10 minutes, + /// and a record from before the lost-leaf list also held a leaf the balance had dropped already. + func testLostLeavesStayOutOfWhatStaysOnSparkWhetherTheBalanceCountsThemOrNot() { + var record = SparkExitFixtures.record(phase: .failed(reason: SparkExitRecord.lostReason)) + record.leafTotalSats = 65536 + record.lostLeaves = [.init(leafId: "lost", valueSats: 65536)] + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 68928, record: record), 3392, "balance still counts it") + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 3392, record: record), 3392, "balance synced") + + record.leafTotalSats = 131_072 + record.lostLeaves = [.init(leafId: "lost-before", valueSats: 65536), .init(leafId: "lost", valueSats: 65536)] + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 68928, record: record), 3392) + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 3392, record: record), 3392) + + var prepared = SparkExitFixtures.prepared(leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "small", valueSats: 3392, reason: .feeExceedsValue)] + let quote = SparkExitQuote(prepared: prepared, lostLeafSats: [65536, 65536]) + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 68928, quote: quote), 3392) + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 3392, quote: quote), 3392) + } + + /// qa-03: once Bitkit paid the funding from savings, or a payment covering it is seen, the exit waits for it + /// instead of asking again; only what stays short after confirmation is asked for. + func testFundingStateWaitsForAPaymentInsteadOfAskingAgain() { + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 9000)) + + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 9000)) + // The payment still to send adds its input fee. + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("a", value: 4000)]), .ask(sats: 5136, receivedSats: 4000)) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("x", value: 9000, confirmed: false)]), .seen(txid: "x")) + + record.fundingPaymentTxid = "pay" + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: nil), .paid(txid: "pay")) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "pay")) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 9000, confirmed: false)]), .paid(txid: "pay")) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 6000)]), .ask(sats: 3136, receivedSats: 6000)) + + // Adopted, and spent by a rebuild's CPFP: a later shortfall is asked for, not shown as paid. + record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "pay", vout: 0, valueSats: 9000)] + record.phase = .needsFunding(requiredSats: 12000) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 3000)) + + record.phase = .running + XCTAssertNil(SparkExitMath.fundingState(record, utxos: [])) + } + + /// qa-03: a short rebuild showed "₿ 68 more" for one tick before the ask's 294 floor applied. + func testTheNeededAmountIsNeverUnderTheDustLimit() { + var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 20068)) + record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 0, valueSats: 20000)] + + XCTAssertEqual(SparkExitMath.neededSats(record, state: nil), 294) + XCTAssertEqual(SparkExitMath.neededSats(record, state: .seen(txid: "x")), 294) + XCTAssertEqual(SparkExitMath.neededSats(record, state: SparkExitMath.fundingState(record, utxos: [])), 294) + XCTAssertEqual(SparkExitMath.neededSats(record, state: .ask(sats: 500)), 500) + } + + /// qa-03: 2,000 of 4,348 at the address showed no hint until the whole sum arrived. + func testAPartialPaymentNamesWhatIsStillMissing() { + XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 0, requiredSats: 4348)) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 2000, requiredSats: 4348), 2348) + XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 4348, requiredSats: 4348)) + // qa-03: bitcoind refuses a 68-sat output, so no ask goes below the P2WPKH dust limit. + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 4280, requiredSats: 4348), 294) + let short = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 4348)) + XCTAssertEqual( + SparkExitMath.fundingState(short, utxos: [SparkExitFixtures.utxo("a", value: 4280)]), + .ask(sats: 294, receivedSats: 4280) + ) + + // A top-up counts only new funding: the adopted input and CPFP change at the address are not received. + var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 9000)) + record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 0, valueSats: 5000)] + let utxos = [SparkExitFixtures.utxo("f0", value: 5000), SparkExitFixtures.utxo("new", value: 1500, confirmed: false)] + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: utxos), .ask(sats: 2636, receivedSats: 1500)) + } + + func testEachExitTakesAFundingIndexNoEarlierExitUsed() { + var record = SparkExitFixtures.record(phase: .done) + + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 0, record: nil), 0) + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 0, record: record), 1, "a record from before per-exit indexes used 0") + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 3, record: record), 3) + record.funding[0] = .init(index: 4, address: "a", pubkey: "02ab") + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 1, record: record), 5) + } + + /// qa-03: value the quote cannot exit, such as a counter-swap still unclaimed, is said on the quote screen. + func testQuoteNamesTheBalanceItCannotExit() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared()) + + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 133_068, quote: quote), 300 + 32768) + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 100_300, quote: quote), 300, "the skipped leaves") + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 90000, quote: quote), 300) + } + + func testReadsTheLeafAnUnexitableErrorNames() { + let message = "Leaf 0193a7c2-11aa-7bbb-8ccc-1234567890ab cannot be exited: its refund cannot pay its own fee at this fee rate" + XCTAssertTrue(SparkExitMath.isUnexitableLeafError("SparkError: " + message)) + XCTAssertEqual(SparkExitMath.unexitableLeafId("SparkError: " + message), "0193a7c2-11aa-7bbb-8ccc-1234567890ab") + XCTAssertNil(SparkExitMath.unexitableLeafId("The leaf cannot be exited: no refund")) + XCTAssertFalse(SparkExitMath.isUnexitableLeafError("operators unavailable")) + } + + /// A run that cannot read the tip guesses each timelock's reminder about 6 hours out: the guess never takes the + /// date of the reminder at maturity, which a run with the tip plans. + func testARunWithoutTheTipNeverTakesATimelocksReminderEarly() throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("r1", kind: .refund, status: .waitingForTimelock(spendableAtHeight: 3000))])) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let id = "\(record.generation)-h3000" + let guessed = try XCTUnwrap(SparkExitMath.reminders(record, tip: nil, now: now, network: .bitcoin).first { $0.id == id }) + let known = try XCTUnwrap(SparkExitMath.reminders(record, tip: 2000, now: now, network: .bitcoin).first { $0.id == id }) + + let first = SparkExitManager.reminderDate(guessed, rememberedAt: nil, now: now) + let second = SparkExitManager.reminderDate(known, rememberedAt: first.remembered, now: now) + let third = SparkExitManager.reminderDate(guessed, rememberedAt: second.remembered, now: now) + + XCTAssertNil(first.remembered, "a date guessed without the tip was kept") + XCTAssertEqual(second.fireAt, known.fireAt, "the reminder at maturity was taken early") + XCTAssertEqual(third.fireAt, known.fireAt) + } + + /// With the operators down an older state's refund can take a leaf 100 blocks after ours matures, and the + /// watchtower may publish after 50: reminders land well inside the smaller window. + func testRemindersLandWellInsideTheDeadline() { + let deadline = min(SparkExitMath.operatorWindowBlocks, SparkExitMath.olderStateLeadBlocks) + XCTAssertLessThanOrEqual(SparkExitMath.reminderGraceBlocks * 2, deadline) + XCTAssertLessThanOrEqual(UInt32(SparkExitMath.reminderSeconds(network: .bitcoin) / 600) * 2, deadline) + } + + func testSavingsMustCoverTheFundingAndItsFee() { + XCTAssertTrue(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10499, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10000, fundingSats: 0, paymentFeeSats: 0, readsBitkitChain: true)) + } + + /// A Savings payment goes out on Bitkit's chain: while Spark reads another chain, such as its hosted regtest, it + /// would never fund the exit. + func testSavingsCannotFundAnExitOnAChainSparkDoesNotRead() { + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: false)) + } + + /// A higher fee's extra funding goes out from savings on Bitkit's chain too: off while Spark reads another chain. + /// A rate with nothing to pay needs no payment. + func testSavingsPaysAHigherFeeOnlyOnAChainSparkReads() { + XCTAssertTrue(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5980, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5979, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: nil, spendableSats: 5980, readsBitkitChain: true), "not priced") + XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5980, readsBitkitChain: false)) + XCTAssertTrue(SparkExitMath.canPayFeeBump(totalSats: 0, spendableSats: 0, readsBitkitChain: false)) + } + + func testEtaUsesTheNetworksBlockTime() { + XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .bitcoin), 6000) + XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .regtest), 300) + XCTAssertEqual(SparkExitMath.secondsUntil(height: 90, tip: 100, network: .bitcoin), 0) + } + + func testDecodesACompressedWif() throws { + let key = try SparkExitFundingKey.secretKey(fromWif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn") + + XCTAssertEqual(key, Data(repeating: 0, count: 31) + Data([1])) + } + + func testRejectsAWifWithABadChecksumOrUncompressedKey() { + XCTAssertThrowsError(try SparkExitFundingKey.secretKey(fromWif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWo")) + // Uncompressed key 1 (no 0x01 flag). + XCTAssertThrowsError(try SparkExitFundingKey.secretKey(fromWif: "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf")) + } + + func testFundingKeyDerivesOnItsOwnAccount() throws { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + let regtest = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + let again = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + let mainnet = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .bitcoin) + + XCTAssertEqual(SparkExitFundingKey.path(index: 0, network: .regtest), "m/84'/1'/7797'/0/0") + XCTAssertEqual(SparkExitFundingKey.path(index: 3, network: .bitcoin), "m/84'/0'/7797'/0/3") + XCTAssertTrue(regtest.address.hasPrefix("bcrt1q")) + XCTAssertTrue(mainnet.address.hasPrefix("bc1q")) + XCTAssertEqual(regtest.pubkey.count, 66) + XCTAssertEqual(regtest.secretKey.count, 32) + XCTAssertEqual(regtest.secretKey, again.secretKey) + XCTAssertNoThrow(try singleKeyCpfpSigner(secretKeyBytes: regtest.secretKey)) + } + + func testTxidIgnoresWitnessData() { + let segwit = "0200000000010111111111111111111111111111111111111111111111111111111111111111110000000000ffffffff01e80300000000000016001422222222222222222222222222222222222222220202abcd0301020300000000" + + XCTAssertEqual(SparkExitTxid.txid(hex: SparkExitFixtures.parentHex), SparkExitFixtures.parentTxid) + XCTAssertEqual(SparkExitTxid.txid(hex: segwit), SparkExitFixtures.parentTxid) + XCTAssertNil(SparkExitTxid.txid(hex: "02zz")) + } +} + +// MARK: - Broadcast client + +final class SparkExitBroadcasterTests: XCTestCase { + final class FakeHTTP: SparkExitHTTP, @unchecked Sendable { + var responses: [(path: String, status: Int, body: String)] = [] + private(set) var requests: [URLRequest] = [] + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let url = try XCTUnwrap(request.url) + guard let index = responses.firstIndex(where: { url.absoluteString.hasSuffix($0.path) }) else { throw URLError(.cannotConnectToHost) } + let response = responses.remove(at: index) + return (Data(response.body.utf8), HTTPURLResponse(url: url, statusCode: response.status, httpVersion: nil, headerFields: nil)!) + } + } + + private let hostA = URL(string: "https://a.example/api")! + private let hostB = URL(string: "https://b.example/api")! + + func testSendsAPackageAsAJsonArray() async throws { + let http = FakeHTTP() + http.responses = [("a.example/api/v1/txs/package", 200, #"{"package_msg":"success","tx-results":{}}"#)] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("n1", cpfp: "03child")) + + XCTAssertEqual(outcome, .accepted) + let request = try XCTUnwrap(http.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(try JSONDecoder().decode([String].self, from: XCTUnwrap(request.httpBody)), ["02n1", "03child"]) + } + + func testFallsBackToTheUnversionedPackagePathThenTheNextHost() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/v1/txs/package", 404, "Not Found"), + ("a.example/api/txs/package", 404, "Not Found"), + ("b.example/api/v1/txs/package", 200, #"{"package_msg":"success"}"#), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("n1", cpfp: "03c")) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.host }, ["a.example", "a.example", "b.example"]) + } + + func testSendsAStepWithoutCpfpAsOneTransaction() async throws { + let http = FakeHTTP() + http.responses = [("a.example/api/tx", 200, "s1")] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(try String(decoding: XCTUnwrap(http.requests.first?.httpBody), as: UTF8.self), "02s1") + } + + /// Spark's hosted regtest answers a bare -1 to every lone transaction: the same hex goes again as a package of one, + /// on the same host, versioned path first. + func testALoneTransactionTheHostRefusesWithABareRpcErrorGoesAgainAsAPackage() async throws { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"{"error":"sendrawtransaction RPC error: {\"code\":-1}"}"#), + ("a.example/api/v1/txs/package", 404, "Not Found"), + ("a.example/api/txs/package", 200, #"{"package_msg":"success","tx-results":{}}"#), + ] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.path }, ["/api/tx", "/api/v1/txs/package", "/api/txs/package"]) + XCTAssertEqual(try JSONDecoder().decode([String].self, from: XCTUnwrap(http.requests.last?.httpBody)), ["02s1"]) + } + + /// The package of one answers with the transaction's own error, which the classifier places. + func testThePackageOfOnesErrorIsClassified() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"{"error":"sendrawtransaction RPC error: {\"code\":-1}"}"#), + ("a.example/api/v1/txs/package", 200, + #"{"package_msg":"transaction failed","tx-results":{"w":{"txid":"s1","error":"bad-txns-inputs-missingorspent"}}}"#), + ] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .inputsMissing("bad-txns-inputs-missingorspent")) + } + + /// A refusal with a reason stays a refusal, and an accepted lone transaction never goes to the package path. + func testOnlyABareRpcErrorTriesThePackagePath() async { + XCTAssertTrue(SparkExitBroadcastClassifier.isUnplaced(.rejected(#"sendrawtransaction RPC error: {"code":-1}"#))) + let withReason = #"sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"# + XCTAssertFalse(SparkExitBroadcastClassifier.isUnplaced(.rejected(withReason))) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnplaced(.rejected(#"{"code":-1,"message":"TX decode failed"}"#))) + + let http = FakeHTTP() + http.responses = [("a.example/api/tx", 200, "s1")] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.path }, ["/api/tx"]) + } + + func testTriesTheNextHostAfterARejection() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"#), + ("b.example/api/tx", 200, "s1"), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + } + + func testTriesTheNextHostWhenOneIsUnavailable() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 429, "Too Many Requests"), + ("b.example/api/tx", 200, "s1"), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + } + + func testANodeRefusalWinsOverAnUnavailableHost() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"#), + ("b.example/api/tx", 503, ""), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + guard case .rejected = outcome else { return XCTFail("Expected the refusal, got \(outcome)") } + } + + /// Esplora hosts (bitkit.to, blockstream.info) answer a step that already confirmed in Core's newer plain-text form. + func testReadsAStepAlreadyInTheUtxoSetAsAccepted() { + let text = "sendrawtransaction RPC error -27: Transaction outputs already in utxo set" + XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(text.utf8)), .accepted) + XCTAssertEqual(SparkExitBroadcastClassifier.classify(error: "sendrawtransaction RPC error -27: unknown"), .accepted) + } + + func testClassifiesNodeAnswers() { + XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(#"sendrawtransaction RPC error: {"code":-27}"#.utf8)), .accepted) + XCTAssertEqual( + SparkExitBroadcastClassifier.single( + status: 400, + body: Data(#"sendrawtransaction RPC error: {"code":-26,"message":"non-BIP68-final"}"#.utf8) + ), + .notMature(#"sendrawtransaction RPC error: {"code":-26,"message":"non-BIP68-final"}"#) + ) + XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 503, body: Data()), .retryLater("HTTP 503")) + if case .rejected = SparkExitBroadcastClassifier.single(status: 400, body: Data(#"{"code":-26,"message":"min relay fee not met"}"#.utf8)) {} + else { + XCTFail("Expected a rejection") + } + let missing = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"bad-txns-inputs-missingorspent"},"w2":{"txid":"b","error":"bad-txns-inputs-missingorspent"}},"replaced-transactions":[]}"# + if case .inputsMissing = SparkExitBroadcastClassifier.package(status: 200, body: Data(missing.utf8)) {} else { + XCTFail("Missing inputs are their own outcome") + } + if case .inputsMissing = SparkExitBroadcastClassifier.single(status: 400, body: Data(#"sendrawtransaction RPC error: {"code":-25}"#.utf8)) {} + else { + XCTFail("RPC -25 is missing inputs") + } + let parentNotFinal = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"non-BIP68-final"},"w2":{"txid":"b","error":"bad-txns-inputs-missingorspent"}}}"# + if case .notMature = SparkExitBroadcastClassifier.package(status: 200, body: Data(parentNotFinal.utf8)) {} else { + XCTFail("A child missing its immature parent is not mature, not spent") + } + let childMissing = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step"},"w2":{"txid":"child","error":"bad-txns-inputs-missingorspent"}}}"# + if case .retryLater = SparkExitBroadcastClassifier.package(status: 200, body: Data(childMissing.utf8), txid: "step") {} else { + XCTFail("Only the step's own missing input means it was spent") + } + let stepMissing = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step","error":"bad-txns-inputs-missingorspent"}}}"# + if case .inputsMissing = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepMissing.utf8), txid: "step") {} else { + XCTFail("The step's own input is missing") + } + let childRejected = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step","error":"non-BIP68-final"},"w2":{"txid":"child","error":"min relay fee not met"}}}"# + if case .notMature = SparkExitBroadcastClassifier.package(status: 200, body: Data(childRejected.utf8), txid: "step") {} else { + XCTFail("An immature parent outranks the child's refusal") + } + let stepRejected = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"child","error":"non-BIP68-final"},"w2":{"txid":"step","error":"min relay fee not met"}}}"# + if case .rejected = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepRejected.utf8), txid: "step") {} else { + XCTFail("The step's own refusal decides, whatever its child reports") + } + let stepSpent = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"child","error":"non-BIP68-final"},"w2":{"txid":"step","error":"bad-txns-inputs-missingorspent"}}}"# + if case .inputsMissing = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepSpent.utf8), txid: "step") {} else { + XCTFail("The step's own missing input decides, whatever its child reports") + } + let stepKnown = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step","error":"txn-already-in-mempool"},"w2":{"txid":"child","error":"min relay fee not met"}}}"# + if case .rejected = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepKnown.utf8), txid: "step") {} else { + XCTFail("A step the node already has leaves the outcome to its child") + } + let known = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"txn-already-in-mempool"},"w2":{"txid":"b"}}}"# + XCTAssertEqual(SparkExitBroadcastClassifier.package(status: 200, body: Data(known.utf8)), .accepted) + let nonFinal = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"non-BIP68-final"}}}"# + if case .notMature = SparkExitBroadcastClassifier.package(status: 200, body: Data(nonFinal.utf8)) {} else { + XCTFail("A timelock is not mature") + } + XCTAssertEqual( + SparkExitBroadcastClassifier.package(status: 400, body: Data(#"{"error":"submitpackage RPC error: {\"code\":-25}"}"#.utf8)), + .inputsMissing(#"{"error":"submitpackage RPC error: {\"code\":-25}"}"#) + ) + } + + func testKnowsATransactionOnlyWhileAHostHasIt() async throws { + let http = FakeHTTP() + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found"), ("b.example/api/tx/c/hex", 404, "Transaction not found")] + let isUnknown = try await chain.isKnown(txid: "c") + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found"), ("b.example/api/tx/c/hex", 200, "0300")] + let isKnown = try await chain.isKnown(txid: "c") + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found")] + let isUnreachable = await (try? chain.isKnown(txid: "c")) == nil + + XCTAssertFalse(isUnknown) + XCTAssertTrue(isKnown) + XCTAssertTrue(isUnreachable, "a host that could not answer is no proof it is gone") + XCTAssertTrue(SparkExitBroadcastClassifier.isUnpaid(.rejected("min relay fee not met, 0 < 17"))) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.rejected("min relay fee not met, 100 < 170"))) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.inputsMissing("min relay fee not met, 0 < 17"))) + } + + func testMainnetReadsOnlyBitkitElectrsAndBroadcastsThereFirst() throws { + let chain = EsploraSparkExitChain.network(.mainnet) + let electrs = try XCTUnwrap(URL(string: "https://bitkit.to/electrs")) + let mempool = try XCTUnwrap(URL(string: "https://mempool.space/api")) + let blockstream = try XCTUnwrap(URL(string: "https://blockstream.info/api")) + + XCTAssertEqual(chain.readHosts, [electrs]) + XCTAssertEqual(chain.broadcastHosts, [ + .init(url: electrs, packagePaths: ["/txs/package"]), + .init(url: mempool, packagePaths: ["/v1/txs/package", "/txs/package"]), + .init(url: blockstream, packagePaths: ["/v1/txs/package", "/txs/package"]), + ]) + XCTAssertEqual( + EsploraSparkExitChain.network(.regtest, regtestChainServiceUrl: nil).readHosts.map(\.host), + ["regtest-mempool.us-west-2.sparkinfra.net"] + ) + } + + /// On regtest the exit reads and broadcasts on the chain Spark connects with when `SPARK_CHAIN_SERVICE_URL` names one. + func testTheRegtestExitUsesTheConfiguredSparkChain() { + let chain = EsploraSparkExitChain.network(.regtest, regtestChainServiceUrl: "http://localhost:3002") + XCTAssertEqual(chain.readHosts.map(\.absoluteString), ["http://localhost:3002"]) + XCTAssertEqual(chain.broadcastHosts.map(\.url.absoluteString), ["http://localhost:3002"]) + } + + func testSendsAPackageToBitkitElectrsWithoutTheV1Path() async { + let http = FakeHTTP() + http.responses = [("bitkit.to/electrs/txs/package", 200, #"{"package_msg":"success"}"#)] + let mainnet = EsploraSparkExitChain.network(.mainnet) + let chain = EsploraSparkExitChain(readHosts: mainnet.readHosts, broadcastHosts: mainnet.broadcastHosts, http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("n1", cpfp: "03c")) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.absoluteString }, ["https://bitkit.to/electrs/txs/package"]) + } + + func testBroadcastFallsBackFromBitkitElectrsButReadsNeverDo() async { + let http = FakeHTTP() + http.responses = [ + ("bitkit.to/electrs/tx", 503, ""), + ("mempool.space/api/tx", 200, "s1"), + ("bitkit.to/electrs/blocks/tip/height", 503, ""), + ] + let mainnet = EsploraSparkExitChain.network(.mainnet) + let chain = EsploraSparkExitChain(readHosts: mainnet.readHosts, broadcastHosts: mainnet.broadcastHosts, http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + let tip = try? await chain.tipHeight() + + XCTAssertEqual(outcome, .accepted) + XCTAssertNil(tip) + XCTAssertEqual(http.requests.compactMap { $0.url?.host }, ["bitkit.to", "mempool.space", "bitkit.to"]) + } + + func testOneReadHostOnlyCallsATransactionGoneOnA404() async throws { + let http = FakeHTTP() + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found")] + let isUnknown = try await chain.isKnown(txid: "c") + http.responses = [("a.example/api/tx/c/hex", 503, "")] + let failedOnError = await (try? chain.isKnown(txid: "c")) == nil + http.responses = [] + let failedOnTimeout = await (try? chain.isKnown(txid: "c")) == nil + + XCTAssertFalse(isUnknown) + XCTAssertTrue(failedOnError, "a 5xx is no proof it is gone") + XCTAssertTrue(failedOnTimeout, "an unreachable host is no proof it is gone") + } + + func testReadsTipAndFundingUtxos() async throws { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/blocks/tip/height", 200, "3681536\n"), + ( + "a.example/api/address/bcrt1qf/utxo", + 200, + #"[{"txid":"t","vout":1,"value":5000,"status":{"confirmed":true,"block_height":7,"block_hash":"h"}}]"# + ), + ] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let tip = try await chain.tipHeight() + let utxos = try await chain.utxos(address: "bcrt1qf") + + XCTAssertEqual(tip, 3_681_536) + XCTAssertEqual(utxos, [SparkExitFixtures.utxo("t", vout: 1, value: 5000)].map { + EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.value, status: EsploraTxStatus(confirmed: true, blockHeight: 7)) + }) + } +} diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift new file mode 100644 index 000000000..745eddbdf --- /dev/null +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -0,0 +1,95 @@ +@testable import Bitkit +import XCTest + +/// A Spark funding whose outcome was unknown, resolved from the Send sheet's pending screen. +@MainActor +final class SparkFundingRecoveryTests: XCTestCase { + private let walletId = OnchainSendAttemptService.walletId(index: 0) + + private func resolvedSend(isSparkFunding: Bool, txid: String) async throws -> OnchainSendLocalResolution { + let service = OnchainSendAttemptService(store: MemoryAttemptStore()) + let id = try await service.admit( + walletId: walletId, requestId: nil, orderId: nil, address: "bcrt1qspark-deposit", amountSats: 30000, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 200, feeRate: 2, tags: [], contact: nil, createdAt: 100), + isSparkFunding: isSparkFunding + ) + try await service.record(.accepted(txid: txid), attemptId: id) + let resolution = try await service.resumeAcceptedOrdinarySend(walletId: walletId) + return try XCTUnwrap(resolution) + } + + func testARecoveredSparkFundingIsHandedToSparkAndLeavesTheFundingFlow() async throws { + let txid = String(repeating: "5a", count: 32) + let resolution = try await resolvedSend(isSparkFunding: true, txid: txid) + var handedOff: [(String, UInt64)] = [] + + let leaves = await SparkFundingRecovery.follow(resolution) { handedOff.append(($0, $1)) } + + XCTAssertTrue(resolution.isSparkFunding) + XCTAssertTrue(leaves, "The old confirm screen would offer the sent transfer again") + XCTAssertEqual(handedOff.map(\.0), [txid]) + XCTAssertEqual(handedOff.map(\.1), [30000]) + } + + func testARecoveredOrdinarySendIsNotHandedToSpark() async throws { + let resolution = try await resolvedSend(isSparkFunding: false, txid: String(repeating: "6b", count: 32)) + var handedOff = 0 + + let leaves = await SparkFundingRecovery.follow(resolution) { _, _ in handedOff += 1 } + + XCTAssertFalse(resolution.isSparkFunding) + XCTAssertFalse(leaves) + XCTAssertEqual(handedOff, 0) + } + + func testSpendingConfirmFollowsItsFundingOnceItResolvesOnItsOwn() async throws { + let spark = try await resolvedSend(isSparkFunding: true, txid: String(repeating: "7c", count: 32)) + let ordinary = try await resolvedSend(isSparkFunding: false, txid: String(repeating: "8d", count: 32)) + + XCTAssertTrue(SpendingConfirm.followsRecovered(spark, isPaying: false)) + XCTAssertFalse(SpendingConfirm.followsRecovered(spark, isPaying: true), "The swipe's own funding goes on to Setting Up") + XCTAssertFalse(SpendingConfirm.followsRecovered(ordinary, isPaying: false)) + } + + func testAnOpenSpendingConfirmOwnsTheRecoveredFunding() { + XCTAssertFalse(SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: true), "Spending Confirm follows it at the chosen speed") + XCTAssertTrue(SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: false)) + } + + func testSettingUpShowsLaterWheneverTheTextSaysItWaitsForConfirmations() { + for state in [SparkClaimState.later, .unavailable, .notSeen] { + XCTAssertTrue(SettingUpView.showsLater(speed: .instant, state: state), "\(state)") + } + for state in [SparkClaimState.looking, .claiming, .adding, .added, .repriced(feeSats: 1)] { + XCTAssertFalse(SettingUpView.showsLater(speed: .instant, state: state), "\(state)") + } + XCTAssertTrue(SettingUpView.showsLater(speed: .later, state: .looking)) + } + + func testSettingUpShowsAStepForEveryStateStillInProgress() { + let states: [SparkClaimState] = [.looking, .claiming, .adding, .repriced(feeSats: 1), .unavailable, .later, .notSeen] + for state in states { + XCTAssertFalse(state.isDone, "\(state)") + XCTAssertLessThan(SettingUpView.currentStep(for: state), 3, "\(state) points past the last step") + } + XCTAssertEqual(SettingUpView.currentStep(for: .adding), 2, "Adding is the last step, Spark adding it") + } + + /// The same step as Android's `SettingUpScreen` for every state: a deposit Spark has not seen stays on + /// Payment Successful, whatever the speed. + func testSettingUpStepMatchesAndroidForEveryState() { + let expected: [(SparkClaimState, Int)] = [ + (.looking, 1), + (.notSeen, 1), + (.later, 1), + (.claiming, 2), + (.adding, 2), + (.repriced(feeSats: 1), 2), + (.unavailable, 2), + (.added, 3), + ] + for (state, step) in expected { + XCTAssertEqual(SettingUpView.currentStep(for: state), step, "\(state)") + } + } +} diff --git a/BitkitTests/SparkLightningSendTests.swift b/BitkitTests/SparkLightningSendTests.swift new file mode 100644 index 000000000..2fc6e7a9c --- /dev/null +++ b/BitkitTests/SparkLightningSendTests.swift @@ -0,0 +1,138 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +@MainActor +final class SparkLightningSendTests: XCTestCase { + func testLightningInvoiceWithoutChannelsOpensPaymentWhenSparkCanPay() async throws { + var checkedAmount: UInt64? + let app = makeApp(hasChannelFreeRail: true) { + checkedAmount = $0 + return true + } + + try await app.handleScannedData("lnbcrt1sparkonly") + + XCTAssertEqual(checkedAmount, 100) + XCTAssertNotNil(app.scannedLightningInvoice) + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + XCTAssertFalse(app.didRejectScannedPayment) + } + + func testSparkSendReturnsBeforeCompletionSoCallersSeeTheCompletionEvent() { + guard case let .bolt11Invoice(_, completionTimeoutSecs) = SparkManager.bolt11SendOptions else { + return XCTFail("Expected bolt11 send options") + } + XCTAssertNil(completionTimeoutSecs) + } + + func testExpiredInvoiceRefusalUsesTheScannerExpiryCopy() { + let refusal = SparkManager.expiredInvoiceRefusal(SdkError.InvalidInput("Invoice has expired")) + XCTAssertEqual(refusal?.errorDescription, t("other__scan__error__expired")) + XCTAssertNil(SparkManager.expiredInvoiceRefusal(SdkError.InvalidInput("Invalid amount"))) + XCTAssertNil(SparkManager.expiredInvoiceRefusal(SdkError.Generic("Invoice has expired"))) + } + + func testLightningInvoiceWithoutChannelsOrSparkIsRejected() async throws { + let app = makeApp(hasChannelFreeRail: false) { _ in false } + + try await app.handleScannedData("lnbcrt1sparkonly") + + XCTAssertTrue(app.didRejectScannedPayment) + } + + func testAUnifiedInvoiceNothingOnSpendingCanPayOpensSavings() async throws { + // A channel whose peer is offline, with nothing to send even once it is back, and no Spark balance. + let app = makeUnifiedApp(channelOutboundSats: 0) { _ in false } + + try await app.handleScannedData(Self.unifiedUri) + + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain, "Spending was picked with nothing to pay from") + XCTAssertNil(app.scannedLightningInvoice) + XCTAssertNotNil(app.scannedOnchainInvoice) + } + + func testAUnifiedInvoiceSparkCanPayOpensSpending() async throws { + let app = makeUnifiedApp(channelOutboundSats: 0) { _ in true } + + try await app.handleScannedData(Self.unifiedUri) + + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + XCTAssertNotNil(app.scannedLightningInvoice) + } + + func testAUnifiedInvoiceAChannelWithAnOfflinePeerCouldPayStillOpensSpending() async throws { + let app = makeUnifiedApp(channelOutboundSats: 5000) { _ in false } + + try await app.handleScannedData(Self.unifiedUri) + + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + } + + private static let unifiedUri = "bitcoin:bcrt1q95ukqhq8q3ymg74zvzpy0n9f8ec6xq26as60u3?lightning=lnbcrt1sparkonly" + + private func makeUnifiedApp(channelOutboundSats: UInt64, canSendLightning: @escaping (UInt64) -> Bool) -> AppViewModel { + AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 50000, + totalLightningBalanceSats: 0, + hasChannels: true, + hasUsableChannels: false, + hasChannelFreeRail: true, + channelOutboundSats: channelOutboundSats + ) + }, + canSendLightning: canSendLightning, + decodeInvoice: { uri in + uri.hasPrefix("bitcoin:") + ? .onChain(invoice: OnChainInvoice( + address: "bcrt1q95ukqhq8q3ymg74zvzpy0n9f8ec6xq26as60u3", amountSatoshis: 0, label: nil, message: nil, + params: ["lightning": "lnbcrt1sparkonly"] + )) + : .lightning(invoice: self.invoice) + } + ) + ) + } + + private func makeApp(hasChannelFreeRail: Bool, canSendLightning: @escaping (UInt64) -> Bool) -> AppViewModel { + AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 0, + totalLightningBalanceSats: 9572, + hasChannels: false, + hasUsableChannels: false, + hasChannelFreeRail: hasChannelFreeRail + ) + }, + canSendLightning: canSendLightning, + decodeInvoice: { _ in .lightning(invoice: self.invoice) } + ) + ) + } + + private var invoice: LightningInvoice { + LightningInvoice( + bolt11: "lnbcrt1sparkonly", + paymentHash: Data(), + amountSatoshis: 100, + timestampSeconds: UInt64(Date().timeIntervalSince1970), + expirySeconds: 3600, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + } +} diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift new file mode 100644 index 000000000..c900a9a92 --- /dev/null +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -0,0 +1,462 @@ +@testable import Bitkit +import BreezSdkSpark +import LDKNode +import XCTest + +@MainActor +final class SparkPaymentSafetyTests: XCTestCase { + private struct RefreshFailed: Error {} + + // MARK: - Start + + func testAStartWhileConnectedDoesNotRewriteEveryActivity() async throws { + var upserts: [String: Int] = [:] + var synced = false + let sync = { ["a", "b"].forEach { upserts[$0, default: 0] += 1 } } + + for _ in 0 ..< 2 { + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: sync) + } + + XCTAssertEqual(upserts, ["a": 1, "b": 1]) + } + + func testAStartWhoseRefreshFailedLeavesTheActivitySyncToTheNext() async throws { + var syncs = 0 + var synced = false + + do { + synced = try await SparkManager.startConnected( + activitiesSynced: synced, + refresh: { throw RefreshFailed() }, + syncActivities: { syncs += 1 } + ) + XCTFail("Expected the refresh to fail") + } catch {} + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: { syncs += 1 }) + + XCTAssertTrue(synced) + XCTAssertEqual(syncs, 1) + } + + func testAStartWhoseActivityWriteFailedSyncsTheActivitiesAgain() async throws { + let payments = ["a", "b"].map { lightningPayment(type: .receive, status: .completed, id: $0) } + var writes: [String: Int] = [:] + var failing: Set = ["b"] + var synced = false + let sync = { + try await SparkManager.recordAll(payments) { payment in + writes[payment.id, default: 0] += 1 + return !failing.contains(payment.id) + } + } + + do { + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: sync) + } catch {} + XCTAssertFalse(synced, "A sync that failed to save a payment was marked complete") + failing = [] + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: sync) + + XCTAssertTrue(synced) + XCTAssertEqual(writes, ["a": 2, "b": 2]) + } + + func testAPaymentThatFailedToSaveIsWrittenAgainAtTheNextRefresh() { + let payments = [ + lightningPayment(type: .receive, status: .completed, id: "receive"), + lightningPayment(type: .send, status: .completed, id: "send"), + lightningPayment(type: .send, status: .completed, id: "fee-now-known"), + lightningPayment(type: .receive, status: .completed, id: "saved"), + ] + + let again = SparkManager.paymentsToRecordAgain( + payments, + unsaved: ["receive", "send"], + recordedWithoutFee: ["fee-now-known"] + ) + + XCTAssertEqual(again.map(\.id), ["receive", "send", "fee-now-known"]) + } + + // MARK: - Settled payment events + + func testSettledPaymentIsRecordedAndEmittedEvenWhenTheRefreshFails() async { + var steps: [String] = [] + + await SparkManager.handleSettledPayment( + lightningPayment(type: .send, status: .completed), + record: { _ in steps.append("record") }, + emit: { event in + if case .paymentSuccessful = event { + steps.append("emit") + } + }, + refresh: { + steps.append("refresh") + throw RefreshFailed() + } + ) + + XCTAssertEqual(steps, ["record", "emit", "refresh"]) + } + + func testReceivedPaymentEmitsItsNodeEvent() { + let event = SparkManager.nodeEvent(for: lightningPayment(type: .receive, status: .completed)) + + guard case let .paymentReceived(_, paymentHash, amountMsat, _) = event else { + return XCTFail("Expected a receive, got \(String(describing: event))") + } + XCTAssertEqual(paymentHash, "hash") + XCTAssertEqual(amountMsat, 1_000_000) + } + + // MARK: - Withdrawal + + func testSentWithdrawalSucceedsWhenTheRefreshAfterItFails() async throws { + var refreshed = false + + try await SparkManager.settleWithdrawal(status: .pending) { + refreshed = true + throw RefreshFailed() + } + + XCTAssertTrue(refreshed) + } + + func testWithdrawalSparkReportsFailedFails() async { + do { + try await SparkManager.settleWithdrawal(status: .failed) {} + XCTFail("Expected Spark's failed status to fail the withdrawal") + } catch {} + } + + func testWithdrawalFeeAboveTheShownFeeIsRefused() throws { + XCTAssertNoThrow(try SparkManager.checkWithdrawalFee(2190, shownFeeSats: 2190)) + XCTAssertNoThrow(try SparkManager.checkWithdrawalFee(2000, shownFeeSats: 2190)) + XCTAssertThrowsError(try SparkManager.checkWithdrawalFee(2191, shownFeeSats: 2190)) { error in + XCTAssertEqual(error as? SparkWithdrawalFeeIncreased, SparkWithdrawalFeeIncreased(feeSats: 2191, shownFeeSats: 2190)) + } + } + + func testSwipeWaitsForTheSparkWithdrawalFee() { + let transfer = TransferViewModel() + + XCTAssertFalse(transfer.isSparkWithdrawalPriced(withdrawsSpark: true)) + XCTAssertTrue(transfer.isSparkWithdrawalPriced(withdrawsSpark: false)) + + transfer.sparkWithdrawFeeSats = 2190 + + XCTAssertTrue(transfer.isSparkWithdrawalPriced(withdrawsSpark: true)) + } + + func testRecheckShowsTheHigherFeeAndAsksAgain() async throws { + let transfer = TransferViewModel() + let fee = FeeQuotes([2190, 2500]) + transfer.priceSparkWithdrawal = { _ in await fee.next() } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) + + do { + try await transfer.recheckSparkWithdrawFee() + XCTFail("Expected the higher fee to ask for another swipe") + } catch let increase as SparkWithdrawalFeeIncreased { + XCTAssertEqual(increase.shownFeeSats, 2190) + } + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2500) + } + + func testWithdrawalSendsWithTheShownFeeAsItsCap() async throws { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.sparkBalanceSats = { 99901 } + var sent: (String, UInt64)? + transfer.withdrawSpark = { sent = ($0, $1) } + transfer.sparkReadsSavingsChain = { true } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { + try await Task.sleep(nanoseconds: 20_000_000) + } + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true) + + _ = try await transfer.closeSelectedChannels() + + XCTAssertEqual(sent?.0, "bcrt1q-savings") + XCTAssertEqual(sent?.1, 2190) + } + + // MARK: - Transfer to spending minimum + + func testTransferMustStayClaimableAtTheClaimFeeCap() { + let fees = SparkClaimFees(instantMaxSats: 500, laterSats: 300) + let quote = { (deposit: UInt64) in + SparkTransferQuote(address: "bcrt1q-spark", satsPerVbyte: 2, networkFeeSats: 200, depositSats: deposit, claimFees: fees) + } + + XCTAssertFalse(quote(400).coversClaimFee, "Above the later fee but not the cap a mature claim may cost") + XCTAssertFalse(quote(500).coversClaimFee) + XCTAssertTrue(quote(501).coversClaimFee) + } + + // MARK: - Error copy + + func testSdkErrorsShowAppCopy() { + XCTAssertEqual( + SparkErrorCopy.message(for: SdkError.InsufficientFunds(tokenIdentifier: nil), fallback: "fallback"), + t("other__pay_insufficient_spending_description") + ) + XCTAssertEqual(SparkErrorCopy.message(for: SdkError.SparkError("raw sdk text"), fallback: "fallback"), "fallback") + XCTAssertEqual( + ChannelFreeRailSendRefused(reason: "copy", underlying: SdkError.SparkError("raw sdk text")).errorDescription, + "copy" + ) + } + + func testSentQuoteMustStayWithinTheFeeLimit() { + XCTAssertNoThrow(try SparkManager.checkSendFee(10, maxFeeSats: 10)) + XCTAssertNoThrow(try SparkManager.checkSendFee(500, maxFeeSats: nil)) + XCTAssertThrowsError(try SparkManager.checkSendFee(11, maxFeeSats: 10)) { error in + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused) + } + } + + func testAFreshQuoteThatRaisesTheTotalNeedsAnotherSwipe() { + func quote(networkFee: UInt64, instantClaimFee: UInt64) -> SparkTransferQuote { + SparkTransferQuote( + address: "bcrt1q-spark", satsPerVbyte: 2, networkFeeSats: networkFee, depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: instantClaimFee, laterSats: 198) + ) + } + let approved = quote(networkFee: 200, instantClaimFee: 1190) + + // A lower claim fee doesn't offset the higher network fee that leaves savings. + XCTAssertEqual(quote(networkFee: 400, instantClaimFee: 893).increase(over: approved, speed: .instant), 200) + // A higher claim fee leaves less for spending. + XCTAssertEqual(quote(networkFee: 200, instantClaimFee: 1290).increase(over: approved, speed: .instant), 100) + // A lower network fee doesn't offset a higher claim fee that leaves less for spending. + XCTAssertEqual(quote(networkFee: 150, instantClaimFee: 1240).increase(over: approved, speed: .instant), 50) + XCTAssertEqual(quote(networkFee: 150, instantClaimFee: 1190).increase(over: approved, speed: .instant), 0) + XCTAssertEqual(approved.increase(over: approved, speed: .instant), 0) + } + + func testHardwareTransferBelowTheClaimFeeNamesTheMinimum() { + let quote = SparkTransferQuote( + address: "bcrt1q-spark", satsPerVbyte: 20, networkFeeSats: 300, depositSats: 1000, + claimFees: SparkClaimFees(instantMaxSats: 2178, laterSats: 1980) + ) + + XCTAssertFalse(quote.coversClaimFee) + XCTAssertTrue(quote.belowClaimFeeMessage.contains(CurrencyFormatter.formatSats(2179))) + } + + func testSendMustFitTheQuotedFee() { + XCTAssertTrue(SparkManager.fitsBalance(amountSats: 9000, feeSats: 572, balanceSats: 9572)) + XCTAssertFalse(SparkManager.fitsBalance(amountSats: 9001, feeSats: 572, balanceSats: 9572)) + XCTAssertFalse(SparkManager.fitsBalance(amountSats: UInt64.max, feeSats: 1, balanceSats: UInt64.max)) + } + + func testChainFundingNeedsSparkToReadBitkitsChain() { + XCTAssertTrue(SparkManager.readsBitkitChain(network: .bitcoin, chainServiceUrl: nil)) + XCTAssertFalse(SparkManager.readsBitkitChain(network: .regtest, chainServiceUrl: nil), "Spark's regtest never sees the deposit") + XCTAssertTrue(SparkManager.readsBitkitChain(network: .regtest, chainServiceUrl: "http://127.0.0.1:3002")) + } + + func testASendSparkFailedWhileTheAppWasClosedIsReleasedAtTheNextSync() { + let payments = [ + lightningPayment(type: .send, status: .failed, hash: "failed"), + lightningPayment(type: .send, status: .failed, hash: "retried"), + lightningPayment(type: .send, status: .pending, hash: "retried"), + lightningPayment(type: .receive, status: .failed, hash: "received"), + lightningPayment(type: .send, status: .completed, hash: "paid"), + ] + + XCTAssertEqual(SparkManager.failedSendHashes(payments), ["failed"]) + } + + func testASparkRetryThatReturnsWhileTheSyncReadsSparksListKeepsItsHold() async throws { + let service = Bitkit.LightningService.shared + let store = service.railAttemptsStore + service.railAttemptsStore = try XCTUnwrap(UserDefaults(suiteName: "SparkPaymentSafetyTests")) + service.forgetRailAttempts() + defer { + service.forgetRailAttempts() + service.railAttemptsStore = store + } + service.recordRailAttempt(paymentHash: "failed") + service.recordRailAttempt(paymentHash: "retried") + + await SparkManager.releaseFailedSendHolds { + // The user retries one invoice, and the retry returns, while this list, which still shows it failed, is read. + service.recordRailAttempt(paymentHash: "retried") + return ["failed", "retried"].map { self.lightningPayment(type: .send, status: .failed, hash: $0) } + } + + XCTAssertEqual(service.railAttemptsStore.stringArray(forKey: "channelFreeRailAttemptedPaymentHashes"), ["retried"]) + } + + func testASparkRetryInFlightWhenTheSyncReadsSparksListKeepsItsHoldAfterItEnds() async throws { + let service = Bitkit.LightningService.shared + let store = service.railAttemptsStore + service.railAttemptsStore = try XCTUnwrap(UserDefaults(suiteName: "SparkPaymentSafetyTests")) + service.forgetRailAttempts() + defer { + service.endRailSend(paymentHash: "retried") + service.forgetRailAttempts() + service.railAttemptsStore = store + } + service.recordRailAttempt(paymentHash: "retried") + XCTAssertTrue(service.beginRailSend(paymentHash: "retried")) + + await SparkManager.releaseFailedSendHolds { + // The retry recorded before this list ends while the list, which still shows it failed, is read. + service.endRailSend(paymentHash: "retried") + return [self.lightningPayment(type: .send, status: .failed, hash: "retried")] + } + + XCTAssertEqual(service.railAttemptsStore.stringArray(forKey: "channelFreeRailAttemptedPaymentHashes"), ["retried"]) + } + + private func lightningPayment( + type: PaymentType, + status: BreezSdkSpark.PaymentStatus, + hash: String = "hash", + id: String = "id" + ) -> Payment { + Payment( + id: id, + paymentType: type, + status: status, + amount: 1000, + fees: 2, + timestamp: 1, + method: .lightning, + details: .lightning( + description: nil, + invoice: "lnbcrt1", + destinationPubkey: "pubkey", + htlcDetails: SparkHtlcDetails(paymentHash: hash, preimage: "preimage", expiryTime: 0, status: .preimageShared), + lnurlPayInfo: nil, + lnurlWithdrawInfo: nil, + lnurlReceiveMetadata: nil, + conversionInfo: nil + ), + conversionDetails: nil + ) + } +} + +private actor FeeQuotes { + private var fees: [UInt64] + + init(_ fees: [UInt64]) { + self.fees = fees + } + + func next() -> UInt64 { + fees.count > 1 ? fees.removeFirst() : fees[0] + } +} + +extension SparkPaymentSafetyTests { + func testSparkCountsAsSyncedOnlyAfterASyncThisRun() { + // After a relaunch the SDK reports no sync error until this run's first sync ends, and keeps the last sync time. + XCTAssertFalse( + SparkManager.hasSynced(after: nil, syncedThisRun: false, lastSyncFailed: false, lastSyncedAt: 2000), + "a relaunched wallet's unsynced list was read as complete" + ) + XCTAssertFalse(SparkManager.hasSynced(after: 1000, syncedThisRun: false, lastSyncFailed: false, lastSyncedAt: 2000)) + XCTAssertTrue(SparkManager.hasSynced(after: 1000, syncedThisRun: true, lastSyncFailed: false, lastSyncedAt: 2000)) + XCTAssertFalse(SparkManager.hasSynced(after: nil, syncedThisRun: true, lastSyncFailed: true, lastSyncedAt: 2000)) + } + + func testSparkCountsAsSyncedOnlyOnceAWalletSyncSucceeded() { + // The SDK reports no error before any sync ends, and sets its sync time only on a wallet sync that succeeded, + // while it also reports Synced for a partial or skipped sync. + XCTAssertFalse( + SparkManager.hasSynced(after: nil, syncedThisRun: true, lastSyncFailed: false, lastSyncedAt: nil), + "a list no wallet sync filled was read as complete" + ) + } +} + +// MARK: - Exit hold + +extension SparkPaymentSafetyTests { + func testAnExitStartingWhileASendPreparesSendsNothing() async { + var isExiting = false + let spends = SparkSpends { isExiting } + var sent = false + + // The exit starts while the send prepares, after any check the send made before it. + isExiting = true + do { + try await spends.run { sent = true } + XCTFail("a send went out once an exit held Spark") + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused) + } + + XCTAssertFalse(sent) + XCTAssertEqual(spends.inFlight, 0) + } + + func testAnExitStartWaitsForASendAlreadyPastItsExitCheck() async throws { + var isExiting = false + let spends = SparkSpends { isExiting } + var release: CheckedContinuation? + let send = Task { @MainActor in + try await spends.run { + await withCheckedContinuation { release = $0 } + } + } + while release == nil { + await Task.yield() + } + + isExiting = true + var isIdle = false + let idle = Task { @MainActor in + try await spends.holdForExit(timeout: 120) + isIdle = true + } + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertFalse(isIdle, "the exit start went on while a send past its check still spent leaves") + release?.resume() + try await send.value + try await idle.value + XCTAssertTrue(isIdle) + } + + /// A send still sending after the wait refuses the exit start before its record exists; Spark is let go, so the + /// user can start again once the send ended. + func testAnExitStartIsRefusedWhileASendStaysActivePastTheWait() async throws { + let spends = SparkSpends { false } + var release: CheckedContinuation? + let send = Task { @MainActor in + try await spends.run { + await withCheckedContinuation { release = $0 } + } + } + while release == nil { + await Task.yield() + } + + var isRefused = false + do { + try await spends.holdForExit(timeout: 0.3) + } catch { + isRefused = true + } + + XCTAssertTrue(isRefused, "the exit start went on while a send could still spend its leaves") + XCTAssertFalse(spends.isHeld, "Spark stayed held after the refused start") + release?.resume() + try await send.value + try await spends.holdForExit(timeout: 1) + XCTAssertTrue(spends.isHeld) + } +} diff --git a/BitkitTests/SparkRelaunchActivityTests.swift b/BitkitTests/SparkRelaunchActivityTests.swift new file mode 100644 index 000000000..15bab32b6 --- /dev/null +++ b/BitkitTests/SparkRelaunchActivityTests.swift @@ -0,0 +1,100 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +/// A send over Spark that was still in flight when the app was killed, recorded on the next launch. +@MainActor +final class SparkRelaunchActivityTests: XCTestCase { + func testTheFirstActivitySyncOfALaunchWaitsForSparksOwnSync() async throws { + var calls: [String] = [] + + _ = try await SparkManager.startConnected( + activitiesSynced: false, + refresh: { calls.append("refresh") }, + syncWallet: { calls.append("syncWallet") }, + syncActivities: { calls.append("syncActivities") } + ) + + XCTAssertEqual(calls, ["refresh", "syncWallet", "syncActivities"], "The activities were read before Spark listed the send") + } + + func testAFailedSparkSyncStillRecordsTheActivitiesItHas() async throws { + var synced = false + + _ = try await SparkManager.startConnected( + activitiesSynced: false, + refresh: {}, + syncWallet: { throw URLError(.timedOut) }, + syncActivities: { synced = true } + ) + + XCTAssertTrue(synced) + } + + func testContactsRememberedAndForgottenAtOnceAreNotLost() throws { + let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let contacts = SentPaymentContacts(defaults: defaults) + + DispatchQueue.concurrentPerform(iterations: 500) { index in + contacts.remember("pk:\(index)", forPaymentHash: "hash\(index)") + contacts.forget(paymentHash: "gone\(index)") + } + + let lost = (0 ..< 500).filter { contacts.contact(forPaymentHash: "hash\($0)") == nil } + XCTAssertEqual(lost, [], "Concurrent updates dropped remembered contacts") + } + + func testASendRecordedAfterARelaunchKeepsTheContactItPaid() async throws { + let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let previous = ActivityService.sentPaymentContacts + ActivityService.sentPaymentContacts = SentPaymentContacts(defaults: defaults) + defer { ActivityService.sentPaymentContacts = previous } + // A fresh hash per run, as the activity store outlives a test run. + let hash = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + String(repeating: "c4", count: 16) + let contact = "pk:contact" + ActivityService.sentPaymentContacts.remember(contact, forPaymentHash: hash) + + // The relaunch records the send from Spark's history; the send flow that would set its contact is gone. + try await CoreService.shared.activity.recordChannelFreeRailPayment(PaymentDetails( + id: hash, + kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: "lnbcrt1"), + amountMsat: 300_000, feePaidMsat: 1000, direction: .outbound, status: .succeeded, latestUpdateTimestamp: 100 + )) + + let activity = try await CoreService.shared.activity.getActivity(id: hash) + guard case let .lightning(lightning)? = activity else { return XCTFail("The send wasn't recorded") } + XCTAssertEqual(lightning.contact, contact, "The relaunch recorded the send without its contact") + XCTAssertNil(ActivityService.sentPaymentContacts.contact(forPaymentHash: hash), "The remembered contact outlived its activity") + } + + func testASendRecordedPendingBeforeTheKillGetsItsContactWhenItSettles() async throws { + let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let previous = ActivityService.sentPaymentContacts + ActivityService.sentPaymentContacts = SentPaymentContacts(defaults: defaults) + defer { ActivityService.sentPaymentContacts = previous } + let hash = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + String(repeating: "d5", count: 16) + func send(_ status: LDKNode.PaymentStatus, at timestamp: UInt64) -> PaymentDetails { + PaymentDetails( + id: hash, + kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: "lnbcrt1"), + amountMsat: 300_000, feePaidMsat: 1000, direction: .outbound, status: status, latestUpdateTimestamp: timestamp + ) + } + // Spark recorded the send as pending before the app was killed, with no contact yet. + try await CoreService.shared.activity.recordChannelFreeRailPayment(send(.pending, at: 100)) + ActivityService.sentPaymentContacts.remember("pk:contact", forPaymentHash: hash) + + try await CoreService.shared.activity.recordChannelFreeRailPayment(send(.succeeded, at: 200)) + + let activity = try await CoreService.shared.activity.getActivity(id: hash) + guard case let .lightning(lightning)? = activity else { return XCTFail("The send wasn't recorded") } + XCTAssertEqual(lightning.contact, "pk:contact") + } +} diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift new file mode 100644 index 000000000..74142b411 --- /dev/null +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -0,0 +1,144 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +final class SparkTransferActivityTests: XCTestCase { + func testDepositsAndWithdrawalsAreTransfersButFailedOnesAndLightningAreNot() { + let unclaimed = DepositInfo( + txid: "pending-deposit", vout: 0, amountSats: 7500, isMature: true, + refundTx: nil, refundTxId: nil, refundState: nil, claimError: nil, instantClaimStatus: nil, maxClaimFee: nil + ) + let payments = [ + payment(id: "1", status: .completed, details: .deposit(txId: "claimed-deposit", vout: 0)), + payment(id: "2", status: .completed, details: .withdraw(txId: "withdrawal", vout: 0, destinationAddress: "bcrt1qsavings")), + payment(id: "3", status: .failed, details: .withdraw(txId: "failed-withdrawal", vout: 0, destinationAddress: "bcrt1qsavings")), + payment(id: "4", status: .completed, details: nil), + ] + + let txIds = SparkManager.transferTxIds(unclaimed: [unclaimed], payments: payments) + + XCTAssertEqual(txIds, ["pending-deposit", "claimed-deposit", "withdrawal"]) + } + + func testADepositClaimedEarlyIsListedOnlyAsClaimed() { + let deposits = ["claimed-early", "pending-deposit"].map { + DepositInfo( + txid: $0, vout: 0, amountSats: 7500, isMature: true, + refundTx: nil, refundTxId: nil, refundState: nil, claimError: nil, instantClaimStatus: nil, maxClaimFee: nil + ) + } + let claimed = [payment(id: "1", status: .completed, details: .deposit(txId: "claimed-early", vout: 0))] + + let unclaimed = SparkManager.unclaimedDeposits(deposits, claimed: claimed) + + XCTAssertEqual(unclaimed.map(\.txid), ["pending-deposit"], "A deposit claimed early still shows as in progress") + } + + func testAClaimedDepositHidesOnlyItsOwnOutputOfTheTransaction() { + let deposits = [0, 1].map { + DepositInfo( + txid: "batched", vout: $0, amountSats: 7500, isMature: true, + refundTx: nil, refundTxId: nil, refundState: nil, claimError: nil, instantClaimStatus: nil, maxClaimFee: nil + ) + } + let claimed = [payment(id: "1", status: .completed, details: .deposit(txId: "batched", vout: 0))] + + let unclaimed = SparkManager.unclaimedDeposits(deposits, claimed: claimed) + + XCTAssertEqual(unclaimed.map(\.vout), [1], "Another output of a claimed deposit's transaction was hidden") + } + + func testARecoveredReadBalanceNotifiesEvenWhenItEqualsTheShownOne() { + let kept = SparkBalances(readSats: 0, keptSats: 9000, pendingSats: 0) + let recovered = SparkBalances(readSats: 9000, keptSats: nil, pendingSats: 0) + + XCTAssertTrue(SparkManager.balancesChanged(from: kept, to: recovered), "Sendable stayed 0 after Spark read the balance again") + XCTAssertFalse(SparkManager.balancesChanged(from: recovered, to: recovered)) + XCTAssertTrue(SparkManager.balancesChanged(from: recovered, to: SparkBalances(readSats: 9000, keptSats: nil, pendingSats: 1))) + } + + func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { + XCTAssertEqual( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: false, isExiting: false), 9000, + "The balance read 0 while stale" + ) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false, withdrewAll: false, isExiting: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true, withdrewAll: false, isExiting: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true, withdrewAll: false, isExiting: false)) + } + + func testAZeroReadDuringAnEmergencyExitKeepsNoBalance() { + XCTAssertNil( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: false, isExiting: true), + "The balance the exit moves to savings stayed shown in Spending" + ) + } + + func testACancelledExitShowsTheBalanceKeptBeforeIt() { + XCTAssertEqual(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 0, isCancel: true), 10000, "The cancel dropped the kept balance") + XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 0, isCancel: false), "The exit's balance came back after it ended") + XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 4000, isCancel: true)) + XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: nil, read: 0, isCancel: true)) + } + + func testAZeroReadAfterASuccessfulWithdrawalDropsTheLastShownBalance() { + XCTAssertNil( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: true, isExiting: false), + "The withdrawn balance stayed shown while Spark's last sync had failed" + ) + } + + func testABalanceReadBeforeAFullWithdrawalNeverBringsItBack() { + XCTAssertEqual( + SparkManager.withdrewAllAt(100, read: 9000, lastSyncedAt: 90), + 100, + "A refresh that read the balance before the withdrawal brought it back" + ) + XCTAssertEqual(SparkManager.withdrewAllAt(100, read: 9000, lastSyncedAt: nil), 100) + XCTAssertEqual(SparkManager.withdrewAllAt(100, read: 0, lastSyncedAt: 101), 100) + XCTAssertNil(SparkManager.withdrewAllAt(100, read: 2000, lastSyncedAt: 101), "New funds after the withdrawal stayed hidden") + XCTAssertNil(SparkManager.withdrewAllAt(nil, read: 0, lastSyncedAt: 101)) + } + + func testASyncedZeroReadAfterAFailedWithdrawalClearsTheKeptBalance() { + XCTAssertNil( + SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 101, syncFailed: false), + "A sync after the failed withdrawal read 0 and the balance stayed kept" + ) + XCTAssertEqual(SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 40, syncFailed: false), 100) + XCTAssertEqual(SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 101, syncFailed: true), 100) + XCTAssertEqual(SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: nil, syncFailed: false), 100) + XCTAssertNil(SparkManager.withdrawalFailedAt(100, read: 4000, lastSyncedAt: nil, syncFailed: true)) + } + + func testListsWithdrawalsWithWhatSparkKept() { + let payments = [ + payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal", vout: 0, destinationAddress: "bcrt1qsavings"), fees: 1470), + payment( + id: "2", + status: .failed, + details: .withdraw(txId: "failed-withdrawal", vout: 0, destinationAddress: "bcrt1qsavings"), + fees: 1470 + ), + payment(id: "3", status: .completed, details: .deposit(txId: "deposit", vout: 0)), + ] + + let withdrawals = SparkManager.withdrawals(payments) + + XCTAssertEqual(withdrawals, [SparkWithdrawal(txid: "withdrawal", amountSats: 1000, feeSats: 1470)]) + } + + private func payment(id: String, status: PaymentStatus, details: PaymentDetails?, fees: U128 = 0) -> Payment { + Payment( + id: id, + paymentType: .receive, + status: status, + amount: 1000, + fees: fees, + timestamp: 1, + method: .deposit, + details: details, + conversionDetails: nil + ) + } +} diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift new file mode 100644 index 000000000..a816c1283 --- /dev/null +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -0,0 +1,452 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class SparkTransferFlowTests: XCTestCase { + private let ceiling: UInt64 = 1000 + + private func choice(earlyFee: UInt64) -> SparkClaimOffer.Kind { + .choice(earlyFeeSats: earlyFee, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3) + } + + func testInstantWithinCeilingClaimsEarly() { + XCTAssertEqual(SparkClaimDecision.onOffer(choice(earlyFee: 900), speed: .instant, ceilingSats: ceiling), .claiming) + XCTAssertEqual(SparkClaimDecision.onOffer(choice(earlyFee: ceiling), speed: .instant, ceilingSats: ceiling), .claiming) + } + + func testInstantOverCeilingAsksBeforePaying() { + XCTAssertEqual( + SparkClaimDecision.onOffer(choice(earlyFee: 1500), speed: .instant, ceilingSats: ceiling), + .repriced(feeSats: 1500) + ) + } + + func testInstantWithoutEarlyOfferWaitsForConfirmations() { + XCTAssertEqual( + SparkClaimDecision.onOffer(.standard(feeSats: 198, confirmations: 3), speed: .instant, ceilingSats: ceiling), + .unavailable + ) + } + + func testInstantClaimedBySdkShowsAddingUntilSparkListsItClaimed() { + XCTAssertEqual( + SparkClaimDecision.onOffer(.automatic(feeSats: 300, confirmations: 0), speed: .instant, ceilingSats: ceiling), + .adding, + "Success showed before Spark claimed the deposit" + ) + } + + func testLaterNeverClaimsEarly() { + XCTAssertEqual(SparkClaimDecision.onOffer(choice(earlyFee: 10), speed: .later, ceilingSats: ceiling), .later) + XCTAssertEqual( + SparkClaimDecision.onOffer(.standard(feeSats: 198, confirmations: 3), speed: .later, ceilingSats: ceiling), + .later + ) + } + + func testClaimOutcomes() { + XCTAssertEqual(SparkClaimDecision.onClaim(.claimed), .added) + XCTAssertEqual(SparkClaimDecision.onClaim(.submitted), .adding) + XCTAssertEqual(SparkClaimDecision.onClaim(.repriced(requiredFeeSats: 1200)), .repriced(feeSats: 1200)) + XCTAssertEqual(SparkClaimDecision.onClaim(.declined), .unavailable) + XCTAssertNil(SparkClaimDecision.onClaim(.waiting)) + } + + @MainActor + func testLaterBroadcastSkipsTheClaim() { + let flow = SparkTransferFlow(amountSats: 100_000, speed: .later, quote: quote) + flow.onBroadcast(txid: "txid") + XCTAssertEqual(flow.claimState, .later) + } + + @MainActor + func testWaitFallsBackToLater() { + let flow = SparkTransferFlow(amountSats: 100_000, claimState: .repriced(feeSats: 1500)) + flow.waitForConfirmations() + XCTAssertEqual(flow.claimState, .later) + } + + @MainActor + func testWaitAfterAClaimTimeRepriceClearsTheEarlyClaimCap() async throws { + var released: [String] = [] + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { _ in .repriced(requiredFeeSats: 1500) }, + refresh: {}, + sleep: { _ in }, + releaseEarlyClaim: { released.append($0.txid) + return true + } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .repriced(feeSats: 1500)) + + flow.waitForConfirmations() + for _ in 0 ..< 100 where released.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(flow.claimState, .later) + XCTAssertEqual(released, ["txid"], "Spark may still claim early at the cap Wait declined") + } + + @MainActor + func testWaitClearsTheEarlyClaimCapAgainWhenAnotherClaimHeldTheDeposit() async throws { + var releaseCalls = 0 + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { _ in .repriced(requiredFeeSats: 1500) }, + refresh: {}, + sleep: { _ in }, + releaseEarlyClaim: { _ in + releaseCalls += 1 + // The first call meets Spark's own sync claiming the deposit, which leaves the cap in place. + return releaseCalls > 1 + } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + + flow.waitForConfirmations() + for _ in 0 ..< 100 where releaseCalls < 2 { + try await Task.sleep(nanoseconds: 10_000_000) + } + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(releaseCalls, 2, "Wait gave up on the early claim cap after Spark was busy with the deposit") + } + + @MainActor + func testWaitBeforeAnyClaimAttemptLeavesTheDepositAlone() async throws { + var released: [String] = [] + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 1500, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { _ in .claimed }, + refresh: {}, + sleep: { _ in }, + releaseEarlyClaim: { released.append($0.txid) + return true + } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .repriced(feeSats: 1500)) + + flow.waitForConfirmations() + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(released, []) + } + + @MainActor + func testTimeoutWhenSparkNeverListsTheDeposit() async { + let flow = pollingFlow(offers: [nil, nil, nil]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .notSeen) + } + + @MainActor + func testListedDepositWhoseEarlyClaimKeepsWaitingIsNotUnseen() async { + let flow = pollingFlow( + offers: [choice(earlyFee: 900), choice(earlyFee: 900), choice(earlyFee: 900)], + claims: [.waiting, .waiting, .waiting], + pollAttempts: 3 + ) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .unavailable) + } + + @MainActor + func testDepositListedThenGoneWasAdded() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900), nil], claims: [.waiting]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .added) + } + + @MainActor + func testWaitingKeepsPollingUntilClaimed() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900), choice(earlyFee: 900)], claims: [.waiting, .claimed]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .added) + } + + @MainActor + func testPayAfterRepriceClaimsAtTheNewFee() async { + let flow = pollingFlow(offers: [choice(earlyFee: 1500), choice(earlyFee: 1500)], claims: [.claimed]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .repriced(feeSats: 1500)) + + flow.payRepriced() + XCTAssertEqual(flow.claimState, .claiming) + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .added) + } + + @MainActor + func testDeclinedEarlyClaimWaitsForConfirmations() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900)], claims: [.declined]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .unavailable) + } + + @MainActor + func testANewTransferKeepsTheEarlierClaimAndItsResultOffScreen() async throws { + var claimed: [String] = [] + var release: CheckedContinuation? + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { offer in + await withCheckedContinuation { release = $0 } + claimed.append(offer.txid) + return .claimed + }, + refresh: {}, + sleep: { _ in } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "first") + for _ in 0 ..< 100 where release == nil { + try await Task.sleep(nanoseconds: 10_000_000) + } + + flow.beginIfBroadcast() + flow.speed = .later + flow.onBroadcast(txid: "second") + release?.resume() + for _ in 0 ..< 100 where claimed.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(claimed, ["first"], "The earlier deposit's early claim went on") + XCTAssertEqual(flow.claimState, .later, "Its result stays off the new transfer's screen") + } + + @MainActor + func testSubmittedEarlyClaimShowsAddingUntilTheDepositSettles() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.submitted]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .adding) + XCTAssertFalse(flow.claimState.isDone, "Setting up showed success while the claim was only submitted") + + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: false)]) + XCTAssertEqual(flow.claimState, .adding) + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: true)]) + XCTAssertEqual(flow.claimState, .added) + XCTAssertTrue(flow.claimState.isDone) + } + + @MainActor + func testALaterTransferEndsOnceSparkClaimsItsDeposit() { + let flow = SparkTransferFlow(amountSats: 100_000, speed: .later, quote: quote) + flow.onBroadcast(txid: "txid") + + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: false)]) + XCTAssertEqual(flow.claimState, .later) + flow.noteSettled([SparkDeposit(txid: "other", amountSats: 100_000, isClaimed: true)]) + XCTAssertEqual(flow.claimState, .later) + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: true)]) + XCTAssertEqual(flow.claimState, .added, "Later stayed in progress after the deposit reached spending") + } + + @MainActor + func testARecoveredFundingClaimsEarlyOnlyUpToTheSwipedCeiling() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + flow.quote = quote(instantMax: 337) + let attemptId = UUID() + flow.approveSwipe() + flow.bindApproval(to: attemptId) + // The confirm screen requoted under the pending sheet while fee rates rose. + flow.quote = quote(instantMax: 3010) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: attemptId) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "Paid an early claim fee the user never approved") + } + + @MainActor + func testARecoveredFundingKeepsItsOwnApprovalWhenALaterSwipeIsRejected() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let first = UUID() + flow.quote = quote(instantMax: 337) + flow.approveSwipe() + flow.bindApproval(to: first) + // A second swipe at a higher ceiling is rejected while the first funding is still unresolved, so the attempt + // the screen sees is still the first one. + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: first) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: first) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "The first funding claimed early at the rejected swipe's ceiling") + } + + @MainActor + func testASwipeRefusedForAnOlderFundingAfterARelaunchLeavesItsEarlyClaimAsking() async { + // After a relaunch nothing is tied to the older funding still unresolved, which refuses the new swipe. + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let older = UUID() + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: older, after: OnchainSendAttemptError.blocked(nil)) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: older) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "The older funding claimed early at the refused swipe's ceiling") + } + + @MainActor + func testASwipeWhoseFundingIsLeftUnresolvedTiesItsApprovalToIt() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let attemptId = UUID() + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: attemptId, after: OnchainSendAttemptError.unresolved) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: attemptId) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .added, "The funding this swipe left unresolved lost the approval it gave") + } + + @MainActor + func testAFundingThatResolvesOffTheConfirmScreenIsFollowedOnce() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let attemptId = UUID() + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: attemptId, after: OnchainSendAttemptError.unresolved) + + // The swipe's funding resolves while the Amount screen shows, so no confirm screen follows it. + XCTAssertTrue(flow.followIfOwned(txid: "txid", attemptId: attemptId), "A funding this flow approved went unfollowed") + await flow.waitForClaim() + + XCTAssertEqual(flow.txid, "txid") + XCTAssertEqual(flow.claimState, .added) + XCTAssertFalse(flow.followIfOwned(txid: "txid", attemptId: attemptId), "A followed funding was followed again") + } + + @MainActor + func testANewAmountAfterAFollowedFundingIsANewTransfer() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let attemptId = UUID() + flow.start(amountSats: 40000, isMaxAmount: false) + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: attemptId, after: OnchainSendAttemptError.unresolved) + XCTAssertTrue(flow.followIfOwned(txid: "txid", attemptId: attemptId)) + await flow.waitForClaim() + + flow.start(amountSats: 40000, isMaxAmount: false) + XCTAssertEqual(flow.txid, "txid", "the transfer lost the funding it follows") + // The funding resolved while Spending Amount showed, and a new amount is a new transfer. + flow.start(amountSats: 50000, isMaxAmount: false) + XCTAssertNil(flow.txid, "an earlier funding stood in for a new transfer's swipe") + } + + @MainActor + func testAFundingWhoseOutcomeIsUnknownIsKeptNotFailed() { + XCTAssertEqual(try TransferViewModel.fundedTxid(.accepted(txid: "txid")), "txid") + XCTAssertThrowsError(try TransferViewModel.fundedTxid(.unknown(txid: "txid"))) { error in + XCTAssertEqual((error as? SparkFundingUnresolved)?.txid, "txid", "A funding that may be on the network read as failed") + } + XCTAssertThrowsError(try TransferViewModel.fundedTxid(.rejected(txid: "txid", reason: "fee too low"))) { error in + XCTAssertEqual((error as? SparkFundingUnresolved)?.rejectionReason, "fee too low") + } + } + + @MainActor + func testARecoveredFundingWithNoApprovalAsksBeforeAnEarlyClaim() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + + flow.onRecoveredBroadcast(txid: "txid", attemptId: UUID()) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "Claimed early on an approval made for another attempt") + } + + private func quote(instantMax: UInt64) -> SparkTransferQuote { + SparkTransferQuote( + address: "bcrt1qtest", satsPerVbyte: 6, networkFeeSats: 577, depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: instantMax, laterSats: 198) + ) + } + + /// A flow whose poll answers with `offers` in turn (nil once they run out) and whose claims answer with `claims`. + @MainActor + private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = [], pollAttempts: Int? = nil) -> SparkTransferFlow { + var offers = offers + var claims = claims + let operations = SparkClaimOperations( + offer: { txid in + guard !offers.isEmpty, let kind = offers.removeFirst() else { return nil } + return SparkClaimOffer(txid: txid, vout: 0, kind: kind) + }, + claimEarly: { _ in claims.isEmpty ? .waiting : claims.removeFirst() }, + refresh: {}, + sleep: { _ in } + ) + return SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: pollAttempts ?? offers.count + 1) + } + + @MainActor + func testARefusedFixedFundingKeepsItsFeeUntilTheTransferChanges() { + let flow = SparkTransferFlow() + flow.start(amountSats: 50000, isMaxAmount: false) + flow.onFundingChanged(preparedFeeSats: 700) + XCTAssertEqual(flow.fixedFeeFloorSats, 700, "The next swipe would approve less than the refused fee") + flow.start(amountSats: 50000, isMaxAmount: false) + XCTAssertEqual(flow.fixedFeeFloorSats, 700) + flow.start(amountSats: 60000, isMaxAmount: false) + XCTAssertNil(flow.fixedFeeFloorSats) + + flow.start(amountSats: 60000, isMaxAmount: true) + flow.onFundingChanged(preparedFeeSats: 700) + XCTAssertNil(flow.fixedFeeFloorSats, "Max re-prices from the balance instead") + } + + func testQuoteTiles() { + XCTAssertEqual(quote.toSpendingSats(for: .instant), 100_000 - 1093) + XCTAssertEqual(quote.toSpendingSats(for: .later), 100_000 - 198) + XCTAssertEqual(quote.totalSats, 100_577) + } + + func testMatureClaimFee() { + XCTAssertEqual(SparkManager.matureDepositClaimFeeSats(halfHourFeeRate: 2), 198) + } + + private var quote: SparkTransferQuote { + SparkTransferQuote( + address: "bcrt1qtest", + satsPerVbyte: 6, + networkFeeSats: 577, + depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: 1093, laterSats: 198) + ) + } +} diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift new file mode 100644 index 000000000..74a7ed0b3 --- /dev/null +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -0,0 +1,533 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +final class SparkWithdrawalTests: XCTestCase { + // MARK: - Quote reuse + + func testQuoteHoldsForTheSameAddressAndBalanceUntilShortlyBeforeItExpires() { + let quote = SparkWithdrawalQuote(address: "bcrt1q-savings", balanceSats: 99901, expiresAt: 1000) + + XCTAssertTrue(quote.holds(address: "bcrt1q-savings", balanceSats: 99901, now: 900)) + XCTAssertFalse(quote.holds(address: "bcrt1q-savings", balanceSats: 99901, now: 1000 - SparkWithdrawalQuote.expiryMarginSecs)) + XCTAssertFalse(quote.holds(address: "bcrt1q-other", balanceSats: 99901, now: 900)) + XCTAssertFalse(quote.holds(address: "bcrt1q-savings", balanceSats: 100_000, now: 900)) + } + + func testEstimateNeverHolds() { + let estimate = SparkWithdrawalQuote(address: "bcrt1q-savings", balanceSats: 99901, expiresAt: 0) + + XCTAssertFalse(estimate.holds(address: "bcrt1q-savings", balanceSats: 99901, now: 0)) + } + + // MARK: - Fee pricing + + @MainActor + func testPricesTheFeeOncePerBalanceToOneAddress() async { + let transfer = TransferViewModel() + let calls = PriceCalls() + transfer.priceSparkWithdrawal = { address in + await calls.record(address) + return 2190 + } + + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-first") + await waitUntilPriced(transfer) + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-second") + await waitUntilPriced(transfer) + + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) + let addresses = await calls.addresses + XCTAssertEqual(addresses, ["bcrt1q-first"]) + + transfer.loadSparkWithdrawFee(balanceSats: 50000, savingsAddress: "bcrt1q-second") + await waitUntilPriced(transfer) + + let repriced = await calls.addresses + XCTAssertEqual(repriced, ["bcrt1q-first", "bcrt1q-first"]) + } + + @MainActor + func testGivesUpOnASlowQuoteAndPricesAgainNextTime() async { + let transfer = TransferViewModel() + transfer.sparkFeeTimeout = 0.1 + transfer.priceSparkWithdrawal = { _ in + try await Task.sleep(nanoseconds: 5_000_000_000) + return 2190 + } + + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + XCTAssertTrue(transfer.isPricingSparkWithdrawal) + await waitUntilPriced(transfer) + + XCTAssertNil(transfer.sparkWithdrawFeeSats) + + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) + } + + @MainActor + func testTheFeeTimeoutEndsTheWaitWhileTheSdkCallIgnoresCancellation() async { + let transfer = TransferViewModel() + transfer.sparkFeeTimeout = 0.05 + // Like the Breez bridge, this call doesn't observe task cancellation. + transfer.priceSparkWithdrawal = { _ in + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 3) { continuation.resume(returning: 2190) } + } + } + let start = Date() + + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertFalse(transfer.isPricingSparkWithdrawal) + XCTAssertNil(transfer.sparkWithdrawFeeSats) + XCTAssertLessThan(Date().timeIntervalSince(start), 1.5) + } + + @MainActor + func testASparkBalanceTooSmallToMoveLeavesTheChannelsToMoveByDefault() async { + let transfer = TransferViewModel() + transfer.beginSavingsTransfer() + transfer.priceSparkWithdrawal = { _ in 1250 } + transfer.loadSparkWithdrawFee(balanceSats: 1000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + let selection = transfer.savingsSelection(readyChannelIds: ["channel"], sparkSats: 1000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["channel"], withdrawsSpark: false)) + XCTAssertTrue(transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark), "Spark held the channels back") + } + + @MainActor + func testABalanceThatLeavesDustAfterItsFeeIsTooSmallToMove() async { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 1093 } + + transfer.loadSparkWithdrawFee(balanceSats: 1093 + UInt64(Env.dustLimit) - 1, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertTrue(transfer.isSparkWithdrawTooSmall) + XCTAssertNil(transfer.sparkWithdrawFeeSats) + XCTAssertFalse(transfer.isSparkWithdrawalPriced(withdrawsSpark: true)) + + transfer.loadSparkWithdrawFee(balanceSats: 1093 + UInt64(Env.dustLimit), savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertFalse(transfer.isSparkWithdrawTooSmall) + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 1093) + } + + @MainActor + func testABalanceSparkRefusesToQuoteAsBelowTheMinimumIsTooSmallToMove() async { + let transfer = TransferViewModel() + // What the SDK answers when even its cheapest fee would leave dust. + transfer.priceSparkWithdrawal = { _ in + throw SdkError.InvalidInput("Amount is below the minimum of 330 sats required for this address after lowest fees of 1093 sats") + } + + transfer.loadSparkWithdrawFee(balanceSats: 500, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertTrue(transfer.isSparkWithdrawTooSmall, "Spark stayed selected with nothing to retry") + XCTAssertNil(transfer.sparkWithdrawFeeSats) + } + + @MainActor + func testCloseInsteadWaitsForTheSparkFee() { + let transfer = TransferViewModel() + + let committed = transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close) + + XCTAssertFalse(committed, "A Spark withdrawal was committed with no fee shown") + XCTAssertFalse(transfer.hasChannelsToClose) + } + + @MainActor + func testNoSecondCommitWhileTheSwipeRepricesTheSparkFee() async throws { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let gate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await gate.wait() + return 2190 + } + + let recheck = Task { try await transfer.recheckSparkWithdrawFee() } + try await Task.sleep(nanoseconds: 50_000_000) + let committedDuringRecheck = transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close) + await gate.open() + _ = try await recheck.value + + XCTAssertFalse(committedDuringRecheck, "Close instead committed while the swipe was repricing") + XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true)) + } + + @MainActor + func testTheSwipeResetsWhenSparksBalanceChangesDuringItsRecheck() async throws { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let recheckGate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await recheckGate.wait() + return 2190 + } + + let swipe = Task { try await transfer.confirmSavingsSwipe(channels: [], withdrawsSpark: true) } + try await Task.sleep(nanoseconds: 50_000_000) + let repriceGate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await repriceGate.wait() + return 2300 + } + // A receive lands during the recheck, so the confirm screen prices the new balance. + transfer.loadSparkWithdrawFee(balanceSats: 120_000, savingsAddress: "bcrt1q-savings") + await recheckGate.open() + + do { + try await swipe.value + XCTFail("The swipe committed while Spark's new balance was being priced") + } catch { + XCTAssertTrue(error is SavingsCommitRefused, "Unexpected error: \(error)") + } + await repriceGate.open() + await waitUntilPriced(transfer) + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2300) + } + + @MainActor + func testTheSwipeResetsWhenSparksNewBalanceIsPricedBeforeItsRecheckReturns() async throws { + let transfer = TransferViewModel() + transfer.sparkReadsSavingsChain = { true } + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let recheckGate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await recheckGate.wait() + return 2190 + } + + let swipe = Task { try await transfer.confirmSavingsSwipe(channels: [], withdrawsSpark: true) } + try await Task.sleep(nanoseconds: 50_000_000) + // A receive lands during the recheck, and the confirm screen prices the new balance before the recheck returns. + transfer.priceSparkWithdrawal = { _ in 2300 } + transfer.loadSparkWithdrawFee(balanceSats: 120_000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2300) + await recheckGate.open() + + do { + try await swipe.value + XCTFail("The swipe committed at a fee it did not recheck") + } catch { + XCTAssertTrue(error is SavingsCommitRefused, "Unexpected error: \(error)") + } + let withdrawn = WithdrawnFee() + transfer.sparkBalanceSats = { 120_000 } + transfer.withdrawSpark = { _, feeSats in withdrawn.feeSats = feeSats } + _ = try await transfer.closeSelectedChannels() + XCTAssertNil(withdrawn.feeSats, "Spark withdrew at a fee the swipe did not approve") + } + + @MainActor + func testTheSwipeWithdrawsSparkAtTheFeeItConfirmedWhenSparkIsPricedAgainBeforeTheWithdrawal() async throws { + let transfer = await pricedTransfer() + try await transfer.confirmSavingsSwipe(channels: [], withdrawsSpark: true) + + let withdrawnFeeSats = try await repriceAndWithdraw(transfer) + + XCTAssertEqual(withdrawnFeeSats, 1470, "Spark withdrew at a fee priced after the swipe") + } + + @MainActor + func testCloseInsteadWithdrawsSparkAtTheFeeShownWhenSparkIsPricedAgainBeforeTheWithdrawal() async throws { + let transfer = await pricedTransfer() + XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close)) + + let withdrawnFeeSats = try await repriceAndWithdraw(transfer) + + XCTAssertEqual(withdrawnFeeSats, 1470, "Spark withdrew at a fee priced after close instead") + } + + @MainActor + func testSparkIsNotWithdrawnToASavingsAddressOnAnotherChain() async throws { + let transfer = await pricedTransfer() + XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close)) + // A regtest Savings wallet reads its own chain unless Spark is pointed at it. + transfer.sparkReadsSavingsChain = { false } + let withdrawn = WithdrawnFee() + transfer.sparkBalanceSats = { 40000 } + transfer.withdrawSpark = { _, feeSats in withdrawn.feeSats = feeSats } + + _ = try await transfer.closeSelectedChannels() + + XCTAssertNil(withdrawn.feeSats, "Spark sent its balance to a Savings address on another chain") + XCTAssertNotNil(transfer.sparkWithdrawError, "the withdrawal was skipped without telling the user") + } + + @MainActor + private func pricedTransfer() async -> TransferViewModel { + let transfer = TransferViewModel() + transfer.sparkReadsSavingsChain = { true } + transfer.priceSparkWithdrawal = { _ in 1470 } + transfer.loadSparkWithdrawFee(balanceSats: 40000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + return transfer + } + + /// A receive lands during the transition, so the confirm screen prices Spark's new balance, then the transfer runs. + @MainActor + private func repriceAndWithdraw(_ transfer: TransferViewModel) async throws -> UInt64? { + transfer.priceSparkWithdrawal = { _ in 1620 } + transfer.loadSparkWithdrawFee(balanceSats: 52000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let withdrawn = WithdrawnFee() + transfer.sparkBalanceSats = { 52000 } + transfer.withdrawSpark = { _, feeSats in withdrawn.feeSats = feeSats } + _ = try await transfer.closeSelectedChannels() + return withdrawn.feeSats + } + + // MARK: - Failed withdrawal + + // MARK: - Failed withdrawal + + func testFailedSparkWithdrawalIsShownAndNamesWhetherChannelsMoved() { + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: true, movedChannels: true, channelsFailedToClose: false), + .sparkFailed(channelsMoved: true) + ) + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: true, movedChannels: false, channelsFailedToClose: false), + .sparkFailed(channelsMoved: false) + ) + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: false, movedChannels: true, channelsFailedToClose: false), + .success + ) + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: true, movedChannels: true, channelsFailedToClose: true), + .channelsInterrupted + ) + } + + // MARK: - Withdrawal receipt + + func testWithdrawalReceiptIsMatchedByItsTxid() { + let listed = [SparkWithdrawal(txid: "listed", amountSats: 97711, feeSats: 2190)] + + XCTAssertTrue(SparkManager.isWithdrawal(txid: "sent", sent: ["sent"], withdrawals: [])) + XCTAssertTrue(SparkManager.isWithdrawal(txid: "listed", sent: [], withdrawals: listed)) + XCTAssertFalse(SparkManager.isWithdrawal(txid: "payment", sent: ["sent"], withdrawals: listed)) + } + + func testASparkWithdrawalIsNeverBoosted() { + let pending = Payment( + id: "pending", + paymentType: .send, + status: .pending, + amount: 20000, + fees: 300, + timestamp: 1, + method: .withdraw, + details: .withdraw(txId: "listed", vout: 0, destinationAddress: "bcrt1qcontact"), + conversionDetails: nil + ) + + XCTAssertTrue(SparkManager.isSparkWithdrawal(txid: "contact", isSent: true, known: ["contact"], payments: [])) + XCTAssertTrue( + SparkManager.isSparkWithdrawal(txid: "listed", isSent: true, known: [], payments: [pending]), + "a pending withdrawal Spark lists can't be boosted" + ) + XCTAssertFalse(SparkManager.isSparkWithdrawal(txid: "savings", isSent: true, known: ["contact"], payments: [pending])) + } + + func testASparkWithdrawalsSavingsReceiptCanStillBeBoosted() { + XCTAssertFalse( + SparkManager.isSparkWithdrawal(txid: "transfer", isSent: false, known: ["transfer"], payments: []), + "Savings owns the output a Spark withdrawal pays it, so its receipt keeps CPFP" + ) + } + + // MARK: - Payout confirmation + + /// Spark lists a withdrawal completed once its provider took the payout on, before it is mined, so only a payout + /// output the chain lists in a block confirms its activity. + func testACompletedContactWithdrawalIsConfirmedOnlyOnceThePayoutIsMined() async { + let payouts = SparkManager.completedContactPayouts( + [ + withdrawal(txId: "mined", status: .completed), + withdrawal(txId: "mempool", status: .completed), + withdrawal(txId: "unlisted", status: .completed), + withdrawal(txId: "pending", status: .pending), + withdrawal(txId: "savings", status: .completed), + ], + contactWithdrawals: ["mined", "mempool", "unlisted", "pending"] + ) + let marked = TxIds() + + await SparkManager.confirmMinedPayouts( + payouts, + needsConfirmation: { _ in true }, + unspentOutputs: { _ in [Self.output(txId: "mined", confirmations: 1), Self.output(txId: "mempool", confirmations: 0)] }, + isMined: { _ in false }, + markConfirmed: { await marked.insert($0) } + ) + + let confirmed = await marked.ids + XCTAssertEqual(confirmed, ["mined"], "a payout still in the mempool or not yet broadcast stays unconfirmed") + } + + /// A payout the contact spent before Bitkit saw it mined no longer lists as unspent, so its transaction is asked. + func testAPayoutTheContactAlreadySpentIsConfirmedFromItsTransaction() async { + let marked = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "spent", vout: 1, address: "bcrt1qcontact")], + needsConfirmation: { _ in true }, + unspentOutputs: { _ in [] }, + isMined: { $0 == "spent" }, + markConfirmed: { await marked.insert($0) } + ) + + let confirmed = await marked.ids + XCTAssertEqual(confirmed, ["spent"]) + } + + func testAPayoutIsConfirmedFromItsTransactionWhenElectrumFails() async { + let marked = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "mined", vout: 1, address: "bcrt1qcontact")], + needsConfirmation: { _ in true }, + unspentOutputs: { _ in throw URLError(.timedOut) }, + isMined: { $0 == "mined" }, + markConfirmed: { await marked.insert($0) } + ) + + let confirmed = await marked.ids + XCTAssertEqual(confirmed, ["mined"], "a failed Electrum read should fall back to the payout transaction's status") + } + + func testAPayoutListedUnspentIsNotAskedAboutByItsTransaction() async { + let asked = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "mempool", vout: 1, address: "bcrt1qcontact")], + needsConfirmation: { _ in true }, + unspentOutputs: { _ in [Self.output(txId: "mempool", confirmations: 0)] }, + isMined: { txId in + await asked.insert(txId) + return true + }, + markConfirmed: { _ in } + ) + + let lookedUp = await asked.ids + XCTAssertTrue(lookedUp.isEmpty, "the unspent list already says the payout is in the mempool") + } + + func testAConfirmedContactWithdrawalIsNotLookedUpAgain() async { + let lookups = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "mined", vout: 0, address: "bcrt1qcontact")], + needsConfirmation: { _ in false }, + unspentOutputs: { address in + await lookups.insert(address) + return [] + }, + isMined: { txId in + await lookups.insert(txId) + return true + }, + markConfirmed: { _ in } + ) + + let looked = await lookups.ids + XCTAssertTrue(looked.isEmpty) + } + + private func withdrawal(txId: String, status: BreezSdkSpark.PaymentStatus) -> BreezSdkSpark.Payment { + Payment( + id: txId, + paymentType: .send, + status: status, + amount: 20000, + fees: 300, + timestamp: 1, + method: .withdraw, + details: .withdraw(txId: txId, vout: 1, destinationAddress: "bcrt1qcontact"), + conversionDetails: nil + ) + } + + private static func output(txId: String, confirmations: UInt32) -> AccountUtxo { + AccountUtxo( + txid: txId, + vout: 1, + amount: 20000, + blockHeight: confirmations > 0 ? 100 : 0, + address: "bcrt1qcontact", + path: "", + confirmations: confirmations, + coinbase: false, + own: false, + required: nil + ) + } + + @MainActor + private func waitUntilPriced(_ transfer: TransferViewModel) async { + for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { + try? await Task.sleep(nanoseconds: 20_000_000) + } + } +} + +private actor PriceCalls { + private(set) var addresses: [String] = [] + + func record(_ address: String) { + addresses.append(address) + } +} + +private actor PriceGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters = [] + } +} + +@MainActor +private final class WithdrawnFee { + var feeSats: UInt64? +} + +private actor TxIds { + private(set) var ids: [String] = [] + + func insert(_ id: String) { + ids.append(id) + } +} diff --git a/BitkitTests/TransferNavigationTests.swift b/BitkitTests/TransferNavigationTests.swift new file mode 100644 index 000000000..30b44221b --- /dev/null +++ b/BitkitTests/TransferNavigationTests.swift @@ -0,0 +1,37 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class TransferNavigationTests: XCTestCase { + private let amountPath: [Route] = [.transferIntro, .fundingOptions, .spendingIntro, .spendingAmount] + + func testContinueOpensConfirmWhileAmountIsShown() { + let navigation = NavigationViewModel() + navigation.path = amountPath + + navigation.navigate(.spendingConfirm, from: amountPath) + + XCTAssertEqual(navigation.path, amountPath + [.spendingConfirm]) + } + + func testQuoteFinishingAfterBackKeepsTransferClosed() { + let navigation = NavigationViewModel() + navigation.path = amountPath + + // Back while the quote loads, then the quote returns. + navigation.navigateBack() + navigation.navigate(.spendingConfirm, from: amountPath) + + XCTAssertEqual(navigation.path, Array(amountPath.dropLast())) + } + + func testQuoteFinishingAfterLeavingTransferKeepsItClosed() { + let navigation = NavigationViewModel() + navigation.path = amountPath + + navigation.reset() + navigation.navigate(.spendingHwSign(walletId: "wallet"), from: amountPath) + + XCTAssertEqual(navigation.path, []) + } +} diff --git a/Docs/RELEASE.md b/Docs/RELEASE.md index 37f3c97c6..0000d92de 100644 --- a/Docs/RELEASE.md +++ b/Docs/RELEASE.md @@ -127,6 +127,8 @@ Apply the shared [store release-note checks](https://github.com/synonymdev/bitki ## 2. Build and Upload to TestFlight +Before archiving, make sure `Secrets.xcconfig` exists at the repo root with `BREEZ_API_KEY` set (copy `Secrets.xcconfig.template`; ask the team for the key). Release builds fail without it, so Spark can't ship switched off. + ### Option A: Manual (Xcode) 1. Open `Bitkit.xcodeproj` in Xcode diff --git a/README.md b/README.md index a48ce0de3..93bd19467 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ The app automatically selects the network based on the build configuration: - **Debug builds** → Uses **Regtest** network (for local development and testing) - **Release builds** → Uses **Bitcoin Mainnet** network (for production) +### Build secrets + +Spark (Lightning without channels) needs a Breez API key on mainnet. Copy `Secrets.xcconfig.template` to `Secrets.xcconfig` (gitignored) and paste the key after `BREEZ_API_KEY =`. Without it the app builds and Spark stays off on mainnet; regtest needs no key. + ### Building for E2E tests To produce an E2E build (uses the local Electrum backend by default), pass the `E2E_BUILD` compilation flag: diff --git a/Secrets.xcconfig.template b/Secrets.xcconfig.template new file mode 100644 index 000000000..8c39d559c --- /dev/null +++ b/Secrets.xcconfig.template @@ -0,0 +1,3 @@ +// Copy to Secrets.xcconfig (gitignored) and paste the values. +// Breez SDK API key: enables Spark on mainnet builds; regtest needs none. +BREEZ_API_KEY = diff --git a/changelog.d/next/1128.added.md b/changelog.d/next/1128.added.md new file mode 100644 index 000000000..e1e6a1d79 --- /dev/null +++ b/changelog.d/next/1128.added.md @@ -0,0 +1 @@ +Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments. diff --git a/changelog.d/next/1191.added.md b/changelog.d/next/1191.added.md new file mode 100644 index 000000000..c908dc605 --- /dev/null +++ b/changelog.d/next/1191.added.md @@ -0,0 +1 @@ +Paykit contacts who only accept on-chain bitcoin can be paid from Spark. diff --git a/changelog.d/next/1206.added.md b/changelog.d/next/1206.added.md new file mode 100644 index 000000000..80633ba9b --- /dev/null +++ b/changelog.d/next/1206.added.md @@ -0,0 +1 @@ +Move to Spark: move a Lightning connection's balance to Spark, then close the emptied connection to return its reserve to Savings. diff --git a/changelog.d/next/1217.added.md b/changelog.d/next/1217.added.md new file mode 100644 index 000000000..ebd705d08 --- /dev/null +++ b/changelog.d/next/1217.added.md @@ -0,0 +1 @@ +Add Emergency exit, which moves your Spark balance to savings on-chain without the Spark operators. diff --git a/changelog.d/next/1256.added.md b/changelog.d/next/1256.added.md new file mode 100644 index 000000000..5e84c2054 --- /dev/null +++ b/changelog.d/next/1256.added.md @@ -0,0 +1 @@ +App Status shows Spark's connection, and Lightning Connection no longer reports an error while Spark carries Lightning payments. diff --git a/changelog.d/next/1257.added.md b/changelog.d/next/1257.added.md new file mode 100644 index 000000000..37aff52e5 --- /dev/null +++ b/changelog.d/next/1257.added.md @@ -0,0 +1 @@ +App Status shows an emergency exit while it runs. diff --git a/journeys/README.md b/journeys/README.md index bddcc99d8..e433bdd66 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -195,13 +195,14 @@ Known naming differences: | Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` (the same tag on every row) | `PubkyChoiceRingLookup_` (bare z32 key) | | Contact import overview profile and friend count | — *(no tag; read the "Import" title and the "N friends" text)* | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | | Lightning Connections add connection | `add_connection_button` | `NavigationAction` | +| Send contact row | `SendContact_` | `SendContact-` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one subscription carries the same `paymentRequestId` and they can list together — Android stops at the id and lets those rows collide. Match on the prefix when a journey needs to work on both platforms. -Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `TransferUnavailable*`, `Fund*`, +Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount*`, `TransferUnavailable*`, `Fund*`, `External*`, `Hardware*`, `Widget*` — matches Android exactly. ## Capabilities @@ -219,6 +220,8 @@ journey PR, which is what made this file conflict on every merge. | USDT wallet navigation without funding | an onboarded build with USDT configured; wallet syncing remains silent while offline; payment-action errors remain available — [USDT QA](../Docs/USDT-QA.md) | | Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) | | On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) | +| Spark connected (App Status Spark row, spending through Spark, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key. Transfers from regtest Savings also need the build launched with `SPARK_CHAIN_SERVICE_URL` set to the regtest's Esplora URL, as Spark's default regtest is its own chain; without it they show the Transfer Unavailable sheet. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [app status](settings/app-status-spark-row.xml), [transfer](transfer/spark-transfer-to-spending.xml) | +| Spark emergency exit up to the swipe | a Debug build with Spark connected on its regtest and a Spark balance from Lightspark's regtest faucet; the states after the swipe take days of blocks, so they are covered by unit tests — [transfer](transfer/spark-emergency-exit.xml) | | Lightning channels | a channel opened through Manual Setup to the node of the LSP the `E2E_BUILD` app targets, as an external LN peer — [Backend preconditions](#backend-preconditions) | | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) | | The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild | diff --git a/journeys/amount-limits/README.md b/journeys/amount-limits/README.md index ac906a37a..331c23c64 100644 --- a/journeys/amount-limits/README.md +++ b/journeys/amount-limits/README.md @@ -2,7 +2,10 @@ These journeys exercise the "block number pad input exceeding the max/available amount" behaviour. The same `AmountInputViewModel` cap + `maxExceededCount` effect path backs the amount-entry screens -for Send and the External node (Manual Setup) channel. +for Send, Transfer→Spending and the External node (Manual Setup) channel. + +The hardware Transfer To Spending amount shares that path. It needs a paired device, so its journey +lives in [`../hardware-wallet/transfer-to-spending-over-max.xml`](../hardware-wallet/transfer-to-spending-over-max.xml). `custom-fee-wallet-switch.xml` covers adjacent send preparation: a custom fee switches Spending to Savings, with manual or automatic coin selection after returning through the fee picker. It requires @@ -35,7 +38,12 @@ preparation. Do not submit a payment. then `../bitkit-android/lsp POST /regtest/chain/mine '{"count":3}'`. `mine` prints nothing on success — that is not a failure. There is no iOS copy of that helper yet — see #694 and the top-level README. -2. **The external-node flow needs a reachable Lightning peer.** The staging LSP node works +2. **The Transfer→Spending flow needs Spark connected** (a dev build reaches Spark's regtest with no + key); otherwise the transfer opens the Transfer Unavailable sheet. Its maximum is the Savings + balance less the fast-rate fee for sending all of it to the Spark deposit address, so it loads + behind a spinner — wait for it before typing. `transfer-spending-over-max.xml` has not been walked + on the Spark flow yet. +3. **The external-node flow needs a reachable Lightning peer.** The staging LSP node works (`../bitkit-android/lsp GET /info` for its id/host/port). ## Gotchas @@ -45,7 +53,8 @@ preparation. Do not submit a payment. an over-max state, so the capped value is valid and Continue stays **enabled**. - Toasts are short-lived (`Toast.visibilityTimeShort`) and will have faded before a snapshot round-trip completes. Assert one with `xcodebuildmcp ui-automation wait-for-ui` on its identifier - (`SendAmountExceededToast`, `ExternalAmountExceededToast`) immediately after the over-max keypress. + (`SendAmountExceededToast`, `SpendingAmountExceededToast`, `ExternalAmountExceededToast`) immediately + after the over-max keypress. - Prefer `snapshot-ui` and tap elements by identifier (`N9`, `NRemove`) over screen coordinates. ## Verified behaviour @@ -66,6 +75,9 @@ snapshot round-trip returns — race a `wait-for-ui` on the toast identifier aga - Send: screen `SendAmount`, field `SendNumberField`, available `AvailableAmount`, continue `ContinueAmount`; recipient `RecipientManual` / `RecipientInput` / `AddressContinue`; home Send button `Send`. +- Transfer→Spending: screen `SpendingAmount`, field `SpendingAmountNumberField`, + available `SpendingAmountAvailable` (or `SpendingAmountRetry` when the fee could not be priced), + 25% `SpendingAmountQuarter`, max `SpendingAmountMax`, continue `SpendingAmountContinue`. - External: funding `FundManual`; connection `NodeIdInput`/`HostInput`/`PortInput`/`ExternalContinue`; amount screen `ExternalAmount`, field `ExternalAmountNumberField`, available `ExternalAmountAvailable`, 25% `ExternalAmountQuarter`, max `ExternalAmountMax`, diff --git a/journeys/amount-limits/transfer-spending-over-max.xml b/journeys/amount-limits/transfer-spending-over-max.xml new file mode 100644 index 000000000..785228591 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-over-max.xml @@ -0,0 +1,31 @@ + + + Verifies the "Transfer to Spending" amount number pad blocks input exceeding the maximum allowed + transfer amount, shows the "Spending Balance Maximum" warning toast, and still allows deleting + digits while at the cap. + + Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and Spark connected, on a + regtest build launched with SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL (Spark's default + regtest is its own chain, so without it the transfer shows the Transfer Unavailable sheet), so the + transfer opens the amount screen. The maximum is the Savings balance less the fast-rate network fee for + sending all of it to the Spark deposit address; it loads behind a spinner — wait for it to populate. + Start on the wallet home screen. + + Enter via the Savings card, not the Spending card: the Spending screen only offers + "Transfer From Savings" while the spending balance is zero, and shows "Transfer To Savings" + once a balance exists. The Savings card's "Transfer To Spending" reaches the same screen in + both states. + + + Tap the Savings balance card on the home screen (id "ActivitySavings") + Tap "Transfer To Spending" (id "TransferToSpending") + If a transfer intro screen appears, tap "Get Started" + Verify the spending amount screen (id "SpendingAmount") is visible + Wait until the available/maximum amount (id "SpendingAmountAvailable") finishes loading and shows a positive value + Tap the "9" key (id "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears (id "SpendingAmountExceededToast") + Verify the amount in the input field (id "SpendingAmountNumberField") does not exceed the stated maximum + Tap the delete key (id "NRemove") once + Verify the amount in the input field (id "SpendingAmountNumberField") decreased after the delete + + diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 8b70b600e..343896f07 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -1,7 +1,9 @@ # Hardware wallet journeys Cover pairing a Trezor, the home tile and detail screen, the Hardware Wallets settings surface, -passphrase (hidden) wallets, wallet-scoped activity, and the Transfer To Spending button, which opens the Transfer Unavailable sheet. +passphrase (hidden) wallets, wallet-scoped activity, and the watch-only Transfer To Spending flow, +which pays the Spark deposit address from the device. It needs Spark connected on a regtest build launched with +`SPARK_CHAIN_SERVICE_URL` set to the regtest's Esplora URL; otherwise it shows the Transfer Unavailable sheet. ## Transport: Bridge, not USB @@ -51,9 +53,10 @@ Several journeys mutate pairing state. Run them in this order, or re-pair betwee 1. `connect-home-tile.xml` — pairs the emulator; every other journey assumes it ran. 2. `settings-hardware-wallets.xml`, `connect-flow.xml`, `suggestion-intro-sheet.xml` — each forgets and re-pairs the device, ending paired. -3. `activity-blue-icons.xml`, `activity-detail-hw-tags.xml`, `reconnect.xml`, `send-onchain.xml`, - `scan-onchain-amount-over-spendable.xml`. -4. `passphrase-pairing.xml` → `passphrase-duplicate.xml` → `passphrase-settings-remove.xml`. +3. `activity-blue-icons.xml`, `activity-detail-hw-tags.xml`, `transfer-to-spending*.xml`, `reconnect.xml`, + `send-onchain.xml`, `scan-onchain-amount-over-spendable.xml`. +4. `passphrase-pairing.xml` → `passphrase-duplicate.xml` → `passphrase-transfer-to-spending.xml` → + `passphrase-settings-remove.xml`. 5. `detail-overview.xml` **last** — its final step forgets the device. ## Approving prompts on the emulator @@ -108,8 +111,9 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities `HardwareWalletRowName` has **no** separator, while `HardwareWalletRowDelete_` and `HardwareWalletRow_` use an underscore. The `` is the full `trezor:` device id. - Confirmed resolving without the emulator: `HardwareWalletsSettings`, `HardwareWalletsScreen`, - `AddHardwareWallet`, `HardwareWalletIntroScreen`/`Continue`/`Cancel`, `HardwareWalletScreen` and - `HardwareTransferToSpending`. + `AddHardwareWallet`, `HardwareWalletIntroScreen`/`Continue`/`Cancel`, `HardwareWalletScreen`, + `HardwareTransferToSpending`, `HardwareTransferAmount` and its Available/25%/MAX/Continue controls, + and the number pad keys. ## Identifiers used - Dev Trezor screen: `TrezorScanButton`, `TrezorDevice-bridge`, `TrezorKnownDeviceConnect-bridge`, @@ -127,7 +131,12 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities `RenameHardwareWalletInput`, `RenameHardwareWalletSave`. - Home and detail: tile `ActivityHardware`, screen `HardwareWalletScreen`, `RemoveHardwareWallet`, `RemoveHwWalletDialog`, `HwRemoveKeepBackupToggle`. -- Transfer: `HardwareTransferToSpending` (opens the Transfer Unavailable sheet). +- Transfer: `HardwareTransferToSpending`, `HardwareTransferAmount`, + `HardwareTransferAmountAvailable`/`Quarter`/`Max`/`Continue`/`Retry`, + over-max toast `HardwareTransferAmountExceededToast`, `HardwareTransferSign`, + `HardwareTransferSignLearnMore`, speed rows `SpendingConfirmInstant`/`SpendingConfirmLater`, + `HardwareTransferOpenTrezorConnect`, `HardwareTransferSigned`, + `HwTransferPassphraseSheet`/`Input`/`Continue`/`Cancel`. - Activity: `ActivityShowAll` (home only). Rows use **two different schemes** — `ActivityShort-` in the home recent list, `Activity-` in All Activity and on the hardware wallet screen. Tabs `Tab-all`/`Tab-sent`/`Tab-received`/`Tab-other`, diff --git a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml new file mode 100644 index 000000000..a77e738b5 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -0,0 +1,38 @@ + + + Drives Transfer To Spending from a passphrase (hidden) wallet. While the Trezor session that + holds the passphrase is still open the transfer signs straight away; after the session is + dropped Bitkit asks for the passphrase again, and a wrong one is refused instead of signing + from whichever wallet the device happens to have open. Requires the emulator started with + passphrase protection enabled, the hidden wallet from passphrase-pairing.xml paired, and its + native-segwit account holding spendable regtest funds, and Spark connected on a regtest build launched + with SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL (otherwise the Transfer Unavailable sheet + opens). + + + Launch the Bitkit app and go to the wallet home screen + Tap the hidden wallet tile ("Hidden Trezor") and verify its detail screen opens (id "HardwareWalletScreen") + Tap "Transfer To Spending" (id "HardwareTransferToSpending"), and if the first-run intro is shown tap "Get Started" + Tap the "25%" quick button (id "HardwareTransferAmountQuarter"), then "Continue" (id "HardwareTransferAmountContinue") and wait for the fee estimate + Verify the sign screen opens (id "HardwareTransferSign") titled "SIGN WITH YOUR DEVICE" + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect") and verify no passphrase sheet appears, because the session opened at pairing still holds the hidden wallet + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order, and verify the transaction signed screen appears (id "HardwareTransferSigned") + Wait for the Funds In Transfer screen (id "LightningSettingUp"), tap "Continue Using Bitkit", and verify the app returns to the wallet home screen + Force-quit Bitkit: xcodebuildmcp simulator stop --bundle-id to.bitkit + Relaunch it: xcodebuildmcp simulator launch-app --bundle-id to.bitkit + Once the app is back on the home screen, open the hidden wallet tile and start Transfer To Spending again, setting an amount with the "25%" quick button and continuing to the sign screen + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect") and verify the passphrase sheet opens (id "HwTransferPassphraseSheet"), because the session that held the passphrase is gone + Type a wrong passphrase "not-the-one" into the input (id "HwTransferPassphraseInput") and tap "Continue" (id "HwTransferPassphraseContinue") + + Confirm on the emulated device for every account the passphrase session reads: the device + prompts once per address type and the call blocks until it is acknowledged. Either tap the + emulator UI or run: ../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes","id":1}' + + Verify an error toast says the passphrase opens a different wallet, that no signing prompt was shown on the emulator, and that no new transfer appears in the activity list + Navigate to the wallet home screen and verify the hardware tile count is unchanged: the wallet the wrong passphrase opened must not be added + Reopen the sign screen, tap "Open Trezor Connect", type the correct passphrase "bitkit-hidden" and tap "Continue" + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator, and verify the transaction signed screen appears (id "HardwareTransferSigned") + Scan the app group for both passphrases, treating only "no match" as clean: UDID=<the simulator this journey is running on>; GROUP=$(xcrun simctl get_app_container "$UDID" to.bitkit groups | awk '{print $2}'); [ -d "$GROUP" ] || { echo "LEAK_CHECK_INVALID: container not found"; exit 1; }; grep -rl -e "bitkit-hidden" -e "not-the-one" "$GROUP"; s=$?; case $s in 1) echo NO_PASSPHRASE_LEAK;; 0) echo "LEAK FOUND";; *) echo "LEAK_CHECK_INVALID: grep exit $s";; esac + Verify the previous command printed NO_PASSPHRASE_LEAK. LEAK_CHECK_INVALID means the check did not scan anything and the journey cannot conclude either way — treat it as a failed step, not a pass + + diff --git a/journeys/hardware-wallet/transfer-to-spending-over-max.xml b/journeys/hardware-wallet/transfer-to-spending-over-max.xml new file mode 100644 index 000000000..49feb667f --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-over-max.xml @@ -0,0 +1,40 @@ + + + Verifies the hardware Transfer To Spending amount number pad enforces the transferable maximum: + an entry above it snaps down to the maximum with the "Spending Balance Maximum" warning toast, + and deleting digits still works while at the cap. + + iOS-only — there is no counterpart under `bitkit-android/journeys/hardware-wallet`. It was added + alongside the fix for the bug it covers. `SpendingAmountHw` observed `maxAllowed` without + `initial: true`, and since that value lives on the shared `TransferViewModel` and outlives the + screen, arriving with the limits already computed produced no change to observe. The freshly + created `AmountInputViewModel` then kept `maxAmountOverride` nil, leaving the pad bounded only by + the global 999 999 999 sat ceiling. Measured on an iPhone 17 simulator: nine taps on `N9` entered + 999 999 999 against an AVAILABLE of 610 015, with no toast and no snap. Continue stayed disabled, + so nothing unaffordable could be ordered — the gap was in the input, not in the order. + + Precondition: Spark connected on a regtest build launched with SPARK_CHAIN_SERVICE_URL set to the + regtest's Esplora URL (otherwise the Transfer Unavailable sheet opens), and a paired Bridge + emulator whose native-segwit account holds spendable regtest funds. Start on the wallet home screen. + + The screen is entered twice on purpose. A first entry that computes the limits from zero fires + the change either way, so it passes even with the bug; the second entry, where the limits are + already settled, is the one that regressed. + + + Tap the hardware wallet tile (id "ActivityHardware") beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (id "HardwareWalletScreen") + Tap the "Transfer To Spending" button (id "HardwareTransferToSpending") + If the first-run Transfer To Spending intro is shown, tap "Get Started" + Verify the transfer amount screen opens (id "HardwareTransferAmount") + Wait for the limits to load (id "N9", predicate enabled) — the number pad is held disabled behind the AVAILABLE spinner until the device balance and the fee to the Spark deposit address return + Verify the AVAILABLE amount (id "HardwareTransferAmountAvailable") shows a positive value + Tap the "9" key (id "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears (id "HardwareTransferAmountExceededToast") and names the maximum + Verify the amount in the input field snapped to the maximum and does not exceed the AVAILABLE amount — 999 999 999 here is the regression this journey exists for + Tap the delete key (id "NRemove") once, and verify the amount decreased + Tap back (id "NavigationBack") to return to the hardware wallet screen + Tap "Transfer To Spending" (id "HardwareTransferToSpending") again — this second entry arrives with the limits already computed, which is the case that regressed + Wait for the number pad (id "N9", predicate enabled), then tap the "9" key nine times again + Verify the toast appears again (id "HardwareTransferAmountExceededToast") and the amount snapped to the same maximum, not to 999 999 999 + + diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml new file mode 100644 index 000000000..0229ba78f --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -0,0 +1,29 @@ + + + Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With + Your Device -> Transaction Signed -> Funds In Transfer, then verifies the new transfer is + represented once in the hardware wallet's Core activity scope. The device pays the Spark deposit + address. Requires Spark connected on a regtest build launched with SPARK_CHAIN_SERVICE_URL set to the + regtest's Esplora URL (otherwise the Transfer Unavailable sheet opens), a paired + Bridge emulator whose native-segwit account holds spendable regtest funds, and at least one older + hardware receive row with a distinct amount. + + + Launch the Bitkit app and go to the wallet home screen + Verify the recent activity list contains an older blue hardware received row with a distinct amount + Tap the hardware wallet tile (id "ActivityHardware") beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (id "HardwareWalletScreen") + Tap the "Transfer To Spending" button (id "HardwareTransferToSpending") + If the first-run Transfer To Spending intro is shown, tap "Get Started" + Verify the transfer amount screen opens (id "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row (id "HardwareTransferAmountAvailable"), the 25% and MAX quick buttons, and a number pad + Tap the "25%" quick button (id "HardwareTransferAmountQuarter") to set a valid amount below the available limit + Tap "Continue" (id "HardwareTransferAmountContinue") and wait for the fee estimate + Verify the sign screen opens (id "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SPENDING FEE, TO SPENDING and TOTAL cells, the Instant and Later rows with Instant selected, the Learn More button, and the Trezor illustration + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect"). If fees rose since the screen priced them, verify a "Fees changed" toast and refreshed fee cells, and tap "Open Trezor Connect" again; otherwise signing starts immediately + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order + Verify the transaction signed screen appears (id "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration + Wait for the screen to auto-forward and verify the Funds In Transfer screen appears (id "LightningSettingUp") + Tap "Continue Using Bitkit" (id "TransferSuccess-button") and verify the app returns to the wallet home screen + Verify the recent activity list contains exactly one new blue hardware Transfer row, above the older blue received row + Tap the new blue Transfer row and verify its detail screen shows "TO SPENDING" + + diff --git a/journeys/paykit-spark-route.xml b/journeys/paykit-spark-route.xml new file mode 100644 index 000000000..2ed0eaa9a --- /dev/null +++ b/journeys/paykit-spark-route.xml @@ -0,0 +1,27 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet whose spending balance is held + on Spark, routes through a Spark withdrawal straight to the contact's address, without the user picking a route, + and that the contact receives the exact amount on chain. + + Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, + E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver; bitkit-docker's default stack with the marketplace and payment-requests + profiles. The simulator reaches the host's loopback directly, so it needs no port forwarding. + The wallet has no channels and an empty savings balance, a Spark balance of at least 25 000 sats from Lightspark's + regtest faucet sent to the deposit address on the Spark screen, a Pubky profile on the local homeserver, and the + fixture-issuer's pubky saved as a contact. The contact is bitkit-docker's fixture-issuer, which publishes only a + btc-regtest-p2wpkh endpoint; Spark pays it on Lightspark's regtest chain. + + + Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 + Tap Send (tag "Send"), then Contact (tag "RecipientContact") + Tap the fixture-issuer (tag "SendContact-" followed by its pubky) + Verify the amount screen (tag "SendAmount") offers SPENDING as the source + Enter 20000 and tap Continue (tag "ContinueAmount") + Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") + Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Spark" + Swipe to pay (tag "GRAB") + Verify the Bitcoin Sent screen (tag "SendSuccess") shows 20 000 + Verify Lightspark's regtest mempool shows the withdrawal transaction paying exactly 20 000 sats to the fixture-issuer's address + + diff --git a/journeys/settings/app-status-emergency-exit.xml b/journeys/settings/app-status-emergency-exit.xml new file mode 100644 index 000000000..9b443c538 --- /dev/null +++ b/journeys/settings/app-status-emergency-exit.xml @@ -0,0 +1,24 @@ + + + While an emergency exit is active, App Status shows an "Emergency Exit" row under Lightning Connection. + It reads "Fee funding needed" while the exit asks for fee funding and "In progress" otherwise, including + while a payment that covers the funding waits to confirm. The row opens the exit's progress screen. With + no active exit the row is absent. An exit paid from savings cannot be cancelled and runs for days, + so the row's removal once an exit ends is covered by unit tests, not here. On iOS, OK on the progress + screen returns to the wallet home screen from any entry point, App Status included. An exit takes Spark off the + Lightning rail, so with no usable channel Lightning Connection stops reading "Using Spark" while it runs. + + Precondition: Debug build on regtest with Spark connected, a Spark balance and savings that can pay the + exit's fee funding. The wallet has no Lightning channel. Start an emergency exit from Spark (spark emergency exit journey), pick "Pay from + savings" (id "SparkExitFundSavings") and swipe to start it. Starting an exit takes the Spark balance out of + Spending for days, so use a throwaway wallet. Start on the wallet home screen. + + + Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") + Verify a row titled "Emergency Exit" (id "Status-spark_exit") sits under Lightning Connection (id "Status-lightning_connection") and reads "In progress", since savings paid the funding + Verify Lightning Connection (id "Status-lightning_connection") reads "No open connections", not "Using Spark" + Tap the row and verify the exit's progress screen opens + Tap "OK" (id "SparkExitProgressOk") and verify the wallet home screen shows + Open App Status again from the menu and verify the "Emergency Exit" row is still there + + diff --git a/journeys/settings/app-status-spark-row.xml b/journeys/settings/app-status-spark-row.xml new file mode 100644 index 000000000..4e3f721b2 --- /dev/null +++ b/journeys/settings/app-status-spark-row.xml @@ -0,0 +1,18 @@ + + + App Status shows a Spark row under Lightning Connection on builds where Spark is supported. A Debug build + on regtest connects Spark to its hosted regtest with no key (journeys/README.md capabilities), so with + no channel the row reads Connected and Lightning Connection reads "Using Spark". Once Connected, the + row opens Spark's detail screen. Connecting and Offline are covered by unit tests and the PR's + screenshots. If the row never reads Connected within 2 minutes, the run is blocked on the hosted + regtest, not failed. + + Precondition: onboarded Debug build wallet with no channel, start on the wallet home screen. + + + Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") + Verify a row titled "Spark" (id "Status-spark") sits directly under Lightning Connection (id "Status-lightning_connection") and reads "Connected" within 2 minutes + Verify Lightning Connection reads "Using Spark" + Tap the Spark row and verify Spark's detail screen opens + + diff --git a/journeys/transfer/move-to-spark.xml b/journeys/transfer/move-to-spark.xml new file mode 100644 index 000000000..2379e4c49 --- /dev/null +++ b/journeys/transfer/move-to-spark.xml @@ -0,0 +1,29 @@ + + + Verifies the post-update "Move to Spark" sheet and the move itself. With an LDK channel that can send at least + 1,000 sats and Spark connected, Home shows the "Move To Spark" sheet once (testTag "MoveToSparkIntro"); Spending + and Lightning Connections show a "Move To Spark" button (testTag "MoveToSpark") while the channel can still send. + The move quotes the routing fee over the channel only, pays the amount to a new invoice of the user's own Spark + wallet, and then offers to close the emptied connection. A wallet without such a channel never gets the sheet or + the button. + + The "too large to route" state (testTag "MoveToSparkTrySmaller") needs a route that cannot carry the balance; on a + direct channel to the Spark service provider every amount routes, so it is covered by the quote's unit tests and the + PR's manual test rather than here. + + Precondition: dev build pointed at a local Spark regtest (spark-sdk regtest/local), and a wallet with an open + channel to that stack's SSP Lightning node that holds most of its capacity on the wallet's side (open it from + Lightning Connections, Manual Setup, with the node URI the stack prints). Start on the wallet home screen with the + app freshly launched. + + + Wait on Home and verify the "Move To Spark" sheet (testTag "MoveToSparkIntro") shows, saying Blocktank is closing its Lightning connections + Tap "Later" (testTag "MoveToSparkIntroLater") and verify the sheet closes + Relaunch the app, wait on Home and verify the sheet does not show again + Open the Spending wallet and verify the "Move To Spark" button (testTag "MoveToSpark") shows + Tap "Move To Spark" and verify the screen shows an amount (testTag "MoveToSparkAmount") and a routing fee (testTag "MoveToSparkFee") + Swipe to move (testTag "MoveToSparkSwipe") and verify "Moved to Spark" shows the moved amount (testTag "MoveToSparkMoved") and "Close" (testTag "MoveToSparkClose") + Tap "Close" (testTag "MoveToSparkClose"), then "Close" on the close confirmation, and verify Lightning Connections shows no open connection and the Spark balance grew by the moved amount + Go back to Home, open Spending and verify the "Move To Spark" button is gone + + diff --git a/journeys/transfer/savings-select-funds.xml b/journeys/transfer/savings-select-funds.xml new file mode 100644 index 000000000..c43c0cc1c --- /dev/null +++ b/journeys/transfer/savings-select-funds.xml @@ -0,0 +1,36 @@ + + + Verifies that Transfer to Savings lets the user pick what moves: each open channel and Spark are + rows on the Select funds list, all selected by default, and the confirm screen shows only what is + selected, with Spark's withdrawal fee only while Spark is selected. With a single item (Spark + alone, or one channel without a Spark balance) the confirm screen shows no Advanced button and + moves everything, as before Spark. + + Precondition: onboarded Debug build with a Spark balance and at least one open channel. On iOS the + two live on different regtest chains: open the channels through Manual Setup to Blocktank's staging + regtest node, funded from Savings, and fund Spark from Lightspark's web regtest faucet + (https://app.lightspark.com/regtest-faucet) to the address on Settings › Advanced › Lightning + Connections › Spark; Spark adds it to Spending within a few minutes. Start on the wallet home + screen. The journey stops before the swipe. Spark prices its fee on the confirm screen, so allow + about 20 seconds for "Spark fee" to show. + + Adapted from Android: a picked selection shows "Transfer all" in place of "Advanced" on both + platforms, so reaching the list again takes "Transfer all" first. + + + Tap the Spending balance card (testTag "ActivitySpending") + Tap "Transfer To Savings" (testTag "TransferToSavings") + If the transfer to savings intro appears, tap "Get Started" (testTag "SavingsIntro-button") + Tap "Continue" (testTag "AvailabilityContinue") on the funds availability screen + On the confirm screen, verify the amount is the channels' balance plus the Spark balance, a "Spark fee" amount shows, and an "Advanced" button shows + Tap "Advanced" + Verify the "Select funds to transfer" screen lists a "Spark" row with the Spark balance and one "Connection N" row per open channel, every switch on, and "Total selected" equals the confirm amount + Turn off the last "Connection N" row and verify "Total selected" drops by that row's amount + Tap "Continue" and verify the confirm amount equals the new total, "Spark fee" still shows, and the button reads "Transfer all" + Tap "Transfer all", then "Advanced", turn off the "Spark" row and every "Connection N" row, and verify "Continue" is disabled + Turn on the "Spark" row only and tap "Continue" + Verify the confirm amount equals the Spark balance and "Spark fee" shows + Tap "Transfer all" and verify the amount returns to the channels' balance plus the Spark balance, and the button reads "Advanced" + Tap "Advanced", turn off the "Spark" row, tap "Continue", and verify the amount equals the channels' balance with no "Spark fee" + + diff --git a/journeys/transfer/spark-emergency-exit.xml b/journeys/transfer/spark-emergency-exit.xml new file mode 100644 index 000000000..a5b390195 --- /dev/null +++ b/journeys/transfer/spark-emergency-exit.xml @@ -0,0 +1,31 @@ + + + Verifies the Spark emergency exit up to the swipe. With a Spark balance, the Spark connection screen shows + "Emergency exit" (testTag "SparkEmergencyExit") under "Transfer to Savings". It opens the explainer, then the + quote, which asks the SDK's prepareRecoverFunds for every leaf and lists "Amount to exit", "Network fees", + "You receive about" and, when the exit needs CPFP fee funding, "Fee funding needed". Continue leads to the + funding screen, whose "Fund from another wallet" shows the funding address as a QR code. The journey stops before + the swipe: starting an exit takes the Spark balance out of Spending for days. The states after the swipe are + covered by SparkExitDriverTests, SparkExitBackgroundRunTests (a background run from a cold start), + SparkExitFeeBumpTests and SparkExitSweepTests. iOS shows the notes on Background App Refresh, Low Power Mode and + notifications in place of Android's battery notes. + + Precondition: dev build with Spark connected on its regtest (no key needed) and a Spark balance, funded at the + deposit address the Spark connection screen shows (testTag "SparkDepositAddress") from Lightspark's regtest + faucet. Start on the wallet home screen. + + + Tap the menu icon (testTag "HeaderMenu") + Tap Settings (testTag "DrawerSettings") + Tap the Advanced tab (testTag "Tab-advanced") + Tap Lightning Connections (testTag "Channels") + Tap the Spark connection (testTag "SparkConnection") + Scroll down and verify "Transfer to Savings" (testTag "SparkTransferToSavings") is enabled and "Emergency exit" (testTag "SparkEmergencyExit") shows under it + Tap "Emergency exit" and verify the "Leave Spark on-chain" explainer says the exit takes up to 4 weeks, runs in the background and that Bitkit notifies you if it needs you + Tap "Continue" (testTag "SparkExitIntroContinue") and verify the "Exit quote" screen shows "Getting a quote…" and then "Amount to exit", "Network fees" and "You receive about" + Verify no reason shows in red (testTag "SparkExitQuoteBlocked") and "Continue" (testTag "SparkExitQuoteContinue") is enabled + Tap "Continue" and verify the "Fund the fees" screen names the fee funding amount, with "Pay from savings" (testTag "SparkExitFundSavings") and "Fund from another wallet" (testTag "SparkExitFundExternal") + Tap "Fund from another wallet" and verify a QR code and a bcrt1 address (testTag "SparkExitFundingAddress") show, and "Continue" (testTag "SparkExitFundingContinue") is disabled + Go back to the Spark connection screen and verify "Transfer to Savings" is still enabled: nothing started + + diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml new file mode 100644 index 000000000..8e134e2df --- /dev/null +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -0,0 +1,32 @@ + + + Verifies the transfer to spending flow on Spark. With Spark ready, Funding leads with "Transfer From Savings" + (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") above "Manual Setup" (testTag + "FundManual") and an enabled "Transfer from HW wallet" (testTag "FundHardware"), which opens a paired hardware + wallet's transfer to spending, a funded one first, or the connect sheet when none is paired; and the Savings and + Spending transfer buttons open the spending intro, then the amount screen (testTag "SpendingAmount"), + instead of the Transfer Unavailable sheet. The confirm screen shows the network fee, the spending fee, what reaches + spending and the total, and a choice between Instant and Later. What happens after + the swipe depends on the deposit Spark sees and the provider's quote; the setting-up states are covered by the + previews and unit tests, so this journey stops before the swipe. + + Precondition: a build whose Spark reads Bitkit's chain (mainnet with a Breez key, or a regtest build launched with + SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL; Spark's default regtest is its own chain, so without it + the Savings transfer shows the Transfer Unavailable sheet), a freshly onboarded wallet with a positive on-chain Savings balance and no + channels. Start on the wallet home screen. + + + Tap the Savings balance card (testTag "ActivitySavings"), tap "Transfer To Spending" (testTag "TransferToSpending") and verify the spending intro (testTag "SpendingIntro") opens, not the Transfer Unavailable sheet + Tap "Get Started" (testTag "SpendingIntro-button") and verify the amount screen (testTag "SpendingAmount") opens with the available amount + Tap "25%" (testTag "SpendingAmountQuarter") and verify the amount is about a quarter of the Savings balance; tap "Max" (testTag "SpendingAmountMax") and verify the amount equals the available amount + Tap "Continue" (testTag "SpendingAmountContinue") and verify the confirm screen shows "Network fees", "Spending fee", "To spending" and "Total", with "Instant" (testTag "SpendingConfirmInstant") selected and "Later" (testTag "SpendingConfirmLater") below it + Verify "Spending fee" reads "±" an amount equal to the Instant row's fee, and "To spending" equals the amount less that fee + Tap "Later" and verify "Spending fee" shows "±" the Later row's fee and "To spending" grows by the difference, while "Network fees" and "Total" stay the same + Tap "Learn More" (testTag "SpendingConfirmMore"), verify the screen explains Instant and Later and shows a spending-only balance bar, then tap "Understood" (testTag "LiquidityContinue") + Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap "Transfer From Savings" (testTag "TransferFromSavings") and verify the amount screen opens without the intro + Go back to the wallet home, open Settings › Advanced › Lightning Connections and tap add connection (testTag "add_connection_button") + Verify "Transfer From Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual"), and "Transfer from HW wallet" (testTag "FundHardware") is enabled below it + With no hardware wallet paired, tap "Transfer from HW wallet" and verify the connect hardware wallet sheet opens; close it + Tap "Transfer From Savings" and verify the amount screen (testTag "SpendingAmount") opens + + diff --git a/journeys/transfer/spending-confirm-amount-change.xml b/journeys/transfer/spending-confirm-amount-change.xml new file mode 100644 index 000000000..7a47aca0f --- /dev/null +++ b/journeys/transfer/spending-confirm-amount-change.xml @@ -0,0 +1,38 @@ + + + Verifies that a transfer to spending never pays more than Spending Confirm shows. The screen prices + the network fee to the Spark deposit address when it opens, whenever the amount or the speed + changes, every minute while it stays open, and again at the swipe. A swipe whose fresh price costs + the same or less pays at once; one that costs more pays nothing: the screen shows the new amounts, a + "Fees changed" toast explains the increase, and the next swipe pays them. + + This journey covers the no-higher path and the re-quote after an amount change. A larger UTXO confirms + while Spending Confirm is open, so the transfer is priced at the swipe against a changed wallet. Coin + selection may keep two inputs (150 000 + 150 000, or 150 000 + 500 000) at the same fee, or spend the + new UTXO alone at a lower fee; either way the fresh price costs no more than the one shown, so the + swipe pays at once. The higher-total path (the "Fees changed" toast) needs fee rates to rise between + the quote and the swipe, which is not a listed capability. + + Precondition: a freshly onboarded Debug build on the staging regtest backend with Spark connected, launched with + SPARK_CHAIN_SERVICE_URL set to that regtest's Esplora URL (otherwise the transfer shows the Transfer Unavailable + sheet), no spending balance and exactly two confirmed on-chain UTXOs of 150 000 sats each (two + ../bitkit-android/lsp deposits to the wallet's savings address, each followed by a mine). Start on + the wallet home screen. + + + Tap Receive (id "Receive"), tap the "Savings" receive tab if one is shown, tap "Show Details" (id "ShowDetails"), read the address from id "ReceiveOnchainAddress", then close the sheet to return to the home screen + Tap the Savings balance card (id "ActivitySavings"), then "Transfer To Spending" (id "TransferToSpending") + If the spending intro appears, tap "Get Started" (id "SpendingIntro-button") + Enter 100 000 on the number pad (ids "N1", "N0", "N0", "N0", "N0", "N0") and tap "Continue" (id "SpendingAmountContinue") + Verify the confirm screen is visible (id "SpendingConfirm"), wait until its network fee is shown, and note "To spending" and "Total" + Tap back (id "NavigationBack"), delete one digit (id "NRemove") and enter "N2", "N0" so the amount reads 200 000, then tap "Continue" (id "SpendingAmountContinue") + Verify "To spending" and "Total" now reflect 200 000 rather than the noted values, and wait until the network fee is shown + Note the amounts under "Network fees" and "Total" + Run in one command: ../bitkit-android/lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":500000}' && ../bitkit-android/lsp POST /regtest/chain/mine '{"count":1}' + Wait 30s for the wallet to sync the new UTXO + If the received sheet (id "ReceivedTransaction") is visible, tap its button (id "ReceivedTransactionButton") + Verify the confirm screen is visible; "Network fees" and "Total" may already show lower amounts than noted + Swipe the "Swipe To Transfer" handle (id "GRAB") fully to the right + Verify no "Fees changed" toast appears and the setting up screen (id "LightningSettingUp") opens within 30s + + diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index a3ae0538a..0ca412412 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -8,7 +8,9 @@ (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning path shows the same sheet content. - Precondition: onboarded wallet with a positive on-chain Savings balance and no channels. Start on the + Precondition: onboarded wallet with a positive on-chain Savings balance, no channels and Spark + not running (a build without a Breez API key, or Spark's regtest unreachable); with Spark ready, see + spark-transfer-to-spending.xml. Start on the wallet home screen. @@ -18,7 +20,7 @@ Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." - Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer from Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer from Savings" and verify nothing opens + Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer From Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer From Savings" and verify nothing opens Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens blog.bitkit.to, then return to Bitkit and go back to the wallet home Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" Verify the screen titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider.", with a back button