From fa4e6316eb55d81b90ae63baffbb60a63799568c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:00:53 +0100 Subject: [PATCH 01/15] feat: spending on arkade --- Bitkit/AppScene.swift | 51 +- Bitkit/Components/LightningChannel.swift | 106 ++-- Bitkit/Constants/Env.swift | 14 + Bitkit/MainNavView.swift | 4 + Bitkit/Managers/ArkadeBridge.swift | 231 +++++++ Bitkit/Managers/ArkadeManager.swift | 577 ++++++++++++++++++ Bitkit/Models/BackupCategory.swift | 5 + Bitkit/Models/BackupPayloads.swift | 24 + .../Localization/ar.lproj/Localizable.strings | 18 + .../Localization/ca.lproj/Localizable.strings | 18 + .../Localization/cs.lproj/Localizable.strings | 18 + .../Localization/de.lproj/Localizable.strings | 18 + .../Localization/el.lproj/Localizable.strings | 18 + .../Localization/en.lproj/Localizable.strings | 18 + .../es-419.lproj/Localizable.strings | 18 + .../Localization/es.lproj/Localizable.strings | 18 + .../Localization/fr.lproj/Localizable.strings | 18 + .../Localization/it.lproj/Localizable.strings | 18 + .../Localization/nl.lproj/Localizable.strings | 18 + .../Localization/pl.lproj/Localizable.strings | 18 + .../pt-BR.lproj/Localizable.strings | 18 + .../Localization/pt.lproj/Localizable.strings | 18 + .../Localization/ru.lproj/Localizable.strings | 18 + Bitkit/Services/BackupService.swift | 39 ++ Bitkit/Services/CoreService.swift | 26 + Bitkit/Services/LightningService.swift | 96 ++- Bitkit/Services/PaykitAllowanceManager.swift | 3 +- .../Services/PaykitPaymentProofService.swift | 32 +- .../PaykitPaymentRequestService.swift | 2 +- .../PrivatePaykitService+Invoices.swift | 56 +- Bitkit/Services/PublicPaykitService.swift | 9 +- Bitkit/Utilities/AppStatusHelper.swift | 7 +- .../QuickPayPaymentCoordinator.swift | 6 +- Bitkit/Utilities/QuickPaySpendStore.swift | 11 + Bitkit/ViewModels/AppViewModel.swift | 17 +- Bitkit/ViewModels/NavigationViewModel.swift | 4 + Bitkit/ViewModels/TransferViewModel.swift | 62 +- Bitkit/ViewModels/WalletViewModel.swift | 65 +- .../CreatePaymentRequestView.swift | 2 +- .../Advanced/AdvancedSettingsView.swift | 3 +- .../Advanced/ArkadeConnectionViews.swift | 282 +++++++++ .../Advanced/LightningConnectionsView.swift | 23 +- .../Settings/Security/DataBackupsScreen.swift | 9 +- .../CreateSubscriptionView.swift | 2 +- .../Views/Transfer/ArkadeFundingViews.swift | 309 ++++++++++ Bitkit/Views/Transfer/FundingOptions.swift | 26 +- .../Views/Transfer/SavingsConfirmView.swift | 2 +- .../Views/Transfer/SavingsProgressView.swift | 4 +- .../Views/Wallets/Receive/ReceiveEdit.swift | 6 +- .../Views/Wallets/SavingsWalletScreen.swift | 6 +- .../Views/Wallets/Send/LnurlPayAmount.swift | 9 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 27 +- .../Views/Wallets/SpendingWalletScreen.swift | 8 +- BitkitTests/ArkadeExitBackupTests.swift | 65 ++ BitkitTests/ArkadeManagerTests.swift | 135 ++++ BitkitTests/ChannelFreeRailTests.swift | 124 ++++ .../PaykitPaymentRequestServiceTests.swift | 34 ++ .../transfer/arkade-transfer-to-spending.xml | 20 + .../transfer/transfer-unavailable-sheet.xml | 4 +- 59 files changed, 2674 insertions(+), 143 deletions(-) create mode 100644 Bitkit/Managers/ArkadeBridge.swift create mode 100644 Bitkit/Managers/ArkadeManager.swift create mode 100644 Bitkit/Views/Settings/Advanced/ArkadeConnectionViews.swift create mode 100644 Bitkit/Views/Transfer/ArkadeFundingViews.swift create mode 100644 BitkitTests/ArkadeExitBackupTests.swift create mode 100644 BitkitTests/ArkadeManagerTests.swift create mode 100644 BitkitTests/ChannelFreeRailTests.swift create mode 100644 journeys/transfer/arkade-transfer-to-spending.xml diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e14a45a2b..b5bb5d2f0 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -297,6 +297,16 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { return IncomingPaykitPaymentRequestPresentationFeedback(paymentDetailsPendingWasRequested: wasRequested) } + /// Ends a presentation the user asked for once its payment details fail to resolve, so the send sheet stops loading + /// and the user sees why; the request stays in Payment Requests to try again. Automatic presentations keep retrying. + static func finishRequestedPresentationAfterResolutionFailure( + for request: PaykitPaymentRequest, + with manager: PaykitPaymentRequestManager + ) -> IncomingPaykitPaymentRequestPresentationFeedback? { + guard manager.requestedPresentationId == request.id else { return nil } + return finishPendingPrivateLink(for: request, with: manager) + } + static func handleStateChange( from previous: IncomingPaykitPaymentRequestPresentationState, to current: IncomingPaykitPaymentRequestPresentationState, @@ -509,8 +519,8 @@ struct AppScene: View { let reservations = PrivatePaykitAddressReservationStore.shared let reservationRevision = await reservations.attributionRevision guard let combined = try? await contacts.includingReservations(for: outputAddresses, lookup: { - try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) - }), pubkyProfile.authState == .authenticated, + try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) + }), pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey), contacts == paykitPaymentRequestManager.receivedPaymentContacts, await reservations.attributionRevision == reservationRevision @@ -1718,6 +1728,20 @@ struct AppScene: View { return } guard case let .opened(paymentTarget, privatePaymentContext, endpoints) = result else { + let isWaitingForUpdatedList = if case .waitingForUpdatedPaymentList = result { + true + } else { + false + } + if !isWaitingForUpdatedList, + let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: paykitPaymentRequestManager + ) + { + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + return + } deferIncomingPaykitPaymentRequestPresentation( request, reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed @@ -1843,6 +1867,13 @@ struct AppScene: View { return } catch { guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } + if let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: paykitPaymentRequestManager + ) { + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + return + } deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed) return } @@ -1994,14 +2025,14 @@ struct AppScene: View { return } guard IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( - manager: paykitPaymentRequestManager, app: app, sheets: sheets - ), await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), - !Task.isCancelled, - pubkyProfile.automaticPaykitSession == session, - PaykitSubscriptionNotificationTargetStore.load() == target, - IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( - manager: paykitPaymentRequestManager, app: app, sheets: sheets - ) + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ), await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), + !Task.isCancelled, + pubkyProfile.automaticPaykitSession == session, + PaykitSubscriptionNotificationTargetStore.load() == target, + IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ) else { return } guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { diff --git a/Bitkit/Components/LightningChannel.swift b/Bitkit/Components/LightningChannel.swift index 5d35ecfe3..702537d4d 100644 --- a/Bitkit/Components/LightningChannel.swift +++ b/Bitkit/Components/LightningChannel.swift @@ -12,19 +12,23 @@ struct LightningChannel: View { let remoteBalance: UInt64 let status: ChannelStatus let showLabels: Bool + /// False for a balance whose receiving isn't capped by a channel, such as Arkade: only the spending side is drawn. + let showReceiving: Bool init( capacity: UInt64, localBalance: UInt64, remoteBalance: UInt64, status: ChannelStatus = .open, - showLabels: Bool = false + showLabels: Bool = false, + showReceiving: Bool = true ) { self.capacity = capacity self.localBalance = localBalance self.remoteBalance = remoteBalance self.status = status self.showLabels = showLabels + self.showReceiving = showReceiving } private var spendingColor: Color { @@ -90,7 +94,9 @@ struct LightningChannel: View { Spacer() - CaptionMText(t("lightning__receiving_label")) + if showReceiving { + CaptionMText(t("lightning__receiving_label")) + } } } @@ -106,49 +112,75 @@ struct LightningChannel: View { Spacer() - HStack(spacing: 4) { - Image("arrow-down") - .resizable() - .frame(width: 14, height: 14) - .foregroundColor(receivingAvailableColor) + if showReceiving { + HStack(spacing: 4) { + Image("arrow-down") + .resizable() + .frame(width: 14, height: 14) + .foregroundColor(receivingAvailableColor) - BodySSBText(formatNumber(remoteBalance), textColor: receivingAvailableColor) + BodySSBText(formatNumber(remoteBalance), textColor: receivingAvailableColor) + } } } - HStack(spacing: 4) { - // Spending bar (left) - ZStack(alignment: .trailing) { - // Background - Rectangle() - .fill(spendingColor) - .cornerRadius(8, corners: [.topLeft, .bottomLeft]) - - // Available - Rectangle() - .fill(spendingAvailableColor) - .frame(width: spendingPercentage * 100.0) - .cornerRadius(8, corners: [.topLeft, .bottomLeft]) - } - - // Receiving bar (right) - ZStack(alignment: .leading) { - // Background - Rectangle() - .fill(receivingColor) - .cornerRadius(8, corners: [.topRight, .bottomRight]) - - // Available - Rectangle() - .fill(receivingAvailableColor) - .frame(width: receivingPercentage * 100.0) - .cornerRadius(8, corners: [.topRight, .bottomRight]) - } + if showReceiving { + channelBars + } else { + spendingBar } - .frame(height: 16) } .opacity(status == .pending ? 0.5 : 1.0) } + + private var spendingBar: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(localBalance == 0 ? Color.gray6 : spendingColor) + .cornerRadius(8) + + Rectangle() + .fill(spendingAvailableColor) + .frame(width: spendingPercentage * geometry.size.width) + .cornerRadius(8) + } + } + .frame(height: 16) + } + + private var channelBars: some View { + HStack(spacing: 4) { + // Spending bar (left) + ZStack(alignment: .trailing) { + // Background + Rectangle() + .fill(spendingColor) + .cornerRadius(8, corners: [.topLeft, .bottomLeft]) + + // Available + Rectangle() + .fill(spendingAvailableColor) + .frame(width: spendingPercentage * 100.0) + .cornerRadius(8, corners: [.topLeft, .bottomLeft]) + } + + // Receiving bar (right) + ZStack(alignment: .leading) { + // Background + Rectangle() + .fill(receivingColor) + .cornerRadius(8, corners: [.topRight, .bottomRight]) + + // Available + Rectangle() + .fill(receivingAvailableColor) + .frame(width: receivingPercentage * 100.0) + .cornerRadius(8, corners: [.topRight, .bottomRight]) + } + } + .frame(height: 16) + } } /// Extension to apply rounded corners to specific corners diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 90aed6ed0..3e2c05ec5 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -272,6 +272,20 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/ldk") } + /// A restored Arkade wallet data backup waiting to be imported when the Arkade wallet next starts. + static func arkadeExitRestore(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/arkade-restore/wallet-state.json") + } + + /// Written after the Arkade wallet's first start on this install; until then it scans the operator for funds. + static func arkadeStartedMarker(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/arkade-restore/started") + } + static func bitkitCoreStorage(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 64da4682a..e799d3571 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -514,6 +514,9 @@ struct MainNavView: View { case let .fundManualAmount(lnPeer): FundManualAmountView(lnPeer: lnPeer) case let .fundManualConfirm(lnPeer, amountSats): FundManualConfirmView(lnPeer: lnPeer, amountSats: amountSats) case .fundManualSuccess: FundManualSuccessView() + case .arkadeFundingAmount: ArkadeFundingAmountView() + case let .arkadeFundingConfirm(amountSats, isMaxAmount): ArkadeFundingConfirmView(amountSats: amountSats, isMaxAmount: isMaxAmount) + case let .arkadeFundingDeposit(txid): ArkadeFundingDepositView(txid: txid) case let .lnurlChannel(channelData): LnurlChannel(channelData: channelData) case .savingsIntro: SavingsIntroView() case .savingsAvailability: SavingsAvailabilityView() @@ -692,6 +695,7 @@ struct MainNavView: View { case .addressTypePreference: AddressTypePreferenceView() case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) + case .arkadeConnectionDetail: ArkadeConnectionDetailView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/ArkadeBridge.swift b/Bitkit/Managers/ArkadeBridge.swift new file mode 100644 index 000000000..7abbc226c --- /dev/null +++ b/Bitkit/Managers/ArkadeBridge.swift @@ -0,0 +1,231 @@ +import Foundation + +/// Arkade's wallet and Lightning client for Bitkit. The engine is a Rust binding on `arkade-os/rust-sdk` (ark-rs) over +/// UniFFI, shared with Android, which adds the Arkade Intents Lightning client the Rust SDK lacks. Until it lands, +/// every call throws `ArkadeError.unsupported` and Arkade stays off, so every flow takes its channel path. +@MainActor +final class ArkadeBridge { + var onEvent: ((ArkadeBridgeEvent) -> Void)? + var onTerminated: (() -> Void)? + + static let isAvailable = false + + func start(_: ArkadeStartParams) async throws -> ArkadeInfo { + throw ArkadeError.unsupported + } + + func info() async throws -> ArkadeInfo { + throw ArkadeError.unsupported + } + + func balance() async throws -> ArkadeBalance { + throw ArkadeError.unsupported + } + + func vtxos() async throws -> [ArkadeVtxo] { + throw ArkadeError.unsupported + } + + func history() async throws -> [ArkadeHistoryEntry] { + throw ArkadeError.unsupported + } + + func swaps() async throws -> [ArkadeSwap] { + throw ArkadeError.unsupported + } + + func receive(amountSats _: UInt64) async throws -> ArkadeSwap { + throw ArkadeError.unsupported + } + + func quotePay(bolt11 _: String) async throws -> ArkadePayQuote { + throw ArkadeError.unsupported + } + + func pay(bolt11 _: String) async throws -> ArkadeSwap { + throw ArkadeError.unsupported + } + + func offboard(address _: String) async throws -> String { + throw ArkadeError.unsupported + } + + func lightningAddress() async throws -> ArkadeLightningAddress? { + throw ArkadeError.unsupported + } + + func exportState() async throws -> String { + throw ArkadeError.unsupported + } + + func wipe() async throws {} + func reload() async throws {} +} + +enum ArkadeBridgeEvent { + case swap(ArkadeSwap) + case history([ArkadeHistoryEntry]) +} + +/// What `start` gets; the mnemonic and passphrase leave the app only inside this call. +struct ArkadeStartParams: Encodable { + let mnemonic: String + let passphrase: String? + let network: String + let arkServerUrl: String + let delegateUrl: String? + let lnurlUrl: String? + let restore: Bool + let backup: String? +} + +struct ArkadeInfo: Decodable { + let arkAddress: String + let boardingAddress: String + let network: String +} + +struct ArkadeBalance: Decodable { + var available: Int64 = 0 + var recoverable: Int64 = 0 + var boardingConfirmed: Int64 = 0 + var boardingUnconfirmed: Int64 = 0 + + init(available: Int64 = 0, recoverable: Int64 = 0, boardingConfirmed: Int64 = 0, boardingUnconfirmed: Int64 = 0) { + self.available = available + self.recoverable = recoverable + self.boardingConfirmed = boardingConfirmed + self.boardingUnconfirmed = boardingUnconfirmed + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + available = try container.decodeIfPresent(Int64.self, forKey: .available) ?? 0 + recoverable = try container.decodeIfPresent(Int64.self, forKey: .recoverable) ?? 0 + boardingConfirmed = try container.decodeIfPresent(Int64.self, forKey: .boardingConfirmed) ?? 0 + boardingUnconfirmed = try container.decodeIfPresent(Int64.self, forKey: .boardingUnconfirmed) ?? 0 + } + + private enum CodingKeys: String, CodingKey { + case available, recoverable, boardingConfirmed, boardingUnconfirmed + } +} + +struct ArkadeVtxo: Decodable, Equatable { + var txid: String? + /// Unix seconds, or milliseconds, after which the operator may sweep this output unless it is renewed. + var batchExpiry: Int64? + var isSwept: Bool? + + init(txid: String? = nil, batchExpiry: Int64? = nil, isSwept: Bool? = nil) { + self.txid = txid + self.batchExpiry = batchExpiry + self.isSwept = isSwept + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + txid = try? container.decodeIfPresent(String.self, forKey: .txid) + batchExpiry = try? container.decodeIfPresent(Int64.self, forKey: .batchExpiry) + isSwept = try? container.decodeIfPresent(Bool.self, forKey: .isSwept) + } + + private enum CodingKeys: String, CodingKey { + case txid, batchExpiry, isSwept + } +} + +struct ArkadeHistoryEntry: Decodable, Equatable { + static let received = "RECEIVED" + static let sent = "SENT" + + var boardingTxid: String? + var commitmentTxid: String? + var arkTxid: String? + /// `SENT` or `RECEIVED`. + var type: String + var amount: Int64 + var settled: Bool + /// Unix milliseconds. + var createdAt: Int64 +} + +/// A Lightning swap through Arkade Intents: `receive` pays into the wallet, `send` pays an invoice out of it. +struct ArkadeSwap: Decodable, Equatable { + static let claimed = "claimed" + static let paid = "paid" + static let failedSendOutcomes: Set = ["refunded", "cancelled", "failed"] + + var id: String? + var direction: String? + var outcome: String? + var takeSat: Int64? + var feeSat: Int64? + var bolt11: String? + var paymentHash: String? + /// Unix seconds. + var createdAt: Int64? + /// Unix seconds. + var updatedAt: Int64? + + init( + id: String? = nil, + direction: String? = nil, + outcome: String? = nil, + takeSat: Int64? = nil, + feeSat: Int64? = nil, + bolt11: String? = nil, + paymentHash: String? = nil, + createdAt: Int64? = nil, + updatedAt: Int64? = nil + ) { + self.id = id + self.direction = direction + self.outcome = outcome + self.takeSat = takeSat + self.feeSat = feeSat + self.bolt11 = bolt11 + self.paymentHash = paymentHash + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Reads each field on its own, so one the bridge reports in another shape doesn't drop the whole swap. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try? container.decodeIfPresent(String.self, forKey: .id) + direction = try? container.decodeIfPresent(String.self, forKey: .direction) + outcome = try? container.decodeIfPresent(String.self, forKey: .outcome) + takeSat = try? container.decodeIfPresent(Int64.self, forKey: .takeSat) + feeSat = try? container.decodeIfPresent(Int64.self, forKey: .feeSat) + bolt11 = try? container.decodeIfPresent(String.self, forKey: .bolt11) + paymentHash = try? container.decodeIfPresent(String.self, forKey: .paymentHash) + createdAt = try? container.decodeIfPresent(Int64.self, forKey: .createdAt) + updatedAt = try? container.decodeIfPresent(Int64.self, forKey: .updatedAt) + } + + private enum CodingKeys: String, CodingKey { + case id, direction, outcome, takeSat, feeSat, bolt11, paymentHash, createdAt, updatedAt + } + + var isReceive: Bool { + direction == "receive" + } + + var isSend: Bool { + direction == "send" + } +} + +struct ArkadePayQuote: Decodable { + let feeSat: Int64 +} + +struct ArkadeLightningAddress: Decodable { + let lightningAddress: String? + let lnurl: String +} + +private struct ArkadeTxid: Decodable { + let txid: String? +} diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift new file mode 100644 index 000000000..206a10507 --- /dev/null +++ b/Bitkit/Managers/ArkadeManager.swift @@ -0,0 +1,577 @@ +import Combine +import Foundation +import LDKNode +import Observation + +/// Lightning without channels, through Arkade (Ark Labs) and Arkade Intents. Savings to Spending boards on-chain funds +/// into the Arkade wallet, receives fall back to it when no channel has the inbound capacity, and sends fall back to it +/// when no channel has the outbound capacity. The wallet runs in Ark Labs' TypeScript stack inside `ArkadeBridge`'s +/// hidden web view and derives its keys from the Bitkit seed on its own BIP86 account (`m/86'/'/1'`). +/// Its payments reach the app as LDK events through `LightningService.emitChannelFreeRailEvent`, so the receive and +/// send flows built on node events work for both rails. +@Observable @MainActor +final class ArkadeManager: ChannelFreeLightningRail { + static let shared = ArkadeManager() + private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() + /// Fires once Arkade connects and whenever its outputs or swaps change, so the wallet data backup is redone. + nonisolated static var exitStateChangedPublisher: AnyPublisher { + exitStateChanged.eraseToAnyPublisher() + } + + /// Prefix of the activity id for a payment Arkade received outside a swap, such as an offline LNURL receive. + nonisolated static let arkReceiveIdPrefix = "ark:" + + private(set) var isReady = false + /// Spendable now: settled plus preconfirmed outputs. + private(set) var balanceSats: UInt64 = 0 + /// On-chain deposits waiting to confirm or to be boarded into the next batch. + private(set) var pendingDepositSats: UInt64 = 0 + /// Expired or swept outputs that the next renewal settles back into the balance. + private(set) var recoverableFundsSats: UInt64 = 0 + /// On-chain deposits into Arkade, pending ones first, for the Lightning Connections screens. + private(set) var deposits: [ArkadeDeposit] = [] + private(set) var withdrawals: [ArkadeWithdrawal] = [] + /// Unix seconds of the soonest batch expiry; the wallet renews 3 days before it. + private(set) var nextExpiryAt: UInt64? + private(set) var lightningAddress: String? + /// Called after the Arkade balance or readiness was read again, so balances and the receive invoice can follow. + var onStateChanged: (() -> Void)? + + /// Deposits this app broadcast that the Arkade wallet hasn't listed yet, by txid, so they show as pending at once. + @ObservationIgnored private var broadcastDeposits: [String: UInt64] = [:] + @ObservationIgnored private var info: ArkadeInfo? + @ObservationIgnored private var walletIndex = 0 + @ObservationIgnored private var connectTask: Task? + @ObservationIgnored private lazy var bridge: ArkadeBridge = { + let bridge = ArkadeBridge() + bridge.onEvent = { [weak self] event in + Task { await self?.onEvent(event) } + } + bridge.onTerminated = { [weak self] in + Task { await self?.restartAfterTermination() } + } + return bridge + }() + + private init() {} + + var isSupported: Bool { + ArkadeBridge.isAvailable && Self.config != nil + } + + var isConnected: Bool { + info != nil + } + + /// Arkade runs on mainnet against Ark Labs' operator, and on mutinynet, Arkade's test network, for dev builds. + nonisolated static var config: ArkadeConfig? { + switch Env.network { + case .bitcoin: + ArkadeConfig( + network: "bitcoin", + arkServerUrl: "https://arkade.computer", + delegateUrl: "https://delegate.arkade.money", + lnurlUrl: "https://lnurl.arkade.sh" + ) + case .regtest: + ArkadeConfig( + network: "mutinynet", + arkServerUrl: "https://mutinynet.arkade.sh", + delegateUrl: "https://delegator.mutinynet.arkade.sh", + lnurlUrl: nil + ) + default: + nil + } + } + + func start(walletIndex: Int) async { + guard Self.config != nil else { return } + do { + if info == nil { + if connectTask == nil { + connectTask = Task { try await connect(walletIndex: walletIndex) } + } + try await connectTask?.value + connectTask = nil + } + try await refresh() + await syncActivities() + LightningService.shared.channelFreeRail = self + do { + lightningAddress = try await bridge.lightningAddress()?.lightningAddress + } catch { + Logger.warn("Failed to get the Arkade Lightning address: \(error)", context: "ArkadeManager") + } + } catch { + connectTask = nil + Logger.error("Failed to start Arkade wallet: \(error)", context: "ArkadeManager") + } + } + + /// Starts the wallet in the bridge. The first start on an install scans the operator for the wallet's past outputs + /// and swaps, which a wallet restored from its phrase needs, and a restored backup is imported before the wallet + /// opens its databases. + private func connect(walletIndex: Int) async throws { + guard let config = Self.config else { throw ArkadeError.unsupported } + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let restoreFile = Env.arkadeExitRestore(walletIndex: walletIndex) + let backup = FileManager.default.contents(atPath: restoreFile.path).map { String(decoding: $0, as: UTF8.self) } + let startedMarker = Env.arkadeStartedMarker(walletIndex: walletIndex) + let params = ArkadeStartParams( + mnemonic: mnemonic, + passphrase: passphrase, + network: config.network, + arkServerUrl: config.arkServerUrl, + delegateUrl: config.delegateUrl, + lnurlUrl: config.lnurlUrl, + restore: !FileManager.default.fileExists(atPath: startedMarker.path), + backup: backup + ) + info = try await bridge.start(params) + self.walletIndex = walletIndex + try FileManager.default.createDirectory(at: startedMarker.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("\(Date().timeIntervalSince1970)".utf8).write(to: startedMarker, options: .atomic) + if backup != nil { + try? FileManager.default.removeItem(at: restoreFile) + Logger.info("Imported Arkade wallet data from backup", context: "ArkadeManager") + } + Logger.info("Connected Arkade wallet on '\(config.network)'", context: "ArkadeManager") + Self.exitStateChanged.send() + } + + /// The web content process took the wallet with it: start it again, without a restore scan or backup import. + private func restartAfterTermination() async { + guard info != nil else { return } + Logger.warn("Restarting Arkade wallet after its web content process stopped", context: "ArkadeManager") + info = nil + isReady = false + await start(walletIndex: walletIndex) + } + + func wipe(walletIndex: Int) async { + if LightningService.shared.channelFreeRail === self { + LightningService.shared.channelFreeRail = nil + } + do { + try await bridge.wipe() + } catch { + Logger.warn("Failed to wipe Arkade storage: \(error)", context: "ArkadeManager") + } + try? FileManager.default.removeItem(at: Env.arkadeExitRestore(walletIndex: walletIndex)) + try? FileManager.default.removeItem(at: Env.arkadeStartedMarker(walletIndex: walletIndex)) + info = nil + connectTask = nil + isReady = false + balanceSats = 0 + pendingDepositSats = 0 + recoverableFundsSats = 0 + deposits = [] + withdrawals = [] + nextExpiryAt = nil + lightningAddress = nil + broadcastDeposits = [:] + } + + // MARK: ChannelFreeLightningRail + + /// An invoice for exactly `amountSats` through Arkade's Lightning solver. Without an amount there is no invoice: + /// the wallet's Lightning address takes amountless payments instead, so its LNURL is returned. + nonisolated func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + try await createInvoice(amountSats: amountSats) + } + + private func createInvoice(amountSats: UInt64?) async throws -> String { + guard let amountSats, amountSats > 0 else { + guard let address = try await bridge.lightningAddress() else { + throw ArkadeError.bridge("Arkade has no Lightning address") + } + lightningAddress = address.lightningAddress + return address.lnurl + } + let swap = try await bridge.receive(amountSats: amountSats) + guard let bolt11 = swap.bolt11 else { throw ArkadeError.bridge("Arkade returned no invoice") } + return bolt11 + } + + /// Pays `bolt11` and returns its payment hash, the id LDK payments use, so the send flow can match it. Returns once + /// the lockup is funded; the outcome arrives as a swap event. + nonisolated func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + guard Self.invoiceAmountSats(bolt11) != nil else { + throw ArkadeError.bridge("Arkade can't pay an invoice without an amount, '\(sats.map(String.init) ?? "nil")' sats asked") + } + let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash() + try await pay(bolt11: bolt11) + return paymentHash + } + + private func pay(bolt11: String) async throws { + let swap = try await bridge.pay(bolt11: bolt11) + Logger.info("Funded Arkade payment '\(swap.id ?? "")' with outcome '\(swap.outcome ?? "")'", context: "ArkadeManager") + await recordSwap(swap) + try await refresh() + } + + nonisolated func lightningPayments() async -> [ChannelFreeRailPayment] { + await listSwaps().compactMap(Self.railPayment) + } + + /// The solver's fee for paying `bolt11`: the difference between the lockup and the invoice amount. + nonisolated func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + if amountSats != nil, Self.invoiceAmountSats(bolt11) == nil { + throw ArkadeError.bridge("AmountlessInvoice") + } + return try await UInt64(max(0, quotePay(bolt11: bolt11).feeSat)) + } + + private func quotePay(bolt11: String) async throws -> ArkadePayQuote { + try await bridge.quotePay(bolt11: bolt11) + } + + private nonisolated static func invoiceAmountSats(_ bolt11: String) -> UInt64? { + (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } + } + + // MARK: Transfers + + /// The on-chain boarding address that funds the Arkade wallet; a deposit is boarded once it confirms. + func depositAddress() async throws -> String { + try await bridge.info().boardingAddress + } + + /// What the operator charges to board a deposit: its fee formula has no on-chain input term, so 0 today. + func boardingFeeSats() -> UInt64 { + 0 + } + + /// Moves the whole Arkade balance to the on-chain `address` in the next batch, for Spending to Savings. + func withdrawAll(to address: String) async throws { + let txid = try await bridge.offboard(address: address) + Logger.info("Offboarded Arkade balance in '\(txid)'", context: "ArkadeManager") + try await refresh() + } + + /// The wallet data backup, or nil while Arkade is not connected and there is nothing to back up. + func exitBackup() async throws -> ArkadeExitBackupV1? { + guard isConnected, let config = Self.config else { return nil } + return try await ArkadeExitBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), + network: config.network, + exitState: bridge.exportState() + ) + } + + /// Puts restored wallet data back before the Arkade wallet next opens its databases, and has it scan the operator + /// again on that start for outputs and swaps the backup missed. + func restoreExitBackup(_ backup: ArkadeExitBackupV1, walletIndex: Int) async throws { + guard let config = Self.config, backup.network == config.network else { + Logger.warn("Skipped Arkade wallet data from network '\(backup.network)'", context: "ArkadeManager") + return + } + let file = Env.arkadeExitRestore(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(backup.exitState.utf8).write(to: file, options: .atomic) + try? FileManager.default.removeItem(at: Env.arkadeStartedMarker(walletIndex: walletIndex)) + if isConnected { + info = nil + isReady = false + try await bridge.reload() + await start(walletIndex: walletIndex) + } + } + + /// Lists a deposit the app just broadcast as pending right away, then boards it once it confirms. + func onDepositBroadcast(txid: String, amountSats: UInt64) { + broadcastDeposits[txid] = amountSats + applyBroadcastDeposits() + onStateChanged?() + Task { await refreshState() } + } + + private func applyBroadcastDeposits() { + let known = Set(deposits.map(\.txid)) + let local = broadcastDeposits + .filter { !known.contains($0.key) } + .map { ArkadeDeposit(txid: $0.key, amountSats: $0.value, isClaimed: false, confirmations: 0) } + guard !local.isEmpty else { return } + deposits = local + deposits + pendingDepositSats = deposits.filter { !$0.isClaimed }.reduce(0) { $0 + $1.amountSats } + } + + /// Reads the Arkade balance and deposits again, for pull to refresh and the deposit screen. + func refreshState() async { + guard isConnected else { return } + do { + try await refresh() + } catch { + Logger.warn("Failed to refresh Arkade state: \(error)", context: "ArkadeManager") + } + } + + private func refresh() async throws { + let balance = try await bridge.balance() + let history = try await bridge.history() + let vtxos = try await bridge.vtxos() + let pending = UInt64(max(0, balance.boardingConfirmed + balance.boardingUnconfirmed)) + let available = UInt64(max(0, balance.available)) + let changed = !isReady || balanceSats != available || pendingDepositSats != pending + isReady = true + balanceSats = available + pendingDepositSats = pending + recoverableFundsSats = UInt64(max(0, balance.recoverable)) + deposits = Self.deposits(history) + withdrawals = Self.withdrawals(history) + nextExpiryAt = Self.nextExpiry(vtxos) + for deposit in deposits where deposit.isClaimed { + broadcastDeposits[deposit.txid] = nil + } + applyBroadcastDeposits() + deposits = await Self.withConfirmations(deposits) + if changed { + onStateChanged?() + } + for txId in deposits.map(\.txid) + withdrawals.map(\.txid) { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) + } + Self.exitStateChanged.send() + } + + /// On-chain deposits: boarding entries of the wallet's history, boarded once settled. + nonisolated static func deposits(_ history: [ArkadeHistoryEntry]) -> [ArkadeDeposit] { + let deposits = history.compactMap { entry -> ArkadeDeposit? in + guard let txid = entry.boardingTxid, entry.type == ArkadeHistoryEntry.received else { return nil } + return ArkadeDeposit(txid: txid, amountSats: UInt64(max(0, entry.amount)), isClaimed: entry.settled) + } + return deposits.filter { !$0.isClaimed } + deposits.filter(\.isClaimed) + } + + /// Transfers to Savings: sends settled in a batch's commitment transaction without an Arkade transaction. + nonisolated static func withdrawals(_ history: [ArkadeHistoryEntry]) -> [ArkadeWithdrawal] { + history.compactMap { entry in + guard entry.type == ArkadeHistoryEntry.sent, let txid = entry.commitmentTxid, entry.arkTxid == nil else { return nil } + return ArkadeWithdrawal(txid: txid, amountSats: UInt64(max(0, entry.amount)), feeSats: 0) + } + } + + /// Unix seconds of the soonest expiry of an unswept output; the bridge may report it in milliseconds. + nonisolated static func nextExpiry(_ vtxos: [ArkadeVtxo]) -> UInt64? { + guard let expiry = vtxos.filter({ $0.isSwept != true }).compactMap(\.batchExpiry).min(), expiry > 0 else { return nil } + return UInt64(expiry > 1_000_000_000_000 ? expiry / 1000 : expiry) + } + + private static func withConfirmations(_ deposits: [ArkadeDeposit]) async -> [ArkadeDeposit] { + guard deposits.contains(where: { !$0.isClaimed }) else { return deposits } + let confirmations = await onchainConfirmations() + return deposits.map { deposit in + guard !deposit.isClaimed else { return deposit } + var pending = deposit + pending.confirmations = confirmations[deposit.txid] ?? nil + return pending + } + } + + /// Confirmations of the on-chain transactions this wallet sent or received, 0 while unconfirmed. + private static func onchainConfirmations() async -> [String: UInt32?] { + let tip = LightningService.shared.status?.currentBestBlock.height + let payments = await LightningService.shared.listPayments() ?? [] + var result: [String: UInt32?] = [:] + for payment in payments { + guard case let .onchain(txid, status) = payment.kind else { continue } + switch status { + case .unconfirmed: + result[txid] = 0 + case let .confirmed(_, height, _): + result[txid] = tip.map { $0 >= height ? $0 - height + 1 : 0 } + } + } + return result + } + + // MARK: Swaps and history + + private nonisolated func listSwaps() async -> [ArkadeSwap] { + do { + return try await bridgeSwaps() + } catch { + Logger.warn("Failed to list Arkade swaps: \(error)", context: "ArkadeManager") + return [] + } + } + + private func bridgeSwaps() async throws -> [ArkadeSwap] { + guard isConnected else { return [] } + return try await bridge.swaps() + } + + private func syncActivities() async { + for swap in await listSwaps() { + await recordSwap(swap) + } + } + + private func onEvent(_ event: ArkadeBridgeEvent) async { + do { + switch event { + case let .swap(swap): + Logger.info("Arkade swap '\(swap.id ?? "")' is '\(swap.outcome ?? "")'", context: "ArkadeManager") + await recordSwap(swap) + try await refresh() + if let event = Self.nodeEvent(for: swap) { + LightningService.shared.emitChannelFreeRailEvent(event) + } + case let .history(entries): + try await onHistory(entries) + } + } catch { + Logger.error("Failed to handle Arkade event: \(error)", context: "ArkadeManager") + } + } + + /// A payment that reached the wallet outside this client's swaps: an offline receive the LNURL server claimed, or a + /// payment straight to the Arkade address. Boarding and swap claims show up through their own records. + private func onHistory(_ entries: [ArkadeHistoryEntry]) async throws { + try await refresh() + let swapClaims = await Set(listSwaps().filter(\.isReceive).compactMap(\.takeSat)) + for entry in Self.offlineReceives(entries, swapClaims: swapClaims) { + guard let arkTxid = entry.arkTxid else { continue } + let id = Self.arkReceiveIdPrefix + arkTxid + if try await CoreService.shared.activity.getActivity(id: id) != nil { + continue + } + let amountMsat = UInt64(max(0, entry.amount)) * 1000 + let details = PaymentDetails( + id: id, + kind: .bolt11(hash: id, preimage: nil, secret: nil, description: nil, bolt11: nil), + amountMsat: amountMsat, + feePaidMsat: 0, + direction: .inbound, + status: .succeeded, + latestUpdateTimestamp: UInt64(max(0, entry.createdAt / 1000)) + ) + try await CoreService.shared.activity.recordChannelFreeRailPayment(details) + Logger.info("Recorded Arkade receive '\(id)' of '\(entry.amount)' sats", context: "ArkadeManager") + LightningService.shared.emitChannelFreeRailEvent( + .paymentReceived(paymentId: id, paymentHash: id, amountMsat: amountMsat, customRecords: []) + ) + } + } + + /// History entries received outside a swap and outside boarding, which no other record covers. + nonisolated static func offlineReceives(_ entries: [ArkadeHistoryEntry], swapClaims: Set) -> [ArkadeHistoryEntry] { + entries.filter { + $0.type == ArkadeHistoryEntry.received && $0.boardingTxid == nil && $0.arkTxid != nil && !swapClaims.contains($0.amount) + } + } + + private func recordSwap(_ swap: ArkadeSwap) async { + guard let details = Self.paymentDetails(for: swap) else { return } + do { + try await CoreService.shared.activity.recordChannelFreeRailPayment(details) + } catch { + Logger.error("Failed to record Arkade swap '\(swap.id ?? "")': \(error)", context: "ArkadeManager") + } + } + + /// The swap as an LDK payment, for its activity; a receive is recorded only once claimed. + nonisolated static func paymentDetails(for swap: ArkadeSwap) -> PaymentDetails? { + guard let hash = swap.paymentHash, let status = ldkStatus(swap), swap.isSend || status == .succeeded else { return nil } + let timestamp = swap.updatedAt ?? swap.createdAt ?? Int64(Date().timeIntervalSince1970) + return PaymentDetails( + id: hash, + kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: swap.bolt11), + amountMsat: UInt64(max(0, swap.takeSat ?? 0)) * 1000, + feePaidMsat: UInt64(max(0, swap.feeSat ?? 0)) * 1000, + direction: swap.isSend ? .outbound : .inbound, + status: status, + latestUpdateTimestamp: UInt64(max(0, timestamp)) + ) + } + + nonisolated static func railPayment(_ swap: ArkadeSwap) -> ChannelFreeRailPayment? { + guard let hash = swap.paymentHash, swap.isSend || swap.isReceive, let status = ldkStatus(swap) else { return nil } + return ChannelFreeRailPayment(paymentHash: hash.lowercased(), direction: swap.isSend ? .outbound : .inbound, status: status, preimage: nil) + } + + nonisolated static func nodeEvent(for swap: ArkadeSwap) -> Event? { + guard let hash = swap.paymentHash else { return nil } + switch (swap.isReceive, swap.isSend, ldkStatus(swap)) { + case (true, _, .succeeded): + return .paymentReceived( + paymentId: hash, + paymentHash: hash, + amountMsat: UInt64(max(0, swap.takeSat ?? 0)) * 1000, + customRecords: [] + ) + case (_, true, .succeeded): + return .paymentSuccessful( + paymentId: hash, + paymentHash: hash, + paymentPreimage: nil, + feePaidMsat: UInt64(max(0, swap.feeSat ?? 0)) * 1000 + ) + case (_, true, .failed): + return .paymentFailed(paymentId: hash, paymentHash: hash, reason: .unexpectedError) + default: + return nil + } + } + + /// A receive succeeds once claimed and a send once paid; a send that was refunded, cancelled or failed failed. + private nonisolated static func ldkStatus(_ swap: ArkadeSwap) -> PaymentStatus? { + switch swap.outcome { + case ArkadeSwap.claimed, ArkadeSwap.paid: .succeeded + case let outcome? where ArkadeSwap.failedSendOutcomes.contains(outcome): .failed + case .some: .pending + case nil: nil + } + } +} + +struct ArkadeConfig { + let network: String + let arkServerUrl: String + let delegateUrl: String? + let lnurlUrl: String? +} + +/// An on-chain deposit into Arkade: pending until it confirms and the wallet boards it into a batch. +struct ArkadeDeposit: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let isClaimed: Bool + /// On-chain confirmations, known for deposits this wallet sent. + var confirmations: UInt32? + + var id: String { + "\(txid)-\(isClaimed)" + } +} + +/// A Transfer to Savings out of Arkade, offboarded in a batch. +struct ArkadeWithdrawal: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let feeSats: UInt64 + + var id: String { + txid + } +} + +enum ArkadeError: LocalizedError { + case unsupported + case notConnected + case bridge(String) + + var errorDescription: String? { + switch self { + case .unsupported: "Arkade is not available on this network" + case .notConnected: "Arkade wallet is not connected" + case let .bridge(message): message + } + } +} diff --git a/Bitkit/Models/BackupCategory.swift b/Bitkit/Models/BackupCategory.swift index 1ea1acb40..49775a1d0 100644 --- a/Bitkit/Models/BackupCategory.swift +++ b/Bitkit/Models/BackupCategory.swift @@ -9,6 +9,7 @@ enum BackupCategory: String, CaseIterable { case settings = "SETTINGS" case widgets = "WIDGETS" case metadata = "METADATA" + case arkadeExit = "ARKADE_EXIT" } // MARK: - UI Extensions @@ -32,6 +33,8 @@ extension BackupCategory { return "stack" case .metadata: return "tag" + case .arkadeExit: + return "bolt-hollow" } } @@ -53,6 +56,8 @@ extension BackupCategory { return t("settings__backup__category_widgets") case .metadata: return t("settings__backup__category_tags") + case .arkadeExit: + return t("settings__backup__category_arkade_exit") } } } diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index b763bc3ad..6c6635b55 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -214,3 +214,27 @@ struct WidgetsBackupV1 { return try JSONSerialization.data(withJSONObject: dict) } } + +/// Everything the Arkade wallet keeps locally, as the bridge's `exportState` returns it, kept so a wallet restored on +/// another device has its outputs, swaps and exit data before it scans the operator again. +struct ArkadeExitBackupV1: Codable { + var version = 1 + let createdAt: UInt64 + let network: String + let exitState: String + + init(version: Int = 1, createdAt: UInt64, network: String, exitState: String) { + self.version = version + self.createdAt = createdAt + self.network = network + self.exitState = exitState + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + createdAt = try container.decode(UInt64.self, forKey: .createdAt) + network = try container.decode(String.self, forKey: .network) + exitState = try container.decode(String.self, forKey: .exitState) + } +} diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index fc06ea674..2263b5f8b 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -170,11 +170,13 @@ "fee__custom__description" = "يعتمد على الرسوم"; "fee__custom__shortDescription" = "يعتمد على الرسوم"; "fee__custom__range" = "يعتمد على الرسوم"; +"lightning__funding__text" = "أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة."; "lightning__funding__title" = "موّل رصيد إنفاقك"; "lightning__funding__button1" = "تحويل من المدخرات"; "lightning__funding__button2" = "استخدام محفظة أخرى"; "lightning__funding__button3" = "إعداد يدوي"; "lightning__funding_advanced__button_short" = "إعداد متقدم"; +"lightning__spending_amount__title" = "تحويل\nإلى الإنفاق"; "lightning__transfer__nav_title" = "تحويل الأموال"; "lightning__transfer__confirm" = "يرجى\nالتأكيد"; "lightning__transfer__swipe" = "اسحب للتحويل"; @@ -258,6 +260,21 @@ "lightning__transaction" = "المعاملة"; "lightning__order_fee" = "رسوم الطلب"; "lightning__balance" = "الرصيد"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "رسوم الإدراج"; +"lightning__arkade_confirmations" = "{count}/1 تأكيد"; +"lightning__arkade_deposit" = "إيداع"; +"lightning__arkade_deposit__boarded" = "أضافه Arkade إلى رصيد الإنفاق لديك."; +"lightning__arkade_deposit__boarding" = "تم التأكيد. سيضيفه Arkade إلى رصيد الإنفاق لديك في دفعته التالية، عادةً خلال دقيقة."; +"lightning__arkade_deposit__looking" = "بانتظار التأكيد الأول، نحو 10 دقائق."; +"lightning__arkade_deposits_claimed" = "إيداعات تم إدراجها"; +"lightning__arkade_deposits_pending" = "إيداعات قيد التنفيذ"; +"lightning__arkade_expiry" = "التجديد التالي"; +"lightning__arkade_lightning_address" = "عنوان Lightning"; +"lightning__arkade_provider" = "المزوّد"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "بلا حد"; +"lightning__arkade_trust" = "يُحفظ هذا الرصيد على Arkade الذي تديره Ark Labs، ولا تستطيع إنفاقه من دونك. تنتهي صلاحية أمواله ما لم تُجدَّد: يجدّدها Bitkit أثناء فتحه، وخدمة التجديد لدى Ark Labs أثناء إغلاقه. الخروج من دون Ark Labs يتطلب خروجًا على السلسلة يستغرق نحو أسبوع."; "lightning__reserve_balance" = "رصيد الاحتياطي (غير قابل للإنفاق)"; "lightning__total_size" = "الحجم الإجمالي للقناة"; "lightning__fees" = "الرسوم"; @@ -683,6 +700,7 @@ "settings__backup__category_transaction_log" = "سجل المعاملات"; "settings__backup__category_wallet" = "التعزيزات والتحويلات"; "settings__backup__category_settings" = "الإعدادات"; +"settings__backup__category_arkade_exit" = "بيانات محفظة Arkade"; "settings__backup__category_widgets" = "الأدوات"; "settings__backup__category_tags" = "العلامات"; "wallet__payment_recipient_rejected" = "رفض المستلم دفعة Lightning هذه. تحقق من الفاتورة وحاول مرة أخرى."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 94ea5367b..c13ce5b1d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__longTitle" = "Personalitzat"; "fee__custom__longRange" = "Depèn de la tarifa"; "lightning__funding__nav_title" = "Saldo de despesa"; +"lightning__funding__text" = "La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera."; "lightning__funding__title" = "Finança el teu saldo de despesa"; "lightning__funding__button1" = "Transferir des d\'estalvis"; "lightning__funding__button2" = "Utilitza un altre moneder"; "lightning__funding__button3" = "Configuració manual"; "lightning__funding_advanced__button_short" = "Configuració avançada"; +"lightning__spending_amount__title" = "Transferir\na despesa"; "lightning__transfer__nav_title" = "Transferir fons"; "lightning__transfer__confirm" = "Si us plau\nconfirma"; "lightning__transfer__swipe" = "Llisca per transferir"; @@ -295,6 +297,21 @@ "lightning__transaction" = "Transacció"; "lightning__order_fee" = "Comissió de comanda"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Comissió d’incorporació"; +"lightning__arkade_confirmations" = "{count}/1 confirmacions"; +"lightning__arkade_deposit" = "Dipòsit"; +"lightning__arkade_deposit__boarded" = "Arkade l’ha afegit al teu saldo de despesa."; +"lightning__arkade_deposit__boarding" = "Confirmat. Arkade l’afegeix al teu saldo de despesa en el seu proper lot, normalment en menys d’un minut."; +"lightning__arkade_deposit__looking" = "Esperant la primera confirmació, uns 10 minuts."; +"lightning__arkade_deposits_claimed" = "Dipòsits incorporats"; +"lightning__arkade_deposits_pending" = "Dipòsits en curs"; +"lightning__arkade_expiry" = "Propera renovació"; +"lightning__arkade_lightning_address" = "Adreça Lightning"; +"lightning__arkade_provider" = "Proveïdor"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Sense límit"; +"lightning__arkade_trust" = "Aquest saldo es guarda a Arkade, gestionat per Ark Labs, que no el pot gastar sense tu. Els seus fons caduquen si no es renoven: Bitkit els renova mentre és obert, i el servei de renovació d’Ark Labs mentre és tancat. Sortir sense Ark Labs requereix una sortida on-chain d’aproximadament una setmana."; "lightning__reserve_balance" = "Saldo de reserva (no gastable)"; "lightning__total_size" = "Mida total del canal"; "lightning__fees" = "Comissions"; @@ -779,6 +796,7 @@ "settings__backup__category_transaction_log" = "Registre de transaccions"; "settings__backup__category_wallet" = "Impulsos i transferències"; "settings__backup__category_settings" = "Configuració"; +"settings__backup__category_arkade_exit" = "Dades de la cartera de Arkade"; "settings__backup__category_widgets" = "Ginys"; "settings__backup__category_tags" = "Etiquetes"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 66328b257..0e5fb286a 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__shortRange" = "Odvíjí se od poplatku"; "fee__custom__longRange" = "Odvíjí se od poplatku"; "lightning__funding__nav_title" = "Dostupný zůstatek"; +"lightning__funding__text" = "Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky."; "lightning__funding__title" = "Navyšte svůj disponibilní zůstatek"; "lightning__funding__button1" = "Převod z úspor"; "lightning__funding__button2" = "Použijte jinou peněženku"; "lightning__funding__button3" = "Manuální nastavení"; "lightning__funding_advanced__button_short" = "Pokročilé nastavení"; +"lightning__spending_amount__title" = "Převést\ndo výdajů"; "lightning__transfer__nav_title" = "Převést prostředky"; "lightning__transfer__confirm" = "Prosím,\npotvrďte"; "lightning__transfer__custom_fee" = "Vlastní poplatek"; @@ -311,6 +313,21 @@ "lightning__transaction" = "Transakce"; "lightning__order_fee" = "Poplatek za objednávku"; "lightning__balance" = "Zůstatek"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Poplatek za převod do Arkade"; +"lightning__arkade_confirmations" = "{count}/1 potvrzení"; +"lightning__arkade_deposit" = "Vklad"; +"lightning__arkade_deposit__boarded" = "Arkade jej přidal do vašeho zůstatku pro útraty."; +"lightning__arkade_deposit__boarding" = "Potvrzeno. Arkade jej přidá do vašeho zůstatku pro útraty v další dávce, obvykle do minuty."; +"lightning__arkade_deposit__looking" = "Čekáme na první potvrzení, asi 10 minut."; +"lightning__arkade_deposits_claimed" = "Převedené vklady"; +"lightning__arkade_deposits_pending" = "Probíhající vklady"; +"lightning__arkade_expiry" = "Další obnovení"; +"lightning__arkade_lightning_address" = "Lightning adresa"; +"lightning__arkade_provider" = "Poskytovatel"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Bez limitu"; +"lightning__arkade_trust" = "Tento zůstatek je uložen na Arkade, který provozuje Ark Labs a který jej bez vás nemůže utratit. Jeho prostředky vyprší, pokud nejsou obnoveny: Bitkit je obnovuje, dokud je otevřený, a obnovovací služba Ark Labs, dokud je zavřený. Odchod bez Ark Labs vyžaduje on-chain výstup trvající asi týden."; "lightning__reserve_balance" = "Rezervní zůstatek"; "lightning__total_size" = "Celková velikost kanálu"; "lightning__fees" = "Poplatky"; @@ -842,6 +859,7 @@ "settings__backup__category_transaction_log" = "Transakční protokol"; "settings__backup__category_wallet" = "Posílení a převody"; "settings__backup__category_settings" = "Nastavení"; +"settings__backup__category_arkade_exit" = "Data peněženky Arkade"; "settings__backup__category_widgets" = "Widgety"; "settings__backup__category_tags" = "Tagy"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 9ad85432e..b8c56f27d 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__shortRange" = "Abhängig von der Gebühr"; "fee__custom__longRange" = "Abhängig von der Gebühr"; "lightning__funding__nav_title" = "Spending Balance"; +"lightning__funding__text" = "Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden."; "lightning__funding__title" = "Fund your spending balance"; "lightning__funding__button1" = "Von Sparkonto übertragen"; "lightning__funding__button2" = "Anderes Wallet verwenden"; "lightning__funding__button3" = "Manuelle Einrichtung"; "lightning__funding_advanced__button_short" = "Fortgeschrittenes Setup"; +"lightning__spending_amount__title" = "zu den Ausgaben"; "lightning__transfer__nav_title" = "Übertrage Guthaben"; "lightning__transfer__confirm" = "Bitte\nbestätige"; "lightning__transfer__custom_fee" = "Benutzerdefiniert fee"; @@ -309,6 +311,21 @@ "lightning__transaction" = "Transaktion"; "lightning__order_fee" = "Auftragsgebühr"; "lightning__balance" = "Guthaben"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Boarding-Gebühr"; +"lightning__arkade_confirmations" = "{count}/1 Bestätigungen"; +"lightning__arkade_deposit" = "Einzahlung"; +"lightning__arkade_deposit__boarded" = "Arkade hat sie deinem Ausgabenkonto gutgeschrieben."; +"lightning__arkade_deposit__boarding" = "Bestätigt. Arkade schreibt sie deinem Ausgabenkonto im nächsten Batch gut, meist innerhalb einer Minute."; +"lightning__arkade_deposit__looking" = "Warte auf die erste Bestätigung, etwa 10 Minuten."; +"lightning__arkade_deposits_claimed" = "Geboardete Einzahlungen"; +"lightning__arkade_deposits_pending" = "Laufende Einzahlungen"; +"lightning__arkade_expiry" = "Nächste Erneuerung"; +"lightning__arkade_lightning_address" = "Lightning-Adresse"; +"lightning__arkade_provider" = "Anbieter"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Kein Limit"; +"lightning__arkade_trust" = "Dieses Guthaben liegt auf Arkade, betrieben von Ark Labs, das es nicht ohne dich ausgeben kann. Das Guthaben verfällt, wenn es nicht erneuert wird: Bitkit erneuert es, solange die App geöffnet ist, und der Erneuerungsdienst von Ark Labs, solange sie geschlossen ist. Ein Ausstieg ohne Ark Labs dauert als On-Chain-Exit etwa eine Woche."; "lightning__reserve_balance" = "Reserveguthaben"; "lightning__total_size" = "Gesamtgröße des Channels"; "lightning__fees" = "Gebühren"; @@ -840,6 +857,7 @@ "settings__backup__category_transaction_log" = "Transaktionshistorie"; "settings__backup__category_wallet" = "Boosts & Übertragungen"; "settings__backup__category_settings" = "Einstellungen"; +"settings__backup__category_arkade_exit" = "Arkade-Wallet-Daten"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index b4ef29785..2dea2a5b9 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -192,11 +192,13 @@ "fee__custom__shortRange" = "Εξαρτάται από το τέλος"; "fee__custom__longRange" = "Εξαρτάται από την προμήθεια"; "lightning__funding__nav_title" = "Υπόλοιπο Δαπανών"; +"lightning__funding__text" = "Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι."; "lightning__funding__title" = "Χρηματοδοτήστε το υπόλοιπο δαπανών σας"; "lightning__funding__button1" = "Μεταφορά από Αποταμιεύσεις"; "lightning__funding__button2" = "Χρήση Άλλου Πορτοφολιού"; "lightning__funding__button3" = "Χειροκίνητη Ρύθμιση"; "lightning__funding_advanced__button_short" = "Ρύθμιση για Προχωρημένους"; +"lightning__spending_amount__title" = "Μεταφορά\nστις δαπάνες"; "lightning__transfer__nav_title" = "Μεταφορά Κεφαλαίων"; "lightning__transfer__confirm" = "Παρακαλώ\nεπιβεβαιώστε"; "lightning__transfer__swipe" = "Σύρετε για Μεταφορά"; @@ -282,6 +284,21 @@ "lightning__transaction" = "Συναλλαγή"; "lightning__order_fee" = "Τέλος παραγγελίας"; "lightning__balance" = "Υπόλοιπο"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Χρέωση ένταξης"; +"lightning__arkade_confirmations" = "{count}/1 επιβεβαιώσεις"; +"lightning__arkade_deposit" = "Κατάθεση"; +"lightning__arkade_deposit__boarded" = "Το Arkade το πρόσθεσε στο υπόλοιπο δαπανών σας."; +"lightning__arkade_deposit__boarding" = "Επιβεβαιώθηκε. Το Arkade το προσθέτει στο υπόλοιπο δαπανών σας στην επόμενη παρτίδα του, συνήθως σε λιγότερο από ένα λεπτό."; +"lightning__arkade_deposit__looking" = "Αναμονή για την πρώτη επιβεβαίωση, περίπου 10 λεπτά."; +"lightning__arkade_deposits_claimed" = "Καταθέσεις που εντάχθηκαν"; +"lightning__arkade_deposits_pending" = "Καταθέσεις σε εξέλιξη"; +"lightning__arkade_expiry" = "Επόμενη ανανέωση"; +"lightning__arkade_lightning_address" = "Διεύθυνση Lightning"; +"lightning__arkade_provider" = "Πάροχος"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Χωρίς όριο"; +"lightning__arkade_trust" = "Αυτό το υπόλοιπο φυλάσσεται στο Arkade, που λειτουργεί η Ark Labs, η οποία δεν μπορεί να το ξοδέψει χωρίς εσάς. Τα κεφάλαιά του λήγουν αν δεν ανανεωθούν: το Bitkit τα ανανεώνει όσο είναι ανοιχτό και η υπηρεσία ανανέωσης της Ark Labs όσο είναι κλειστό. Η αποχώρηση χωρίς την Ark Labs απαιτεί έξοδο on-chain διάρκειας περίπου μίας εβδομάδας."; "lightning__reserve_balance" = "Υπόλοιπο αποθεματικού (μη δαπανήσιμο)"; "lightning__total_size" = "Συνολικό μέγεθος καναλιού"; "lightning__fees" = "Τέλη"; @@ -724,6 +741,7 @@ "settings__backup__category_transaction_log" = "Αρχείο Συναλλαγών"; "settings__backup__category_wallet" = "Ενισχύσεις & Μεταφορές"; "settings__backup__category_settings" = "Ρυθμίσεις"; +"settings__backup__category_arkade_exit" = "Δεδομένα πορτοφολιού Arkade"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Ετικέτες"; "settings__backup__category_profile" = "Προφίλ"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index ceae804f8..6d32f4560 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -189,11 +189,13 @@ "fee__custom__shortDescription" = "Depends on the fee"; "fee__custom__range" = "Depends on the fee"; "lightning__funding__nav_title" = "Spending Balance"; +"lightning__funding__text" = "The easiest option is to use your Bitkit savings or send bitcoin from a different wallet."; "lightning__funding__title" = "Fund your spending balance"; "lightning__funding__button1" = "Transfer from Savings"; "lightning__funding__button2" = "Use Other Wallet"; "lightning__funding__button3" = "Manual Setup"; "lightning__funding_advanced__button_short" = "Advanced Setup"; +"lightning__spending_amount__title" = "Transfer\nto spending"; "lightning__transfer__nav_title" = "Transfer Funds"; "lightning__transfer__confirm" = "Please\nconfirm"; "lightning__transfer__custom_fee" = "Custom fee"; @@ -304,6 +306,21 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Order fee"; "lightning__balance" = "Balance"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Boarding fee"; +"lightning__arkade_confirmations" = "{count}/1 confirmations"; +"lightning__arkade_deposit" = "Deposit"; +"lightning__arkade_deposit__boarded" = "Arkade added it to your spending balance."; +"lightning__arkade_deposit__boarding" = "Confirmed. Arkade adds it to your spending balance in its next batch, usually within a minute."; +"lightning__arkade_deposit__looking" = "Waiting for the first confirmation, about 10 minutes."; +"lightning__arkade_deposits_claimed" = "Boarded deposits"; +"lightning__arkade_deposits_pending" = "Deposits in progress"; +"lightning__arkade_expiry" = "Next renewal"; +"lightning__arkade_lightning_address" = "Lightning address"; +"lightning__arkade_provider" = "Provider"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "No limit"; +"lightning__arkade_trust" = "This balance is held on Arkade, run by Ark Labs, which can't spend it without you. Its funds expire unless renewed: Bitkit renews them while open, and Ark Labs' renewal service while closed. Leaving without Ark Labs takes an on-chain exit of about a week."; "lightning__reserve_balance" = "Reserve balance (not spendable)"; "lightning__total_size" = "Total channel size"; "lightning__fees" = "Fees"; @@ -825,6 +842,7 @@ "settings__backup__category_transaction_log" = "Transaction Log"; "settings__backup__category_wallet" = "Boosts & Transfers"; "settings__backup__category_settings" = "Settings"; +"settings__backup__category_arkade_exit" = "Arkade wallet data"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profile"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 781e97dae..9f398e659 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -197,11 +197,13 @@ "fee__custom__shortRange" = "Depende de la tarifa"; "fee__custom__longRange" = "Depende de la tasa"; "lightning__funding__nav_title" = "Saldo para gastos"; +"lightning__funding__text" = "La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente."; "lightning__funding__title" = "Fondee su saldo de gastos"; "lightning__funding__button1" = "Transferir desde Ahorros"; "lightning__funding__button2" = "Usar Otra Billetera"; "lightning__funding__button3" = "Configuración Manual"; "lightning__funding_advanced__button_short" = "Configuración avanzada"; +"lightning__spending_amount__title" = "Transferir\na gastos"; "lightning__transfer__nav_title" = "Transferir fondos"; "lightning__transfer__confirm" = "Por favor,\nconfirme"; "lightning__transfer__custom_fee" = "Tarifa manual"; @@ -312,6 +314,21 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Comisión de incorporación"; +"lightning__arkade_confirmations" = "{count}/1 confirmaciones"; +"lightning__arkade_deposit" = "Depósito"; +"lightning__arkade_deposit__boarded" = "Arkade lo agregó a tu saldo de gastos."; +"lightning__arkade_deposit__boarding" = "Confirmado. Arkade lo agrega a tu saldo de gastos en su próximo lote, normalmente en menos de un minuto."; +"lightning__arkade_deposit__looking" = "Esperando la primera confirmación, unos 10 minutos."; +"lightning__arkade_deposits_claimed" = "Depósitos incorporados"; +"lightning__arkade_deposits_pending" = "Depósitos en curso"; +"lightning__arkade_expiry" = "Próxima renovación"; +"lightning__arkade_lightning_address" = "Dirección Lightning"; +"lightning__arkade_provider" = "Proveedor"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Sin límite"; +"lightning__arkade_trust" = "Este saldo se guarda en Arkade, operado por Ark Labs, que no puede gastarlo sin ti. Sus fondos vencen si no se renuevan: Bitkit los renueva mientras está abierto, y el servicio de renovación de Ark Labs mientras está cerrado. Salir sin Ark Labs requiere una salida on-chain de aproximadamente una semana."; "lightning__reserve_balance" = "Saldo de reserva (no gastable)"; "lightning__total_size" = "Tamaño total del canal"; "lightning__fees" = "Tarifa"; @@ -843,6 +860,7 @@ "settings__backup__category_transaction_log" = "Registro de transacciones"; "settings__backup__category_wallet" = "Impulsos y transferencias"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_arkade_exit" = "Datos de la billetera de Arkade"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Etiquetas"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index c765eaaf2..a8e5a925d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -194,11 +194,13 @@ "fee__custom__shortRange" = "Depende de la tarifa"; "fee__custom__longRange" = "Depende de la comisión"; "lightning__funding__nav_title" = "Saldo de gastos"; +"lightning__funding__text" = "La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde un monedero diferente."; "lightning__funding__title" = "Rellene su saldo de gastos"; "lightning__funding__button1" = "Transferir desde Ahorros"; "lightning__funding__button2" = "Usar Otro Monedero"; "lightning__funding__button3" = "Configuración Manual"; "lightning__funding_advanced__button_short" = "Configuración avanzada"; +"lightning__spending_amount__title" = "Transferir\na gasto"; "lightning__transfer__nav_title" = "Transferir fondos"; "lightning__transfer__confirm" = "Por favor,\nconfirme"; "lightning__transfer__swipe" = "Deslizar para transferir"; @@ -296,6 +298,21 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Comisión de incorporación"; +"lightning__arkade_confirmations" = "{count}/1 confirmaciones"; +"lightning__arkade_deposit" = "Depósito"; +"lightning__arkade_deposit__boarded" = "Arkade lo agregó a tu saldo de gasto."; +"lightning__arkade_deposit__boarding" = "Confirmado. Arkade lo agrega a tu saldo de gasto en su próximo lote, normalmente en menos de un minuto."; +"lightning__arkade_deposit__looking" = "Esperando la primera confirmación, unos 10 minutos."; +"lightning__arkade_deposits_claimed" = "Depósitos incorporados"; +"lightning__arkade_deposits_pending" = "Depósitos en curso"; +"lightning__arkade_expiry" = "Próxima renovación"; +"lightning__arkade_lightning_address" = "Dirección Lightning"; +"lightning__arkade_provider" = "Proveedor"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Sin límite"; +"lightning__arkade_trust" = "Este saldo se guarda en Arkade, operado por Ark Labs, que no puede gastarlo sin ti. Sus fondos vencen si no se renuevan: Bitkit los renueva mientras está abierto, y el servicio de renovación de Ark Labs mientras está cerrado. Salir sin Ark Labs requiere una salida on-chain de aproximadamente una semana."; "lightning__reserve_balance" = "Saldo de reserva"; "lightning__total_size" = "Tamaño total del canal"; "lightning__fees" = "Comisiones"; @@ -789,6 +806,7 @@ "settings__backup__category_transaction_log" = "Registro de Transacciones"; "settings__backup__category_wallet" = "Impulsos y Transferencias"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_arkade_exit" = "Datos de la cartera de Arkade"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Etiquetas"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 7a1bdafc0..2cc2514da 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -197,11 +197,13 @@ "fee__custom__shortRange" = "Dépend des frais"; "fee__custom__longRange" = "Dépend du montant des frais"; "lightning__funding__nav_title" = "Solde Dépenses"; +"lightning__funding__text" = "Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille."; "lightning__funding__title" = "Financez votre solde Dépenses"; "lightning__funding__button1" = "Transfert depuis l\'épargne"; "lightning__funding__button2" = "Utiliser un autre portefeuille"; "lightning__funding__button3" = "Configuration manuelle"; "lightning__funding_advanced__button_short" = "Configuration avancée"; +"lightning__spending_amount__title" = "Transfert \nDépenses"; "lightning__transfer__nav_title" = "Transfert de fonds"; "lightning__transfer__confirm" = "Veuillez\nconfirmer"; "lightning__transfer__custom_fee" = "Frais sur mesure"; @@ -312,6 +314,21 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Frais"; "lightning__balance" = "Solde"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Frais d’intégration"; +"lightning__arkade_confirmations" = "{count}/1 confirmations"; +"lightning__arkade_deposit" = "Dépôt"; +"lightning__arkade_deposit__boarded" = "Arkade l’a ajouté à votre compte courant."; +"lightning__arkade_deposit__boarding" = "Confirmé. Arkade l’ajoute à votre compte courant lors de son prochain lot, généralement en moins d’une minute."; +"lightning__arkade_deposit__looking" = "En attente de la première confirmation, environ 10 minutes."; +"lightning__arkade_deposits_claimed" = "Dépôts intégrés"; +"lightning__arkade_deposits_pending" = "Dépôts en cours"; +"lightning__arkade_expiry" = "Prochain renouvellement"; +"lightning__arkade_lightning_address" = "Adresse Lightning"; +"lightning__arkade_provider" = "Fournisseur"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Sans limite"; +"lightning__arkade_trust" = "Ce solde est détenu sur Arkade, géré par Ark Labs, qui ne peut pas le dépenser sans vous. Ses fonds expirent s’ils ne sont pas renouvelés : Bitkit les renouvelle tant qu’il est ouvert, et le service de renouvellement d’Ark Labs lorsqu’il est fermé. Partir sans Ark Labs nécessite une sortie on-chain d’environ une semaine."; "lightning__reserve_balance" = "Solde de réserve (non dépensable)"; "lightning__total_size" = "Taille totale du canal"; "lightning__fees" = "Frais"; @@ -843,6 +860,7 @@ "settings__backup__category_transaction_log" = "Journal des transactions"; "settings__backup__category_wallet" = "Boosts et transferts"; "settings__backup__category_settings" = "Réglages"; +"settings__backup__category_arkade_exit" = "Données du portefeuille Arkade"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 0df8ab194..f90c7df7e 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -195,11 +195,13 @@ "fee__custom__shortRange" = "Dipende dalle fee"; "fee__custom__longRange" = "Dipende dalla commissione"; "lightning__funding__nav_title" = "Conto di Spesa"; +"lightning__funding__text" = "Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso."; "lightning__funding__title" = "Manda fondi al tuo conto di spesa"; "lightning__funding__button1" = "Trasferimento da risparmio"; "lightning__funding__button2" = "Usa un altro portafoglio"; "lightning__funding__button3" = "Configurazione manuale"; "lightning__funding_advanced__button_short" = "Configurazione Avanzata"; +"lightning__spending_amount__title" = "Trasferisci\na spesa"; "lightning__transfer__nav_title" = "Trasferimento Fondi"; "lightning__transfer__confirm" = "Per favore\nconferma"; "lightning__transfer__swipe" = "Swipe Per Trasferire"; @@ -305,6 +307,21 @@ "lightning__transaction" = "Transazione"; "lightning__order_fee" = "Fee Ordine"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Commissione di ingresso"; +"lightning__arkade_confirmations" = "{count}/1 conferme"; +"lightning__arkade_deposit" = "Deposito"; +"lightning__arkade_deposit__boarded" = "Arkade l’ha aggiunto al tuo conto di spesa."; +"lightning__arkade_deposit__boarding" = "Confermato. Arkade lo aggiunge al tuo conto di spesa nel prossimo batch, di solito entro un minuto."; +"lightning__arkade_deposit__looking" = "In attesa della prima conferma, circa 10 minuti."; +"lightning__arkade_deposits_claimed" = "Depositi accreditati"; +"lightning__arkade_deposits_pending" = "Depositi in corso"; +"lightning__arkade_expiry" = "Prossimo rinnovo"; +"lightning__arkade_lightning_address" = "Indirizzo Lightning"; +"lightning__arkade_provider" = "Fornitore"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Nessun limite"; +"lightning__arkade_trust" = "Questo saldo è custodito su Arkade, gestito da Ark Labs, che non può spenderlo senza di te. I suoi fondi scadono se non vengono rinnovati: Bitkit li rinnova quando è aperto e il servizio di rinnovo di Ark Labs quando è chiuso. Uscire senza Ark Labs richiede un’uscita on-chain di circa una settimana."; "lightning__reserve_balance" = "Saldo di riserva (non spendibile)"; "lightning__total_size" = "Dimensione totale del canale"; "lightning__fees" = "Commissioni"; @@ -823,6 +840,7 @@ "settings__backup__category_transaction_log" = "Log delle transazioni"; "settings__backup__category_wallet" = "Potenziamenti e Trasferimenti"; "settings__backup__category_settings" = "Impostazioni"; +"settings__backup__category_arkade_exit" = "Dati del wallet Arkade"; "settings__backup__category_widgets" = "Widget"; "settings__backup__category_tags" = "Tag"; "settings__backup__category_profile" = "Profilo"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 99e3880cb..922a0c4da 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__shortRange" = "Afhankelijk van het tarief"; "fee__custom__longRange" = "Afhankelijk van de kosten"; "lightning__funding__nav_title" = "Bestedingssaldo"; +"lightning__funding__text" = "De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet."; "lightning__funding__title" = "Vul uw bestedingssaldo"; "lightning__funding__button1" = "Overboeking van Spaargeld"; "lightning__funding__button2" = "Gebruik Andere Wallet"; "lightning__funding__button3" = "Handmatige configuratie"; "lightning__funding_advanced__button_short" = "Geavanceerde Configuratie"; +"lightning__spending_amount__title" = "Overboeking\nnaar bestedingssaldo"; "lightning__transfer__nav_title" = "Geld Overboeken"; "lightning__transfer__confirm" = "Bevestig\nalstublieft"; "lightning__transfer__custom_fee" = "Benutzerdefiniert fee"; @@ -309,6 +311,21 @@ "lightning__transaction" = "Transactie"; "lightning__order_fee" = "Order kosten"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Boardingkosten"; +"lightning__arkade_confirmations" = "{count}/1 bevestigingen"; +"lightning__arkade_deposit" = "Storting"; +"lightning__arkade_deposit__boarded" = "Arkade heeft het aan je bestedingssaldo toegevoegd."; +"lightning__arkade_deposit__boarding" = "Bevestigd. Arkade voegt het in de volgende batch toe aan je bestedingssaldo, meestal binnen een minuut."; +"lightning__arkade_deposit__looking" = "Wachten op de eerste bevestiging, ongeveer 10 minuten."; +"lightning__arkade_deposits_claimed" = "Geboarde stortingen"; +"lightning__arkade_deposits_pending" = "Lopende stortingen"; +"lightning__arkade_expiry" = "Volgende vernieuwing"; +"lightning__arkade_lightning_address" = "Lightning-adres"; +"lightning__arkade_provider" = "Aanbieder"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Geen limiet"; +"lightning__arkade_trust" = "Dit saldo staat op Arkade, beheerd door Ark Labs, dat het niet zonder jou kan uitgeven. De fondsen verlopen als ze niet worden vernieuwd: Bitkit vernieuwt ze terwijl de app open is en de vernieuwingsdienst van Ark Labs terwijl de app gesloten is. Vertrekken zonder Ark Labs vereist een on-chain exit van ongeveer een week."; "lightning__reserve_balance" = "Reservesaldo (niet besteedbaar)"; "lightning__total_size" = "Grootte van kanaal"; "lightning__fees" = "Vergoedingen"; @@ -840,6 +857,7 @@ "settings__backup__category_transaction_log" = "Transactie Log"; "settings__backup__category_wallet" = "Boosts & Overboekingen"; "settings__backup__category_settings" = "Instellingen"; +"settings__backup__category_arkade_exit" = "Arkade-walletgegevens"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Labels"; "settings__backup__category_profile" = "Profiel"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index b4697826c..236ae9337 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__shortRange" = "Zależy od opłaty"; "fee__custom__longRange" = "Zależy od opłaty transakcyjnej"; "lightning__funding__nav_title" = "Saldo wydatków"; +"lightning__funding__text" = "Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela."; "lightning__funding__title" = "Zasil swoje saldo wydatków"; "lightning__funding__button1" = "Prześlij z Oszczędności"; "lightning__funding__button2" = "Użyj innego portfela"; "lightning__funding__button3" = "Ręczna konfiguracja"; "lightning__funding_advanced__button_short" = "Ustawienia zaawansowane"; +"lightning__spending_amount__title" = "Prześlij \nna saldo wydatków"; "lightning__transfer__nav_title" = "Prześlij środki"; "lightning__transfer__confirm" = "Proszę \npotwierdzić"; "lightning__transfer__custom_fee" = "Niestandardowa opłata transakcyjna"; @@ -311,6 +313,21 @@ "lightning__transaction" = "Transakcja"; "lightning__order_fee" = "Opłata za zamówienie"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Opłata za wprowadzenie"; +"lightning__arkade_confirmations" = "{count}/1 potwierdzeń"; +"lightning__arkade_deposit" = "Wpłata"; +"lightning__arkade_deposit__boarded" = "Arkade dodał je do Twojego salda wydatków."; +"lightning__arkade_deposit__boarding" = "Potwierdzone. Arkade doda je do Twojego salda wydatków w kolejnej partii, zwykle w ciągu minuty."; +"lightning__arkade_deposit__looking" = "Czekamy na pierwsze potwierdzenie, około 10 minut."; +"lightning__arkade_deposits_claimed" = "Wprowadzone wpłaty"; +"lightning__arkade_deposits_pending" = "Wpłaty w toku"; +"lightning__arkade_expiry" = "Następne odnowienie"; +"lightning__arkade_lightning_address" = "Adres Lightning"; +"lightning__arkade_provider" = "Dostawca"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Bez limitu"; +"lightning__arkade_trust" = "To saldo jest przechowywane na Arkade, prowadzonym przez Ark Labs, które nie może go wydać bez Ciebie. Środki wygasają, jeśli nie zostaną odnowione: Bitkit odnawia je, gdy jest otwarty, a usługa odnawiania Ark Labs, gdy jest zamknięty. Wyjście bez Ark Labs wymaga wyjścia on-chain trwającego około tygodnia."; "lightning__reserve_balance" = "Saldo rezerwy (nie do wydania)"; "lightning__total_size" = "Całkowity rozmiar kanału"; "lightning__fees" = "Opłaty"; @@ -842,6 +859,7 @@ "settings__backup__category_transaction_log" = "Log transakcji"; "settings__backup__category_wallet" = "Przyśpieszenia i transfery"; "settings__backup__category_settings" = "Ustawienia"; +"settings__backup__category_arkade_exit" = "Dane portfela Arkade"; "settings__backup__category_widgets" = "Widgety"; "settings__backup__category_tags" = "Tagi"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 2d7c2290e..1a84be927 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__shortRange" = "Depende da taxa"; "fee__custom__longRange" = "Depende da taxa"; "lightning__funding__nav_title" = "Saldo de Gastos"; +"lightning__funding__text" = "A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente."; "lightning__funding__title" = "Transfira para seu saldo de gastos"; "lightning__funding__button1" = "Transferir do Saldo de Poupança"; "lightning__funding__button2" = "Transferir de Outra Carteira"; "lightning__funding__button3" = "Configuração manual"; "lightning__funding_advanced__button_short" = "Configuração Avançada"; +"lightning__spending_amount__title" = "Transferir\npara gastos"; "lightning__transfer__nav_title" = "Transf. fundos"; "lightning__transfer__confirm" = "Por favor\nconfirme"; "lightning__transfer__custom_fee" = "Taxa personalizada"; @@ -311,6 +313,21 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Taxa de embarque"; +"lightning__arkade_confirmations" = "{count}/1 confirmações"; +"lightning__arkade_deposit" = "Depósito"; +"lightning__arkade_deposit__boarded" = "A Arkade adicionou ao seu saldo de gastos."; +"lightning__arkade_deposit__boarding" = "Confirmado. A Arkade adiciona ao seu saldo de gastos no próximo lote, geralmente em até um minuto."; +"lightning__arkade_deposit__looking" = "Aguardando a primeira confirmação, cerca de 10 minutos."; +"lightning__arkade_deposits_claimed" = "Depósitos embarcados"; +"lightning__arkade_deposits_pending" = "Depósitos em andamento"; +"lightning__arkade_expiry" = "Próxima renovação"; +"lightning__arkade_lightning_address" = "Endereço Lightning"; +"lightning__arkade_provider" = "Provedor"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Sem limite"; +"lightning__arkade_trust" = "Este saldo fica na Arkade, operada pela Ark Labs, que não pode gastá-lo sem você. Os fundos expiram se não forem renovados: o Bitkit os renova enquanto está aberto, e o serviço de renovação da Ark Labs enquanto está fechado. Sair sem a Ark Labs exige uma saída on-chain de cerca de uma semana."; "lightning__reserve_balance" = "Saldo de reserva (não utilizável)"; "lightning__total_size" = "Tamanho total do canal"; "lightning__fees" = "Taxas"; @@ -842,6 +859,7 @@ "settings__backup__category_transaction_log" = "Histórico de transações"; "settings__backup__category_wallet" = "Boosts e transferências"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_arkade_exit" = "Dados da carteira do Arkade"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 2a2b4076e..23fcf400a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1,3 +1,5 @@ +"lightning__funding__text" = "A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente."; +"lightning__spending_amount__title" = "Transferir\npara gastos"; "security__cp_text" = "Você pode alterar seu código PIN para uma nova\ncombinação de 4 dígitos. Primeiro, digite seu código PIN atual."; "wallet__onchain_abandon_transfer_note" = "As transferências para o saldo de gastos já não estão disponíveis. A Bitkit verifica primeiro a rede e depois devolve as moedas desta transferência à sua carteira, para que a transferência original nunca seja confirmada. Isto tem uma taxa de rede."; "wallet__payment_recipient_rejected" = "O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente."; @@ -309,6 +311,21 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Taxa de embarque"; +"lightning__arkade_confirmations" = "{count}/1 confirmações"; +"lightning__arkade_deposit" = "Depósito"; +"lightning__arkade_deposit__boarded" = "A Arkade adicionou-o ao seu saldo de gastos."; +"lightning__arkade_deposit__boarding" = "Confirmado. A Arkade adiciona-o ao seu saldo de gastos no próximo lote, normalmente em menos de um minuto."; +"lightning__arkade_deposit__looking" = "A aguardar a primeira confirmação, cerca de 10 minutos."; +"lightning__arkade_deposits_claimed" = "Depósitos embarcados"; +"lightning__arkade_deposits_pending" = "Depósitos em curso"; +"lightning__arkade_expiry" = "Próxima renovação"; +"lightning__arkade_lightning_address" = "Endereço Lightning"; +"lightning__arkade_provider" = "Fornecedor"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Sem limite"; +"lightning__arkade_trust" = "Este saldo é mantido na Arkade, operada pela Ark Labs, que não o pode gastar sem si. Os fundos expiram se não forem renovados: o Bitkit renova-os enquanto está aberto, e o serviço de renovação da Ark Labs enquanto está fechado. Sair sem a Ark Labs exige uma saída on-chain de cerca de uma semana."; "lightning__reserve_balance" = "Saldo de reserva (não utilizável)"; "lightning__total_size" = "Tamanho total do canal"; "lightning__fees" = "Taxas"; @@ -730,6 +747,7 @@ "settings__backup__category_transaction_log" = "Histórico de transações"; "settings__backup__category_wallet" = "Boosts e transferências"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_arkade_exit" = "Dados da carteira do Arkade"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__support__title" = "Suporte"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 02790e985..10b68ff35 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -196,11 +196,13 @@ "fee__custom__shortRange" = "Зависит от комиссии"; "fee__custom__longRange" = "Зависит от комиссии"; "lightning__funding__nav_title" = "Баланс Расходов"; +"lightning__funding__text" = "Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька."; "lightning__funding__title" = "Пополните свой Баланс Расходов"; "lightning__funding__button1" = "Перевести из Сбережений"; "lightning__funding__button2" = "Использовать Другой Кошелёк"; "lightning__funding__button3" = "Ручная настройка"; "lightning__funding_advanced__button_short" = "Расширенная Настройка"; +"lightning__spending_amount__title" = "Перевод\nна Расходы"; "lightning__transfer__nav_title" = "Перевод Средств"; "lightning__transfer__confirm" = "Пожалуйста,\nПодтвердите"; "lightning__transfer__swipe" = "Проведите для Перевода"; @@ -306,6 +308,21 @@ "lightning__transaction" = "Транзакция"; "lightning__order_fee" = "Комиссия Ордера"; "lightning__balance" = "Баланс"; +"lightning__arkade" = "Arkade"; +"lightning__arkade_claim_fee" = "Комиссия за зачисление"; +"lightning__arkade_confirmations" = "{count}/1 подтверждений"; +"lightning__arkade_deposit" = "Депозит"; +"lightning__arkade_deposit__boarded" = "Arkade добавил их на ваш баланс расходов."; +"lightning__arkade_deposit__boarding" = "Подтверждено. Arkade добавит их на ваш баланс расходов в следующей партии, обычно в течение минуты."; +"lightning__arkade_deposit__looking" = "Ждём первого подтверждения, около 10 минут."; +"lightning__arkade_deposits_claimed" = "Зачисленные депозиты"; +"lightning__arkade_deposits_pending" = "Депозиты в обработке"; +"lightning__arkade_expiry" = "Следующее продление"; +"lightning__arkade_lightning_address" = "Lightning-адрес"; +"lightning__arkade_provider" = "Провайдер"; +"lightning__arkade_provider_value" = "Ark Labs"; +"lightning__arkade_receiving_unlimited" = "Без ограничений"; +"lightning__arkade_trust" = "Этот баланс хранится в Arkade, которым управляет Ark Labs, и без вас потратить его не может. Средства истекают, если их не продлевать: Bitkit продлевает их, пока приложение открыто, а сервис продления Ark Labs — пока закрыто. Выход без Ark Labs требует ончейн-вывода примерно в неделю."; "lightning__reserve_balance" = "Резервный Баланс (не доступен для трат)"; "lightning__total_size" = "Общий Размер Канала"; "lightning__fees" = "Комиссии"; @@ -837,6 +854,7 @@ "settings__backup__category_transaction_log" = "Журнал Транзакций"; "settings__backup__category_wallet" = "Ускорения и Переводы"; "settings__backup__category_settings" = "Настройки"; +"settings__backup__category_arkade_exit" = "Данные кошелька Arkade"; "settings__backup__category_widgets" = "Виджеты"; "settings__backup__category_tags" = "Теги"; "settings__backup__category_profile" = "Профиль"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index dd24da660..bab822d3d 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -301,6 +301,10 @@ class BackupService { self.startDataStoreListeners(settings: publishers.0, widgets: publishers.1, appState: publishers.2) self.startPeriodicBackupFailureCheck() } + // Arkade's connect signal is missed when it connected before observing started. + if await ArkadeManager.shared.isConnected, !shouldSkipBackup() { + markBackupRequired(category: .arkadeExit) + } } } @@ -374,6 +378,11 @@ class BackupService { } else { try await persistWalletBackup() } + } else if category == .arkadeExit { + // Nothing to back up until Arkade connects, which marks this category required again. + if let data = try await arkadeExitBackupData() { + try await uploadBackup(category.rawValue, data) + } } else { let data = try await getBackupDataBytes(category: category) try Task.checkCancellation() @@ -698,6 +707,11 @@ class BackupService { Logger.debug("Restored \(payload.orders.count) orders, \(payload.cjitEntries.count) CJITs", context: "BackupService") } + try await performRestore(category: .arkadeExit) { dataBytes in + let backup = try JSONDecoder().decode(ArkadeExitBackupV1.self, from: dataBytes) + try await ArkadeManager.shared.restoreExitBackup(backup, walletIndex: LightningService.shared.currentWalletIndex) + } + // Always reset PIN settings after restore (PIN is never backed up for security) await SettingsViewModel.shared.resetPinSettings() @@ -766,6 +780,13 @@ class BackupService { } .store(in: &cancellables) + ArkadeManager.exitStateChangedPublisher + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .arkadeExit) + } + .store(in: &cancellables) + // Record requirements synchronously; the scheduled upload already has a debounce. WalletBackupChangeTracker.localChanges .merge(with: PaykitSdkService.walletBackupDataChangedPublisher) @@ -1248,7 +1269,25 @@ class BackupService { code: -1, userInfo: [NSLocalizedDescriptionKey: "LIGHTNING_CONNECTIONS backup is managed by ldk-node"] ) + + case .arkadeExit: + guard let data = try await arkadeExitBackupData() else { + throw NSError( + domain: "BackupService", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "ARKADE_EXIT has nothing to back up while Arkade is not connected"] + ) + } + return data + } + } + + private func arkadeExitBackupData() async throws -> Data? { + if let backupData { + return try await backupData(.arkadeExit) } + guard let backup = try await ArkadeManager.shared.exitBackup() else { return nil } + return try JSONEncoder().encode(backup) } /// Re-upload the envelopes whose Core-owned fields the restore had to migrate, so later restores diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 4a2dbe334..d410d0ad2 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -955,6 +955,14 @@ class ActivityService { } } + /// Records a Lightning payment the channel-free rail sent or received the way an LDK payment is recorded, so it keeps + /// its Paykit contact and seen state across syncs. + func recordChannelFreeRailPayment(_ payment: PaymentDetails) async throws { + try await ServiceQueue.background(.core) { + try await self.processLightningPayment(payment) + } + } + private func processLightningPayment(_ payment: PaymentDetails) async throws { guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } @@ -1459,6 +1467,24 @@ class ActivityService { } } + /// Marks the on-chain activity for `txId` as a transfer between Savings and Spending that has no channel, + /// such as an Arkade deposit or withdrawal. No-op until the activity exists or once it is marked. + func markOnchainActivityAsTransfer(txId: String) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId), + !existing.isTransfer + else { return } + var updated = existing + updated.isTransfer = true + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + } + } catch { + Logger.error("Failed to mark activity as transfer for \(txId): \(error)", context: "ActivityService") + } + } + /// Resolve the on-chain activity for `txId`, preferring the row that most likely represents the /// transfer: one already flagged as a transfer, then a hardware-wallet send (the hardware /// funding path), then whatever else matches. diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f24601b98..d9d46ea73 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -5,6 +5,25 @@ import LDKNode // TODO: catch all errors and pass a readable error message to the UI +/// A Lightning rail that needs no channels (Arkade). The main app sets it; the notification +/// extension leaves it unset, so only LDK runs there. +protocol ChannelFreeLightningRail: AnyObject { + @MainActor var isReady: Bool { get } + @MainActor var balanceSats: UInt64 { get } + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String + func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 + func lightningPayments() async -> [ChannelFreeRailPayment] +} + +/// A Lightning payment the channel-free rail sent or received, for checks that would otherwise read only LDK's payments. +struct ChannelFreeRailPayment: Equatable { + let paymentHash: String + let direction: PaymentDirection + let status: PaymentStatus + let preimage: String? +} + class LightningService { private static let watchOnlyAccountHighestPreRevealedAddressIndex: UInt32 = 999 @@ -37,6 +56,9 @@ class LightningService { @MainActor private var cachedChannels: [ChannelDetails]? private var storedEventCallback: ((Event) -> Void)? + /// Rail events that arrived before the node's event callback was set, delivered once it is. + private var pendingChannelFreeRailEvents: [Event] = [] + weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? @@ -254,7 +276,7 @@ class LightningService { } if let onEvent { - storedEventCallback = onEvent + setEventCallback(onEvent) } listenForEvents(onEvent: storedEventCallback) @@ -312,6 +334,7 @@ class LightningService { if clearEventCallback { storedEventCallback = nil + pendingChannelFreeRailEvents = [] } await MainActor.run { @@ -717,6 +740,10 @@ class LightningService { } func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { + if let rail = channelFreeRail, await shouldReceiveWithRail(rail, amountSats: amountMsats.map { $0 / 1000 }) { + let amountSats = amountMsats.map { $0 / 1000 }.flatMap { $0 > 0 ? $0 : nil } + return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -743,12 +770,40 @@ class LightningService { return bolt11.description } + /// Receives on the channel-free rail when no ready channel has the inbound capacity for the amount. + @MainActor + private func shouldReceiveWithRail(_ rail: ChannelFreeLightningRail, amountSats: UInt64?) -> Bool { + guard rail.isReady else { return false } + let readyChannels = (channels ?? []).filter(\.isChannelReady) + guard !readyChannels.isEmpty else { return true } + let inboundSats = readyChannels.map(\.inboundCapacityMsat).reduce(0, +) / 1000 + guard let amountSats, amountSats > 0 else { return inboundSats == 0 } + return amountSats > inboundSats + } + + /// Pays on the channel-free rail when no channel has the outbound capacity and the rail's balance covers the amount. + @MainActor + private func shouldPayWithRail(_ rail: ChannelFreeLightningRail, bolt11: String, sats: UInt64?) -> Bool { + guard rail.isReady else { return false } + let invoiceSats = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } + guard let amountSats = sats ?? invoiceSats else { return false } + return !canSendWithChannels(amountSats: amountSats) && rail.balanceSats >= amountSats + } + /// Checks if we have the correct outbound capacity to send the amount /// - Parameter amountSats: Amount to send in satoshis /// - Returns: True if we can send the amount /// Note: Uses cached channels for fast, non-blocking checks @MainActor func canSend(amountSats: UInt64) -> Bool { + if let rail = channelFreeRail, rail.isReady, rail.balanceSats >= amountSats, !canSendWithChannels(amountSats: amountSats) { + return true + } + return canSendWithChannels(amountSats: amountSats) + } + + @MainActor + private func canSendWithChannels(amountSats: UInt64) -> Bool { guard let channels else { return false } @@ -828,6 +883,20 @@ class LightningService { params: RouteParametersConfig? = nil, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { + if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + // Arkade takes no route parameters, so a caller's routing fee cap is checked against its estimate instead. + if let maxFeeMsat = params?.maxTotalRoutingFeeMsat { + let feeSats = try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: sats) + guard feeSats * 1000 <= maxFeeMsat else { + throw AppError( + message: "Arkade routing fee exceeds the limit", + debugMessage: "Fee \(feeSats) sats, limit \(maxFeeMsat / 1000) sats" + ) + } + } + try beforeSubmission() + return try await rail.send(bolt11: bolt11, sats: sats) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -1348,6 +1417,28 @@ extension LightningService { // MARK: Events extension LightningService { + /// Delivers a channel-free rail's payment as an LDK event, so flows that wait on node events cover both rails. + /// Sets the callback node and rail events are delivered to, and delivers the rail events that arrived before it. + func setEventCallback(_ onEvent: @escaping (Event) -> Void) { + storedEventCallback = onEvent + let pending = pendingChannelFreeRailEvents + pendingChannelFreeRailEvents = [] + pending.forEach(onEvent) + } + + /// Arkade runs before the node starts, so its events wait until the node's event callback is set rather than being lost. + func emitChannelFreeRailEvent(_ event: Event) { + guard let storedEventCallback else { + pendingChannelFreeRailEvents.append(event) + return + } + storedEventCallback(event) + } + + func channelFreeRailPayments() async -> [ChannelFreeRailPayment] { + await channelFreeRail?.lightningPayments() ?? [] + } + func listenForEvents(onEvent: ((Event) -> Void)? = nil) { Task { while true { @@ -1717,6 +1808,9 @@ extension LightningService { } func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil) async throws -> UInt64 { + if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: amountSats) { + return try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 3f28e8976..6f5bd596b 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -98,7 +98,8 @@ final class PaykitAllowanceManager { guard LightningService.shared.status?.isRunning == true, let channels = LightningService.shared.channels else { return false } - return channels.isEmpty || channels.contains(where: \.isUsable) + // Arkade pays without channels, so a channel still reconnecting does not hold it back. + return channels.isEmpty || channels.contains(where: \.isUsable) || LightningService.shared.channelFreeRail?.isReady == true } var entries: [PaykitAllowanceEntry] { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index f3483f49e..38dd82ca7 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -195,11 +195,15 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payments = await LightningService.shared.listPayments() else { return .unavailable } + guard let payments = await LightningService.shared.listPayments() else { + let railStatus = await Self.railStatus(paymentHash: paymentHash) + guard case .unknown = railStatus else { return railStatus } + return .unavailable + } guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { - return .unknown + return await Self.railStatus(paymentHash: paymentHash) } switch payment.status { @@ -212,6 +216,20 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { return .succeeded(preimage: preimage) } } + + /// Arkade payments are not in LDK's list, so a proof whose completion event was missed settles from Arkade's own record. + static func railStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + guard let payment = await LightningService.shared.channelFreeRailPayments().first(where: { + $0.paymentHash.caseInsensitiveCompare(paymentHash) == .orderedSame && $0.direction == .outbound + }) else { + return .unknown + } + switch payment.status { + case .pending: return .pending + case .failed: return .failed + case .succeeded: return .succeeded(preimage: payment.preimage) + } + } } private actor PaykitProofMutationLock { @@ -847,11 +865,11 @@ actor PaykitPaymentProofService { } let proofs = try await loadProofs() guard let proof = proofs.first(where: { - PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && - $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && - $0.onchainAddress == address && $0.onchainAmountSats == amountSats && - $0.proofData == nil && $0.onchainAcceptanceVerified != true - }), let raw = proof.hardwareSignedTransaction, + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && + $0.onchainAddress == address && $0.onchainAmountSats == amountSats && + $0.proofData == nil && $0.onchainAcceptanceVerified != true + }), let raw = proof.hardwareSignedTransaction, try SignedTransactionId.fromHex(raw) == proof.paymentIdentifier, let fee = proof.hardwareMiningFeeSats, let rate = proof.hardwareFeeRate, let spent = proof.hardwareTotalSpent diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index bfff5a91d..23907ccb1 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -679,7 +679,7 @@ struct PaykitPaymentRequestService { UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) }, canReceiveLightning: @escaping @MainActor @Sendable () -> Bool = { - LightningService.shared.channels?.contains(where: \.isUsable) ?? false + LightningService.shared.channels?.contains(where: \.isUsable) ?? false || ArkadeManager.shared.isReady }, marketRate: @escaping @MainActor @Sendable () -> PaykitExchangeRate? = { CurrencyService.shared.loadCachedRates()?.first { $0.quote == "USD" }.map { PaykitExchangeRate( diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 0beb64c7e..3fccc9563 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -16,6 +16,10 @@ extension PrivatePaykitService { } let bolt11 = try await createVariableInvoice(wallet) + // Arkade answers an amountless receive with its Lightning address, which no private invoice can stand for. + guard !PublicPaykitService.isLnurl(bolt11) else { + throw PrivatePaykitError.routeHintsUnavailable + } guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { throw PublicPaykitError.invalidPayload } @@ -78,7 +82,7 @@ extension PrivatePaykitService { @MainActor func walletHasUsableChannels(_ wallet: WalletViewModel) -> Bool { - wallet.hasUsableChannels + wallet.canReceiveLightning } @MainActor @@ -127,33 +131,35 @@ extension PrivatePaykitService { } func receivedSettledPaymentHashes() async -> Set { - guard let payments = await LightningService.shared.listPayments() else { return [] } - - return Set( - payments.compactMap { payment in - guard payment.direction == .inbound, - payment.status == .succeeded, - case .bolt11 = payment.kind - else { return nil } - - return payment.id - } - ) + let payments = await LightningService.shared.listPayments() ?? [] + let ldkHashes = payments.compactMap { payment -> String? in + guard payment.direction == .inbound, + payment.status == .succeeded, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + let railHashes = await LightningService.shared.channelFreeRailPayments() + .filter { $0.direction == .inbound && $0.status == .succeeded } + .map(\.paymentHash) + return Set(ldkHashes + railHashes) } func attemptedOutboundBolt11PaymentHashes() async -> Set { - guard let payments = await LightningService.shared.listPayments() else { return [] } - - return Set( - payments.compactMap { payment in - guard payment.direction == .outbound, - payment.status != .failed, - case .bolt11 = payment.kind - else { return nil } - - return payment.id - } - ) + let payments = await LightningService.shared.listPayments() ?? [] + let ldkHashes = payments.compactMap { payment -> String? in + guard payment.direction == .outbound, + payment.status != .failed, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + let railHashes = await LightningService.shared.channelFreeRailPayments() + .filter { $0.direction == .outbound && $0.status != .failed } + .map(\.paymentHash) + return Set(ldkHashes + railHashes) } func localInvoice(for publicKey: String) -> StoredInvoice? { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 030f791e9..c2a9d129c 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -439,11 +439,16 @@ enum PublicPaykitService { else { return } let publishedCurrentInvoice = !isLightningPaymentOptionEnabled() || (wallet.publicPaykitInvoiceRevision == invoiceRevision && wallet.publicPaykitBolt11 == - (endpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "")) + (endpoints.first { $0.methodId == .bitcoinLightningBolt11 || $0.methodId == .bitcoinLightningLnurl }?.value ?? "")) let publishedUsdt = !isUsdtPaymentOptionEnabled() || endpoints.contains { $0.methodId == .usdtArbitrum } setCleanupPending(!publishedCurrentInvoice || !publishedUsdt) } + /// Whether a receive's Lightning request is an LNURL, which Arkade gives for an amountless receive. + static func isLnurl(_ paymentRequest: String) -> Bool { + paymentRequest.lowercased().hasPrefix("lnurl") + } + static func removePublishedEndpoints() async throws { try await applyPublishedEndpoints([]) } @@ -619,7 +624,7 @@ enum PublicPaykitService { if includeLightning, !bolt11.isEmpty { try endpoints.append( Endpoint( - methodId: .bitcoinLightningBolt11, + methodId: isLnurl(bolt11) ? .bitcoinLightningLnurl : .bitcoinLightningBolt11, value: bolt11, min: nil, max: nil, diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index 3e57c941a..c44e1a210 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -70,10 +70,11 @@ struct AppStatusHelper { return .error } - if !hasChannels { - return .error - } else if wallet.hasUsableChannels { + // Arkade carries Lightning without channels, so an Arkade wallet's connection is ready. + if wallet.hasUsableChannels || ArkadeManager.shared.isReady { return .ready + } else if !hasChannels { + return .error } else { return .pending } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index f1552bb33..8247418e4 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -69,7 +69,11 @@ final class QuickPayPaymentCoordinator { try await String(LightningService.shared.send(bolt11: bolt11)) } self.listRows = listRows ?? { - await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) + let railRows = await LightningService.shared.channelFreeRailPayments().map(QuickPayReconcileRow.init(railPayment:)) + guard let ldkRows = await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) else { + return railRows.isEmpty ? nil : railRows + } + return ldkRows + railRows } } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index fceaf350f..154874010 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -144,6 +144,17 @@ struct QuickPayReconcileRow { } } + init(railPayment: ChannelFreeRailPayment) { + paymentId = railPayment.paymentHash + invoicePaymentHash = railPayment.paymentHash + isOutboundBolt11 = railPayment.direction == .outbound + status = switch railPayment.status { + case .succeeded: .succeeded + case .failed: .failed + case .pending: .pending + } + } + init(paymentId: String, invoicePaymentHash: String, isOutboundBolt11: Bool, status: Status) { self.paymentId = paymentId self.invoicePaymentHash = invoicePaymentHash diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 65dfd0cf3..04cbc5acd 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -106,6 +106,8 @@ struct ScanPaymentState { let totalLightningBalanceSats: UInt64 let hasChannels: Bool let hasUsableChannels: Bool + /// Arkade sends Lightning without channels. + var hasChannelFreeRail = false } struct ScanPaymentOperations { @@ -121,9 +123,11 @@ struct ScanPaymentOperations { return ScanPaymentState( isNodeRunning: lightningService.status?.isRunning == true, spendableOnchainBalanceSats: lightningService.balances?.spendableOnchainBalanceSats ?? 0, - totalLightningBalanceSats: lightningService.balances?.totalLightningBalanceSats ?? 0, + totalLightningBalanceSats: (lightningService.balances?.totalLightningBalanceSats ?? 0) + + (lightningService.channelFreeRail?.balanceSats ?? 0), hasChannels: channels?.isEmpty == false, - hasUsableChannels: channels?.contains(where: \.isUsable) == true + hasUsableChannels: channels?.contains(where: \.isUsable) == true, + hasChannelFreeRail: lightningService.channelFreeRail?.isReady == true ) }, canSendLightning: lightningService.canSend @@ -1063,13 +1067,13 @@ extension AppViewModel { let paymentAmount = requestedAmount ?? invoice.amountSatoshis // If user has no channels at all, they can never pay a pure lightning invoice. // Show insufficient spending toast and do not navigate to the send flow. - if !paymentState.hasChannels { + if !paymentState.hasChannels, !paymentState.hasChannelFreeRail { showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return } - // If channels are usable, validate capacity immediately - if paymentState.hasUsableChannels { + // If channels are usable or Arkade can pay, validate capacity immediately + if paymentState.hasUsableChannels || paymentState.hasChannelFreeRail { guard scanPaymentOperations.canSendLightning(paymentAmount) else { showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return @@ -1243,8 +1247,9 @@ extension AppViewModel { return } + // Arkade receives without inbound liquidity, so only a channel-only wallet can lack the capacity. let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - if lightningBalance < max(1, data.minWithdrawableSat) { + if lightningService.channelFreeRail?.isReady != true, lightningBalance < max(1, data.minWithdrawableSat) { toast( type: .warning, title: t("other__lnurl_withdr_error"), diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 0c5d5e457..6df3b0648 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -44,6 +44,9 @@ enum Route: Hashable { case fundManualAmount(lnPeer: LnPeer) case fundManualConfirm(lnPeer: LnPeer, amountSats: UInt64) case fundManualSuccess + case arkadeFundingAmount + case arkadeFundingConfirm(amountSats: UInt64, isMaxAmount: Bool) + case arkadeFundingDeposit(txid: String) case lnurlChannel(channelData: LnurlChannelData) case savingsIntro case savingsAvailability @@ -93,6 +96,7 @@ enum Route: Hashable { case addressTypePreference case connections case connectionDetail(channelId: String) + case arkadeConnectionDetail case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6bf25a561..1915be5aa 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -149,13 +149,13 @@ class TransferViewModel: ObservableObject { onEvent(eventId) { event in if case let .channelPending( - eventChannelId, - eventUserChannelId, - _, - _, - fundingTxo - ) = event, - // Match by user channel ID + eventChannelId, + eventUserChannelId, + _, + _, + fundingTxo + ) = event, + // Match by user channel ID eventUserChannelId.description == userChannelId { Task { @@ -245,10 +245,56 @@ class TransferViewModel: ObservableObject { savingsSwapState.quote != nil ? .swap : .close } + /// Whether Transfer to Savings closes channels, rather than only withdrawing Arkade's balance. + var hasChannelsToClose: Bool { + !channelsToClose.isEmpty + } + func closeSelectedChannels() async throws -> [ChannelDetails] { + try await withdrawArkadeToSavings() return try await closeChannels(channels: channelsToClose) } + private func withdrawArkadeToSavings() async throws { + let arkade = ArkadeManager.shared + guard arkade.balanceSats > 0 else { return } + let address = try await lightningService.newAddress() + try await arkade.withdrawAll(to: address) + } + + // MARK: - Arkade Funding + + /// Sends `amountSats` of savings to the Arkade boarding address and returns its txid. Once it confirms, the Arkade + /// wallet boards it into the operator's next batch. + func fundArkade(address: String, amountSats: UInt64, satsPerVbyte: UInt32, txFee: UInt64, isMaxAmount: Bool) async throws -> String { + let walletId = OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex) + let attempts = OnchainSendAttemptService.shared + let result = try await attempts.send( + using: lightningService, + address: address, + amountSats: amountSats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: nil, + isMaxAmount: isMaxAmount, + followupContext: OnchainSendFollowupContext( + feeSats: txFee, + feeRate: satsPerVbyte, + tags: [], + contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + ) + guard case let .accepted(txid) = result else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Arkade funding outcome: \(result)") + } + _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + ArkadeManager.shared.onDepositBroadcast(txid: txid, amountSats: amountSats) + Logger.info("Funded Arkade wallet with \(amountSats) sats in \(txid)", context: "TransferViewModel") + await onBalanceRefresh?() + return txid + } + func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] { var failedChannels: [ChannelDetails] = [] var successfulChannels: [ChannelDetails] = [] @@ -528,6 +574,8 @@ class TransferViewModel: ObservableObject { } do { + // The confirm screen counts Arkade's balance in the transfer, and the swap moves only the channel's. + try await withdrawArkadeToSavings() let claimAddress = try await lightningService.newAddress() let swap = try await boltzService.createReverseSwap(amountSat: amount, claimAddress: claimAddress) Logger.info("Created savings transfer swap \(swap.id)", context: "TransferViewModel") diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1d30a3e4c..99a7daa1d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -152,6 +152,12 @@ class WalletViewModel: ObservableObject { } lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } + ArkadeManager.shared.onStateChanged = { [weak self] in + Task { @MainActor in + await self?.updateBalanceState() + try? await self?.refreshBip21(reportsError: false) + } + } } /// Convenience initializer for previews and testing @@ -198,6 +204,8 @@ class WalletViewModel: ObservableObject { } syncState() + // Arkade needs no node, so it starts alongside it and keeps running while the node stops. + Task { await ArkadeManager.shared.start(walletIndex: walletIndex) } do { let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl let rgsServerUrl = rgsConfigService.getCurrentServerUrl() @@ -1036,6 +1044,9 @@ class WalletViewModel: ObservableObject { } syncState() + Task { [weak self] in + await self?.finishWatchSendIfRailAlreadySettled(hash: hash, eventId: eventId) + } } } onCancel: { Task { @MainActor [weak self] in @@ -1044,6 +1055,22 @@ class WalletViewModel: ObservableObject { } } + /// An Arkade payment can settle before `watchSend` starts listening, so its completion event can fire unseen; the + /// rail's own payment list still has the outcome. + private func finishWatchSendIfRailAlreadySettled(hash: String, eventId: String) async { + let payment = await lightningService.channelFreeRailPayments().first { + $0.direction == .outbound && $0.paymentHash.caseInsensitiveCompare(hash) == .orderedSame + } + switch payment?.status { + case .succeeded: + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: 0))) + case .failed: + finishWatchSend(id: eventId, result: .failure(AppError(paymentFailureReason: nil))) + case .pending, nil: + break + } + } + private func finishWatchSend(id: String, result: Result) { guard let continuation = watchSendContinuations.removeValue(forKey: id) else { return } removeOnEvent(id: id) @@ -1238,14 +1265,16 @@ class WalletViewModel: ObservableObject { do { try? await transferService.syncTransferStates() let state = try await balanceManager.deriveBalanceState() + let arkade = ArkadeManager.shared balanceInTransferToSavings = Int(state.balanceInTransferToSavings) - balanceInTransferToSpending = Int(state.balanceInTransferToSpending) + balanceInTransferToSpending = Int(state.balanceInTransferToSpending + arkade.pendingDepositSats) // Update display values with adjusted balances totalOnchainSats = Int(state.totalOnchainSats) - totalLightningSats = Int(state.totalLightningSats) - totalBalanceSats = Int(state.totalBalanceSats) - maxSendLightningSats = Int(state.maxSendLightningSats) + totalLightningSats = Int(state.totalLightningSats + arkade.balanceSats) + totalBalanceSats = Int(state.totalBalanceSats + arkade.balanceSats + arkade.pendingDepositSats) + // A payment goes over one rail, so the larger of the two balances caps it. + maxSendLightningSats = Int(max(state.maxSendLightningSats, arkade.balanceSats)) // Update channel fundable balance (excludes Legacy UTXOs and accounts for funding tx fee) let (selectedType, monitoredTypes) = LightningService.addressTypeStateFromUserDefaults() @@ -1301,13 +1330,18 @@ class WalletViewModel: ObservableObject { return channels?.contains(where: \.isUsable) ?? false } + /// Whether Lightning can be received, through a usable channel or Arkade; Paykit publishes a Lightning endpoint only then. + var canReceiveLightning: Bool { + hasUsableChannels || ArkadeManager.shared.isReady + } + var canCreateReceiveLightningInvoice: Bool { let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil return canCreateReceiveLightningInvoice(amountSats: amountSats) } func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { - ReceiveLiquidityDecision.canCreateLightningInvoice( + ArkadeManager.shared.isReady || ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels: hasReadyChannels, inboundCapacitySats: totalReadyInboundLightningSats, invoiceAmountSats: amountSats @@ -1363,7 +1397,7 @@ class WalletViewModel: ObservableObject { if attempt > 0 { guard try await canCommitInvoice() else { continue } } - if includeLightning, hasUsableChannels { + if includeLightning, canReceiveLightning { let hasReusableInvoice = await hasReusablePublicPaykitInvoice() guard try await canCommitInvoice() else { continue } let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice @@ -1429,7 +1463,7 @@ class WalletViewModel: ObservableObject { if sharesPublicPaykitEndpoints { do { - try await publishPublicPaykitEndpoints(refreshIfNeeded: hasUsableChannels, isSessionCurrent: isSessionCurrent) + try await publishPublicPaykitEndpoints(refreshIfNeeded: canReceiveLightning, isSessionCurrent: isSessionCurrent) } catch { Logger.warn("Failed to refresh public Paykit endpoints after channel availability changed: \(error)", context: "WalletViewModel") } @@ -1467,6 +1501,15 @@ class WalletViewModel: ObservableObject { private func refreshPublicPaykitBolt11(canCommitInvoice: () async throws -> Bool) async throws -> Bool { let invoice = try await createInvoice(amountSats: nil, note: "") + // Arkade has no amountless invoice: it answers with its Lightning address, which also takes payments offline. + if PublicPaykitService.isLnurl(invoice) { + guard try await canCommitInvoice() else { return false } + PublicPaykitService.setCleanupPending(true) + publicPaykitBolt11 = invoice + publicPaykitBolt11PaymentHash = "" + publicPaykitBolt11ExpiresAt = 0 + return true + } let decodedInvoice = try await decode(invoice: invoice) guard try await canCommitInvoice() else { return false } guard case let .lightning(lightningInvoice) = decodedInvoice else { @@ -1662,14 +1705,17 @@ class WalletViewModel: ObservableObject { await (try? coreService.activity.getPreActivityMetadata(searchKey: paymentId, searchByAddress: searchByAddress))?.tags } + /// Arkade answers an amountless receive with its Lightning address's LNURL, which has no payment hash. func receiveInvoicePaymentHash(invoice: String) async -> String? { - guard !invoice.isEmpty, case let .lightning(decoded) = try? await decode(invoice: invoice) else { return nil } + guard !invoice.isEmpty, !PublicPaykitService.isLnurl(invoice), + case let .lightning(decoded) = try? await decode(invoice: invoice) else { return nil } return decoded.paymentHash.hex } /// Payment hash from the current bolt11 invoice, if available private func paymentHash() async -> String? { - guard !bolt11.isEmpty, case let .lightning(lightningInvoice) = try? await decode(invoice: bolt11) else { return nil } + guard !bolt11.isEmpty, !PublicPaykitService.isLnurl(bolt11), + case let .lightning(lightningInvoice) = try? await decode(invoice: bolt11) else { return nil } return lightningInvoice.paymentHash.hex } @@ -1830,6 +1876,7 @@ class WalletViewModel: ObservableObject { } try await lightningService.wipeStorage(walletIndex: 0) + await ArkadeManager.shared.wipe(walletIndex: 0) probeOutcomes.removeAll() diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 24a34f99f..5d56e8cdb 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -558,7 +558,7 @@ struct PaymentRequestDetailsView: View { } private var enabledEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) } private var selectedEnabledEndpoints: [String] { diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 58e3374ed..434ff9946 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -82,8 +82,7 @@ struct AdvancedSettingsView: View { NavigationLink(value: Route.connections) { SettingsRow( title: t("settings__adv__lightning_connections"), - iconName: "bolt-hollow", - rightText: String(wallet.channels?.count ?? 0) + iconName: "bolt-hollow" ) } .accessibilityIdentifier("Channels") diff --git a/Bitkit/Views/Settings/Advanced/ArkadeConnectionViews.swift b/Bitkit/Views/Settings/Advanced/ArkadeConnectionViews.swift new file mode 100644 index 000000000..e7e940f73 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/ArkadeConnectionViews.swift @@ -0,0 +1,282 @@ +import SwiftUI + +/// The Arkade wallet as a connection: shown once it holds a balance or a deposit, and always on regtest so testers +/// reach its deposit address to fund it from the mutinynet faucet. +struct ArkadeConnectionRow: View { + @EnvironmentObject var navigation: NavigationViewModel + + private let arkade = ArkadeManager.shared + + static var isVisible: Bool { + let arkade = ArkadeManager.shared + return arkade.isReady && (arkade.balanceSats > 0 || !arkade.deposits.isEmpty || Env.network == .regtest) + } + + var body: some View { + Button { + navigation.navigate(.arkadeConnectionDetail) + } label: { + VStack(spacing: 0) { + HStack { + SubtitleText(t("lightning__arkade_provider_value")) + Spacer() + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + } + .padding(.bottom, 6) + + LightningChannel( + capacity: arkade.balanceSats, + localBalance: arkade.balanceSats, + remoteBalance: 0, + status: .open, + showReceiving: false + ) + .padding(.bottom, 16) + + ForEach(arkade.deposits.filter { !$0.isClaimed }) { deposit in + ArkadeDepositRow(deposit: deposit) + .padding(.bottom, 16) + } + + CustomDivider() + } + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("ArkadeConnection") + } +} + +/// A deposit into Arkade or a withdrawal out of it, with its amount, confirmations while pending, and a link to +/// its transaction. +struct ArkadeDepositRow: View { + @Environment(\.openURL) private var openURL + + let title: String + let txid: String + let amountSats: UInt64 + var confirmations: UInt32? + + init(deposit: ArkadeDeposit) { + title = t("lightning__arkade_deposit") + txid = deposit.txid + amountSats = deposit.amountSats + confirmations = deposit.confirmations + } + + init(withdrawal: ArkadeWithdrawal) { + title = t("wallet__activity_transfer_to_savings") + txid = withdrawal.txid + amountSats = withdrawal.amountSats + } + + var body: some View { + Button { + if let url = URL(string: "\(Env.blockExplorerUrl)/tx/\(txid)") { + openURL(url) + } + } label: { + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + CaptionBText(title, textColor: .textPrimary) + CaptionBText(txid) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + MoneyText(sats: Int(amountSats), size: .captionB, symbol: true) + if let confirmations { + CaptionBText( + t("lightning__arkade_confirmations", variables: ["count": "\(min(confirmations, 1))"]), + textColor: .purpleAccent + ) + } + } + } + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("ArkadeDeposit") + } +} + +struct ArkadeConnectionDetailView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + @State private var depositAddress: String? + + private let arkade = ArkadeManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__arkade")) + .padding(.bottom, 16) + + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + LightningChannel( + capacity: arkade.balanceSats, + localBalance: arkade.balanceSats, + remoteBalance: 0, + status: .open, + showReceiving: false + ) + .padding(.bottom, 28) + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { + CustomDivider() + SettingsSectionHeader(t("lightning__arkade_provider")) + detailRow(label: t("lightning__arkade_provider"), value: t("lightning__arkade_provider_value")) + BodySText(t("lightning__arkade_trust")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) + + if let depositAddress { + Button { + copy(depositAddress) + } label: { + detailRow(label: t("wallet__activity_address"), value: depositAddress) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("ArkadeDepositAddress") + } + } + + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__balance")) + amountRow(label: t("lightning__spending_label"), amount: arkade.balanceSats) + + if let lightningAddress = arkade.lightningAddress { + Button { + copy(lightningAddress) + } label: { + detailRow(label: t("lightning__arkade_lightning_address"), value: lightningAddress) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("ArkadeLightningAddress") + } + + if let nextExpiryAt = arkade.nextExpiryAt { + detailRow(label: t("lightning__arkade_expiry"), value: Self.renewalText(expiresAt: nextExpiryAt)) + .accessibilityIdentifier("ArkadeNextRenewal") + } + } + + let pending = arkade.deposits.filter { !$0.isClaimed } + if !pending.isEmpty { + VStack(alignment: .leading, spacing: 16) { + SettingsSectionHeader(t("lightning__arkade_deposits_pending")) + ForEach(pending) { ArkadeDepositRow(deposit: $0) } + } + } + + let claimed = arkade.deposits.filter(\.isClaimed) + if !claimed.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__arkade_deposits_claimed")) + ForEach(claimed) { deposit in + ArkadeDepositRow(deposit: deposit) + .padding(.bottom, 8) + } + } + } + + if !arkade.withdrawals.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__savings_confirm__label")) + ForEach(arkade.withdrawals) { withdrawal in + ArkadeDepositRow(withdrawal: withdrawal) + .padding(.bottom, 8) + amountRow(label: t("wallet__activity_fee"), amount: withdrawal.feeSats) + } + } + } + } + .padding(.bottom, 32) + + Spacer(minLength: 0) + + CustomButton(title: t("lightning__transfer_to_savings_button")) { + if app.hasSeenTransferToSavingsIntro { + navigation.navigate(.savingsAvailability) + } else { + navigation.navigate(.savingsIntro) + } + } + .disabled(arkade.balanceSats == 0) + .accessibilityIdentifier("ArkadeTransferToSavings") + } + .frame(minHeight: geometry.size.height) + .bottomSafeAreaPadding() + } + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .refreshable { + await arkade.refreshState() + } + .task { + await loadDepositAddress() + } + } + + /// On regtest, shows Arkade's boarding address so testers can fund Arkade from the mutinynet faucet, since regtest + /// Savings can't pay a mutinynet address. + private func loadDepositAddress() async { + guard Env.network == .regtest else { return } + do { + depositAddress = try await arkade.depositAddress() + } catch { + Logger.warn("Failed to load Arkade deposit address: \(error)", context: "ArkadeConnectionDetailView") + } + } + + /// The wallet renews outputs this long before their batch expires. + private static let renewalLeadSeconds: UInt64 = 3 * 24 * 60 * 60 + + private static func renewalText(expiresAt: UInt64) -> String { + let renewAt = Date(timeIntervalSince1970: TimeInterval(expiresAt - min(expiresAt, renewalLeadSeconds))) + return renewAt.formatted(date: .abbreviated, time: .shortened) + } + + private func copy(_ text: String) { + UIPasteboard.general.string = text + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: text) + } + + private func detailRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 16) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + CaptionBText(value, textColor: .textPrimary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(height: 50) + CustomDivider() + } + } + + private func amountRow(label: String, amount: UInt64) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(amount), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 1f80a4b63..a24934d84 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -54,7 +54,11 @@ struct LightningConnectionsView: View { .resizable() .frame(width: 22, height: 22) .foregroundColor(.white) - TitleText(formatNumber(receivingCapacity), textColor: .white) + // Arkade receives without inbound liquidity, so no channel caps receiving. + TitleText( + ArkadeManager.shared.isReady ? t("lightning__arkade_receiving_unlimited") : formatNumber(receivingCapacity), + textColor: .white + ) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -67,6 +71,12 @@ struct LightningConnectionsView: View { EmptyStateView() .padding(.top, 16) } else { + if ArkadeConnectionRow.isVisible { + SettingsSectionHeader(t("lightning__arkade")) + ArkadeConnectionRow() + .padding(.bottom, 16) + } + // Pending Connections section if !pendingConnections.isEmpty { SettingsSectionHeader(t("lightning__conn_pending")) @@ -229,8 +239,9 @@ struct LightningConnectionsView: View { // MARK: - Computed Properties private var spendingBalance: UInt64 { - guard let balanceDetails = wallet.balanceDetails else { return 0 } - return balanceDetails.totalLightningBalanceSats + let arkadeSats = ArkadeManager.shared.balanceSats + guard let balanceDetails = wallet.balanceDetails else { return arkadeSats } + return balanceDetails.totalLightningBalanceSats + arkadeSats } private var receivingCapacity: UInt64 { @@ -243,7 +254,7 @@ struct LightningConnectionsView: View { } private var shouldShowEmptyState: Bool { - openChannels.isEmpty && pendingConnections.isEmpty && closedChannels.isEmpty + !ArkadeConnectionRow.isVisible && openChannels.isEmpty && pendingConnections.isEmpty && closedChannels.isEmpty } private var buttonText: String { @@ -290,6 +301,10 @@ struct LightningConnectionsView: View { } } + group.addTask { + await ArkadeManager.shared.refreshState() + } + // Sync wallet channels group.addTask { do { diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift index 31b84c1a2..6ddbb60ad 100644 --- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift +++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift @@ -40,8 +40,13 @@ private struct StatusItemView: View { struct DataBackupsScreen: View { @StateObject private var viewModel = BackupViewModel() + /// Arkade's wallet data shows only where Arkade runs. + private var categories: [BackupCategory] { + BackupCategory.allCases.filter { $0 != .arkadeExit || ArkadeManager.shared.isSupported } + } + private var allSynced: Bool { - BackupCategory.allCases.allSatisfy { category in + categories.allSatisfy { category in let status = viewModel.getStatus(for: category) return !status.running && !status.isRequired } @@ -65,7 +70,7 @@ struct DataBackupsScreen: View { } .padding(.top, 16) - ForEach(BackupCategory.allCases, id: \.self) { category in + ForEach(categories, id: \.self) { category in let status = viewModel.getStatus(for: category) let statusText = viewModel.formatStatusText(for: category) let iconColor = viewModel.iconColor(for: status) diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index edb0a4220..08718c2d1 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -120,7 +120,7 @@ struct CreateSubscriptionView: View { } private var availableEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) .filter { settings.isUsdtEnabled || $0 != PublicPaykitService.MethodId.usdtArbitrum.rawValue } } diff --git a/Bitkit/Views/Transfer/ArkadeFundingViews.swift b/Bitkit/Views/Transfer/ArkadeFundingViews.swift new file mode 100644 index 000000000..46cd07d96 --- /dev/null +++ b/Bitkit/Views/Transfer/ArkadeFundingViews.swift @@ -0,0 +1,309 @@ +import SwiftUI + +/// Transfer to Spending through Arkade: savings go to the wallet's Arkade boarding address. Once it confirms, the Arkade +/// wallet boards it into the operator's next batch by itself, with no fee. +struct ArkadeFundingAmountView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var amountViewModel = AmountInputViewModel() + @State private var fastMaxSats: UInt64? + + private var amountSats: UInt64 { + amountViewModel.amountSats + } + + /// The balance less a fast-rate fee for sending all of it to the deposit address, the rate the transfer pays. + private var maxSats: UInt64 { + fastMaxSats ?? UInt64(max(0, wallet.spendableOnchainBalanceSats)) + } + + private var isValidAmount: Bool { + amountSats > 0 && amountSats <= maxSats + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spending_amount__title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + + NumberPadTextField( + viewModel: amountViewModel, + showConversion: false, + testIdentifier: "SpendingAmountNumberField" + ) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.vertical, 32) + + Spacer() + + HStack(alignment: .bottom) { + AvailableAmount( + label: t("wallet__send_available"), + amount: Int(maxSats), + testIdentifier: "SpendingAmountAvailable" + ) + .onTapGesture { + amountViewModel.updateFromSats(maxSats, currency: currency) + } + + Spacer() + + numberPadButtons + } + .padding(.bottom, 12) + + Divider() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton(title: t("common__continue"), isDisabled: !isValidAmount) { + navigation.navigate(.arkadeFundingConfirm(amountSats: amountSats, isMaxAmount: amountSats >= maxSats)) + } + .accessibilityIdentifier("SpendingAmountContinue") + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingAmount") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task(id: wallet.spendableOnchainBalanceSats) { await loadFastMax() } + .onChange(of: maxSats, initial: true) { + amountViewModel.maxAmountOverride = maxSats > 0 ? maxSats : nil + } + .onChange(of: amountViewModel.maxExceededCount) { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxSats)] + ), + visibilityTime: Toast.visibilityTimeShort + ) + } + } + + private func loadFastMax() async { + do { + let address = try await ArkadeManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: false) else { return } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + fastMaxSats = try await wallet.calculateMaxSendableAmount(address: address, satsPerVByte: rate) + } catch { + Logger.warn("Failed to estimate the most Arkade funding can send: \(error)", context: "ArkadeFundingAmountView") + } + } + + private var numberPadButtons: some View { + HStack(spacing: 8) { + NumberPadActionButton( + text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency, + imageName: "arrow-up-down" + ) { + withAnimation { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + } + + NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + amountViewModel.updateFromSats(maxSats / 4, currency: currency) + } + .accessibilityIdentifier("SpendingAmountQuarter") + + NumberPadActionButton(text: t("common__max")) { + amountViewModel.updateFromSats(maxSats, currency: currency) + } + .accessibilityIdentifier("SpendingAmountMax") + } + } +} + +struct ArkadeFundingConfirmView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel + + let amountSats: UInt64 + let isMaxAmount: Bool + + @State private var address: String? + @State private var feeRate: UInt32 = 0 + @State private var networkFeeSats: UInt64 = 0 + @State private var claimFeeSats: UInt64 = 0 + + /// What leaves savings for the deposit: a max transfer sends the balance less the network fee. + private var depositSats: UInt64 { + isMaxAmount ? amountSats - min(amountSats, networkFeeSats) : amountSats + } + + private var isPriced: Bool { + address != nil && networkFeeSats > 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) + + VStack(spacing: 16) { + HStack { + FeeDisplayRow(label: t("lightning__spending_confirm__network_fee"), amount: networkFeeSats) + .frame(maxWidth: .infinity) + FeeDisplayRow(label: t("lightning__arkade_claim_fee"), amount: claimFeeSats) + .frame(maxWidth: .infinity) + } + HStack { + FeeDisplayRow(label: t("lightning__spending_confirm__amount"), amount: depositSats - min(depositSats, claimFeeSats)) + .frame(maxWidth: .infinity) + FeeDisplayRow(label: t("lightning__spending_confirm__total"), amount: depositSats + networkFeeSats) + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 16) + + Spacer() + + SwipeButton(title: t("lightning__transfer__swipe"), accentColor: .purpleAccent, isDisabled: !isPriced) { + guard let address else { return } + do { + let txid = try await transfer.fundArkade( + address: address, + amountSats: isMaxAmount ? amountSats : depositSats, + satsPerVbyte: feeRate, + txFee: networkFeeSats, + isMaxAmount: isMaxAmount + ) + await wallet.updateBalanceState() + navigation.navigate(.arkadeFundingDeposit(txid: txid)) + } catch { + Logger.error("Failed to fund Arkade: \(error)", context: "ArkadeFundingConfirmView") + app.toast(error) + throw error + } + } + } + .padding(.horizontal, 16) + .navigationBarHidden(true) + .task { await price() } + } + + private func price() async { + do { + let address = try await ArkadeManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: true) else { return } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + networkFeeSats = if isMaxAmount { + try await wallet.estimateSendAllFee(address: address, satsPerVByte: rate) + } else { + try await wallet.calculateTotalFee(address: address, amountSats: amountSats, satsPerVByte: rate) + } + claimFeeSats = ArkadeManager.shared.boardingFeeSats() + feeRate = rate + self.address = address + } catch { + Logger.error("Failed to price Arkade funding: \(error)", context: "ArkadeFundingConfirmView") + app.toast(error) + } + } +} + +struct ArkadeFundingDepositView: View { + @EnvironmentObject var navigation: NavigationViewModel + + let txid: String + + private let arkade = ArkadeManager.shared + /// How often to check the deposit until it is boarded; boarding needs 1 confirmation, then a batch. + private static let pollInterval: Duration = .seconds(15) + + private var stage: ArkadeDepositStage { + ArkadeDepositStage(deposit: arkade.deposits.first { $0.txid.caseInsensitiveCompare(txid) == .orderedSame }) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title"), showBackButton: false) + .padding(.bottom, 16) + + DisplayText(t("lightning__savings_progress__title"), accentColor: .purpleAccent) + .padding(.bottom, 8) + + BodyMText(t(stage.textKey)) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Image("hourglass") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + + CustomButton(title: t("common__continue"), variant: .secondary) { + navigation.reset() + } + .accessibilityIdentifier("ArkadeDepositContinue") + .padding(.bottom, 16) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("ArkadeDeposit") + .padding(.horizontal, 16) + .navigationBarHidden(true) + .task { await watchDeposit() } + } + + private func watchDeposit() async { + while !Task.isCancelled, stage != .boarded { + await arkade.refreshState() + try? await Task.sleep(for: Self.pollInterval) + } + } +} + +/// Where a deposit into Arkade stands: broadcast, then confirmed and waiting for the next batch, then boarded. +enum ArkadeDepositStage: Equatable { + /// Broadcast and waiting for its first confirmation. + case looking + /// Confirmed; the wallet boards it in the operator's next batch. + case boarding + /// In the Arkade balance. + case boarded + + init(deposit: ArkadeDeposit?) { + if deposit?.isClaimed == true { + self = .boarded + } else if (deposit?.confirmations ?? 0) > 0 { + self = .boarding + } else { + self = .looking + } + } + + var textKey: String { + switch self { + case .looking: "lightning__arkade_deposit__looking" + case .boarding: "lightning__arkade_deposit__boarding" + case .boarded: "lightning__arkade_deposit__boarded" + } + } +} diff --git a/Bitkit/Views/Transfer/FundingOptions.swift b/Bitkit/Views/Transfer/FundingOptions.swift index 2cf7144c6..60bd659fc 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -2,6 +2,12 @@ import SwiftUI struct FundingOptions: View { @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel + + /// Arkade keeps Spending fundable from savings or another wallet without a channel. + private var canFundWithArkade: Bool { + ArkadeManager.shared.isReady + } var body: some View { VStack(spacing: 0) { @@ -14,11 +20,21 @@ struct FundingOptions: View { ) .padding(.bottom, 8) - BodyMText(t("lightning__transfer_unavailable__text")) + BodyMText(t(canFundWithArkade ? "lightning__funding__text" : "lightning__transfer_unavailable__text")) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 32) VStack(spacing: 8) { + if canFundWithArkade { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), testID: "FundTransfer") { + navigation.navigate(.arkadeFundingAmount) + } + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), testID: "FundReceive") { + navigation.reset() + sheets.showSheet(.receive) + } + } + RectangleButton( icon: "pencil", title: t("lightning__funding__button3"), @@ -27,14 +43,18 @@ struct FundingOptions: View { navigation.navigate(.fundManual(nodeUri: nil)) } - RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} + if !canFundWithArkade { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} + } RectangleButton( icon: "device-tablet-speaker", title: t("lightning__funding__button_hw"), isDisabled: true, testID: "FundHardware" ) {} - RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + if !canFundWithArkade { + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + } } Spacer() diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 8fcf963e2..b691b8133 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -39,7 +39,7 @@ struct SavingsConfirmView: View { } private var headlineSats: UInt64 { - swapState.quote?.amountSat ?? totalSats + (swapState.quote?.amountSat ?? totalSats) + ArkadeManager.shared.balanceSats } var body: some View { diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index b13b8e17c..264994a6b 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -188,7 +188,9 @@ struct SavingsProgressView: View { /// Legacy path: cooperatively close the selected channel(s), retrying on failure. private func runChannelClose() async { do { - try await Task.sleep(nanoseconds: 2_000_000_000) + if transfer.hasChannelsToClose { + try await Task.sleep(nanoseconds: 2_000_000_000) + } let channelsFailedToCoopClose = try await transfer.closeSelectedChannels() diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 61433e842..67a204af2 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -231,7 +231,11 @@ struct ReceiveEdit: View { } private func additionalLiquidityAction() -> ReceiveAdditionalLiquidityAction { - ReceiveLiquidityDecision.additionalLiquidityAction( + // Arkade receives any amount without inbound liquidity, so no channel is needed. + if ArkadeManager.shared.isReady { + return .none + } + return ReceiveLiquidityDecision.additionalLiquidityAction( source: liquiditySource, invoiceAmountSats: amountViewModel.amountSats, inboundCapacitySats: wallet.totalReadyInboundLightningSats diff --git a/Bitkit/Views/Wallets/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index d757856c3..51bcfe35d 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -122,7 +122,11 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if ArkadeManager.shared.isReady { + navigation.navigate(.arkadeFundingAmount) + } else { + sheets.showSheet(.transferUnavailable) + } } .accessibilityIdentifier("TransferToSpending") } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 07e14e413..bd182f2ef 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -17,7 +17,8 @@ struct LnurlPayAmount: View { var maxAmount: UInt64 { // TODO: subtract fee guard let data = app.lnurlPayData else { return 0 } - return min(data.maxSendableSat, UInt64(wallet.totalLightningSats)) + // One payment goes over one rail, so the larger rail balance caps it. + return min(data.maxSendableSat, UInt64(wallet.maxSendLightningSats)) } var amount: UInt64 { @@ -46,9 +47,9 @@ struct LnurlPayAmount: View { Spacer() HStack(alignment: .bottom) { - AvailableAmount(label: t("wallet__send_available_spending"), amount: wallet.totalLightningSats) + AvailableAmount(label: t("wallet__send_available_spending"), amount: wallet.maxSendLightningSats) .onTapGesture { - amountViewModel.updateFromSats(UInt64(wallet.totalLightningSats), currency: currency) + amountViewModel.updateFromSats(UInt64(wallet.maxSendLightningSats), currency: currency) } Spacer() @@ -98,7 +99,7 @@ struct LnurlPayAmount: View { private func showMaxExceededToast() { guard let data = app.lnurlPayData else { return } // The cap is min(invoice max, spending balance); word the message for whichever bound is hit. - let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.totalLightningSats) + let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.maxSendLightningSats) app.toast( type: .warning, diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index dfe6a4608..ff0e3d967 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -293,7 +293,7 @@ struct SendSheet: View { // there is nothing to \"sync into\". Let validation/UX handle this as // an \"insufficient capacity / no channels\" case instead of a sync wait. let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - guard hasAnyChannels else { return false } + guard hasAnyChannels, !arkadeCanPayScannedInvoice else { return false } // We have channels but none are usable yet → show sync overlay return !wallet.hasUsableChannels @@ -302,6 +302,13 @@ struct SendSheet: View { return false } + /// Arkade pays without channels, so a channel that is still reconnecting is no reason to wait. + private var arkadeCanPayScannedInvoice: Bool { + let arkade = ArkadeManager.shared + let amountSats = wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis ?? 0 + return arkade.isReady && amountSats > 0 && arkade.balanceSats >= amountSats + } + /// Identity for the sync timeout task. The timer only counts down while the overlay is visible /// and the device is online (while offline, `offlineSheetOverlay` covers the sheet and a timeout /// would act on stale state). Node readiness is part of the identity so the window restarts when @@ -577,11 +584,11 @@ struct SendSheet: View { } if let request = flow.incomingPaymentRequest, app.contactPaymentContext?.requiresAssetSelection != true || app.paykitPaymentTerms != nil { guard isCurrentIncomingRequest(request.id), let amount = try? request.payment( - using: app.paykitPaymentMethod, - quoteId: app.paykitPaymentTerms?.quoteId - ).amount.atomic, - await selectHardwareFundingSourceIfNeeded(amountSats: amount, requestId: request.id), - isCurrentIncomingRequest(request.id) else { return } + using: app.paykitPaymentMethod, + quoteId: app.paykitPaymentTerms?.quoteId + ).amount.atomic, + await selectHardwareFundingSourceIfNeeded(amountSats: amount, requestId: request.id), + isCurrentIncomingRequest(request.id) else { return } if !shouldShowSyncOverlay { validatePaymentAfterSync() } @@ -760,7 +767,8 @@ struct SendSheet: View { return .failed } guard LightningService.shared.canSend(amountSats: requestedAmount) else { - let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + let spendingBalance = (LightningService.shared.balances?.totalLightningBalanceSats ?? 0) + + (LightningService.shared.channelFreeRail?.balanceSats ?? 0) showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) flow.hasValidatedAfterSync = true return .failed @@ -777,7 +785,7 @@ struct SendSheet: View { // usable, proceed with validation/fallback. // Use channelCount as fallback in case channels array is nil but count is cached let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait { + if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait, !arkadeCanPayScannedInvoice { // We have channels but none usable yet → wait return .waiting } @@ -827,7 +835,8 @@ struct SendSheet: View { return .ready } else { // For pure lightning invoices, show error toast and dismiss sheet - let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + let spendingBalance = (LightningService.shared.balances?.totalLightningBalanceSats ?? 0) + + (LightningService.shared.channelFreeRail?.balanceSats ?? 0) showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) flow.hasValidatedAfterSync = true return .failed diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index bae55e69a..e2c9ceeac 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -41,7 +41,7 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { - if let channels = wallet.channels, !channels.isEmpty { + if wallet.channels?.isEmpty == false || ArkadeManager.shared.balanceSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) @@ -115,7 +115,11 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if ArkadeManager.shared.isReady { + navigation.navigate(.arkadeFundingAmount) + } else { + sheets.showSheet(.transferUnavailable) + } } .accessibilityIdentifier("TransferFromSavings") } diff --git a/BitkitTests/ArkadeExitBackupTests.swift b/BitkitTests/ArkadeExitBackupTests.swift new file mode 100644 index 000000000..28a5e3251 --- /dev/null +++ b/BitkitTests/ArkadeExitBackupTests.swift @@ -0,0 +1,65 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class ArkadeExitBackupTests: XCTestCase { + private let walletIndex = 99 + + override func tearDown() { + try? FileManager.default.removeItem(at: Env.arkadeExitRestore(walletIndex: walletIndex)) + try? FileManager.default.removeItem(at: Env.arkadeStartedMarker(walletIndex: walletIndex)) + super.tearDown() + } + + func testUploadsTheExportedWalletDataUnderItsOwnKey() async throws { + let suite = "ArkadeExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let backup = ArkadeExitBackupV1(createdAt: 1000, network: "mutinynet", exitState: "{\"version\":1}") + var uploads: [(String, Data)] = [] + let service = BackupService( + defaults: defaults, + backupData: { _ in try JSONEncoder().encode(backup) }, + uploadBackup: { uploads.append(($0, $1)) } + ) + + await service.triggerBackup(category: .arkadeExit) + + let upload = try XCTUnwrap(uploads.first) + XCTAssertEqual(upload.0, "ARKADE_EXIT") + let restored = try JSONDecoder().decode(ArkadeExitBackupV1.self, from: upload.1) + XCTAssertEqual(restored.exitState, backup.exitState) + XCTAssertEqual(restored.network, backup.network) + } + + func testReadsABackupWithoutAVersion() throws { + let json = Data(#"{"createdAt":1000,"network":"bitcoin","exitState":"state"}"#.utf8) + + let backup = try JSONDecoder().decode(ArkadeExitBackupV1.self, from: json) + + XCTAssertEqual(backup.version, 1) + XCTAssertEqual(backup.exitState, "state") + } + + func testStagesARestoredBackupForTheNextStartAndScansAgain() async throws { + let network = try XCTUnwrap(ArkadeManager.config?.network) + let marker = Env.arkadeStartedMarker(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: marker.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("1".utf8).write(to: marker) + let backup = ArkadeExitBackupV1(createdAt: 1000, network: network, exitState: "state") + + try await ArkadeManager.shared.restoreExitBackup(backup, walletIndex: walletIndex) + + let staged = try String(contentsOf: Env.arkadeExitRestore(walletIndex: walletIndex), encoding: .utf8) + XCTAssertEqual(staged, "state") + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + + func testSkipsABackupFromAnotherNetwork() async throws { + let backup = ArkadeExitBackupV1(createdAt: 1000, network: "testnet", exitState: "state") + + try await ArkadeManager.shared.restoreExitBackup(backup, walletIndex: walletIndex) + + XCTAssertFalse(FileManager.default.fileExists(atPath: Env.arkadeExitRestore(walletIndex: walletIndex).path)) + } +} diff --git a/BitkitTests/ArkadeManagerTests.swift b/BitkitTests/ArkadeManagerTests.swift new file mode 100644 index 000000000..9cddb4283 --- /dev/null +++ b/BitkitTests/ArkadeManagerTests.swift @@ -0,0 +1,135 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class ArkadeManagerTests: XCTestCase { + func testMarksADepositBoardedOnceItsBoardingSettles() { + let history = [ + entry(boardingTxid: "boarded", type: ArkadeHistoryEntry.received, amount: 35000, settled: true), + entry(boardingTxid: "waiting", type: ArkadeHistoryEntry.received, amount: 15000, settled: false), + entry(arkTxid: "lightning", type: ArkadeHistoryEntry.received, amount: 1000, settled: true), + ] + + let deposits = ArkadeManager.deposits(history) + + XCTAssertEqual(deposits, [ + ArkadeDeposit(txid: "waiting", amountSats: 15000, isClaimed: false), + ArkadeDeposit(txid: "boarded", amountSats: 35000, isClaimed: true), + ]) + } + + func testDepositStageFollowsConfirmationAndBoarding() { + XCTAssertEqual(ArkadeDepositStage(deposit: nil), .looking) + XCTAssertEqual(ArkadeDepositStage(deposit: ArkadeDeposit(txid: "a", amountSats: 1, isClaimed: false, confirmations: 0)), .looking) + XCTAssertEqual(ArkadeDepositStage(deposit: ArkadeDeposit(txid: "a", amountSats: 1, isClaimed: false, confirmations: 1)), .boarding) + XCTAssertEqual(ArkadeDepositStage(deposit: ArkadeDeposit(txid: "a", amountSats: 1, isClaimed: true)), .boarded) + } + + func testListsOffboardsAsWithdrawals() { + let history = [ + entry(commitmentTxid: "offboard", type: ArkadeHistoryEntry.sent, amount: 4917, settled: true), + entry(commitmentTxid: "batch", arkTxid: "payment", type: ArkadeHistoryEntry.sent, amount: 100, settled: true), + ] + + XCTAssertEqual(ArkadeManager.withdrawals(history), [ArkadeWithdrawal(txid: "offboard", amountSats: 4917, feeSats: 0)]) + } + + func testRecordsOnlyReceivesNoSwapOrBoardingCovers() { + let entries = [ + entry(arkTxid: "offline", type: ArkadeHistoryEntry.received, amount: 2100, settled: true), + entry(arkTxid: "swap-claim", type: ArkadeHistoryEntry.received, amount: 990, settled: true), + entry(boardingTxid: "deposit", type: ArkadeHistoryEntry.received, amount: 5000, settled: true), + entry(arkTxid: "sent", type: ArkadeHistoryEntry.sent, amount: 300, settled: true), + ] + + let receives = ArkadeManager.offlineReceives(entries, swapClaims: [990]) + + XCTAssertEqual(receives.map(\.arkTxid), ["offline"]) + } + + func testNextExpiryIsTheSoonestUnsweptOutputInSeconds() { + let vtxos = [ + ArkadeVtxo(txid: "a", batchExpiry: 1_800_000_000_000, isSwept: false), + ArkadeVtxo(txid: "b", batchExpiry: 1_700_000_000, isSwept: true), + ArkadeVtxo(txid: "c", batchExpiry: 1_750_000_000, isSwept: nil), + ] + + XCTAssertEqual(ArkadeManager.nextExpiry(vtxos), 1_750_000_000) + XCTAssertNil(ArkadeManager.nextExpiry([])) + } + + func testRaisesAClaimedReceiveAsAReceivedPayment() { + let swap = ArkadeSwap(id: "s", direction: "receive", outcome: "claimed", takeSat: 990, feeSat: 10, paymentHash: "aa") + + guard case let .paymentReceived(_, paymentHash, amountMsat, _) = ArkadeManager.nodeEvent(for: swap) else { + return XCTFail("Expected a received payment") + } + XCTAssertEqual(paymentHash, "aa") + XCTAssertEqual(amountMsat, 990_000) + XCTAssertNil(ArkadeManager.nodeEvent(for: ArkadeSwap(direction: "receive", outcome: "pending", paymentHash: "aa"))) + } + + func testRaisesAPaidSendWithItsFeeAndAFailedOne() { + let paid = ArkadeSwap(direction: "send", outcome: "paid", takeSat: 1000, feeSat: 7, paymentHash: "bb") + guard case let .paymentSuccessful(_, paymentHash, _, feePaidMsat) = ArkadeManager.nodeEvent(for: paid) else { + return XCTFail("Expected a successful payment") + } + XCTAssertEqual(paymentHash, "bb") + XCTAssertEqual(feePaidMsat, 7000) + + let refunded = ArkadeSwap(direction: "send", outcome: "refunded", paymentHash: "cc") + guard case .paymentFailed = ArkadeManager.nodeEvent(for: refunded) else { return XCTFail("Expected a failed payment") } + } + + func testRecordsASendAtOnceButAReceiveOnlyOnceClaimed() throws { + let pendingSend = ArkadeSwap(direction: "send", outcome: "funded", takeSat: 1000, feeSat: 7, bolt11: "lnbc1", paymentHash: "dd") + let details = try XCTUnwrap(ArkadeManager.paymentDetails(for: pendingSend)) + XCTAssertEqual(details.status, .pending) + XCTAssertEqual(details.direction, .outbound) + XCTAssertEqual(details.amountMsat, 1_000_000) + + XCTAssertNil(ArkadeManager.paymentDetails(for: ArkadeSwap(direction: "receive", outcome: "pending", paymentHash: "ee"))) + XCTAssertEqual( + ArkadeManager.railPayment(ArkadeSwap(direction: "receive", outcome: "claimed", paymentHash: "FF")), + Bitkit.ChannelFreeRailPayment(paymentHash: "ff", direction: .inbound, status: .succeeded, preimage: nil) + ) + } + + func testRefusesToPayAnInvoiceWithoutAnAmount() async { + do { + _ = try await ArkadeManager.shared.estimateRoutingFees(bolt11: "not-an-invoice", amountSats: 100) + XCTFail("Expected an amountless invoice to be refused") + } catch { + XCTAssertEqual(error.localizedDescription, "AmountlessInvoice") + } + } + + func testDecodesASwapWhoseTimesComeInAnotherShape() throws { + let json = Data(#"{"id":"s","direction":"send","outcome":"paid","createdAt":"2026-10-29T10:00:00Z","takeSat":5}"#.utf8) + + let swap = try JSONDecoder().decode(ArkadeSwap.self, from: json) + + XCTAssertEqual(swap.outcome, "paid") + XCTAssertNil(swap.createdAt) + XCTAssertEqual(swap.takeSat, 5) + } + + private func entry( + boardingTxid: String? = nil, + commitmentTxid: String? = nil, + arkTxid: String? = nil, + type: String, + amount: Int64, + settled: Bool + ) -> ArkadeHistoryEntry { + ArkadeHistoryEntry( + boardingTxid: boardingTxid, + commitmentTxid: commitmentTxid, + arkTxid: arkTxid, + type: type, + amount: amount, + settled: settled, + createdAt: 1_700_000_000_000 + ) + } +} diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift new file mode 100644 index 000000000..1e95f8bde --- /dev/null +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -0,0 +1,124 @@ +@testable import Bitkit +import LDKNode +import XCTest + +@MainActor +final class ChannelFreeRailTests: XCTestCase { + private var rail: FakeRail! + private var previousRail: Bitkit.ChannelFreeLightningRail? + + override func setUp() { + super.setUp() + rail = FakeRail() + previousRail = Bitkit.LightningService.shared.channelFreeRail + Bitkit.LightningService.shared.channelFreeRail = rail + } + + override func tearDown() { + Bitkit.LightningService.shared.channelFreeRail = previousRail + rail = nil + super.tearDown() + } + + func testRailEventsBeforeTheNodeCallbackAreDeliveredOnceItIsSet() { + let hash = "aa" + Bitkit.LightningService.shared.emitChannelFreeRailEvent( + .paymentReceived(paymentId: hash, paymentHash: hash, amountMsat: 1000, customRecords: []) + ) + var received: [String] = [] + + Bitkit.LightningService.shared.setEventCallback { event in + if case let .paymentReceived(_, paymentHash, _, _) = event { + received.append(paymentHash) + } + } + + XCTAssertEqual(received, [hash]) + } + + func testPaykitProofSettlesFromTheRailWhenTheEventWasMissed() async { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "bb", direction: .outbound, status: .succeeded, preimage: "cc")] + + let status = await PaykitLightningPaymentProofLookup().status(paymentHash: "BB") + + guard case let .succeeded(preimage) = status else { return XCTFail("Expected a settled proof, got \(status)") } + XCTAssertEqual(preimage, "cc") + } + + func testPrivatePaykitSeesRailReceivesAndSends() async { + rail.payments = [ + Bitkit.ChannelFreeRailPayment(paymentHash: "in", direction: .inbound, status: .succeeded, preimage: nil), + Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil), + Bitkit.ChannelFreeRailPayment(paymentHash: "failed", direction: .outbound, status: .failed, preimage: nil), + ] + + let received = await PrivatePaykitService.shared.receivedSettledPaymentHashes() + let attempted = await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() + + XCTAssertTrue(received.contains("in")) + XCTAssertEqual(attempted.intersection(["out", "failed"]), ["out"]) + } + + func testLightningWaitFinishesWhenTheRailSettledBeforeWatching() async throws { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "ff", direction: .outbound, status: .succeeded, preimage: nil)] + + let settled = try await WalletViewModel().waitForLightningPayment(hash: "ff", timeoutSeconds: 5) + + XCTAssertEqual(settled.paymentHash, "ff") + } + + func testQuickPayReconcilesRailPayments() { + let row = QuickPayReconcileRow(railPayment: Bitkit.ChannelFreeRailPayment( + paymentHash: "dd", direction: .outbound, status: .succeeded, preimage: nil + )) + + XCTAssertEqual(row.invoicePaymentHash, "dd") + XCTAssertTrue(row.isOutboundBolt11) + XCTAssertEqual(row.status, QuickPayReconcileRow.Status.succeeded) + } + + func testRailSendHonoursTheRoutingFeeLimit() async throws { + rail.routingFeeSats = 5 + let limit = RouteParametersConfig( + maxTotalRoutingFeeMsat: 1000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: limit) + XCTFail("Expected the fee limit to stop the payment") + } catch { + XCTAssertNil(rail.sentBolt11) + } + + let roomy = RouteParametersConfig( + maxTotalRoutingFeeMsat: 10000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: roomy) + XCTAssertEqual(rail.sentBolt11, "lnbcrt1rail") + } +} + +private final class FakeRail: Bitkit.ChannelFreeLightningRail { + var isReady = true + var balanceSats: UInt64 = 9572 + var payments: [Bitkit.ChannelFreeRailPayment] = [] + var routingFeeSats: UInt64 = 0 + var sentBolt11: String? + + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + "lnbcrt1fake" + } + + func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + sentBolt11 = bolt11 + return "ee" + } + + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + routingFeeSats + } + + func lightningPayments() async -> [Bitkit.ChannelFreeRailPayment] { + payments + } +} diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 6f21e9fcc..f99bb7cf2 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4858,6 +4858,40 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestPresentation(request)) } + func testResolutionFailureEndsARequestedPresentationWithAToast() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + let feedback = try XCTUnwrap( + IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: manager + ) + ) + + XCTAssertNil(manager.requestedPresentationId) + XCTAssertFalse(manager.isWaitingForPresentationRetry(request)) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertNotNil(feedback.toast) + } + + func testResolutionFailureLeavesAutomaticPresentationToRetry() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + + XCTAssertNil( + IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: manager + ) + ) + } + func testPendingPrivateLinkRecoveryStopsAutomaticPresentationWithoutToast() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) diff --git a/journeys/transfer/arkade-transfer-to-spending.xml b/journeys/transfer/arkade-transfer-to-spending.xml new file mode 100644 index 000000000..706ff7ce6 --- /dev/null +++ b/journeys/transfer/arkade-transfer-to-spending.xml @@ -0,0 +1,20 @@ + + + Verifies that Arkade is the easy option where Transfer To Spending otherwise leads to Manual Setup. With Arkade + ready, Funding leads with "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag + "FundReceive") above "Manual Setup" (testTag "FundManual"), and the Savings and Spending transfer buttons open + the Arkade amount screen (testTag "SpendingAmount") instead of the Transfer Unavailable sheet. The confirm screen + shows the network fee and the Arkade boarding fee. Boarding needs the deposit confirmed on the chain Arkade runs + on, so this journey stops before the swipe. + + Precondition: dev build with Arkade connected on mutinynet (no key needed), onboarded wallet with a positive + on-chain Savings balance and no channels. Start on the wallet home screen. + + + Tap the Savings balance card (testTag "ActivitySavings"), tap "Transfer To Spending" (testTag "TransferToSpending") and verify the amount screen (testTag "SpendingAmount") opens with the available amount, not the Transfer Unavailable sheet + Tap "Max" (testTag "SpendingAmountMax"), tap "Continue" (testTag "SpendingAmountContinue") and verify the confirm screen shows "Network fees", "Boarding fee", "To spending" and "Total", and a swipe to transfer + Go back to the wallet home, open Settings › Advanced › Lightning Connections and tap add connection (testTag "add_connection_button") + Verify "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual"), and "Transfer from HW wallet" (testTag "FundHardware") is disabled + Tap "Transfer from Savings" and verify the amount screen (testTag "SpendingAmount") opens + + diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index a3ae0538a..572b7f9c9 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -8,7 +8,9 @@ (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning path shows the same sheet content. - Precondition: onboarded wallet with a positive on-chain Savings balance and no channels. Start on the + Precondition: onboarded wallet with a positive on-chain Savings balance, no channels and Arkade + not running (Arkade's network unreachable); with Arkade ready, see + arkade-transfer-to-spending.xml. Start on the wallet home screen. From ddbc30770f96091b305af9a0c41ac0faa314244c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:01:35 +0100 Subject: [PATCH 02/15] chore: add arkade changelog entry --- changelog.d/next/1169.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1169.added.md diff --git a/changelog.d/next/1169.added.md b/changelog.d/next/1169.added.md new file mode 100644 index 000000000..64e56ec48 --- /dev/null +++ b/changelog.d/next/1169.added.md @@ -0,0 +1 @@ +Spending can now run on Arkade, so Lightning works without channels. From 76d5d6cb9162f1509ae750e49920e9eff26886bc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:14:40 +0100 Subject: [PATCH 03/15] style: match swiftformat 0.64.0 --- Bitkit/AppScene.swift | 20 +++++++++---------- .../Services/PaykitPaymentProofService.swift | 10 +++++----- Bitkit/ViewModels/TransferViewModel.swift | 14 ++++++------- Bitkit/Views/Wallets/Send/SendSheet.swift | 10 +++++----- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b5bb5d2f0..415df1aed 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -519,8 +519,8 @@ struct AppScene: View { let reservations = PrivatePaykitAddressReservationStore.shared let reservationRevision = await reservations.attributionRevision guard let combined = try? await contacts.includingReservations(for: outputAddresses, lookup: { - try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) - }), pubkyProfile.authState == .authenticated, + try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) + }), pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey), contacts == paykitPaymentRequestManager.receivedPaymentContacts, await reservations.attributionRevision == reservationRevision @@ -2025,14 +2025,14 @@ struct AppScene: View { return } guard IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( - manager: paykitPaymentRequestManager, app: app, sheets: sheets - ), await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), - !Task.isCancelled, - pubkyProfile.automaticPaykitSession == session, - PaykitSubscriptionNotificationTargetStore.load() == target, - IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( - manager: paykitPaymentRequestManager, app: app, sheets: sheets - ) + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ), await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), + !Task.isCancelled, + pubkyProfile.automaticPaykitSession == session, + PaykitSubscriptionNotificationTargetStore.load() == target, + IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ) else { return } guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 38dd82ca7..36a65448e 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -865,11 +865,11 @@ actor PaykitPaymentProofService { } let proofs = try await loadProofs() guard let proof = proofs.first(where: { - PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && - $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && - $0.onchainAddress == address && $0.onchainAmountSats == amountSats && - $0.proofData == nil && $0.onchainAcceptanceVerified != true - }), let raw = proof.hardwareSignedTransaction, + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && + $0.onchainAddress == address && $0.onchainAmountSats == amountSats && + $0.proofData == nil && $0.onchainAcceptanceVerified != true + }), let raw = proof.hardwareSignedTransaction, try SignedTransactionId.fromHex(raw) == proof.paymentIdentifier, let fee = proof.hardwareMiningFeeSats, let rate = proof.hardwareFeeRate, let spent = proof.hardwareTotalSpent diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 1915be5aa..70c794c56 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -149,13 +149,13 @@ class TransferViewModel: ObservableObject { onEvent(eventId) { event in if case let .channelPending( - eventChannelId, - eventUserChannelId, - _, - _, - fundingTxo - ) = event, - // Match by user channel ID + eventChannelId, + eventUserChannelId, + _, + _, + fundingTxo + ) = event, + // Match by user channel ID eventUserChannelId.description == userChannelId { Task { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index ff0e3d967..5d6c1b77b 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -584,11 +584,11 @@ struct SendSheet: View { } if let request = flow.incomingPaymentRequest, app.contactPaymentContext?.requiresAssetSelection != true || app.paykitPaymentTerms != nil { guard isCurrentIncomingRequest(request.id), let amount = try? request.payment( - using: app.paykitPaymentMethod, - quoteId: app.paykitPaymentTerms?.quoteId - ).amount.atomic, - await selectHardwareFundingSourceIfNeeded(amountSats: amount, requestId: request.id), - isCurrentIncomingRequest(request.id) else { return } + using: app.paykitPaymentMethod, + quoteId: app.paykitPaymentTerms?.quoteId + ).amount.atomic, + await selectHardwareFundingSourceIfNeeded(amountSats: amount, requestId: request.id), + isCurrentIncomingRequest(request.id) else { return } if !shouldShowSyncOverlay { validatePaymentAfterSync() } From ee827d9635238a6cd6721f5359e3d9214860b522 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 02:13:37 +0100 Subject: [PATCH 04/15] feat: run arkade on bitkit-core ArkadeBridge now calls bitkit-core's Arkade module instead of throwing, converting its records to the app types, and Arkade is on wherever the network has a config. The wallet keeps its storage in wallet/arkade, core events arrive through one registered listener, and the WebView-era decoding, endpoints and restart path are gone. Needs a bitkit-core prerelease that ships the Arkade module; the app still pins 0.8.0-rc11. --- Bitkit/Constants/Env.swift | 6 + Bitkit/Managers/ArkadeBridge.swift | 242 +++++++++++++++------------ Bitkit/Managers/ArkadeManager.swift | 47 +----- BitkitTests/ArkadeManagerTests.swift | 10 -- 4 files changed, 146 insertions(+), 159 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 3e2c05ec5..4cf5d8bd3 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -272,6 +272,12 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/ldk") } + static func arkadeStorage(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/arkade") + } + /// A restored Arkade wallet data backup waiting to be imported when the Arkade wallet next starts. static func arkadeExitRestore(walletIndex: Int) -> URL { appStorageUrl diff --git a/Bitkit/Managers/ArkadeBridge.swift b/Bitkit/Managers/ArkadeBridge.swift index 7abbc226c..489a9f912 100644 --- a/Bitkit/Managers/ArkadeBridge.swift +++ b/Bitkit/Managers/ArkadeBridge.swift @@ -1,65 +1,96 @@ +import BitkitCore import Foundation -/// Arkade's wallet and Lightning client for Bitkit. The engine is a Rust binding on `arkade-os/rust-sdk` (ark-rs) over -/// UniFFI, shared with Android, which adds the Arkade Intents Lightning client the Rust SDK lacks. Until it lands, -/// every call throws `ArkadeError.unsupported` and Arkade stays off, so every flow takes its channel path. +/// Arkade's wallet and Lightning client for Bitkit: the Arkade module of bitkit-core, a Rust engine on +/// `arkade-os/rust-sdk` (ark-rs) shared with Android, which adds the Arkade Intents Lightning client the Rust SDK +/// lacks. It converts the core's records to the app's types below, which shadow the core types of the same name. @MainActor final class ArkadeBridge { var onEvent: ((ArkadeBridgeEvent) -> Void)? - var onTerminated: (() -> Void)? - static let isAvailable = false + private var dataDir: String? - func start(_: ArkadeStartParams) async throws -> ArkadeInfo { - throw ArkadeError.unsupported + init() { + arkadeSetListener(listener: ArkadeListener { [weak self] event in + Task { @MainActor in self?.onEvent?(event) } + }) + } + + func start(_ params: ArkadeStartParams) async throws -> ArkadeInfo { + let network: ArkadeNetwork = switch params.network { + case "bitcoin": .bitcoin + case "mutinynet": .mutinynet + default: throw ArkadeError.unsupported + } + let info = try await arkadeStart(params: BitkitCore.ArkadeStartParams( + dataDir: params.dataDir, + mnemonic: params.mnemonic, + passphrase: params.passphrase, + network: network, + restore: params.restore, + backup: params.backup + )) + dataDir = params.dataDir + return ArkadeInfo(info) } func info() async throws -> ArkadeInfo { - throw ArkadeError.unsupported + try await ArkadeInfo(arkadeInfo()) } func balance() async throws -> ArkadeBalance { - throw ArkadeError.unsupported + try await ArkadeBalance(arkadeBalance()) } func vtxos() async throws -> [ArkadeVtxo] { - throw ArkadeError.unsupported + try await arkadeVtxos().map(ArkadeVtxo.init) } func history() async throws -> [ArkadeHistoryEntry] { - throw ArkadeError.unsupported + try await arkadeHistory().map(ArkadeHistoryEntry.init) } func swaps() async throws -> [ArkadeSwap] { - throw ArkadeError.unsupported + try await arkadeSwaps().map(ArkadeSwap.init) } - func receive(amountSats _: UInt64) async throws -> ArkadeSwap { - throw ArkadeError.unsupported + func receive(amountSats: UInt64) async throws -> ArkadeSwap { + try await ArkadeSwap(arkadeReceive(amountSat: amountSats)) } - func quotePay(bolt11 _: String) async throws -> ArkadePayQuote { - throw ArkadeError.unsupported + func quotePay(bolt11: String) async throws -> ArkadePayQuote { + try await ArkadePayQuote(feeSat: Int64(clamping: arkadeQuotePay(bolt11: bolt11).feeSat)) } - func pay(bolt11 _: String) async throws -> ArkadeSwap { - throw ArkadeError.unsupported + func pay(bolt11: String) async throws -> ArkadeSwap { + try await ArkadeSwap(arkadePay(bolt11: bolt11, maxFeeSat: nil)) } - func offboard(address _: String) async throws -> String { - throw ArkadeError.unsupported + /// Moves the whole balance to `address` in the next batch; the commitment txid. + func offboard(address: String) async throws -> String { + try await arkadeOffboard(address: address, amountSat: nil) } func lightningAddress() async throws -> ArkadeLightningAddress? { - throw ArkadeError.unsupported + let address = try await arkadeLightningAddress() + return ArkadeLightningAddress(lightningAddress: address.lightningAddress, lnurl: address.lnurl) } func exportState() async throws -> String { - throw ArkadeError.unsupported + guard let dataDir else { throw ArkadeError.notConnected } + return try await Task.detached { try arkadeExportState(dataDir: dataDir) }.value } - func wipe() async throws {} - func reload() async throws {} + /// Stops the wallet and deletes its storage in `dataDir`. + func wipe(dataDir: String) async throws { + try await arkadeWipe(dataDir: dataDir) + self.dataDir = nil + } + + /// Stops the wallet, so the next `start` opens its storage again and imports a staged backup. + func reload() async throws { + await arkadeStop() + } } enum ArkadeBridgeEvent { @@ -67,75 +98,78 @@ enum ArkadeBridgeEvent { case history([ArkadeHistoryEntry]) } +private final class ArkadeListener: ArkadeEventListener { + private let send: @Sendable (ArkadeBridgeEvent) -> Void + + init(send: @escaping @Sendable (ArkadeBridgeEvent) -> Void) { + self.send = send + } + + func onHistory(entries: [BitkitCore.ArkadeHistoryEntry], balance _: BitkitCore.ArkadeBalance) { + send(.history(entries.map(ArkadeHistoryEntry.init))) + } + + func onSwap(swap: BitkitCore.ArkadeSwap) { + send(.swap(ArkadeSwap(swap))) + } +} + /// What `start` gets; the mnemonic and passphrase leave the app only inside this call. -struct ArkadeStartParams: Encodable { +struct ArkadeStartParams { + /// The wallet's own storage directory. + let dataDir: String let mnemonic: String let passphrase: String? + /// `bitcoin` or `mutinynet`. let network: String - let arkServerUrl: String - let delegateUrl: String? - let lnurlUrl: String? let restore: Bool let backup: String? } -struct ArkadeInfo: Decodable { +struct ArkadeInfo { let arkAddress: String let boardingAddress: String let network: String } -struct ArkadeBalance: Decodable { +extension ArkadeInfo { + init(_ info: BitkitCore.ArkadeInfo) { + self.init(arkAddress: info.arkAddress, boardingAddress: info.boardingAddress, network: info.network) + } +} + +struct ArkadeBalance { var available: Int64 = 0 var recoverable: Int64 = 0 var boardingConfirmed: Int64 = 0 var boardingUnconfirmed: Int64 = 0 +} - init(available: Int64 = 0, recoverable: Int64 = 0, boardingConfirmed: Int64 = 0, boardingUnconfirmed: Int64 = 0) { - self.available = available - self.recoverable = recoverable - self.boardingConfirmed = boardingConfirmed - self.boardingUnconfirmed = boardingUnconfirmed - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - available = try container.decodeIfPresent(Int64.self, forKey: .available) ?? 0 - recoverable = try container.decodeIfPresent(Int64.self, forKey: .recoverable) ?? 0 - boardingConfirmed = try container.decodeIfPresent(Int64.self, forKey: .boardingConfirmed) ?? 0 - boardingUnconfirmed = try container.decodeIfPresent(Int64.self, forKey: .boardingUnconfirmed) ?? 0 - } - - private enum CodingKeys: String, CodingKey { - case available, recoverable, boardingConfirmed, boardingUnconfirmed +extension ArkadeBalance { + init(_ balance: BitkitCore.ArkadeBalance) { + self.init( + available: Int64(clamping: balance.available), + recoverable: Int64(clamping: balance.recoverable), + boardingConfirmed: Int64(clamping: balance.boardingConfirmed), + boardingUnconfirmed: Int64(clamping: balance.boardingUnconfirmed) + ) } } -struct ArkadeVtxo: Decodable, Equatable { +struct ArkadeVtxo: Equatable { var txid: String? /// Unix seconds, or milliseconds, after which the operator may sweep this output unless it is renewed. var batchExpiry: Int64? var isSwept: Bool? +} - init(txid: String? = nil, batchExpiry: Int64? = nil, isSwept: Bool? = nil) { - self.txid = txid - self.batchExpiry = batchExpiry - self.isSwept = isSwept - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - txid = try? container.decodeIfPresent(String.self, forKey: .txid) - batchExpiry = try? container.decodeIfPresent(Int64.self, forKey: .batchExpiry) - isSwept = try? container.decodeIfPresent(Bool.self, forKey: .isSwept) - } - - private enum CodingKeys: String, CodingKey { - case txid, batchExpiry, isSwept +extension ArkadeVtxo { + init(_ vtxo: BitkitCore.ArkadeVtxo) { + self.init(txid: vtxo.txid, batchExpiry: vtxo.batchExpiry.map { Int64(clamping: $0) }, isSwept: vtxo.isSwept) } } -struct ArkadeHistoryEntry: Decodable, Equatable { +struct ArkadeHistoryEntry: Equatable { static let received = "RECEIVED" static let sent = "SENT" @@ -150,8 +184,22 @@ struct ArkadeHistoryEntry: Decodable, Equatable { var createdAt: Int64 } +extension ArkadeHistoryEntry { + init(_ entry: BitkitCore.ArkadeHistoryEntry) { + self.init( + boardingTxid: entry.boardingTxid, + commitmentTxid: entry.commitmentTxid, + arkTxid: entry.arkTxid, + type: entry.txType, + amount: Int64(clamping: entry.amount), + settled: entry.settled, + createdAt: Int64(clamping: entry.createdAt) + ) + } +} + /// A Lightning swap through Arkade Intents: `receive` pays into the wallet, `send` pays an invoice out of it. -struct ArkadeSwap: Decodable, Equatable { +struct ArkadeSwap: Equatable { static let claimed = "claimed" static let paid = "paid" static let failedSendOutcomes: Set = ["refunded", "cancelled", "failed"] @@ -168,46 +216,6 @@ struct ArkadeSwap: Decodable, Equatable { /// Unix seconds. var updatedAt: Int64? - init( - id: String? = nil, - direction: String? = nil, - outcome: String? = nil, - takeSat: Int64? = nil, - feeSat: Int64? = nil, - bolt11: String? = nil, - paymentHash: String? = nil, - createdAt: Int64? = nil, - updatedAt: Int64? = nil - ) { - self.id = id - self.direction = direction - self.outcome = outcome - self.takeSat = takeSat - self.feeSat = feeSat - self.bolt11 = bolt11 - self.paymentHash = paymentHash - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - /// Reads each field on its own, so one the bridge reports in another shape doesn't drop the whole swap. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try? container.decodeIfPresent(String.self, forKey: .id) - direction = try? container.decodeIfPresent(String.self, forKey: .direction) - outcome = try? container.decodeIfPresent(String.self, forKey: .outcome) - takeSat = try? container.decodeIfPresent(Int64.self, forKey: .takeSat) - feeSat = try? container.decodeIfPresent(Int64.self, forKey: .feeSat) - bolt11 = try? container.decodeIfPresent(String.self, forKey: .bolt11) - paymentHash = try? container.decodeIfPresent(String.self, forKey: .paymentHash) - createdAt = try? container.decodeIfPresent(Int64.self, forKey: .createdAt) - updatedAt = try? container.decodeIfPresent(Int64.self, forKey: .updatedAt) - } - - private enum CodingKeys: String, CodingKey { - case id, direction, outcome, takeSat, feeSat, bolt11, paymentHash, createdAt, updatedAt - } - var isReceive: Bool { direction == "receive" } @@ -217,15 +225,27 @@ struct ArkadeSwap: Decodable, Equatable { } } -struct ArkadePayQuote: Decodable { +extension ArkadeSwap { + init(_ swap: BitkitCore.ArkadeSwap) { + self.init( + id: swap.id, + direction: swap.direction, + outcome: swap.outcome, + takeSat: Int64(clamping: swap.takeSat), + feeSat: Int64(clamping: swap.feeSat), + bolt11: swap.bolt11, + paymentHash: swap.paymentHash, + createdAt: Int64(clamping: swap.createdAt), + updatedAt: Int64(clamping: swap.updatedAt) + ) + } +} + +struct ArkadePayQuote { let feeSat: Int64 } -struct ArkadeLightningAddress: Decodable { +struct ArkadeLightningAddress { let lightningAddress: String? let lnurl: String } - -private struct ArkadeTxid: Decodable { - let txid: String? -} diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift index 206a10507..5b0b2cc9e 100644 --- a/Bitkit/Managers/ArkadeManager.swift +++ b/Bitkit/Managers/ArkadeManager.swift @@ -5,8 +5,8 @@ import Observation /// Lightning without channels, through Arkade (Ark Labs) and Arkade Intents. Savings to Spending boards on-chain funds /// into the Arkade wallet, receives fall back to it when no channel has the inbound capacity, and sends fall back to it -/// when no channel has the outbound capacity. The wallet runs in Ark Labs' TypeScript stack inside `ArkadeBridge`'s -/// hidden web view and derives its keys from the Bitkit seed on its own BIP86 account (`m/86'/'/1'`). +/// when no channel has the outbound capacity. The wallet runs in bitkit-core's Arkade module through `ArkadeBridge` +/// and derives its keys from the Bitkit seed on its own BIP86 account (`m/86'/'/1'`). /// Its payments reach the app as LDK events through `LightningService.emitChannelFreeRailEvent`, so the receive and /// send flows built on node events work for both rails. @Observable @MainActor @@ -47,16 +47,13 @@ final class ArkadeManager: ChannelFreeLightningRail { bridge.onEvent = { [weak self] event in Task { await self?.onEvent(event) } } - bridge.onTerminated = { [weak self] in - Task { await self?.restartAfterTermination() } - } return bridge }() private init() {} var isSupported: Bool { - ArkadeBridge.isAvailable && Self.config != nil + Self.config != nil } var isConnected: Bool { @@ -64,24 +61,12 @@ final class ArkadeManager: ChannelFreeLightningRail { } /// Arkade runs on mainnet against Ark Labs' operator, and on mutinynet, Arkade's test network, for dev builds. + /// bitkit-core holds each network's endpoints. nonisolated static var config: ArkadeConfig? { switch Env.network { - case .bitcoin: - ArkadeConfig( - network: "bitcoin", - arkServerUrl: "https://arkade.computer", - delegateUrl: "https://delegate.arkade.money", - lnurlUrl: "https://lnurl.arkade.sh" - ) - case .regtest: - ArkadeConfig( - network: "mutinynet", - arkServerUrl: "https://mutinynet.arkade.sh", - delegateUrl: "https://delegator.mutinynet.arkade.sh", - lnurlUrl: nil - ) - default: - nil + case .bitcoin: ArkadeConfig(network: "bitcoin") + case .regtest: ArkadeConfig(network: "mutinynet") + default: nil } } @@ -122,12 +107,10 @@ final class ArkadeManager: ChannelFreeLightningRail { let backup = FileManager.default.contents(atPath: restoreFile.path).map { String(decoding: $0, as: UTF8.self) } let startedMarker = Env.arkadeStartedMarker(walletIndex: walletIndex) let params = ArkadeStartParams( + dataDir: Env.arkadeStorage(walletIndex: walletIndex).path, mnemonic: mnemonic, passphrase: passphrase, network: config.network, - arkServerUrl: config.arkServerUrl, - delegateUrl: config.delegateUrl, - lnurlUrl: config.lnurlUrl, restore: !FileManager.default.fileExists(atPath: startedMarker.path), backup: backup ) @@ -143,21 +126,12 @@ final class ArkadeManager: ChannelFreeLightningRail { Self.exitStateChanged.send() } - /// The web content process took the wallet with it: start it again, without a restore scan or backup import. - private func restartAfterTermination() async { - guard info != nil else { return } - Logger.warn("Restarting Arkade wallet after its web content process stopped", context: "ArkadeManager") - info = nil - isReady = false - await start(walletIndex: walletIndex) - } - func wipe(walletIndex: Int) async { if LightningService.shared.channelFreeRail === self { LightningService.shared.channelFreeRail = nil } do { - try await bridge.wipe() + try await bridge.wipe(dataDir: Env.arkadeStorage(walletIndex: walletIndex).path) } catch { Logger.warn("Failed to wipe Arkade storage: \(error)", context: "ArkadeManager") } @@ -533,9 +507,6 @@ final class ArkadeManager: ChannelFreeLightningRail { struct ArkadeConfig { let network: String - let arkServerUrl: String - let delegateUrl: String? - let lnurlUrl: String? } /// An on-chain deposit into Arkade: pending until it confirms and the wallet boards it into a batch. diff --git a/BitkitTests/ArkadeManagerTests.swift b/BitkitTests/ArkadeManagerTests.swift index 9cddb4283..f78fdffb2 100644 --- a/BitkitTests/ArkadeManagerTests.swift +++ b/BitkitTests/ArkadeManagerTests.swift @@ -104,16 +104,6 @@ final class ArkadeManagerTests: XCTestCase { } } - func testDecodesASwapWhoseTimesComeInAnotherShape() throws { - let json = Data(#"{"id":"s","direction":"send","outcome":"paid","createdAt":"2026-10-29T10:00:00Z","takeSat":5}"#.utf8) - - let swap = try JSONDecoder().decode(ArkadeSwap.self, from: json) - - XCTAssertEqual(swap.outcome, "paid") - XCTAssertNil(swap.createdAt) - XCTAssertEqual(swap.takeSat, 5) - } - private func entry( boardingTxid: String? = nil, commitmentTxid: String? = nil, From 8a223dafee68cd22c1d2bc988ef398486d600305 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 02:14:42 +0100 Subject: [PATCH 05/15] fix: keep arkade off on test networks --- Bitkit/Managers/ArkadeManager.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift index 5b0b2cc9e..ba98df056 100644 --- a/Bitkit/Managers/ArkadeManager.swift +++ b/Bitkit/Managers/ArkadeManager.swift @@ -60,14 +60,10 @@ final class ArkadeManager: ChannelFreeLightningRail { info != nil } - /// Arkade runs on mainnet against Ark Labs' operator, and on mutinynet, Arkade's test network, for dev builds. - /// bitkit-core holds each network's endpoints. + /// Arkade runs on mainnet against Ark Labs' operator; bitkit-core holds its endpoints. Its test network has no + /// Lightning solver or LNURL server in bitkit-core, so dev builds keep Arkade off and use channels. nonisolated static var config: ArkadeConfig? { - switch Env.network { - case .bitcoin: ArkadeConfig(network: "bitcoin") - case .regtest: ArkadeConfig(network: "mutinynet") - default: nil - } + Env.network == .bitcoin ? ArkadeConfig(network: "bitcoin") : nil } func start(walletIndex: Int) async { From ad465c36e05ba8a561fd0ad5b6f8254938e4f29d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 02:54:16 +0100 Subject: [PATCH 06/15] chore: use bitkit-core 0.8.0-arkade.1 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..199f27ad3 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-rc11"; + version = "0.8.0-arkade.1"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..f97c76345 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "22e0acc31a97b65205e71f4190cfc659fdecd3ca", - "version" : "0.8.0-rc11" + "revision" : "ec3f9cd24d2a445b4ccd6dd4c5c6cea8ec39a5e7", + "version" : "0.8.0-arkade.1" } }, { From 373991a5cf4d949dc4537683489bbc14b02bf401 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 03:00:00 +0100 Subject: [PATCH 07/15] fix: keep arkade ready when its first refresh times out --- Bitkit/Managers/ArkadeManager.swift | 51 +++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift index ba98df056..e30ab4769 100644 --- a/Bitkit/Managers/ArkadeManager.swift +++ b/Bitkit/Managers/ArkadeManager.swift @@ -66,27 +66,50 @@ final class ArkadeManager: ChannelFreeLightningRail { Env.network == .bitcoin ? ArkadeConfig(network: "bitcoin") : nil } + /// Waits between attempts to connect the Arkade wallet when the operator or chain source is slow. + private static let startRetryDelays: [Duration] = [.seconds(10), .seconds(30), .seconds(60)] + func start(walletIndex: Int) async { guard Self.config != nil else { return } - do { - if info == nil { - if connectTask == nil { - connectTask = Task { try await connect(walletIndex: walletIndex) } - } - try await connectTask?.value - connectTask = nil + if info == nil { + var connected = await tryConnect(walletIndex: walletIndex) + for delay in Self.startRetryDelays where !connected { + try? await Task.sleep(for: delay) + connected = await tryConnect(walletIndex: walletIndex) } + guard connected else { + Logger.error("Failed to start Arkade wallet", context: "ArkadeManager") + return + } + } + // A connected wallet is ready even when its first refresh times out; the next event or refresh catches up. + isReady = true + LightningService.shared.channelFreeRail = self + do { try await refresh() - await syncActivities() - LightningService.shared.channelFreeRail = self - do { - lightningAddress = try await bridge.lightningAddress()?.lightningAddress - } catch { - Logger.warn("Failed to get the Arkade Lightning address: \(error)", context: "ArkadeManager") + } catch { + Logger.warn("Failed to refresh Arkade state: \(error)", context: "ArkadeManager") + } + await syncActivities() + do { + lightningAddress = try await bridge.lightningAddress()?.lightningAddress + } catch { + Logger.warn("Failed to get the Arkade Lightning address: \(error)", context: "ArkadeManager") + } + } + + private func tryConnect(walletIndex: Int) async -> Bool { + do { + if connectTask == nil { + connectTask = Task { try await connect(walletIndex: walletIndex) } } + try await connectTask?.value + connectTask = nil + return true } catch { connectTask = nil - Logger.error("Failed to start Arkade wallet: \(error)", context: "ArkadeManager") + Logger.warn("Failed to connect the Arkade wallet: \(error)", context: "ArkadeManager") + return false } } From e661cda8bc73bb974dcd8a839f89a02f93791770 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 31 Oct 2026 16:50:48 +0100 Subject: [PATCH 08/15] feat: arkade dev builds on a local regtest stack --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Constants/Env.swift | 9 ++++++++ Bitkit/Managers/ArkadeBridge.swift | 11 +++++---- Bitkit/Managers/ArkadeManager.swift | 23 ++++++++++++++++--- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 199f27ad3..9a130b026 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-arkade.1"; + version = "0.8.0-arkade.2"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f97c76345..fd6973dd6 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "ec3f9cd24d2a445b4ccd6dd4c5c6cea8ec39a5e7", - "version" : "0.8.0-arkade.1" + "revision" : "27cd0c07f3e84d2e924ba9860a794335ddbdc5db", + "version" : "0.8.0-arkade.2" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4cf5d8bd3..e80323797 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -113,6 +113,15 @@ enum Env { configValue("E2E_LOCAL_HOST") ?? "127.0.0.1" } + /// The solver key of a local Arkade regtest stack; set, it turns Arkade on in dev builds. + static var arkadeRegtestSolverPubkey: String? { + configValue("ARKADE_REGTEST_SOLVER_PUBKEY") + } + + static var arkadeRegtestHost: String { + configValue("ARKADE_REGTEST_HOST") ?? "localhost" + } + private static var e2eHomegateUrl: String { infoPlistValue("E2E_HOMEGATE_URL") ?? "http://\(e2eLocalHost):6288" } diff --git a/Bitkit/Managers/ArkadeBridge.swift b/Bitkit/Managers/ArkadeBridge.swift index 489a9f912..eb00033c1 100644 --- a/Bitkit/Managers/ArkadeBridge.swift +++ b/Bitkit/Managers/ArkadeBridge.swift @@ -17,9 +17,10 @@ final class ArkadeBridge { } func start(_ params: ArkadeStartParams) async throws -> ArkadeInfo { - let network: ArkadeNetwork = switch params.network { - case "bitcoin": .bitcoin - case "mutinynet": .mutinynet + let network: ArkadeNetwork = switch (params.network, params.regtest) { + case ("bitcoin", _): .bitcoin + case ("mutinynet", _): .mutinynet + case let ("regtest", endpoints?): .regtest(endpoints: endpoints) default: throw ArkadeError.unsupported } let info = try await arkadeStart(params: BitkitCore.ArkadeStartParams( @@ -120,8 +121,10 @@ struct ArkadeStartParams { let dataDir: String let mnemonic: String let passphrase: String? - /// `bitcoin` or `mutinynet`. + /// `bitcoin`, `mutinynet` or `regtest`. let network: String + /// The local stack's endpoints when `network` is `regtest`. + let regtest: ArkadeRegtestEndpoints? let restore: Bool let backup: String? } diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift index e30ab4769..cbca4b887 100644 --- a/Bitkit/Managers/ArkadeManager.swift +++ b/Bitkit/Managers/ArkadeManager.swift @@ -1,3 +1,4 @@ +import BitkitCore import Combine import Foundation import LDKNode @@ -60,10 +61,23 @@ final class ArkadeManager: ChannelFreeLightningRail { info != nil } - /// Arkade runs on mainnet against Ark Labs' operator; bitkit-core holds its endpoints. Its test network has no - /// Lightning solver or LNURL server in bitkit-core, so dev builds keep Arkade off and use channels. + /// Arkade runs on mainnet against Ark Labs' operator, whose endpoints bitkit-core holds, and in dev builds against + /// a local regtest stack (ArkLabsHQ/arkade-regtest) when `ARKADE_REGTEST_SOLVER_PUBKEY` is set. Otherwise dev + /// builds keep Arkade off and use channels: Arkade's test network has no Lightning solver or LNURL server. nonisolated static var config: ArkadeConfig? { - Env.network == .bitcoin ? ArkadeConfig(network: "bitcoin") : nil + if Env.network == .bitcoin { + return ArkadeConfig(network: "bitcoin", regtest: nil) + } + guard Env.network == .regtest, let solver = Env.arkadeRegtestSolverPubkey else { return nil } + let host = Env.arkadeRegtestHost + return ArkadeConfig(network: "regtest", regtest: ArkadeRegtestEndpoints( + arkServerUrl: "http://\(host):7070", + esploraUrl: "http://\(host):3000/api", + lnurlUrl: "http://\(host):9090", + delegateUrl: "http://\(host):7012", + solverPubkey: solver, + solverRelays: ["ws://\(host):7777"] + )) } /// Waits between attempts to connect the Arkade wallet when the operator or chain source is slow. @@ -130,6 +144,7 @@ final class ArkadeManager: ChannelFreeLightningRail { mnemonic: mnemonic, passphrase: passphrase, network: config.network, + regtest: config.regtest, restore: !FileManager.default.fileExists(atPath: startedMarker.path), backup: backup ) @@ -526,6 +541,8 @@ final class ArkadeManager: ChannelFreeLightningRail { struct ArkadeConfig { let network: String + /// The local stack's endpoints when `network` is `regtest`. + let regtest: ArkadeRegtestEndpoints? } /// An on-chain deposit into Arkade: pending until it confirms and the wallet boards it into a batch. From b8277d77d138a1810307ee0a394e8fb65e6f847a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 31 Oct 2026 16:50:48 +0100 Subject: [PATCH 09/15] fix: arkade activity lists offline receives at their net amount --- Bitkit/Managers/ArkadeManager.swift | 13 ++++++++++++- BitkitTests/ArkadeManagerTests.swift | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift index cbca4b887..e6e50a849 100644 --- a/Bitkit/Managers/ArkadeManager.swift +++ b/Bitkit/Managers/ArkadeManager.swift @@ -419,6 +419,12 @@ final class ArkadeManager: ChannelFreeLightningRail { for swap in await listSwaps() { await recordSwap(swap) } + // The history watch reports only what arrives while the wallet runs, so record what arrived while it was off. + do { + try await recordReceives(bridge.history()) + } catch { + Logger.warn("Failed to record Arkade receives from history: \(error)", context: "ArkadeManager") + } } private func onEvent(_ event: ArkadeBridgeEvent) async { @@ -443,6 +449,10 @@ final class ArkadeManager: ChannelFreeLightningRail { /// payment straight to the Arkade address. Boarding and swap claims show up through their own records. private func onHistory(_ entries: [ArkadeHistoryEntry]) async throws { try await refresh() + try await recordReceives(entries) + } + + private func recordReceives(_ entries: [ArkadeHistoryEntry]) async throws { let swapClaims = await Set(listSwaps().filter(\.isReceive).compactMap(\.takeSat)) for entry in Self.offlineReceives(entries, swapClaims: swapClaims) { guard let arkTxid = entry.arkTxid else { continue } @@ -492,7 +502,8 @@ final class ArkadeManager: ChannelFreeLightningRail { id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: swap.bolt11), amountMsat: UInt64(max(0, swap.takeSat ?? 0)) * 1000, - feePaidMsat: UInt64(max(0, swap.feeSat ?? 0)) * 1000, + // A receive's solver fee is already out of `takeSat`; the activity row adds the fee to the amount. + feePaidMsat: swap.isSend ? UInt64(max(0, swap.feeSat ?? 0)) * 1000 : 0, direction: swap.isSend ? .outbound : .inbound, status: status, latestUpdateTimestamp: UInt64(max(0, timestamp)) diff --git a/BitkitTests/ArkadeManagerTests.swift b/BitkitTests/ArkadeManagerTests.swift index f78fdffb2..8ea6c1bbc 100644 --- a/BitkitTests/ArkadeManagerTests.swift +++ b/BitkitTests/ArkadeManagerTests.swift @@ -81,6 +81,13 @@ final class ArkadeManagerTests: XCTestCase { guard case .paymentFailed = ArkadeManager.nodeEvent(for: refunded) else { return XCTFail("Expected a failed payment") } } + func testListsAClaimedReceiveAtWhatReachedTheWallet() throws { + let claimed = ArkadeSwap(direction: "receive", outcome: "claimed", takeSat: 995, feeSat: 5, paymentHash: "ab") + let details = try XCTUnwrap(ArkadeManager.paymentDetails(for: claimed)) + XCTAssertEqual(details.amountMsat, 995_000) + XCTAssertEqual(details.feePaidMsat, 0) + } + func testRecordsASendAtOnceButAReceiveOnlyOnceClaimed() throws { let pendingSend = ArkadeSwap(direction: "send", outcome: "funded", takeSat: 1000, feeSat: 7, bolt11: "lnbc1", paymentHash: "dd") let details = try XCTUnwrap(ArkadeManager.paymentDetails(for: pendingSend)) From 87af1180a704966ce7749aeb9ab0594b471a7f7b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:05:07 +0100 Subject: [PATCH 10/15] fix: show an arkade receive when the balance read fails A claimed swap now raises its received event and records its activity before the wallet re-reads its balance, which reads the chain and can fail. --- Bitkit/Managers/ArkadeManager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/ArkadeManager.swift b/Bitkit/Managers/ArkadeManager.swift index e6e50a849..f154b5914 100644 --- a/Bitkit/Managers/ArkadeManager.swift +++ b/Bitkit/Managers/ArkadeManager.swift @@ -433,10 +433,11 @@ final class ArkadeManager: ChannelFreeLightningRail { case let .swap(swap): Logger.info("Arkade swap '\(swap.id ?? "")' is '\(swap.outcome ?? "")'", context: "ArkadeManager") await recordSwap(swap) - try await refresh() + // Before the refresh, which reads the chain and can fail, so a claimed swap still shows as received if let event = Self.nodeEvent(for: swap) { LightningService.shared.emitChannelFreeRailEvent(event) } + try await refresh() case let .history(entries): try await onHistory(entries) } @@ -448,8 +449,8 @@ final class ArkadeManager: ChannelFreeLightningRail { /// A payment that reached the wallet outside this client's swaps: an offline receive the LNURL server claimed, or a /// payment straight to the Arkade address. Boarding and swap claims show up through their own records. private func onHistory(_ entries: [ArkadeHistoryEntry]) async throws { - try await refresh() try await recordReceives(entries) + try await refresh() } private func recordReceives(_ entries: [ArkadeHistoryEntry]) async throws { From d26159afde8a82335ab8535ae77d0e5a8f68865c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:07:51 +0100 Subject: [PATCH 11/15] chore: bitkit-core 0.8.0-arkade.3 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 9a130b026..2c5beac53 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-arkade.2"; + version = "0.8.0-arkade.3"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fd6973dd6..120b423c5 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "27cd0c07f3e84d2e924ba9860a794335ddbdc5db", - "version" : "0.8.0-arkade.2" + "revision" : "50672ab1d816c5c1a9e0b730d9a7f473494d899a", + "version" : "0.8.0-arkade.3" } }, { From cd8ab64d0e275147a20b301258d0c6d59e54435f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 17:08:20 +0100 Subject: [PATCH 12/15] chore: bitkit-core 0.8.0-arkade.4 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 2c5beac53..bb3c26780 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-arkade.3"; + version = "0.8.0-arkade.4"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 120b423c5..06e0741ea 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "50672ab1d816c5c1a9e0b730d9a7f473494d899a", - "version" : "0.8.0-arkade.3" + "revision" : "3712146ccfd91b39da64900ba61a8849eb82aa7a", + "version" : "0.8.0-arkade.4" } }, { From 6eb0e7376d179e56078af95acb728637d72c1b64 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 17:45:05 +0100 Subject: [PATCH 13/15] chore: bitkit-core 0.8.0-arkade.5 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index bb3c26780..56a525287 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-arkade.4"; + version = "0.8.0-arkade.5"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 06e0741ea..8f2b1e8d2 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "3712146ccfd91b39da64900ba61a8849eb82aa7a", - "version" : "0.8.0-arkade.4" + "revision" : "cf421ab9d47231ea7c70b0539f00ca79e0681c04", + "version" : "0.8.0-arkade.5" } }, { From d79549b0bb6e6cb12a5d3c4c8d73b78cec577b85 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 21:06:15 +0100 Subject: [PATCH 14/15] fix: publish the arkade lightning address for paykit contacts --- .../PaykitPaymentRequestService.swift | 34 +++++--- .../PrivatePaykitService+Endpoints.swift | 16 +--- .../PrivatePaykitService+Invoices.swift | 25 ++++-- Bitkit/Services/PublicPaykitService.swift | 5 +- Bitkit/ViewModels/AppViewModel.swift | 3 + .../CreatePaymentRequestView.swift | 4 +- .../CreateSubscriptionView.swift | 6 +- .../PaykitPaymentRequestServiceTests.swift | 8 +- BitkitTests/PublicPaykitServiceTests.swift | 86 +++++++++++++++++++ 9 files changed, 146 insertions(+), 41 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 2c22469d2..4607135ff 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -670,7 +670,8 @@ struct PaykitPaymentRequestService { private let now: @Sendable () -> Date private let subscriptionNow: @Sendable () -> Date private let isPrivatePaymentPublishingEnabled: @Sendable () -> Bool - private let canReceiveLightning: @MainActor @Sendable () -> Bool + private let canReceiveBolt11: @MainActor @Sendable () -> Bool + private let canReceiveLnurl: @MainActor @Sendable () -> Bool private let marketRate: @MainActor @Sendable () -> PaykitExchangeRate? private let logWarning: @Sendable (String) -> Void private let incomingRejectionLog = IncomingPaykitPaymentRequestRejectionLog() @@ -682,9 +683,10 @@ struct PaykitPaymentRequestService { isPrivatePaymentPublishingEnabled: @escaping @Sendable () -> Bool = { UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) }, - canReceiveLightning: @escaping @MainActor @Sendable () -> Bool = { - LightningService.shared.channels?.contains(where: \.isUsable) ?? false || ArkadeManager.shared.isReady + canReceiveBolt11: @escaping @MainActor @Sendable () -> Bool = { + LightningService.shared.channels?.contains(where: \.isUsable) ?? false }, + canReceiveLnurl: @escaping @MainActor @Sendable () -> Bool = { ArkadeManager.shared.isReady }, marketRate: @escaping @MainActor @Sendable () -> PaykitExchangeRate? = { CurrencyService.shared.loadCachedRates()?.first { $0.quote == "USD" }.map { PaykitExchangeRate( price: $0.lastPrice, @@ -695,7 +697,8 @@ struct PaykitPaymentRequestService { } ) { self.sdk = sdk - self.canReceiveLightning = canReceiveLightning + self.canReceiveBolt11 = canReceiveBolt11 + self.canReceiveLnurl = canReceiveLnurl self.marketRate = marketRate self.now = now self.subscriptionNow = subscriptionNow ?? { SubscriptionClock.subscriptionDate(from: now()) } @@ -796,7 +799,7 @@ struct PaykitPaymentRequestService { ) async throws -> PaykitPaymentRequestTargetDiscovery { let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true, capabilityCheckedPublicKeys: []) guard isPrivatePaymentPublishingEnabled(), - await !Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning()).isEmpty else { return unavailable } + await !enabledPaymentEndpointIdentifiers().isEmpty else { return unavailable } let sdkPriority: PaykitSdkOperationLock.Priority = priority == .bulk ? .background : .interactive guard let identityStatus = try await sdk.identityStatus(priority: sdkPriority), identityStatus.capability == .privateLinkCapable, @@ -872,7 +875,7 @@ struct PaykitPaymentRequestService { savedPublicKeys: [String], expectedIdentity: String ) async throws -> PaykitPaymentRequest { - let enabled = await Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning()) + let enabled = await enabledPaymentEndpointIdentifiers() let acceptedPaymentEndpointIdentifiers = draft.acceptedPaymentEndpointIdentifiers.map { selected in enabled.filter { selected.contains($0) } } ?? enabled @@ -933,7 +936,7 @@ struct PaykitPaymentRequestService { ) async throws -> PaykitSubscription { let acceptedPaymentEndpointIdentifiers = await PaykitRequestPricing.subscriptionEndpoints( requested: draft.amount.asset, - available: Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning()), + available: enabledPaymentEndpointIdentifiers(), selected: draft.acceptedPaymentEndpointIdentifiers ) let validationDate = now() @@ -975,7 +978,7 @@ struct PaykitPaymentRequestService { try await validateBeforeProposing() guard await PaykitRequestPricing.subscriptionEndpoints( requested: draft.amount.asset, - available: Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning()), + available: enabledPaymentEndpointIdentifiers(), selected: draft.acceptedPaymentEndpointIdentifiers ) == acceptedPaymentEndpointIdentifiers else { throw PaykitPaymentRequestError.requestUnavailable @@ -1144,13 +1147,20 @@ struct PaykitPaymentRequestService { return subscription } - static func acceptedPaymentEndpointIdentifiers(canReceiveLightning: Bool) -> [String] { - PublicPaykitService.MethodId.publishableMethodIds.compactMap { methodId in + @MainActor + private func enabledPaymentEndpointIdentifiers() -> [String] { + Self.acceptedPaymentEndpointIdentifiers(canReceiveBolt11: canReceiveBolt11(), canReceiveLnurl: canReceiveLnurl()) + } + + /// A usable channel receives on a bolt11 invoice; Arkade receives on its Lightning address (LNURL). + static func acceptedPaymentEndpointIdentifiers(canReceiveBolt11: Bool, canReceiveLnurl: Bool) -> [String] { + PublicPaykitService.MethodId.payablePreferenceOrder.compactMap { methodId in if methodId == .usdtArbitrum { return PublicPaykitService.isUsdtPaymentOptionEnabled() ? methodId.rawValue : nil } - if methodId == .bitcoinLightningBolt11 { - return canReceiveLightning && PublicPaykitService.isLightningPaymentOptionEnabled() ? methodId.rawValue : nil + if methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl { + let canReceive = methodId == .bitcoinLightningBolt11 ? canReceiveBolt11 : canReceiveLnurl + return canReceive && PublicPaykitService.isLightningPaymentOptionEnabled() ? methodId.rawValue : nil } guard methodId.onchainNetwork == Env.network, PublicPaykitService.isOnchainPaymentOptionEnabled() diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index a289851b9..d9d1bc63f 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -74,20 +74,8 @@ extension PrivatePaykitService { if PublicPaykitService.isLightningPaymentOptionEnabled(), walletHasUsableChannels(wallet) { do { - let invoice = try await currentOrRotatedInvoice( - for: publicKey, - wallet: wallet, - forceRefresh: forceRefreshLightning - ) - let invoicePayload = try PublicPaykitService.serializePayload(value: invoice.bolt11) - endpoints.append( - PublicPaykitService.Endpoint( - methodId: .bitcoinLightningBolt11, - value: invoice.bolt11, - min: nil, - max: nil, - rawPayload: invoicePayload - ) + try await endpoints.append( + currentLightningEndpoint(for: publicKey, wallet: wallet, forceRefresh: forceRefreshLightning) ) } catch PrivatePaykitError.routeHintsUnavailable { Logger.warn("Private Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", context: "PrivatePaykit") diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 3fccc9563..24899937e 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -6,19 +6,20 @@ import UIKit // MARK: - Invoice Rotation extension PrivatePaykitService { - func currentOrRotatedInvoice( + /// The contact's private Lightning endpoint: its own bolt11 invoice, or the Arkade Lightning address (LNURL) that + /// Arkade answers an amountless receive with. + func currentLightningEndpoint( for publicKey: String, wallet: WalletViewModel, forceRefresh: Bool = false - ) async throws -> StoredInvoice { + ) async throws -> PublicPaykitService.Endpoint { if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { - return invoice + return try lightningEndpoint(.bitcoinLightningBolt11, value: invoice.bolt11) } let bolt11 = try await createVariableInvoice(wallet) - // Arkade answers an amountless receive with its Lightning address, which no private invoice can stand for. - guard !PublicPaykitService.isLnurl(bolt11) else { - throw PrivatePaykitError.routeHintsUnavailable + if PublicPaykitService.isLnurl(bolt11) { + return try lightningEndpoint(.bitcoinLightningLnurl, value: bolt11) } guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { throw PublicPaykitError.invalidPayload @@ -34,7 +35,17 @@ extension PrivatePaykitService { ) setLocalInvoice(invoice, publicKey: publicKey) persistState() - return invoice + return try lightningEndpoint(.bitcoinLightningBolt11, value: invoice.bolt11) + } + + private func lightningEndpoint(_ methodId: PublicPaykitService.MethodId, value: String) throws -> PublicPaykitService.Endpoint { + try PublicPaykitService.Endpoint( + methodId: methodId, + value: value, + min: nil, + max: nil, + rawPayload: PublicPaykitService.serializePayload(value: value) + ) } func rememberReceivedInvoicePaymentHash(_ paymentHash: String, publicKey: String) { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index ae64b3e8a..4401e70e4 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -585,14 +585,15 @@ enum PublicPaykitService { static func paymentRequest(from endpoints: [Endpoint]) -> String { guard let onchainEndpoint = MethodId.onchainPreferenceOrder.compactMap({ methodId in endpoints.first { $0.methodId == methodId } }).first, - let bolt11Endpoint = endpoints.first(where: { $0.methodId == .bitcoinLightningBolt11 }) + let lightningEndpoint = endpoints.first(where: { $0.methodId == .bitcoinLightningBolt11 }) ?? + endpoints.first(where: { $0.methodId == .bitcoinLightningLnurl }) else { return endpoints.first?.paymentRequest ?? "" } var allowedCharacters = CharacterSet.urlQueryAllowed allowedCharacters.remove(charactersIn: "?&=") - let lightningPaymentRequest = bolt11Endpoint.paymentRequest + let lightningPaymentRequest = lightningEndpoint.paymentRequest let encodedLightning = lightningPaymentRequest.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? lightningPaymentRequest return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(encodedLightning)" } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 04cbc5acd..01a5e6b4f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -32,8 +32,11 @@ struct ContactPaymentContext: Equatable { let isInitialSubscriptionPayment: Bool let endpoints: [PublicPaykitService.Endpoint] + /// A unified request carries an LNURL only as a `lightning` parameter, which the scan drops for on-chain, so a contact's + /// LNURL beside an on-chain address also goes through the amount view to keep both Spending and Savings. var requiresAssetSelection: Bool { endpoints.contains { $0.methodId == .usdtArbitrum } || + (endpoints.contains { $0.methodId == .bitcoinLightningLnurl } && endpoints.contains { $0.methodId.onchainNetwork != nil }) || incomingPaymentRequest.map { $0.amount.asset != .btc || $0.pricing.conversion != nil } == true } diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 5d56e8cdb..84854aaf0 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -558,7 +558,9 @@ struct PaymentRequestDetailsView: View { } private var enabledEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers( + canReceiveBolt11: wallet.hasUsableChannels, canReceiveLnurl: ArkadeManager.shared.isReady + ) } private var selectedEnabledEndpoints: [String] { diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index 08718c2d1..e716bb03f 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -120,8 +120,10 @@ struct CreateSubscriptionView: View { } private var availableEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) - .filter { settings.isUsdtEnabled || $0 != PublicPaykitService.MethodId.usdtArbitrum.rawValue } + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers( + canReceiveBolt11: wallet.hasUsableChannels, canReceiveLnurl: ArkadeManager.shared.isReady + ) + .filter { settings.isUsdtEnabled || $0 != PublicPaykitService.MethodId.usdtArbitrum.rawValue } } private var acceptedEndpoints: [String] { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 87c3d2f46..f3090de0f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -2939,7 +2939,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) let service = PaykitPaymentRequestService(sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, - canReceiveLightning: { true }, marketRate: { nil }) + canReceiveBolt11: { true }, canReceiveLnurl: { false }, marketRate: { nil }) do { let subscription = try await service.proposeSubscription( PaykitSubscriptionDraft(amount: PaykitAmount(asset: .usd, atomic: 500), name: "Monthly support", @@ -10026,7 +10026,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { presentationStore: PaymentRequestPresentationMemoryStore = PaymentRequestPresentationMemoryStore(), acceptanceStore: PaymentRequestPresentationMemoryStore? = nil, acceptedRecords: [PaymentRequestRecord] = [], - canReceiveLightning: Bool = true + canReceiveLightning: Bool = true, + canReceiveLnurl: Bool = false ) -> PaykitPaymentRequestManager { let now: @Sendable () -> Date = { clock.now() } let acceptanceStore = acceptanceStore ?? PaymentRequestPresentationMemoryStore(ids: Set(acceptedRecords.map { @@ -10041,7 +10042,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { now: now, subscriptionNow: subscriptionNow, isPrivatePaymentPublishingEnabled: { isPrivatePaymentPublishingEnabled }, - canReceiveLightning: { canReceiveLightning }, + canReceiveBolt11: { canReceiveLightning }, + canReceiveLnurl: { canReceiveLnurl }, marketRate: { PaykitExchangeRate(price: "100000", timestamp: now()) }, logWarning: { _ in } ), diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 5cbc41658..fb2214d02 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -142,6 +142,76 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(request, "lnurl1example") } + func testPaymentRequestCombinesOnchainAndLnurlEndpoints() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningLnurl, value: "lnurl1example"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"), + ]) + + XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnurl1example") + } + + func testContactWithLnurlBesideOnchainSelectsFundingSource() { + let publicKey = "pubky" + String(repeating: "y", count: 52) + let lnurl = endpoint(.bitcoinLightningLnurl, value: "lnurl1example") + let onchain = endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress") + + XCTAssertTrue(ContactPaymentContext(publicKey: publicKey, endpoints: [lnurl, onchain]).requiresAssetSelection) + XCTAssertFalse(ContactPaymentContext(publicKey: publicKey, endpoints: [lnurl]).requiresAssetSelection) + XCTAssertFalse(ContactPaymentContext( + publicKey: publicKey, endpoints: [endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), onchain] + ).requiresAssetSelection) + } + + func testAcceptedIdentifiersFollowTheLightningReceiveRail() { + UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + let bolt11 = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + let lnurl = PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + + let arkade = PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveBolt11: false, canReceiveLnurl: true) + XCTAssertTrue(arkade.contains(lnurl)) + XCTAssertFalse(arkade.contains(bolt11)) + + let channels = PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveBolt11: true, canReceiveLnurl: false) + XCTAssertTrue(channels.contains(bolt11)) + XCTAssertFalse(channels.contains(lnurl)) + + let none = PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveBolt11: false, canReceiveLnurl: false) + XCTAssertFalse(none.contains(bolt11) || none.contains(lnurl)) + } + + @MainActor + func testPrivateEndpointsPublishArkadeLightningAddress() async throws { + UserDefaults.standard.set(false, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + UserDefaults.standard.set(false, forKey: PublicPaykitService.usdtPaymentOptionEnabledKey) + let privateService = PrivatePaykitService() + let contact = "pubky" + String(repeating: "y", count: 52) + + let endpoints = try await privateService.buildLocalEndpoints(for: contact, wallet: ArkadeLightningAddressWallet()) + + XCTAssertEqual(endpoints.map(\.methodId), [.bitcoinLightningLnurl]) + XCTAssertEqual(endpoints.first?.value, ArkadeLightningAddressWallet.lightningAddress) + } + + func testPrivateReservationsCarryNoInvoiceDetailsForLnurl() async { + let privateService = PrivatePaykitService() + let contact = "pubky" + String(repeating: "y", count: 52) + await privateService.setEndpointTestInvoice( + .init(bolt11: "lnbcrt1stored", paymentHash: "stored-hash", expiresAt: 1_900_000_000), publicKey: contact + ) + + let reservations = await privateService.reservations(from: [ + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"), + endpoint(.bitcoinLightningLnurl, value: "lnurl1example"), + ], publicKey: contact) + + XCTAssertEqual(reservations.map(\.identifier), ["btc-bitcoin-p2wpkh", "btc-lightning-lnurl"]) + XCTAssertNil(reservations.last?.expiresAt) + XCTAssertNil(reservations.last?.attribution["payment_hash"]) + XCTAssertEqual(reservations.last?.attribution["counterparty"], contact) + } + func testOnchainMethodIdUsesAddressPrefixAndNetwork() { XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample", network: .bitcoin), .bitcoinOnchainP2tr) XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample", network: .testnet), .testnetOnchainP2wpkh) @@ -593,6 +663,22 @@ private final class EndpointPublicationWallet: WalletViewModel { } } +private final class ArkadeLightningAddressWallet: WalletViewModel { + static let lightningAddress = "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns" + + override var hasUsableChannels: Bool { + false + } + + override var canReceiveLightning: Bool { + true + } + + override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + Self.lightningAddress + } +} + private extension PrivatePaykitService { func setEndpointTestInvoice(_ invoice: StoredInvoice, publicKey: String) { state.contacts[publicKey] = ContactState() From cf03dc3ab4dc3afcfe5f72f55fd8996958b3312d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 23:21:27 +0100 Subject: [PATCH 15/15] chore: bitkit-core 0.8.0-arkade.6 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 56a525287..797ce6523 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-arkade.5"; + version = "0.8.0-arkade.6"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8f2b1e8d2..3cf0bc2c4 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "cf421ab9d47231ea7c70b0539f00ca79e0681c04", - "version" : "0.8.0-arkade.5" + "revision" : "b7117d877e66dc8357baf658de66c0fb9bf5029e", + "version" : "0.8.0-arkade.6" } }, {