diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index f83b181fa..2452a7706 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,
@@ -1221,10 +1223,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 = 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 4a921d6b3..50a6c5748 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" : "e09626aa88d43a8a7d9e969beb99920b72ad32b1"
}
},
{
diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift
index b6d10a790..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
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/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift
index 3936ba524..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:
- sheets.showSheet(.transferUnavailable)
+ route = .primaryTransfer
}
if let route {
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index 90aed6ed0..623f8d449 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -157,6 +157,15 @@ 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 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 defaultPrimaryNodeUri: String {
+ configValue("PRIMARY_NODE_URI") ?? (network == .bitcoin ? primaryNodeUriMainnet : "")
+ }
+
static let network: LDKNode.Network = {
if isUnitTest {
return .regtest
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/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
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 7e3528e6c..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 {
@@ -29,13 +31,19 @@ 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),
- (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..3b2e4a391 100644
--- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
@@ -1565,3 +1565,39 @@
"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" = "التحويل إلى الإنفاق";
+"primary_node__balance" = "بيتكوين";
diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
index 94ea5367b..97eb9a726 100644
--- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
@@ -1749,3 +1749,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
index 66328b257..91197f5b7 100644
--- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
@@ -1888,3 +1888,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
index 9ad85432e..67a2fe216 100644
--- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
@@ -1885,3 +1885,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
index b4ef29785..a1e112676 100644
--- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
@@ -1620,3 +1620,39 @@
"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" = "Μεταφορά στις Δαπάνες";
+"primary_node__balance" = "Μπιτκόιν";
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index ceae804f8..23d8f6851 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -1977,3 +1977,39 @@
"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";
+"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 781e97dae..edf5aa828 100644
--- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
@@ -1891,3 +1891,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
index c765eaaf2..4dcc06822 100644
--- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
@@ -1769,3 +1769,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
index 7a1bdafc0..316877d7e 100644
--- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
@@ -1899,3 +1899,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
index 0df8ab194..a62950ccb 100644
--- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
@@ -1858,3 +1858,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
index 99e3880cb..d80e20ff3 100644
--- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
@@ -1893,3 +1893,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
index b4697826c..35ad8546f 100644
--- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
@@ -1898,3 +1898,39 @@
"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";
+"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 2d7c2290e..87c5703a9 100644
--- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
@@ -1899,3 +1899,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
index 2a2b4076e..1e29f6857 100644
--- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
@@ -1545,3 +1545,39 @@
"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";
+"primary_node__balance" = "Bitcoin";
diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
index 02790e985..0d35dd7df 100644
--- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
@@ -1879,3 +1879,39 @@
"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" = "Перевод в расходы";
+"primary_node__balance" = "Биткоин";
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/LightningService.swift b/Bitkit/Services/LightningService.swift
index f24601b98..3be0ec3e9 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,6 +130,10 @@ class LightningService {
let builder = Builder.fromConfig(config: config)
builder.setCustomLogger(logWriter: LdkLogWriter())
+ primaryNode = PrimaryNode.current
+ if let primary = primaryNode {
+ builder.setPrimaryNode(nodeId: primary.nodeId)
+ }
let resolvedElectrumServerUrl = electrumServerUrl ?? Env.electrumServerUrl
@@ -264,6 +277,10 @@ class LightningService {
try node.start()
}
+ if let peer = primaryNode?.peer {
+ try? await connectPeer(peer: peer)
+ }
+
#if !BITKIT_NOTIFICATION_EXTENSION
do {
try await reconcileWatchOnlyAccounts()
@@ -372,7 +389,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 != self.primaryNode?.nodeId },
channels: node.listChannels(),
lightningBalances: balances.lightningBalances,
pendingSweeps: balances.pendingBalancesFromChannelClosures
@@ -716,6 +733,88 @@ class LightningService {
try await receiveMsats(amountMsats: amountSats.map { $0 * 1000 }, description: description, expirySecs: expirySecs)
}
+ /// 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 = primaryNode else {
+ throw PrimaryNodeError.notConfigured
+ }
+
+ let bolt11 = try await ServiceQueue.background(.ldk) {
+ try Self.connectPrimaryIfNeeded(node: node, primary: primary)
+ return try node.receiveViaPrimary(
+ nodeId: primary.nodeId,
+ amountMsat: amountSats * 1000,
+ description: Bolt11InvoiceDescription.direct(description: description),
+ 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)
@@ -933,6 +1032,36 @@ 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)
+ }
+
+ 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/Services/PrimaryNodeService.swift b/Bitkit/Services/PrimaryNodeService.swift
new file mode 100644
index 000000000..bec331ccb
--- /dev/null
+++ b/Bitkit/Services/PrimaryNodeService.swift
@@ -0,0 +1,359 @@
+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 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
+ /// 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 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,
+ 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
+ /// 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
+
+ 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 }
+ 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
+ } 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.
+ /// For a manual transfer, an offline primary or a pending funding throws instead of planning nothing.
+ @MainActor
+ 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()
+ 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 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 {
+ Logger.warn("Failed to fund primary channel: \(error)", context: "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 {
+ 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)
+ }
+ }
+
+ @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 = spliceOutFeeAt(feeRate: feeRate)
+ 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 = 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 {
+ 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()
+ spliceOutTxids.insert(txid)
+ 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 {
+ let error = (error as? AppError)?.underlyingError ?? 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/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/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 65dfd0cf3..cb155159c 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
@@ -1769,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/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 6bf25a561..d60fbd6bf 100644
--- a/Bitkit/ViewModels/TransferViewModel.swift
+++ b/Bitkit/ViewModels/TransferViewModel.swift
@@ -242,7 +242,10 @@ 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] {
@@ -252,6 +255,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 \(primaryChannel.channelId): \(error)", context: "TransferViewModel")
+ throw error
+ }
+ }
// 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 1d30a3e4c..5ca751fc7 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 {
@@ -330,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
@@ -579,6 +591,8 @@ class WalletViewModel: ObservableObject {
isSyncingWallet = false
syncState()
+ Task { await PrimaryNodeService.shared.fundIfNeeded() }
+ await confirmPrimaryChannelSend()
QuickPayPaymentCoordinator.shared.reconcileAgainstLdk()
if isPaykitUIActive {
await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self)
@@ -608,6 +622,36 @@ 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 || paymentDeadline != nil
+ 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)
+ )
+ 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,
+ contact: followupContext?.contact
+ )
+ Task {
+ try? await sync()
+ }
+ return .accepted(txid: txid)
+ }
+
if let selectedUtxos {
Logger.info("Using selected UTXO for send: \(selectedUtxos)")
} else {
@@ -638,6 +682,33 @@ class WalletViewModel: ObservableObject {
return result
}
+ /// 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)
+ 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.
+ 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),
+ sats + fee <= spendable
+ {
+ return false
+ }
+ return PrimaryNodeService.shared.spliceOutFee(amountSats: sats, feeRate: UInt64(feeRate)) != nil
+ }
+
func resolvedAcceptedOnchainTransfer(context: OnchainSendPendingContext, attempts: OnchainSendAttemptService) async throws
-> OnchainSendLocalResolution?
{
@@ -784,6 +855,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,
@@ -833,13 +907,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
@@ -1303,6 +1384,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)
}
@@ -1547,7 +1631,11 @@ class WalletViewModel: ObservableObject {
try Task.checkCancellation()
guard receiveRefreshID == refreshID else { return }
var invoice = ""
- if canCreateReceiveLightningInvoice(amountSats: amountSats) {
+ 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)
} else if try await isReceiveInvoiceReusable(cachedInvoice) {
@@ -1623,6 +1711,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/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/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..bd2d64881
--- /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 isInputDefault: Bool {
+ input == Env.defaultPrimaryNodeUri
+ }
+
+ 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 || isInputDefault) {
+ 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/Transfer/PrimaryTransferScreen.swift b/Bitkit/Views/Transfer/PrimaryTransferScreen.swift
new file mode 100644
index 000000000..3ea969e86
--- /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(isManual: true)
+ } 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/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/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
index 61433e842..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)
@@ -234,7 +236,8 @@ struct ReceiveEdit: View {
ReceiveLiquidityDecision.additionalLiquidityAction(
source: liquiditySource,
invoiceAmountSats: amountViewModel.amountSats,
- inboundCapacitySats: wallet.totalReadyInboundLightningSats
+ inboundCapacitySats: wallet.totalReadyInboundLightningSats,
+ 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 e666abb9f..c3e905a9c 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 PrimaryNodeService.shared.primaryNode != nil {
+ navigationPath.append(.edit(tab: .spending, onchainOnly: false))
+ } else {
+ navigationPath.append(.transferUnavailable)
+ }
}
} else if showDetails {
VStack(spacing: 16) {
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 d757856c3..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)
) {
- sheets.showSheet(.transferUnavailable)
+ navigation.navigate(.primaryTransfer)
}
.accessibilityIdentifier("TransferToSpending")
}
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/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)
}
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 bae55e69a..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)
@@ -115,7 +115,7 @@ struct SpendingWalletScreen: View {
.frame(width: 16, height: 16)
.foregroundColor(.white80)
) {
- sheets.showSheet(.transferUnavailable)
+ navigation.navigate(.primaryTransfer)
}
.accessibilityIdentifier("TransferFromSavings")
}
diff --git a/BitkitTests/PrimaryNodeTests.swift b/BitkitTests/PrimaryNodeTests.swift
new file mode 100644
index 000000000..ba0f3d49b
--- /dev/null
+++ b/BitkitTests/PrimaryNodeTests.swift
@@ -0,0 +1,59 @@
+@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, 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: 6, hasChannel: false))
+
+ XCTAssertFalse(small.isAutomatic)
+ XCTAssertFalse(expensive.isAutomatic)
+ XCTAssertTrue(expensive.opensChannel)
+ XCTAssertNil(PrimaryFundingPlan.plan(spendableSats: 200, inputs: 1, feeRate: 1, hasChannel: true))
+ }
+
+ func testLiquidityErrorsMapToWhatTheUserIsTold() {
+ 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"))
+ }
+}
diff --git a/BitkitTests/ReceiveLiquidityDecisionTests.swift b/BitkitTests/ReceiveLiquidityDecisionTests.swift
index de2dfcdf7..4447068ca 100644
--- a/BitkitTests/ReceiveLiquidityDecisionTests.swift
+++ b/BitkitTests/ReceiveLiquidityDecisionTests.swift
@@ -77,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
new file mode 100644
index 000000000..6e809af9f
--- /dev/null
+++ b/changelog.d/next/1160.added.md
@@ -0,0 +1 @@
+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
+
+