From 74bc8298828a338c647ef91b340692c70b122f8b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 16:32:26 +0200 Subject: [PATCH 1/5] fix: add lsp refund address --- Bitkit/Models/BackupPayloads.swift | 12 +- Bitkit/Models/SettingsBackupConfig.swift | 1 + .../Localization/en.lproj/Localizable.strings | 2 +- Bitkit/Services/BackupService.swift | 4 +- .../BlocktankRefundAddressProvider.swift | 188 +++++++++ Bitkit/Services/LightningService.swift | 9 +- Bitkit/ViewModels/BlocktankViewModel.swift | 97 ++++- Bitkit/ViewModels/SettingsViewModel.swift | 44 +- .../Advanced/AddressTypePreferenceView.swift | 17 +- BitkitTests/AddressTypeSettingsTests.swift | 48 ++- .../BlocktankRefundAddressProviderTests.swift | 378 ++++++++++++++++++ changelog.d/next/732.fixed.md | 1 + 12 files changed, 736 insertions(+), 65 deletions(-) create mode 100644 Bitkit/Services/BlocktankRefundAddressProvider.swift create mode 100644 BitkitTests/BlocktankRefundAddressProviderTests.swift create mode 100644 changelog.d/next/732.fixed.md diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 4df1695e8..a8d8b4ead 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -54,6 +54,11 @@ struct PubkySessionBackupV1: Codable, Equatable { let sessionSecret: String? } +struct BlocktankRefundAddress: Codable, Equatable, Sendable { + let address: String + let index: UInt32 +} + struct AppCacheData: Codable { let hasSeenContactsIntro: Bool let hasSeenProfileIntro: Bool @@ -72,6 +77,7 @@ struct AppCacheData: Codable { let dismissedSuggestions: [String] let lastUsedTags: [String] let quickPayLedger: QuickPayLedger? + let blocktankRefundAddress: BlocktankRefundAddress? init( hasSeenContactsIntro: Bool, @@ -90,7 +96,8 @@ struct AppCacheData: Codable { highBalanceIgnoreTimestamp: TimeInterval, dismissedSuggestions: [String], lastUsedTags: [String], - quickPayLedger: QuickPayLedger? = nil + quickPayLedger: QuickPayLedger? = nil, + blocktankRefundAddress: BlocktankRefundAddress? = nil ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro @@ -109,6 +116,7 @@ struct AppCacheData: Codable { self.dismissedSuggestions = dismissedSuggestions self.lastUsedTags = lastUsedTags self.quickPayLedger = quickPayLedger + self.blocktankRefundAddress = blocktankRefundAddress } init(from decoder: Decoder) throws { @@ -130,6 +138,7 @@ struct AppCacheData: Codable { dismissedSuggestions = try c.decodeIfPresent([String].self, forKey: .dismissedSuggestions) ?? [] lastUsedTags = try c.decodeIfPresent([String].self, forKey: .lastUsedTags) ?? [] quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger) + blocktankRefundAddress = try c.decodeIfPresent(BlocktankRefundAddress.self, forKey: .blocktankRefundAddress) } private enum CodingKeys: String, CodingKey { @@ -139,6 +148,7 @@ struct AppCacheData: Codable { case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp case dismissedSuggestions, lastUsedTags case quickPayLedger + case blocktankRefundAddress } } diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 591c04979..c899e678c 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -33,6 +33,7 @@ enum SettingsBackupConfig { "dismissedSuggestions", "lastUsedTags", "quickPayLedger", + BlocktankRefundAddressStore.key, ] static let settingsKeyTypes: [String: SettingKeyType] = [ diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 2344930f8..c0b904606 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -907,7 +907,7 @@ "settings__adv__addr_type_monitored_updated_title" = "Settings Updated"; "settings__adv__addr_type_monitored_updated_desc" = "Address monitoring settings applied."; "settings__adv__addr_type_cannot_disable_title" = "Cannot Disable"; -"settings__adv__addr_type_cannot_disable_native_desc" = "At least one Native SegWit or Taproot wallet is required for Lightning channels."; +"settings__adv__addr_type_cannot_disable_native_desc" = "Native SegWit monitoring is required to detect automatic Blocktank refunds."; "settings__adv__addr_type_cannot_disable_balance_desc" = "{type} addresses have balance."; "settings__adv__addr_type_monitored_failed_desc" = "Could not update monitoring settings. Please try again."; "settings__adv__addr_type_currently_selected" = "Currently selected"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 82a27728f..bbb13edbc 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -255,7 +255,7 @@ class BackupService { categoriesNeedingRewrite.insert(.metadata) } - await SettingsViewModel.shared.restoreAppCacheData(payload.cache) + try await SettingsViewModel.shared.restoreAppCacheData(payload.cache) do { try await PubkyProfileManager.restoreSessionBackupState(payload.pubkySession) @@ -768,7 +768,7 @@ class BackupService { case .metadata: let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) - let cache = await SettingsViewModel.shared.getAppCacheData() + let cache = try await SettingsViewModel.shared.getAppCacheData() let pubkySession = try PubkyProfileManager.snapshotSessionBackupState() let pubkyContactProfileOverrides = ContactsManager.backupContactProfileOverrides() diff --git a/Bitkit/Services/BlocktankRefundAddressProvider.swift b/Bitkit/Services/BlocktankRefundAddressProvider.swift new file mode 100644 index 000000000..30b2c424d --- /dev/null +++ b/Bitkit/Services/BlocktankRefundAddressProvider.swift @@ -0,0 +1,188 @@ +import Foundation +import LDKNode + +enum BlocktankRefundAddressError: LocalizedError, Equatable { + case invalidCache + case invalidAddress + case indexOutOfRange(UInt32) + case ownershipMismatch + case persistenceFailed + + var errorDescription: String? { + switch self { + case .invalidCache: + "The saved Blocktank refund address is invalid." + case .invalidAddress: + "The Blocktank refund address is empty." + case let .indexOutOfRange(index): + "The Blocktank refund address index is out of range: \(index)." + case .ownershipMismatch: + "The saved Blocktank refund address does not belong to the active wallet and network." + case .persistenceFailed: + "The Blocktank refund address could not be saved." + } + } +} + +struct BlocktankRefundAddressStore { + static let key = "blocktankRefundAddress" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() throws -> BlocktankRefundAddress? { + guard defaults.object(forKey: Self.key) != nil else { return nil } + guard let data = defaults.data(forKey: Self.key) else { + throw BlocktankRefundAddressError.invalidCache + } + + do { + return try JSONDecoder().decode(BlocktankRefundAddress.self, from: data) + } catch { + throw BlocktankRefundAddressError.invalidCache + } + } + + func save(_ value: BlocktankRefundAddress) throws { + let data = try JSONEncoder().encode(value) + defaults.set(data, forKey: Self.key) + + guard try load() == value else { + throw BlocktankRefundAddressError.persistenceFailed + } + } + + func clear() { + defaults.removeObject(forKey: Self.key) + } +} + +@MainActor +protocol BlocktankRefundAddressProviding: AnyObject { + func addressForOrder() async throws -> String +} + +@MainActor +final class BlocktankRefundAddressProvider: BlocktankRefundAddressProviding { + static let maximumExternalIndex = UInt32(Int32.max) + + typealias Load = () throws -> BlocktankRefundAddress? + typealias Save = (BlocktankRefundAddress) throws -> Void + typealias Lookup = (UInt32) async throws -> BlocktankRefundAddress + typealias Reveal = (UInt32) async throws -> Void + typealias IsUsed = (String) async throws -> Bool + typealias Allocate = () async throws -> BlocktankRefundAddress + + private let load: Load + private let save: Save + private let lookup: Lookup + private let reveal: Reveal + private let isUsed: IsUsed + private let allocate: Allocate + private var inFlight: Task? + + init( + load: @escaping Load, + save: @escaping Save, + lookup: @escaping Lookup, + reveal: @escaping Reveal, + isUsed: @escaping IsUsed, + allocate: @escaping Allocate + ) { + self.load = load + self.save = save + self.lookup = lookup + self.reveal = reveal + self.isUsed = isUsed + self.allocate = allocate + } + + convenience init( + lightningService: LightningService, + utilityService: UtilityService, + store: BlocktankRefundAddressStore = .init() + ) { + self.init( + load: { try store.load() }, + save: { try store.save($0) }, + lookup: { index in + let info = try await lightningService.addressInfoForType( + .nativeSegwit, + keychain: .external, + atIndex: index + ) + return BlocktankRefundAddress(address: info.address, index: info.index) + }, + reveal: { index in + try await lightningService.revealReceiveAddresses(to: index, forType: .nativeSegwit) + }, + isUsed: { address in + try await utilityService.isAddressUsed(address: address) + }, + allocate: { + let info = try await lightningService.newAddressInfoForType(.nativeSegwit) + return BlocktankRefundAddress(address: info.address, index: info.index) + } + ) + } + + func addressForOrder() async throws -> String { + if let inFlight { + return try await inFlight.value + } + + let operation = Task { @MainActor [load, save, lookup, reveal, isUsed, allocate] in + try await Self.resolve( + load: load, + save: save, + lookup: lookup, + reveal: reveal, + isUsed: isUsed, + allocate: allocate + ) + } + inFlight = operation + defer { inFlight = nil } + return try await operation.value + } + + private static func resolve( + load: Load, + save: Save, + lookup: Lookup, + reveal: Reveal, + isUsed: IsUsed, + allocate: Allocate + ) async throws -> String { + if let cached = try load() { + try validate(cached) + + let derived = try await lookup(cached.index) + guard derived == cached else { + throw BlocktankRefundAddressError.ownershipMismatch + } + + try await reveal(cached.index) + if try await isUsed(cached.address) == false { + return cached.address + } + } + + let generated = try await allocate() + try validate(generated) + try save(generated) + return generated.address + } + + private static func validate(_ value: BlocktankRefundAddress) throws { + guard !value.address.isEmpty else { + throw BlocktankRefundAddressError.invalidAddress + } + guard value.index <= maximumExternalIndex else { + throw BlocktankRefundAddressError.indexOutOfRange(value.index) + } + } +} diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 3dec793eb..bf2b3bd7f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1295,13 +1295,18 @@ extension LightningService { return totalFundable } - /// Reads selected and monitored address types from UserDefaults. Use when calling from UI/balance flow. + /// Reads selected and monitored address types from UserDefaults and keeps native SegWit enabled + /// so delayed automatic Blocktank refunds remain detectable. static func addressTypeStateFromUserDefaults(_ defaults: UserDefaults = .standard) -> (selectedType: LDKNode.AddressType, monitoredTypes: [LDKNode.AddressType]) { let selectedType = LDKNode.AddressType.fromStorage(defaults.string(forKey: "selectedAddressType")) let monitoredString = defaults.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" - let monitoredTypes = LDKNode.AddressType.parseCommaSeparated(monitoredString) + var monitoredTypes = LDKNode.AddressType.parseCommaSeparated(monitoredString) + if !monitoredTypes.contains(.nativeSegwit) { + monitoredTypes.append(.nativeSegwit) + defaults.set(monitoredTypes.map(\.stringValue).joined(separator: ","), forKey: "addressTypesToMonitor") + } return (selectedType, monitoredTypes) } diff --git a/Bitkit/ViewModels/BlocktankViewModel.swift b/Bitkit/ViewModels/BlocktankViewModel.swift index 1c9f197c0..913414c7a 100644 --- a/Bitkit/ViewModels/BlocktankViewModel.swift +++ b/Bitkit/ViewModels/BlocktankViewModel.swift @@ -1,6 +1,47 @@ import BitkitCore import SwiftUI +struct BlocktankOrderClient { + typealias Submit = (UInt64, UInt32, CreateOrderOptions) async throws -> IBtOrder + typealias Estimate = (UInt64, UInt32, CreateOrderOptions) async throws -> IBtEstimateFeeResponse2 + + let nodeId: () -> String? + let sign: (String) async throws -> String + let submit: Submit + let estimate: Estimate + + init(coreService: CoreService, lightningService: LightningService) { + nodeId = { lightningService.nodeId } + sign = { try await lightningService.sign(message: $0) } + submit = { lspBalanceSat, channelExpiryWeeks, options in + try await coreService.blocktank.newOrder( + lspBalanceSat: lspBalanceSat, + channelExpiryWeeks: channelExpiryWeeks, + options: options + ) + } + estimate = { lspBalanceSat, channelExpiryWeeks, options in + try await coreService.blocktank.estimateFee( + lspBalanceSat: lspBalanceSat, + channelExpiryWeeks: channelExpiryWeeks, + options: options + ) + } + } + + init( + nodeId: @escaping () -> String?, + sign: @escaping (String) async throws -> String, + submit: @escaping Submit, + estimate: @escaping Estimate + ) { + self.nodeId = nodeId + self.sign = sign + self.submit = submit + self.estimate = estimate + } +} + @MainActor class BlocktankViewModel: ObservableObject { @Published var orders: [IBtOrder]? = nil @@ -23,20 +64,32 @@ class BlocktankViewModel: ObservableObject { private let coreService: CoreService private let lightningService: LightningService private let currencyService: CurrencyService + private let orderClient: BlocktankOrderClient + private let refundAddressProvider: any BlocktankRefundAddressProviding private var refreshTimer: Timer? private var refreshTask: Task? init( coreService: CoreService = .shared, lightningService: LightningService = .shared, - currencyService: CurrencyService = .shared + currencyService: CurrencyService = .shared, + orderClient: BlocktankOrderClient? = nil, + refundAddressProvider: (any BlocktankRefundAddressProviding)? = nil, + startPolling: Bool = true ) { self.coreService = coreService self.lightningService = lightningService self.currencyService = currencyService + self.orderClient = orderClient ?? BlocktankOrderClient(coreService: coreService, lightningService: lightningService) + self.refundAddressProvider = refundAddressProvider ?? BlocktankRefundAddressProvider( + lightningService: lightningService, + utilityService: coreService.utility + ) - Task { try? await refreshInfo() } - startPolling() + if startPolling { + Task { try? await refreshInfo() } + self.startPolling() + } } deinit { @@ -144,15 +197,22 @@ class BlocktankViewModel: ObservableObject { Logger.warn("Has not refreshed Blocktank info yet, skipping validation of limits") } - let options = try await defaultCreateOrderOptions(clientBalanceSat: clientBalance) - - Logger.debug("Buying channel with lspBalanceSat: \(finalReceivingBalanceSats) and options: \(options)") + guard orderClient.nodeId() != nil else { + throw CustomServiceError.nodeNotStarted + } + try Task.checkCancellation() + let refundAddress = try await refundAddressProvider.addressForOrder() + let options = try await defaultCreateOrderOptions( + clientBalanceSat: clientBalance, + refundOnchainAddress: refundAddress + ) - return try await coreService.blocktank.newOrder( - lspBalanceSat: finalReceivingBalanceSats, - channelExpiryWeeks: defaultChannelExpiryWeeks, - options: options + Logger.debug( + "Buying channel with lspBalanceSat: \(finalReceivingBalanceSats), clientBalanceSat: \(clientBalance), expiryWeeks: \(defaultChannelExpiryWeeks)" ) + + try Task.checkCancellation() + return try await orderClient.submit(finalReceivingBalanceSats, defaultChannelExpiryWeeks, options) } func openChannel(orderId: String) async throws -> IBtOrder { @@ -172,11 +232,7 @@ class BlocktankViewModel: ObservableObject { ) { let options = try await defaultCreateOrderOptions(clientBalanceSat: clientBalance) - let estimate = try await coreService.blocktank.estimateFee( - lspBalanceSat: lspBalance, - channelExpiryWeeks: defaultChannelExpiryWeeks, - options: options - ) + let estimate = try await orderClient.estimate(lspBalance, defaultChannelExpiryWeeks, options) return ( feeSat: estimate.feeSat, @@ -186,13 +242,16 @@ class BlocktankViewModel: ObservableObject { } /// Creates default options for channel creation or fee estimation - private func defaultCreateOrderOptions(clientBalanceSat: UInt64) async throws -> CreateOrderOptions { - guard let nodeId = lightningService.nodeId else { + private func defaultCreateOrderOptions( + clientBalanceSat: UInt64, + refundOnchainAddress: String? = nil + ) async throws -> CreateOrderOptions { + guard let nodeId = orderClient.nodeId() else { throw CustomServiceError.nodeNotStarted } let timestamp = Date().formatted(.iso8601) - let signature = try await lightningService.sign(message: "channelOpen-\(timestamp)") + let signature = try await orderClient.sign("channelOpen-\(timestamp)") return CreateOrderOptions( clientBalanceSat: clientBalanceSat, @@ -206,7 +265,7 @@ class BlocktankViewModel: ObservableObject { clientNodeId: nodeId, signature: signature, timestamp: timestamp, - refundOnchainAddress: nil, + refundOnchainAddress: refundOnchainAddress, announceChannel: false ) } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 78ed97fb3..42f7f96fd 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -238,6 +238,7 @@ class SettingsViewModel: NSObject, ObservableObject { _coinSelectionAlgorithm = CoinSelectionAlgorithm.branchAndBound.stringValue _selectedAddressType = "nativeSegwit" _addressTypesToMonitor = "nativeSegwit" + BlocktankRefundAddressStore().clear() pinEnabled = false isChangingAddressType = false restoredMonitoredTypesFromBackup = false @@ -304,9 +305,6 @@ class SettingsViewModel: NSObject, ObservableObject { } /// Address Type Settings - /// Address types that support native SegWit scripts (required for Lightning). - private static let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] - @AppStorage("selectedAddressType") private var _selectedAddressType: String = "nativeSegwit" @AppStorage("addressTypesToMonitor") private var _addressTypesToMonitor: String = "nativeSegwit" @@ -352,6 +350,7 @@ class SettingsViewModel: NSObject, ObservableObject { func setMonitoring(_ addressType: AddressScriptType, enabled: Bool, wallet: WalletViewModel? = nil) async -> Bool { guard !isChangingAddressType else { return false } + guard enabled || addressType != .nativeSegwit else { return false } isChangingAddressType = true lastAddressTypeError = nil @@ -400,11 +399,6 @@ class SettingsViewModel: NSObject, ObservableObject { return false } - let remainingNativeWitness = current.filter { $0 != addressType && Self.nativeWitnessTypes.contains($0) } - if remainingNativeWitness.isEmpty { - return false - } - current.removeAll { $0 == addressType } addressTypesToMonitor = current @@ -452,6 +446,7 @@ class SettingsViewModel: NSObject, ObservableObject { let nodeMonitored = lightningService.listMonitoredAddressTypes() var combined = Set(nodeMonitored) combined.insert(selectedAddressType) + combined.insert(.nativeSegwit) addressTypesToMonitor = AddressScriptType.allAddressTypes.filter { combined.contains($0) } } @@ -490,12 +485,9 @@ class SettingsViewModel: NSObject, ObservableObject { } } - // Ensure at least one native witness type - if !newMonitored.contains(where: { Self.nativeWitnessTypes.contains($0) }) { - if !newMonitored.contains(.nativeSegwit) { - newMonitored.append(.nativeSegwit) - changed = true - } + if !newMonitored.contains(.nativeSegwit) { + newMonitored.append(.nativeSegwit) + changed = true } guard changed else { return } @@ -520,12 +512,9 @@ class SettingsViewModel: NSObject, ObservableObject { } } - /// True if disabling this would leave no native witness wallet (required for Lightning). - func isLastRequiredNativeWitnessWallet(_ addressType: AddressScriptType) -> Bool { - guard Self.nativeWitnessTypes.contains(addressType) else { return false } - - let remainingNativeWitness = addressTypesToMonitor.filter { $0 != addressType && Self.nativeWitnessTypes.contains($0) } - return remainingNativeWitness.isEmpty + /// Native SegWit monitoring is required to detect delayed automatic Blocktank refunds. + func isRequiredRefundAddressType(_ addressType: AddressScriptType) -> Bool { + addressType == .nativeSegwit } var selectedAddressType: AddressScriptType { @@ -832,6 +821,7 @@ class SettingsViewModel: NSObject, ObservableObject { } syncAppStorageFromDefaults() + ensureMonitoring(.nativeSegwit) let restoredMonitored = addressTypesToMonitor let restoredPrimary = selectedAddressType @@ -866,8 +856,9 @@ class SettingsViewModel: NSObject, ObservableObject { } /// Gets the current app cache data for backup - func getAppCacheData() -> AppCacheData { + func getAppCacheData() throws -> AppCacheData { let spend = QuickPaySpendStore.shared.backupSnapshot() + let refundAddress = try BlocktankRefundAddressStore().load() return AppCacheData( hasSeenContactsIntro: defaults.bool(forKey: "hasSeenContactsIntro"), hasSeenProfileIntro: defaults.bool(forKey: "hasSeenProfileIntro"), @@ -885,12 +876,13 @@ class SettingsViewModel: NSObject, ObservableObject { highBalanceIgnoreTimestamp: defaults.double(forKey: "highBalanceIgnoreTimestamp"), dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [], lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], - quickPayLedger: spend + quickPayLedger: spend, + blocktankRefundAddress: refundAddress ) } /// Restores app cache data from backup - func restoreAppCacheData(_ cache: AppCacheData) { + func restoreAppCacheData(_ cache: AppCacheData) throws { defaults.set(cache.hasSeenContactsIntro, forKey: "hasSeenContactsIntro") defaults.set(cache.hasSeenProfileIntro, forKey: "hasSeenProfileIntro") defaults.set(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") @@ -908,5 +900,11 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.dismissedSuggestions, forKey: "dismissedSuggestions") defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") QuickPaySpendStore.shared.restoreFromBackup(ledger: cache.quickPayLedger) + let refundAddressStore = BlocktankRefundAddressStore() + if let refundAddress = cache.blocktankRefundAddress { + try refundAddressStore.save(refundAddress) + } else { + refundAddressStore.clear() + } } } diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index ab969a639..3505b2433 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -39,6 +39,7 @@ struct MonitoredAddressTypeToggle: View { let addressType: AddressScriptType let isMonitored: Bool let isSelectedType: Bool + let isRequiredRefundType: Bool let onToggle: (Bool) -> Void private var toggleId: String { @@ -48,7 +49,7 @@ struct MonitoredAddressTypeToggle: View { var body: some View { VStack(alignment: .leading, spacing: 0) { Button(action: { - if !isSelectedType { + if !isSelectedType, !isRequiredRefundType { onToggle(!isMonitored) } }) { @@ -70,10 +71,15 @@ struct MonitoredAddressTypeToggle: View { .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .disabled(isSelectedType) - .opacity(isSelectedType ? 0.5 : 1.0) + .disabled(isSelectedType || isRequiredRefundType) + .opacity((isSelectedType || isRequiredRefundType) ? 0.5 : 1.0) .accessibilityIdentifier(toggleId) + if isRequiredRefundType { + BodySText(t("settings__adv__addr_type_cannot_disable_native_desc"), textColor: .textSecondary) + .padding(.bottom, 8) + } + Divider() } } @@ -168,7 +174,8 @@ struct AddressTypePreferenceView: View { MonitoredAddressTypeToggle( addressType: addressType, isMonitored: settingsViewModel.isMonitoring(addressType), - isSelectedType: settingsViewModel.selectedAddressType == addressType + isSelectedType: settingsViewModel.selectedAddressType == addressType, + isRequiredRefundType: settingsViewModel.isRequiredRefundAddressType(addressType) ) { enabled in guard !settingsViewModel.isChangingAddressType else { return } @@ -184,7 +191,7 @@ struct AddressTypePreferenceView: View { description: t("settings__adv__addr_type_monitored_updated_desc") ) } else if !enabled { - if settingsViewModel.isLastRequiredNativeWitnessWallet(addressType) { + if settingsViewModel.isRequiredRefundAddressType(addressType) { app.toast( type: .error, title: t("settings__adv__addr_type_cannot_disable_title"), diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index 54b2b215c..7b1c7cdb4 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -122,32 +122,46 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) } - // MARK: - isLastRequiredNativeWitnessWallet + // MARK: - Required refund address type - func testIsLastRequiredNativeWitnessWalletWhenOnlyNativeSegwit() { + func testNativeSegwitIsAlwaysRequiredForRefundMonitoring() { settings.addressTypesToMonitor = [.nativeSegwit] - XCTAssertTrue(settings.isLastRequiredNativeWitnessWallet(.nativeSegwit)) + XCTAssertTrue(settings.isRequiredRefundAddressType(.nativeSegwit)) } - func testIsLastRequiredNativeWitnessWalletWhenOnlyTaproot() { + func testTaprootDoesNotReplaceNativeSegwitRefundMonitoring() { settings.addressTypesToMonitor = [.taproot] - XCTAssertTrue(settings.isLastRequiredNativeWitnessWallet(.taproot)) + XCTAssertFalse(settings.isRequiredRefundAddressType(.taproot)) } - func testIsLastRequiredNativeWitnessWalletFalseForLegacy() { + func testLegacyIsNotRequiredForRefundMonitoring() { settings.addressTypesToMonitor = [.legacy] - XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.legacy)) + XCTAssertFalse(settings.isRequiredRefundAddressType(.legacy)) } - func testIsLastRequiredNativeWitnessWalletFalseForNestedSegwit() { + func testNestedSegwitIsNotRequiredForRefundMonitoring() { settings.addressTypesToMonitor = [.nestedSegwit] - XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.nestedSegwit)) + XCTAssertFalse(settings.isRequiredRefundAddressType(.nestedSegwit)) } - func testIsLastRequiredNativeWitnessWalletFalseWhenOtherNativeWitnessExists() { + func testNativeSegwitRemainsRequiredWhenTaprootIsMonitored() { settings.addressTypesToMonitor = [.nativeSegwit, .taproot] - XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.nativeSegwit)) - XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.taproot)) + XCTAssertTrue(settings.isRequiredRefundAddressType(.nativeSegwit)) + XCTAssertFalse(settings.isRequiredRefundAddressType(.taproot)) + } + + func testStartupStateNormalizesTaprootOnlyMonitoring() throws { + let suiteName = UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set("taproot", forKey: "selectedAddressType") + defaults.set("taproot", forKey: "addressTypesToMonitor") + + let state = LightningService.addressTypeStateFromUserDefaults(defaults) + + XCTAssertEqual(state.selectedType, .taproot) + XCTAssertEqual(state.monitoredTypes, [.taproot, .nativeSegwit]) + XCTAssertEqual(defaults.string(forKey: "addressTypesToMonitor"), "taproot,nativeSegwit") } // MARK: - resetToDefaults @@ -187,6 +201,16 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(UserDefaults.standard.string(forKey: "addressTypesToMonitor"), "nativeSegwit,taproot") } + func testRestoreSettingsDictionaryNormalizesTaprootOnlyMonitoring() { + settings.restoreSettingsDictionary([ + "selectedAddressType": "taproot", + "addressTypesToMonitor": "taproot", + ]) + + XCTAssertEqual(settings.selectedAddressType, .taproot) + XCTAssertEqual(settings.addressTypesToMonitor, [.taproot, .nativeSegwit]) + } + func testRestoreSettingsDictionaryFiltersInvalidAddressTypes() { // Restore writes raw string; parseAddressTypesString filters invalid when reading let dict: [String: Any] = [ diff --git a/BitkitTests/BlocktankRefundAddressProviderTests.swift b/BitkitTests/BlocktankRefundAddressProviderTests.swift new file mode 100644 index 000000000..ce1f31d8e --- /dev/null +++ b/BitkitTests/BlocktankRefundAddressProviderTests.swift @@ -0,0 +1,378 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class BlocktankRefundAddressProviderTests: XCTestCase { + private enum StubError: Error { + case lookup + case reveal + case persist + case estimate + case submit + } + + private final class State { + var cached: BlocktankRefundAddress? + var usedAddresses: Set = [] + var allocationCount = 0 + var lookupCount = 0 + var revealCount = 0 + } + + private final class StubRefundProvider: BlocktankRefundAddressProviding { + var callCount = 0 + var result: Result + + init(result: Result) { + self.result = result + } + + func addressForOrder() async throws -> String { + callCount += 1 + return try result.get() + } + } + + override func setUp() { + super.setUp() + BlocktankRefundAddressStore().clear() + } + + override func tearDown() { + BlocktankRefundAddressStore().clear() + super.tearDown() + } + + private func makeProvider( + state: State, + lookup: ((UInt32) async throws -> BlocktankRefundAddress)? = nil, + reveal: ((UInt32) async throws -> Void)? = nil, + save: ((BlocktankRefundAddress) throws -> Void)? = nil, + allocationDelayNanoseconds: UInt64 = 0 + ) -> BlocktankRefundAddressProvider { + BlocktankRefundAddressProvider( + load: { state.cached }, + save: save ?? { state.cached = $0 }, + lookup: lookup ?? { index in + state.lookupCount += 1 + guard let cached = state.cached else { throw StubError.lookup } + return BlocktankRefundAddress(address: cached.address, index: index) + }, + reveal: reveal ?? { _ in state.revealCount += 1 }, + isUsed: { state.usedAddresses.contains($0) }, + allocate: { + if allocationDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: allocationDelayNanoseconds) + } + let index = UInt32(state.allocationCount) + state.allocationCount += 1 + return BlocktankRefundAddress(address: "refund-\(index)", index: index) + } + ) + } + + func testUnpaidAndFailedOrdersReuseOneAddressAcrossProviderRestarts() async throws { + let state = State() + + for _ in 0 ..< 8 { + let provider = makeProvider(state: state) + let address = try await provider.addressForOrder() + XCTAssertEqual(address, "refund-0") + } + + XCTAssertEqual(state.allocationCount, 1) + XCTAssertEqual(state.cached, BlocktankRefundAddress(address: "refund-0", index: 0)) + } + + func testRecordedPaymentRotatesExactlyOnce() async throws { + let state = State() + let provider = makeProvider(state: state) + + let firstAddress = try await provider.addressForOrder() + XCTAssertEqual(firstAddress, "refund-0") + state.usedAddresses.insert("refund-0") + + let rotatedAddress = try await provider.addressForOrder() + let reusedAddress = try await provider.addressForOrder() + XCTAssertEqual(rotatedAddress, "refund-1") + XCTAssertEqual(reusedAddress, "refund-1") + XCTAssertEqual(state.allocationCount, 2) + XCTAssertEqual(state.cached, BlocktankRefundAddress(address: "refund-1", index: 1)) + } + + func testConcurrentCallsCoalesceOneAllocation() async throws { + let state = State() + let provider = makeProvider(state: state, allocationDelayNanoseconds: 50_000_000) + + async let first = provider.addressForOrder() + async let second = provider.addressForOrder() + async let third = provider.addressForOrder() + let addresses = try await [first, second, third] + + XCTAssertEqual(addresses, ["refund-0", "refund-0", "refund-0"]) + XCTAssertEqual(state.allocationCount, 1) + } + + func testCachedAddressIsLookedUpAndRevealedBeforeReuse() async throws { + let state = State() + state.cached = BlocktankRefundAddress(address: "refund-7", index: 7) + let provider = makeProvider(state: state) + + let address = try await provider.addressForOrder() + XCTAssertEqual(address, "refund-7") + XCTAssertEqual(state.lookupCount, 1) + XCTAssertEqual(state.revealCount, 1) + XCTAssertEqual(state.allocationCount, 0) + } + + func testOwnershipMismatchIsRejected() async { + let state = State() + state.cached = BlocktankRefundAddress(address: "other-wallet", index: 7) + let provider = makeProvider(state: state, lookup: { index in + BlocktankRefundAddress(address: "active-wallet", index: index) + }) + + await XCTAssertThrowsErrorAsync({ try await provider.addressForOrder() }) { error in + XCTAssertEqual(error as? BlocktankRefundAddressError, .ownershipMismatch) + } + XCTAssertEqual(state.allocationCount, 0) + } + + func testHardenedIndexIsRejected() async { + let state = State() + state.cached = BlocktankRefundAddress(address: "refund", index: UInt32(Int32.max) + 1) + let provider = makeProvider(state: state) + + await XCTAssertThrowsErrorAsync({ try await provider.addressForOrder() }) { error in + XCTAssertEqual(error as? BlocktankRefundAddressError, .indexOutOfRange(UInt32(Int32.max) + 1)) + } + XCTAssertEqual(state.lookupCount, 0) + XCTAssertEqual(state.allocationCount, 0) + } + + func testLookupFailureDoesNotAllocate() async { + let state = State() + state.cached = BlocktankRefundAddress(address: "refund", index: 1) + let provider = makeProvider(state: state, lookup: { _ in throw StubError.lookup }) + + await XCTAssertThrowsErrorAsync { try await provider.addressForOrder() } + XCTAssertEqual(state.allocationCount, 0) + } + + func testRevealFailureDoesNotAllocate() async { + let state = State() + state.cached = BlocktankRefundAddress(address: "refund", index: 1) + let provider = makeProvider(state: state, reveal: { _ in throw StubError.reveal }) + + await XCTAssertThrowsErrorAsync { try await provider.addressForOrder() } + XCTAssertEqual(state.allocationCount, 0) + } + + func testCachePersistenceFailureReturnsNoAddress() async { + let state = State() + let provider = makeProvider(state: state, save: { _ in throw StubError.persist }) + + await XCTAssertThrowsErrorAsync { try await provider.addressForOrder() } + XCTAssertEqual(state.allocationCount, 1) + XCTAssertNil(state.cached) + } + + func testAppCacheSerializationMatchesSharedOptionalShape() throws { + let json = #"{"blocktankRefundAddress":{"address":"bcrt1refund","index":7}}"# + let cache = try JSONDecoder().decode(AppCacheData.self, from: Data(json.utf8)) + + XCTAssertEqual(cache.blocktankRefundAddress, BlocktankRefundAddress(address: "bcrt1refund", index: 7)) + + let encoded = try JSONEncoder().encode(cache) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let refund = try XCTUnwrap(object["blocktankRefundAddress"] as? [String: Any]) + XCTAssertEqual(refund["address"] as? String, "bcrt1refund") + XCTAssertEqual(refund["index"] as? Int, 7) + } + + func testOlderAppCacheWithoutRefundAddressDecodesAsNil() throws { + let cache = try JSONDecoder().decode(AppCacheData.self, from: Data("{}".utf8)) + + XCTAssertNil(cache.blocktankRefundAddress) + } + + func testSettingsCacheRestoreRoundTripAndWipe() throws { + let json = #"{"blocktankRefundAddress":{"address":"bcrt1refund","index":7}}"# + let restored = try JSONDecoder().decode(AppCacheData.self, from: Data(json.utf8)) + + try SettingsViewModel.shared.restoreAppCacheData(restored) + + XCTAssertEqual( + try SettingsViewModel.shared.getAppCacheData().blocktankRefundAddress, + BlocktankRefundAddress(address: "bcrt1refund", index: 7) + ) + XCTAssertTrue(SettingsBackupConfig.appStateKeys.contains(BlocktankRefundAddressStore.key)) + + SettingsViewModel.shared.resetToDefaults() + XCTAssertNil(try BlocktankRefundAddressStore().load()) + } + + func testCorruptLocalCacheIsRejected() throws { + UserDefaults.standard.set(Data("not-json".utf8), forKey: BlocktankRefundAddressStore.key) + + XCTAssertThrowsError(try BlocktankRefundAddressStore().load()) { error in + XCTAssertEqual(error as? BlocktankRefundAddressError, .invalidCache) + } + } + + func testEstimatesNeverResolveRefundAddress() async { + let refundProvider = StubRefundProvider(result: .success("refund")) + var estimateCount = 0 + let viewModel = makeViewModel(refundProvider: refundProvider, estimate: { _, _, _ in + estimateCount += 1 + throw StubError.estimate + }) + + for _ in 0 ..< 8 { + await XCTAssertThrowsErrorAsync { + try await viewModel.estimateOrderFee(clientBalance: 1, lspBalance: 2) + } + } + + XCTAssertEqual(refundProvider.callCount, 0) + XCTAssertEqual(estimateCount, 8) + } + + func testCreateOrderSubmitsResolvedRefundAddress() async { + let refundProvider = StubRefundProvider(result: .success("refund-address")) + var submittedOptions: CreateOrderOptions? + let viewModel = makeViewModel(refundProvider: refundProvider, submit: { _, _, options in + submittedOptions = options + throw StubError.submit + }) + + await XCTAssertThrowsErrorAsync { + try await viewModel.createOrder(clientBalance: 1, lspBalance: 2) + } + + XCTAssertEqual(refundProvider.callCount, 1) + XCTAssertEqual(submittedOptions?.refundOnchainAddress, "refund-address") + } + + func testRefundResolutionFailureBlocksSubmission() async { + let refundProvider = StubRefundProvider(result: .failure(StubError.lookup)) + var submitCount = 0 + let viewModel = makeViewModel(refundProvider: refundProvider, submit: { _, _, _ in + submitCount += 1 + throw StubError.submit + }) + + await XCTAssertThrowsErrorAsync { + try await viewModel.createOrder(clientBalance: 1, lspBalance: 2) + } + + XCTAssertEqual(submitCount, 0) + } + + func testMissingNodeBlocksResolutionAndSubmission() async { + let refundProvider = StubRefundProvider(result: .success("refund")) + var submitCount = 0 + let orderClient = BlocktankOrderClient( + nodeId: { nil }, + sign: { _ in "signature" }, + submit: { _, _, _ in + submitCount += 1 + throw StubError.submit + }, + estimate: { _, _, _ in throw StubError.estimate } + ) + let viewModel = BlocktankViewModel( + orderClient: orderClient, + refundAddressProvider: refundProvider, + startPolling: false + ) + + await XCTAssertThrowsErrorAsync { + try await viewModel.createOrder(clientBalance: 1, lspBalance: 2) + } + + XCTAssertEqual(refundProvider.callCount, 0) + XCTAssertEqual(submitCount, 0) + } + + func testCancellationBeforeResolutionBlocksAllocationAndSubmission() async { + let refundProvider = StubRefundProvider(result: .success("refund")) + var submitCount = 0 + let viewModel = makeViewModel(refundProvider: refundProvider, submit: { _, _, _ in + submitCount += 1 + throw StubError.submit + }) + + let task = Task { @MainActor in + withUnsafeCurrentTask { $0?.cancel() } + return try await viewModel.createOrder(clientBalance: 1, lspBalance: 2) + } + + await XCTAssertThrowsErrorAsync({ try await task.value }) { error in + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(refundProvider.callCount, 0) + XCTAssertEqual(submitCount, 0) + } + + func testCancelledWaiterDoesNotSubmitWhileOtherWaiterCompletesResolution() async { + let state = State() + let provider = makeProvider(state: state, allocationDelayNanoseconds: 100_000_000) + var submitCount = 0 + let viewModel = makeViewModel(refundProvider: provider, submit: { _, _, _ in + submitCount += 1 + throw StubError.submit + }) + + let cancelled = Task { @MainActor in + try await viewModel.createOrder(clientBalance: 1, lspBalance: 2) + } + await Task.yield() + let active = Task { @MainActor in + try await viewModel.createOrder(clientBalance: 1, lspBalance: 2) + } + cancelled.cancel() + + await XCTAssertThrowsErrorAsync({ try await cancelled.value }) { error in + XCTAssertTrue(error is CancellationError) + } + await XCTAssertThrowsErrorAsync({ try await active.value }) { error in + XCTAssertTrue(error is StubError) + } + XCTAssertEqual(state.allocationCount, 1) + XCTAssertEqual(submitCount, 1) + } + + private func makeViewModel( + refundProvider: any BlocktankRefundAddressProviding, + submit: @escaping BlocktankOrderClient.Submit = { _, _, _ in throw StubError.submit }, + estimate: @escaping BlocktankOrderClient.Estimate = { _, _, _ in throw StubError.estimate } + ) -> BlocktankViewModel { + let orderClient = BlocktankOrderClient( + nodeId: { "node-id" }, + sign: { _ in "signature" }, + submit: submit, + estimate: estimate + ) + return BlocktankViewModel( + orderClient: orderClient, + refundAddressProvider: refundProvider, + startPolling: false + ) + } + + private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> some Any, + _ errorHandler: (Error) -> Void = { _ in }, + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + _ = try await expression() + XCTFail("Expected error", file: file, line: line) + } catch { + errorHandler(error) + } + } +} diff --git a/changelog.d/next/732.fixed.md b/changelog.d/next/732.fixed.md new file mode 100644 index 000000000..a8f56d9c2 --- /dev/null +++ b/changelog.d/next/732.fixed.md @@ -0,0 +1 @@ +Paid Blocktank channel orders can now refund automatically to the wallet if they expire before being claimed. From f1ecaf2a31c668fd354f600b8c2100da2f2ada80 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 18:17:43 +0200 Subject: [PATCH 2/5] test: cover lsp refund address lifecycle --- .github/workflows/integration-tests.yml | 1 + .github/workflows/unit-tests.yml | 1 + .../Localization/en.lproj/Localizable.strings | 2 +- Bitkit/Services/LightningService.swift | 2 +- Bitkit/ViewModels/SettingsViewModel.swift | 2 +- ...ankRefundAddressLiveIntegrationTests.swift | 174 ++++++++++++++++++ changelog.d/next/732.fixed.md | 2 +- 7 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0a988fcd9..d66dc8bf0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -70,6 +70,7 @@ jobs: -only-testing:BitkitTests/UtxoSelectionTests \ -only-testing:BitkitTests/BlocktankTests \ -only-testing:BitkitTests/PaymentFlowTests \ + -only-testing:BitkitTests/BlocktankRefundAddressLiveIntegrationTests \ -only-testing:BitkitTests/AddressTypeIntegrationTests \ | xcbeautify --report junit } diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 21815005d..65f297c72 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -74,6 +74,7 @@ jobs: -skip-testing:BitkitTests/UtxoSelectionTests \ -skip-testing:BitkitTests/BlocktankTests \ -skip-testing:BitkitTests/PaymentFlowTests \ + -skip-testing:BitkitTests/BlocktankRefundAddressLiveIntegrationTests \ -skip-testing:BitkitTests/AddressTypeIntegrationTests \ | xcbeautify --report junit echo "✅ Unit tests completed at $(date)" diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c0b904606..311b88df0 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -907,7 +907,7 @@ "settings__adv__addr_type_monitored_updated_title" = "Settings Updated"; "settings__adv__addr_type_monitored_updated_desc" = "Address monitoring settings applied."; "settings__adv__addr_type_cannot_disable_title" = "Cannot Disable"; -"settings__adv__addr_type_cannot_disable_native_desc" = "Native SegWit monitoring is required to detect automatic Blocktank refunds."; +"settings__adv__addr_type_cannot_disable_native_desc" = "Native SegWit monitoring is required to detect Blocktank refund payments."; "settings__adv__addr_type_cannot_disable_balance_desc" = "{type} addresses have balance."; "settings__adv__addr_type_monitored_failed_desc" = "Could not update monitoring settings. Please try again."; "settings__adv__addr_type_currently_selected" = "Currently selected"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index bf2b3bd7f..d0fd3d7cd 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1296,7 +1296,7 @@ extension LightningService { } /// Reads selected and monitored address types from UserDefaults and keeps native SegWit enabled - /// so delayed automatic Blocktank refunds remain detectable. + /// so delayed Blocktank refund payments remain detectable. static func addressTypeStateFromUserDefaults(_ defaults: UserDefaults = .standard) -> (selectedType: LDKNode.AddressType, monitoredTypes: [LDKNode.AddressType]) { diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 42f7f96fd..32d3df71e 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -512,7 +512,7 @@ class SettingsViewModel: NSObject, ObservableObject { } } - /// Native SegWit monitoring is required to detect delayed automatic Blocktank refunds. + /// Native SegWit monitoring is required to detect delayed Blocktank refund payments. func isRequiredRefundAddressType(_ addressType: AddressScriptType) -> Bool { addressType == .nativeSegwit } diff --git a/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift new file mode 100644 index 000000000..ea246d49e --- /dev/null +++ b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift @@ -0,0 +1,174 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +@MainActor +final class BlocktankRefundAddressLiveIntegrationTests: XCTestCase { + private let walletIndex = 0 + private let lightning = Bitkit.LightningService.shared + private let core = Bitkit.CoreService.shared + + override func setUp() async throws { + try await super.setUp() + try Bitkit.Keychain.wipeEntireKeychain() + Bitkit.SettingsViewModel.shared.resetToDefaults() + Bitkit.BlocktankRefundAddressStore().clear() + } + + override func tearDown() async throws { + lightning.dumpLdkLogs() + if lightning.status?.isRunning == true { + try? await lightning.stop() + } + try? await lightning.wipeStorage(walletIndex: walletIndex) + try Bitkit.Keychain.wipeEntireKeychain() + Bitkit.SettingsViewModel.shared.resetToDefaults() + try await super.tearDown() + } + + func testRefundAddressLifecycleAcrossOrdersRestartAndVssRestore() async throws { + guard Bitkit.Env.network == .regtest else { + throw XCTSkip("Blocktank refund address integration requires regtest") + } + + let settings = Bitkit.SettingsViewModel.shared + settings.selectedAddressType = .nativeSegwit + settings.addressTypesToMonitor = [.nativeSegwit] + + let mnemonic = try Bitkit.StartupHandler.createNewWallet(bip39Passphrase: nil, walletIndex: walletIndex) + XCTAssertFalse(mnemonic.isEmpty) + try await lightning.setup(walletIndex: walletIndex) + try await lightning.start() + try await lightning.sync() + XCTAssertNotNil(lightning.nodeId) + + var submittedOptions: [CreateOrderOptions] = [] + var estimatedOptions: [CreateOrderOptions] = [] + let makeViewModel = { + let client = Bitkit.BlocktankOrderClient( + nodeId: { self.lightning.nodeId }, + sign: { try await self.lightning.sign(message: $0) }, + submit: { lspBalanceSat, expiryWeeks, options in + submittedOptions.append(options) + return try await self.core.blocktank.newOrder( + lspBalanceSat: lspBalanceSat, + channelExpiryWeeks: expiryWeeks, + options: options + ) + }, + estimate: { lspBalanceSat, expiryWeeks, options in + estimatedOptions.append(options) + return try await self.core.blocktank.estimateFee( + lspBalanceSat: lspBalanceSat, + channelExpiryWeeks: expiryWeeks, + options: options + ) + } + ) + return Bitkit.BlocktankViewModel( + coreService: self.core, + lightningService: self.lightning, + orderClient: client, + startPolling: false + ) + } + + var viewModel = makeViewModel() + for _ in 0 ..< 5 { + _ = try await viewModel.estimateOrderFee(clientBalance: 0, lspBalance: 100_000) + } + XCTAssertEqual(estimatedOptions.count, 5) + XCTAssertTrue(estimatedOptions.allSatisfy { $0.refundOnchainAddress == nil }) + XCTAssertNil(try Bitkit.BlocktankRefundAddressStore().load(), "Fee estimates must not allocate refund addresses") + + let firstOrder = try await viewModel.createOrder(clientBalance: 0, lspBalance: 100_000) + let firstRefund = try XCTUnwrap(Bitkit.BlocktankRefundAddressStore().load()) + XCTAssertEqual(submittedOptions.last?.refundOnchainAddress, firstRefund.address) + XCTAssertTrue(firstRefund.address.hasPrefix("bcrt1q")) + + let clientFundedOrder = try await viewModel.createOrder(clientBalance: 25000, lspBalance: 100_000) + XCTAssertEqual(submittedOptions.last?.refundOnchainAddress, firstRefund.address) + let derivedFirst = try await lightning.addressInfoForType( + .nativeSegwit, + keychain: .external, + atIndex: firstRefund.index + ) + XCTAssertEqual(derivedFirst.address, firstRefund.address, "Client-funded orders must refund to the internal Bitkit wallet") + + do { + _ = try await viewModel.createOrder(clientBalance: 0, lspBalance: 1) + XCTFail("Expected the staging backend to reject an undersized order") + } catch { + XCTAssertEqual(try Bitkit.BlocktankRefundAddressStore().load(), firstRefund) + } + + try await lightning.stop() + try await lightning.setup(walletIndex: walletIndex) + try await lightning.start() + try await lightning.sync() + viewModel = makeViewModel() + + let restartedOrder = try await viewModel.createOrder(clientBalance: 0, lspBalance: 100_000) + XCTAssertEqual(submittedOptions.last?.refundOnchainAddress, firstRefund.address) + XCTAssertEqual(try Bitkit.BlocktankRefundAddressStore().load(), firstRefund) + + let appCache = try settings.getAppCacheData() + try await lightning.stop() + try await lightning.wipeStorage(walletIndex: walletIndex) + Bitkit.BlocktankRefundAddressStore().clear() + settings.selectedAddressType = .taproot + settings.addressTypesToMonitor = [.taproot] + try settings.restoreAppCacheData(appCache) + XCTAssertEqual(try Bitkit.BlocktankRefundAddressStore().load(), firstRefund) + + try await lightning.setup(walletIndex: walletIndex) + try await lightning.start() + try await lightning.sync() + await settings.pruneEmptyAddressTypesAfterRestore() + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nativeSegwit)) + + viewModel = makeViewModel() + let restoredOrder = try await viewModel.createOrder(clientBalance: 0, lspBalance: 100_000) + XCTAssertEqual(submittedOptions.last?.refundOnchainAddress, firstRefund.address) + XCTAssertEqual(try Bitkit.BlocktankRefundAddressStore().load(), firstRefund) + let addressWasUsedBeforeDeposit = try await core.utility.isAddressUsed(address: firstRefund.address) + XCTAssertFalse(addressWasUsedBeforeDeposit) + + let refundDepositTxId = try await core.blocktank.regtestDepositFunds(address: firstRefund.address, amountSat: 10000) + XCTAssertFalse(refundDepositTxId.isEmpty) + try await core.blocktank.regtestMineBlocks(3) + + let activityDeadline = Date().addingTimeInterval(120) + var recordedPayment = false + repeat { + try await lightning.sync() + if let payments = await lightning.listPayments() { + try await core.activity.syncLdkNodePayments(payments) + } + recordedPayment = try await core.utility.isAddressUsed(address: firstRefund.address) + if !recordedPayment { + try await Task.sleep(nanoseconds: 3_000_000_000) + } + } while !recordedPayment && Date() < activityDeadline + XCTAssertTrue(recordedPayment, "Incoming refund-address payment must be recorded in the local activity database") + + let rotatedOrder = try await viewModel.createOrder(clientBalance: 0, lspBalance: 100_000) + let rotatedRefund = try XCTUnwrap(Bitkit.BlocktankRefundAddressStore().load()) + XCTAssertEqual(rotatedRefund.index, firstRefund.index + 1) + XCTAssertNotEqual(rotatedRefund.address, firstRefund.address) + XCTAssertEqual(submittedOptions.last?.refundOnchainAddress, rotatedRefund.address) + + let reusedRotatedOrder = try await viewModel.createOrder(clientBalance: 0, lspBalance: 100_000) + XCTAssertEqual(try Bitkit.BlocktankRefundAddressStore().load(), rotatedRefund) + XCTAssertEqual(submittedOptions.last?.refundOnchainAddress, rotatedRefund.address) + + Bitkit.Logger.test( + "Refund lifecycle orders: first=\(firstOrder.id), clientFunded=\(clientFundedOrder.id), restart=\(restartedOrder.id), " + + "restore=\(restoredOrder.id), rotated=\(rotatedOrder.id), rotatedReuse=\(reusedRotatedOrder.id); " + + "refundAddress=\(firstRefund.address), refundIndex=\(firstRefund.index), refundDepositTxId=\(refundDepositTxId), " + + "rotatedAddress=\(rotatedRefund.address), rotatedIndex=\(rotatedRefund.index)", + context: "BlocktankRefundAddressLiveIntegrationTests" + ) + } +} diff --git a/changelog.d/next/732.fixed.md b/changelog.d/next/732.fixed.md index a8f56d9c2..ef36ac717 100644 --- a/changelog.d/next/732.fixed.md +++ b/changelog.d/next/732.fixed.md @@ -1 +1 @@ -Paid Blocktank channel orders can now refund automatically to the wallet if they expire before being claimed. +Paid Blocktank channel orders now include a wallet-owned address for refunds if they expire before being claimed. From 9b6478debe3f2172d2fa01a5ccdedfb27d7abbeb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 22:59:30 +0200 Subject: [PATCH 3/5] fix: namespace refund address cache --- .../BlocktankRefundAddressProvider.swift | 58 ++++++++++++++++--- .../BlocktankRefundAddressProviderTests.swift | 47 +++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/BlocktankRefundAddressProvider.swift b/Bitkit/Services/BlocktankRefundAddressProvider.swift index 30b2c424d..b44dc745d 100644 --- a/Bitkit/Services/BlocktankRefundAddressProvider.swift +++ b/Bitkit/Services/BlocktankRefundAddressProvider.swift @@ -25,17 +25,45 @@ enum BlocktankRefundAddressError: LocalizedError, Equatable { } struct BlocktankRefundAddressStore { - static let key = "blocktankRefundAddress" + static let legacyKey = "blocktankRefundAddress" + static var key: String { key(for: Env.network) } private let defaults: UserDefaults + private let network: LDKNode.Network - init(defaults: UserDefaults = .standard) { + private var key: String { + Self.key(for: network) + } + + init(defaults: UserDefaults = .standard, network: LDKNode.Network = Env.network) { self.defaults = defaults + self.network = network + } + + static func key(for network: LDKNode.Network) -> String { + "\(legacyKey)_\(Env.networkName(for: network))" } func load() throws -> BlocktankRefundAddress? { - guard defaults.object(forKey: Self.key) != nil else { return nil } - guard let data = defaults.data(forKey: Self.key) else { + if let value = try load(forKey: key) { + return value + } + + guard let legacy = try load(forKey: Self.legacyKey) else { return nil } + let matchingNetworks = Self.matchingNetworks(for: legacy.address) + guard !matchingNetworks.isEmpty else { + throw BlocktankRefundAddressError.invalidCache + } + guard matchingNetworks == [network] else { return nil } + + try save(legacy) + defaults.removeObject(forKey: Self.legacyKey) + return legacy + } + + private func load(forKey key: String) throws -> BlocktankRefundAddress? { + guard defaults.object(forKey: key) != nil else { return nil } + guard let data = defaults.data(forKey: key) else { throw BlocktankRefundAddressError.invalidCache } @@ -48,15 +76,31 @@ struct BlocktankRefundAddressStore { func save(_ value: BlocktankRefundAddress) throws { let data = try JSONEncoder().encode(value) - defaults.set(data, forKey: Self.key) + defaults.set(data, forKey: key) - guard try load() == value else { + guard try load(forKey: key) == value else { throw BlocktankRefundAddressError.persistenceFailed } } func clear() { - defaults.removeObject(forKey: Self.key) + defaults.removeObject(forKey: key) + + guard defaults.object(forKey: Self.legacyKey) != nil else { return } + guard let legacy = try? load(forKey: Self.legacyKey) else { + defaults.removeObject(forKey: Self.legacyKey) + return + } + let matchingNetworks = Self.matchingNetworks(for: legacy.address) + if matchingNetworks.isEmpty || matchingNetworks == [network] { + defaults.removeObject(forKey: Self.legacyKey) + } + } + + private static func matchingNetworks(for address: String) -> [LDKNode.Network] { + [LDKNode.Network.bitcoin, .testnet, .signet, .regtest].filter { + AddressScriptType.nativeSegwit.matchesAddressFormat(address, network: $0) + } } } diff --git a/BitkitTests/BlocktankRefundAddressProviderTests.swift b/BitkitTests/BlocktankRefundAddressProviderTests.swift index ce1f31d8e..829aacbc1 100644 --- a/BitkitTests/BlocktankRefundAddressProviderTests.swift +++ b/BitkitTests/BlocktankRefundAddressProviderTests.swift @@ -197,6 +197,53 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { XCTAssertNil(cache.blocktankRefundAddress) } + func testStoreKeepsRefundAddressesSeparateByNetwork() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + let regtestStore = BlocktankRefundAddressStore(defaults: defaults, network: .regtest) + let bitcoinStore = BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin) + let regtestAddress = BlocktankRefundAddress(address: "bcrt1qrefund", index: 7) + let bitcoinAddress = BlocktankRefundAddress(address: "bc1qrefund", index: 9) + + try regtestStore.save(regtestAddress) + XCTAssertEqual(try regtestStore.load(), regtestAddress) + XCTAssertNil(try bitcoinStore.load()) + + try bitcoinStore.save(bitcoinAddress) + XCTAssertEqual(try bitcoinStore.load(), bitcoinAddress) + XCTAssertEqual(try regtestStore.load(), regtestAddress) + } + + func testLegacyCacheMigratesOnlyOnMatchingNetwork() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + let legacy = BlocktankRefundAddress(address: "bcrt1qrefund", index: 7) + defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + + let bitcoinStore = BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin) + XCTAssertNil(try bitcoinStore.load()) + XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) + XCTAssertNil(defaults.data(forKey: BlocktankRefundAddressStore.key(for: .bitcoin))) + + let regtestStore = BlocktankRefundAddressStore(defaults: defaults, network: .regtest) + XCTAssertEqual(try regtestStore.load(), legacy) + XCTAssertNil(defaults.object(forKey: BlocktankRefundAddressStore.legacyKey)) + XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.key(for: .regtest))) + } + + func testClearPreservesLegacyCacheFromAnotherNetwork() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + let legacy = BlocktankRefundAddress(address: "bcrt1qrefund", index: 7) + defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + + BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin).clear() + XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) + + BlocktankRefundAddressStore(defaults: defaults, network: .regtest).clear() + XCTAssertNil(defaults.object(forKey: BlocktankRefundAddressStore.legacyKey)) + } + func testSettingsCacheRestoreRoundTripAndWipe() throws { let json = #"{"blocktankRefundAddress":{"address":"bcrt1refund","index":7}}"# let restored = try JSONDecoder().decode(AppCacheData.self, from: Data(json.utf8)) From b477696293d798ef4d34cc73a84cf0d7e1e0bf85 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 9 Sep 2026 18:43:51 +0200 Subject: [PATCH 4/5] fix: validate refund address allocation --- .../BlocktankRefundAddressProvider.swift | 55 ++++++- .../BlocktankRefundAddressProviderTests.swift | 141 ++++++++++++++++-- 2 files changed, 176 insertions(+), 20 deletions(-) diff --git a/Bitkit/Services/BlocktankRefundAddressProvider.swift b/Bitkit/Services/BlocktankRefundAddressProvider.swift index b44dc745d..71c2325a9 100644 --- a/Bitkit/Services/BlocktankRefundAddressProvider.swift +++ b/Bitkit/Services/BlocktankRefundAddressProvider.swift @@ -7,6 +7,8 @@ enum BlocktankRefundAddressError: LocalizedError, Equatable { case indexOutOfRange(UInt32) case ownershipMismatch case persistenceFailed + case allocationDidNotAdvance + case allocationAttemptsExhausted var errorDescription: String? { switch self { @@ -20,6 +22,10 @@ enum BlocktankRefundAddressError: LocalizedError, Equatable { "The saved Blocktank refund address does not belong to the active wallet and network." case .persistenceFailed: "The Blocktank refund address could not be saved." + case .allocationDidNotAdvance: + "The Blocktank refund address index did not advance." + case .allocationAttemptsExhausted: + "No unused Blocktank refund address was found." } } } @@ -76,9 +82,19 @@ struct BlocktankRefundAddressStore { func save(_ value: BlocktankRefundAddress) throws { let data = try JSONEncoder().encode(value) + let previousValue = defaults.object(forKey: key) defaults.set(data, forKey: key) - guard try load(forKey: key) == value else { + do { + guard try load(forKey: key) == value else { + throw BlocktankRefundAddressError.persistenceFailed + } + } catch { + if let previousValue { + defaults.set(previousValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } throw BlocktankRefundAddressError.persistenceFailed } } @@ -98,9 +114,17 @@ struct BlocktankRefundAddressStore { } private static func matchingNetworks(for address: String) -> [LDKNode.Network] { - [LDKNode.Network.bitcoin, .testnet, .signet, .regtest].filter { - AddressScriptType.nativeSegwit.matchesAddressFormat(address, network: $0) + let trimmed = address.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("bc1q") { + return [.bitcoin] } + if trimmed.hasPrefix("bcrt1q") { + return [.regtest] + } + if trimmed.hasPrefix("tb1q") { + return [.testnet, .signet] + } + return [] } } @@ -112,6 +136,7 @@ protocol BlocktankRefundAddressProviding: AnyObject { @MainActor final class BlocktankRefundAddressProvider: BlocktankRefundAddressProviding { static let maximumExternalIndex = UInt32(Int32.max) + static let maximumAllocationAttempts = 20 typealias Load = () throws -> BlocktankRefundAddress? typealias Save = (BlocktankRefundAddress) throws -> Void @@ -201,6 +226,8 @@ final class BlocktankRefundAddressProvider: BlocktankRefundAddressProviding { isUsed: IsUsed, allocate: Allocate ) async throws -> String { + var previousIndex: UInt32? + if let cached = try load() { try validate(cached) @@ -213,12 +240,26 @@ final class BlocktankRefundAddressProvider: BlocktankRefundAddressProviding { if try await isUsed(cached.address) == false { return cached.address } + previousIndex = cached.index + } + + for _ in 0 ..< maximumAllocationAttempts { + let generated = try await allocate() + try validate(generated) + if let previousIndex, generated.index <= previousIndex { + throw BlocktankRefundAddressError.allocationDidNotAdvance + } + previousIndex = generated.index + + if try await isUsed(generated.address) { + continue + } + + try save(generated) + return generated.address } - let generated = try await allocate() - try validate(generated) - try save(generated) - return generated.address + throw BlocktankRefundAddressError.allocationAttemptsExhausted } private static func validate(_ value: BlocktankRefundAddress) throws { diff --git a/BitkitTests/BlocktankRefundAddressProviderTests.swift b/BitkitTests/BlocktankRefundAddressProviderTests.swift index 829aacbc1..2eaed7f8a 100644 --- a/BitkitTests/BlocktankRefundAddressProviderTests.swift +++ b/BitkitTests/BlocktankRefundAddressProviderTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import LDKNode import XCTest @MainActor @@ -8,6 +9,7 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { case lookup case reveal case persist + case usage case estimate case submit } @@ -49,6 +51,8 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { lookup: ((UInt32) async throws -> BlocktankRefundAddress)? = nil, reveal: ((UInt32) async throws -> Void)? = nil, save: ((BlocktankRefundAddress) throws -> Void)? = nil, + isUsed: ((String) async throws -> Bool)? = nil, + allocate: (() async throws -> BlocktankRefundAddress)? = nil, allocationDelayNanoseconds: UInt64 = 0 ) -> BlocktankRefundAddressProvider { BlocktankRefundAddressProvider( @@ -60,8 +64,8 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { return BlocktankRefundAddress(address: cached.address, index: index) }, reveal: reveal ?? { _ in state.revealCount += 1 }, - isUsed: { state.usedAddresses.contains($0) }, - allocate: { + isUsed: isUsed ?? { state.usedAddresses.contains($0) }, + allocate: allocate ?? { if allocationDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: allocationDelayNanoseconds) } @@ -101,6 +105,68 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { XCTAssertEqual(state.cached, BlocktankRefundAddress(address: "refund-1", index: 1)) } + func testAllocationSkipsUsedCandidatesUntilFirstUnusedAddress() async throws { + let state = State() + state.usedAddresses = ["refund-0", "refund-1"] + let provider = makeProvider(state: state) + + let address = try await provider.addressForOrder() + + XCTAssertEqual(address, "refund-2") + XCTAssertEqual(state.allocationCount, 3) + XCTAssertEqual(state.cached, BlocktankRefundAddress(address: "refund-2", index: 2)) + } + + func testAllocationStopsAfterTwentyUsedCandidatesAndPreservesCache() async { + let state = State() + state.cached = BlocktankRefundAddress(address: "refund-0", index: 0) + state.allocationCount = 1 + state.usedAddresses = Set((0 ... 20).map { "refund-\($0)" }) + let provider = makeProvider(state: state) + + await XCTAssertThrowsErrorAsync({ try await provider.addressForOrder() }) { error in + XCTAssertEqual(error as? BlocktankRefundAddressError, .allocationAttemptsExhausted) + } + XCTAssertEqual(state.allocationCount, 21) + XCTAssertEqual(state.cached, BlocktankRefundAddress(address: "refund-0", index: 0)) + } + + func testAllocationUsageCheckFailurePreservesCache() async { + let state = State() + state.cached = BlocktankRefundAddress(address: "refund-0", index: 0) + state.allocationCount = 1 + let provider = makeProvider(state: state, isUsed: { address in + if address == "refund-0" { + return true + } + throw StubError.usage + }) + + await XCTAssertThrowsErrorAsync({ try await provider.addressForOrder() }) { error in + XCTAssertTrue(error is StubError) + } + XCTAssertEqual(state.allocationCount, 2) + XCTAssertEqual(state.cached, BlocktankRefundAddress(address: "refund-0", index: 0)) + } + + func testAllocationRejectsNonAdvancingIndex() async { + let state = State() + var candidates = [ + BlocktankRefundAddress(address: "refund-used", index: 7), + BlocktankRefundAddress(address: "refund-unused", index: 7), + ] + let provider = makeProvider( + state: state, + isUsed: { $0 == "refund-used" }, + allocate: { candidates.removeFirst() } + ) + + await XCTAssertThrowsErrorAsync({ try await provider.addressForOrder() }) { error in + XCTAssertEqual(error as? BlocktankRefundAddressError, .allocationDidNotAdvance) + } + XCTAssertNil(state.cached) + } + func testConcurrentCallsCoalesceOneAllocation() async throws { let state = State() let provider = makeProvider(state: state, allocationDelayNanoseconds: 50_000_000) @@ -214,33 +280,82 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { XCTAssertEqual(try regtestStore.load(), regtestAddress) } - func testLegacyCacheMigratesOnlyOnMatchingNetwork() throws { + func testLegacyCacheMigratesOnlyWhenPrefixIdentifiesNetwork() throws { + let cases: [(LDKNode.Network, String)] = [ + (.bitcoin, " bc1qrefund "), + (.regtest, " bcrt1qrefund "), + ] + + for (offset, testCase) in cases.enumerated() { + let suiteName = "\(#function)-\(offset)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let legacy = BlocktankRefundAddress(address: testCase.1, index: 7) + defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + + let store = BlocktankRefundAddressStore(defaults: defaults, network: testCase.0) + XCTAssertEqual(try store.load(), legacy) + XCTAssertNil(defaults.object(forKey: BlocktankRefundAddressStore.legacyKey)) + let migrated = try XCTUnwrap( + defaults.data(forKey: BlocktankRefundAddressStore.key(for: testCase.0)) + ) + XCTAssertEqual( + try JSONDecoder().decode(BlocktankRefundAddress.self, from: migrated), + legacy + ) + } + } + + func testClearPreservesLegacyCacheFromAnotherNetwork() throws { let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) defer { defaults.removePersistentDomain(forName: #function) } let legacy = BlocktankRefundAddress(address: "bcrt1qrefund", index: 7) defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) - let bitcoinStore = BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin) - XCTAssertNil(try bitcoinStore.load()) + BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin).clear() XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) - XCTAssertNil(defaults.data(forKey: BlocktankRefundAddressStore.key(for: .bitcoin))) - let regtestStore = BlocktankRefundAddressStore(defaults: defaults, network: .regtest) - XCTAssertEqual(try regtestStore.load(), legacy) + BlocktankRefundAddressStore(defaults: defaults, network: .regtest).clear() XCTAssertNil(defaults.object(forKey: BlocktankRefundAddressStore.legacyKey)) - XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.key(for: .regtest))) } - func testClearPreservesLegacyCacheFromAnotherNetwork() throws { + func testAmbiguousTestnetLegacyCacheIsPreserved() throws { let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) defer { defaults.removePersistentDomain(forName: #function) } - let legacy = BlocktankRefundAddress(address: "bcrt1qrefund", index: 7) + let legacy = BlocktankRefundAddress(address: "tb1qrefund", index: 7) defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) - BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin).clear() + XCTAssertNil(try BlocktankRefundAddressStore(defaults: defaults, network: .testnet).load()) + XCTAssertNil(try BlocktankRefundAddressStore(defaults: defaults, network: .signet).load()) + BlocktankRefundAddressStore(defaults: defaults, network: .testnet).clear() + BlocktankRefundAddressStore(defaults: defaults, network: .signet).clear() XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) + } - BlocktankRefundAddressStore(defaults: defaults, network: .regtest).clear() + func testScopedCacheTakesPrecedenceOverLegacyCache() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + let store = BlocktankRefundAddressStore(defaults: defaults, network: .regtest) + let scoped = BlocktankRefundAddress(address: "bcrt1qscoped", index: 8) + let legacy = BlocktankRefundAddress(address: "bc1qlegacy", index: 7) + try store.save(scoped) + defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + + XCTAssertEqual(try store.load(), scoped) + XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) + } + + func testUnknownLegacyCacheFailsClosedAndIsCleared() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + let legacy = BlocktankRefundAddress(address: "unknown", index: 7) + defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + let store = BlocktankRefundAddressStore(defaults: defaults, network: .regtest) + + XCTAssertThrowsError(try store.load()) { error in + XCTAssertEqual(error as? BlocktankRefundAddressError, .invalidCache) + } + store.clear() XCTAssertNil(defaults.object(forKey: BlocktankRefundAddressStore.legacyKey)) } From 9ecf57879a74a1a96ce609c5331410053be7b2d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 9 Sep 2026 19:41:10 +0200 Subject: [PATCH 5/5] style: format refund address changes --- Bitkit/Services/BlocktankRefundAddressProvider.swift | 4 +++- Bitkit/Services/LightningService.swift | 8 ++++++-- BitkitTests/BlocktankRefundAddressProviderTests.swift | 10 +++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/BlocktankRefundAddressProvider.swift b/Bitkit/Services/BlocktankRefundAddressProvider.swift index 71c2325a9..05eaaa438 100644 --- a/Bitkit/Services/BlocktankRefundAddressProvider.swift +++ b/Bitkit/Services/BlocktankRefundAddressProvider.swift @@ -32,7 +32,9 @@ enum BlocktankRefundAddressError: LocalizedError, Equatable { struct BlocktankRefundAddressStore { static let legacyKey = "blocktankRefundAddress" - static var key: String { key(for: Env.network) } + static var key: String { + key(for: Env.network) + } private let defaults: UserDefaults private let network: LDKNode.Network diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index d0fd3d7cd..ca78ba743 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -986,8 +986,12 @@ class LightningService { for (index, channel) in channels.enumerated() { totalOutboundMsat += channel.outboundCapacityMsat totalInboundMsat += channel.inboundCapacityMsat - if channel.isUsable { usableChannels += 1 } - if channel.isAnnounced { announcedChannels += 1 } + if channel.isUsable { + usableChannels += 1 + } + if channel.isAnnounced { + announcedChannels += 1 + } sb += " Channel \(index + 1):\n" sb += " - Channel ID: \(channel.channelId)\n" diff --git a/BitkitTests/BlocktankRefundAddressProviderTests.swift b/BitkitTests/BlocktankRefundAddressProviderTests.swift index 2eaed7f8a..9b85995f3 100644 --- a/BitkitTests/BlocktankRefundAddressProviderTests.swift +++ b/BitkitTests/BlocktankRefundAddressProviderTests.swift @@ -291,7 +291,7 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let legacy = BlocktankRefundAddress(address: testCase.1, index: 7) - defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + try defaults.set(JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) let store = BlocktankRefundAddressStore(defaults: defaults, network: testCase.0) XCTAssertEqual(try store.load(), legacy) @@ -310,7 +310,7 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) defer { defaults.removePersistentDomain(forName: #function) } let legacy = BlocktankRefundAddress(address: "bcrt1qrefund", index: 7) - defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + try defaults.set(JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) BlocktankRefundAddressStore(defaults: defaults, network: .bitcoin).clear() XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) @@ -323,7 +323,7 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) defer { defaults.removePersistentDomain(forName: #function) } let legacy = BlocktankRefundAddress(address: "tb1qrefund", index: 7) - defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + try defaults.set(JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) XCTAssertNil(try BlocktankRefundAddressStore(defaults: defaults, network: .testnet).load()) XCTAssertNil(try BlocktankRefundAddressStore(defaults: defaults, network: .signet).load()) @@ -339,7 +339,7 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { let scoped = BlocktankRefundAddress(address: "bcrt1qscoped", index: 8) let legacy = BlocktankRefundAddress(address: "bc1qlegacy", index: 7) try store.save(scoped) - defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + try defaults.set(JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) XCTAssertEqual(try store.load(), scoped) XCTAssertNotNil(defaults.data(forKey: BlocktankRefundAddressStore.legacyKey)) @@ -349,7 +349,7 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) defer { defaults.removePersistentDomain(forName: #function) } let legacy = BlocktankRefundAddress(address: "unknown", index: 7) - defaults.set(try JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) + try defaults.set(JSONEncoder().encode(legacy), forKey: BlocktankRefundAddressStore.legacyKey) let store = BlocktankRefundAddressStore(defaults: defaults, network: .regtest) XCTAssertThrowsError(try store.load()) { error in