From 7315045682b8fa42aa97ea2293ce756be18c5e0d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 05:26:13 +0100 Subject: [PATCH 1/6] feat: pay contacts through pubky swaps A spending-only wallet pays a Paykit contact whose only endpoint is on-chain through a Pubky Swaps provider found among its contacts. Behind PUBKY_SWAPS_ROUTES on regtest debug builds. Uses bitkit-core 0.8.0-swaps.2. --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Constants/Env.swift | 13 +++ .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + Bitkit/Services/PubkySwapsRouteService.swift | 98 +++++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 14 +++ .../Views/Wallets/Send/SendAmountView.swift | 40 ++++++++ .../Wallets/Send/SendConfirmationView.swift | 93 +++++++++++++++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 4 +- 23 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 Bitkit/Services/PubkySwapsRouteService.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..96c61ddba 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-rc11"; + version = "0.8.0-swaps.2"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..d86e9115d 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "22e0acc31a97b65205e71f4190cfc659fdecd3ca", - "version" : "0.8.0-rc11" + "revision" : "1abc3ff8bc40848951d19ae74259f004b78ce5be", + "version" : "0.8.0-swaps.2" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 90aed6ed0..dee647e1a 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -228,6 +228,19 @@ enum Env { return electrumServerUrl(for: network) } + /// Whether a Paykit payment may route through a Pubky Swaps provider when no balance reaches the contact's + /// endpoints. Regtest debug builds with `PUBKY_SWAPS_ROUTES=true` only. + static var isPubkySwapsRouteEnabled: Bool { + isDebug && network == .regtest && boolConfigValue("PUBKY_SWAPS_ROUTES") + } + + /// Swap providers to ask besides the user's contacts, from `PUBKY_SWAPS_PROVIDERS` (comma-separated pubkys). + static var pubkySwapsProviders: [String] { + (configValue("PUBKY_SWAPS_PROVIDERS") ?? "").split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + static var trezorBridgeEnabled: Bool { (isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE") } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index fc06ea674..a9fa1a334 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -993,6 +993,7 @@ "wallet__send_sync_long_title" = "مشاكل\nالاتصال"; "wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ."; "wallet__send_to" = "إلى"; +"wallet__send_swap_route" = "المسار"; "wallet__recipient_contact" = "جهة الاتصال"; "wallet__recipient_invoice" = "لصق فاتورة"; "wallet__recipient_manual" = "إدخال يدوي"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 94ea5367b..3cb95cce5 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1112,6 +1112,7 @@ "wallet__send_sync_long_title" = "Problemes de\nconnexió"; "wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons."; "wallet__send_to" = "A"; +"wallet__send_swap_route" = "Ruta"; "wallet__recipient_contact" = "Contacte"; "wallet__recipient_invoice" = "Enganxa factura"; "wallet__recipient_manual" = "Introdueix manualment"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 66328b257..9397f044b 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1219,6 +1219,7 @@ "wallet__send_sync_long_title" = "Problémy\ns připojením"; "wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit."; "wallet__send_to" = "Komu"; +"wallet__send_swap_route" = "Trasa"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Vložit fakturu"; "wallet__recipient_manual" = "Zadat ručně"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 9ad85432e..0af9688ea 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1217,6 +1217,7 @@ "wallet__send_sync_long_title" = "Verbindungs-\nprobleme"; "wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden."; "wallet__send_to" = "An"; +"wallet__send_swap_route" = "Route"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Rechnung einfügen"; "wallet__recipient_manual" = "Manuell eingeben"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index b4ef29785..2b82af606 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1011,6 +1011,7 @@ "wallet__send_sync_long_title" = "Προβλήματα\nσύνδεσης"; "wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα."; "wallet__send_to" = "Προς"; +"wallet__send_swap_route" = "Διαδρομή"; "wallet__recipient_contact" = "Επαφή"; "wallet__recipient_invoice" = "Επικόλληση Τιμολογίου"; "wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index ceae804f8..9679b0358 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1232,6 +1232,7 @@ "wallet__send_sync_long_title" = "Connection\nissues"; "wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds."; "wallet__send_to" = "To"; +"wallet__send_swap_route" = "Route"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; "wallet__recipient_manual" = "Enter Manually"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 781e97dae..095799e6a 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1222,6 +1222,7 @@ "wallet__send_sync_long_title" = "Problemas de\nconexión"; "wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Ruta"; "wallet__recipient_contact" = "Contacto"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index c765eaaf2..ec79fdcd1 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1129,6 +1129,7 @@ "wallet__send_sync_long_title" = "Problemas de\nconexión"; "wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Ruta"; "wallet__recipient_contact" = "Contactar"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 7a1bdafc0..459955343 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1230,6 +1230,7 @@ "wallet__send_sync_long_title" = "Problèmes de\nconnexion"; "wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes."; "wallet__send_to" = "Pour"; +"wallet__send_swap_route" = "Itinéraire"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Coller la facture"; "wallet__recipient_manual" = "Entrer manuellement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 0df8ab194..772041734 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1195,6 +1195,7 @@ "wallet__send_sync_long_title" = "Problemi di\nconnessione"; "wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi."; "wallet__send_to" = "A"; +"wallet__send_swap_route" = "Percorso"; "wallet__recipient_contact" = "Contatto"; "wallet__recipient_invoice" = "Incolla fattura"; "wallet__recipient_manual" = "Inserisci Manualmente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 99e3880cb..fdad7d04f 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1225,6 +1225,7 @@ "wallet__send_sync_long_title" = "Verbindings\nproblemen"; "wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken."; "wallet__send_to" = "Naar"; +"wallet__send_swap_route" = "Route"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Factuur Plakken"; "wallet__recipient_manual" = "Handmatig Invoeren"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index b4697826c..3bb186427 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1229,6 +1229,7 @@ "wallet__send_sync_long_title" = "Problemy\nz połączeniem"; "wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund."; "wallet__send_to" = "Do"; +"wallet__send_swap_route" = "Trasa"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Wklej fakturę"; "wallet__recipient_manual" = "Wprowadź ręcznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 2d7c2290e..1b1323a19 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1230,6 +1230,7 @@ "wallet__send_sync_long_title" = "Problemas de\nconexão"; "wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Rota"; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 2a2b4076e..95a31b7ba 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -984,6 +984,7 @@ "wallet__send_sync_long_title" = "Problemas\nde ligação"; "wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Rota"; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 02790e985..55f1c59f6 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1214,6 +1214,7 @@ "wallet__send_sync_long_title" = "Проблемы\nс подключением"; "wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд."; "wallet__send_to" = "Кому"; +"wallet__send_swap_route" = "Маршрут"; "wallet__recipient_contact" = "Контакт"; "wallet__recipient_invoice" = "Вставить Инвойс"; "wallet__recipient_manual" = "Ввести Вручную"; diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift new file mode 100644 index 000000000..0b17a0be9 --- /dev/null +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -0,0 +1,98 @@ +import BitkitCore +import Foundation + +/// A Paykit payment routed through a Pubky Swaps provider: the endpoint, the route once quoted, and its provider. +struct PaykitSwapRoute: Equatable { + let endpoint: PaykitEndpoint + var quote: PubkySwapRouteQuote? + var providerName: String? +} + +/// Routes a Paykit payment through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the +/// contact published. bitkit-core picks the provider and checks its quote, contract and hold invoice; the wallet pays +/// the hold invoice from spending and bitkit-core claims the provider's lockup straight to the contact's address. +enum PubkySwapsRouteService { + private static let claimFeeRateSatVb: UInt64 = 2 + private static let pollInterval: Duration = .seconds(2) + private static let claimTimeout: TimeInterval = 10 * 60 + + static var isEnabled: Bool { + Env.isPubkySwapsRouteEnabled + } + + /// A contact whose only endpoints are on-chain, paid when savings cannot cover it but spending can. + static func route(for context: ContactPaymentContext, address: String, onchainBalanceSats: UInt64, + lightningBalanceSats: UInt64) -> PaykitSwapRoute? + { + guard isEnabled, context.incomingPaymentRequest == nil, !address.isEmpty, + !context.endpoints.isEmpty, context.endpoints.allSatisfy({ $0.methodId.onchainNetwork != nil }), + onchainBalanceSats <= UInt64(Env.dustLimit), lightningBalanceSats > 0, + let methodId = context.endpoints.first?.methodId + else { return nil } + return PaykitSwapRoute(endpoint: PaykitEndpoint(methodId: methodId.rawValue, value: address)) + } + + static func quote( + amountSats: UInt64, + endpoint: PaykitEndpoint, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + contacts: [String] + ) async throws -> PaykitRoute { + // Providers come from the user's own graph: every contact is asked for its swap offer. + let candidates = contacts + Env.pubkySwapsProviders + Logger.debug("Routing Paykit payment via \(candidates.map(PubkyPublicKeyFormat.redacted))", context: "PubkySwapsRoute") + let (mnemonic, passphrase) = try secrets() + return try await pubkySwapsRoutePaykitPayment( + request: PaykitRouteRequest( + amountSat: amountSats, + contactEndpoints: [endpoint], + lightningSpendableSat: lightningSpendableSats, + onchainSpendableSat: onchainSpendableSats, + providers: candidates, + feeRateSatVb: claimFeeRateSatVb + ), + network: Env.bitkitCoreNetwork, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + + static func commit(quoteId: String) async throws -> PubkySwapRoutePayment { + let (mnemonic, passphrase) = try secrets() + return try await pubkySwapsCommitRoute( + quoteId: quoteId, + electrumUrl: Env.electrumServerUrl, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + + /// Advance the swap until bitkit-core claims the lockup to the contact, returning the claim txid. + static func awaitClaim(swapId: String) async throws -> String { + let deadline = Date().addingTimeInterval(claimTimeout) + while true { + let (mnemonic, passphrase) = try secrets() + let status = try await pubkySwapsAdvance(swapId: swapId, mnemonic: mnemonic, bip39Passphrase: passphrase) + switch status.state { + case let .claimed(txid): + return txid + case let .failed(reason): + throw AppError(message: reason, debugMessage: nil) + default: + Logger.debug("Pubky Swaps route \(swapId): \(status.state)", context: "PubkySwapsRoute") + } + guard Date() < deadline else { + throw AppError(message: "Swap \(swapId) not claimed yet", debugMessage: "\(status.state)") + } + try await Task.sleep(for: pollInterval) + } + } + + private static func secrets() throws -> (String, String?) { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { + throw AppError(message: "Mnemonic not found", debugMessage: nil) + } + return (mnemonic, try? Keychain.loadString(key: .bip39Passphrase(index: 0))) + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 65dfd0cf3..c50b5cb72 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -163,6 +163,7 @@ class AppViewModel: ObservableObject { @Published var contactPaymentContext: ContactPaymentContext? private(set) var didRejectScannedPayment = false @Published var paykitUsesUsdt = false + @Published var paykitSwapRoute: PaykitSwapRoute? @Published var paykitReviewedAmount: PaykitAmount? @Published var paykitPaymentTerms: PaykitRequestPricing.Payment? @@ -1015,6 +1016,18 @@ extension AppViewModel { // Fallback to on-chain if address is available guard !invoice.address.isEmpty, validateIncomingOnchainPaymentRequestAmount(requestedAmount ?? invoice.amountSatoshis) else { return } + if let context = contactPaymentContext, let route = PubkySwapsRouteService.route( + for: context, + address: invoice.address, + onchainBalanceSats: max(paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats), + lightningBalanceSats: paymentState.totalLightningBalanceSats + ) { + handleScannedOnchainInvoice(invoice) + paykitSwapRoute = route + selectedWalletToPayFrom = .lightning + return + } + // If node is running, validate balance immediately if paymentState.isNodeRunning { let onchainBalance = max( @@ -1359,6 +1372,7 @@ extension AppViewModel { sendStateOwnerID = ownerID sendStateGeneration = UUID() paykitUsesUsdt = false + paykitSwapRoute = nil paykitReviewedAmount = nil paykitPaymentTerms = nil scannedLightningInvoice = nil diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index deb01b020..12c84a1fe 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -10,6 +10,7 @@ enum SendFundingSource: Equatable { struct SendAmountView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @@ -43,6 +44,9 @@ struct SendAmountView: View { { return [.usdt] } + if app.paykitSwapRoute != nil { + return [.spending] + } var sources: [SendFundingSource] = [] if app.scannedLightningInvoice != nil || app.lnurlPayData != nil { sources.append(.spending) @@ -398,6 +402,12 @@ struct SendAmountView: View { return } + if let route = app.paykitSwapRoute { + guard await quoteSwapRoute(route) else { return } + navigationPath.append(.confirm) + return + } + navigationPath.append(app.lnurlPayData != nil && app.scannedLightningInvoice == nil ? .lnurlPayConfirm : .confirm) return } @@ -446,6 +456,36 @@ struct SendAmountView: View { } } + private func quoteSwapRoute(_ route: PaykitSwapRoute) async -> Bool { + do { + let paykitRoute = try await PubkySwapsRouteService.quote( + amountSats: amountSats, + endpoint: route.endpoint, + lightningSpendableSats: UInt64(wallet.maxSendLightningSats), + onchainSpendableSats: UInt64(wallet.spendableOnchainBalanceSats), + contacts: contactsManager.contacts.map(\.publicKey) + ) + switch paykitRoute { + case let .swap(quote): + var quoted = route + quoted.quote = quote + quoted.providerName = contactsManager.contacts + .first { PubkyPublicKeyFormat.matches($0.publicKey, quote.providerPubky) }?.displayName + app.paykitSwapRoute = quoted + return true + case let .none(reason): + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: reason) + case .direct: + app.paykitSwapRoute = nil + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) + } + } catch { + Logger.error("Failed to route Paykit payment through Pubky Swaps: \(error)", context: "SendAmountView") + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + } + return false + } + private func updateInputCap() { // Don't cap when nothing is sendable, so the pad stays usable (Continue stays disabled instead). amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 564d721c6..03e3f560f 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -3,6 +3,8 @@ import LDKNode import SwiftUI struct SendConfirmationView: View { + private static let providerKeyLength = 12 + @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager @EnvironmentObject var app: AppViewModel @EnvironmentObject var activityList: ActivityListViewModel @@ -304,6 +306,13 @@ struct SendConfirmationView: View { PaykitAmountText(amount: preparingRequest.amount, size: .display) .accessibilityIdentifier("ReviewAmount") } + } else if let quote = app.paykitSwapRoute?.quote { + MoneyStack( + sats: Int(wallet.sendAmountSats ?? quote.deliveredSat), + showSymbol: true, + testIdPrefix: "ReviewAmount", + onTap: navigateToAmount + ) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), @@ -325,7 +334,9 @@ struct SendConfirmationView: View { .padding(.bottom, oneOffPaymentRequest == nil ? 44 : 24) if showDetails { - if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { + if let route = app.paykitSwapRoute, let quote = route.quote { + swapRouteView(route, quote: quote) + } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { lightningView(invoice) @@ -653,6 +664,62 @@ struct SendConfirmationView: View { } } + func swapRouteView(_ route: PaykitSwapRoute, quote: PubkySwapRouteQuote) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + SendSectionView(t("wallet__send_from")) { + NumberPadActionButton( + text: t("wallet__spending__title"), + color: .purpleAccent, + variant: .secondary, + disabled: true + ) {} + .accessibilityIdentifier("SendConfirmAssetButton") + } + + Spacer(minLength: 16) + + if let contact = contactPaymentContact { + SendSectionView(contactSectionTitle) { + contactRecipient(contact) + } + } + } + + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 0) { + Image("bolt-hollow") + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + BodySSBText("\(t("fee__instant__title")) (") + MoneyText(sats: Int(quote.totalFeeSat), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + } + + SendSectionView(t("wallet__send_swap_route")) { + BodySSBText("Pubky Swaps · \(route.providerName ?? quote.providerPubky.ellipsis(maxLength: Self.providerKeyLength))") + .lineLimit(2) + .accessibilityIdentifier("ReviewSwapRoute") + } + } + + SendSectionView(t("wallet__tags")) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(tagManager.selectedTagsArray, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { tagManager.removeTagFromSelection(tag) }) + } + AddTagButton(onPress: { navigationPath.append(.tag) }) + .accessibilityIdentifier("TagsAddSend") + } + } + } + } + } + func lightningView(_ invoice: LightningInvoice) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -903,6 +970,8 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) + } else if let quote = app.paykitSwapRoute?.quote { + await performSwapRoutedPayment(quote) } else { try await performPayment(isAutomatic: isAutomatic) } @@ -1166,6 +1235,28 @@ struct SendConfirmationView: View { return note } + private func performSwapRoutedPayment(_ quote: PubkySwapRouteQuote) async { + isSubmittingPayment = true + defer { isSubmittingPayment = false } + do { + let payment = try await PubkySwapsRouteService.commit(quoteId: quote.quoteId) + Logger.info("Committed Pubky Swaps route \(payment.swapId) to \(payment.destinationAddress)", context: "SendConfirmation") + // The hold invoice settles only after the claim reveals the preimage, so its payment stays pending until then. + let paymentHash = try await LightningService.shared.send(bolt11: payment.invoice) + let claimTxid = try await PubkySwapsRouteService.awaitClaim(swapId: payment.swapId) + Logger.info("Claimed Pubky Swaps route \(payment.swapId) in \(claimTxid)", context: "SendConfirmation") + navigationPath.append(.success(paymentId: String(paymentHash))) + } catch { + Logger.error("Pubky Swaps route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + ))) + } + } + private func performPayment(isAutomatic: Bool) async throws { let paymentActivity = PaykitPaymentActivity.shared.begin() defer { PaykitPaymentActivity.shared.end(paymentActivity) } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index dfe6a4608..768e80997 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -839,8 +839,8 @@ struct SendSheet: View { } } - // Validate onchain payment balance (for pure onchain invoices) - if let onchainInvoice = app.scannedOnchainInvoice { + // Validate onchain payment balance (for pure onchain invoices); a swap route pays it from spending + if app.paykitSwapRoute == nil, let onchainInvoice = app.scannedOnchainInvoice { let onchainBalance = max( LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0, hwWalletManager.maximumFundingBalanceSats From 37c477564616682de1a34aeeba089c9b65e2b795 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 05:26:13 +0100 Subject: [PATCH 2/6] docs: add the paykit swap route journey --- journeys/README.md | 1 + journeys/paykit-swap-route.xml | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 journeys/paykit-swap-route.xml diff --git a/journeys/README.md b/journeys/README.md index 8a219926c..d65c2964f 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -184,6 +184,7 @@ Known naming differences: | Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` (the same tag on every row) | `PubkyChoiceRingLookup_` (bare z32 key) | | Contact import overview profile and friend count | — *(no tag; read the "Import" title and the "N friends" text)* | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | | Lightning Connections add connection | `add_connection_button` | `NavigationAction` | +| Send contact row | `SendContact_` | `SendContact-` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one diff --git a/journeys/paykit-swap-route.xml b/journeys/paykit-swap-route.xml new file mode 100644 index 000000000..3499e9490 --- /dev/null +++ b/journeys/paykit-swap-route.xml @@ -0,0 +1,29 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet that holds only a spending + balance, routes through a Pubky Swaps provider found among the user's own contacts, without the user picking a + swap, and that the contact receives the exact amount on chain. + + Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, + E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver, launched with the + -PUBKY_SWAPS_ROUTES=true argument; bitkit-docker's default stack with the marketplace, payment-requests and + lnurl-pay profiles; the provider running against bitkit-docker's LND and funded. The simulator reaches the host's + loopback directly, so it needs no port forwarding. + The wallet has a spending balance from an LNURL channel with bitkit-docker's LND and an empty savings balance, + a Pubky profile on the local homeserver, and both the provider's and the fixture-issuer's pubkys saved as contacts. + The contact is bitkit-docker's fixture-issuer, which publishes only a btc-regtest-p2wpkh endpoint. + + + Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 + Tap Send (tag "Send"), then Contact (tag "RecipientContact") + Verify the contact list shows both the fixture-issuer and the provider, and tap the fixture-issuer (tag "SendContact-" followed by its pubky) + Verify the amount screen (tag "SendAmount") offers SPENDING as the source + Enter 20000 and tap Continue (tag "ContinueAmount") + Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") + Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Pubky Swaps" followed by the provider's shortened pubky + Swipe to pay (tag "GRAB") + Once the provider logs "HTLC funded; awaiting client claim", mine one block on bitkit-docker's bitcoind + Verify the Bitcoin Sent screen (tag "SendSuccess") is visible + Verify the provider logs "client claimed; hold invoice settled", and bitcoind shows the claim transaction paying exactly 0.0002 BTC to the fixture-issuer's address + + From 7f9355778841697b489a4657be439888eb5df104 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 05:45:19 +0100 Subject: [PATCH 3/6] chore: add changelog fragment --- changelog.d/next/1170.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1170.added.md diff --git a/changelog.d/next/1170.added.md b/changelog.d/next/1170.added.md new file mode 100644 index 000000000..d7dfa8025 --- /dev/null +++ b/changelog.d/next/1170.added.md @@ -0,0 +1 @@ +Bitkit can pay a contact who only accepts on-chain bitcoin from your spending balance, through a Pubky Swaps provider among your contacts. From 01198d3f80d55fb2a80aa5b40bd0ada2f457ff93 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 13:33:25 +0100 Subject: [PATCH 4/6] chore: move to bitkit-core 0.8.0-swaps.3 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Services/PubkySwapsRouteService.swift | 6 ++++-- Bitkit/Views/Wallets/Send/SendAmountView.swift | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 96c61ddba..8d3f23880 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-swaps.2"; + version = "0.8.0-swaps.3"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d86e9115d..220809c02 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "1abc3ff8bc40848951d19ae74259f004b78ce5be", - "version" : "0.8.0-swaps.2" + "revision" : "f701e77dd004f3e89c2f45dc21edf7e03963194a", + "version" : "0.8.0-swaps.3" } }, { diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift index 0b17a0be9..5669d936e 100644 --- a/Bitkit/Services/PubkySwapsRouteService.swift +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -37,7 +37,8 @@ enum PubkySwapsRouteService { endpoint: PaykitEndpoint, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64, - contacts: [String] + contacts: [String], + serviceLegs: [ServiceRouteLeg] = [] ) async throws -> PaykitRoute { // Providers come from the user's own graph: every contact is asked for its swap offer. let candidates = contacts + Env.pubkySwapsProviders @@ -50,7 +51,8 @@ enum PubkySwapsRouteService { lightningSpendableSat: lightningSpendableSats, onchainSpendableSat: onchainSpendableSats, providers: candidates, - feeRateSatVb: claimFeeRateSatVb + feeRateSatVb: claimFeeRateSatVb, + serviceLegs: serviceLegs ), network: Env.bitkitCoreNetwork, mnemonic: mnemonic, diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 12c84a1fe..7efaf380f 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -475,6 +475,9 @@ struct SendAmountView: View { return true case let .none(reason): app.toast(type: .error, title: t("slashtags__error_pay_title"), description: reason) + case let .service(leg): + Logger.error("Unexpected service route \(leg.service)", context: "SendAmountView") + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) case .direct: app.paykitSwapRoute = nil app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) From 08cc3335fd087e8b7a510f98f764296f9577033e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 20:04:06 +0100 Subject: [PATCH 5/6] feat: return swap routes to savings after a wallet restore --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/AppScene.swift | 24 +++++++++++++++++++ .../Localization/ar.lproj/Localizable.strings | 2 ++ .../Localization/ca.lproj/Localizable.strings | 2 ++ .../Localization/cs.lproj/Localizable.strings | 2 ++ .../Localization/de.lproj/Localizable.strings | 2 ++ .../Localization/el.lproj/Localizable.strings | 2 ++ .../Localization/en.lproj/Localizable.strings | 2 ++ .../es-419.lproj/Localizable.strings | 2 ++ .../Localization/es.lproj/Localizable.strings | 2 ++ .../Localization/fr.lproj/Localizable.strings | 2 ++ .../Localization/it.lproj/Localizable.strings | 2 ++ .../Localization/nl.lproj/Localizable.strings | 2 ++ .../Localization/pl.lproj/Localizable.strings | 2 ++ .../pt-BR.lproj/Localizable.strings | 2 ++ .../Localization/pt.lproj/Localizable.strings | 2 ++ .../Localization/ru.lproj/Localizable.strings | 2 ++ Bitkit/Services/PubkySwapsRouteService.swift | 16 +++++++++++++ 19 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 8d3f23880..beb76bd3b 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-swaps.3"; + version = "0.8.0-swaps.4"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 220809c02..aa65c923d 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "f701e77dd004f3e89c2f45dc21edf7e03963194a", - "version" : "0.8.0-swaps.3" + "revision" : "467036fee61bca6280333ea00a0e2aaefcc7291f", + "version" : "0.8.0-swaps.4" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 4d6c4088c..8ddab376a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1199,6 +1199,30 @@ struct AppScene: View { widgets.objectWillChange.send() await pubkyProfile.initialize() await startWallet(completingBackupRestore: true) + restorePubkySwaps() + } + + /// Finishes swap routes a restored wallet left in flight: each is rebuilt from its provider's listing and claimed to + /// Savings, since the contact's address did not survive the restore, so the user learns the payment came back. + private func restorePubkySwaps() { + guard PubkySwapsRouteService.isEnabled else { return } + let contacts = contactsManager.contacts.map(\.publicKey) + Task { + let restored: [String] + do { + restored = try await PubkySwapsRouteService.restoreSwaps(contacts: contacts) + } catch { + Logger.warn("Failed to restore Pubky Swaps routes: \(error)", context: "AppScene") + return + } + Logger.info("Restored \(restored.count) Pubky Swaps routes", context: "AppScene") + for swapId in restored { + Task { + guard await (try? PubkySwapsRouteService.awaitClaim(swapId: swapId)) != nil else { return } + app.toast(type: .info, title: t("wallet__swap_route_restored_title"), description: t("wallet__swap_route_restored_text")) + } + } + } } private func retryWalletStart() async { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index a9fa1a334..ee49ced0f 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -994,6 +994,8 @@ "wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ."; "wallet__send_to" = "إلى"; "wallet__send_swap_route" = "المسار"; +"wallet__swap_route_restored_text" = "دفعة لجهة اتصال قاطعتها الاستعادة عادت إلى مدخراتك."; +"wallet__swap_route_restored_title" = "أُعيدت الدفعة إلى المدخرات"; "wallet__recipient_contact" = "جهة الاتصال"; "wallet__recipient_invoice" = "لصق فاتورة"; "wallet__recipient_manual" = "إدخال يدوي"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 3cb95cce5..6a0d236e2 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1113,6 +1113,8 @@ "wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons."; "wallet__send_to" = "A"; "wallet__send_swap_route" = "Ruta"; +"wallet__swap_route_restored_text" = "Un pagament a un contacte que la restauració va interrompre ha tornat als teus estalvis."; +"wallet__swap_route_restored_title" = "Pagament retornat a Estalvis"; "wallet__recipient_contact" = "Contacte"; "wallet__recipient_invoice" = "Enganxa factura"; "wallet__recipient_manual" = "Introdueix manualment"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 9397f044b..bf509e9bb 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1220,6 +1220,8 @@ "wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit."; "wallet__send_to" = "Komu"; "wallet__send_swap_route" = "Trasa"; +"wallet__swap_route_restored_text" = "Platba kontaktu přerušená obnovou se vrátila do vašich úspor."; +"wallet__swap_route_restored_title" = "Platba vrácena do Úspor"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Vložit fakturu"; "wallet__recipient_manual" = "Zadat ručně"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 0af9688ea..b540e7acd 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1218,6 +1218,8 @@ "wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden."; "wallet__send_to" = "An"; "wallet__send_swap_route" = "Route"; +"wallet__swap_route_restored_text" = "Eine Zahlung an einen Kontakt, die die Wiederherstellung unterbrochen hat, ist in deine Ersparnisse zurückgeflossen."; +"wallet__swap_route_restored_title" = "Zahlung an Ersparnisse zurückgegeben"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Rechnung einfügen"; "wallet__recipient_manual" = "Manuell eingeben"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 2b82af606..ff8e65286 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1012,6 +1012,8 @@ "wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα."; "wallet__send_to" = "Προς"; "wallet__send_swap_route" = "Διαδρομή"; +"wallet__swap_route_restored_text" = "Μια πληρωμή σε επαφή που διέκοψε η επαναφορά επέστρεψε στις αποταμιεύσεις σου."; +"wallet__swap_route_restored_title" = "Η πληρωμή επέστρεψε στις Αποταμιεύσεις"; "wallet__recipient_contact" = "Επαφή"; "wallet__recipient_invoice" = "Επικόλληση Τιμολογίου"; "wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9679b0358..0fc8a33ad 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1233,6 +1233,8 @@ "wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds."; "wallet__send_to" = "To"; "wallet__send_swap_route" = "Route"; +"wallet__swap_route_restored_text" = "A contact payment the restore interrupted came back to your savings."; +"wallet__swap_route_restored_title" = "Payment returned to Savings"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; "wallet__recipient_manual" = "Enter Manually"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 095799e6a..9fcc07baa 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1223,6 +1223,8 @@ "wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Ruta"; +"wallet__swap_route_restored_text" = "Un pago a un contacto que la restauración interrumpió volvió a tus ahorros."; +"wallet__swap_route_restored_title" = "Pago devuelto a Ahorros"; "wallet__recipient_contact" = "Contacto"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index ec79fdcd1..f3700364b 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1130,6 +1130,8 @@ "wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Ruta"; +"wallet__swap_route_restored_text" = "Un pago a un contacto que la restauración interrumpió ha vuelto a tus ahorros."; +"wallet__swap_route_restored_title" = "Pago devuelto a Ahorros"; "wallet__recipient_contact" = "Contactar"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 459955343..c705e6364 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1231,6 +1231,8 @@ "wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes."; "wallet__send_to" = "Pour"; "wallet__send_swap_route" = "Itinéraire"; +"wallet__swap_route_restored_text" = "Un paiement à un contact interrompu par la restauration est revenu sur ton épargne."; +"wallet__swap_route_restored_title" = "Paiement renvoyé vers l'Épargne"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Coller la facture"; "wallet__recipient_manual" = "Entrer manuellement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 772041734..3ba692e73 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1196,6 +1196,8 @@ "wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi."; "wallet__send_to" = "A"; "wallet__send_swap_route" = "Percorso"; +"wallet__swap_route_restored_text" = "Un pagamento a un contatto interrotto dal ripristino è tornato nei tuoi risparmi."; +"wallet__swap_route_restored_title" = "Pagamento restituito ai Risparmi"; "wallet__recipient_contact" = "Contatto"; "wallet__recipient_invoice" = "Incolla fattura"; "wallet__recipient_manual" = "Inserisci Manualmente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index fdad7d04f..fa43eb8ac 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1226,6 +1226,8 @@ "wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken."; "wallet__send_to" = "Naar"; "wallet__send_swap_route" = "Route"; +"wallet__swap_route_restored_text" = "Een betaling aan een contact die door het herstel werd onderbroken, is teruggekomen op je spaargeld."; +"wallet__swap_route_restored_title" = "Betaling teruggestort naar Spaargeld"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Factuur Plakken"; "wallet__recipient_manual" = "Handmatig Invoeren"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 3bb186427..afb2e186a 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1230,6 +1230,8 @@ "wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund."; "wallet__send_to" = "Do"; "wallet__send_swap_route" = "Trasa"; +"wallet__swap_route_restored_text" = "Płatność do kontaktu przerwana przez przywracanie wróciła do twoich oszczędności."; +"wallet__swap_route_restored_title" = "Płatność zwrócona do Oszczędności"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Wklej fakturę"; "wallet__recipient_manual" = "Wprowadź ręcznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 1b1323a19..ce4efd00c 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1231,6 +1231,8 @@ "wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Rota"; +"wallet__swap_route_restored_text" = "Um pagamento a um contato que a restauração interrompeu voltou para sua poupança."; +"wallet__swap_route_restored_title" = "Pagamento devolvido à Poupança"; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 95a31b7ba..227399005 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -985,6 +985,8 @@ "wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Rota"; +"wallet__swap_route_restored_text" = "Um pagamento a um contacto que o restauro interrompeu voltou às tuas poupanças."; +"wallet__swap_route_restored_title" = "Pagamento devolvido às Poupanças"; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 55f1c59f6..1dfaf9bc5 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1215,6 +1215,8 @@ "wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд."; "wallet__send_to" = "Кому"; "wallet__send_swap_route" = "Маршрут"; +"wallet__swap_route_restored_text" = "Платёж контакту, прерванный восстановлением, вернулся в ваши сбережения."; +"wallet__swap_route_restored_title" = "Платёж возвращён в Сбережения"; "wallet__recipient_contact" = "Контакт"; "wallet__recipient_invoice" = "Вставить Инвойс"; "wallet__recipient_manual" = "Ввести Вручную"; diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift index 5669d936e..76d579a02 100644 --- a/Bitkit/Services/PubkySwapsRouteService.swift +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -60,6 +60,22 @@ enum PubkySwapsRouteService { ) } + /// Rebuilds the swaps this wallet started from each provider's listing, after a restore from the seed. The listing + /// does not carry the contact's address, so a restored swap claims to a fresh Savings address. Returns the swap ids. + static func restoreSwaps(contacts: [String]) async throws -> [String] { + let (mnemonic, passphrase) = try secrets() + let payoutAddress = try await LightningService.shared.newAddress() + return try await pubkySwapsRestore( + providers: contacts + Env.pubkySwapsProviders, + electrumUrl: Env.electrumServerUrl, + payoutAddress: payoutAddress, + feeRateSatVb: claimFeeRateSatVb, + network: Env.bitkitCoreNetwork, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + static func commit(quoteId: String) async throws -> PubkySwapRoutePayment { let (mnemonic, passphrase) = try secrets() return try await pubkySwapsCommitRoute( From 83940d332a08b27c5a725dbf5a4295e62193c610 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 23:04:41 +0100 Subject: [PATCH 6/6] docs: add the paykit swap restore journey --- journeys/paykit-swap-restore.xml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 journeys/paykit-swap-restore.xml diff --git a/journeys/paykit-swap-restore.xml b/journeys/paykit-swap-restore.xml new file mode 100644 index 000000000..6514df12a --- /dev/null +++ b/journeys/paykit-swap-restore.xml @@ -0,0 +1,26 @@ + + + Proves that a swap route the wallet left in flight before its claim is finished after the wallet is wiped and + restored from its recovery phrase: the restore finds the swap at its provider, claims it to Savings (the contact's + address does not survive the restore), and tells the user the payment was returned to Savings. + + Preconditions: as paykit-swap-route.xml, with the provider running against bitkit-docker (`--no-homeserver`) and + the build's PUBKY_SWAPS_PROVIDERS naming it. A throwaway regtest wallet restored from a recovery phrase, with a + spending balance from an LNURL channel, an empty savings balance, a Pubky profile, and the provider and the + fixture-issuer saved as contacts. Start a 20 000 sat payment to the fixture-issuer through the swap route, wait + until the provider logs "HTLC funded; awaiting client claim", and force-stop the app before any block is mined. + Then delete the app, reset the simulator's keychain (`xcrun simctl keychain reset`), reinstall it, launch it + with the same arguments, and enter the same recovery phrase on the restore screen. On iOS the received sheet opens + once the claim confirms, after the toast, so the toast shows before the sheet rather than after closing it. + + + Tap Restore (tag "RestoreButton") and wait for the Wallet Restored screen, then tap Get Started (tag "GetStartedButton") + Verify the savings balance (tag "ActivitySavings") shows 0 + Mine one block on bitkit-docker's bitcoind, so the provider's lockup confirms + Verify the provider logs "client claimed; hold invoice settled" + Mine one more block, so the claim confirms + Verify the toast "Payment returned to Savings" is visible + Verify the "Received Bitcoin" sheet shows 20 000, then close it + Verify the savings balance (tag "ActivitySavings") shows 20 000, and bitcoind shows the claim transaction paying exactly 0.0002 BTC to the wallet's own address + +