Skip to content
Merged
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 @@ -911,7 +911,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
275 changes: 275 additions & 0 deletions Bitkit/Services/BlocktankRefundAddressProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import Foundation
import LDKNode

enum BlocktankRefundAddressError: LocalizedError, Equatable {
case invalidCache
case invalidAddress
case indexOutOfRange(UInt32)
case ownershipMismatch
case persistenceFailed
case allocationDidNotAdvance
case allocationAttemptsExhausted

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."
case .allocationDidNotAdvance:
"The Blocktank refund address index did not advance."
case .allocationAttemptsExhausted:
"No unused Blocktank refund address was found."
}
}
}

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)
let previousValue = defaults.object(forKey: key)
defaults.set(data, forKey: key)

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

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] {
Comment thread
ovitrif marked this conversation as resolved.
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 []
}
}

@MainActor
protocol BlocktankRefundAddressProviding: AnyObject {
func addressForOrder() async throws -> String
}

@MainActor
final class BlocktankRefundAddressProvider: BlocktankRefundAddressProviding {
static let maximumExternalIndex = UInt32(Int32.max)
static let maximumAllocationAttempts = 20

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 {
var previousIndex: UInt32?

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

throw BlocktankRefundAddressError.allocationAttemptsExhausted
}

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)
}
}
}
Loading
Loading