From 535e291ae19292ecace62228d5bc6feb1fb7b3b1 Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 16:39:27 +0100 Subject: [PATCH 01/10] feat: lightning-first primary node --- Bitkit.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 5 +- Bitkit/Components/TabBar/TabBar.swift | 3 +- Bitkit/Components/Widgets/Suggestions.swift | 2 +- Bitkit/Constants/Env.swift | 12 ++++ Bitkit/Models/ReceiveLiquidityDecision.swift | 15 ++++- Bitkit/Services/LightningService.swift | 53 ++++++++++++++- Bitkit/Utilities/Errors.swift | 12 ++++ Bitkit/ViewModels/TransferViewModel.swift | 65 +++++++++++++++++-- Bitkit/ViewModels/WalletViewModel.swift | 17 ++++- .../Sheets/TransferUnavailableSheet.swift | 10 +++ .../Views/Wallets/Receive/ReceiveEdit.swift | 3 +- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 6 +- .../Views/Wallets/SavingsWalletScreen.swift | 2 +- .../Views/Wallets/SpendingWalletScreen.swift | 2 +- .../ReceiveLiquidityDecisionTests.swift | 32 +++++++++ 16 files changed, 221 insertions(+), 24 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..8ce343881 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1221,10 +1221,10 @@ }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/synonymdev/ldk-node"; + repositoryURL = "https://github.com/ovitrif/ldk-node"; requirement = { - kind = exactVersion; - version = "0.7.0-rc.78"; + kind = revision; + revision = 46ecee6b1e8ef4e2d6c258767b0c3eee8be3cbe6; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..60f3e7479 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -13,10 +13,9 @@ { "identity" : "ldk-node", "kind" : "remoteSourceControl", - "location" : "https://github.com/synonymdev/ldk-node", + "location" : "https://github.com/ovitrif/ldk-node", "state" : { - "revision" : "1976754dd49886e102f2b3132d5f700d6aabd8d7", - "version" : "0.7.0-rc.78" + "revision" : "46ecee6b1e8ef4e2d6c258767b0c3eee8be3cbe6" } }, { diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index b6d10a790..491cd7bce 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -95,7 +95,8 @@ struct TabBar: View { wallet: WalletViewModel, hasPendingTransfersToSpending: Bool ) -> Bool { - isSpendingWallet && !wallet.canCreateReceiveLightningInvoice(amountSats: nil) && !hasPendingTransfersToSpending + isSpendingWallet && Env.primaryNode == nil && !wallet.canCreateReceiveLightningInvoice(amountSats: nil) + && !hasPendingTransfersToSpending } } diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 3936ba524..1f1567b0a 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -374,7 +374,7 @@ struct Suggestions: View { case .hardware: sheets.showSheet(.hardwareConnect) case .transferToSpending: - sheets.showSheet(.transferUnavailable) + showTransferToSpending(sheets: sheets, navigation: navigation) } if let route { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 90aed6ed0..ff8f0eb1b 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -157,6 +157,18 @@ enum Env { // MARK: wallet services + /// Beignet primary run for the Lightning-first mainnet test, reached through a public TCP relay. + private static let primaryNodeUriMainnet = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101" + + /// The Lightning-first primary node: it funds channels just in time and grows them by splicing. + static var primaryNodeUri: String? { + network == .bitcoin ? primaryNodeUriMainnet : nil + } + + static var primaryNode: LnPeer? { + primaryNodeUri.flatMap { try? LnPeer(connection: $0) } + } + static let network: LDKNode.Network = { if isUnitTest { return .regtest diff --git a/Bitkit/Models/ReceiveLiquidityDecision.swift b/Bitkit/Models/ReceiveLiquidityDecision.swift index 7e3528e6c..7f2688990 100644 --- a/Bitkit/Models/ReceiveLiquidityDecision.swift +++ b/Bitkit/Models/ReceiveLiquidityDecision.swift @@ -13,8 +13,13 @@ enum ReceiveLiquidityDecision { static func canCreateLightningInvoice( hasReadyChannels: Bool, inboundCapacitySats: UInt64?, - invoiceAmountSats: UInt64? + invoiceAmountSats: UInt64?, + hasPrimaryNode: Bool = false ) -> Bool { + if hasPrimaryNode, (invoiceAmountSats ?? 0) > 0 { + return true + } + guard hasReadyChannels, let inboundCapacitySats else { return false } @@ -29,9 +34,13 @@ enum ReceiveLiquidityDecision { static func additionalLiquidityAction( source: ReceiveLiquiditySource, invoiceAmountSats: UInt64, - inboundCapacitySats: UInt64? + inboundCapacitySats: UInt64?, + hasPrimaryNode: Bool = false ) -> ReceiveAdditionalLiquidityAction { - guard source == .spending, needsInboundLiquidity(invoiceAmountSats: invoiceAmountSats, inboundCapacitySats: inboundCapacitySats), + guard source == .spending, !hasPrimaryNode, needsInboundLiquidity( + invoiceAmountSats: invoiceAmountSats, + inboundCapacitySats: inboundCapacitySats + ), (inboundCapacitySats ?? 0) > 0 else { return .none diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f24601b98..9553245b1 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -121,6 +121,9 @@ class LightningService { let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) + if let primary = Env.primaryNode { + builder.setPrimaryNode(nodeId: primary.nodeId) + } let resolvedElectrumServerUrl = electrumServerUrl ?? Env.electrumServerUrl @@ -264,6 +267,10 @@ class LightningService { try node.start() } + if let primary = Env.primaryNode { + try? await connectPeer(peer: primary) + } + #if !BITKIT_NOTIFICATION_EXTENSION do { try await reconcileWatchOnlyAccounts() @@ -372,7 +379,7 @@ class LightningService { try await ServiceQueue.background(.ldk) { let balances = node.listBalances() let idlePeerIds = Self.idlePeerIds( - peers: node.listPeers(), + peers: node.listPeers().filter { $0.nodeId != Env.primaryNode?.nodeId }, channels: node.listChannels(), lightningBalances: balances.lightningBalances, pendingSweeps: balances.pendingBalancesFromChannelClosures @@ -716,6 +723,36 @@ class LightningService { try await receiveMsats(amountMsats: amountSats.map { $0 * 1000 }, description: description, expirySecs: expirySecs) } + /// Creates an invoice the primary node delivers through a channel it opens or grows just in time, + /// accepting at most the fee it quotes now. Primaries authorize such a receive for ten minutes. + func receiveViaPrimary(amountSats: UInt64, description: String) async throws -> String { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + guard let primary = Env.primaryNode else { + throw AppError(message: "No primary node", debugMessage: "No primary node for \(Env.network)") + } + + let bolt11 = try await ServiceQueue.background(.ldk) { + if !node.listPeers().contains(where: { $0.nodeId == primary.nodeId && $0.isConnected }) { + try node.connect(nodeId: primary.nodeId, address: primary.address, persist: true) + } + let amountMsat = amountSats * 1000 + let quote = try node.quotePrimaryReceive(nodeId: primary.nodeId, amountMsat: amountMsat, targetInboundSats: 0) + Logger.info("Received primary quote: \(quote)") + return try node.receiveViaPrimary( + nodeId: primary.nodeId, + amountMsat: amountMsat, + description: Bolt11InvoiceDescription.direct(description: description), + expirySecs: 600, + maxFeeMsat: quote.feeMsat, + targetInboundSats: 0 + ) + } + + return bolt11.description + } + func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -933,6 +970,20 @@ class LightningService { } } + func spliceIn(channel: ChannelDetails, amountSats: UInt64) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + try node.spliceIn( + userChannelId: channel.userChannelId, + counterpartyNodeId: channel.counterpartyNodeId, + spliceAmountSats: amountSats + ) + } + } + func openChannel(peer: LnPeer, channelAmountSats: UInt64, pushToCounterpartySats: UInt64? = nil, channelConfig: ChannelConfig? = nil) async throws -> UserChannelId { diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index e259a1a19..f28bbf79f 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -383,6 +383,18 @@ struct AppError: LocalizedError { case let .LiquiditySourceUnavailable(message: ldkMessage): message = "Liquidity source unavailable" debugMessage = ldkMessage + case let .LiquiditySourceInsufficientFunds(message: ldkMessage): + message = "The primary node has too little funds for this amount" + debugMessage = ldkMessage + case let .LiquidityRequestRejected(message: ldkMessage): + message = "The primary node declined this receive" + debugMessage = ldkMessage + case let .LiquidityRequestTimedOut(message: ldkMessage): + message = "The primary node did not answer in time" + debugMessage = ldkMessage + case let .LiquidityReceiveAmountTooSmall(message: ldkMessage): + message = "The amount is too small to receive through the primary node" + debugMessage = ldkMessage case let .LiquidityFeeTooHigh(message: ldkMessage): message = "Liquidity fee too high" debugMessage = ldkMessage diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6bf25a561..402c786aa 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -86,6 +86,10 @@ class TransferViewModel: ObservableObject { onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, removeEvent: @escaping (String) -> Void ) async throws -> (channelId: String, fundingTxId: String?) { + if let readyChannel = lightningService.channels?.first(where: { $0.counterpartyNodeId == peer.nodeId && $0.isChannelReady }) { + return try await spliceIn(channel: readyChannel, amountSats: amountSats, onEvent: onEvent, removeEvent: removeEvent) + } + // Open the channel - this returns the user channel ID let userChannelId = try await lightningService.openChannel( peer: peer, @@ -133,6 +137,53 @@ class TransferViewModel: ObservableObject { return (actualChannelId, fundingTxId) } + /// Grows a ready channel with on-chain funds and tracks the transfer like a channel open. + private func spliceIn( + channel: ChannelDetails, + amountSats: UInt64, + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async throws -> (channelId: String, fundingTxId: String?) { + let channelCapture = ChannelPendingCapture() + let userChannelId = channel.userChannelId.description + let eventId = "manual-channel-splice-\(userChannelId)" + onEvent(eventId) { event in + if case let .splicePending(eventChannelId, eventUserChannelId, _, newFundingTxo) = event, + eventUserChannelId.description == userChannelId + { + Task { + await channelCapture.setChannelData( + channelId: eventChannelId.description, + fundingTxId: newFundingTxo.txid.description + ) + } + } + } + defer { removeEvent(eventId) } + + try await lightningService.spliceIn(channel: channel, amountSats: amountSats) + let (channelId, fundingTxId) = await Self.waitForChannelData(capture: channelCapture, maxAttempts: 10, initialDelayMs: 50) + guard let channelId else { + throw AppError( + message: "Timeout waiting for splice pending event", + debugMessage: "Did not receive splicePending event for userChannelId: \(userChannelId)" + ) + } + + do { + _ = try await transferService.createTransfer( + type: .toSpending, + amountSats: amountSats, + channelId: channelId, + fundingTxId: fundingTxId + ) + } catch { + Logger.error("Failed to create transfer tracking record", context: error.localizedDescription) + } + + return (channelId, fundingTxId) + } + /// Waits for a channel pending event and captures both the channel ID and funding transaction ID /// - Parameters: /// - userChannelId: The user channel ID returned from openChannel @@ -149,13 +200,13 @@ class TransferViewModel: ObservableObject { onEvent(eventId) { event in if case let .channelPending( - eventChannelId, - eventUserChannelId, - _, - _, - fundingTxo - ) = event, - // Match by user channel ID + eventChannelId, + eventUserChannelId, + _, + _, + fundingTxo + ) = event, + // Match by user channel ID eventUserChannelId.description == userChannelId { Task { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1d30a3e4c..dfe0d4347 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1307,6 +1307,15 @@ class WalletViewModel: ObservableObject { } func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: hasReadyChannels, + inboundCapacitySats: totalReadyInboundLightningSats, + invoiceAmountSats: amountSats, + hasPrimaryNode: Env.primaryNode != nil + ) + } + + private func canReceiveOnReadyChannels(amountSats: UInt64?) -> Bool { ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels: hasReadyChannels, inboundCapacitySats: totalReadyInboundLightningSats, @@ -1547,7 +1556,13 @@ class WalletViewModel: ObservableObject { try Task.checkCancellation() guard receiveRefreshID == refreshID else { return } var invoice = "" - if canCreateReceiveLightningInvoice(amountSats: amountSats) { + if !canReceiveOnReadyChannels(amountSats: amountSats), let amountSats, Env.primaryNode != nil { + do { + invoice = try await lightningService.receiveViaPrimary(amountSats: amountSats, description: note).lowercased() + } catch { + Logger.warn("Failed to create primary invoice for \(amountSats) sats: \(error)") + } + } else if canCreateReceiveLightningInvoice(amountSats: amountSats) { if forceRefreshBolt11 || wasUpdating || !hasMatchingInputs || cachedInvoice.isEmpty { invoice = try await createInvoice(amountSats: amountSats, note: note) } else if try await isReceiveInvoiceReusable(cachedInvoice) { diff --git a/Bitkit/Views/Sheets/TransferUnavailableSheet.swift b/Bitkit/Views/Sheets/TransferUnavailableSheet.swift index ab722797c..d8cda3d70 100644 --- a/Bitkit/Views/Sheets/TransferUnavailableSheet.swift +++ b/Bitkit/Views/Sheets/TransferUnavailableSheet.swift @@ -1,5 +1,15 @@ import SwiftUI +/// Opens a channel with the primary node, or explains that transfers need manual setup without one. +@MainActor +func showTransferToSpending(sheets: SheetViewModel, navigation: NavigationViewModel) { + guard let primaryNodeUri = Env.primaryNodeUri else { + sheets.showSheet(.transferUnavailable) + return + } + navigation.navigate(.fundManual(nodeUri: primaryNodeUri)) +} + struct TransferUnavailableSheetItem: SheetItem { let id: SheetID = .transferUnavailable let size: SheetSize = .large diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 61433e842..c95096309 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -234,7 +234,8 @@ struct ReceiveEdit: View { ReceiveLiquidityDecision.additionalLiquidityAction( source: liquiditySource, invoiceAmountSats: amountViewModel.amountSats, - inboundCapacitySats: wallet.totalReadyInboundLightningSats + inboundCapacitySats: wallet.totalReadyInboundLightningSats, + hasPrimaryNode: Env.primaryNode != nil ) } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index e666abb9f..cf888c127 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -221,7 +221,11 @@ struct ReceiveQr: View { .foregroundColor(.purpleAccent), isDisabled: wallet.nodeLifecycleState != .running ) { - navigationPath.append(.transferUnavailable) + if Env.primaryNode != nil, let route = editRoute(for: .spending) { + navigationPath.append(route) + } else { + navigationPath.append(.transferUnavailable) + } } } else if showDetails { VStack(spacing: 16) { diff --git a/Bitkit/Views/Wallets/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index d757856c3..6658929d8 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -122,7 +122,7 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + showTransferToSpending(sheets: sheets, navigation: navigation) } .accessibilityIdentifier("TransferToSpending") } diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index bae55e69a..e75fe4509 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -115,7 +115,7 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + showTransferToSpending(sheets: sheets, navigation: navigation) } .accessibilityIdentifier("TransferFromSavings") } diff --git a/BitkitTests/ReceiveLiquidityDecisionTests.swift b/BitkitTests/ReceiveLiquidityDecisionTests.swift index de2dfcdf7..7ad2b2a61 100644 --- a/BitkitTests/ReceiveLiquidityDecisionTests.swift +++ b/BitkitTests/ReceiveLiquidityDecisionTests.swift @@ -2,6 +2,38 @@ import XCTest final class ReceiveLiquidityDecisionTests: XCTestCase { + func testPrimaryNodeCoversFixedAmountsWithoutChannels() { + XCTAssertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: false, + inboundCapacitySats: nil, + invoiceAmountSats: 5000, + hasPrimaryNode: true + ) + ) + + XCTAssertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: false, + inboundCapacitySats: nil, + invoiceAmountSats: nil, + hasPrimaryNode: true + ) + ) + } + + func testPrimaryNodeNeedsNoAdditionalLiquidityAction() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + hasPrimaryNode: true + ), + .none + ) + } + func testLightningInvoiceRequiresReadyChannel() { XCTAssertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( From e6fe8f43fb89729a71a2270ebac1963c0140ee47 Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 16:42:36 +0100 Subject: [PATCH 02/10] chore: add changelog fragment --- changelog.d/next/1160.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1160.added.md diff --git a/changelog.d/next/1160.added.md b/changelog.d/next/1160.added.md new file mode 100644 index 000000000..f6b7bd3de --- /dev/null +++ b/changelog.d/next/1160.added.md @@ -0,0 +1 @@ +Lightning can receive and transfer to spending through a primary node that opens or grows a channel just in time. From 3aa6f678001393473d680a883a96426b7a0df7a6 Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 16:57:03 +0100 Subject: [PATCH 03/10] fix: format for swiftformat 0.64.0 --- Bitkit/Models/ReceiveLiquidityDecision.swift | 8 ++++---- Bitkit/ViewModels/TransferViewModel.swift | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Bitkit/Models/ReceiveLiquidityDecision.swift b/Bitkit/Models/ReceiveLiquidityDecision.swift index 7f2688990..5ebeeebf3 100644 --- a/Bitkit/Models/ReceiveLiquidityDecision.swift +++ b/Bitkit/Models/ReceiveLiquidityDecision.swift @@ -38,10 +38,10 @@ enum ReceiveLiquidityDecision { hasPrimaryNode: Bool = false ) -> ReceiveAdditionalLiquidityAction { guard source == .spending, !hasPrimaryNode, needsInboundLiquidity( - invoiceAmountSats: invoiceAmountSats, - inboundCapacitySats: inboundCapacitySats - ), - (inboundCapacitySats ?? 0) > 0 + invoiceAmountSats: invoiceAmountSats, + inboundCapacitySats: inboundCapacitySats + ), + (inboundCapacitySats ?? 0) > 0 else { return .none } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 402c786aa..50546c7fb 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -200,13 +200,13 @@ class TransferViewModel: ObservableObject { onEvent(eventId) { event in if case let .channelPending( - eventChannelId, - eventUserChannelId, - _, - _, - fundingTxo - ) = event, - // Match by user channel ID + eventChannelId, + eventUserChannelId, + _, + _, + fundingTxo + ) = event, + // Match by user channel ID eventUserChannelId.description == userChannelId { Task { From 8d652c81d2874b08d02d26c73ff764e7ecd5fc53 Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 18:39:54 +0100 Subject: [PATCH 04/10] feat: primary node override for regtest builds --- Bitkit/Constants/Env.swift | 3 ++- Bitkit/Info.plist | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index ff8f0eb1b..d4964f811 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -161,8 +161,9 @@ enum Env { private static let primaryNodeUriMainnet = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101" /// The Lightning-first primary node: it funds channels just in time and grows them by splicing. + /// A `PRIMARY_NODE_URI` config value overrides it, for regtest runs against a local primary. static var primaryNodeUri: String? { - network == .bitcoin ? primaryNodeUriMainnet : nil + configValue("PRIMARY_NODE_URI") ?? (network == .bitcoin ? primaryNodeUriMainnet : nil) } static var primaryNode: LnPeer? { diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 3ef85706e..4c7105ed8 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -40,6 +40,8 @@ $(E2E_HOMESERVER_PUBKY) E2E_LOCAL_HOST $(E2E_LOCAL_HOST) + PRIMARY_NODE_URI + $(PRIMARY_NODE_URI) E2E_NETWORK $(E2E_NETWORK) TREZOR_BRIDGE From 07e5199c8920154b0374777b84260da8cb1e8d9a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:41:36 +0100 Subject: [PATCH 05/10] feat: primary node pairing, fee review and transfers --- Bitkit.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- Bitkit/Components/TabBar/TabBar.swift | 5 +- Bitkit/Components/Widgets/Suggestions.swift | 2 +- Bitkit/Constants/Env.swift | 10 +- Bitkit/MainNavView.swift | 2 + Bitkit/Managers/ScannerManager.swift | 10 + Bitkit/Models/PrimaryNode.swift | 82 +++++ Bitkit/Models/ReceiveLiquidityDecision.swift | 21 +- Bitkit/Models/SettingsBackupConfig.swift | 2 + .../Localization/ar.lproj/Localizable.strings | 35 ++ .../Localization/ca.lproj/Localizable.strings | 35 ++ .../Localization/cs.lproj/Localizable.strings | 35 ++ .../Localization/de.lproj/Localizable.strings | 35 ++ .../Localization/el.lproj/Localizable.strings | 35 ++ .../Localization/en.lproj/Localizable.strings | 35 ++ .../es-419.lproj/Localizable.strings | 35 ++ .../Localization/es.lproj/Localizable.strings | 35 ++ .../Localization/fr.lproj/Localizable.strings | 35 ++ .../Localization/it.lproj/Localizable.strings | 35 ++ .../Localization/nl.lproj/Localizable.strings | 35 ++ .../Localization/pl.lproj/Localizable.strings | 35 ++ .../pt-BR.lproj/Localizable.strings | 35 ++ .../Localization/pt.lproj/Localizable.strings | 35 ++ .../Localization/ru.lproj/Localizable.strings | 35 ++ Bitkit/Services/LightningService.swift | 114 +++++-- Bitkit/Services/PrimaryNodeService.swift | 314 ++++++++++++++++++ Bitkit/ViewModels/NavigationViewModel.swift | 2 + Bitkit/ViewModels/TransferViewModel.swift | 68 +--- Bitkit/ViewModels/WalletViewModel.swift | 76 +++-- Bitkit/Views/Scanner/ScannerScreen.swift | 4 + .../Advanced/AdvancedSettingsView.swift | 8 + .../Advanced/PrimaryNodeSettingsScreen.swift | 165 +++++++++ .../Sheets/TransferUnavailableSheet.swift | 10 - .../Transfer/PrimaryTransferScreen.swift | 107 ++++++ .../Views/Wallets/Receive/ReceiveEdit.swift | 4 +- .../Receive/ReceivePrimaryConfirm.swift | 112 +++++++ Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 4 +- .../Views/Wallets/Receive/ReceiveSheet.swift | 7 +- .../Views/Wallets/SavingsWalletScreen.swift | 2 +- .../Views/Wallets/SpendingWalletScreen.swift | 2 +- BitkitTests/PrimaryNodeTests.swift | 57 ++++ .../ReceiveLiquidityDecisionTests.swift | 69 ++-- changelog.d/next/1160.added.md | 2 +- journeys/primary-node/README.md | 14 + journeys/primary-node/pair-primary-node.xml | 16 + .../primary-node/primary-offline-receive.xml | 13 + .../primary-node/receive-primary-quote.xml | 16 + .../primary-node/transfer-through-primary.xml | 16 + 49 files changed, 1703 insertions(+), 164 deletions(-) create mode 100644 Bitkit/Models/PrimaryNode.swift create mode 100644 Bitkit/Services/PrimaryNodeService.swift create mode 100644 Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift create mode 100644 Bitkit/Views/Transfer/PrimaryTransferScreen.swift create mode 100644 Bitkit/Views/Wallets/Receive/ReceivePrimaryConfirm.swift create mode 100644 BitkitTests/PrimaryNodeTests.swift create mode 100644 journeys/primary-node/README.md create mode 100644 journeys/primary-node/pair-primary-node.xml create mode 100644 journeys/primary-node/primary-offline-receive.xml create mode 100644 journeys/primary-node/receive-primary-quote.xml create mode 100644 journeys/primary-node/transfer-through-primary.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 8ce343881..fa710c2a9 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -164,6 +164,7 @@ "Extensions/PaymentFailureReason+UserMessage.swift", Models/BlocksWidgetOptions.swift, Models/LnPeer.swift, + Models/PrimaryNode.swift, Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, Models/Transfer.swift, @@ -199,6 +200,7 @@ Extensions/PaymentDetails.swift, "Extensions/PaymentFailureReason+UserMessage.swift", Models/LnPeer.swift, + Models/PrimaryNode.swift, Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, Services/CoreService.swift, @@ -1224,7 +1226,7 @@ repositoryURL = "https://github.com/ovitrif/ldk-node"; requirement = { kind = revision; - revision = 46ecee6b1e8ef4e2d6c258767b0c3eee8be3cbe6; + revision = e2643c7b7eee9b4fe0b6162dee234cbf9367823f; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 60f3e7479..6116ac120 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ovitrif/ldk-node", "state" : { - "revision" : "46ecee6b1e8ef4e2d6c258767b0c3eee8be3cbe6" + "revision" : "e2643c7b7eee9b4fe0b6162dee234cbf9367823f" } }, { diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index 491cd7bce..0926902dd 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -74,7 +74,7 @@ struct TabBar: View { let hasPendingTransfersToSpending = wallet.balanceInTransferToSpending > 0 - if Self.shouldOpenSpendingTransferUnavailable( + if PrimaryNodeService.shared.primaryNode == nil, Self.shouldOpenSpendingTransferUnavailable( isSpendingWallet: navigation.currentRoute == .spendingWallet, wallet: wallet, hasPendingTransfersToSpending: hasPendingTransfersToSpending @@ -95,8 +95,7 @@ struct TabBar: View { wallet: WalletViewModel, hasPendingTransfersToSpending: Bool ) -> Bool { - isSpendingWallet && Env.primaryNode == nil && !wallet.canCreateReceiveLightningInvoice(amountSats: nil) - && !hasPendingTransfersToSpending + isSpendingWallet && !wallet.canCreateReceiveLightningInvoice(amountSats: nil) && !hasPendingTransfersToSpending } } diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 1f1567b0a..e8da1edee 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -374,7 +374,7 @@ struct Suggestions: View { case .hardware: sheets.showSheet(.hardwareConnect) case .transferToSpending: - showTransferToSpending(sheets: sheets, navigation: navigation) + route = .primaryTransfer } if let route { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index d4964f811..623f8d449 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -160,14 +160,10 @@ enum Env { /// Beignet primary run for the Lightning-first mainnet test, reached through a public TCP relay. private static let primaryNodeUriMainnet = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101" - /// The Lightning-first primary node: it funds channels just in time and grows them by splicing. + /// The primary node a wallet uses until the user pairs another one in Settings. /// A `PRIMARY_NODE_URI` config value overrides it, for regtest runs against a local primary. - static var primaryNodeUri: String? { - configValue("PRIMARY_NODE_URI") ?? (network == .bitcoin ? primaryNodeUriMainnet : nil) - } - - static var primaryNode: LnPeer? { - primaryNodeUri.flatMap { try? LnPeer(connection: $0) } + static var defaultPrimaryNodeUri: String { + configValue("PRIMARY_NODE_URI") ?? (network == .bitcoin ? primaryNodeUriMainnet : "") } static let network: LDKNode.Network = { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 64da4682a..57655a920 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -695,6 +695,8 @@ struct MainNavView: View { case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() + case .primaryNodeSettings: PrimaryNodeSettingsScreen() + case .primaryTransfer: PrimaryTransferScreen() case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() case .watchOnlyAccounts: WatchOnlyAccountsView() diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index b8444d1b2..6b67b8093 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -6,6 +6,12 @@ enum ScannerContext { case main case send case electrum + case primaryNode +} + +extension Notification.Name { + /// Posted with the scanned node URI as its object, for the Primary node settings screen. + static let primaryNodeScanned = Notification.Name("primaryNodeScanned") } @MainActor @@ -72,6 +78,10 @@ class ScannerManager: ObservableObject { case .electrum: playScanSuccess() await handleElectrumScan(uri) + case .primaryNode: + playScanSuccess() + NotificationCenter.default.post(name: .primaryNodeScanned, object: uri) + navigation?.navigateBack() } } diff --git a/Bitkit/Models/PrimaryNode.swift b/Bitkit/Models/PrimaryNode.swift new file mode 100644 index 000000000..8c1c19edf --- /dev/null +++ b/Bitkit/Models/PrimaryNode.swift @@ -0,0 +1,82 @@ +import Foundation + +/// The Lightning node that serves this wallet: it opens a channel just in time for the first receive, +/// grows it by splicing, and is the wallet's only Lightning peer. +struct PrimaryNode: Hashable { + static let uriKey = "primaryNodeUri" + static let fundingPausedKey = "primaryFundingPaused" + + let nodeId: String + let address: String? + + var uri: String { + address.map { "\(nodeId)@\($0)" } ?? nodeId + } + + var peer: LnPeer? { + guard let address else { return nil } + return try? LnPeer(connection: "\(nodeId)@\(address)") + } + + /// Parses a `pubkey` or `pubkey@host:port` node URI, as pasted or scanned. + static func parse(_ input: String) throws -> PrimaryNode { + var value = input.trimmingCharacters(in: .whitespacesAndNewlines) + for prefix in ["lightning://", "lightning:", "node://"] where value.lowercased().hasPrefix(prefix) { + value = String(value.dropFirst(prefix.count)) + break + } + let parts = value.split(separator: "@", omittingEmptySubsequences: false).map(String.init) + guard (1 ... 2).contains(parts.count), !value.contains(where: \.isWhitespace) else { + throw PrimaryNodeError.invalidInput + } + let nodeId = parts[0].lowercased() + guard nodeId.range(of: "^(02|03)[0-9a-f]{64}$", options: .regularExpression) != nil else { + throw PrimaryNodeError.invalidInput + } + return try PrimaryNode(nodeId: nodeId, address: parts.count == 2 ? parseAddress(parts[1]) : nil) + } + + static func parseOrNil(_ input: String) -> PrimaryNode? { + try? parse(input) + } + + /// The primary the wallet uses: the paired one, else the network's default. + static func effective(savedUri: String?) -> PrimaryNode? { + let saved = savedUri ?? "" + return parseOrNil(saved.isEmpty ? Env.defaultPrimaryNodeUri : saved) + } + + /// The primary saved in settings, else the network's default. + static var current: PrimaryNode? { + effective(savedUri: UserDefaults.standard.string(forKey: uriKey)) + } + + private static func parseAddress(_ value: String) throws -> String { + guard let separator = value.lastIndex(of: ":") else { throw PrimaryNodeError.invalidInput } + let host = String(value[.. 0, + host.range(of: "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", options: .regularExpression) != nil + else { + throw PrimaryNodeError.invalidInput + } + return "\(host):\(port)" + } +} + +enum PrimaryNodeError: Error, Equatable { + case invalidInput + case addressUnknown + case connectionInUse + case notConfigured +} + +/// Why the primary node could not provide a receive or a channel change, shown to the user. +enum PrimaryReceiveError: Error, Equatable { + case insufficientFunds + case rejected + case timedOut + case amountTooSmall + case offline + case busy + case spliceFailed +} diff --git a/Bitkit/Models/ReceiveLiquidityDecision.swift b/Bitkit/Models/ReceiveLiquidityDecision.swift index 5ebeeebf3..61ed0f490 100644 --- a/Bitkit/Models/ReceiveLiquidityDecision.swift +++ b/Bitkit/Models/ReceiveLiquidityDecision.swift @@ -1,6 +1,8 @@ enum ReceiveAdditionalLiquidityAction: Equatable { case none case transferUnavailable + /// The primary node opens or grows the channel for this receive, once the user approves its fee. + case primaryReceive } enum ReceiveLiquiditySource: Equatable { @@ -13,13 +15,8 @@ enum ReceiveLiquidityDecision { static func canCreateLightningInvoice( hasReadyChannels: Bool, inboundCapacitySats: UInt64?, - invoiceAmountSats: UInt64?, - hasPrimaryNode: Bool = false + invoiceAmountSats: UInt64? ) -> Bool { - if hasPrimaryNode, (invoiceAmountSats ?? 0) > 0 { - return true - } - guard hasReadyChannels, let inboundCapacitySats else { return false } @@ -37,14 +34,16 @@ enum ReceiveLiquidityDecision { inboundCapacitySats: UInt64?, hasPrimaryNode: Bool = false ) -> ReceiveAdditionalLiquidityAction { - guard source == .spending, !hasPrimaryNode, needsInboundLiquidity( - invoiceAmountSats: invoiceAmountSats, - inboundCapacitySats: inboundCapacitySats - ), - (inboundCapacitySats ?? 0) > 0 + guard source != .savings, needsInboundLiquidity(invoiceAmountSats: invoiceAmountSats, inboundCapacitySats: inboundCapacitySats) else { return .none } + if hasPrimaryNode, invoiceAmountSats > 0 { + return .primaryReceive + } + guard source == .spending, (inboundCapacitySats ?? 0) > 0 else { + return .none + } return .transferUnavailable } diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 03a769836..ea87a6b9a 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -54,6 +54,8 @@ enum SettingsBackupConfig { "enableNotifications": .bool, "quickpayAmount": .double(optional: false), "quickpayDailyLimitMultiplier": .double(optional: false), + "primaryNodeUri": .string(optional: true), + "primaryFundingPaused": .bool, ] static var settingsKeys: [String] { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index fc06ea674..ecb325f85 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1565,3 +1565,38 @@ "subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا"; "wallet__balance_updating" = "جارٍ تحديث الرصيد…"; +"primary_node__address" = "عنوان URI للعقدة"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "لا يوجد عنوان معروف لهذا المفتاح العام. امسح عنوان URI الكامل publickey@host:port ضوئيًا أو الصقه."; +"primary_node__busy" = "ما زال هناك تغيير معلق في قناتك مع عقدتك الأساسية. حاول مرة أخرى بعد تأكيده."; +"primary_node__connect" = "اتصال"; +"primary_node__connected" = "تم اتصال العقدة الأساسية"; +"primary_node__connection_in_use" = "حوّل رصيد الإنفاق إلى المدخرات وأغلق القناة مع عقدتك الأساسية الحالية قبل اختيار عقدة أخرى."; +"primary_node__current" = "العقدة الأساسية الحالية"; +"primary_node__description" = "تفتح عقدتك الأساسية قناة الإنفاق الخاصة بك وتوسّعها عند الاستلام، بحيث يمكنك الإرسال والاستلام من رصيد واحد."; +"primary_node__error_amount_too_small" = "هذا المبلغ صغير جدًا لتغطية رسوم الاستلام. اطلب مبلغًا أكبر."; +"primary_node__error_generic" = "حدث خطأ ما في عقدتك الأساسية. حاول مرة أخرى."; +"primary_node__error_insufficient_funds" = "لا تملك عقدتك الأساسية أموالًا كافية لهذا المبلغ. جرّب مبلغًا أصغر أو استلم على السلسلة."; +"primary_node__error_offline" = "عقدتك الأساسية غير متصلة أو لا يمكن الوصول إليها. تحقق من اتصالك وحاول مرة أخرى، أو استلم على السلسلة."; +"primary_node__error_rejected" = "رفضت عقدتك الأساسية هذا المبلغ. جرّب مبلغًا مختلفًا."; +"primary_node__error_timed_out" = "لم تستجب عقدتك الأساسية. حاول مرة أخرى، أو استلم على السلسلة."; +"primary_node__funding_paused" = "التمويل التلقائي متوقف منذ آخر تحويل لك إلى المدخرات. حوّل إلى الإنفاق لاستئنافه."; +"primary_node__invalid" = "أدخل مفتاحًا عامًا صالحًا للعقدة أو عنوان URI بصيغة publickey@host:port."; +"primary_node__not_configured" = "لا توجد عقدة أساسية متصلة. عيّن واحدة في الإعدادات › متقدم › العقدة الأساسية."; +"primary_node__status_connected" = "متصلة"; +"primary_node__status_offline" = "غير متصلة"; +"primary_node__title" = "العقدة الأساسية"; +"primary_node__trust" = "اختر عقدة تثق بها. فهي توفر سيولة قناتك وهي نظير Lightning الوحيد لمحفظتك."; +"primary_receive__capacity" = "تمت إضافة سعة الإنفاق"; +"primary_receive__create" = "إنشاء طلب Lightning"; +"primary_receive__fee" = "الحد الأقصى لرسوم الاستلام"; +"primary_receive__minimum" = "ستستلم على الأقل"; +"primary_receive__note" = "تخصم عقدتك الأساسية هذه الرسوم من الدفعة. أبقِ Bitkit مفتوحًا حتى تصل الدفعة."; +"primary_receive__title" = "مراجعة رسوم الاستلام"; +"primary_transfer__confirm" = "تحويل"; +"primary_transfer__fee" = "رسوم الشبكة"; +"primary_transfer__note" = "ينقل Bitkit مدخراتك المؤكدة إلى قناتك مع عقدتك الأساسية، لتتمكن من إنفاقها فورًا."; +"primary_transfer__nothing" = "ليس لديك مدخرات مؤكدة لتحويلها بعد."; +"primary_transfer__started" = "يجري نقل مدخراتك إلى رصيد الإنفاق."; +"primary_transfer__started_title" = "بدأ التحويل"; +"primary_transfer__title" = "التحويل إلى الإنفاق"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 94ea5367b..3828cd84b 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1749,3 +1749,38 @@ "subscriptions__allowance_auto_paid" = "Pagat automàticament"; "wallet__balance_updating" = "S’està actualitzant el saldo…"; +"primary_node__address" = "URI del node"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Aquesta clau pública no té cap adreça coneguda. Escaneja o enganxa l'URI complet publickey@host:port."; +"primary_node__busy" = "Encara hi ha un canvi pendent al teu canal amb el node principal. Torna-ho a provar quan es confirmi."; +"primary_node__connect" = "Connecta"; +"primary_node__connected" = "Node principal connectat"; +"primary_node__connection_in_use" = "Transfereix el teu saldo de despesa als estalvis i tanca el canal amb el teu node principal actual abans de triar-ne un altre."; +"primary_node__current" = "Node principal actual"; +"primary_node__description" = "El teu node principal obre i amplia el teu canal de despesa a mesura que reps, perquè puguis enviar i rebre des d'un únic saldo."; +"primary_node__error_amount_too_small" = "Aquest import és massa petit per cobrir la comissió de recepció. Sol·licita un import més gran."; +"primary_node__error_generic" = "Alguna cosa ha anat malament amb el teu node principal. Torna-ho a provar."; +"primary_node__error_insufficient_funds" = "El teu node principal no té prou fons per a aquest import. Prova un import més petit o rep on-chain."; +"primary_node__error_offline" = "El teu node principal està fora de línia o no es pot assolir. Comprova la connexió i torna-ho a provar, o rep on-chain."; +"primary_node__error_rejected" = "El teu node principal ha rebutjat aquest import. Prova un import diferent."; +"primary_node__error_timed_out" = "El teu node principal no ha respost. Torna-ho a provar, o rep on-chain."; +"primary_node__funding_paused" = "El finançament automàtic està en pausa des de la darrera transferència als estalvis. Transfereix a despesa per reprendre'l."; +"primary_node__invalid" = "Introdueix una clau pública de node vàlida o un URI publickey@host:port."; +"primary_node__not_configured" = "No hi ha cap node principal connectat. Defineix-ne un a Configuracions › Avançat › Node principal."; +"primary_node__status_connected" = "Connectat"; +"primary_node__status_offline" = "Fora de línia"; +"primary_node__title" = "Node principal"; +"primary_node__trust" = "Tria un node de confiança. Proporciona la liquiditat del teu canal i és l'únic parell Lightning de la teva cartera."; +"primary_receive__capacity" = "Capacitat de despesa afegida"; +"primary_receive__create" = "Crea una sol·licitud Lightning"; +"primary_receive__fee" = "Comissió màxima de recepció"; +"primary_receive__minimum" = "Reps almenys"; +"primary_receive__note" = "El teu node principal descompta aquesta comissió del pagament. Mantén Bitkit obert fins que arribi el pagament."; +"primary_receive__title" = "Revisa la comissió de recepció"; +"primary_transfer__confirm" = "Transferir"; +"primary_transfer__fee" = "Tarifa de xarxa"; +"primary_transfer__note" = "Bitkit mou els teus estalvis confirmats al teu canal amb el node principal, perquè els puguis gastar a l'instant."; +"primary_transfer__nothing" = "Encara no tens estalvis confirmats per transferir."; +"primary_transfer__started" = "Els teus estalvis s'estan movent al teu saldo de despesa."; +"primary_transfer__started_title" = "Transferència iniciada"; +"primary_transfer__title" = "Transferir a despesa"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 66328b257..e804427ff 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1888,3 +1888,38 @@ "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; "wallet__balance_updating" = "Aktualizace zůstatku…"; +"primary_node__address" = "URI uzlu"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Tento veřejný klíč nemá známou adresu. Naskenujte nebo vložte celé URI publickey@host:port."; +"primary_node__busy" = "Změna vašeho kanálu s primárním uzlem stále probíhá. Zkuste to znovu, až bude potvrzena."; +"primary_node__connect" = "Připojit"; +"primary_node__connected" = "Primární uzel připojen"; +"primary_node__connection_in_use" = "Převeďte svůj zůstatek útrat do úspor a uzavřete kanál se stávajícím primárním uzlem, než zvolíte jiný."; +"primary_node__current" = "Aktuální primární uzel"; +"primary_node__description" = "Váš primární uzel otevírá a rozšiřuje váš kanál útrat, jak přijímáte platby, takže můžete odesílat i přijímat z jednoho zůstatku."; +"primary_node__error_amount_too_small" = "Tato částka je příliš malá na pokrytí poplatku za příjem. Požádejte o vyšší částku."; +"primary_node__error_generic" = "S vaším primárním uzlem se něco pokazilo. Zkuste to znovu."; +"primary_node__error_insufficient_funds" = "Váš primární uzel nemá dostatek prostředků na tuto částku. Zkuste menší částku nebo přijměte on-chain."; +"primary_node__error_offline" = "Váš primární uzel je offline nebo nedostupný. Zkontrolujte připojení a zkuste to znovu, nebo přijměte on-chain."; +"primary_node__error_rejected" = "Váš primární uzel tuto částku odmítl. Zkuste jinou částku."; +"primary_node__error_timed_out" = "Váš primární uzel neodpověděl. Zkuste to znovu nebo přijměte on-chain."; +"primary_node__funding_paused" = "Automatické financování je pozastaveno od vašeho posledního převodu do úspor. Obnovíte ho převodem do útrat."; +"primary_node__invalid" = "Zadejte platný veřejný klíč uzlu nebo URI publickey@host:port."; +"primary_node__not_configured" = "Není připojen žádný primární uzel. Nastavte ho v Nastavení › Pokročilé › Primární uzel."; +"primary_node__status_connected" = "Připojeno"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Primární uzel"; +"primary_node__trust" = "Vyberte uzel, kterému důvěřujete. Poskytuje likviditu vašeho kanálu a je jediným Lightning protějškem vaší peněženky."; +"primary_receive__capacity" = "Přidaná kapacita útrat"; +"primary_receive__create" = "Vytvořit Lightning požadavek"; +"primary_receive__fee" = "Maximální poplatek za příjem"; +"primary_receive__minimum" = "Obdržíte alespoň"; +"primary_receive__note" = "Váš primární uzel si tento poplatek strhne z platby. Nechte Bitkit otevřený, dokud platba nedorazí."; +"primary_receive__title" = "Zkontrolovat poplatek za příjem"; +"primary_transfer__confirm" = "Převést"; +"primary_transfer__fee" = "Síťový poplatek"; +"primary_transfer__note" = "Bitkit přesune vaše potvrzené úspory do kanálu s vaším primárním uzlem, abyste je mohli okamžitě utratit."; +"primary_transfer__nothing" = "Zatím nemáte žádné potvrzené úspory k převodu."; +"primary_transfer__started" = "Vaše úspory se přesouvají do vašeho zůstatku útrat."; +"primary_transfer__started_title" = "Převod zahájen"; +"primary_transfer__title" = "Převod do útrat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 9ad85432e..2f5c35f91 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1885,3 +1885,38 @@ "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; "wallet__balance_updating" = "Kontostand wird aktualisiert…"; +"primary_node__address" = "Node-URI"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Für diesen Public Key ist keine Adresse bekannt. Scanne oder füge den vollständigen URI publickey@host:port ein."; +"primary_node__busy" = "Eine Änderung an deinem Kanal mit deinem primären Node steht noch aus. Versuche es erneut, sobald sie bestätigt ist."; +"primary_node__connect" = "Verbinden"; +"primary_node__connected" = "Primärer Node verbunden"; +"primary_node__connection_in_use" = "Übertrage dein Ausgabenkonto auf dein Sparkonto und schließe den Kanal zu deinem aktuellen primären Node, bevor du einen anderen wählst."; +"primary_node__current" = "Aktueller primärer Node"; +"primary_node__description" = "Dein primärer Node öffnet und vergrößert deinen Ausgabenkanal, wenn du Zahlungen empfängst, damit du von einem einzigen Guthaben senden und empfangen kannst."; +"primary_node__error_amount_too_small" = "Dieser Betrag ist zu klein, um die Empfangsgebühr zu decken. Fordere einen größeren Betrag an."; +"primary_node__error_generic" = "Mit deinem primären Node ist etwas schiefgelaufen. Versuche es erneut."; +"primary_node__error_insufficient_funds" = "Dein primärer Node hat nicht genug Guthaben für diesen Betrag. Versuche einen kleineren Betrag oder empfange On-Chain."; +"primary_node__error_offline" = "Dein primärer Node ist offline oder nicht erreichbar. Prüfe deine Verbindung und versuche es erneut oder empfange On-Chain."; +"primary_node__error_rejected" = "Dein primärer Node hat diesen Betrag abgelehnt. Versuche einen anderen Betrag."; +"primary_node__error_timed_out" = "Dein primärer Node hat nicht geantwortet. Versuche es erneut oder empfange On-Chain."; +"primary_node__funding_paused" = "Die automatische Finanzierung ist seit deiner letzten Übertragung auf das Sparkonto pausiert. Übertrage auf das Ausgabenkonto, um sie fortzusetzen."; +"primary_node__invalid" = "Gib einen gültigen Public Key eines Nodes oder einen URI publickey@host:port ein."; +"primary_node__not_configured" = "Es ist kein primärer Node verbunden. Lege einen unter Einstellungen › Fortgeschritten › Primärer Node fest."; +"primary_node__status_connected" = "Verbunden"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Primärer Node"; +"primary_node__trust" = "Wähle einen Node, dem du vertraust. Er stellt die Liquidität deines Kanals bereit und ist der einzige Lightning-Peer deiner Wallet."; +"primary_receive__capacity" = "Hinzugefügte Ausgabenkapazität"; +"primary_receive__create" = "Lightning-Anfrage erstellen"; +"primary_receive__fee" = "Maximale Empfangsgebühr"; +"primary_receive__minimum" = "Du erhältst mindestens"; +"primary_receive__note" = "Dein primärer Node behält diese Gebühr von der Zahlung ein. Lass Bitkit geöffnet, bis die Zahlung eintrifft."; +"primary_receive__title" = "Empfangsgebühr prüfen"; +"primary_transfer__confirm" = "Übertragen"; +"primary_transfer__fee" = "Netzgebühr"; +"primary_transfer__note" = "Bitkit verschiebt dein bestätigtes Sparguthaben in deinen Kanal zu deinem primären Node, damit du es sofort ausgeben kannst."; +"primary_transfer__nothing" = "Du hast noch kein bestätigtes Sparguthaben zum Übertragen."; +"primary_transfer__started" = "Dein Sparguthaben wird auf dein Ausgabenkonto verschoben."; +"primary_transfer__started_title" = "Übertragung gestartet"; +"primary_transfer__title" = "Auf Ausgabenkonto übertragen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index b4ef29785..7e64792ee 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1620,3 +1620,38 @@ "subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα"; "wallet__balance_updating" = "Ενημέρωση υπολοίπου…"; +"primary_node__address" = "URI κόμβου"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Αυτό το δημόσιο κλειδί δεν έχει γνωστή διεύθυνση. Σάρωσε ή επικόλλησε ολόκληρο το URI publickey@host:port."; +"primary_node__busy" = "Μια αλλαγή στο κανάλι σου με τον κύριο κόμβο εκκρεμεί ακόμη. Δοκίμασε ξανά μόλις επιβεβαιωθεί."; +"primary_node__connect" = "Σύνδεση"; +"primary_node__connected" = "Ο κύριος κόμβος συνδέθηκε"; +"primary_node__connection_in_use" = "Μετάφερε το υπόλοιπο Δαπανών σου στις Αποταμιεύσεις και κλείσε το κανάλι με τον τρέχοντα κύριο κόμβο πριν επιλέξεις άλλον."; +"primary_node__current" = "Τρέχων κύριος κόμβος"; +"primary_node__description" = "Ο κύριος κόμβος σου ανοίγει και επεκτείνει το κανάλι δαπανών σου καθώς λαμβάνεις, ώστε να στέλνεις και να λαμβάνεις από ένα ενιαίο υπόλοιπο."; +"primary_node__error_amount_too_small" = "Αυτό το ποσό είναι πολύ μικρό για να καλύψει το τέλος λήψης. Ζήτησε μεγαλύτερο ποσό."; +"primary_node__error_generic" = "Κάτι πήγε στραβά με τον κύριο κόμβο σου. Δοκίμασε ξανά."; +"primary_node__error_insufficient_funds" = "Ο κύριος κόμβος σου δεν έχει αρκετά κεφάλαια για αυτό το ποσό. Δοκίμασε μικρότερο ποσό ή λάβε on-chain."; +"primary_node__error_offline" = "Ο κύριος κόμβος σου είναι εκτός σύνδεσης ή μη προσβάσιμος. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά ή λάβε on-chain."; +"primary_node__error_rejected" = "Ο κύριος κόμβος σου απέρριψε αυτό το ποσό. Δοκίμασε διαφορετικό ποσό."; +"primary_node__error_timed_out" = "Ο κύριος κόμβος σου δεν απάντησε. Δοκίμασε ξανά ή λάβε on-chain."; +"primary_node__funding_paused" = "Η αυτόματη χρηματοδότηση έχει τεθεί σε παύση από την τελευταία σου μεταφορά στις Αποταμιεύσεις. Μετάφερε στις Δαπάνες για να συνεχιστεί."; +"primary_node__invalid" = "Εισήγαγε ένα έγκυρο δημόσιο κλειδί κόμβου ή URI publickey@host:port."; +"primary_node__not_configured" = "Δεν υπάρχει συνδεδεμένος κύριος κόμβος. Όρισε έναν στις Ρυθμίσεις › Για προχωρημένους › Κύριος κόμβος."; +"primary_node__status_connected" = "Συνδεδεμένος"; +"primary_node__status_offline" = "Εκτός σύνδεσης"; +"primary_node__title" = "Κύριος κόμβος"; +"primary_node__trust" = "Επίλεξε έναν κόμβο που εμπιστεύεσαι. Παρέχει τη ρευστότητα του καναλιού σου και είναι ο μοναδικός Lightning ομότιμος του πορτοφολιού σου."; +"primary_receive__capacity" = "Προστέθηκε χωρητικότητα δαπανών"; +"primary_receive__create" = "Δημιουργία αιτήματος Lightning"; +"primary_receive__fee" = "Μέγιστο τέλος λήψης"; +"primary_receive__minimum" = "Λαμβάνεις τουλάχιστον"; +"primary_receive__note" = "Ο κύριος κόμβος σου παρακρατά αυτό το τέλος από την πληρωμή. Κράτα το Bitkit ανοιχτό μέχρι να φτάσει η πληρωμή."; +"primary_receive__title" = "Έλεγχος τέλους λήψης"; +"primary_transfer__confirm" = "Μεταφορά"; +"primary_transfer__fee" = "Τέλος δικτύου"; +"primary_transfer__note" = "Το Bitkit μετακινεί τις επιβεβαιωμένες Αποταμιεύσεις σου στο κανάλι σου με τον κύριο κόμβο, ώστε να τις ξοδέψεις άμεσα."; +"primary_transfer__nothing" = "Δεν έχεις ακόμη επιβεβαιωμένες Αποταμιεύσεις για μεταφορά."; +"primary_transfer__started" = "Οι Αποταμιεύσεις σου μετακινούνται στο υπόλοιπο Δαπανών."; +"primary_transfer__started_title" = "Η μεταφορά ξεκίνησε"; +"primary_transfer__title" = "Μεταφορά στις Δαπάνες"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index ceae804f8..00fa822fc 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1977,3 +1977,38 @@ "usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network."; "wallet__balance_updating" = "Updating balance…"; +"primary_node__address" = "Node URI"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "This public key has no known address. Scan or paste the full publickey@host:port URI."; +"primary_node__busy" = "A change to your channel with your primary node is still pending. Try again once it confirms."; +"primary_node__connect" = "Connect"; +"primary_node__connected" = "Primary node connected"; +"primary_node__connection_in_use" = "Transfer your spending balance to savings and close the channel with your current primary node before choosing another one."; +"primary_node__current" = "Current primary node"; +"primary_node__description" = "Your primary node opens and grows your spending channel as you receive, so you can send and receive from one balance."; +"primary_node__error_amount_too_small" = "This amount is too small to cover the receive fee. Request a larger amount."; +"primary_node__error_generic" = "Something went wrong with your primary node. Try again."; +"primary_node__error_insufficient_funds" = "Your primary node does not have enough funds for this amount. Try a smaller amount, or receive on-chain instead."; +"primary_node__error_offline" = "Your primary node is offline or unreachable. Check your connection and try again, or receive on-chain instead."; +"primary_node__error_rejected" = "Your primary node declined this amount. Try a different amount."; +"primary_node__error_timed_out" = "Your primary node did not respond. Try again, or receive on-chain instead."; +"primary_node__funding_paused" = "Automatic funding is paused since your last transfer to savings. Transfer to spending to resume it."; +"primary_node__invalid" = "Enter a valid node public key or publickey@host:port URI."; +"primary_node__not_configured" = "No primary node is connected. Set one in Settings › Advanced › Primary node."; +"primary_node__status_connected" = "Connected"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Primary node"; +"primary_node__trust" = "Choose a node you trust. It provides your channel's liquidity and is your wallet's only Lightning peer."; +"primary_receive__capacity" = "Spending capacity added"; +"primary_receive__create" = "Create Lightning request"; +"primary_receive__fee" = "Maximum receive fee"; +"primary_receive__minimum" = "You receive at least"; +"primary_receive__note" = "Your primary node takes this fee from the payment. Keep Bitkit open until the payment arrives."; +"primary_receive__title" = "Review receive fee"; +"primary_transfer__confirm" = "Transfer"; +"primary_transfer__fee" = "Network fee"; +"primary_transfer__note" = "Bitkit moves your confirmed savings into your channel with your primary node, so you can spend them instantly."; +"primary_transfer__nothing" = "You have no confirmed savings to transfer yet."; +"primary_transfer__started" = "Your savings are moving into your spending balance."; +"primary_transfer__started_title" = "Transfer started"; +"primary_transfer__title" = "Transfer to spending"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 781e97dae..7429b5011 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1891,3 +1891,38 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; +"primary_node__address" = "URI del nodo"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port."; +"primary_node__busy" = "Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme."; +"primary_node__connect" = "Conectar"; +"primary_node__connected" = "Nodo principal conectado"; +"primary_node__connection_in_use" = "Transfiera su saldo de gasto a ahorro y cierre el canal con su nodo principal actual antes de elegir otro."; +"primary_node__current" = "Nodo principal actual"; +"primary_node__description" = "Su nodo principal abre y amplía su canal de gasto a medida que recibe, para que pueda enviar y recibir desde un único saldo."; +"primary_node__error_amount_too_small" = "Esta cantidad es demasiado pequeña para cubrir la comisión de recepción. Solicite una cantidad mayor."; +"primary_node__error_generic" = "Algo salió mal con su nodo principal. Inténtelo de nuevo."; +"primary_node__error_insufficient_funds" = "Su nodo principal no tiene fondos suficientes para esta cantidad. Pruebe con una cantidad menor o reciba on-chain."; +"primary_node__error_offline" = "Su nodo principal está sin conexión o no se puede alcanzar. Compruebe su conexión e inténtelo de nuevo, o reciba on-chain."; +"primary_node__error_rejected" = "Su nodo principal rechazó esta cantidad. Pruebe con otra cantidad."; +"primary_node__error_timed_out" = "Su nodo principal no respondió. Inténtelo de nuevo, o reciba on-chain."; +"primary_node__funding_paused" = "La financiación automática está en pausa desde su última transferencia a ahorro. Transfiera a gasto para reanudarla."; +"primary_node__invalid" = "Introduzca una clave pública de nodo válida o un URI publickey@host:port."; +"primary_node__not_configured" = "No hay ningún nodo principal conectado. Configure uno en Ajustes › Avanzado › Nodo principal."; +"primary_node__status_connected" = "Conectado"; +"primary_node__status_offline" = "Sin conexión"; +"primary_node__title" = "Nodo principal"; +"primary_node__trust" = "Elija un nodo de confianza. Proporciona la liquidez de su canal y es el único par Lightning de su billetera."; +"primary_receive__capacity" = "Capacidad de gasto añadida"; +"primary_receive__create" = "Crear solicitud Lightning"; +"primary_receive__fee" = "Comisión máxima de recepción"; +"primary_receive__minimum" = "Recibe al menos"; +"primary_receive__note" = "Su nodo principal descuenta esta comisión del pago. Mantenga Bitkit abierto hasta que llegue el pago."; +"primary_receive__title" = "Revisar comisión de recepción"; +"primary_transfer__confirm" = "Transferir"; +"primary_transfer__fee" = "Comisión de red"; +"primary_transfer__note" = "Bitkit mueve sus ahorros confirmados a su canal con su nodo principal, para que pueda gastarlos al instante."; +"primary_transfer__nothing" = "Aún no tiene ahorros confirmados para transferir."; +"primary_transfer__started" = "Sus ahorros se están moviendo a su saldo de gasto."; +"primary_transfer__started_title" = "Transferencia iniciada"; +"primary_transfer__title" = "Transferir a gasto"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index c765eaaf2..37685bc7e 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1769,3 +1769,38 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; +"primary_node__address" = "URI del nodo"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port."; +"primary_node__busy" = "Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme."; +"primary_node__connect" = "Conectar"; +"primary_node__connected" = "Nodo principal conectado"; +"primary_node__connection_in_use" = "Transfiera su saldo de gasto a ahorro y cierre el canal con su nodo principal actual antes de elegir otro."; +"primary_node__current" = "Nodo principal actual"; +"primary_node__description" = "Su nodo principal abre y amplía su canal de gasto a medida que recibe, para que pueda enviar y recibir desde un único saldo."; +"primary_node__error_amount_too_small" = "Esta cantidad es demasiado pequeña para cubrir la comisión de recepción. Solicite una cantidad mayor."; +"primary_node__error_generic" = "Algo salió mal con su nodo principal. Inténtelo de nuevo."; +"primary_node__error_insufficient_funds" = "Su nodo principal no tiene fondos suficientes para esta cantidad. Pruebe con una cantidad menor o reciba on-chain."; +"primary_node__error_offline" = "Su nodo principal está sin conexión o no se puede alcanzar. Compruebe su conexión e inténtelo de nuevo, o reciba on-chain."; +"primary_node__error_rejected" = "Su nodo principal rechazó esta cantidad. Pruebe con otra cantidad."; +"primary_node__error_timed_out" = "Su nodo principal no respondió. Inténtelo de nuevo, o reciba on-chain."; +"primary_node__funding_paused" = "La financiación automática está en pausa desde su última transferencia a ahorro. Transfiera a gasto para reanudarla."; +"primary_node__invalid" = "Introduzca una clave pública de nodo válida o un URI publickey@host:port."; +"primary_node__not_configured" = "No hay ningún nodo principal conectado. Configure uno en Ajustes › Avanzado › Nodo principal."; +"primary_node__status_connected" = "Conectado"; +"primary_node__status_offline" = "Sin conexión"; +"primary_node__title" = "Nodo principal"; +"primary_node__trust" = "Elija un nodo de confianza. Proporciona la liquidez de su canal y es el único par Lightning de su monedero."; +"primary_receive__capacity" = "Capacidad de gasto añadida"; +"primary_receive__create" = "Crear solicitud Lightning"; +"primary_receive__fee" = "Comisión máxima de recepción"; +"primary_receive__minimum" = "Recibe al menos"; +"primary_receive__note" = "Su nodo principal descuenta esta comisión del pago. Mantenga Bitkit abierto hasta que llegue el pago."; +"primary_receive__title" = "Revisar comisión de recepción"; +"primary_transfer__confirm" = "Transferir"; +"primary_transfer__fee" = "Comisión de red"; +"primary_transfer__note" = "Bitkit mueve sus ahorros confirmados a su canal con su nodo principal, para que pueda gastarlos al instante."; +"primary_transfer__nothing" = "Aún no tiene ahorros confirmados para transferir."; +"primary_transfer__started" = "Sus ahorros se están moviendo a su saldo de gasto."; +"primary_transfer__started_title" = "Transferencia iniciada"; +"primary_transfer__title" = "Transferir a gasto"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 7a1bdafc0..1b1bcb9e9 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1899,3 +1899,38 @@ "subscriptions__allowance_auto_paid" = "Payé automatiquement"; "wallet__balance_updating" = "Mise à jour du solde…"; +"primary_node__address" = "URI du nœud"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Cette clé publique n'a pas d'adresse connue. Scannez ou collez l'URI complet publickey@host:port."; +"primary_node__busy" = "Une modification de votre canal avec votre nœud principal est encore en attente. Réessayez une fois qu'elle sera confirmée."; +"primary_node__connect" = "Connecter"; +"primary_node__connected" = "Nœud principal connecté"; +"primary_node__connection_in_use" = "Transférez votre solde de dépenses vers l'épargne et fermez le canal avec votre nœud principal actuel avant d'en choisir un autre."; +"primary_node__current" = "Nœud principal actuel"; +"primary_node__description" = "Votre nœud principal ouvre et agrandit votre canal de dépenses au fil de vos réceptions, pour que vous puissiez envoyer et recevoir depuis un seul solde."; +"primary_node__error_amount_too_small" = "Ce montant est trop faible pour couvrir les frais de réception. Demandez un montant plus élevé."; +"primary_node__error_generic" = "Un problème est survenu avec votre nœud principal. Réessayez."; +"primary_node__error_insufficient_funds" = "Votre nœud principal n'a pas assez de fonds pour ce montant. Essayez un montant plus petit, ou recevez on-chain."; +"primary_node__error_offline" = "Votre nœud principal est hors ligne ou injoignable. Vérifiez votre connexion et réessayez, ou recevez on-chain."; +"primary_node__error_rejected" = "Votre nœud principal a refusé ce montant. Essayez un autre montant."; +"primary_node__error_timed_out" = "Votre nœud principal n'a pas répondu. Réessayez, ou recevez on-chain."; +"primary_node__funding_paused" = "Le financement automatique est en pause depuis votre dernier transfert vers l'épargne. Transférez vers les dépenses pour le reprendre."; +"primary_node__invalid" = "Saisissez une clé publique de nœud valide ou un URI publickey@host:port."; +"primary_node__not_configured" = "Aucun nœud principal n'est connecté. Définissez-en un dans Paramètres › Avancé › Nœud principal."; +"primary_node__status_connected" = "Connecté"; +"primary_node__status_offline" = "Hors ligne"; +"primary_node__title" = "Nœud principal"; +"primary_node__trust" = "Choisissez un nœud de confiance. Il fournit la liquidité de votre canal et est le seul pair Lightning de votre portefeuille."; +"primary_receive__capacity" = "Capacité de dépenses ajoutée"; +"primary_receive__create" = "Créer une demande Lightning"; +"primary_receive__fee" = "Frais de réception maximum"; +"primary_receive__minimum" = "Vous recevez au moins"; +"primary_receive__note" = "Votre nœud principal prélève ces frais sur le paiement. Gardez Bitkit ouvert jusqu'à l'arrivée du paiement."; +"primary_receive__title" = "Vérifier les frais de réception"; +"primary_transfer__confirm" = "Transférer"; +"primary_transfer__fee" = "Frais de réseau"; +"primary_transfer__note" = "Bitkit déplace votre épargne confirmée vers votre canal avec votre nœud principal, pour que vous puissiez la dépenser instantanément."; +"primary_transfer__nothing" = "Vous n'avez pas encore d'épargne confirmée à transférer."; +"primary_transfer__started" = "Votre épargne est en cours de transfert vers votre solde de dépenses."; +"primary_transfer__started_title" = "Transfert lancé"; +"primary_transfer__title" = "Transférer vers les dépenses"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 0df8ab194..f4c8760ec 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1858,3 +1858,38 @@ "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; "wallet__balance_updating" = "Aggiornamento del saldo…"; +"primary_node__address" = "URI del nodo"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Questa chiave pubblica non ha un indirizzo noto. Scansiona o incolla l'URI completo publickey@host:port."; +"primary_node__busy" = "Una modifica al tuo canale con il nodo principale è ancora in sospeso. Riprova quando sarà confermata."; +"primary_node__connect" = "Connetti"; +"primary_node__connected" = "Nodo principale connesso"; +"primary_node__connection_in_use" = "Trasferisci il tuo saldo di spesa ai risparmi e chiudi il canale con l'attuale nodo principale prima di sceglierne un altro."; +"primary_node__current" = "Nodo principale attuale"; +"primary_node__description" = "Il tuo nodo principale apre e amplia il tuo canale di spesa man mano che ricevi, così puoi inviare e ricevere da un unico saldo."; +"primary_node__error_amount_too_small" = "Questo importo è troppo piccolo per coprire la commissione di ricezione. Richiedi un importo maggiore."; +"primary_node__error_generic" = "Qualcosa è andato storto con il tuo nodo principale. Riprova."; +"primary_node__error_insufficient_funds" = "Il tuo nodo principale non ha fondi sufficienti per questo importo. Prova con un importo minore, oppure ricevi on-chain."; +"primary_node__error_offline" = "Il tuo nodo principale è offline o irraggiungibile. Controlla la connessione e riprova, oppure ricevi on-chain."; +"primary_node__error_rejected" = "Il tuo nodo principale ha rifiutato questo importo. Prova con un importo diverso."; +"primary_node__error_timed_out" = "Il tuo nodo principale non ha risposto. Riprova, oppure ricevi on-chain."; +"primary_node__funding_paused" = "Il finanziamento automatico è in pausa dall'ultimo trasferimento verso i risparmi. Trasferisci alla spesa per riprenderlo."; +"primary_node__invalid" = "Inserisci una chiave pubblica di nodo valida o un URI publickey@host:port."; +"primary_node__not_configured" = "Nessun nodo principale è connesso. Impostane uno in Impostazioni › Avanzate › Nodo principale."; +"primary_node__status_connected" = "Connesso"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Nodo principale"; +"primary_node__trust" = "Scegli un nodo di cui ti fidi. Fornisce la liquidità del tuo canale ed è l'unico peer Lightning del tuo wallet."; +"primary_receive__capacity" = "Capacità di spesa aggiunta"; +"primary_receive__create" = "Crea richiesta Lightning"; +"primary_receive__fee" = "Commissione massima di ricezione"; +"primary_receive__minimum" = "Ricevi almeno"; +"primary_receive__note" = "Il tuo nodo principale trattiene questa commissione dal pagamento. Tieni Bitkit aperto finché il pagamento non arriva."; +"primary_receive__title" = "Verifica commissione di ricezione"; +"primary_transfer__confirm" = "Trasferisci"; +"primary_transfer__fee" = "Commissione di rete"; +"primary_transfer__note" = "Bitkit sposta i tuoi risparmi confermati nel tuo canale con il nodo principale, così puoi spenderli all'istante."; +"primary_transfer__nothing" = "Non hai ancora risparmi confermati da trasferire."; +"primary_transfer__started" = "I tuoi risparmi si stanno spostando nel tuo saldo di spesa."; +"primary_transfer__started_title" = "Trasferimento avviato"; +"primary_transfer__title" = "Trasferisci alla spesa"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 99e3880cb..840ff5d7c 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1893,3 +1893,38 @@ "subscriptions__allowance_auto_paid" = "Automatisch betaald"; "wallet__balance_updating" = "Saldo bijwerken…"; +"primary_node__address" = "Node-URI"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Voor deze publieke sleutel is geen adres bekend. Scan of plak de volledige URI publickey@host:port."; +"primary_node__busy" = "Een wijziging aan je kanaal met je primaire node is nog in behandeling. Probeer het opnieuw zodra die bevestigd is."; +"primary_node__connect" = "Verbinden"; +"primary_node__connected" = "Primaire node verbonden"; +"primary_node__connection_in_use" = "Maak je bestedingssaldo over naar spaargeld en sluit het kanaal met je huidige primaire node voordat je een andere kiest."; +"primary_node__current" = "Huidige primaire node"; +"primary_node__description" = "Je primaire node opent en vergroot je bestedingskanaal terwijl je ontvangt, zodat je vanuit één saldo kunt verzenden en ontvangen."; +"primary_node__error_amount_too_small" = "Dit bedrag is te klein om de ontvangstvergoeding te dekken. Vraag een groter bedrag aan."; +"primary_node__error_generic" = "Er is iets misgegaan met je primaire node. Probeer het opnieuw."; +"primary_node__error_insufficient_funds" = "Je primaire node heeft niet genoeg saldo voor dit bedrag. Probeer een kleiner bedrag of ontvang on-chain."; +"primary_node__error_offline" = "Je primaire node is offline of onbereikbaar. Controleer je verbinding en probeer het opnieuw, of ontvang on-chain."; +"primary_node__error_rejected" = "Je primaire node heeft dit bedrag geweigerd. Probeer een ander bedrag."; +"primary_node__error_timed_out" = "Je primaire node heeft niet gereageerd. Probeer het opnieuw of ontvang on-chain."; +"primary_node__funding_paused" = "Automatische financiering is gepauzeerd sinds je laatste overboeking naar spaargeld. Boek over naar bestedingssaldo om het te hervatten."; +"primary_node__invalid" = "Voer een geldige publieke sleutel van een node of een URI publickey@host:port in."; +"primary_node__not_configured" = "Er is geen primaire node verbonden. Stel er een in via Instellingen › Geavanceerd › Primaire node."; +"primary_node__status_connected" = "Verbonden"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Primaire node"; +"primary_node__trust" = "Kies een node die je vertrouwt. Die levert de liquiditeit van je kanaal en is de enige Lightning-peer van je wallet."; +"primary_receive__capacity" = "Toegevoegde bestedingscapaciteit"; +"primary_receive__create" = "Lightning-verzoek aanmaken"; +"primary_receive__fee" = "Maximale ontvangstvergoeding"; +"primary_receive__minimum" = "Je ontvangt minimaal"; +"primary_receive__note" = "Je primaire node houdt deze vergoeding in op de betaling. Houd Bitkit open tot de betaling binnen is."; +"primary_receive__title" = "Ontvangstvergoeding controleren"; +"primary_transfer__confirm" = "Overboeken"; +"primary_transfer__fee" = "Netwerkvergoeding"; +"primary_transfer__note" = "Bitkit verplaatst je bevestigde spaargeld naar je kanaal met je primaire node, zodat je het direct kunt uitgeven."; +"primary_transfer__nothing" = "Je hebt nog geen bevestigd spaargeld om over te boeken."; +"primary_transfer__started" = "Je spaargeld wordt verplaatst naar je bestedingssaldo."; +"primary_transfer__started_title" = "Overboeking gestart"; +"primary_transfer__title" = "Overboeken naar besteden"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index b4697826c..2fd085f18 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1898,3 +1898,38 @@ "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; "wallet__balance_updating" = "Aktualizowanie salda…"; +"primary_node__address" = "URI węzła"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Ten klucz publiczny nie ma znanego adresu. Zeskanuj lub wklej pełny URI publickey@host:port."; +"primary_node__busy" = "Zmiana w Twoim kanale z węzłem głównym nadal oczekuje. Spróbuj ponownie po jej potwierdzeniu."; +"primary_node__connect" = "Połącz"; +"primary_node__connected" = "Węzeł główny połączony"; +"primary_node__connection_in_use" = "Przenieś saldo wydatków do oszczędności i zamknij kanał z obecnym węzłem głównym, zanim wybierzesz inny."; +"primary_node__current" = "Obecny węzeł główny"; +"primary_node__description" = "Twój węzeł główny otwiera i powiększa Twój kanał wydatków w miarę otrzymywania płatności, dzięki czemu możesz wysyłać i odbierać z jednego salda."; +"primary_node__error_amount_too_small" = "Ta kwota jest zbyt mała, aby pokryć opłatę za odbiór. Poproś o większą kwotę."; +"primary_node__error_generic" = "Coś poszło nie tak z Twoim węzłem głównym. Spróbuj ponownie."; +"primary_node__error_insufficient_funds" = "Twój węzeł główny nie ma wystarczających środków na tę kwotę. Spróbuj mniejszej kwoty lub odbierz on-chain."; +"primary_node__error_offline" = "Twój węzeł główny jest offline lub niedostępny. Sprawdź połączenie i spróbuj ponownie albo odbierz on-chain."; +"primary_node__error_rejected" = "Twój węzeł główny odrzucił tę kwotę. Spróbuj innej kwoty."; +"primary_node__error_timed_out" = "Twój węzeł główny nie odpowiedział. Spróbuj ponownie lub odbierz on-chain."; +"primary_node__funding_paused" = "Automatyczne zasilanie jest wstrzymane od Twojego ostatniego przelewu do oszczędności. Prześlij środki do wydatków, aby je wznowić."; +"primary_node__invalid" = "Wprowadź prawidłowy klucz publiczny węzła lub URI publickey@host:port."; +"primary_node__not_configured" = "Żaden węzeł główny nie jest połączony. Ustaw go w Ustawienia › Zaawansowane › Węzeł główny."; +"primary_node__status_connected" = "Połączono"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Węzeł główny"; +"primary_node__trust" = "Wybierz węzeł, któremu ufasz. Zapewnia płynność Twojego kanału i jest jedynym partnerem Lightning Twojego portfela."; +"primary_receive__capacity" = "Dodana pojemność wydatków"; +"primary_receive__create" = "Utwórz żądanie Lightning"; +"primary_receive__fee" = "Maksymalna opłata za odbiór"; +"primary_receive__minimum" = "Otrzymasz co najmniej"; +"primary_receive__note" = "Twój węzeł główny pobiera tę opłatę z płatności. Pozostaw Bitkit otwarty, dopóki płatność nie dotrze."; +"primary_receive__title" = "Sprawdź opłatę za odbiór"; +"primary_transfer__confirm" = "Prześlij"; +"primary_transfer__fee" = "Opłata sieciowa"; +"primary_transfer__note" = "Bitkit przenosi Twoje potwierdzone oszczędności do kanału z węzłem głównym, abyś mógł je wydać natychmiast."; +"primary_transfer__nothing" = "Nie masz jeszcze potwierdzonych oszczędności do przesłania."; +"primary_transfer__started" = "Twoje oszczędności są przenoszone do salda wydatków."; +"primary_transfer__started_title" = "Transfer rozpoczęty"; +"primary_transfer__title" = "Prześlij do wydatków"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 2d7c2290e..ef1b9517a 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1899,3 +1899,38 @@ "subscriptions__allowance_auto_paid" = "Pago automaticamente"; "wallet__balance_updating" = "Atualizando saldo…"; +"primary_node__address" = "URI do nó"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Esta chave pública não tem um endereço conhecido. Leia ou cole o URI completo publickey@host:port."; +"primary_node__busy" = "Ainda há uma alteração pendente no canal com seu nó principal. Tente novamente quando for confirmada."; +"primary_node__connect" = "Conectar"; +"primary_node__connected" = "Nó principal conectado"; +"primary_node__connection_in_use" = "Transfira seu saldo de gastos para a poupança e feche o canal com seu nó principal atual antes de escolher outro."; +"primary_node__current" = "Nó principal atual"; +"primary_node__description" = "Seu nó principal abre e aumenta seu canal de gastos à medida que você recebe, para que possa enviar e receber a partir de um único saldo."; +"primary_node__error_amount_too_small" = "Este valor é pequeno demais para cobrir a taxa de recebimento. Solicite um valor maior."; +"primary_node__error_generic" = "Algo deu errado com seu nó principal. Tente novamente."; +"primary_node__error_insufficient_funds" = "Seu nó principal não tem fundos suficientes para este valor. Tente um valor menor ou receba on-chain."; +"primary_node__error_offline" = "Seu nó principal está offline ou inacessível. Verifique sua conexão e tente novamente, ou receba on-chain."; +"primary_node__error_rejected" = "Seu nó principal recusou este valor. Tente um valor diferente."; +"primary_node__error_timed_out" = "Seu nó principal não respondeu. Tente novamente, ou receba on-chain."; +"primary_node__funding_paused" = "O financiamento automático está em pausa desde sua última transferência para a poupança. Transfira para gastos para retomá-lo."; +"primary_node__invalid" = "Insira uma chave pública de nó válida ou um URI publickey@host:port."; +"primary_node__not_configured" = "Nenhum nó principal está conectado. Defina um em Ajustes › Avançado › Nó principal."; +"primary_node__status_connected" = "Conectado"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Nó principal"; +"primary_node__trust" = "Escolha um nó em que confie. Ele fornece a liquidez dseu canal e é o único par Lightning dsua carteira."; +"primary_receive__capacity" = "Capacidade de gastos adicionada"; +"primary_receive__create" = "Criar pedido Lightning"; +"primary_receive__fee" = "Taxa máxima de recebimento"; +"primary_receive__minimum" = "Você recebe pelo menos"; +"primary_receive__note" = "Seu nó principal retira esta taxa do pagamento. Mantenha o Bitkit aberto até o pagamento chegar."; +"primary_receive__title" = "Revisar taxa de recebimento"; +"primary_transfer__confirm" = "Transferir"; +"primary_transfer__fee" = "Taxa da rede"; +"primary_transfer__note" = "O Bitkit move sua poupança confirmada para seu canal com seu nó principal, para que possa gastá-la instantaneamente."; +"primary_transfer__nothing" = "Ainda não tem poupança confirmada para transferir."; +"primary_transfer__started" = "Sua poupança está sendo movida para seu saldo de gastos."; +"primary_transfer__started_title" = "Transferência iniciada"; +"primary_transfer__title" = "Transferir para gastos"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 2a2b4076e..05305890e 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1545,3 +1545,38 @@ "subscriptions__allowance_auto_paid" = "Pago automaticamente"; "wallet__balance_updating" = "A atualizar o saldo…"; +"primary_node__address" = "URI do nó"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Esta chave pública não tem um endereço conhecido. Leia ou cole o URI completo publickey@host:port."; +"primary_node__busy" = "Ainda há uma alteração pendente no seu canal com o seu nó principal. Tente novamente quando for confirmada."; +"primary_node__connect" = "Conectar"; +"primary_node__connected" = "Nó principal conectado"; +"primary_node__connection_in_use" = "Transfira o seu saldo de gastos para a poupança e feche o canal com o seu nó principal atual antes de escolher outro."; +"primary_node__current" = "Nó principal atual"; +"primary_node__description" = "O seu nó principal abre e aumenta o seu canal de gastos à medida que você recebe, para que possa enviar e receber a partir de um único saldo."; +"primary_node__error_amount_too_small" = "Este valor é pequeno demais para cobrir a taxa de recebimento. Solicite um valor maior."; +"primary_node__error_generic" = "Algo correu mal com o seu nó principal. Tente novamente."; +"primary_node__error_insufficient_funds" = "O seu nó principal não tem fundos suficientes para este valor. Tente um valor menor ou receba on-chain."; +"primary_node__error_offline" = "O seu nó principal está offline ou inacessível. Verifique a sua conexão e tente novamente, ou receba on-chain."; +"primary_node__error_rejected" = "O seu nó principal recusou este valor. Tente um valor diferente."; +"primary_node__error_timed_out" = "O seu nó principal não respondeu. Tente novamente, ou receba on-chain."; +"primary_node__funding_paused" = "O financiamento automático está em pausa desde a sua última transferência para a poupança. Transfira para gastos para retomá-lo."; +"primary_node__invalid" = "Insira uma chave pública de nó válida ou um URI publickey@host:port."; +"primary_node__not_configured" = "Nenhum nó principal está conectado. Defina um em Ajustes › Avançado › Nó principal."; +"primary_node__status_connected" = "Conectado"; +"primary_node__status_offline" = "Offline"; +"primary_node__title" = "Nó principal"; +"primary_node__trust" = "Escolha um nó em que confie. Ele fornece a liquidez do seu canal e é o único par Lightning da sua carteira."; +"primary_receive__capacity" = "Capacidade de gastos adicionada"; +"primary_receive__create" = "Criar pedido Lightning"; +"primary_receive__fee" = "Taxa máxima de recebimento"; +"primary_receive__minimum" = "Você recebe pelo menos"; +"primary_receive__note" = "O seu nó principal retira esta taxa do pagamento. Mantenha o Bitkit aberto até o pagamento chegar."; +"primary_receive__title" = "Rever taxa de recebimento"; +"primary_transfer__confirm" = "Transferir"; +"primary_transfer__fee" = "Taxa da rede"; +"primary_transfer__note" = "O Bitkit move a sua poupança confirmada para o seu canal com o seu nó principal, para que possa gastá-la instantaneamente."; +"primary_transfer__nothing" = "Ainda não tem poupança confirmada para transferir."; +"primary_transfer__started" = "A sua poupança está a ser movida para o seu saldo de gastos."; +"primary_transfer__started_title" = "Transferência iniciada"; +"primary_transfer__title" = "Transferir para gastos"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 02790e985..97dfa5230 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1879,3 +1879,38 @@ "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; "wallet__balance_updating" = "Обновление баланса…"; +"primary_node__address" = "URI ноды"; +"primary_node__address_placeholder" = "publickey@host:port"; +"primary_node__address_required" = "Для этого публичного ключа адрес неизвестен. Отсканируйте или вставьте полный URI publickey@host:port."; +"primary_node__busy" = "Изменение вашего канала с основной нодой всё ещё в обработке. Повторите попытку после подтверждения."; +"primary_node__connect" = "Подключиться"; +"primary_node__connected" = "Основная нода подключена"; +"primary_node__connection_in_use" = "Переведите баланс расходов в сбережения и закройте канал с текущей основной нодой, прежде чем выбрать другую."; +"primary_node__current" = "Текущая основная нода"; +"primary_node__description" = "Ваша основная нода открывает и расширяет ваш канал расходов по мере получения платежей, так что вы можете отправлять и получать с одного баланса."; +"primary_node__error_amount_too_small" = "Эта сумма слишком мала, чтобы покрыть комиссию за получение. Запросите большую сумму."; +"primary_node__error_generic" = "Что-то пошло не так с вашей основной нодой. Повторите попытку."; +"primary_node__error_insufficient_funds" = "У вашей основной ноды недостаточно средств для этой суммы. Попробуйте меньшую сумму или получите on-chain."; +"primary_node__error_offline" = "Ваша основная нода не в сети или недоступна. Проверьте подключение и повторите попытку или получите on-chain."; +"primary_node__error_rejected" = "Ваша основная нода отклонила эту сумму. Попробуйте другую сумму."; +"primary_node__error_timed_out" = "Ваша основная нода не ответила. Повторите попытку или получите on-chain."; +"primary_node__funding_paused" = "Автоматическое пополнение приостановлено с момента вашего последнего перевода в сбережения. Переведите в расходы, чтобы возобновить его."; +"primary_node__invalid" = "Введите действительный публичный ключ ноды или URI publickey@host:port."; +"primary_node__not_configured" = "Основная нода не подключена. Задайте её в Настройки › Дополнительно › Основная нода."; +"primary_node__status_connected" = "Подключено"; +"primary_node__status_offline" = "Не в сети"; +"primary_node__title" = "Основная нода"; +"primary_node__trust" = "Выберите ноду, которой доверяете. Она обеспечивает ликвидность вашего канала и является единственным Lightning-пиром вашего кошелька."; +"primary_receive__capacity" = "Добавленная ёмкость расходов"; +"primary_receive__create" = "Создать Lightning-запрос"; +"primary_receive__fee" = "Максимальная комиссия за получение"; +"primary_receive__minimum" = "Вы получите не менее"; +"primary_receive__note" = "Ваша основная нода удерживает эту комиссию из платежа. Не закрывайте Bitkit, пока платёж не поступит."; +"primary_receive__title" = "Проверка комиссии за получение"; +"primary_transfer__confirm" = "Перевести"; +"primary_transfer__fee" = "Комиссия сети"; +"primary_transfer__note" = "Bitkit переводит ваши подтверждённые сбережения в канал с вашей основной нодой, чтобы вы могли тратить их мгновенно."; +"primary_transfer__nothing" = "У вас пока нет подтверждённых сбережений для перевода."; +"primary_transfer__started" = "Ваши сбережения переводятся на баланс расходов."; +"primary_transfer__started_title" = "Перевод начат"; +"primary_transfer__title" = "Перевод в расходы"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 9553245b1..1e604289b 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -9,6 +9,15 @@ class LightningService { private static let watchOnlyAccountHighestPreRevealedAddressIndex: UInt32 = 999 private var node: Node? + + /// The primary node the running node was built with, or nil when the network has none. + private(set) var primaryNode: PrimaryNode? + + /// Primary nodes authorize a just-in-time receive for ten minutes at most. + static let primaryInvoiceExpirySecs: UInt32 = 600 + + /// Inbound capacity a primary leaves on top of a receive, so the next payments fit without a splice. + static let primaryInboundHeadroomSats: UInt64 = 10000 var onchainDispatchNode: AnyObject? { node } @@ -121,7 +130,8 @@ class LightningService { let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) - if let primary = Env.primaryNode { + primaryNode = PrimaryNode.current + if let primary = primaryNode { builder.setPrimaryNode(nodeId: primary.nodeId) } @@ -267,8 +277,8 @@ class LightningService { try node.start() } - if let primary = Env.primaryNode { - try? await connectPeer(peer: primary) + if let peer = primaryNode?.peer { + try? await connectPeer(peer: peer) } #if !BITKIT_NOTIFICATION_EXTENSION @@ -379,7 +389,7 @@ class LightningService { try await ServiceQueue.background(.ldk) { let balances = node.listBalances() let idlePeerIds = Self.idlePeerIds( - peers: node.listPeers().filter { $0.nodeId != Env.primaryNode?.nodeId }, + peers: node.listPeers().filter { $0.nodeId != self.primaryNode?.nodeId }, channels: node.listChannels(), lightningBalances: balances.lightningBalances, pendingSweeps: balances.pendingBalancesFromChannelClosures @@ -723,36 +733,86 @@ class LightningService { try await receiveMsats(amountMsats: amountSats.map { $0 * 1000 }, description: description, expirySecs: expirySecs) } - /// Creates an invoice the primary node delivers through a channel it opens or grows just in time, - /// accepting at most the fee it quotes now. Primaries authorize such a receive for ten minutes. - func receiveViaPrimary(amountSats: UInt64, description: String) async throws -> String { + /// Quotes the fee the primary node charges to deliver `amountSats` through a channel it opens or grows. + func quotePrimaryReceive(amountSats: UInt64) async throws -> PrimaryReceiveQuote { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + guard let primary = primaryNode else { + throw PrimaryNodeError.notConfigured + } + + return try await ServiceQueue.background(.ldk) { + try Self.connectPrimaryIfNeeded(node: node, primary: primary) + return try node.quotePrimaryReceive( + nodeId: primary.nodeId, + amountMsat: amountSats * 1000, + targetInboundSats: Self.primaryInboundHeadroomSats + ) + } + } + + /// Creates an invoice the primary node delivers, accepting at most the `maxFeeMsat` the user approved. + func receiveViaPrimary(amountSats: UInt64, description: String, maxFeeMsat: UInt64) async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) } - guard let primary = Env.primaryNode else { - throw AppError(message: "No primary node", debugMessage: "No primary node for \(Env.network)") + guard let primary = primaryNode else { + throw PrimaryNodeError.notConfigured } let bolt11 = try await ServiceQueue.background(.ldk) { - if !node.listPeers().contains(where: { $0.nodeId == primary.nodeId && $0.isConnected }) { - try node.connect(nodeId: primary.nodeId, address: primary.address, persist: true) - } - let amountMsat = amountSats * 1000 - let quote = try node.quotePrimaryReceive(nodeId: primary.nodeId, amountMsat: amountMsat, targetInboundSats: 0) - Logger.info("Received primary quote: \(quote)") + try Self.connectPrimaryIfNeeded(node: node, primary: primary) return try node.receiveViaPrimary( nodeId: primary.nodeId, - amountMsat: amountMsat, + amountMsat: amountSats * 1000, description: Bolt11InvoiceDescription.direct(description: description), - expirySecs: 600, - maxFeeMsat: quote.feeMsat, - targetInboundSats: 0 + expirySecs: Self.primaryInvoiceExpirySecs, + maxFeeMsat: maxFeeMsat, + targetInboundSats: Self.primaryInboundHeadroomSats ) } return bolt11.description } + /// Checks that the connected primary speaks the primary-wallet protocol; a funding refusal still passes. + func checkPrimaryConnection(nodeId: String) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + try node.checkPrimaryConnection(nodeId: nodeId) + } + } + + func hasPendingFundingOperation() async throws -> Bool { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + node.hasPendingFundingOperation() + } + } + + func channelFundingFeeRate() async throws -> UInt64 { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + node.estimatedFeeRates().channelFundingSatPerVbyte + } + } + + private static func connectPrimaryIfNeeded(node: Node, primary: PrimaryNode) throws { + if node.listPeers().contains(where: { $0.nodeId == primary.nodeId && $0.isConnected }) { return } + guard let address = primary.address else { throw PrimaryNodeError.addressUnknown } + try node.connect(nodeId: primary.nodeId, address: address, persist: true) + } + func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -970,6 +1030,22 @@ class LightningService { } } + func spliceOut(channel: ChannelDetails, address: String, amountSats: UInt64, feeRateSatsPerVByte: UInt64) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + try node.spliceOutWithFeeRate( + userChannelId: channel.userChannelId, + counterpartyNodeId: channel.counterpartyNodeId, + address: address, + spliceAmountSats: amountSats, + feeRateSatPerVbyte: feeRateSatsPerVByte + ) + } + } + func spliceIn(channel: ChannelDetails, amountSats: UInt64) async throws { guard let node else { throw AppError(serviceError: .nodeNotSetup) diff --git a/Bitkit/Services/PrimaryNodeService.swift b/Bitkit/Services/PrimaryNodeService.swift new file mode 100644 index 000000000..cc5c0ea43 --- /dev/null +++ b/Bitkit/Services/PrimaryNodeService.swift @@ -0,0 +1,314 @@ +import Foundation +import LDKNode + +/// A move of confirmed savings into the primary channel: `amountSats` arrives, `feeSats` pays the miners. +struct PrimaryFundingPlan: Equatable { + let amountSats: UInt64 + let feeSats: UInt64 + let opensChannel: Bool + let isAutomatic: Bool + + /// Confirmed savings below this stay on-chain instead of moving into the primary channel. + static let minFundingSats: UInt64 = 25000 + /// Automatic funding waits while its mining fee is above this share of the amount moved. + static let maxFundingFeePercent: UInt64 = 5 + /// Virtual size of a splice or channel funding before its inputs, for fee estimates. + static let fundingBaseVbytes: UInt64 = 300 + /// Virtual size each wallet input adds to a funding transaction. + static let fundingInputVbytes: UInt64 = 300 + + /// Plans moving `spendableSats` of savings, held in `inputs` outputs, into the primary channel at `feeRate`. + /// Returns nil when there is nothing worth moving; `isAutomatic` is false when the fee is above + /// `maxFundingFeePercent`, so only a manual transfer moves it. + static func plan(spendableSats: UInt64, inputs: UInt64, feeRate: UInt64, hasChannel: Bool) -> PrimaryFundingPlan? { + guard inputs > 0, feeRate > 0 else { return nil } + let fee = feeRate * (fundingBaseVbytes + fundingInputVbytes * inputs) + guard spendableSats > fee else { return nil } + return PrimaryFundingPlan( + amountSats: spendableSats - fee, + feeSats: fee, + opensChannel: !hasChannel, + isAutomatic: spendableSats >= minFundingSats && fee <= spendableSats / 100 * maxFundingFeePercent + ) + } +} + +/// Lightning-first wallet operations through the primary node: pairing, just-in-time receives, +/// moving confirmed savings into the primary channel, and paying on-chain from that channel. +final class PrimaryNodeService { + static let shared = PrimaryNodeService() + + private let lightningService: LightningService + private let defaults: UserDefaults + private var spliceWaiters: [String: CheckedContinuation] = [:] + private let waitersLock = NSLock() + private var isFunding = false + + /// How long a splice may take to reach its pending funding transaction. + private static let spliceTimeoutSecs: UInt64 = 60 + + init(lightningService: LightningService = .shared, defaults: UserDefaults = .standard) { + self.lightningService = lightningService + self.defaults = defaults + } + + var primaryNode: PrimaryNode? { + lightningService.primaryNode + } + + var isFundingPaused: Bool { + get { defaults.bool(forKey: PrimaryNode.fundingPausedKey) } + set { defaults.set(newValue, forKey: PrimaryNode.fundingPausedKey) } + } + + @MainActor + func primaryChannel() -> ChannelDetails? { + guard let primary = primaryNode else { return nil } + return lightningService.channels?.first { $0.counterpartyNodeId == primary.nodeId && $0.isChannelReady } + } + + @MainActor + func isPrimaryConnected() -> Bool { + guard let primary = primaryNode else { return false } + return lightningService.peers?.contains { $0.nodeId == primary.nodeId && $0.isConnected } ?? false + } + + // MARK: Pairing + + /// Pairs `input` as the primary: saves it, restarts the node so it trusts the new primary, connects, + /// and checks the node speaks the primary-wallet protocol. A failure restores the previous primary. + @MainActor + func pair(_ input: String) async throws -> PrimaryNode { + var primary = try PrimaryNode.parse(input) + if primary.address == nil { + guard let known = lightningService.peers?.first(where: { $0.nodeId == primary.nodeId }) else { + throw PrimaryNodeError.addressUnknown + } + primary = PrimaryNode(nodeId: primary.nodeId, address: known.address) + } + if let current = primaryNode, current.nodeId != primary.nodeId, + lightningService.channels?.contains(where: { $0.counterpartyNodeId == current.nodeId }) == true + { + throw PrimaryNodeError.connectionInUse + } + + let previousUri = defaults.string(forKey: PrimaryNode.uriKey) + let previousPaused = isFundingPaused + defaults.set(primary.uri == Env.defaultPrimaryNodeUri ? "" : primary.uri, forKey: PrimaryNode.uriKey) + isFundingPaused = false + + do { + try await lightningService.restart() + guard let peer = primary.peer else { throw PrimaryNodeError.addressUnknown } + try await lightningService.connectPeer(peer: peer) + try await lightningService.checkPrimaryConnection(nodeId: primary.nodeId) + return primary + } catch { + defaults.set(previousUri, forKey: PrimaryNode.uriKey) + isFundingPaused = previousPaused + try? await lightningService.restart() + throw Self.mapError(error) + } + } + + // MARK: Receive + + func quoteReceive(amountSats: UInt64) async throws -> PrimaryReceiveQuote { + do { + let quote = try await lightningService.quotePrimaryReceive(amountSats: amountSats) + guard quote.amountMsat == amountSats * 1000, quote.feeMsat < quote.amountMsat else { + throw PrimaryReceiveError.amountTooSmall + } + return quote + } catch { + throw Self.mapError(error) + } + } + + func createInvoice(amountSats: UInt64, description: String, maxFeeMsat: UInt64) async throws -> String { + do { + return try await lightningService.receiveViaPrimary(amountSats: amountSats, description: description, maxFeeMsat: maxFeeMsat) + } catch { + throw Self.mapError(error) + } + } + + // MARK: Funding + + /// How much of the confirmed savings can move into the primary channel now, and at what fee. + @MainActor + func fundingPlan() async throws -> PrimaryFundingPlan? { + guard primaryNode != nil, isPrimaryConnected() else { return nil } + guard try await !lightningService.hasPendingFundingOperation() else { return nil } + let spendable = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + let inputs = try await UInt64(lightningService.listSpendableOutputs().count) + let feeRate = try await lightningService.channelFundingFeeRate() + return PrimaryFundingPlan.plan(spendableSats: spendable, inputs: inputs, feeRate: feeRate, hasChannel: primaryChannel() != nil) + } + + /// Moves confirmed savings into the primary channel when the plan allows it and funding isn't paused. + @MainActor + func fundIfNeeded() async { + guard !isFundingPaused, !isFunding else { return } + isFunding = true + defer { isFunding = false } + do { + guard let plan = try await fundingPlan(), plan.isAutomatic else { return } + Logger.info("Funding primary channel with \(plan.amountSats) sats, fee \(plan.feeSats)", context: "PrimaryNodeService") + try await executeFunding(plan) + } catch { + Logger.warn("Failed to fund primary channel: \(error)", context: "PrimaryNodeService") + } + } + + /// Transfer to Spending: moves the planned savings now, below the automatic fee limit too, and resumes funding. + @MainActor + func transferToSpending(_ plan: PrimaryFundingPlan) async throws { + isFundingPaused = false + do { + try await executeFunding(plan) + } catch { + throw Self.mapError(error) + } + } + + @MainActor + private func executeFunding(_ plan: PrimaryFundingPlan) async throws { + guard let primary = primaryNode else { throw PrimaryNodeError.notConfigured } + if let channel = primaryChannel() { + try await lightningService.spliceIn(channel: channel, amountSats: plan.amountSats) + } else { + guard let peer = primary.peer else { throw PrimaryNodeError.addressUnknown } + _ = try await lightningService.openChannel(peer: peer, channelAmountSats: plan.amountSats) + } + } + + // MARK: Splice out + + /// Fee for paying `amountSats` on-chain out of the primary channel, or nil when the channel can't cover it. + @MainActor + func spliceOutFee(amountSats: UInt64, feeRate: UInt64) -> UInt64? { + guard let channel = primaryChannel(), channel.isUsable else { return nil } + let fee = feeRate * PrimaryFundingPlan.fundingBaseVbytes + return amountSats + fee <= channel.outboundCapacityMsat / 1000 ? fee : nil + } + + /// The most a single on-chain payment can take out of the primary channel at `feeRate`. + @MainActor + func maxSpliceOutSats(feeRate: UInt64) -> UInt64 { + guard let channel = primaryChannel(), channel.isUsable else { return 0 } + let outbound = channel.outboundCapacityMsat / 1000 + let fee = feeRate * PrimaryFundingPlan.fundingBaseVbytes + return outbound > fee ? outbound - fee : 0 + } + + /// Pays `amountSats` to `address` from the primary channel by splicing it out; returns the splice txid. + @MainActor + func sendOnchain(address: String, amountSats: UInt64, feeRate: UInt64) async throws -> String { + guard let channel = primaryChannel(), channel.isUsable else { throw PrimaryNodeError.notConfigured } + guard try await !lightningService.hasPendingFundingOperation() else { throw PrimaryReceiveError.busy } + let userChannelId = channel.userChannelId + + return try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { try await self.awaitSplice(userChannelId: userChannelId) } + group.addTask { + try await Task.sleep(nanoseconds: Self.spliceTimeoutSecs * 1_000_000_000) + throw PrimaryReceiveError.busy + } + do { + try await lightningService.spliceOut(channel: channel, address: address, amountSats: amountSats, feeRateSatsPerVByte: feeRate) + let txid = try await group.next() ?? "" + group.cancelAll() + return txid + } catch { + group.cancelAll() + cancelSpliceWaiter(userChannelId: userChannelId) + throw Self.mapError(error) + } + } + } + + /// Transfer to Savings: splices the channel's spendable balance out to the wallet and pauses funding. + @MainActor + func transferToSavings() async throws -> String { + let feeRate = try await lightningService.channelFundingFeeRate() + let amount = maxSpliceOutSats(feeRate: feeRate) + guard amount > 0 else { throw PrimaryNodeError.notConfigured } + let address = try await lightningService.newAddress() + isFundingPaused = true + do { + return try await sendOnchain(address: address, amountSats: amount, feeRate: feeRate) + } catch { + isFundingPaused = false + throw error + } + } + + /// Resolves a pending splice with its new funding transaction; call for every node event. + func handle(event: Event) { + switch event { + case let .splicePending(_, userChannelId, _, newFundingTxo): + takeSpliceWaiter(userChannelId: userChannelId)?.resume(returning: newFundingTxo.txid.description) + case let .spliceFailed(_, userChannelId, _, _): + takeSpliceWaiter(userChannelId: userChannelId)?.resume(throwing: PrimaryReceiveError.spliceFailed) + default: + break + } + } + + private func awaitSplice(userChannelId: String) async throws -> String { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waitersLock.lock() + spliceWaiters[userChannelId] = continuation + waitersLock.unlock() + } + } onCancel: { + cancelSpliceWaiter(userChannelId: userChannelId) + } + } + + private func takeSpliceWaiter(userChannelId: String) -> CheckedContinuation? { + waitersLock.lock() + defer { waitersLock.unlock() } + return spliceWaiters.removeValue(forKey: userChannelId) + } + + private func cancelSpliceWaiter(userChannelId: String) { + takeSpliceWaiter(userChannelId: userChannelId)?.resume(throwing: CancellationError()) + } + + // MARK: Errors + + /// Maps a node error to what the user is told about the primary node. + static func mapError(_ error: Error) -> Error { + guard let nodeError = error as? NodeError else { return error } + switch nodeError { + case .LiquiditySourceInsufficientFunds: return PrimaryReceiveError.insufficientFunds + case .LiquidityRequestRejected: return PrimaryReceiveError.rejected + case .LiquidityRequestTimedOut: return PrimaryReceiveError.timedOut + case .LiquidityReceiveAmountTooSmall: return PrimaryReceiveError.amountTooSmall + case .ConnectionFailed, .LiquiditySourceUnavailable, .NotRunning: return PrimaryReceiveError.offline + default: return error + } + } +} + +extension Error { + /// The localized message shown to the user for a primary node failure. + var primaryErrorMessage: String { + switch PrimaryNodeService.mapError(self) { + case PrimaryReceiveError.insufficientFunds: return t("primary_node__error_insufficient_funds") + case PrimaryReceiveError.rejected: return t("primary_node__error_rejected") + case PrimaryReceiveError.timedOut: return t("primary_node__error_timed_out") + case PrimaryReceiveError.amountTooSmall: return t("primary_node__error_amount_too_small") + case PrimaryReceiveError.offline: return t("primary_node__error_offline") + case PrimaryReceiveError.busy, PrimaryReceiveError.spliceFailed: return t("primary_node__busy") + case PrimaryNodeError.invalidInput: return t("primary_node__invalid") + case PrimaryNodeError.addressUnknown: return t("primary_node__address_required") + case PrimaryNodeError.connectionInUse: return t("primary_node__connection_in_use") + case PrimaryNodeError.notConfigured: return t("primary_node__not_configured") + default: return t("primary_node__error_generic") + } + } +} diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 54a6f2f3b..8848cbe36 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -96,6 +96,8 @@ enum Route: Hashable { case closeConnection(channel: ChannelDetails) case node case electrumSettings + case primaryNodeSettings + case primaryTransfer case rgsSettings case addressViewer case watchOnlyAccounts diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 50546c7fb..9a70d4c58 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -86,10 +86,6 @@ class TransferViewModel: ObservableObject { onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, removeEvent: @escaping (String) -> Void ) async throws -> (channelId: String, fundingTxId: String?) { - if let readyChannel = lightningService.channels?.first(where: { $0.counterpartyNodeId == peer.nodeId && $0.isChannelReady }) { - return try await spliceIn(channel: readyChannel, amountSats: amountSats, onEvent: onEvent, removeEvent: removeEvent) - } - // Open the channel - this returns the user channel ID let userChannelId = try await lightningService.openChannel( peer: peer, @@ -137,53 +133,6 @@ class TransferViewModel: ObservableObject { return (actualChannelId, fundingTxId) } - /// Grows a ready channel with on-chain funds and tracks the transfer like a channel open. - private func spliceIn( - channel: ChannelDetails, - amountSats: UInt64, - onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, - removeEvent: @escaping (String) -> Void - ) async throws -> (channelId: String, fundingTxId: String?) { - let channelCapture = ChannelPendingCapture() - let userChannelId = channel.userChannelId.description - let eventId = "manual-channel-splice-\(userChannelId)" - onEvent(eventId) { event in - if case let .splicePending(eventChannelId, eventUserChannelId, _, newFundingTxo) = event, - eventUserChannelId.description == userChannelId - { - Task { - await channelCapture.setChannelData( - channelId: eventChannelId.description, - fundingTxId: newFundingTxo.txid.description - ) - } - } - } - defer { removeEvent(eventId) } - - try await lightningService.spliceIn(channel: channel, amountSats: amountSats) - let (channelId, fundingTxId) = await Self.waitForChannelData(capture: channelCapture, maxAttempts: 10, initialDelayMs: 50) - guard let channelId else { - throw AppError( - message: "Timeout waiting for splice pending event", - debugMessage: "Did not receive splicePending event for userChannelId: \(userChannelId)" - ) - } - - do { - _ = try await transferService.createTransfer( - type: .toSpending, - amountSats: amountSats, - channelId: channelId, - fundingTxId: fundingTxId - ) - } catch { - Logger.error("Failed to create transfer tracking record", context: error.localizedDescription) - } - - return (channelId, fundingTxId) - } - /// Waits for a channel pending event and captures both the channel ID and funding transaction ID /// - Parameters: /// - userChannelId: The user channel ID returned from openChannel @@ -293,7 +242,8 @@ class TransferViewModel: ObservableObject { } private var resolvedSavingsTransferMode: SavingsTransferMode { - savingsSwapState.quote != nil ? .swap : .close + if PrimaryNodeService.shared.primaryChannel() != nil { return .close } + return savingsSwapState.quote != nil ? .swap : .close } func closeSelectedChannels() async throws -> [ChannelDetails] { @@ -303,6 +253,20 @@ class TransferViewModel: ObservableObject { func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] { var failedChannels: [ChannelDetails] = [] var successfulChannels: [ChannelDetails] = [] + var channels = channels + + // The primary channel stays open: its balance is spliced out to savings instead. + if let primaryChannelId = PrimaryNodeService.shared.primaryChannel()?.channelId, + let primaryChannel = channels.first(where: { $0.channelId == primaryChannelId }) + { + channels.removeAll { $0.channelId == primaryChannelId } + do { + _ = try await PrimaryNodeService.shared.transferToSavings() + } catch { + Logger.warn("Failed to splice out primary channel: \(error)", context: "TransferViewModel") + failedChannels.append(primaryChannel) + } + } // Close channels in parallel and track which ones succeeded try await withThrowingTaskGroup(of: ChannelDetails?.self) { group in diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index dfe0d4347..b32bf3159 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -225,6 +225,10 @@ class WalletViewModel: ObservableObject { for handler in self.eventHandlers.values { handler(event) } + PrimaryNodeService.shared.handle(event: event) + if case .paymentReceived = event { + self.approvedPrimaryInvoice = nil + } // Handle specific events for targeted UI updates switch event { @@ -579,6 +583,7 @@ class WalletViewModel: ObservableObject { isSyncingWallet = false syncState() + Task { await PrimaryNodeService.shared.fundIfNeeded() } QuickPayPaymentCoordinator.shared.reconcileAgainstLdk() if isPaykitUIActive { await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) @@ -608,6 +613,20 @@ class WalletViewModel: ObservableObject { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") } + let hasPaymentContext = requestId != nil || paymentIdentity != nil || followupContext != nil || paymentDeadline != nil + if !hasPaymentContext, await shouldSendFromPrimaryChannel(address: address, sats: sats, feeRate: selectedFeeRateSatsPerVByte) { + try await beforeBroadcastAttempt() + let txid = try await PrimaryNodeService.shared.sendOnchain( + address: address, + amountSats: sats, + feeRate: UInt64(selectedFeeRateSatsPerVByte) + ) + Task { + try? await sync() + } + return .accepted(txid: txid) + } + if let selectedUtxos { Logger.info("Using selected UTXO for send: \(selectedUtxos)") } else { @@ -638,6 +657,18 @@ class WalletViewModel: ObservableObject { return result } + /// True when savings can't cover `sats` and its fee, but the primary channel can. + private func shouldSendFromPrimaryChannel(address: String, sats: UInt64, feeRate: UInt32) async -> Bool { + guard selectedUtxos == nil, PrimaryNodeService.shared.primaryChannel() != nil else { return false } + let spendable = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + if let fee = try? await lightningService.calculateTotalFee(address: address, amountSats: sats, satsPerVByte: feeRate), + sats + fee <= spendable + { + return false + } + return PrimaryNodeService.shared.spliceOutFee(amountSats: sats, feeRate: UInt64(feeRate)) != nil + } + func resolvedAcceptedOnchainTransfer(context: OnchainSendPendingContext, attempts: OnchainSendAttemptService) async throws -> OnchainSendLocalResolution? { @@ -833,13 +864,20 @@ class WalletViewModel: ObservableObject { let spendableBalance = UInt64(spendableOnchainBalanceSats) try await loadAvailableUtxos(isCurrentSend: isCurrentSend) - let fee = try await lightningService.estimateSendAllFee( - address: address, - satsPerVByte: satsPerVByte - ) + let maxSpliceOut = PrimaryNodeService.shared.maxSpliceOutSats(feeRate: UInt64(satsPerVByte)) + let fee: UInt64 + do { + fee = try await lightningService.estimateSendAllFee( + address: address, + satsPerVByte: satsPerVByte + ) + } catch { + guard maxSpliceOut > 0 else { throw error } + return maxSpliceOut + } // Use spendableBalance (not utxoTotal) to respect anchor channel reserves - return spendableBalance >= fee ? spendableBalance - fee : 0 + return max(spendableBalance >= fee ? spendableBalance - fee : 0, maxSpliceOut) } /// Estimates the routing fees for a lightning payment @@ -1307,15 +1345,6 @@ class WalletViewModel: ObservableObject { } func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { - ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels: hasReadyChannels, - inboundCapacitySats: totalReadyInboundLightningSats, - invoiceAmountSats: amountSats, - hasPrimaryNode: Env.primaryNode != nil - ) - } - - private func canReceiveOnReadyChannels(amountSats: UInt64?) -> Bool { ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels: hasReadyChannels, inboundCapacitySats: totalReadyInboundLightningSats, @@ -1556,12 +1585,10 @@ class WalletViewModel: ObservableObject { try Task.checkCancellation() guard receiveRefreshID == refreshID else { return } var invoice = "" - if !canReceiveOnReadyChannels(amountSats: amountSats), let amountSats, Env.primaryNode != nil { - do { - invoice = try await lightningService.receiveViaPrimary(amountSats: amountSats, description: note).lowercased() - } catch { - Logger.warn("Failed to create primary invoice for \(amountSats) sats: \(error)") - } + if let approved = approvedPrimaryInvoice, approved.amountSats == amountSats, + try await isReceiveInvoiceReusable(approved.bolt11) + { + invoice = approved.bolt11 } else if canCreateReceiveLightningInvoice(amountSats: amountSats) { if forceRefreshBolt11 || wasUpdating || !hasMatchingInputs || cachedInvoice.isEmpty { invoice = try await createInvoice(amountSats: amountSats, note: note) @@ -1638,6 +1665,15 @@ class WalletViewModel: ObservableObject { } } + /// An invoice the primary node delivers, approved by the user for `amountSats`; the receive QR shows it. + private(set) var approvedPrimaryInvoice: (bolt11: String, amountSats: UInt64)? + + func setPrimaryReceiveInvoice(bolt11: String, amountSats: UInt64) async throws { + approvedPrimaryInvoice = (bolt11, amountSats) + invoiceAmountSats = amountSats + try await refreshBip21(forceRefreshBolt11: false) + } + func invalidateReceiveInvoice() { receiveRefreshID = UUID() isReceiveInvoiceUpdating = false diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift index 1b2a4e121..25cf7158b 100644 --- a/Bitkit/Views/Scanner/ScannerScreen.swift +++ b/Bitkit/Views/Scanner/ScannerScreen.swift @@ -19,6 +19,10 @@ struct ScannerScreen: View { return .electrum } + if navigation.path.contains(.primaryNodeSettings) { + return .primaryNode + } + if navigation.path.dropLast().last == .contacts { return .addContact } diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 58e3374ed..f1366b782 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -79,6 +79,14 @@ struct AdvancedSettingsView: View { SettingsSectionHeader(t("settings__adv__section_networks")) .padding(.top, 16) + NavigationLink(value: Route.primaryNodeSettings) { + SettingsRow( + title: t("primary_node__title"), + iconName: "bolt-hollow" + ) + } + .accessibilityIdentifier("PrimaryNodeSettings") + NavigationLink(value: Route.connections) { SettingsRow( title: t("settings__adv__lightning_connections"), diff --git a/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift b/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift new file mode 100644 index 000000000..0985fd88e --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift @@ -0,0 +1,165 @@ +import SwiftUI + +struct PrimaryNodeSettingsScreen: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var input = "" + @State private var hasEdited = false + @State private var isLoading = false + @State private var errorMessage: String? + @FocusState private var isTextFieldFocused: Bool + + private var currentPrimary: PrimaryNode? { + PrimaryNodeService.shared.primaryNode + } + + private var isConnected: Bool { + guard let primary = currentPrimary else { return false } + return wallet.peers?.contains { $0.nodeId == primary.nodeId && $0.isConnected } ?? false + } + + private var isDefault: Bool { + currentPrimary?.uri == PrimaryNode.parseOrNil(Env.defaultPrimaryNodeUri)?.uri + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar( + title: t("primary_node__title"), + action: AnyView(Button(action: { + navigation.navigate(.scanner) + }) { + Image("scan") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .padding(.leading, 16) + .contentShape(Rectangle()) + } + .accessibilityIdentifier("NavigationAction")) + ) + .padding(.bottom, 16) + + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("primary_node__description")) + .padding(.bottom, 24) + + if let currentPrimary { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("primary_node__current")) + BodySText(currentPrimary.uri, textColor: .textPrimary) + .accessibilityIdentifier("PrimaryNodeCurrent") + BodyMText( + isConnected ? t("primary_node__status_connected") : t("primary_node__status_offline"), + textColor: isConnected ? .greenAccent : .redAccent + ) + .accessibilityIdentifier(isConnected ? "PrimaryNodeOnline" : "PrimaryNodeOffline") + } + .padding(.bottom, 16) + } + + if PrimaryNodeService.shared.isFundingPaused { + BodyMText(t("primary_node__funding_paused")) + .accessibilityIdentifier("PrimaryNodeFundingPaused") + .padding(.bottom, 16) + } + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("primary_node__address")) + TextField(t("primary_node__address_placeholder"), text: $input, axis: .vertical) + .lineLimit(3 ... 5) + .focused($isTextFieldFocused) + .autocapitalization(.none) + .autocorrectionDisabled(true) + .disabled(isLoading) + .onChange(of: input) { _ in + hasEdited = true + errorMessage = nil + } + .accessibilityIdentifier("PrimaryNodeInput") + } + .padding(.bottom, 16) + + CustomButton(title: t("lightning__external_manual__paste"), variant: .secondary) { + if let text = UIPasteboard.general.string { + input = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + .accessibilityIdentifier("PrimaryNodePaste") + .padding(.bottom, 16) + + if let errorMessage { + BodyMText(errorMessage, textColor: .redAccent) + .accessibilityIdentifier("PrimaryNodeError") + .padding(.bottom, 16) + } + + BodySText(t("primary_node__trust")) + + Spacer() + + HStack(spacing: 16) { + CustomButton(title: t("settings__es__button_reset"), variant: .secondary, isDisabled: isLoading || isDefault) { + input = Env.defaultPrimaryNodeUri + } + .accessibilityIdentifier("PrimaryNodeReset") + + CustomButton( + title: t("primary_node__connect"), + isDisabled: input.isEmpty || isLoading, + isLoading: isLoading + ) { + await connect() + } + .accessibilityIdentifier("PrimaryNodeConnect") + } + .padding(.top, 16) + .bottomSafeAreaPadding() + } + .frame(minHeight: geometry.size.height) + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + } + } + .scrollDismissesKeyboard(.interactively) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .onAppear { + if !hasEdited { + input = currentPrimary?.uri ?? "" + hasEdited = false + } + } + .onReceive(NotificationCenter.default.publisher(for: .primaryNodeScanned)) { notification in + guard let uri = notification.object as? String else { return } + if let primary = PrimaryNode.parseOrNil(uri) { + input = primary.uri + } else { + errorMessage = t("primary_node__invalid") + } + } + } + + private func connect() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + _ = try await PrimaryNodeService.shared.pair(input) + wallet.syncState() + hasEdited = false + app.toast(type: .success, title: t("primary_node__connected"), accessibilityIdentifier: "PrimaryNodeConnectedToast") + } catch { + errorMessage = error.primaryErrorMessage + } + } +} diff --git a/Bitkit/Views/Sheets/TransferUnavailableSheet.swift b/Bitkit/Views/Sheets/TransferUnavailableSheet.swift index d8cda3d70..ab722797c 100644 --- a/Bitkit/Views/Sheets/TransferUnavailableSheet.swift +++ b/Bitkit/Views/Sheets/TransferUnavailableSheet.swift @@ -1,15 +1,5 @@ import SwiftUI -/// Opens a channel with the primary node, or explains that transfers need manual setup without one. -@MainActor -func showTransferToSpending(sheets: SheetViewModel, navigation: NavigationViewModel) { - guard let primaryNodeUri = Env.primaryNodeUri else { - sheets.showSheet(.transferUnavailable) - return - } - navigation.navigate(.fundManual(nodeUri: primaryNodeUri)) -} - struct TransferUnavailableSheetItem: SheetItem { let id: SheetID = .transferUnavailable let size: SheetSize = .large diff --git a/Bitkit/Views/Transfer/PrimaryTransferScreen.swift b/Bitkit/Views/Transfer/PrimaryTransferScreen.swift new file mode 100644 index 000000000..583519348 --- /dev/null +++ b/Bitkit/Views/Transfer/PrimaryTransferScreen.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// Transfer to Spending: moves confirmed savings into the channel with the primary node. +struct PrimaryTransferScreen: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @State private var plan: PrimaryFundingPlan? + @State private var isLoading = true + @State private var errorMessage: String? + + private var hasPrimaryNode: Bool { + PrimaryNodeService.shared.primaryNode != nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("primary_transfer__title")) + .padding(.bottom, 16) + + VStack(alignment: .leading, spacing: 0) { + if !hasPrimaryNode { + BodyMText(t("primary_node__not_configured")) + } else if let plan { + MoneyStack(sats: Int(plan.amountSats)) + .padding(.bottom, 32) + CaptionMText(t("primary_transfer__fee")) + .padding(.bottom, 8) + MoneyText(sats: Int(plan.feeSats), size: .bodySSB, testIdentifier: "PrimaryTransferFee") + .padding(.bottom, 24) + BodyMText(t("primary_transfer__note")) + } else if !isLoading, errorMessage == nil { + BodyMText(t("primary_transfer__nothing")) + } + + if let errorMessage { + BodyMText(errorMessage, textColor: .redAccent) + .accessibilityIdentifier("PrimaryTransferError") + .padding(.top, 16) + } + + Spacer() + + if !hasPrimaryNode { + CustomButton(title: t("primary_node__title")) { + navigation.navigate(.primaryNodeSettings) + } + .accessibilityIdentifier("PrimaryTransferSetUp") + } else if plan == nil, errorMessage != nil { + CustomButton(title: t("common__retry")) { + await load() + } + .accessibilityIdentifier("PrimaryTransferRetry") + } else { + CustomButton(title: t("primary_transfer__confirm"), isDisabled: plan == nil || isLoading, isLoading: isLoading) { + await confirm() + } + .accessibilityIdentifier("PrimaryTransferConfirm") + } + } + .bottomSafeAreaPadding() + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("PrimaryTransfer") + .task { + await load() + } + } + + private func load() async { + guard hasPrimaryNode else { + isLoading = false + return + } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + plan = try await PrimaryNodeService.shared.fundingPlan() + } catch { + errorMessage = error.primaryErrorMessage + } + } + + private func confirm() async { + guard let plan else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + try await PrimaryNodeService.shared.transferToSpending(plan) + wallet.syncState() + app.toast( + type: .success, + title: t("primary_transfer__started_title"), + description: t("primary_transfer__started"), + accessibilityIdentifier: "PrimaryTransferStartedToast" + ) + navigation.reset() + } catch { + errorMessage = error.primaryErrorMessage + } + } +} diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index c95096309..1994d7fce 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -204,6 +204,8 @@ struct ReceiveEdit: View { dismiss() case .transferUnavailable: navigationPath.append(.transferUnavailable) + case .primaryReceive: + navigationPath.append(.primaryReceive(amountSats: amountSats)) } } catch { app.toast(error) @@ -235,7 +237,7 @@ struct ReceiveEdit: View { source: liquiditySource, invoiceAmountSats: amountViewModel.amountSats, inboundCapacitySats: wallet.totalReadyInboundLightningSats, - hasPrimaryNode: Env.primaryNode != nil + hasPrimaryNode: PrimaryNodeService.shared.primaryNode != nil ) } diff --git a/Bitkit/Views/Wallets/Receive/ReceivePrimaryConfirm.swift b/Bitkit/Views/Wallets/Receive/ReceivePrimaryConfirm.swift new file mode 100644 index 000000000..87daa7e84 --- /dev/null +++ b/Bitkit/Views/Wallets/Receive/ReceivePrimaryConfirm.swift @@ -0,0 +1,112 @@ +import LDKNode +import SwiftUI + +/// Review receive fee: the quote the primary node charges to open or grow the channel for this receive, +/// which the user approves before the invoice is created. +struct ReceivePrimaryConfirm: View { + let amountSats: UInt64 + let onCreated: () -> Void + + @EnvironmentObject private var wallet: WalletViewModel + + @State private var quote: PrimaryReceiveQuote? + @State private var isLoading = false + @State private var errorMessage: String? + + private var maxFeeSats: UInt64 { + ((quote?.feeMsat ?? 0) + 999) / 1000 + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("primary_receive__title"), showBackButton: true) + + VStack(alignment: .leading, spacing: 0) { + MoneyStack(sats: Int(amountSats)) + .padding(.bottom, 32) + + if let quote { + CaptionMText(t("primary_receive__fee")) + .padding(.bottom, 8) + MoneyText(sats: Int(maxFeeSats), size: .bodySSB, testIdentifier: "PrimaryReceiveFee") + .padding(.bottom, 24) + CaptionMText(t("primary_receive__minimum")) + .padding(.bottom, 8) + MoneyText(sats: Int(amountSats - min(amountSats, maxFeeSats)), size: .bodySSB, testIdentifier: "PrimaryReceiveMin") + .padding(.bottom, 24) + if quote.fundingSats > 0 { + CaptionMText(t("primary_receive__capacity")) + .padding(.bottom, 8) + MoneyText(sats: Int(quote.fundingSats), size: .bodySSB, testIdentifier: "PrimaryReceiveCapacity") + .padding(.bottom, 24) + } + BodyMText(t("primary_receive__note")) + } + + if let errorMessage { + BodyMText(errorMessage, textColor: .redAccent) + .accessibilityIdentifier("PrimaryReceiveError") + } + + Spacer() + + HStack(spacing: 16) { + if errorMessage != nil { + CustomButton(title: t("common__retry"), variant: .secondary, isDisabled: isLoading) { + await requestQuote() + } + .accessibilityIdentifier("PrimaryReceiveRetry") + } + CustomButton( + title: t("primary_receive__create"), + isDisabled: quote == nil || isLoading, + isLoading: isLoading + ) { + await approve() + } + .accessibilityIdentifier("PrimaryReceiveApprove") + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + .navigationBarHidden(true) + .sheetBackground() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("PrimaryReceiveConfirm") + .task { + await requestQuote() + } + } + + private func requestQuote() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + quote = try await PrimaryNodeService.shared.quoteReceive(amountSats: amountSats) + } catch { + quote = nil + errorMessage = error.primaryErrorMessage + } + } + + private func approve() async { + guard let quote else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let bolt11 = try await PrimaryNodeService.shared.createInvoice( + amountSats: amountSats, + description: wallet.invoiceNote, + maxFeeMsat: quote.feeMsat + ) + try await wallet.setPrimaryReceiveInvoice(bolt11: bolt11, amountSats: amountSats) + onCreated() + } catch { + self.quote = nil + errorMessage = error.primaryErrorMessage + } + } +} diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index cf888c127..c3e905a9c 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -221,8 +221,8 @@ struct ReceiveQr: View { .foregroundColor(.purpleAccent), isDisabled: wallet.nodeLifecycleState != .running ) { - if Env.primaryNode != nil, let route = editRoute(for: .spending) { - navigationPath.append(route) + if PrimaryNodeService.shared.primaryNode != nil { + navigationPath.append(.edit(tab: .spending, onchainOnly: false)) } else { navigationPath.append(.transferUnavailable) } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index 23daaa0cc..ee0b538cf 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -7,6 +7,7 @@ enum ReceiveRoute: Hashable { case edit(tab: ReceiveQr.ReceiveTab, onchainOnly: Bool) case tag case transferUnavailable + case primaryReceive(amountSats: UInt64) case requestOrPay(publicKey: String) case paymentRequestRecipient(PaykitPaymentRequestDraft) case paymentRequestAmount(PaykitPaymentRequestDraft, PaykitPaymentRequestTarget) @@ -15,7 +16,7 @@ enum ReceiveRoute: Hashable { var requiresInvoiceRefresh: Bool { switch self { - case .qr, .edit, .tag, .transferUnavailable: + case .qr, .edit, .tag, .transferUnavailable, .primaryReceive: return true case .usdt, .requestOrPay, .paymentRequestRecipient, .paymentRequestAmount, .paymentRequestDetails, .paymentRequestSent: return false @@ -124,6 +125,10 @@ struct ReceiveSheet: View { ReceiveTag(navigationPath: $navigationPath) case .transferUnavailable: ReceiveTransferUnavailable() + case let .primaryReceive(amountSats): + ReceivePrimaryConfirm(amountSats: amountSats) { + navigationPath.removeAll() + } case let .requestOrPay(publicKey): RequestOrPayView(publicKey: publicKey) { target in navigationPath.append(.paymentRequestAmount(Self.defaultPaymentRequestDraft, target)) diff --git a/Bitkit/Views/Wallets/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index 6658929d8..9e3e4cb04 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -122,7 +122,7 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - showTransferToSpending(sheets: sheets, navigation: navigation) + navigation.navigate(.primaryTransfer) } .accessibilityIdentifier("TransferToSpending") } diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index e75fe4509..76debfa7e 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -115,7 +115,7 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - showTransferToSpending(sheets: sheets, navigation: navigation) + navigation.navigate(.primaryTransfer) } .accessibilityIdentifier("TransferFromSavings") } diff --git a/BitkitTests/PrimaryNodeTests.swift b/BitkitTests/PrimaryNodeTests.swift new file mode 100644 index 000000000..6c5c4cf69 --- /dev/null +++ b/BitkitTests/PrimaryNodeTests.swift @@ -0,0 +1,57 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class PrimaryNodeTests: XCTestCase { + private let nodeId = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c" + + func testParsesNodeUriAndNormalizesIt() throws { + let primary = try PrimaryNode.parse("lightning:\(nodeId.uppercased())@Bore.pub:39101") + + XCTAssertEqual(primary, PrimaryNode(nodeId: nodeId, address: "bore.pub:39101")) + XCTAssertEqual(primary.uri, "\(nodeId)@bore.pub:39101") + } + + func testParsesBarePublicKeyWithoutAddress() throws { + XCTAssertEqual(try PrimaryNode.parse(nodeId), PrimaryNode(nodeId: nodeId, address: nil)) + } + + func testRejectsInvalidKeysAndAddresses() { + XCTAssertThrowsError(try PrimaryNode.parse("04" + String(repeating: "0", count: 64))) + XCTAssertThrowsError(try PrimaryNode.parse("\(nodeId)@host:0")) + XCTAssertThrowsError(try PrimaryNode.parse("\(nodeId)@host:9735/path")) + XCTAssertThrowsError(try PrimaryNode.parse("\(nodeId) @host:9735")) + XCTAssertNil(PrimaryNode.parseOrNil("not a node")) + } + + func testFundingPlanMovesConfirmedSavingsMinusMiningFee() throws { + let plan = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 50000, inputs: 1, feeRate: 2, hasChannel: true)) + + XCTAssertEqual(plan.feeSats, 1200) + XCTAssertEqual(plan.amountSats, 48800) + XCTAssertFalse(plan.opensChannel) + XCTAssertTrue(plan.isAutomatic) + } + + func testAutomaticFundingWaitsBelowMinimumOrAboveFeeLimit() throws { + let small = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 24999, inputs: 1, feeRate: 1, hasChannel: true)) + let expensive = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 30000, inputs: 1, feeRate: 3, hasChannel: false)) + + XCTAssertFalse(small.isAutomatic) + XCTAssertFalse(expensive.isAutomatic) + XCTAssertTrue(expensive.opensChannel) + XCTAssertNil(PrimaryFundingPlan.plan(spendableSats: 500, inputs: 1, feeRate: 1, hasChannel: true)) + } + + func testLiquidityErrorsMapToWhatTheUserIsTold() { + func map(_ error: NodeError) -> PrimaryReceiveError? { + PrimaryNodeService.mapError(error) as? PrimaryReceiveError + } + + XCTAssertEqual(map(.LiquiditySourceInsufficientFunds(message: "")), .insufficientFunds) + XCTAssertEqual(map(.LiquidityRequestRejected(message: "")), .rejected) + XCTAssertEqual(map(.LiquidityRequestTimedOut(message: "")), .timedOut) + XCTAssertEqual(map(.LiquidityReceiveAmountTooSmall(message: "")), .amountTooSmall) + XCTAssertEqual(map(.ConnectionFailed(message: "")), .offline) + } +} diff --git a/BitkitTests/ReceiveLiquidityDecisionTests.swift b/BitkitTests/ReceiveLiquidityDecisionTests.swift index 7ad2b2a61..4447068ca 100644 --- a/BitkitTests/ReceiveLiquidityDecisionTests.swift +++ b/BitkitTests/ReceiveLiquidityDecisionTests.swift @@ -2,38 +2,6 @@ import XCTest final class ReceiveLiquidityDecisionTests: XCTestCase { - func testPrimaryNodeCoversFixedAmountsWithoutChannels() { - XCTAssertTrue( - ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels: false, - inboundCapacitySats: nil, - invoiceAmountSats: 5000, - hasPrimaryNode: true - ) - ) - - XCTAssertFalse( - ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels: false, - inboundCapacitySats: nil, - invoiceAmountSats: nil, - hasPrimaryNode: true - ) - ) - } - - func testPrimaryNodeNeedsNoAdditionalLiquidityAction() { - XCTAssertEqual( - ReceiveLiquidityDecision.additionalLiquidityAction( - source: .spending, - invoiceAmountSats: 10000, - inboundCapacitySats: 1000, - hasPrimaryNode: true - ), - .none - ) - } - func testLightningInvoiceRequiresReadyChannel() { XCTAssertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( @@ -109,4 +77,41 @@ final class ReceiveLiquidityDecisionTests: XCTestCase { .transferUnavailable ) } + + func testReceiveAboveInboundGoesThroughPrimaryWhenOneIsSet() { + for source in [ReceiveLiquiditySource.spending, .auto] { + for inbound: UInt64 in [0, 1000] { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: source, + invoiceAmountSats: 10000, + inboundCapacitySats: inbound, + hasPrimaryNode: true + ), + .primaryReceive + ) + } + } + } + + func testPrimaryIsNotAskedForReceiveThatFitsOrSavingsInvoice() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 20000, + hasPrimaryNode: true + ), + .none + ) + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .savings, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + hasPrimaryNode: true + ), + .none + ) + } } diff --git a/changelog.d/next/1160.added.md b/changelog.d/next/1160.added.md index f6b7bd3de..6e809af9f 100644 --- a/changelog.d/next/1160.added.md +++ b/changelog.d/next/1160.added.md @@ -1 +1 @@ -Lightning can receive and transfer to spending through a primary node that opens or grows a channel just in time. +Receive Lightning from an empty wallet, send on-chain from spending, and move funds between savings and spending through a primary node you can change in Settings. diff --git a/journeys/primary-node/README.md b/journeys/primary-node/README.md new file mode 100644 index 000000000..ed92116a5 --- /dev/null +++ b/journeys/primary-node/README.md @@ -0,0 +1,14 @@ +# Primary node + +Lightning-first wallet flows through a primary node: a Lightning node that opens a channel just in time for the +first receive, grows it by splicing, and funds on-chain sends from it by splicing out. + +Preconditions for every journey here: + +- A regtest build with the `PRIMARY_NODE_URI` build setting to a primary on the same chain, for example a + Beignet node with its liquidity provider on (`liquidityProvider`, `waiveClientReserve`) and a funded on-chain wallet. +- Regtest fees at 1 sat/vB: a Beignet primary opens just-in-time channels at a fixed 253 sat/kw commitment fee + (coreyphillips/beignet#1873), which ldk-node refuses once its fee floor is higher. +- A second Lightning wallet with a channel into the primary pays the invoices and receives the sends. + +Both apps run these journeys with the same file names. diff --git a/journeys/primary-node/pair-primary-node.xml b/journeys/primary-node/pair-primary-node.xml new file mode 100644 index 000000000..dc396920b --- /dev/null +++ b/journeys/primary-node/pair-primary-node.xml @@ -0,0 +1,16 @@ + + + Proves the primary node can be seen, changed and reset in Settings, and that a failed pairing keeps the previous + primary. Precondition: see README.md; a fresh wallet with no channel, so changing the primary is allowed. + + + Open Settings, the Advanced tab, and tap "Primary node" (testTag "PrimaryNodeSettings") + Verify "PrimaryNodeCurrent" shows the default primary URI and "PrimaryNodeOnline" is shown + Clear "PrimaryNodeInput", type a valid public key with an unreachable address such as 127.0.0.1:1, and tap "PrimaryNodeConnect" + Verify "PrimaryNodeError" says the primary node is offline or unreachable, and "PrimaryNodeCurrent" still shows the default primary + Type "not a node" into "PrimaryNodeInput" and tap "PrimaryNodeConnect" + Verify "PrimaryNodeError" asks for a valid node public key or URI + Tap "PrimaryNodeReset", then "PrimaryNodeConnect" + Verify the "Primary node connected" toast and "PrimaryNodeOnline" + + diff --git a/journeys/primary-node/primary-offline-receive.xml b/journeys/primary-node/primary-offline-receive.xml new file mode 100644 index 000000000..467028f7e --- /dev/null +++ b/journeys/primary-node/primary-offline-receive.xml @@ -0,0 +1,13 @@ + + + Proves a receive that needs the primary tells the user when the primary is unreachable, instead of a spinner. + Precondition: see README.md; stop the primary node before the quote step and start it again for the retry. + + + Stop the primary node + Open Receive, switch to the Spending tab, enter an amount above the channel's inbound capacity and continue + Verify "PrimaryReceiveError" says the primary node is offline or unreachable and "PrimaryReceiveRetry" is shown + Start the primary node, wait for the wallet to reconnect, and tap "PrimaryReceiveRetry" + Verify "PrimaryReceiveFee" is shown and "PrimaryReceiveApprove" is enabled + + diff --git a/journeys/primary-node/receive-primary-quote.xml b/journeys/primary-node/receive-primary-quote.xml new file mode 100644 index 000000000..89b933b4c --- /dev/null +++ b/journeys/primary-node/receive-primary-quote.xml @@ -0,0 +1,16 @@ + + + Proves a receive the channel cannot take goes through the primary only after the user approves its fee: on an + empty wallet the primary opens a channel, and above the channel's inbound it splices more in. Precondition: see + README.md. + + + Open Receive, switch to the Spending tab and tap the Lightning receive button + Enter 20000 sats and continue + Verify the "Review receive fee" screen (testTag "PrimaryReceiveConfirm") shows "PrimaryReceiveFee" and "PrimaryReceiveMin" + Tap "PrimaryReceiveApprove" and verify the receive QR shows a Lightning invoice for 20000 sats + Pay the invoice from the second wallet and verify the received sheet and a spending balance of at least the minimum shown + Open Receive again, edit the Spending invoice to an amount above the channel's inbound capacity and continue + Approve the fee, pay the invoice from the second wallet, and verify the payment arrives without a second channel in Settings › Advanced › Lightning connections + + diff --git a/journeys/primary-node/transfer-through-primary.xml b/journeys/primary-node/transfer-through-primary.xml new file mode 100644 index 000000000..4d578701e --- /dev/null +++ b/journeys/primary-node/transfer-through-primary.xml @@ -0,0 +1,16 @@ + + + Proves transfers move funds between savings and the primary channel without closing it, and that an on-chain send + larger than savings is paid from the channel. Precondition: see README.md; a wallet with a channel with the primary. + + + Open Spending and tap Transfer To Savings, then confirm the transfer + Verify the transfer completes, Settings › Advanced › Lightning connections still lists the channel, and the savings balance grew + Open Settings › Advanced › Primary node and verify "PrimaryNodeFundingPaused" is shown + Mine a block, open Savings and tap Transfer To Spending + Verify the "PrimaryTransfer" screen shows the amount and "PrimaryTransferFee", then tap "PrimaryTransferConfirm" + Verify the "Transfer started" toast, and after a block that the spending balance grew and "PrimaryNodeFundingPaused" is gone + Send on-chain to the second wallet an amount above the savings balance and below the spending balance + Verify the send completes and the spending balance fell by the amount plus fee + + From 4cfb85440742ccbd9a4d79a338b4bf5720bb3054 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:51:25 +0100 Subject: [PATCH 06/10] feat: one bitcoin balance with a primary node --- Bitkit/Components/WalletBalanceView.swift | 3 +- .../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/PrimaryNodeService.swift | 18 +++++-- Bitkit/Views/Home/HomeWalletView.swift | 48 ++++++++++++------- .../Transfer/PrimaryTransferScreen.swift | 2 +- 19 files changed, 65 insertions(+), 21 deletions(-) diff --git a/Bitkit/Components/WalletBalanceView.swift b/Bitkit/Components/WalletBalanceView.swift index 1bb2b0d59..b174f6f0f 100644 --- a/Bitkit/Components/WalletBalanceView.swift +++ b/Bitkit/Components/WalletBalanceView.swift @@ -3,13 +3,14 @@ import SwiftUI struct WalletBalanceView: View { let type: WalletType let sats: UInt64 + var title: String? var amountTestIdentifier: String? @EnvironmentObject var currency: CurrencyViewModel var body: some View { WalletBalanceContent { - CaptionMText(type.title) + CaptionMText(title ?? type.title) } icon: { Image(type.imageAsset).resizable().scaledToFit().frame(width: 24, height: 24) } amount: { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index ecb325f85..3b2e4a391 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1600,3 +1600,4 @@ "primary_transfer__started" = "يجري نقل مدخراتك إلى رصيد الإنفاق."; "primary_transfer__started_title" = "بدأ التحويل"; "primary_transfer__title" = "التحويل إلى الإنفاق"; +"primary_node__balance" = "بيتكوين"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 3828cd84b..97eb9a726 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1784,3 +1784,4 @@ "primary_transfer__started" = "Els teus estalvis s'estan movent al teu saldo de despesa."; "primary_transfer__started_title" = "Transferència iniciada"; "primary_transfer__title" = "Transferir a despesa"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index e804427ff..91197f5b7 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1923,3 +1923,4 @@ "primary_transfer__started" = "Vaše úspory se přesouvají do vašeho zůstatku útrat."; "primary_transfer__started_title" = "Převod zahájen"; "primary_transfer__title" = "Převod do útrat"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 2f5c35f91..67a2fe216 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1920,3 +1920,4 @@ "primary_transfer__started" = "Dein Sparguthaben wird auf dein Ausgabenkonto verschoben."; "primary_transfer__started_title" = "Übertragung gestartet"; "primary_transfer__title" = "Auf Ausgabenkonto übertragen"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 7e64792ee..a1e112676 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1655,3 +1655,4 @@ "primary_transfer__started" = "Οι Αποταμιεύσεις σου μετακινούνται στο υπόλοιπο Δαπανών."; "primary_transfer__started_title" = "Η μεταφορά ξεκίνησε"; "primary_transfer__title" = "Μεταφορά στις Δαπάνες"; +"primary_node__balance" = "Μπιτκόιν"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 00fa822fc..23d8f6851 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -2012,3 +2012,4 @@ "primary_transfer__started" = "Your savings are moving into your spending balance."; "primary_transfer__started_title" = "Transfer started"; "primary_transfer__title" = "Transfer to spending"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 7429b5011..edf5aa828 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1926,3 +1926,4 @@ "primary_transfer__started" = "Sus ahorros se están moviendo a su saldo de gasto."; "primary_transfer__started_title" = "Transferencia iniciada"; "primary_transfer__title" = "Transferir a gasto"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 37685bc7e..4dcc06822 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1804,3 +1804,4 @@ "primary_transfer__started" = "Sus ahorros se están moviendo a su saldo de gasto."; "primary_transfer__started_title" = "Transferencia iniciada"; "primary_transfer__title" = "Transferir a gasto"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 1b1bcb9e9..316877d7e 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1934,3 +1934,4 @@ "primary_transfer__started" = "Votre épargne est en cours de transfert vers votre solde de dépenses."; "primary_transfer__started_title" = "Transfert lancé"; "primary_transfer__title" = "Transférer vers les dépenses"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index f4c8760ec..a62950ccb 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1893,3 +1893,4 @@ "primary_transfer__started" = "I tuoi risparmi si stanno spostando nel tuo saldo di spesa."; "primary_transfer__started_title" = "Trasferimento avviato"; "primary_transfer__title" = "Trasferisci alla spesa"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 840ff5d7c..d80e20ff3 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1928,3 +1928,4 @@ "primary_transfer__started" = "Je spaargeld wordt verplaatst naar je bestedingssaldo."; "primary_transfer__started_title" = "Overboeking gestart"; "primary_transfer__title" = "Overboeken naar besteden"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 2fd085f18..35ad8546f 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1933,3 +1933,4 @@ "primary_transfer__started" = "Twoje oszczędności są przenoszone do salda wydatków."; "primary_transfer__started_title" = "Transfer rozpoczęty"; "primary_transfer__title" = "Prześlij do wydatków"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index ef1b9517a..87c5703a9 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1934,3 +1934,4 @@ "primary_transfer__started" = "Sua poupança está sendo movida para seu saldo de gastos."; "primary_transfer__started_title" = "Transferência iniciada"; "primary_transfer__title" = "Transferir para gastos"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 05305890e..1e29f6857 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1580,3 +1580,4 @@ "primary_transfer__started" = "A sua poupança está a ser movida para o seu saldo de gastos."; "primary_transfer__started_title" = "Transferência iniciada"; "primary_transfer__title" = "Transferir para gastos"; +"primary_node__balance" = "Bitcoin"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 97dfa5230..0d35dd7df 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1914,3 +1914,4 @@ "primary_transfer__started" = "Ваши сбережения переводятся на баланс расходов."; "primary_transfer__started_title" = "Перевод начат"; "primary_transfer__title" = "Перевод в расходы"; +"primary_node__balance" = "Биткоин"; diff --git a/Bitkit/Services/PrimaryNodeService.swift b/Bitkit/Services/PrimaryNodeService.swift index cc5c0ea43..194a35336 100644 --- a/Bitkit/Services/PrimaryNodeService.swift +++ b/Bitkit/Services/PrimaryNodeService.swift @@ -136,10 +136,22 @@ final class PrimaryNodeService { // MARK: Funding /// How much of the confirmed savings can move into the primary channel now, and at what fee. + /// For a manual transfer, an offline primary or a pending funding throws instead of planning nothing. @MainActor - func fundingPlan() async throws -> PrimaryFundingPlan? { - guard primaryNode != nil, isPrimaryConnected() else { return nil } - guard try await !lightningService.hasPendingFundingOperation() else { return nil } + func fundingPlan(isManual: Bool = false) async throws -> PrimaryFundingPlan? { + guard primaryNode != nil else { return nil } + guard isPrimaryConnected() else { + if isManual { + throw PrimaryReceiveError.offline + } + return nil + } + guard try await !lightningService.hasPendingFundingOperation() else { + if isManual { + throw PrimaryReceiveError.busy + } + return nil + } let spendable = lightningService.balances?.spendableOnchainBalanceSats ?? 0 let inputs = try await UInt64(lightningService.listSpendableOutputs().count) let feeRate = try await lightningService.channelFundingFeeRate() diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift index d29058aeb..2ab368ed0 100644 --- a/Bitkit/Views/Home/HomeWalletView.swift +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -52,6 +52,27 @@ struct HomeWalletView: View { (balance != 0 && (rateRefreshing || (try? rate?.value(at: now)) == nil)) } + @ViewBuilder + private var savingsAndSpending: some View { + NavigationLink(value: Route.savingsWallet) { + WalletBalanceView( + type: .onchain, + sats: UInt64(wallet.totalOnchainSats), + amountTestIdentifier: "ActivitySavings" + ) + } + + CustomDivider(color: .gray4, type: .vertical) + + NavigationLink(value: Route.spendingWallet) { + WalletBalanceView( + type: .lightning, + sats: UInt64(wallet.totalLightningSats), + amountTestIdentifier: "ActivitySpending" + ) + } + } + var body: some View { let total = headlineSats VStack(spacing: 0) { @@ -72,22 +93,17 @@ struct HomeWalletView: View { Spacer().frame(height: 32) HStack(spacing: 16) { - NavigationLink(value: Route.savingsWallet) { - WalletBalanceView( - type: .onchain, - sats: UInt64(wallet.totalOnchainSats), - amountTestIdentifier: "ActivitySavings" - ) - } - - CustomDivider(color: .gray4, type: .vertical) - - NavigationLink(value: Route.spendingWallet) { - WalletBalanceView( - type: .lightning, - sats: UInt64(wallet.totalLightningSats), - amountTestIdentifier: "ActivitySpending" - ) + if PrimaryNodeService.shared.primaryNode != nil { + NavigationLink(value: Route.spendingWallet) { + WalletBalanceView( + type: .onchain, + sats: UInt64(wallet.totalOnchainSats + wallet.totalLightningSats), + title: t("primary_node__balance"), + amountTestIdentifier: "ActivityBitcoin" + ) + } + } else { + savingsAndSpending } } .frame(height: 50) diff --git a/Bitkit/Views/Transfer/PrimaryTransferScreen.swift b/Bitkit/Views/Transfer/PrimaryTransferScreen.swift index 583519348..3ea969e86 100644 --- a/Bitkit/Views/Transfer/PrimaryTransferScreen.swift +++ b/Bitkit/Views/Transfer/PrimaryTransferScreen.swift @@ -79,7 +79,7 @@ struct PrimaryTransferScreen: View { errorMessage = nil defer { isLoading = false } do { - plan = try await PrimaryNodeService.shared.fundingPlan() + plan = try await PrimaryNodeService.shared.fundingPlan(isManual: true) } catch { errorMessage = error.primaryErrorMessage } From 4c00bd7866361467e5b3e0ec952b8e9a2b41bd51 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:52:46 +0100 Subject: [PATCH 07/10] fix: format for swiftformat 0.64.0 --- Bitkit/Services/LightningService.swift | 4 +++- Bitkit/ViewModels/TransferViewModel.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 1e604289b..3be0ec3e9 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -808,7 +808,9 @@ class LightningService { } private static func connectPrimaryIfNeeded(node: Node, primary: PrimaryNode) throws { - if node.listPeers().contains(where: { $0.nodeId == primary.nodeId && $0.isConnected }) { return } + if node.listPeers().contains(where: { $0.nodeId == primary.nodeId && $0.isConnected }) { + return + } guard let address = primary.address else { throw PrimaryNodeError.addressUnknown } try node.connect(nodeId: primary.nodeId, address: address, persist: true) } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 9a70d4c58..cf23a35a3 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -242,7 +242,9 @@ class TransferViewModel: ObservableObject { } private var resolvedSavingsTransferMode: SavingsTransferMode { - if PrimaryNodeService.shared.primaryChannel() != nil { return .close } + if PrimaryNodeService.shared.primaryChannel() != nil { + return .close + } return savingsSwapState.quote != nil ? .swap : .close } From ee949b064655e7cde050df5a4b56dc332aad1e1b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 07:35:19 +0100 Subject: [PATCH 08/10] fix: primary receives, transfers and errors from regtest qa --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- Bitkit/Extensions/PaymentDetails.swift | 10 ++++++++++ Bitkit/Services/CoreService.swift | 15 ++++++++++++-- Bitkit/Services/PrimaryNodeService.swift | 16 ++++++++++++--- Bitkit/ViewModels/AppViewModel.swift | 3 ++- Bitkit/ViewModels/TransferViewModel.swift | 4 ++-- Bitkit/ViewModels/WalletViewModel.swift | 11 ++++++++++ .../Advanced/PrimaryNodeSettingsScreen.swift | 6 +++--- .../Views/Transfer/SavingsProgressView.swift | 4 +++- .../Wallets/Send/SendEnterManuallyView.swift | 3 ++- .../Views/Wallets/SpendingWalletScreen.swift | 2 +- BitkitTests/PrimaryNodeTests.swift | 20 ++++++++++--------- 13 files changed, 73 insertions(+), 25 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index fa710c2a9..2452a7706 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1226,7 +1226,7 @@ repositoryURL = "https://github.com/ovitrif/ldk-node"; requirement = { kind = revision; - revision = e2643c7b7eee9b4fe0b6162dee234cbf9367823f; + revision = e09626aa88d43a8a7d9e969beb99920b72ad32b1; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6116ac120..50a6c5748 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ovitrif/ldk-node", "state" : { - "revision" : "e2643c7b7eee9b4fe0b6162dee234cbf9367823f" + "revision" : "e09626aa88d43a8a7d9e969beb99920b72ad32b1" } }, { diff --git a/Bitkit/Extensions/PaymentDetails.swift b/Bitkit/Extensions/PaymentDetails.swift index 4889b76b2..b70a9e3b9 100644 --- a/Bitkit/Extensions/PaymentDetails.swift +++ b/Bitkit/Extensions/PaymentDetails.swift @@ -26,3 +26,13 @@ extension PaymentDetails { Date(timeIntervalSince1970: TimeInterval(latestUpdateTimestamp)) } } + +extension PaymentKind { + /// A payment for a BOLT 11 invoice, including one the primary node delivers through a just-in-time channel. + var isBolt11Invoice: Bool { + switch self { + case .bolt11, .bolt11Jit: true + default: false + } + } +} diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 4a2dbe334..7164eed21 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -956,7 +956,18 @@ class ActivityService { } private func processLightningPayment(_ payment: PaymentDetails) async throws { - guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } + let preimage: PaymentPreimage? + let description: String? + let bolt11: String? + switch payment.kind { + case let .bolt11(_, kindPreimage, _, kindDescription, kindBolt11), + let .bolt11Jit(_, kindPreimage, _, _, _, kindDescription, kindBolt11): + preimage = kindPreimage + description = kindDescription + bolt11 = kindBolt11 + default: + return + } // Skip pending inbound payments - just means they created an invoice guard !(payment.status == .pending && payment.direction == .inbound) else { return } @@ -1057,7 +1068,7 @@ class ActivityService { Logger.error("Error processing onchain payment \(txid): \(error)", context: "CoreService.syncLdkNodePayments") latestCaughtError = error } - } else if case .bolt11 = payment.kind { + } else if payment.kind.isBolt11Invoice { do { let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil try await self.processLightningPayment(payment) diff --git a/Bitkit/Services/PrimaryNodeService.swift b/Bitkit/Services/PrimaryNodeService.swift index 194a35336..5dbe33329 100644 --- a/Bitkit/Services/PrimaryNodeService.swift +++ b/Bitkit/Services/PrimaryNodeService.swift @@ -18,11 +18,13 @@ struct PrimaryFundingPlan: Equatable { static let fundingInputVbytes: UInt64 = 300 /// Plans moving `spendableSats` of savings, held in `inputs` outputs, into the primary channel at `feeRate`. + /// ldk-node leaves outputs of a channel's own funding transaction out of `inputs`, such as savings from a + /// splice-out, so the estimate counts at least one input whenever there is something to move. /// Returns nil when there is nothing worth moving; `isAutomatic` is false when the fee is above /// `maxFundingFeePercent`, so only a manual transfer moves it. static func plan(spendableSats: UInt64, inputs: UInt64, feeRate: UInt64, hasChannel: Bool) -> PrimaryFundingPlan? { - guard inputs > 0, feeRate > 0 else { return nil } - let fee = feeRate * (fundingBaseVbytes + fundingInputVbytes * inputs) + guard spendableSats > 0, feeRate > 0 else { return nil } + let fee = feeRate * (fundingBaseVbytes + fundingInputVbytes * max(inputs, 1)) guard spendableSats > fee else { return nil } return PrimaryFundingPlan( amountSats: spendableSats - fee, @@ -100,6 +102,9 @@ final class PrimaryNodeService { do { try await lightningService.restart() guard let peer = primary.peer else { throw PrimaryNodeError.addressUnknown } + if let stale = lightningService.peers?.first(where: { $0.nodeId == peer.nodeId && $0.isConnected && $0.address != peer.address }) { + try? await lightningService.disconnectPeer(peer: stale) + } try await lightningService.connectPeer(peer: peer) try await lightningService.checkPrimaryConnection(nodeId: primary.nodeId) return primary @@ -176,8 +181,12 @@ final class PrimaryNodeService { /// Transfer to Spending: moves the planned savings now, below the automatic fee limit too, and resumes funding. @MainActor func transferToSpending(_ plan: PrimaryFundingPlan) async throws { - isFundingPaused = false + guard !isFunding else { throw PrimaryReceiveError.busy } + isFunding = true + defer { isFunding = false } do { + guard try await !lightningService.hasPendingFundingOperation() else { throw PrimaryReceiveError.busy } + isFundingPaused = false try await executeFunding(plan) } catch { throw Self.mapError(error) @@ -294,6 +303,7 @@ final class PrimaryNodeService { /// Maps a node error to what the user is told about the primary node. static func mapError(_ error: Error) -> Error { + let error = (error as? AppError)?.underlyingError ?? error guard let nodeError = error as? NodeError else { return error } switch nodeError { case .LiquiditySourceInsufficientFunds: return PrimaryReceiveError.insufficientFunds diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 65dfd0cf3..e4ff5d03c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -464,8 +464,9 @@ class AppViewModel: ObservableObject { } /// Validates the onchain request amount and balance, showing feedback when invalid. - private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool { + private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance savingsBalance: UInt64) -> Bool { guard validateIncomingOnchainPaymentRequestAmount(invoiceAmount) else { return false } + let onchainBalance = max(savingsBalance, PrimaryNodeService.shared.maxSpliceOutSats(feeRate: 1)) if invoiceAmount > 0 { guard onchainBalance >= invoiceAmount else { didRejectScannedPayment = true diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index cf23a35a3..d60fbd6bf 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -265,8 +265,8 @@ class TransferViewModel: ObservableObject { do { _ = try await PrimaryNodeService.shared.transferToSavings() } catch { - Logger.warn("Failed to splice out primary channel: \(error)", context: "TransferViewModel") - failedChannels.append(primaryChannel) + Logger.warn("Failed to splice out primary channel \(primaryChannel.channelId): \(error)", context: "TransferViewModel") + throw error } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index b32bf3159..0c835d4c0 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -334,6 +334,14 @@ class WalletViewModel: ObservableObject { Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)") Task { await self.refreshAndSyncState() + if syncType == .onchainWallet { + await PrimaryNodeService.shared.fundIfNeeded() + } + } + + case .splicePending, .spliceFailed: + Task { + try? await self.sync() } // MARK: Balance Events @@ -1341,6 +1349,9 @@ class WalletViewModel: ObservableObject { var canCreateReceiveLightningInvoice: Bool { let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil + if let approved = approvedPrimaryInvoice, approved.amountSats == amountSats, bolt11 == approved.bolt11 { + return true + } return canCreateReceiveLightningInvoice(amountSats: amountSats) } diff --git a/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift b/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift index 0985fd88e..bd2d64881 100644 --- a/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift +++ b/Bitkit/Views/Settings/Advanced/PrimaryNodeSettingsScreen.swift @@ -20,8 +20,8 @@ struct PrimaryNodeSettingsScreen: View { return wallet.peers?.contains { $0.nodeId == primary.nodeId && $0.isConnected } ?? false } - private var isDefault: Bool { - currentPrimary?.uri == PrimaryNode.parseOrNil(Env.defaultPrimaryNodeUri)?.uri + private var isInputDefault: Bool { + input == Env.defaultPrimaryNodeUri } var body: some View { @@ -104,7 +104,7 @@ struct PrimaryNodeSettingsScreen: View { Spacer() HStack(spacing: 16) { - CustomButton(title: t("settings__es__button_reset"), variant: .secondary, isDisabled: isLoading || isDefault) { + CustomButton(title: t("settings__es__button_reset"), variant: .secondary, isDisabled: isLoading || isInputDefault) { input = Env.defaultPrimaryNodeUri } .accessibilityIdentifier("PrimaryNodeReset") diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index b13b8e17c..c1c67a7c6 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -207,7 +207,9 @@ struct SavingsProgressView: View { transfer.startCoopCloseRetries(channels: channelsFailedToCoopClose) } } catch { - app.toast(error) + UIApplication.shared.isIdleTimerDisabled = false + app.toast(type: .error, title: t("common__error"), description: error.primaryErrorMessage) + navigation.reset() } } } diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index 4571274d1..4fee1bc08 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -25,7 +25,8 @@ struct SendEnterManuallyView: View { newValue, savingsBalanceSats: max( wallet.spendableOnchainBalanceSats, - Int(clamping: hwWalletManager.maximumFundingBalanceSats) + Int(clamping: hwWalletManager.maximumFundingBalanceSats), + Int(clamping: PrimaryNodeService.shared.maxSpliceOutSats(feeRate: 1)) ), spendingBalanceSats: wallet.maxSendLightningSats, scope: hwSend.isActive ? .onchainPayments : .unrestricted diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 76debfa7e..fba54f66f 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -34,7 +34,7 @@ struct SpendingWalletScreen: View { .padding(.top, 16) } - if shouldShowOnboarding && wallet.totalOnchainSats > 0 { + if shouldShowOnboarding || PrimaryNodeService.shared.primaryNode != nil, wallet.totalOnchainSats > 0 { transferFromSavingsButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) diff --git a/BitkitTests/PrimaryNodeTests.swift b/BitkitTests/PrimaryNodeTests.swift index 6c5c4cf69..88dc991db 100644 --- a/BitkitTests/PrimaryNodeTests.swift +++ b/BitkitTests/PrimaryNodeTests.swift @@ -44,14 +44,16 @@ final class PrimaryNodeTests: XCTestCase { } func testLiquidityErrorsMapToWhatTheUserIsTold() { - func map(_ error: NodeError) -> PrimaryReceiveError? { - PrimaryNodeService.mapError(error) as? PrimaryReceiveError - } - - XCTAssertEqual(map(.LiquiditySourceInsufficientFunds(message: "")), .insufficientFunds) - XCTAssertEqual(map(.LiquidityRequestRejected(message: "")), .rejected) - XCTAssertEqual(map(.LiquidityRequestTimedOut(message: "")), .timedOut) - XCTAssertEqual(map(.LiquidityReceiveAmountTooSmall(message: "")), .amountTooSmall) - XCTAssertEqual(map(.ConnectionFailed(message: "")), .offline) + XCTAssertEqual(NodeError.LiquiditySourceInsufficientFunds(message: "").primaryErrorMessage, t("primary_node__error_insufficient_funds")) + XCTAssertEqual(NodeError.LiquidityRequestRejected(message: "").primaryErrorMessage, t("primary_node__error_rejected")) + XCTAssertEqual(NodeError.LiquidityRequestTimedOut(message: "").primaryErrorMessage, t("primary_node__error_timed_out")) + XCTAssertEqual(NodeError.LiquidityReceiveAmountTooSmall(message: "").primaryErrorMessage, t("primary_node__error_amount_too_small")) + XCTAssertEqual(NodeError.ConnectionFailed(message: "").primaryErrorMessage, t("primary_node__error_offline")) + } + + func testAnUnreachablePrimaryWrappedByTheServiceQueueIsReportedAsOffline() { + let wrapped = Bitkit.AppError(error: NodeError.ConnectionFailed(message: "")) + + XCTAssertEqual(wrapped.primaryErrorMessage, t("primary_node__error_offline")) } } From 9233b9bb528dfa3f60aec687741ca9c0ab23258f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 10:18:56 +0100 Subject: [PATCH 09/10] fix: primary channel sends and funding --- Bitkit/Services/PrimaryNodeService.swift | 37 +++++++++++++++---- Bitkit/ViewModels/AppViewModel.swift | 5 ++- Bitkit/ViewModels/WalletViewModel.swift | 32 ++++++++++++++-- .../Views/Wallets/Send/SendAmountView.swift | 9 +++++ BitkitTests/PrimaryNodeTests.swift | 8 ++-- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/Bitkit/Services/PrimaryNodeService.swift b/Bitkit/Services/PrimaryNodeService.swift index 5dbe33329..bec331ccb 100644 --- a/Bitkit/Services/PrimaryNodeService.swift +++ b/Bitkit/Services/PrimaryNodeService.swift @@ -12,10 +12,11 @@ struct PrimaryFundingPlan: Equatable { static let minFundingSats: UInt64 = 25000 /// Automatic funding waits while its mining fee is above this share of the amount moved. static let maxFundingFeePercent: UInt64 = 5 - /// Virtual size of a splice or channel funding before its inputs, for fee estimates. - static let fundingBaseVbytes: UInt64 = 300 - /// Virtual size each wallet input adds to a funding transaction. - static let fundingInputVbytes: UInt64 = 300 + /// Virtual size of a splice before its wallet inputs, for fee estimates: the channel's shared input and + /// output plus one more output, with a margin over the 180 vB a splice-out to one address takes. + static let fundingBaseVbytes: UInt64 = 200 + /// Virtual size each wallet input adds to a funding transaction, with a margin, change included. + static let fundingInputVbytes: UInt64 = 70 /// Plans moving `spendableSats` of savings, held in `inputs` outputs, into the primary channel at `feeRate`. /// ldk-node leaves outputs of a channel's own funding transaction out of `inputs`, such as savings from a @@ -45,6 +46,8 @@ final class PrimaryNodeService { private var spliceWaiters: [String: CheckedContinuation] = [:] private let waitersLock = NSLock() private var isFunding = false + /// Splice-outs made this session; their channel-ready event moves funds out of spending, not into it. + private var spliceOutTxids: Set = [] /// How long a splice may take to reach its pending funding transaction. private static let spliceTimeoutSecs: UInt64 = 60 @@ -170,7 +173,7 @@ final class PrimaryNodeService { isFunding = true defer { isFunding = false } do { - guard let plan = try await fundingPlan(), plan.isAutomatic else { return } + guard await !hasUnconfirmedDeposit(), let plan = try await fundingPlan(), plan.isAutomatic else { return } Logger.info("Funding primary channel with \(plan.amountSats) sats, fee \(plan.feeSats)", context: "PrimaryNodeService") try await executeFunding(plan) } catch { @@ -178,6 +181,14 @@ final class PrimaryNodeService { } } + private func hasUnconfirmedDeposit() async -> Bool { + let payments = await lightningService.listPayments() ?? [] + return payments.contains { + guard $0.direction == .inbound, case .onchain(_, .unconfirmed) = $0.kind else { return false } + return true + } + } + /// Transfer to Spending: moves the planned savings now, below the automatic fee limit too, and resumes funding. @MainActor func transferToSpending(_ plan: PrimaryFundingPlan) async throws { @@ -210,7 +221,7 @@ final class PrimaryNodeService { @MainActor func spliceOutFee(amountSats: UInt64, feeRate: UInt64) -> UInt64? { guard let channel = primaryChannel(), channel.isUsable else { return nil } - let fee = feeRate * PrimaryFundingPlan.fundingBaseVbytes + let fee = spliceOutFeeAt(feeRate: feeRate) return amountSats + fee <= channel.outboundCapacityMsat / 1000 ? fee : nil } @@ -219,10 +230,21 @@ final class PrimaryNodeService { func maxSpliceOutSats(feeRate: UInt64) -> UInt64 { guard let channel = primaryChannel(), channel.isUsable else { return 0 } let outbound = channel.outboundCapacityMsat / 1000 - let fee = feeRate * PrimaryFundingPlan.fundingBaseVbytes + let fee = spliceOutFeeAt(feeRate: feeRate) return outbound > fee ? outbound - fee : 0 } + /// True for a splice-out this wallet made, whose locking is no new spending balance. + @MainActor + func isSpliceOut(txid: String) -> Bool { + spliceOutTxids.contains(txid) + } + + /// Estimated fee of a splice-out paying one address at `feeRate` sat/vB. + func spliceOutFeeAt(feeRate: UInt64) -> UInt64 { + feeRate * PrimaryFundingPlan.fundingBaseVbytes + } + /// Pays `amountSats` to `address` from the primary channel by splicing it out; returns the splice txid. @MainActor func sendOnchain(address: String, amountSats: UInt64, feeRate: UInt64) async throws -> String { @@ -240,6 +262,7 @@ final class PrimaryNodeService { try await lightningService.spliceOut(channel: channel, address: address, amountSats: amountSats, feeRateSatsPerVByte: feeRate) let txid = try await group.next() ?? "" group.cancelAll() + spliceOutTxids.insert(txid) return txid } catch { group.cancelAll() diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e4ff5d03c..cb155159c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1770,8 +1770,11 @@ extension AppViewModel { case .channelPending(channelId: _, userChannelId: _, formerTemporaryChannelId: _, counterpartyNodeId: _, fundingTxo: _): // Only relevant for channels to external nodes break - case .channelReady(let channelId, userChannelId: _, counterpartyNodeId: _, fundingTxo: _): + case .channelReady(let channelId, userChannelId: _, counterpartyNodeId: _, fundingTxo: let fundingTxo): _ = Task { + if let txid = fundingTxo?.txid, PrimaryNodeService.shared.isSpliceOut(txid: txid) { + return + } // Use channelCache instead of cachedChannels array, as it's updated immediately if let channel = await lightningService.getChannelFromCache(channelId: channelId) { let cjitOrder = await CoreService.shared.blocktank.getCjit(channel: channel) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 0c835d4c0..92817c49a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -260,9 +260,12 @@ class WalletViewModel: ObservableObject { await self.refreshAndSyncState() try? await self.refreshBip21(reportsError: false) } - case .channelReady: + case let .channelReady(_, _, _, fundingTxo): self.invalidateReceiveInvoice() Task { + if let txid = fundingTxo?.txid { + await self.confirmPrimaryChannelSend(fundingTxid: txid) + } await self.refreshAndSyncState() try? await self.refreshBip21(reportsError: false) await self.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "channel-ready refresh") @@ -622,13 +625,21 @@ class WalletViewModel: ObservableObject { } let hasPaymentContext = requestId != nil || paymentIdentity != nil || followupContext != nil || paymentDeadline != nil - if !hasPaymentContext, await shouldSendFromPrimaryChannel(address: address, sats: sats, feeRate: selectedFeeRateSatsPerVByte) { + if !hasPaymentContext, await paysFromPrimaryChannel(address: address, sats: sats, feeRate: selectedFeeRateSatsPerVByte) { try await beforeBroadcastAttempt() let txid = try await PrimaryNodeService.shared.sendOnchain( address: address, amountSats: sats, feeRate: UInt64(selectedFeeRateSatsPerVByte) ) + // ldk-node records no payment for a splice-out to an address outside the wallet, so the send is added here. + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txid, + address: address, + amount: sats, + fee: PrimaryNodeService.shared.spliceOutFeeAt(feeRate: UInt64(selectedFeeRateSatsPerVByte)), + feeRate: selectedFeeRateSatsPerVByte + ) Task { try? await sync() } @@ -665,8 +676,20 @@ class WalletViewModel: ObservableObject { return result } + /// A splice locks in once its transaction confirms, which confirms a send made out of the primary channel. + private func confirmPrimaryChannelSend(fundingTxid: String) async { + guard var activity = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: fundingTxid), + !activity.confirmed, activity.txType == .sent + else { return } + let now = UInt64(Date().timeIntervalSince1970) + activity.confirmed = true + activity.confirmTimestamp = now + activity.updatedAt = now + try? await CoreService.shared.activity.upsert(.onchain(activity)) + } + /// True when savings can't cover `sats` and its fee, but the primary channel can. - private func shouldSendFromPrimaryChannel(address: String, sats: UInt64, feeRate: UInt32) async -> Bool { + func paysFromPrimaryChannel(address: String, sats: UInt64, feeRate: UInt32) async -> Bool { guard selectedUtxos == nil, PrimaryNodeService.shared.primaryChannel() != nil else { return false } let spendable = lightningService.balances?.spendableOnchainBalanceSats ?? 0 if let fee = try? await lightningService.calculateTotalFee(address: address, amountSats: sats, satsPerVByte: feeRate), @@ -823,6 +846,9 @@ class WalletViewModel: ObservableObject { satsPerVByte: UInt32, utxosToSpend: [SpendableUtxo]? = nil ) async throws -> UInt64 { + if utxosToSpend == nil, await paysFromPrimaryChannel(address: address, sats: amountSats, feeRate: satsPerVByte) { + return PrimaryNodeService.shared.spliceOutFeeAt(feeRate: UInt64(satsPerVByte)) + } return try await lightningService.calculateTotalFee( address: address, amountSats: amountSats, diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index deb01b020..369d61c84 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -403,6 +403,15 @@ struct SendAmountView: View { } // Onchain transaction + if let address = app.scannedOnchainInvoice?.address, let feeRate = wallet.selectedFeeRateSatsPerVByte, + await wallet.paysFromPrimaryChannel(address: address, sats: amountSats, feeRate: feeRate) + { + // Savings can't cover it, so the primary channel pays it with a splice-out; no UTXOs to select. + wallet.selectedUtxos = nil + navigationPath.append(.confirm) + return + } + if settings.coinSelectionMethod == .manual { try await wallet.loadAvailableUtxos() diff --git a/BitkitTests/PrimaryNodeTests.swift b/BitkitTests/PrimaryNodeTests.swift index 88dc991db..ba0f3d49b 100644 --- a/BitkitTests/PrimaryNodeTests.swift +++ b/BitkitTests/PrimaryNodeTests.swift @@ -27,20 +27,20 @@ final class PrimaryNodeTests: XCTestCase { func testFundingPlanMovesConfirmedSavingsMinusMiningFee() throws { let plan = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 50000, inputs: 1, feeRate: 2, hasChannel: true)) - XCTAssertEqual(plan.feeSats, 1200) - XCTAssertEqual(plan.amountSats, 48800) + XCTAssertEqual(plan.feeSats, 540) + XCTAssertEqual(plan.amountSats, 49460) XCTAssertFalse(plan.opensChannel) XCTAssertTrue(plan.isAutomatic) } func testAutomaticFundingWaitsBelowMinimumOrAboveFeeLimit() throws { let small = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 24999, inputs: 1, feeRate: 1, hasChannel: true)) - let expensive = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 30000, inputs: 1, feeRate: 3, hasChannel: false)) + let expensive = try XCTUnwrap(PrimaryFundingPlan.plan(spendableSats: 30000, inputs: 1, feeRate: 6, hasChannel: false)) XCTAssertFalse(small.isAutomatic) XCTAssertFalse(expensive.isAutomatic) XCTAssertTrue(expensive.opensChannel) - XCTAssertNil(PrimaryFundingPlan.plan(spendableSats: 500, inputs: 1, feeRate: 1, hasChannel: true)) + XCTAssertNil(PrimaryFundingPlan.plan(spendableSats: 200, inputs: 1, feeRate: 1, hasChannel: true)) } func testLiquidityErrorsMapToWhatTheUserIsTold() { From b957d601a4d2421c0308e30d2fd044fdc291aa61 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 11:53:31 +0100 Subject: [PATCH 10/10] fix: send from the channel on confirm --- Bitkit/ViewModels/WalletViewModel.swift | 27 ++++++++++++------- .../Wallets/Send/SendConfirmationView.swift | 4 ++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 92817c49a..5ca751fc7 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -260,12 +260,9 @@ class WalletViewModel: ObservableObject { await self.refreshAndSyncState() try? await self.refreshBip21(reportsError: false) } - case let .channelReady(_, _, _, fundingTxo): + case .channelReady: self.invalidateReceiveInvoice() Task { - if let txid = fundingTxo?.txid { - await self.confirmPrimaryChannelSend(fundingTxid: txid) - } await self.refreshAndSyncState() try? await self.refreshBip21(reportsError: false) await self.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "channel-ready refresh") @@ -595,6 +592,7 @@ class WalletViewModel: ObservableObject { isSyncingWallet = false syncState() Task { await PrimaryNodeService.shared.fundIfNeeded() } + await confirmPrimaryChannelSend() QuickPayPaymentCoordinator.shared.reconcileAgainstLdk() if isPaykitUIActive { await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) @@ -624,7 +622,7 @@ class WalletViewModel: ObservableObject { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") } - let hasPaymentContext = requestId != nil || paymentIdentity != nil || followupContext != nil || paymentDeadline != nil + let hasPaymentContext = requestId != nil || paymentIdentity != nil || paymentDeadline != nil if !hasPaymentContext, await paysFromPrimaryChannel(address: address, sats: sats, feeRate: selectedFeeRateSatsPerVByte) { try await beforeBroadcastAttempt() let txid = try await PrimaryNodeService.shared.sendOnchain( @@ -632,13 +630,21 @@ class WalletViewModel: ObservableObject { amountSats: sats, feeRate: UInt64(selectedFeeRateSatsPerVByte) ) + if let followupContext { + try? await CoreService.shared.activity.upsertPreActivityMetadata([BitkitCore.PreActivityMetadata( + walletId: WalletScope.default, paymentId: txid, tags: followupContext.tags, paymentHash: nil, + txId: txid, address: address, isReceive: false, feeRate: UInt64(selectedFeeRateSatsPerVByte), + isTransfer: false, channelId: nil, createdAt: followupContext.createdAt + )]) + } // ldk-node records no payment for a splice-out to an address outside the wallet, so the send is added here. await CoreService.shared.activity.createSentOnchainActivityFromSendResult( txid: txid, address: address, amount: sats, fee: PrimaryNodeService.shared.spliceOutFeeAt(feeRate: UInt64(selectedFeeRateSatsPerVByte)), - feeRate: selectedFeeRateSatsPerVByte + feeRate: selectedFeeRateSatsPerVByte, + contact: followupContext?.contact ) Task { try? await sync() @@ -676,9 +682,12 @@ class WalletViewModel: ObservableObject { return result } - /// A splice locks in once its transaction confirms, which confirms a send made out of the primary channel. - private func confirmPrimaryChannelSend(fundingTxid: String) async { - guard var activity = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: fundingTxid), + /// A send out of the primary channel is a splice, so it confirms with the channel's funding transaction. + /// The splice can lock in at 0 confirmations, so the channel's confirmation count decides, not `ChannelReady`. + private func confirmPrimaryChannelSend() async { + guard let channel = PrimaryNodeService.shared.primaryChannel(), let fundingTxid = channel.fundingTxo?.txid, + (channel.confirmations ?? 0) > 0, + var activity = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: fundingTxid), !activity.confirmed, activity.txType == .sent else { return } let now = UInt64(Date().timeIntervalSince1970) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 799e2305b..a3fb38e14 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1602,7 +1602,9 @@ struct SendConfirmationView: View { warnings.append(.balance) } } else { - let onchainBalance = hwSend.isActive ? hwSend.availableSats : UInt64(clamping: wallet.totalOnchainSats) + let onchainBalance = hwSend.isActive + ? hwSend.availableSats + : max(UInt64(clamping: wallet.totalOnchainSats), PrimaryNodeService.shared.maxSpliceOutSats(feeRate: 1)) if amount > onchainBalance / 2 { warnings.append(.balance) }