Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
12 changes: 11 additions & 1 deletion Bitkit/Models/BackupPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -72,6 +77,7 @@ struct AppCacheData: Codable {
let dismissedSuggestions: [String]
let lastUsedTags: [String]
let quickPayLedger: QuickPayLedger?
let blocktankRefundAddress: BlocktankRefundAddress?

init(
hasSeenContactsIntro: Bool,
Expand All @@ -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
Expand All @@ -109,6 +116,7 @@ struct AppCacheData: Codable {
self.dismissedSuggestions = dismissedSuggestions
self.lastUsedTags = lastUsedTags
self.quickPayLedger = quickPayLedger
self.blocktankRefundAddress = blocktankRefundAddress
}

init(from decoder: Decoder) throws {
Expand All @@ -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 {
Expand All @@ -139,6 +148,7 @@ struct AppCacheData: Codable {
case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp
case dismissedSuggestions, lastUsedTags
case quickPayLedger
case blocktankRefundAddress
}
}

Expand Down
1 change: 1 addition & 0 deletions Bitkit/Models/SettingsBackupConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum SettingsBackupConfig {
"dismissedSuggestions",
"lastUsedTags",
"quickPayLedger",
BlocktankRefundAddressStore.key,
]

static let settingsKeyTypes: [String: SettingKeyType] = [
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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 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";
Expand Down
4 changes: 2 additions & 2 deletions Bitkit/Services/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
232 changes: 232 additions & 0 deletions Bitkit/Services/BlocktankRefundAddressProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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 legacyKey = "blocktankRefundAddress"
static var key: String { key(for: Env.network) }

private let defaults: UserDefaults
private let network: LDKNode.Network

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? {
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
}

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: key)

guard try load(forKey: key) == value else {
throw BlocktankRefundAddressError.persistenceFailed
}
}

func clear() {
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] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't distinguish testnet, signet and regtest, so the legacy migration and cleanup never run on any non-mainnet build.

matchesAddressFormat(.nativeSegwit, network:) (Bitkit/Extensions/LDKNode+AddressType.swift:158) computes isMainnet = network == .bitcoin and for every non-mainnet network returns hasPrefix("tb1q") || hasPrefix("bcrt1q"). So a bcrt1q… address matches .testnet, .signet and .regtest alike and this returns a 3-element array. Only mainnet (bc1q) can ever yield a singleton.

Both consumers compare for equality with a single network — load() at :57 and clear() at :95 — so both guards are unsatisfiable off mainnet:

  • Legacy key holds {bcrt1q…, 7} (written by the first commit on this branch on a regtest device) → namespaced key is nil → falls through to legacy → matchingNetworks returns [.testnet, .signet, .regtest]== [network] false → returns nil. A fresh address is allocated and saved under the namespaced key.
  • clear() hits the same false guard, so the legacy key is never removed. It lingers and is re-parsed on every load()/clear().

This is what Run Tests is failing on: testLegacyCacheMigratesOnlyOnMatchingNetwork (:217) and testClearPreservesLegacyCacheFromAnotherNetwork (:234).

Worth being clear about what is not broken: the cross-network hard-block I raised earlier is genuinely fixed — a bcrt1q legacy value on a mainnet build returns nil and allocates fresh. What's lost is only the "reuse the previously-supplied address" intent and the cleanup.

An explicit HRP switch local to this provider fixes both tests without touching the shared helper, whose loose non-mainnet semantics PrivatePaykitAddressReservationStore and SettingsViewModel:580 rely on:

private static func matchingNetworks(for address: String) -> [LDKNode.Network] {
    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 []
}

testnet/signet still share tb1q and correctly stay non-migrating.

[LDKNode.Network.bitcoin, .testnet, .signet, .regtest].filter {
AddressScriptType.nativeSegwit.matchesAddressFormat(address, network: $0)
}
}
}

@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<String, Error>?

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)
}
}
}
9 changes: 7 additions & 2 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 Blocktank refund payments 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)
}

Expand Down
Loading
Loading