diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 507f9cb4e..e4bc9fec8 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-rc11"; + 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 8401f1e44..8dc479011 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" : "f701e77dd004f3e89c2f45dc21edf7e03963194a", + "version" : "0.8.0-swaps.3" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4cdd9ea87..cbfe04f82 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/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 92305979d..9d334151b 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -14,6 +14,8 @@ import Observation @Observable @MainActor final class SparkManager: ChannelFreeLightningRail { static let shared = SparkManager() + nonisolated static let routeService = "spark" + private nonisolated static let routeAtomicity = "trusted" private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() /// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. nonisolated static var exitStateChangedPublisher: AnyPublisher { @@ -253,6 +255,41 @@ final class SparkManager: ChannelFreeLightningRail { try await refresh() } + /// A Paykit route that withdraws exactly `amountSats` to the contact's on-chain `endpoint` at medium speed, with + /// Spark's fee on top, or nil while Spark cannot send it. bitkit-core weighs it against the Pubky Swaps providers. + func withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: UInt64) async throws -> ServiceRouteLeg? { + guard isReady, balanceSats >= amountSats else { return nil } + let prepared = try await prepareSend(paymentRequest: endpoint.value, amountSats: amountSats) + guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { + throw AppError(message: "Unexpected Spark payment method", debugMessage: "\(prepared.paymentMethod)") + } + let debitSats = amountSats + feeQuote.speedMedium.userFeeSat + guard !feeQuote.isEstimate, balanceSats >= debitSats else { return nil } + return ServiceRouteLeg( + service: Self.routeService, + endpoint: endpoint, + deliveredSat: amountSats, + debitSat: debitSats, + atomicity: Self.routeAtomicity, + validUntilUnix: feeQuote.expiresAt + ) + } + + /// Withdraws exactly `amountSats` to the on-chain `address` at medium speed and returns the withdrawal's txid. + func withdraw(to address: String, amountSats: UInt64) async throws -> String { + let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) + let response = try await requireSdk().sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + try await refresh() + if case let .withdraw(txId) = response.payment.details { + return txId + } + return response.payment.id + } + /// 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 diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 378116611..79631ab54 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1021,6 +1021,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 29bfe8304..2504f2c07 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1140,6 +1140,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 396e45590..9ea9c25c4 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1247,6 +1247,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 d64e58a53..08c2925c1 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1245,6 +1245,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 a5b6b9cca..3411f70da 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1039,6 +1039,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 681b0beaa..7d08c3d34 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1260,6 +1260,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 4a27d5942..84df061d6 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1250,6 +1250,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 3a7ce1d33..85d114a85 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1157,6 +1157,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 ef9d53d4e..228ad065e 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1258,6 +1258,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 b8a40bd90..fd9a035f4 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1223,6 +1223,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 bc861937e..5a9f00cc6 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1253,6 +1253,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 d5a37f5cc..7009c1af8 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1257,6 +1257,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 3486c7103..4e946ebf0 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1258,6 +1258,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 45fe49f1d..8fd18278d 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1012,6 +1012,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 ea2c9dfff..e61518742 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1242,6 +1242,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..2e13d9375 --- /dev/null +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -0,0 +1,128 @@ +import BitkitCore +import Foundation + +/// A Paykit payment routed through a Pubky Swaps provider or a service the wallet holds a balance on: the endpoint, +/// the route once quoted (a provider's quote or a service leg), and the provider's name. +struct PaykitSwapRoute: Equatable { + let endpoint: PaykitEndpoint + var quote: PubkySwapRouteQuote? + var providerName: String? + var serviceLeg: ServiceRouteLeg? + + var deliveredSat: UInt64? { + quote?.deliveredSat ?? serviceLeg?.deliveredSat + } + + var totalFeeSat: UInt64? { + quote?.totalFeeSat ?? serviceLeg.map { $0.debitSat - $0.deliveredSat } + } +} + +/// 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 + let serviceLegs = await serviceLegs(endpoint: endpoint, amountSats: amountSats) + 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, + serviceLegs: serviceLegs + ), + network: Env.bitkitCoreNetwork, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + + /// Legs through the services the wallet holds a balance on, which compete with the providers on fee. + private static func serviceLegs(endpoint: PaykitEndpoint, amountSats: UInt64) async -> [ServiceRouteLeg] { + do { + return try await SparkManager.shared.withdrawalRouteLeg(endpoint: endpoint, amountSats: amountSats).map { [$0] } ?? [] + } catch { + Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PubkySwapsRoute") + return [] + } + } + + /// Runs a service leg the router picked and returns the txid that pays the contact. + static func pay(_ leg: ServiceRouteLeg) async throws -> String { + guard leg.service == SparkManager.routeService else { + throw AppError(message: "Unsupported route service", debugMessage: leg.service) + } + return try await SparkManager.shared.withdraw(to: leg.endpoint.value, amountSats: leg.deliveredSat) + } + + 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 8f102d8b5..7064fe90c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -167,6 +167,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? @@ -1019,6 +1020,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( @@ -1364,6 +1377,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..adbaf6f70 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,43 @@ 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.serviceLeg = nil + 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 let .service(leg): + var quoted = route + quoted.quote = nil + quoted.serviceLeg = leg + app.paykitSwapRoute = quoted + return true + 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 22e5900be..299b5eb3a 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 @@ -320,6 +322,13 @@ struct SendConfirmationView: View { PaykitAmountText(amount: preparingRequest.amount, size: .display) .accessibilityIdentifier("ReviewAmount") } + } else if let deliveredSat = app.paykitSwapRoute?.deliveredSat { + MoneyStack( + sats: Int(wallet.sendAmountSats ?? deliveredSat), + showSymbol: true, + testIdPrefix: "ReviewAmount", + onTap: navigateToAmount + ) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), @@ -341,7 +350,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 totalFeeSat = route.totalFeeSat { + swapRouteView(route, totalFeeSat: totalFeeSat) + } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { lightningView(invoice) @@ -669,6 +680,69 @@ struct SendConfirmationView: View { } } + func swapRouteView(_ route: PaykitSwapRoute, totalFeeSat: UInt64) -> 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(totalFeeSat), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + } + + SendSectionView(t("wallet__send_swap_route")) { + BodySSBText(routeName(route)) + .lineLimit(2) + .accessibilityIdentifier("ReviewSwapRoute") + } + } + + SendSectionView(t("wallet__tags")) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(tagManager.selectedTagsArray, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { tagManager.removeTagFromSelection(tag) }) + } + AddTagButton(onPress: { navigationPath.append(.tag) }) + .accessibilityIdentifier("TagsAddSend") + } + } + } + } + } + + private func routeName(_ route: PaykitSwapRoute) -> String { + if let quote = route.quote { + return "Pubky Swaps · \(route.providerName ?? quote.providerPubky.ellipsis(maxLength: Self.providerKeyLength))" + } + return "Spark" + } + func lightningView(_ invoice: LightningInvoice) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -919,6 +993,10 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) + } else if let leg = app.paykitSwapRoute?.serviceLeg { + await performServiceRoutedPayment(leg) + } else if let quote = app.paykitSwapRoute?.quote { + await performSwapRoutedPayment(quote) } else { try await performPayment(isAutomatic: isAutomatic) } @@ -1182,6 +1260,46 @@ struct SendConfirmationView: View { return note } + private func performServiceRoutedPayment(_ leg: ServiceRouteLeg) async { + isSubmittingPayment = true + defer { isSubmittingPayment = false } + do { + let txid = try await PubkySwapsRouteService.pay(leg) + Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") + navigationPath.append(.success(paymentId: txid)) + } catch { + Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + ))) + } + } + + private func 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 450b1f8af..d42aba65e 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -848,8 +848,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 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. diff --git a/changelog.d/next/1191.added.md b/changelog.d/next/1191.added.md new file mode 100644 index 000000000..9dd64f6e8 --- /dev/null +++ b/changelog.d/next/1191.added.md @@ -0,0 +1 @@ +Paykit contacts who only accept on-chain bitcoin can be paid from Spark when that costs less than a swap. diff --git a/journeys/README.md b/journeys/README.md index 1b4b551cd..25b03e7be 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -185,6 +185,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-spark-route.xml b/journeys/paykit-spark-route.xml new file mode 100644 index 000000000..9a0cd6d97 --- /dev/null +++ b/journeys/paykit-spark-route.xml @@ -0,0 +1,28 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet whose spending balance is held + on Spark, routes through a Spark withdrawal straight to the contact's address, without the user picking a route, + and that the contact receives the exact amount on chain. + + Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, + E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver, launched with the + -PUBKY_SWAPS_ROUTES=true argument; bitkit-docker's default stack with the marketplace and payment-requests + profiles. The simulator reaches the host's loopback directly, so it needs no port forwarding. + The wallet has no channels and an empty savings balance, a Spark balance of at least 25 000 sats from Lightspark's + regtest faucet sent to the deposit address on the Spark screen, a Pubky profile on the local homeserver, and the + fixture-issuer's pubky saved as a contact. The contact is bitkit-docker's fixture-issuer, which publishes only a + btc-regtest-p2wpkh endpoint; Spark pays it on Lightspark's regtest chain. + + + Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 + Tap Send (tag "Send"), then Contact (tag "RecipientContact") + Tap the fixture-issuer (tag "SendContact-" followed by its pubky) + Verify the amount screen (tag "SendAmount") offers SPENDING as the source + Enter 20000 and tap Continue (tag "ContinueAmount") + Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") + Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Spark" + Swipe to pay (tag "GRAB") + Verify the Bitcoin Sent screen (tag "SendSuccess") shows 20 000 + Verify Lightspark's regtest mempool shows the withdrawal transaction paying exactly 20 000 sats to the fixture-issuer's address + + diff --git a/journeys/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 + +