From 0eed9c2a0939b663852be6d42f8cc0ade25146ad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 02:40:03 +0100 Subject: [PATCH 1/3] feat: add spark via breez sdk Spending keeps Lightning without channels through a Spark wallet: transfer to spending funds it from Savings or another wallet, Lightning payments and Paykit run on it, and transfer to savings picks channels, Spark or both. Breez data sync and the Lightning address server are off, and the Spark exit data is backed up. --- .agents/commands/release.md | 2 + .gitignore | 2 +- Bitkit.xcconfig | 4 + Bitkit.xcodeproj/project.pbxproj | 42 + .../xcshareddata/swiftpm/Package.resolved | 20 +- Bitkit/AppScene.swift | 31 + .../coin-stack-x.imageset/Contents.json | 21 + .../coin-stack-x.imageset/coin-stack-x.png | Bin 0 -> 71068 bytes Bitkit/Components/FeeDisplayRow.swift | 6 +- Bitkit/Components/FeeItem.swift | 62 +- Bitkit/Components/LightningChannel.swift | 106 ++- Bitkit/Components/ProgressSteps.swift | 65 ++ Bitkit/Components/Widgets/Suggestions.swift | 6 +- Bitkit/Constants/Env.swift | 19 + .../FixedWidthInteger+Saturating.swift | 5 + Bitkit/Info.plist | 2 + Bitkit/MainNavView.swift | 13 + Bitkit/Managers/SparkManager.swift | 738 ++++++++++++++++++ Bitkit/Models/BackupCategory.swift | 5 + Bitkit/Models/BackupPayloads.swift | 19 +- Bitkit/Models/SettingsBackupConfig.swift | 2 + .../Localization/ar.lproj/Localizable.strings | 65 ++ .../Localization/ca.lproj/Localizable.strings | 65 ++ .../Localization/cs.lproj/Localizable.strings | 65 ++ .../Localization/de.lproj/Localizable.strings | 65 ++ .../Localization/el.lproj/Localizable.strings | 65 ++ .../Localization/en.lproj/Localizable.strings | 65 ++ .../es-419.lproj/Localizable.strings | 65 ++ .../Localization/es.lproj/Localizable.strings | 65 ++ .../Localization/fr.lproj/Localizable.strings | 65 ++ .../Localization/it.lproj/Localizable.strings | 65 ++ .../Localization/nl.lproj/Localizable.strings | 65 ++ .../Localization/pl.lproj/Localizable.strings | 65 ++ .../pt-BR.lproj/Localizable.strings | 65 ++ .../Localization/pt.lproj/Localizable.strings | 65 ++ .../Localization/ru.lproj/Localizable.strings | 65 ++ Bitkit/Services/BackupService.swift | 40 + Bitkit/Services/CoreService.swift | 26 + Bitkit/Services/LightningService.swift | 131 +++- Bitkit/Services/PaykitAllowanceManager.swift | 3 +- .../Services/PaykitPaymentProofService.swift | 24 +- .../PaykitPaymentRequestService.swift | 2 +- .../PrivatePaykitService+Invoices.swift | 52 +- Bitkit/Utilities/AppStatusHelper.swift | 7 +- .../QuickPayPaymentCoordinator.swift | 6 +- Bitkit/Utilities/QuickPaySpendStore.swift | 11 + Bitkit/ViewModels/AppViewModel.swift | 27 +- Bitkit/ViewModels/NavigationViewModel.swift | 11 + Bitkit/ViewModels/SettingsViewModel.swift | 4 + Bitkit/ViewModels/SparkTransferFlow.swift | 322 ++++++++ Bitkit/ViewModels/TransferViewModel.swift | 216 ++++- Bitkit/ViewModels/WalletViewModel.swift | 68 +- .../CreatePaymentRequestView.swift | 2 +- .../Advanced/AdvancedSettingsView.swift | 3 +- .../Advanced/LightningConnectionsView.swift | 23 +- .../Advanced/SparkConnectionViews.swift | 250 ++++++ .../Settings/Security/DataBackupsScreen.swift | 9 +- .../CreateSubscriptionView.swift | 2 +- Bitkit/Views/Transfer/FundingOptions.swift | 61 +- .../Transfer/Hardware/SpendingAmountHw.swift | 177 +++++ .../Transfer/Hardware/SpendingHwSign.swift | 247 ++++++ .../Transfer/Hardware/SpendingHwSigned.swift | 54 ++ .../Views/Transfer/SavingsAdvancedView.swift | 72 +- .../Views/Transfer/SavingsConfirmView.swift | 67 +- .../Views/Transfer/SavingsProgressView.swift | 69 +- Bitkit/Views/Transfer/SettingUpView.swift | 178 +++++ Bitkit/Views/Transfer/SpendingAmount.swift | 209 +++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 286 +++++++ Bitkit/Views/Transfer/SpendingIntroView.swift | 40 + Bitkit/Views/Transfer/TransferIntroView.swift | 33 + .../Transfer/TransferLearnMoreView.swift | 64 ++ .../Views/Wallets/HardwareWalletScreen.swift | 8 +- .../Views/Wallets/Receive/ReceiveEdit.swift | 6 +- .../Views/Wallets/SavingsWalletScreen.swift | 8 +- .../Views/Wallets/Send/LnurlPayAmount.swift | 9 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 17 +- .../Views/Wallets/SpendingWalletScreen.swift | 10 +- BitkitTests/AppCacheDataTests.swift | 12 +- BitkitTests/ChannelFreeRailTests.swift | 177 +++++ .../PaykitPaymentProofServiceTests.swift | 2 + .../PaykitPaymentRequestServiceTests.swift | 34 + BitkitTests/SavingsSelectionTests.swift | 69 ++ BitkitTests/SavingsSwapTests.swift | 6 +- BitkitTests/SparkClaimFeeTests.swift | 10 + BitkitTests/SparkClaimOfferTests.swift | 55 ++ BitkitTests/SparkConfigTests.swift | 15 + BitkitTests/SparkExitBackupTests.swift | 51 ++ BitkitTests/SparkLightningSendTests.swift | 72 ++ BitkitTests/SparkTransferActivityTests.swift | 48 ++ BitkitTests/SparkTransferFlowTests.swift | 147 ++++ BitkitTests/SparkWithdrawalTests.swift | 116 +++ Docs/RELEASE.md | 2 + README.md | 4 + Secrets.xcconfig.template | 3 + changelog.d/next/1128.added.md | 1 + journeys/README.md | 3 +- journeys/amount-limits/README.md | 18 +- .../transfer-spending-over-max.xml | 30 + journeys/hardware-wallet/README.md | 21 +- .../passphrase-transfer-to-spending.xml | 36 + .../transfer-to-spending-over-max.xml | 39 + .../hardware-wallet/transfer-to-spending.xml | 28 + journeys/transfer/savings-select-funds.xml | 36 + .../transfer/spark-transfer-to-spending.xml | 27 + .../spending-confirm-amount-change.xml | 35 + .../transfer/transfer-unavailable-sheet.xml | 4 +- 106 files changed, 5898 insertions(+), 202 deletions(-) create mode 100644 Bitkit.xcconfig create mode 100644 Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png create mode 100644 Bitkit/Components/ProgressSteps.swift create mode 100644 Bitkit/Managers/SparkManager.swift create mode 100644 Bitkit/ViewModels/SparkTransferFlow.swift create mode 100644 Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift create mode 100644 Bitkit/Views/Transfer/SettingUpView.swift create mode 100644 Bitkit/Views/Transfer/SpendingAmount.swift create mode 100644 Bitkit/Views/Transfer/SpendingConfirm.swift create mode 100644 Bitkit/Views/Transfer/SpendingIntroView.swift create mode 100644 Bitkit/Views/Transfer/TransferIntroView.swift create mode 100644 Bitkit/Views/Transfer/TransferLearnMoreView.swift create mode 100644 BitkitTests/ChannelFreeRailTests.swift create mode 100644 BitkitTests/SavingsSelectionTests.swift create mode 100644 BitkitTests/SparkClaimFeeTests.swift create mode 100644 BitkitTests/SparkClaimOfferTests.swift create mode 100644 BitkitTests/SparkConfigTests.swift create mode 100644 BitkitTests/SparkExitBackupTests.swift create mode 100644 BitkitTests/SparkLightningSendTests.swift create mode 100644 BitkitTests/SparkTransferActivityTests.swift create mode 100644 BitkitTests/SparkTransferFlowTests.swift create mode 100644 BitkitTests/SparkWithdrawalTests.swift create mode 100644 Secrets.xcconfig.template create mode 100644 changelog.d/next/1128.added.md create mode 100644 journeys/amount-limits/transfer-spending-over-max.xml create mode 100644 journeys/hardware-wallet/passphrase-transfer-to-spending.xml create mode 100644 journeys/hardware-wallet/transfer-to-spending-over-max.xml create mode 100644 journeys/hardware-wallet/transfer-to-spending.xml create mode 100644 journeys/transfer/savings-select-funds.xml create mode 100644 journeys/transfer/spark-transfer-to-spending.xml create mode 100644 journeys/transfer/spending-confirm-amount-change.xml diff --git a/.agents/commands/release.md b/.agents/commands/release.md index f8a8cedab..d5b8cf3f7 100644 --- a/.agents/commands/release.md +++ b/.agents/commands/release.md @@ -300,6 +300,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 f83b181fa..507f9cb4e 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 */; }; @@ -74,6 +75,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; }; @@ -262,6 +264,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 */, @@ -302,6 +305,7 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */, 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, @@ -378,6 +382,7 @@ isa = PBXNativeTarget; buildConfigurationList = 96FE1F862C2DE6AC006D0C8B /* Build configuration list for PBXNativeTarget "Bitkit" */; buildPhases = ( + B17C0F1A2EAE000000000002 /* Check Breez API Key */, 96FE1F5D2C2DE6AA006D0C8B /* Sources */, 96FE1F5E2C2DE6AA006D0C8B /* Frameworks */, 96FE1F5F2C2DE6AA006D0C8B /* Resources */, @@ -402,6 +407,7 @@ 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, 182817C02F59A7F10055A441 /* Paykit */, + 72B5EE0000000000000000A2 /* BreezSdkSpark */, ); productName = Bitkit; productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */; @@ -511,6 +517,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 = ""; @@ -565,6 +572,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; @@ -944,6 +971,7 @@ }; 96FE1F872C2DE6AC006D0C8B /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -997,6 +1025,7 @@ }; 96FE1F882C2DE6AC006D0C8B /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1219,6 +1248,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.27.2; + }; + }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; @@ -1266,6 +1303,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 4a921d6b3..8401f1e44 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-rc11" } }, + { + "identity" : "breez-sdk-spark-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/breez/breez-sdk-spark-swift", + "state" : { + "revision" : "259f79b8572ed4a1df8b5f6fea64c6f2e534a65d", + "version" : "0.27.2" + } + }, { "identity" : "ldk-node", "kind" : "remoteSourceControl", @@ -37,6 +46,15 @@ "version" : "0.1.1-rc2" } }, + { + "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 88150906f..7ed764826 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, @@ -1799,6 +1809,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 @@ -1926,6 +1950,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 0000000000000000000000000000000000000000..62388ddf9535b82cf05b930d18adff6b00118302 GIT binary patch literal 71068 zcmbSycQBk^)c@*<9wd562oWuawt5gmkPt-gy|WgpM-aV+=%RN)v|TlbzF57ju*$Ap zR&THG``_=c-^_dFJa_JM?sGqL&gagYnS1VYBR^;;lMyoz0{{RrRTV`o000mD-}3

oKBS|gefI3>%a`1j@Czm;CR*Aj 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 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, + onPress: @escaping () -> Void + ) { + 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 { @@ -23,14 +65,14 @@ struct FeeItem: View { 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() @@ -42,6 +84,7 @@ struct FeeItem: View { unitType: .primary, size: .bodyMSB, symbol: true, + prefix: amountPrefix, color: isDisabled ? .gray3 : .textPrimary ) @@ -50,6 +93,7 @@ struct FeeItem: View { unitType: .secondary, size: .bodySSB, symbol: true, + prefix: amountPrefix, color: isDisabled ? .gray3 : .textSecondary ) } 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..2772d2e67 --- /dev/null +++ b/Bitkit/Components/ProgressSteps.swift @@ -0,0 +1,65 @@ +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 + + var body: some View { + VStack(spacing: 0) { + // Steps with circles and separators + GeometryReader { geometry in + ZStack(alignment: .center) { + // Dashed line background + Path { path in + let y = geometry.size.height / 2 + let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20) + path.move(to: CGPoint(x: padding, y: y)) + path.addLine(to: CGPoint(x: geometry.size.width - padding, 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, 16) + } + } + } + } + .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 90aed6ed0..4cdd9ea87 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -272,6 +272,25 @@ 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") + } + + /// 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..e6bac9837 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -32,6 +32,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 64da4682a..0b7cf1540 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,7 @@ struct MainNavView: View { NavigationStack(path: $navigation.path) { navigationContent } + .environment(sparkTransfer) .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } navigation.navigate(.createProfile) @@ -509,7 +511,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 +704,7 @@ struct MainNavView: View { case .addressTypePreference: AddressTypePreferenceView() case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) + case .sparkConnectionDetail: SparkConnectionDetailView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift new file mode 100644 index 000000000..332c17997 --- /dev/null +++ b/Bitkit/Managers/SparkManager.swift @@ -0,0 +1,738 @@ +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() + private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() + /// 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 + private(set) var balanceSats: UInt64 = 0 + /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. + private(set) var pendingDepositSats: UInt64 = 0 + 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 when Spark claims on-chain deposits, which completes a Savings to Spending transfer. + var onDepositsClaimed: (() -> Void)? + /// 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 = [] + @ObservationIgnored private var sdk: BreezSdk? + /// 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 = [] + @ObservationIgnored private var listenerId: String? + @ObservationIgnored private var connectTask: Task? + + private init() {} + + var isSupported: Bool { + Self.sparkNetwork != nil + } + + var isConnected: Bool { + sdk != nil + } + + /// Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a + /// Spark transfer, which lets two dev builds pay each other's invoices. + 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 else { return } + do { + if sdk == nil { + if connectTask == nil { + connectTask = Task { try await connect(walletIndex: walletIndex, network: network) } + } + try await connectTask?.value + connectTask = nil + } + // 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. + LightningService.shared.channelFreeRail = self + try await refresh() + try await syncActivities() + } catch { + connectTask = nil + Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") + } + } + + private func connect(walletIndex: Int, network: BreezSdkSpark.Network) async throws { + 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) + let storageDir = Env.sparkStorage(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) + let connected = try await BreezSdkSpark.connect(request: ConnectRequest( + config: config, + seed: .mnemonic(mnemonic: mnemonic, passphrase: passphrase), + storageDir: storageDir.path + )) + 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.exitStateChanged.send() + } + + /// The SDK config Bitkit connects with. + nonisolated static func sdkConfig(network: BreezSdkSpark.Network, apiKey: String?) -> BreezSdkSpark.Config { + var config = defaultConfig(network: network) + config.apiKey = apiKey + // No Breez data sync server and no Lightning address server: Bitkit uses neither. + config.realTimeSyncServerUrl = nil + config.lnurlDomain = nil + // 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 + ) + return config + } + + func wipe(walletIndex: Int) async { + if LightningService.shared.channelFreeRail === self { + LightningService.shared.channelFreeRail = nil + } + if let sdk { + if let listenerId { + _ = await sdk.removeEventListener(id: listenerId) + } + try? await sdk.disconnect() + } + sdk = nil + listenerId = nil + isReady = false + balanceSats = 0 + pendingDepositSats = 0 + recoverableFundsSats = 0 + deposits = [] + try? FileManager.default.removeItem(at: Env.sparkStorage(walletIndex: walletIndex)) + onStateChanged?() + } + + // 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 + ) + 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. + nonisolated func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + let prepared: PrepareSendPaymentResponse + let sdk: BreezSdk + do { + sdk = try await requireSdk() + prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) + } catch { + throw ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) + } + guard case let .bolt11Invoice(invoiceDetails, _, _) = prepared.paymentMethod else { + throw ChannelFreeRailSendRefused(reason: "Unexpected Spark payment method: \(prepared.paymentMethod)") + } + let response = try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: Self.bolt11SendOptions + )) + Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + return invoiceDetails.paymentHash + } + + nonisolated func lightningPayments() async -> [ChannelFreeRailPayment] { + guard let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return [] } + 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 + ) + } + } + + 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) + } + + /// 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 = .claimed + } + 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. + func withdrawAll(to address: String) async throws { + let prepared = try await prepareWithdrawAll(to: address) + let response = try await requireSdk().sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + preparedWithdrawal = nil + if case let .withdraw(txId) = response.payment.details { + sentWithdrawalTxIds.insert(txId) + } + try await refresh() + } + + /// The fee `withdrawAll(to:)` pays at its speed, which comes out of the balance it moves. + func withdrawAllFeeSats(to address: String) async throws -> UInt64 { + let prepared = try await prepareWithdrawAll(to: address) + guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { return 0 } + return feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat + } + + /// 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 } + } + + /// 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 + } + + /// 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) + } + } + + 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: "Spark wallet is not connected", debugMessage: nil) + } + 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()) + } 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") + } + } + + private func refresh() async throws { + let sdk = try await requireSdk() + let info = try await sdk.getInfo(request: GetInfoRequest(ensureSynced: false)) + let unclaimed = 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 pending = unclaimed.reduce(0) { $0 + $1.amountSats } + let changed = !isReady || balanceSats != info.balanceSats || pendingDepositSats != pending + isReady = true + balanceSats = info.balanceSats + pendingDepositSats = pending + recoverableFundsSats = info.recoverableFundsSats + deposits = await Self.deposits(unclaimed: unclaimed, claimed: claimed) + withdrawals = Self.withdrawals(payments) + for deposit in deposits { + broadcastDeposits[deposit.txid] = nil + } + applyBroadcastDeposits() + if changed { + onStateChanged?() + } + for payment in payments where sendsRecordedWithoutFee.contains(payment.id) && Self.sats(payment.fees) > 0 { + await recordActivity(payment) + } + for txId in Self.transferTxIds(unclaimed: unclaimed, payments: payments) { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) + } + } + + nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { + payments.compactMap { payment in + guard payment.status != .failed, case let .withdraw(txId) = payment.details else { return nil } + return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) + } + } + + /// 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 + } + + private func syncActivities() async throws { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest()).payments + for payment in payments { + await recordActivity(payment) + } + } + + private func onSdkEvent(_ event: SdkEvent) async { + do { + switch event { + case .synced, .newDeposits: + try await refresh() + case .claimedDeposits: + try await refresh() + onDepositsClaimed?() + case let .unclaimedDeposits(deposits): + Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") + case let .paymentSucceeded(payment), let .paymentFailed(payment): + try await refresh() + await recordActivity(payment) + if let event = Self.nodeEvent(for: payment) { + LightningService.shared.emitChannelFreeRailEvent(event) + } + case let .paymentPending(payment): + await recordActivity(payment) + case .unilateralExitStateChanged: + Self.exitStateChanged.send() + default: + break + } + } catch { + Logger.error("Failed to handle Spark event '\(event)': \(error)", context: "SparkManager") + } + } + + private func recordActivity(_ payment: Payment) async { + guard case let .lightning(description, invoice, _, htlcDetails, _, _, _, _) = payment.details else { return } + 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) + } catch { + Logger.error("Failed to record Spark payment '\(payment.id)': \(error)", context: "SparkManager") + } + } + + private 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 { + UInt64(value.description) ?? 0 + } +} + +/// 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 is submitted and the deposit reaches the balance shortly. + case claimed + /// 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, .submitted: + self = .claimed + 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 + } +} + +struct SparkWithdrawal: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let feeSats: UInt64 + + var id: String { + txid + } +} + +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) + } +} 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..a083b111c 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -69,6 +69,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 @@ -89,6 +91,8 @@ struct AppCacheData: Codable { hasSeenNotificationsIntro: Bool, hasSeenQuickpayIntro: Bool, hasSeenShopIntro: Bool, + hasSeenTransferIntro: Bool = false, + hasSeenTransferToSpendingIntro: Bool = false, hasSeenTransferToSavingsIntro: Bool, hasSeenWidgetsIntro: Bool, hasDismissedWidgetsOnboardingHint: Bool, @@ -106,6 +110,8 @@ struct AppCacheData: Codable { self.hasSeenNotificationsIntro = hasSeenNotificationsIntro self.hasSeenQuickpayIntro = hasSeenQuickpayIntro self.hasSeenShopIntro = hasSeenShopIntro + self.hasSeenTransferIntro = hasSeenTransferIntro + self.hasSeenTransferToSpendingIntro = hasSeenTransferToSpendingIntro self.hasSeenTransferToSavingsIntro = hasSeenTransferToSavingsIntro self.hasSeenWidgetsIntro = hasSeenWidgetsIntro self.hasDismissedWidgetsOnboardingHint = hasDismissedWidgetsOnboardingHint @@ -127,6 +133,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 @@ -143,7 +151,7 @@ struct AppCacheData: Codable { 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 @@ -220,3 +228,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/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/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 5e0d9209d..2ba6f3041 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,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__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__up_to" = "up to"; +"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 +224,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 +237,12 @@ "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__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,36 @@ "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_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" = "الرسوم"; @@ -691,6 +755,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 هذه. تحقق من الفاتورة وحاول مرة أخرى."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index c00df3068..78a612fe0 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,30 @@ "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__up_to" = "up to"; +"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 +251,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 +264,12 @@ "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__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,36 @@ "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_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"; @@ -787,6 +851,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"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 1c04e6f03..755de70ef 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,20 @@ "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__up_to" = "up to"; +"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 +259,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 +272,12 @@ "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__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,36 @@ "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_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"; @@ -850,6 +914,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"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index db08ad69f..41d543636 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,19 @@ "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__up_to" = "up to"; +"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 +257,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 +270,12 @@ "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__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,36 @@ "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_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"; @@ -848,6 +912,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"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 385a69a9a..3bcdf21e1 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,30 @@ "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__up_to" = "up to"; +"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 +247,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 +260,12 @@ "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__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,36 @@ "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_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" = "Τέλη"; @@ -732,6 +796,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" = "Προφίλ"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 389638b46..9f299e68b 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__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,20 @@ "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__up_to" = "up to"; +"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__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 +252,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 +265,12 @@ "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__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 +293,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 +338,36 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Order fee"; "lightning__balance" = "Balance"; +"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_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"; @@ -833,6 +897,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"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 1580b2219..7de3e658a 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,20 @@ "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__up_to" = "up to"; +"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 +260,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 +273,12 @@ "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__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,36 @@ "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_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"; @@ -851,6 +915,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"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 6ac479043..510f68918 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,30 @@ "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__up_to" = "up to"; +"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 +249,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 +262,12 @@ "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__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,36 @@ "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_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"; @@ -798,6 +862,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"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 7f1c02d5b..4074d833e 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,20 @@ "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__up_to" = "up to"; +"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 +260,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 +273,12 @@ "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__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,36 @@ "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_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"; @@ -851,6 +915,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"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 4e1820a11..ff531fab8 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,19 @@ "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__up_to" = "up to"; +"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 +254,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 +267,12 @@ "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__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,36 @@ "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_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"; @@ -831,6 +895,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"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 24c357982..087be4fee 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,19 @@ "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__up_to" = "up to"; +"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 +257,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 +270,12 @@ "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__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,36 @@ "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_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"; @@ -848,6 +912,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"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 3fa7834d1..1cd7e31c2 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,20 @@ "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__up_to" = "up to"; +"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 +259,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 +272,12 @@ "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__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,36 @@ "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_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"; @@ -850,6 +914,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"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 86338818c..bd54df0b4 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,20 @@ "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__up_to" = "up to"; +"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 +259,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 +272,12 @@ "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__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,36 @@ "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_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"; @@ -850,6 +914,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"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 5d8e31fd4..5ab90bafd 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,29 @@ "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__up_to" = "up to"; +"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 +275,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 +288,12 @@ "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__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,36 @@ "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_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"; @@ -738,6 +802,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"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 4d1bea428..d2df40a5b 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,19 @@ "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__up_to" = "up to"; +"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 +255,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 +268,12 @@ "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__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,36 @@ "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_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" = "Комиссии"; @@ -845,6 +909,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" = "Профиль"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 5eeafe254..2d014bb4a 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -310,6 +310,11 @@ class BackupService { Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") await installBackupObservers(generation: observation.generation, foregroundGeneration: observation.foregroundGeneration) + + // Spark's connect signal is missed when it connected before observing started. + if await SparkManager.shared.isConnected { + markBackupRequired(category: .sparkExit) + } } } @@ -409,6 +414,11 @@ class BackupService { } else { try await persistWalletBackup() } + } else if category == .sparkExit { + // 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() @@ -788,6 +798,11 @@ class BackupService { Logger.debug("Restored \(payload.orders.count) orders, \(payload.cjitEntries.count) CJITs", context: "BackupService") } + try await performRestore(category: .sparkExit) { dataBytes in + let backup = try JSONDecoder().decode(SparkExitBackupV1.self, from: dataBytes) + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: LightningService.shared.currentWalletIndex) + } + // Always reset PIN settings after restore (PIN is never backed up for security) await SettingsViewModel.shared.resetPinSettings() @@ -864,6 +879,13 @@ class BackupService { } .store(in: cancellables) + SparkManager.exitStateChangedPublisher + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .sparkExit) + } + .store(in: cancellables) + // Record requirements synchronously; the scheduled upload already has a debounce. WalletBackupChangeTracker.localChanges .merge(with: PaykitSdkService.walletBackupDataChangedPublisher) @@ -1370,7 +1392,25 @@ 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 + } + } + + private func sparkExitBackupData() async throws -> Data? { + 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 diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 460eac965..366a612e1 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -955,6 +955,14 @@ class ActivityService { } } + /// 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) + } + } + private func processLightningPayment(_ payment: PaymentDetails) async throws { guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } @@ -1459,6 +1467,24 @@ class 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. diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index cfc03b871..97b7f4d40 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -5,6 +5,56 @@ 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 + func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 + func lightningPayments() async -> [ChannelFreeRailPayment] +} + +@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 sendable = balanceSats * 1_000_000 / (1_000_000 + serviceFeePpm) + let fee = (sendable * 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. +struct ChannelFreeRailSendRefused: LocalizedError { + let reason: String + var underlying: Error? + + var errorDescription: String? { + (underlying as? LocalizedError)?.errorDescription ?? 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? +} + /// The node lifecycle `WalletViewModel` drives, apart from the rest of `LightningService` so its ordering can be tested. protocol NodeLifecycleService: AnyObject { var hasNode: Bool { get } @@ -47,6 +97,9 @@ class LightningService: NodeLifecycleService { @MainActor private var cachedChannels: [ChannelDetails]? 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] = [] + weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? typealias OnchainActivityCompletion = @MainActor () async -> Void @@ -266,7 +319,7 @@ class LightningService: NodeLifecycleService { } if let onEvent { - storedEventCallback = onEvent + setEventCallback(onEvent) } listenForEvents(onEvent: storedEventCallback) @@ -324,6 +377,7 @@ class LightningService: NodeLifecycleService { if clearEventCallback { storedEventCallback = nil + pendingChannelFreeRailEvents = [] } await MainActor.run { @@ -745,6 +799,10 @@ class LightningService: NodeLifecycleService { } func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { + if let rail = channelFreeRail, await shouldReceiveWithRail(rail, amountSats: amountMsats.map { $0 / 1000 }) { + let amountSats = amountMsats.map { $0 / 1000 }.flatMap { $0 > 0 ? $0 : nil } + return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -771,12 +829,40 @@ class LightningService: NodeLifecycleService { return bolt11.description } + /// 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?) -> Bool { + guard rail.isReady else { return false } + let readyChannels = (channels ?? []).filter(\.isChannelReady) + guard !readyChannels.isEmpty else { return true } + let inboundSats = readyChannels.map(\.inboundCapacityMsat).reduce(0, +) / 1000 + guard let amountSats, amountSats > 0 else { return 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 else { return false } + let invoiceSats = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } + guard let amountSats = sats ?? invoiceSats else { return false } + return !canSendWithChannels(amountSats: amountSats) && rail.sendableSats >= amountSats + } + /// 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 { + if let rail = channelFreeRail, rail.isReady, rail.sendableSats >= amountSats, !canSendWithChannels(amountSats: amountSats) { + return true + } + return canSendWithChannels(amountSats: amountSats) + } + + @MainActor + private func canSendWithChannels(amountSats: UInt64) -> Bool { guard let channels else { return false } @@ -856,6 +942,24 @@ class LightningService: NodeLifecycleService { params: RouteParametersConfig? = nil, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { + if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + // 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 { + throw error as? ChannelFreeRailSendRefused ?? ChannelFreeRailSendRefused(reason: "Spark fee estimate failed", underlying: error) + } + guard feeSats * 1000 <= maxFeeMsat else { + throw ChannelFreeRailSendRefused( + reason: "Spark routing fee of \(feeSats) sats exceeds the limit of \(maxFeeMsat / 1000) sats" + ) + } + } + try beforeSubmission() + return try await rail.send(bolt11: bolt11, sats: sats) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -1376,6 +1480,28 @@ 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) { + storedEventCallback = onEvent + let pending = pendingChannelFreeRailEvents + pendingChannelFreeRailEvents = [] + pending.forEach(onEvent) + } + + /// 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) { + guard let storedEventCallback else { + pendingChannelFreeRailEvents.append(event) + return + } + storedEventCallback(event) + } + + func channelFreeRailPayments() async -> [ChannelFreeRailPayment] { + await channelFreeRail?.lightningPayments() ?? [] + } + func deliverEvent(_ event: Event, onEvent: ((Event) -> Void)?) async -> OnchainActivityCompletion? { let completion: OnchainActivityCompletion? = switch event { case .onchainTransactionReceived, .onchainTransactionConfirmed, .onchainTransactionReplaced, @@ -1773,6 +1899,9 @@ extension LightningService { } func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil) async throws -> UInt64 { + if 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) } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index f983011ee..d0853fa1f 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -103,7 +103,8 @@ final class PaykitAllowanceManager { guard LightningService.shared.status?.isRunning == true, let channels = LightningService.shared.channels else { return false } - return channels.isEmpty || channels.contains(where: \.isUsable) + // Spark pays without channels, so a channel still reconnecting does not hold it back. + return channels.isEmpty || channels.contains(where: \.isUsable) || LightningService.shared.channelFreeRail?.isReady == true } var entries: [PaykitAllowanceEntry] { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 94037be5f..cb289a597 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -195,11 +195,15 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payments = await LightningService.shared.listPayments() else { return .unavailable } + guard let payments = await LightningService.shared.listPayments() else { + let railStatus = await Self.railStatus(paymentHash: paymentHash) + guard case .unknown = railStatus else { return railStatus } + return .unavailable + } guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { - return .unknown + return await Self.railStatus(paymentHash: paymentHash) } switch payment.status { @@ -212,6 +216,20 @@ 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. + static func railStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + guard let payment = await LightningService.shared.channelFreeRailPayments().first(where: { + $0.paymentHash.caseInsensitiveCompare(paymentHash) == .orderedSame && $0.direction == .outbound + }) else { + return .unknown + } + switch payment.status { + case .pending: return .pending + case .failed: return .failed + case .succeeded: return .succeeded(preimage: payment.preimage) + } + } } private actor PaykitProofMutationLock { @@ -1257,6 +1275,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 { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 4a319d888..27d5f5996 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -683,7 +683,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( diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 0beb64c7e..7f9316e43 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -78,7 +78,7 @@ extension PrivatePaykitService { @MainActor func walletHasUsableChannels(_ wallet: WalletViewModel) -> Bool { - wallet.hasUsableChannels + wallet.canReceiveLightning } @MainActor @@ -127,33 +127,35 @@ 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 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 [] } - - return Set( - payments.compactMap { payment in - guard payment.direction == .outbound, - payment.status != .failed, - 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 == .outbound, + payment.status != .failed, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + let railHashes = 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/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index 3e57c941a..fe54e823d 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -70,10 +70,11 @@ struct AppStatusHelper { return .error } - if !hasChannels { - return .error - } else if wallet.hasUsableChannels { + // Spark carries Lightning without channels, so a Spark wallet's connection is ready. + if wallet.hasUsableChannels || SparkManager.shared.isReady { return .ready + } else if !hasChannels { + return .error } else { return .pending } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index f1552bb33..8247418e4 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -69,7 +69,11 @@ final class QuickPayPaymentCoordinator { try await String(LightningService.shared.send(bolt11: bolt11)) } self.listRows = listRows ?? { - await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) + let railRows = await LightningService.shared.channelFreeRailPayments().map(QuickPayReconcileRow.init(railPayment:)) + guard let ldkRows = await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) else { + return railRows.isEmpty ? nil : railRows + } + return ldkRows + railRows } } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index fceaf350f..154874010 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 diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1c6fc301e..ce446259f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -106,6 +106,8 @@ struct ScanPaymentState { let totalLightningBalanceSats: UInt64 let hasChannels: Bool let hasUsableChannels: Bool + /// Spark sends Lightning without channels. + var hasChannelFreeRail = false } struct ScanPaymentOperations { @@ -121,9 +123,11 @@ 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 ) }, canSendLightning: lightningService.canSend @@ -204,6 +208,8 @@ class AppViewModel: ObservableObject { @AppStorage("hasSeenNotificationsIntro") var hasSeenNotificationsIntro: Bool = false @AppStorage("hasSeenQuickpayIntro") var hasSeenQuickpayIntro: 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 @@ -555,6 +561,8 @@ class AppViewModel: ObservableObject { hasSeenNotificationsIntro = false hasSeenQuickpayIntro = false hasSeenShopIntro = false + hasSeenTransferIntro = false + hasSeenTransferToSpendingIntro = false hasSeenTransferToSavingsIntro = false hasSeenWidgetsIntro = false hasDismissedWidgetsOnboardingHint = false @@ -1075,13 +1083,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 @@ -1255,8 +1263,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"), @@ -1693,6 +1702,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 } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 0c5d5e457..5bae41280 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,7 @@ enum Route: Hashable { case addressTypePreference case connections case connectionDetail(channelId: String) + case sparkConnectionDetail case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index ac91d07db..f65b63719 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"), @@ -1015,6 +1017,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") diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift new file mode 100644 index 000000000..62b12e0ae --- /dev/null +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -0,0 +1,322 @@ +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) + } +} + +/// 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 accepted, so the deposit reaches the spending balance shortly. + case adding + /// 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 == .adding + } +} + +/// 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: + 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: .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 + + 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) } + ) + } +} + +/// 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 + @ObservationIgnored private var offer: SparkClaimOffer? + @ObservationIgnored private var ceilingSats: UInt64 = 0 + @ObservationIgnored private var watchTask: Task? + @ObservationIgnored private var quoteRequestId = 0 + @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 + } + 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 + } + + // 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 + 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 { + networkFeeSats = try await wallet.calculateTotalFee(address: address, amountSats: amountSats, satsPerVByte: rate) + 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) { + self.txid = txid + ceilingSats = quote?.claimFees.instantMaxSats ?? 0 + offer = nil + claimState = speed == .instant ? .looking : .later + watchTask?.cancel() + watchTask = nil + if speed == .instant { + watch(txid: txid) + } + } + + /// Accepts the early claim at the repriced fee. + func payRepriced() { + guard case let .repriced(feeSats) = claimState, let txid else { return } + ceilingSats = feeSats + claimState = .claiming + watch(txid: txid) + } + + /// Leaves the deposit to the claim after 3 confirmations. + func waitForConfirmations() { + watchTask?.cancel() + watchTask = nil + claimState = .later + } + + /// Waits for the running claim to settle; for tests. + func waitForClaim() async { + await watchTask?.value + } + + private func watch(txid: String) { + watchTask?.cancel() + watchTask = Task { [weak self] in + await self?.pollDeposit(txid: txid) + } + } + + private func pollDeposit(txid: String) async { + for _ in 0 ..< pollAttempts { + if Task.isCancelled { + return + } + do { + let found = try await operations.offer(txid) + if found == nil, offer != nil { + // Listed before and no longer unclaimed: the early claim went through. + claimState = .adding + return + } + if let found { + offer = found + let next = SparkClaimDecision.onOffer(found.kind, speed: .instant, ceilingSats: ceilingSats) + guard next == .claiming else { + claimState = next + return + } + claimState = .claiming + let claim = try await operations.claimEarly(found) + if let state = SparkClaimDecision.onClaim(claim) { + claimState = state + 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 } + Logger.warn("Spark did not list deposit \(txid) in time", context: "SparkTransferFlow") + claimState = .notSeen + } +} diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6bf25a561..18f6346d8 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -5,7 +5,15 @@ 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 + /// 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 +36,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 +46,16 @@ 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) } + /// How long the confirm screen waits for Spark's withdrawal fee before showing none. + var sparkFeeTimeout: TimeInterval = 15 + private var sparkFeeTask: Task? + /// The Spark balance the fee was priced for, so it is priced once per balance. + private var sparkFeeBalanceSats: 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,17 +246,90 @@ class TransferViewModel: ObservableObject { // MARK: - Savings Transfer Methods - func setSelectedChannelIds(_ ids: [String]) { - selectedChannelIds = ids + /// 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 + ) + } + + /// 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 + sparkFeeBalanceSats = balanceSats + sparkWithdrawFeeSats = nil + 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 + let fee = await Self.withTimeout(seconds: timeout) { try await price(address) } + guard let self, !Task.isCancelled else { return } + sparkFeeTask = nil + isPricingSparkWithdrawal = false + if let fee { + sparkWithdrawFeeSats = fee + } else { + sparkFeeBalanceSats = nil + Logger.warn("Spark withdrawal fee not priced within \(timeout)s or failed", context: "TransferViewModel") + } + } + } + + /// The result of `operation`, or nil when it throws or takes longer than `seconds`. + private nonisolated static func withTimeout( + seconds: TimeInterval, + _ operation: @escaping @Sendable () async throws -> T + ) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { + do { + return try await operation() + } catch { + Logger.error("Failed to price the Spark withdrawal", context: error.localizedDescription) + return nil + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } } /// Commit the transfer and pick how it runs. A swap needs a priced quote, so without one /// (swaps unsupported on this network, Boltz unreachable, or an amount below the swap /// minimum) the transfer closes a channel exactly as it did before swaps existed. - func onTransferToSavingsConfirm(channels: [ChannelDetails], mode: SavingsTransferMode? = nil) { + func onTransferToSavingsConfirm(channels: [ChannelDetails], withdrawsSpark: Bool, mode: SavingsTransferMode? = nil) { savingsTransferMode = mode ?? resolvedSavingsTransferMode - selectedChannelIds = [] + selectAllForSavings() channelsToClose = channels + self.withdrawsSpark = withdrawsSpark // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. savingsSwapRun = nil } @@ -245,10 +338,79 @@ class TransferViewModel: ObservableObject { 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", context: error.localizedDescription) + sparkWithdrawError = error.localizedDescription + } + } + + private func withdrawSparkToSavings() async throws { + sparkFeeTask?.cancel() + sparkFeeTask = nil + isPricingSparkWithdrawal = false + let spark = SparkManager.shared + guard spark.balanceSats > 0 else { return } + let address: String = if let sparkWithdrawAddress { + sparkWithdrawAddress + } else { + try await lightningService.newAddress() + } + try await spark.withdrawAll(to: address) + 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) + ) + ) + guard case let .accepted(txid) = result else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark funding outcome: \(result)") + } + _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + SparkManager.shared.onDepositBroadcast(txid: txid, amountSats: amountSats) + Logger.info("Funded Spark wallet with \(amountSats) sats in \(txid)", context: "TransferViewModel") + await onBalanceRefresh?() + return txid + } + func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] { var failedChannels: [ChannelDetails] = [] var successfulChannels: [ChannelDetails] = [] @@ -528,6 +690,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") @@ -671,6 +835,48 @@ 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. + static func resolve( + readyChannelIds: [String], + selectedChannelIds: Set?, + isSparkSelected: Bool, + sparkSats: UInt64 + ) -> SavingsSelection { + SavingsSelection( + channelIds: readyChannelIds.filter { selectedChannelIds?.contains($0) ?? true }, + withdrawsSpark: isSparkSelected && sparkSats > 0 + ) + } +} + +/// 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 + } +} + /// Whether a transfer to savings swaps funds out or closes a channel (default). enum SavingsTransferMode { case swap diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index d4f559f37..1c36919dd 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -24,6 +24,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 { @@ -173,6 +174,19 @@ class WalletViewModel: ObservableObject { } lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } + 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() @@ -261,6 +275,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() @@ -1171,6 +1187,9 @@ class WalletViewModel: ObservableObject { } syncState() + Task { [weak self] in + await self?.finishWatchSendIfRailAlreadySettled(hash: hash, eventId: eventId) + } } } onCancel: { Task { @MainActor [weak self] in @@ -1179,6 +1198,22 @@ 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 { + let payment = await lightningService.channelFreeRailPayments().first { + $0.direction == .outbound && $0.paymentHash.caseInsensitiveCompare(hash) == .orderedSame + } + switch payment?.status { + case .succeeded: + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: 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) @@ -1373,14 +1408,16 @@ class WalletViewModel: ObservableObject { do { try? await transferService.syncTransferStates() let state = try await balanceManager.deriveBalanceState() + let spark = SparkManager.shared balanceInTransferToSavings = Int(state.balanceInTransferToSavings) - balanceInTransferToSpending = Int(state.balanceInTransferToSpending) + balanceInTransferToSpending = Int(state.balanceInTransferToSpending + spark.pendingDepositSats) // 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 + spark.balanceSats) + totalBalanceSats = Int(state.totalBalanceSats + spark.balanceSats + spark.pendingDepositSats) + // A payment goes over one rail, so the larger of the two balances caps it. + maxSendLightningSats = Int(max(state.maxSendLightningSats, spark.sendableSats)) // Update channel fundable balance (excludes Legacy UTXOs and accounts for funding tx fee) let (selectedType, monitoredTypes) = LightningService.addressTypeStateFromUserDefaults() @@ -1436,13 +1473,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 @@ -1515,7 +1557,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 @@ -1612,7 +1654,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") } @@ -2011,6 +2053,8 @@ class WalletViewModel: ObservableObject { try await nodeLifecycle.wipeStorage(walletIndex: 0) } + await SparkManager.shared.wipe(walletIndex: 0) + probeOutcomes.removeAll() // Reset AppStorage display values @@ -2032,3 +2076,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/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..434ff9946 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -82,8 +82,7 @@ struct AdvancedSettingsView: View { NavigationLink(value: Route.connections) { SettingsRow( title: t("settings__adv__lightning_connections"), - iconName: "bolt-hollow", - rightText: String(wallet.channels?.count ?? 0) + iconName: "bolt-hollow" ) } .accessibilityIdentifier("Channels") diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 559e3f1b9..35e4605b9 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")) @@ -230,8 +240,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.balanceSats + guard let balanceDetails = wallet.balanceDetails else { return sparkSats } + return balanceDetails.totalLightningBalanceSats + sparkSats } private var receivingCapacity: UInt64 { @@ -244,7 +255,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 +302,10 @@ struct LightningConnectionsView: View { } } + group.addTask { + await SparkManager.shared.refreshState() + } + // Sync wallet channels group.addTask { do { diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift new file mode 100644 index 000000000..e673479a9 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -0,0 +1,250 @@ +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 + return spark.isReady && (spark.balanceSats > 0 || !spark.deposits.isEmpty || 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.balanceSats, + localBalance: spark.balanceSats, + 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 + + 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.balanceSats, + localBalance: spark.balanceSats, + 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.balanceSats) + } + + 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) + } + } + } + + 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) + .accessibilityIdentifier("SparkTransferToSavings") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .refreshable { + await spark.refreshState() + } + .task { + await loadDepositAddress() + } + } + + /// 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 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/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/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..39962f6d5 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -1,7 +1,17 @@ import SwiftUI struct FundingOptions: View { + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var showNoFundsAlert = false + + /// Spark keeps Spending fundable from savings or another wallet without a channel. + private var canFundWithSpark: Bool { + SparkManager.shared.isReady + } var body: some View { VStack(spacing: 0) { @@ -14,11 +24,27 @@ 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 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 +53,18 @@ struct FundingOptions: View { navigation.navigate(.fundManual(nodeUri: nil)) } - 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, - testID: "FundHardware" - ) {} - RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + if !canFundWithSpark { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} + } + if !canFundWithSpark { + RectangleButton( + icon: "device-tablet-speaker", + title: t("lightning__funding__button_hw"), + isDisabled: true, + testID: "FundHardware" + ) {} + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + } } Spacer() @@ -49,5 +79,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..963083d0f --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -0,0 +1,177 @@ +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 + @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 + + 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) { + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= maxAllowed, walletId: walletId) + navigation.navigate(.spendingHwSign(walletId: walletId)) + } + .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" + ) + } + } + + 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..3075b8db4 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -0,0 +1,247 @@ +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 } + let speed = flow.speed + let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) + let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) + guard freshCost <= shownCost 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: freshCost - shownCost)] + ) + ) + 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..2e5c60a8a 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.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/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 8fcf963e2..f8705aee7 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -10,36 +10,47 @@ 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.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) + } + + 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) } var body: some View { @@ -60,11 +71,21 @@ struct SavingsConfirmView: View { quoteSection(quote) } - if hasMultipleChannels { + 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 ? 24 : 16) + .accessibilityIdentifier("SavingsSparkFee") + } + + if hasMultipleItems { HStack(spacing: 16) { - if hasSelectedChannels { + if hasSelectedItems { CustomButton(title: t("lightning__savings_confirm__transfer_all"), size: .small) { - transfer.setSelectedChannelIds([]) + transfer.selectAllForSavings() } } else { CustomButton(title: t("common__advanced"), size: .small) { @@ -94,7 +115,7 @@ struct SavingsConfirmView: View { Spacer() - if !hideSwipeButton { + if !hideSwipeButton, !selection.isEmpty { SwipeButton( title: t("lightning__transfer__swipe"), accentColor: .brandAccent, @@ -104,7 +125,7 @@ struct SavingsConfirmView: View { // otherwise falls back to the pre-swap behaviour of closing the channel, so it // is never inert. do { - transfer.onTransferToSavingsConfirm(channels: channels) + transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark) try await Task.sleep(nanoseconds: 300_000_000) @@ -123,7 +144,7 @@ struct SavingsConfirmView: View { if swapState.quote != nil { // Fallback: drain a whole channel on-chain by closing it instead of swapping. CustomButton(title: t("lightning__savings_confirm__close_instead"), variant: .tertiary) { - transfer.onTransferToSavingsConfirm(channels: channels, mode: .close) + transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark, mode: .close) navigation.navigate(.savingsProgress) } .padding(.top, 12) @@ -142,9 +163,13 @@ struct SavingsConfirmView: View { await wallet.syncStateAsync() await transfer.loadSavingsSwapQuote( requestedSat: totalSats, - spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) + spendableSats: channelsSendableSats ) } + .onChange(of: selection.withdrawsSpark ? sparkSats : nil, initial: true) { _, balanceSats in + guard let balanceSats else { return } + transfer.loadSparkWithdrawFee(balanceSats: balanceSats, savingsAddress: wallet.onchainAddress) + } } private func quoteSection(_ quote: SavingsSwapQuote) -> some View { diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index b13b8e17c..a9cbb7c4e 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,7 @@ 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, .sparkFailed: return t("lightning__transfer__nav_title") } } @@ -33,6 +35,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 +45,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 } } @@ -98,13 +111,13 @@ 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() @@ -115,7 +128,7 @@ struct SavingsProgressContentView: View { ) { navigation.reset() } - .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess-button" : nil) + .accessibilityIdentifierIfPresent(imageIdentifier.map { "\($0)-button" }) } .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..ecc807d29 --- /dev/null +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -0,0 +1,178 @@ +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 { + switch state { + case .looking: t("lightning__spark_deposit__looking") + case .claiming, .adding: 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 { + let isLater = flow.speed == .later || state == .later + return t("lightning__setting_up_speed", variables: [ + "speed": isLater ? t("lightning__spending_confirm__later") : t("fee__instant__title"), + ]) + } + + 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"), + ] + + private var currentStep: Int { + switch state { + case .looking, .notSeen: 1 + case .claiming, .repriced, .unavailable, .later: 2 + case .adding: 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, 14) + .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: currentStep) + .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() + } + + 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("Adding") { + settingUpPreview(.claiming) +} + +#Preview("Done") { + settingUpPreview(.adding) +} + +#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..f6adb1aa3 --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -0,0 +1,209 @@ +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 + + 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 + ) { + 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") + } + } + + private func onContinue() { + guard let max = maxTransferAmount, isValidAmount else { return } + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= max) + navigation.navigate(.spendingConfirm) + } + + /// 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..62619b852 --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -0,0 +1,286 @@ +import SwiftUI + +struct SpendingConfirm: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(SparkTransferFlow.self) private var flow + + @State private var isPaying = false + @State private var hideSwipeButton = false + + /// 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) + + 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, 32) + + if let error = flow.quoteError { + quoteError(error) + .padding(.bottom, 16) + } + + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) + .disabled(isPaying) + .padding(.horizontal, -16) + .padding(.bottom, 16) + + CustomButton(title: t("common__learn_more"), size: .small) { + navigation.navigate(.transferLearnMore) + } + .disabled(isPaying) + .accessibilityIdentifier("SpendingConfirmMore") + } + // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .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")) + .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 { + 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") + } + let speed = flow.speed + let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) + let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) + if freshCost > shownCost { + 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: freshCost - shownCost)] + ) + ) + throw SpendingFeesIncreasedError() + } + + 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 { + 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 + } + } +} + +/// 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, + 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 + + static func feePrefix(for speed: SparkTransferSpeed) -> String { + switch speed { + case .instant: t("lightning__spending_confirm__up_to") + case .later: "≈" + } + } + + 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 + ) { + speed = .instant + } + .accessibilityIdentifier("SpendingConfirmInstant") + + FeeItem( + title: t("lightning__spending_confirm__later"), + subtitle: t("lightning__spending_confirm__later_text"), + iconName: TransactionSpeed.slow.iconName, + iconColor: .purpleAccent, + amount: quote?.claimFees.laterSats ?? 0, + amountPrefix: Self.feePrefix(for: .later), + isSelected: speed == .later, + isDisabled: false + ) { + speed = .later + } + .accessibilityIdentifier("SpendingConfirmLater") + + Divider() + .padding(.horizontal, 16) + } + } +} + +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(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/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 9034dceda..35c23d2c7 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.isReady { + 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/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index d757856c3..5b3b92632 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -122,7 +122,13 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.isReady { + 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/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 139deaf83..8132cbfd9 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -317,7 +317,7 @@ struct SendSheet: View { // 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 } + guard hasAnyChannels, !sparkCanPayScannedInvoice else { return false } // We have channels but none are usable yet → show sync overlay return !wallet.hasUsableChannels @@ -326,6 +326,13 @@ struct SendSheet: View { 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.isReady && amountSats > 0 && spark.balanceSats >= amountSats + } + /// Identity for the sync timeout task. The timer only counts down while the overlay is visible /// and the device is online (while offline, `offlineSheetOverlay` covers the sheet and a timeout /// would act on stale state). Node readiness is part of the identity so the window restarts when @@ -784,7 +791,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 +809,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 +859,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 diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index bae55e69a..3e8522614 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -41,7 +41,7 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { - if let channels = wallet.channels, !channels.isEmpty { + if wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) @@ -115,7 +115,13 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.isReady { + 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/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift new file mode 100644 index 000000000..81d2865c0 --- /dev/null +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -0,0 +1,177 @@ +@testable import Bitkit +import LDKNode +import XCTest + +@MainActor +final class ChannelFreeRailTests: XCTestCase { + private var rail: FakeRail! + private var previousRail: Bitkit.ChannelFreeLightningRail? + + override func setUp() { + super.setUp() + rail = FakeRail() + previousRail = Bitkit.LightningService.shared.channelFreeRail + Bitkit.LightningService.shared.channelFreeRail = rail + } + + override func tearDown() { + Bitkit.LightningService.shared.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 { + 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 received = await PrivatePaykitService.shared.receivedSettledPaymentHashes() + let attempted = await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() + + XCTAssertTrue(received.contains("in")) + XCTAssertEqual(attempted.intersection(["out", "failed"]), ["out"]) + } + + 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") + } +} + +@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 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 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 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 sentBolt11: String? + + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + "lnbcrt1fake" + } + + func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + sentBolt11 = bolt11 + return "ee" + } + + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + if let estimateError { + throw estimateError + } + return routingFeeSats + } + + func lightningPayments() async -> [Bitkit.ChannelFreeRailPayment] { + payments + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e78b9ca58..c9db89075 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1723,6 +1723,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() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 5fe26294b..0f4a676aa 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5041,6 +5041,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) diff --git a/BitkitTests/SavingsSelectionTests.swift b/BitkitTests/SavingsSelectionTests.swift new file mode 100644 index 000000000..6db3bd2f0 --- /dev/null +++ b/BitkitTests/SavingsSelectionTests.swift @@ -0,0 +1,69 @@ +@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) + } + + 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/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..9874f03eb --- /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), .claimed) + 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..267e1600b --- /dev/null +++ b/BitkitTests/SparkConfigTests.swift @@ -0,0 +1,15 @@ +@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") + + XCTAssertNil(config.realTimeSyncServerUrl, "\(network)") + XCTAssertNil(config.lnurlDomain, "\(network)") + XCTAssertEqual(config.apiKey, "test-key") + } + } +} diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift new file mode 100644 index 000000000..e451aed3d --- /dev/null +++ b/BitkitTests/SparkExitBackupTests.swift @@ -0,0 +1,51 @@ +@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) + } + + 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)) + } +} diff --git a/BitkitTests/SparkLightningSendTests.swift b/BitkitTests/SparkLightningSendTests.swift new file mode 100644 index 000000000..b7fcb4811 --- /dev/null +++ b/BitkitTests/SparkLightningSendTests.swift @@ -0,0 +1,72 @@ +@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 testLightningInvoiceWithoutChannelsOrSparkIsRejected() async throws { + let app = makeApp(hasChannelFreeRail: false) { _ in false } + + try await app.handleScannedData("lnbcrt1sparkonly") + + XCTAssertTrue(app.didRejectScannedPayment) + } + + 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/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift new file mode 100644 index 000000000..d37a1bcfb --- /dev/null +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -0,0 +1,48 @@ +@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")), + payment(id: "3", status: .failed, details: .withdraw(txId: "failed-withdrawal")), + payment(id: "4", status: .completed, details: nil), + ] + + let txIds = SparkManager.transferTxIds(unclaimed: [unclaimed], payments: payments) + + XCTAssertEqual(txIds, ["pending-deposit", "claimed-deposit", "withdrawal"]) + } + + func testListsWithdrawalsWithWhatSparkKept() { + let payments = [ + payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal"), fees: 1470), + payment(id: "2", status: .failed, details: .withdraw(txId: "failed-withdrawal"), 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..82dc21bd2 --- /dev/null +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -0,0 +1,147 @@ +@testable import Bitkit +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 testInstantClaimedBySdkAddsIt() { + XCTAssertEqual( + SparkClaimDecision.onOffer(.automatic(feeSats: 300, confirmations: 0), speed: .instant, ceilingSats: ceiling), + .adding + ) + } + + 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), .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 testTimeoutWhenSparkNeverListsTheDeposit() async { + let flow = pollingFlow(offers: [nil, nil, nil]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .notSeen) + } + + @MainActor + func testDepositListedThenGoneWasAdded() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900), nil], claims: [.waiting]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .adding) + } + + @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, .adding) + } + + @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, .adding) + } + + @MainActor + func testDeclinedEarlyClaimWaitsForConfirmations() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900)], claims: [.declined]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .unavailable) + } + + /// 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] = []) -> 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: offers.count + 1) + } + + 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..dd23d7bec --- /dev/null +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -0,0 +1,116 @@ +@testable import Bitkit +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) + } + + // 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)) + } + + @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) + } +} diff --git a/Docs/RELEASE.md b/Docs/RELEASE.md index af7148739..3d97575c2 100644 --- a/Docs/RELEASE.md +++ b/Docs/RELEASE.md @@ -127,6 +127,8 @@ These translated notes are later pasted into App Store Connect when submitting f ## 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 ca7338f10..53fa3e7a7 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/journeys/README.md b/journeys/README.md index 91d47b09c..53b04b44c 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -197,7 +197,7 @@ but appends the billing period (`-one-time` for a one-off), because every recurr 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 @@ -215,6 +215,7 @@ 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) | +| Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key; transfers fund it from regtest Savings. 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 — [transfer](transfer/spark-transfer-to-spending.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..6763f6ca8 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-over-max.xml @@ -0,0 +1,30 @@ + + + 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 (a + dev build reaches Spark's regtest with no key), so the transfer opens the amount screen rather than + the Transfer Unavailable sheet. 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 8916a48d2..ead51982a 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -1,7 +1,8 @@ # 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 (the Transfer Unavailable sheet while Spark is not ready). ## Transport: Bridge, not USB @@ -51,8 +52,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`. -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`. +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 @@ -107,8 +110,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`, @@ -126,7 +130,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..baf8f5491 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -0,0 +1,36 @@ + + + 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. + + + 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..a91baa878 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-over-max.xml @@ -0,0 +1,39 @@ + + + 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 (a dev build reaches Spark's regtest with no key) 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..a8b31eb8a --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -0,0 +1,28 @@ + + + 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 (a dev build reaches Spark's regtest with no key), 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/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-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml new file mode 100644 index 000000000..0827d2ead --- /dev/null +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -0,0 +1,27 @@ + + + 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 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: dev build with Spark connected on its regtest (no key needed), 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 "up to" an amount equal to the Instant row's fee, and "To spending" equals the amount less that fee + Tap "Later" and verify "Spending fee" changes to "≈" 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") + 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..6b2813456 --- /dev/null +++ b/journeys/transfer/spending-confirm-amount-change.xml @@ -0,0 +1,35 @@ + + + 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 cheaper path and the re-quote after an amount change. A larger UTXO confirms + while Spending Confirm is open, so the fee priced at the swipe spends one input, costs less and 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, + 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..4fc4ab0fd 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. From b277bebe033a71ec92ed706029c53912a63a7ed1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 12:34:16 +0100 Subject: [PATCH 2/3] feat: spark chain service on bitkit electrs --- Bitkit/Constants/Env.swift | 9 +++++++++ Bitkit/Managers/SparkManager.swift | 12 +++++++----- BitkitTests/SparkConfigTests.swift | 5 +++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4cdd9ea87..475fc8da1 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -221,6 +221,15 @@ 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. + static func sparkChainServiceUrl(for network: LDKNode.Network) -> String? { + switch network { + case .bitcoin: "https://bitkit.to/electrs" + default: nil + } + } + static var electrumServerUrl: String { if isE2E, e2eBackend == "local" { return "tcp://\(e2eLocalHost):60001" diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 332c17997..cc7a28af4 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -107,11 +107,13 @@ final class SparkManager: ChannelFreeLightningRail { let config = Self.sdkConfig(network: network, apiKey: Env.breezApiKey) let storageDir = Env.sparkStorage(walletIndex: walletIndex) try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) - let connected = try await BreezSdkSpark.connect(request: ConnectRequest( - config: config, - seed: .mnemonic(mnemonic: mnemonic, passphrase: passphrase), - storageDir: storageDir.path - )) + // 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) + } + let connected = try await builder.build() listenerId = await connected.addEventListener(listener: SparkEventListener { [weak self] event in await self?.onSdkEvent(event) }) diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift index 267e1600b..f61ef0466 100644 --- a/BitkitTests/SparkConfigTests.swift +++ b/BitkitTests/SparkConfigTests.swift @@ -12,4 +12,9 @@ final class SparkConfigTests: XCTestCase { XCTAssertEqual(config.apiKey, "test-key") } } + + func testChainServiceIsBitkitElectrsOnMainnetOnly() { + XCTAssertEqual(Env.sparkChainServiceUrl(for: .bitcoin), "https://bitkit.to/electrs") + XCTAssertNil(Env.sparkChainServiceUrl(for: .regtest)) + } } From 43abf3ec382dc4a33fd2390ec3a06de8698092c4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:21:11 +0100 Subject: [PATCH 3/3] chore: bump spark sdk to 0.28.0 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +-- Bitkit/Managers/SparkManager.swift | 34 ++++++++++++++----- BitkitTests/SparkLightningSendTests.swift | 7 ++++ BitkitTests/SparkTransferActivityTests.swift | 13 ++++--- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 507f9cb4e..b5e81a489 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1253,7 +1253,7 @@ repositoryURL = "https://github.com/breez/breez-sdk-spark-swift"; requirement = { kind = exactVersion; - version = 0.27.2; + version = 0.28.0; }; }; 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 8401f1e44..0ed9cd76c 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/breez/breez-sdk-spark-swift", "state" : { - "revision" : "259f79b8572ed4a1df8b5f6fea64c6f2e534a65d", - "version" : "0.27.2" + "revision" : "09081e9f2862a0359df154aa6813242d36a56275", + "version" : "0.28.0" } }, { diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index cc7a28af4..16c906365 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -168,7 +168,8 @@ final class SparkManager: ChannelFreeLightningRail { amountSats: amountSats, expirySecs: expirySecs, paymentHash: nil, - receiverIdentityPublicKey: nil + receiverIdentityPublicKey: nil, + preimage: nil ) return try await requireSdk().receivePayment(request: ReceivePaymentRequest(paymentMethod: method)).paymentRequest } @@ -182,19 +183,31 @@ final class SparkManager: ChannelFreeLightningRail { sdk = try await requireSdk() prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) } catch { - throw ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) + throw Self.expiredInvoiceRefusal(error) ?? ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) } guard case let .bolt11Invoice(invoiceDetails, _, _) = prepared.paymentMethod else { throw ChannelFreeRailSendRefused(reason: "Unexpected Spark payment method: \(prepared.paymentMethod)") } - let response = try await sdk.sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: Self.bolt11SendOptions - )) + let response: SendPaymentResponse + do { + response = try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: Self.bolt11SendOptions + )) + } catch { + // The SDK checks the expiry again before sending, so an invoice that expired on the confirm screen left nothing. + throw Self.expiredInvoiceRefusal(error) ?? error + } Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") return invoiceDetails.paymentHash } + /// 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 lightningPayments() async -> [ChannelFreeRailPayment] { guard let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return [] } return payments.compactMap { payment in @@ -288,7 +301,7 @@ final class SparkManager: ChannelFreeLightningRail { options: .bitcoinAddress(confirmationSpeed: .medium) )) preparedWithdrawal = nil - if case let .withdraw(txId) = response.payment.details { + if case let .withdraw(txId, _, _) = response.payment.details { sentWithdrawalTxIds.insert(txId) } try await refresh() @@ -437,6 +450,9 @@ final class SparkManager: ChannelFreeLightningRail { private func refresh() 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 unclaimed = 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 } @@ -465,7 +481,7 @@ final class SparkManager: ChannelFreeLightningRail { nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { payments.compactMap { payment in - guard payment.status != .failed, case let .withdraw(txId) = payment.details else { return nil } + guard payment.status != .failed, case let .withdraw(txId, _, _) = payment.details else { return nil } return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) } } @@ -475,7 +491,7 @@ final class SparkManager: ChannelFreeLightningRail { 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 + case let .deposit(txId, _), let .withdraw(txId, _, _): return txId default: return nil } } diff --git a/BitkitTests/SparkLightningSendTests.swift b/BitkitTests/SparkLightningSendTests.swift index b7fcb4811..fa9ade9e6 100644 --- a/BitkitTests/SparkLightningSendTests.swift +++ b/BitkitTests/SparkLightningSendTests.swift @@ -27,6 +27,13 @@ final class SparkLightningSendTests: XCTestCase { 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 } diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index d37a1bcfb..1acae7bdc 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -10,8 +10,8 @@ final class SparkTransferActivityTests: XCTestCase { ) let payments = [ payment(id: "1", status: .completed, details: .deposit(txId: "claimed-deposit", vout: 0)), - payment(id: "2", status: .completed, details: .withdraw(txId: "withdrawal")), - payment(id: "3", status: .failed, details: .withdraw(txId: "failed-withdrawal")), + 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), ] @@ -22,8 +22,13 @@ final class SparkTransferActivityTests: XCTestCase { func testListsWithdrawalsWithWhatSparkKept() { let payments = [ - payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal"), fees: 1470), - payment(id: "2", status: .failed, details: .withdraw(txId: "failed-withdrawal"), fees: 1470), + 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)), ]