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
10 changes: 7 additions & 3 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1985,14 +1985,18 @@ actor AddressSearchCoordinator {
(false, .external),
(true, .internal),
]
let primaryAccounts = addressTypesToSearch.map { LDKNode.OnchainWalletAccount(addressType: $0, accountIndex: 0) }
var primaryAccounts = addressTypesToSearch.map { LDKNode.OnchainWalletAccount(addressType: $0, accountIndex: 0) }
let companionAccounts: [LDKNode.OnchainWalletAccount]
do {
companionAccounts = try await listAccounts().filter { $0.accountIndex > 0 }.sorted {
let accounts = try await listAccounts()
primaryAccounts = primaryAccounts.filter { primary in
accounts.contains { $0.accountIndex == 0 && $0.addressType == primary.addressType }
}
companionAccounts = accounts.filter { $0.accountIndex > 0 }.sorted {
($0.accountIndex, $0.addressType.stringValue) < ($1.accountIndex, $1.addressType.stringValue)
}
} catch {
Logger.warn("Failed to list companion accounts for address search: \(error)", context: "CoreService.AddressSearch")
Logger.warn("Failed to list wallet accounts for address search: \(error)", context: "CoreService.AddressSearch")
companionAccounts = []
}
// Preserve account-zero priority, including its change addresses, before searching companion accounts.
Expand Down
135 changes: 126 additions & 9 deletions BitkitTests/AddressSearchCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,23 @@ final class AddressSearchCoordinatorTests: XCTestCase {
func testCompanionReceiveAddressAfterChangeUsesSeparateSearchWindow() async throws {
let key = "addressSearch_lastUsedReceiveIndex_nativeSegwit_account3"
defaults.set(400, forKey: key)
let calls = AddressSearchCallLog()
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] },
listAccounts: {
await calls.record("list")
return [
OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3),
OnchainWalletAccount(addressType: .taproot, accountIndex: 2),
OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 0),
OnchainWalletAccount(addressType: .legacy, accountIndex: 2),
]
},
deriveAddresses: { account, keychain, start, count in
XCTAssertTrue(account.accountIndex > 0 || account.addressType == .nativeSegwit)
if start == 0 {
await calls.record("\(account.addressType.stringValue):\(account.accountIndex):\(keychain)")
}
XCTAssertEqual(count, 200)
if account.accountIndex == 3, keychain == .external, start == 1200 {
return [LightningService.AddressDerivationInfo(address: "server-receive", index: 1200)]
Expand All @@ -39,14 +52,32 @@ final class AddressSearchCoordinatorTests: XCTestCase {
XCTAssertEqual(result, "server-receive")
XCTAssertEqual(defaults.integer(forKey: key), 1200)
XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_nativeSegwit"))
let recorded = await calls.events
XCTAssertEqual(recorded, [
"list", "nativeSegwit:0:external", "nativeSegwit:0:internal",
"legacy:2:external", "taproot:2:external", "nativeSegwit:3:external",
])
}

func testAccountZeroChangeKeepsPriorityAndExistingCacheKey() async throws {
let calls = AddressSearchCallLog()
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] },
listAccounts: {
[
OnchainWalletAccount(addressType: .legacy, accountIndex: 0),
OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3),
OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 0),
]
},
deriveAddresses: { account, keychain, start, _ in
XCTAssertEqual(account.accountIndex, 0, "Account zero should resolve before companion derivation")
if start == 0 {
await calls.record("\(account.addressType.stringValue):\(account.accountIndex):\(keychain)")
}
if account.accountIndex == 3 {
return [LightningService.AddressDerivationInfo(address: "server-receive", index: 0)]
}
return account.addressType == .nativeSegwit && keychain == .internal && start == 200
? [LightningService.AddressDerivationInfo(address: "owned-change", index: 203)] : []
}
Expand All @@ -57,6 +88,9 @@ final class AddressSearchCoordinatorTests: XCTestCase {
)
XCTAssertEqual(result, "owned-change")
XCTAssertEqual(defaults.integer(forKey: "addressSearch_lastUsedChangeIndex_nativeSegwit"), 203)
let recorded = await calls.events
XCTAssertEqual(recorded, ["nativeSegwit:0:external", "legacy:0:external", "nativeSegwit:0:internal"])
XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_nativeSegwit_account3"))
}

func testMatchedCompanionReceiveIndexExtendsPersistedWindowByOneThousand() async throws {
Expand Down Expand Up @@ -87,7 +121,9 @@ final class AddressSearchCoordinatorTests: XCTestCase {
defaults.set(1000, forKey: key)
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { [] },
listAccounts: {
LDKNode.AddressType.allAddressTypes.map { OnchainWalletAccount(addressType: $0, accountIndex: 0) }
},
deriveAddresses: { account, keychain, start, count in
guard account.accountIndex == 0, account.addressType == .nativeSegwit, keychain == .internal else { return [] }
XCTAssertLessThanOrEqual(start + count, 2001)
Expand Down Expand Up @@ -119,7 +155,9 @@ final class AddressSearchCoordinatorTests: XCTestCase {
func testUnregisteredAddressDoesNotMatchAndSearchStaysBounded() async throws {
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { [] },
listAccounts: {
LDKNode.AddressType.allAddressTypes.map { OnchainWalletAccount(addressType: $0, accountIndex: 0) }
},
deriveAddresses: { account, _, start, count in
XCTAssertEqual(account.accountIndex, 0)
XCTAssertLessThan(start, 1000)
Expand All @@ -135,16 +173,85 @@ final class AddressSearchCoordinatorTests: XCTestCase {
}

func testDerivationFailureDoesNotSelectTransactionOutput() async throws {
for error in [AppError(serviceError: .nodeNotSetup), CancellationError()] as [Error] {
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] },
deriveAddresses: { _, _, _, _ in throw error }
)
let result = try await search.runAddressSearch(
details: details(["unverified-first-output"]), value: 15000,
currentWalletAddress: "", selectedAddressType: .nativeSegwit
)
XCTAssertNil(result)
XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_nativeSegwit_account3"))
XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedChangeIndex_nativeSegwit_account3"))
}
}

func testEmptyAccountListIsReadAgainForTheNextSearch() async throws {
let calls = AddressSearchCallLog()
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] },
deriveAddresses: { _, _, _, _ in throw AppError(serviceError: .nodeNotSetup) }
listAccounts: {
let count = await calls.record("list")
return count == 1 ? [] : [OnchainWalletAccount(addressType: .taproot, accountIndex: 0)]
},
deriveAddresses: { account, keychain, start, _ in
await calls.record("derive")
XCTAssertEqual(account.addressType, .taproot)
XCTAssertEqual(account.accountIndex, 0)
XCTAssertEqual(keychain, .external)
XCTAssertEqual(start, 0)
return [LightningService.AddressDerivationInfo(address: "newly-loaded", index: 0)]
}
)
let result = try await search.runAddressSearch(
details: details(["unverified-first-output"]), value: 15000,
let first = try await search.runAddressSearch(
details: details(["newly-loaded"]), value: 15000,
currentWalletAddress: "", selectedAddressType: .nativeSegwit
)
XCTAssertNil(result)
XCTAssertNil(first)
XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_taproot"))
let firstCalls = await calls.events
XCTAssertEqual(firstCalls, ["list"])

let second = try await search.runAddressSearch(
details: details(["newly-loaded"]), value: 15000,
currentWalletAddress: "", selectedAddressType: .nativeSegwit
)
XCTAssertEqual(second, "newly-loaded")
XCTAssertEqual(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_taproot") as? Int, 0)
let allCalls = await calls.events
XCTAssertEqual(allCalls, ["list", "list", "derive"])
}

func testAccountListingFailureKeepsPrimaryFallback() async throws {
for error in [AppError(serviceError: .nodeNotSetup), CancellationError()] as [Error] {
let calls = AddressSearchCallLog()
let search = AddressSearchCoordinator(
defaults: defaults,
listAccounts: { throw error },
deriveAddresses: { account, keychain, start, _ in
XCTAssertEqual(account.accountIndex, 0)
if start == 0 {
await calls.record("\(account.addressType.stringValue):\(keychain)")
}
return account.addressType == .nativeSegwit && keychain == .internal && start == 0
? [LightningService.AddressDerivationInfo(address: "owned-change", index: 7)] : []
}
)
let result = try await search.runAddressSearch(
details: details(["owned-change"]), value: 15000,
currentWalletAddress: "", selectedAddressType: .taproot
)
XCTAssertEqual(result, "owned-change")
XCTAssertEqual(defaults.integer(forKey: "addressSearch_lastUsedChangeIndex_nativeSegwit"), 7)
let recorded = await calls.events
XCTAssertEqual(recorded, [
"taproot:external", "legacy:external", "nestedSegwit:external", "nativeSegwit:external",
"taproot:internal", "legacy:internal", "nestedSegwit:internal", "nativeSegwit:internal",
])
}
}

private func details(_ addresses: [String]) -> BitkitCore.TransactionDetails {
Expand All @@ -159,3 +266,13 @@ final class AddressSearchCoordinatorTests: XCTestCase {
)
}
}

private actor AddressSearchCallLog {
private(set) var events: [String] = []

@discardableResult
func record(_ event: String) -> Int {
events.append(event)
return events.count
}
}
Loading