From 569a19a84aa719a00d18ad2d20de4552ca378e01 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 10:11:43 -0300 Subject: [PATCH 1/7] chore: bump bitkit-core and add temporary key serialization workaround --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Services/WatchOnlyAccountService.swift | 85 +++++++++++++++++-- BitkitTests/AddressTypeIntegrationTests.swift | 15 ++-- .../WatchOnlyAccountServiceTests.swift | 60 +++++++++++++ 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index a2cdb4832..bd8ec1f76 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1207,7 +1207,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.4.2; + version = 0.5.3; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 35ff21b4d..40c876eee 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "dd99b46d4b849a7716b45664136c4d1ce5e5905e", - "version" : "0.4.2" + "revision" : "4c859d3f2c0022fda25baa62a029e2c03d1892a7", + "version" : "0.5.3" } }, { diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 0d13db020..65d10ea25 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -3,6 +3,7 @@ import Combine import CryptoKit import Foundation import LDKNode +import secp256k1 enum WatchOnlyAccountSetupState: String, Codable { case pendingDelivery @@ -904,19 +905,91 @@ enum WatchOnlyAccountClaimCodec { return claim } + /// Decode a BIP32 extended public key into its canonical 78-byte payload. + /// + /// This used to call `BitkitCore.serializedExtendedPubkey`, but that symbol only ever shipped + /// in bitkit-core v0.4.2, a tag cut from a branch that never merged to bitkit-core `master`, + /// so no later release (0.4.3, any 0.5.x, or master) contains it. Depending on it pinned this + /// repo to a dead-end tag and blocked every bitkit-core upgrade. Decoding locally removes that + /// constraint. + /// + /// TODO: revert to `BitkitCore.serializedExtendedPubkey` once bitkit-core lands + /// `dd99b46d "feat: expose serialized extended public keys"` on `master` and cuts a release + /// carrying both it and the Boltz module. Delete this decoder and its helpers at that point; + /// the `testSerializedXpub*` cases pin the behaviour either way. static func serializedXpub(_ xpub: String) throws -> Data { guard xpub.count > 4 else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - do { - let serialized = try BitkitCore.serializedExtendedPubkey(xpub: xpub) - guard serialized.count == serializedXpubLength else { + let payload = try base58CheckDecode(xpub) + guard payload.count == serializedXpubLength else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + // A BIP32 xpub is version(4) ‖ depth(1) ‖ fingerprint(4) ‖ child(4) ‖ chaincode(32) ‖ key(33). + // Reject anything whose key is not a point on the curve, matching rust-bitcoin's Xpub parse. + let compressedKey = payload.suffix(33) + guard isValidCompressedPublicKey(compressedKey) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + return payload + } + + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + + private static func base58CheckDecode(_ string: String) throws -> Data { + let decoded = try base58Decode(string) + guard decoded.count > 4 else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + let payload = decoded.prefix(decoded.count - 4) + let checksum = decoded.suffix(4) + let expected = Data(SHA256.hash(data: Data(SHA256.hash(data: payload)))).prefix(4) + guard checksum.elementsEqual(expected) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + return Data(payload) + } + + private static func base58Decode(_ string: String) throws -> Data { + var bytes: [UInt8] = [0] + for character in string.utf8 { + guard let digit = base58Alphabet.firstIndex(of: character) else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - return serialized - } catch { - throw WatchOnlyAccountError.invalidExtendedPublicKey + + var carry = digit + for index in bytes.indices.reversed() { + carry += 58 * Int(bytes[index]) + bytes[index] = UInt8(carry & 0xFF) + carry >>= 8 + } + while carry > 0 { + bytes.insert(UInt8(carry & 0xFF), at: 0) + carry >>= 8 + } + } + + // Each leading '1' encodes a leading zero byte that the arithmetic above cannot represent. + let leadingZeros = string.utf8.prefix { $0 == base58Alphabet[0] }.count + let significant = bytes.drop { $0 == 0 } + return Data(repeating: 0, count: leadingZeros) + Data(significant) + } + + private static func isValidCompressedPublicKey(_ key: Data) -> Bool { + guard key.count == 33, let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_NONE)) else { + return false + } + defer { secp256k1_context_destroy(context) } + + var parsed = secp256k1_pubkey() + return Array(key).withUnsafeBufferPointer { buffer in + guard let baseAddress = buffer.baseAddress else { return false } + return secp256k1_ec_pubkey_parse(context, &parsed, baseAddress, buffer.count) == 1 } } } diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index ed106c44d..3e09d7846 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -309,17 +309,18 @@ final class AddressTypeIntegrationTests: XCTestCase { XCTAssertTrue(success, "Enabling \(type.stringValue) monitoring should succeed") } - let expectations: [(LDKNode.AddressType, String, String)] = [ - (.legacy, "m", "Legacy address should start with m or n on regtest"), - (.nestedSegwit, "2", "Nested SegWit address should start with 2 on regtest"), - (.nativeSegwit, "bcrt1q", "Native SegWit address should start with bcrt1q on regtest"), - (.taproot, "bcrt1p", "Taproot address should start with bcrt1p on regtest"), + // Regtest P2PKH addresses start with m or n depending on the hash, so legacy accepts both. + let expectations: [(LDKNode.AddressType, [String], String)] = [ + (.legacy, ["m", "n"], "Legacy address should start with m or n on regtest"), + (.nestedSegwit, ["2"], "Nested SegWit address should start with 2 on regtest"), + (.nativeSegwit, ["bcrt1q"], "Native SegWit address should start with bcrt1q on regtest"), + (.taproot, ["bcrt1p"], "Taproot address should start with bcrt1p on regtest"), ] - for (type, prefix, message) in expectations { + for (type, prefixes, message) in expectations { let address = try await settings.lightningService.newAddressForType(type) Logger.test("\(type.stringValue) address: \(address)", context: "AddressTypeIntegrationTests") - XCTAssertTrue(address.hasPrefix(prefix), "\(message), got: \(address)") + XCTAssertTrue(prefixes.contains(where: address.hasPrefix), "\(message), got: \(address)") } } } diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 3ab0460be..49ea105f7 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -11,6 +11,16 @@ private let alternateTestXpub = private let testSerializedXpubHex = "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" +private let mainnetTestXpub = + "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8" + + "YtGqsefD265TMg7usUDFdp6W1EGMcet8" +private let mainnetSerializedXpubHex = + "0488b21e000000000000000000873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508" + + "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2" +/// Base58check over version(4) ‖ 74 zero bytes: decodes cleanly, but its key is not on the curve. +private let offCurveKeyXpub = + "xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU" + + "521AAwdZafEz7mnzBBsz4wKY5e4cp9LB" final class WatchOnlyAccountServiceTests: XCTestCase { func testUnsignedClaimContainsExactAccountMetadata() throws { @@ -37,6 +47,56 @@ final class WatchOnlyAccountServiceTests: XCTestCase { } } + // MARK: - serializedXpub + + /// Vectors below are bitkit-core's own, from `src/modules/onchain/extended_pubkey.rs` @ v0.4.2, + /// so the local decode stays byte-compatible with the FFI it replaces. + func testSerializedXpubMatchesCoreVectorForMainnetXpub() throws { + let serialized = try WatchOnlyAccountClaimCodec.serializedXpub(mainnetTestXpub) + + XCTAssertEqual(serialized.count, WatchOnlyAccountClaimCodec.serializedXpubLength) + XCTAssertEqual(serialized, mainnetSerializedXpubHex.hexaData) + } + + func testSerializedXpubMatchesCoreVectorForTestnetTpub() throws { + let serialized = try WatchOnlyAccountClaimCodec.serializedXpub(testXpub) + + XCTAssertEqual(serialized.count, WatchOnlyAccountClaimCodec.serializedXpubLength) + XCTAssertEqual(serialized, testSerializedXpubHex.hexaData) + } + + func testSerializedXpubRejectsInvalidBase58Character() throws { + // '0' is not in the base58 alphabet. + let invalidXpub = "0" + mainnetTestXpub.dropFirst() + + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(invalidXpub)) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testSerializedXpubRejectsInvalidChecksum() throws { + let invalidXpub = String(mainnetTestXpub.dropLast()) + "1" + + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(invalidXpub)) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testSerializedXpubRejectsWellFormedPayloadWithKeyOffTheCurve() throws { + // Valid base58check over a 78-byte payload whose 33-byte key is all zeros, so it decodes + // cleanly but is not a point on secp256k1. Guards the parse step, not just the checksum. + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(offCurveKeyXpub)) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testSerializedXpubRejectsPayloadOfTheWrongLength() throws { + // Base58check over 4 bytes: correct checksum, but nowhere near 78 bytes. + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub("kz9795HmHu")) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + func testRestorePreservesAllocatorHighWaterMarkWhileClearingUnrestoredReservation() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) From 7563f0557599d4867f4e3d0e0e492bca4d878f72 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 11:09:25 -0300 Subject: [PATCH 2/7] feat: implementHW snapshot activities reconciliation --- Bitkit.xcodeproj/project.pbxproj | 6 +- Bitkit/Extensions/Activity+Contact.swift | 10 ++ Bitkit/Services/CoreService.swift | 74 +++++++++++++++ Bitkit/Services/HwSnapshotMerge.swift | 45 +++++++++ BitkitTests/HwSnapshotMergeTests.swift | 114 +++++++++++++++++++++++ 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 Bitkit/Services/HwSnapshotMerge.swift create mode 100644 BitkitTests/HwSnapshotMergeTests.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index bd8ec1f76..2a6113fe7 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -171,14 +171,15 @@ Models/TransferType.swift, Services/CoreService.swift, Services/GeoService.swift, + Services/HwSnapshotMerge.swift, Services/LightningService.swift, Services/MigrationsService.swift, - Services/WatchOnlyAccountService.swift, Services/RNBackupClient.swift, Services/ServiceQueue.swift, Services/TransferStorage.swift, Services/VssBackupClient.swift, Services/VssStoreIdProvider.swift, + Services/WatchOnlyAccountService.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, @@ -202,10 +203,11 @@ Models/Toast.swift, Services/CoreService.swift, Services/GeoService.swift, + Services/HwSnapshotMerge.swift, Services/LightningService.swift, - Services/WatchOnlyAccountService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, + Services/WatchOnlyAccountService.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift index 952f9d507..c50a52f63 100644 --- a/Bitkit/Extensions/Activity+Contact.swift +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -1,6 +1,16 @@ import BitkitCore extension Activity { + /// The activity's own id, unique only within its `walletId` scope. + var activityId: String { + switch self { + case let .lightning(lightning): + return lightning.id + case let .onchain(onchain): + return onchain.id + } + } + /// bitkit-core wallet id scoping this activity (`"bitkit"` for the normal wallet, a derived /// id for watch-only hardware wallets — see `HwWalletId`). var walletId: String { diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index e8471815e..25d6716c4 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -380,6 +380,80 @@ class ActivityService { } } + /// Replace the complete stored on-chain snapshot for a watch-only hardware wallet, and return + /// the resulting stored set. + /// + /// The caller must merge every watcher belonging to the wallet before calling this: anything + /// scoped to `walletId` that the snapshot no longer contains is deleted, which is how a reorged + /// or replaced transaction stops showing. Locally written transfer metadata survives (see + /// `HwSnapshotMerge`). + @discardableResult + func replaceHwSnapshot( + walletId: String, + activities: [Activity], + transactionDetails: [BitkitCore.TransactionDetails] + ) async throws -> [Activity] { + try await ServiceQueue.background(.core) { + let plan = try HwSnapshotMerge.plan(existing: Self.storedOnchainActivities(walletId: walletId), incoming: activities) + + for activity in plan.toDelete { + _ = try deleteActivityById(walletId: walletId, activityId: activity.id) + _ = try deleteTransactionDetails(walletId: walletId, txId: activity.txId) + } + + if !plan.toUpsert.isEmpty { + try upsertActivities(activities: plan.toUpsert) + } + + if !transactionDetails.isEmpty { + try upsertTransactionDetails(detailsList: transactionDetails) + } + + await self.refreshBoostTxIdsCache() + self.activitiesChangedSubject.send() + + return try getActivities( + walletId: walletId, + filter: .onchain, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil + ) + } + } + + private static func storedOnchainActivities(walletId: String) throws -> [OnchainActivity] { + try getActivities( + walletId: walletId, + filter: .onchain, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil + ).compactMap { activity in + guard case let .onchain(onchain) = activity else { return nil } + return onchain + } + } + + /// Delete every activity scoped to a watch-only hardware wallet, e.g. when the device is + /// unpaired. Returns the number of rows removed. + @discardableResult + func deleteByWalletId(_ walletId: String) async throws -> UInt32 { + try await ServiceQueue.background(.core) { + let deleted = try deleteActivitiesByWalletId(walletId: walletId) + self.activitiesChangedSubject.send() + return deleted + } + } + func closedChannels(sortDirection: SortDirection = .asc) async throws -> [ClosedChannelDetails] { try await ServiceQueue.background(.core) { try getAllClosedChannels(sortDirection: sortDirection) diff --git a/Bitkit/Services/HwSnapshotMerge.swift b/Bitkit/Services/HwSnapshotMerge.swift new file mode 100644 index 000000000..30f8fb386 --- /dev/null +++ b/Bitkit/Services/HwSnapshotMerge.swift @@ -0,0 +1,45 @@ +import BitkitCore + +/// Plans how a hardware-wallet watcher snapshot should replace what bitkit-core already stores for +/// that wallet. Kept pure and free of the core FFI so the reconciliation rules can be unit tested; +/// `ActivityService.replaceHwSnapshot` applies the plan. Adapts bitkit-android's `mergeHwSnapshot`. +enum HwSnapshotMerge { + struct Plan { + let toDelete: [OnchainActivity] + let toUpsert: [Activity] + } + + static func plan(existing: [OnchainActivity], incoming: [Activity]) -> Plan { + let incomingIds = Set(incoming.map(id(of:))) + + // Transfers are never dropped: the pending Transfer To Spending row is written when the + // funding tx is broadcast, before any watcher poll can report it, so a snapshot that does + // not mention it yet must not delete it. + let toDelete = existing.filter { !$0.isTransfer && !incomingIds.contains($0.id) } + + let storedByTxId = Dictionary(existing.map { ($0.txId, $0) }, uniquingKeysWith: { first, _ in first }) + let toUpsert = incoming.map { activity -> Activity in + guard case var .onchain(onchain) = activity, let stored = storedByTxId[onchain.txId] else { + return activity + } + + // The watcher only knows what is on chain, so transfer metadata the app wrote locally + // would otherwise be erased on every snapshot. + onchain.isTransfer = onchain.isTransfer || stored.isTransfer + onchain.channelId = onchain.channelId ?? stored.channelId + onchain.transferTxId = onchain.transferTxId ?? stored.transferTxId + return .onchain(onchain) + } + + return Plan(toDelete: toDelete, toUpsert: toUpsert) + } + + /// Deliberately not the `Activity.activityId` extension: this file is compiled into the + /// notification and test targets alongside `CoreService`, which do not build `Extensions/`. + private static func id(of activity: Activity) -> String { + switch activity { + case let .lightning(lightning): return lightning.id + case let .onchain(onchain): return onchain.id + } + } +} diff --git a/BitkitTests/HwSnapshotMergeTests.swift b/BitkitTests/HwSnapshotMergeTests.swift new file mode 100644 index 000000000..1fc60490a --- /dev/null +++ b/BitkitTests/HwSnapshotMergeTests.swift @@ -0,0 +1,114 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Covers the reconciliation rules `ActivityService.replaceHwSnapshot` applies when a hardware +/// watcher snapshot replaces what bitkit-core already stores for that wallet. +final class HwSnapshotMergeTests: XCTestCase { + private let walletId = "trezor:abc" + + private func onchain( + id: String, + txId: String? = nil, + isTransfer: Bool = false, + channelId: String? = nil, + transferTxId: String? = nil, + confirmed: Bool = true + ) -> OnchainActivity { + OnchainActivity( + walletId: walletId, + id: id, + txType: .received, + txId: txId ?? id, + value: 1000, + fee: 100, + feeRate: 1, + address: "bcrt1qexample", + confirmed: confirmed, + timestamp: 1, + isBoosted: false, + boostTxIds: [], + isTransfer: isTransfer, + doesExist: true, + confirmTimestamp: nil, + channelId: channelId, + transferTxId: transferTxId, + contact: nil, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + } + + private func upserted(_ plan: HwSnapshotMerge.Plan, id: String) -> OnchainActivity? { + plan.toUpsert.compactMap { activity -> OnchainActivity? in + guard case let .onchain(onchain) = activity else { return nil } + return onchain + }.first { $0.id == id } + } + + func testDropsStoredActivityMissingFromSnapshot() { + let plan = HwSnapshotMerge.plan( + existing: [onchain(id: "kept"), onchain(id: "reorged")], + incoming: [.onchain(onchain(id: "kept"))] + ) + + XCTAssertEqual(plan.toDelete.map(\.id), ["reorged"]) + XCTAssertEqual(plan.toUpsert.map(\.activityId), ["kept"]) + } + + func testKeepsTransferMissingFromSnapshot() { + // A pending Transfer To Spending exists before any watcher poll reports its funding tx. + let plan = HwSnapshotMerge.plan( + existing: [onchain(id: "pendingTransfer", isTransfer: true)], + incoming: [] + ) + + XCTAssertTrue(plan.toDelete.isEmpty) + XCTAssertTrue(plan.toUpsert.isEmpty) + } + + func testCarriesTransferMetadataForwardOntoIncomingActivity() { + let stored = onchain(id: "tx1", isTransfer: true, channelId: "channel-1", transferTxId: "transfer-1") + let plan = HwSnapshotMerge.plan( + existing: [stored], + incoming: [.onchain(onchain(id: "tx1"))] + ) + + let merged = upserted(plan, id: "tx1") + XCTAssertEqual(merged?.isTransfer, true) + XCTAssertEqual(merged?.channelId, "channel-1") + XCTAssertEqual(merged?.transferTxId, "transfer-1") + } + + func testIncomingTransferMetadataWinsOverStoredNil() { + let plan = HwSnapshotMerge.plan( + existing: [onchain(id: "tx1")], + incoming: [.onchain(onchain(id: "tx1", isTransfer: true, channelId: "channel-2", transferTxId: "transfer-2"))] + ) + + let merged = upserted(plan, id: "tx1") + XCTAssertEqual(merged?.isTransfer, true) + XCTAssertEqual(merged?.channelId, "channel-2") + XCTAssertEqual(merged?.transferTxId, "transfer-2") + } + + func testMatchesStoredMetadataByTxIdNotActivityId() { + // Core can re-key an activity (e.g. a boost) while the txid stays the same. + let stored = onchain(id: "oldId", txId: "tx1", isTransfer: true, channelId: "channel-1") + let plan = HwSnapshotMerge.plan( + existing: [stored], + incoming: [.onchain(onchain(id: "newId", txId: "tx1"))] + ) + + XCTAssertTrue(plan.toDelete.isEmpty, "A transfer is never deleted, even when re-keyed") + XCTAssertEqual(upserted(plan, id: "newId")?.channelId, "channel-1") + } + + func testPassesThroughIncomingActivityWithNoStoredCounterpart() { + let plan = HwSnapshotMerge.plan(existing: [], incoming: [.onchain(onchain(id: "tx1"))]) + + XCTAssertTrue(plan.toDelete.isEmpty) + XCTAssertEqual(upserted(plan, id: "tx1")?.isTransfer, false) + } +} From 13146dfd59fa169826cdb892a0b0c3a464ab2365 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 12:54:46 -0300 Subject: [PATCH 3/7] feat: watcher's transaction details now reach Core, and persistence goes through the reconciling path --- Bitkit/Managers/HwWalletManager.swift | 127 +++++++++------- Bitkit/Services/CoreService.swift | 24 ---- BitkitTests/HwWalletManagerFundingTests.swift | 5 +- BitkitTests/HwWalletManagerTests.swift | 135 ++++++++++++++++-- 4 files changed, 204 insertions(+), 87 deletions(-) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 11a62a511..753525df2 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -43,9 +43,8 @@ final class HwWalletManager { private let monitoredTypesProvider: () -> Set private let electrumUrlProvider: () -> String private let networkProvider: () -> TrezorCoinType - private let persistActivities: ([Activity]) -> Void + private let persistSnapshot: (String, [Activity], [TransactionDetails]) -> Void private let deleteActivities: (String) -> Void - private let syncHardwareActivity: (OnchainActivity) -> Void // MARK: - Internal state @@ -77,9 +76,9 @@ final class HwWalletManager { /// event and sync. Pruned to the live device set on `updateDevices`/`removeDevice`. private var walletIdCache: [String: String] = [:] - /// Last activity set persisted per group wallet id, so an unchanged watcher event doesn't - /// re-upsert the whole history to core and fire a redundant activity-list reload. - private var lastPersisted: [String: [Activity]] = [:] + /// Cache key of the last snapshot persisted per group wallet id, so an unchanged watcher event + /// doesn't re-write the whole history to core and fire a redundant activity-list reload. + private var lastPersisted: [String: HwSnapshot] = [:] private var emittedReceivedTxIds: Set = [] private var listeners: [String: TrezorEventListener] = [:] @@ -89,9 +88,8 @@ final class HwWalletManager { monitoredTypes: (() -> Set)? = nil, electrumUrl: (() -> String)? = nil, network: (() -> TrezorCoinType)? = nil, - persistActivities: (([Activity]) -> Void)? = nil, - deleteActivities: ((String) -> Void)? = nil, - syncHardwareActivity: ((OnchainActivity) -> Void)? = nil + persistSnapshot: ((String, [Activity], [TransactionDetails]) -> Void)? = nil, + deleteActivities: ((String) -> Void)? = nil ) { self.watcherService = watcherService networkProvider = network ?? { OnChainHwService.appDefaultCoinType } @@ -99,26 +97,20 @@ final class HwWalletManager { Set(SettingsViewModel.shared.addressTypesToMonitor.map(\.stringValue)) } electrumUrlProvider = electrumUrl ?? { OnChainHwService.getElectrumUrl() } - self.persistActivities = persistActivities ?? { activities in - guard !activities.isEmpty else { return } + self.persistSnapshot = persistSnapshot ?? { walletId, activities, transactionDetails in Task { - try? await ServiceQueue.background(.core) { - try BitkitCore.upsertActivities(activities: activities) - CoreService.shared.activity.notifyActivitiesChanged() - } + try? await CoreService.shared.activity.replaceHwSnapshot( + walletId: walletId, + activities: activities, + transactionDetails: transactionDetails + ) } } self.deleteActivities = deleteActivities ?? { walletId in Task { - try? await ServiceQueue.background(.core) { - _ = try BitkitCore.deleteActivitiesByWalletId(walletId: walletId) - CoreService.shared.activity.notifyActivitiesChanged() - } + try? await CoreService.shared.activity.deleteByWalletId(walletId) } } - self.syncHardwareActivity = syncHardwareActivity ?? { activity in - Task { await CoreService.shared.activity.syncHardwareOnchainActivity(activity) } - } } // MARK: - Device input @@ -175,6 +167,16 @@ final class HwWalletManager { } } + /// The bitkit-core wallet id scoping a paired device's activities. Throws when the device is + /// unknown or has no captured xpubs, so callers that must write wallet-scoped data (e.g. the + /// pending Transfer To Spending activity) fail loudly rather than writing to the wrong wallet. + func walletId(forDevice deviceId: String) throws -> String { + guard let group = deviceGroups().first(where: { $0.ids.contains(deviceId) }) else { + throw AppError(message: "Unknown hardware wallet", debugMessage: "No wallet id for device '\(deviceId)'") + } + return group.walletId + } + // MARK: - Watcher orchestration /// Reconcile watchers in response to a settings change, but only when the monitored address @@ -333,24 +335,18 @@ final class HwWalletManager { /// Core builds the persistence-ready activities (core 0.3.4 watch-only watcher); the manager /// stores, aggregates, and scopes them to the device. func handleWatcherEvent(watcherId: String, event: WatcherEvent) { - guard case let .transactionsChanged(activities, _, balance, _, _, _) = event else { return } + guard case let .transactionsChanged(activities, transactionDetails, balance, _, _, _) = event else { return } let deviceId = deviceId(fromWatcherId: watcherId) let previous = watcherData[watcherId] watcherData[watcherId] = HwWatcherData( deviceId: deviceId, balanceSats: balance.total, - activities: activities + activities: activities, + transactionDetails: transactionDetails ) let groups = deviceGroups() recomputeDerivedState(groups: groups) - persistGroupActivities(forDevice: deviceId, groups: groups) - // Bridge watcher confirmation data to any matching main-wallet transfer activity (e.g. the - // funding tx of a transfer to spending), without clobbering its transfer metadata. - for activity in activities { - if case let .onchain(onchain) = activity { - syncHardwareActivity(onchain) - } - } + persistGroupSnapshot(forDevice: deviceId, groups: groups) emitReceivedTxs(previous: previous, activities: activities) } @@ -374,33 +370,59 @@ final class HwWalletManager { // MARK: - Persistence - private func persistGroupActivities(forDevice deviceId: String, groups: [DeviceGroup]? = nil) { + private func persistGroupSnapshot(forDevice deviceId: String, groups: [DeviceGroup]? = nil) { let groups = groups ?? deviceGroups() guard let group = groups.first(where: { $0.ids.contains(deviceId) }) else { return } - let merged = mergedActivities(for: group) - // Skip the core upsert + activity-list reload when nothing changed for this group. - guard lastPersisted[group.walletId] != merged else { return } - lastPersisted[group.walletId] = merged - persistActivities(merged) + let snapshot = mergedSnapshot(for: group) + // Skip the core write + activity-list reload when nothing meaningful changed for this group. + let cacheKey = snapshotCacheKey(for: snapshot) + guard lastPersisted[group.walletId] != cacheKey else { return } + lastPersisted[group.walletId] = cacheKey + persistSnapshot(group.walletId, snapshot.activities, snapshot.transactionDetails) + } + + /// An unconfirmed activity's timestamp is "now" from the watcher's perspective, so it ticks on + /// every poll even when nothing about the transaction changed. Zeroing it keeps a mempool + /// transaction from re-writing the whole group to core on each event. + private func snapshotCacheKey(for snapshot: HwSnapshot) -> HwSnapshot { + HwSnapshot( + activities: snapshot.activities.map { activity in + guard case var .onchain(onchain) = activity, !onchain.confirmed else { return activity } + onchain.timestamp = 0 + return .onchain(onchain) + }, + transactionDetails: snapshot.transactionDetails + ) } - /// Aggregate the activities core emitted across a device-group's watchers, scoping each to the - /// group's wallet id and deduping by activity id (so the same tx seen by two address-type - /// watchers persists once). Watchers are walked in sorted `watcherId` order and the result is - /// sorted by activity id, so a tx observed by multiple address-type watchers (which can carry + /// Aggregate what core emitted across a device-group's watchers, scoping each activity and + /// transaction-details row to the group's wallet id and deduping (so the same tx seen by two + /// address-type watchers persists once). Watchers are walked in sorted `watcherId` order and the + /// results are sorted by id, so a tx observed by multiple address-type watchers (which can carry /// different wallet-perspective directions) resolves to a deterministic winner — the /// highest-ordered watcherId — rather than depending on dictionary iteration order. - private func mergedActivities(for group: DeviceGroup) -> [Activity] { + private func mergedSnapshot(for group: DeviceGroup) -> HwSnapshot { let watchers = watcherData .filter { group.ids.contains($0.value.deviceId) } .sorted { $0.key < $1.key } .map(\.value) - var byId: [String: Activity] = [:] + + var activitiesById: [String: Activity] = [:] for activity in watchers.flatMap(\.activities) { let scoped = scopedToWallet(activity, walletId: group.walletId) - byId[activityId(of: scoped)] = scoped + activitiesById[scoped.activityId] = scoped } - return byId.values.sorted { activityId(of: $0) < activityId(of: $1) } + + var detailsByTxId: [String: TransactionDetails] = [:] + for var details in watchers.flatMap(\.transactionDetails) { + details.walletId = group.walletId + detailsByTxId[details.txId] = details + } + + return HwSnapshot( + activities: activitiesById.values.sorted { $0.activityId < $1.activityId }, + transactionDetails: detailsByTxId.values.sorted { $0.txId < $1.txId } + ) } private func scopedToWallet(_ activity: Activity, walletId: String) -> Activity { @@ -414,13 +436,6 @@ final class HwWalletManager { } } - private func activityId(of activity: Activity) -> String { - switch activity { - case let .onchain(onchain): return onchain.id - case let .lightning(lightning): return lightning.id - } - } - // MARK: - Aggregation private func recomputeDerivedState(groups: [DeviceGroup]? = nil) { @@ -690,5 +705,13 @@ final class HwWalletManager { let deviceId: String let balanceSats: UInt64 let activities: [Activity] + let transactionDetails: [TransactionDetails] + } + + /// The wallet-scoped set core last stored for a device group, plus the cache key that produced + /// it. See `HwWalletManager.snapshotCacheKey(for:)` for why the key is not the snapshot itself. + private struct HwSnapshot: Equatable { + let activities: [Activity] + let transactionDetails: [TransactionDetails] } } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 25d6716c4..e7d8a0445 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1219,30 +1219,6 @@ class ActivityService { } } - /// Update an existing hardware-wallet on-chain activity from watcher data (confirmation, fee), - /// preserving any transfer metadata already set on it. No-op when no matching activity exists. - func syncHardwareOnchainActivity(_ activity: OnchainActivity) async { - do { - try await ServiceQueue.background(.core) { - guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: activity.txId) else { return } - let confirmTimestamp = existing.confirmTimestamp - ?? (activity.confirmed ? (activity.confirmTimestamp ?? activity.timestamp) : nil) - var updated = existing - updated.confirmed = existing.confirmed || activity.confirmed - updated.confirmTimestamp = confirmTimestamp - updated.doesExist = activity.confirmed ? true : existing.doesExist - updated.fee = (existing.fee == 0 && activity.fee > 0) ? activity.fee : existing.fee - updated.updatedAt = max(existing.updatedAt ?? 0, activity.updatedAt ?? activity.timestamp) - guard updated != existing else { return } - try updateActivity(activityId: existing.id, activity: .onchain(updated)) - self.activitiesChangedSubject.send() - Logger.debug("Synced hardware onchain activity '\(activity.txId)'", context: "ActivityService") - } - } catch { - Logger.error("Failed to sync hardware activity '\(activity.txId)': \(error)", context: "ActivityService") - } - } - /// Atomically mark the on-chain activity for `txId` as a transfer associated with `channelId`, /// in a single core-queue transaction so a concurrent watcher sync can't clobber it. No-op when /// no matching activity exists or it is already correctly tagged. diff --git a/BitkitTests/HwWalletManagerFundingTests.swift b/BitkitTests/HwWalletManagerFundingTests.swift index 57ae08195..9e3c7f169 100644 --- a/BitkitTests/HwWalletManagerFundingTests.swift +++ b/BitkitTests/HwWalletManagerFundingTests.swift @@ -22,9 +22,8 @@ final class HwWalletManagerFundingTests: XCTestCase { monitoredTypes: { monitored }, electrumUrl: { "ssl://test:1" }, network: { .regtest }, - persistActivities: { _ in }, - deleteActivities: { _ in }, - syncHardwareActivity: { _ in } + persistSnapshot: { _, _, _ in }, + deleteActivities: { _ in } ) } diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift index 5db6d3d07..4d23582ac 100644 --- a/BitkitTests/HwWalletManagerTests.swift +++ b/BitkitTests/HwWalletManagerTests.swift @@ -65,14 +65,25 @@ final class HwWalletManagerTests: XCTestCase { func stopAllWatchers() {} } - private var persisted: [[Activity]] = [] + private struct PersistedSnapshot { + let walletId: String + let activities: [Activity] + let transactionDetails: [TransactionDetails] + } + + private var persistedSnapshots: [PersistedSnapshot] = [] private var deleted: [String] = [] private var receivedTxs: [HwWalletReceivedTx] = [] private var cancellables: Set = [] + /// The activity sets handed to core, oldest first — most assertions only care about these. + private var persisted: [[Activity]] { + persistedSnapshots.map(\.activities) + } + override func setUp() { super.setUp() - persisted = [] + persistedSnapshots = [] deleted = [] receivedTxs = [] cancellables = [] @@ -89,7 +100,11 @@ final class HwWalletManagerTests: XCTestCase { monitoredTypes: { monitored }, electrumUrl: { "ssl://test:1" }, network: { .regtest }, - persistActivities: { [weak self] in self?.persisted.append($0) }, + persistSnapshot: { [weak self] walletId, activities, transactionDetails in + self?.persistedSnapshots.append( + PersistedSnapshot(walletId: walletId, activities: activities, transactionDetails: transactionDetails) + ) + }, deleteActivities: { [weak self] in self?.deleted.append($0) } ) vm.receivedTxPublisher @@ -151,13 +166,45 @@ final class HwWalletManagerTests: XCTestCase { )) } - private func makeEvent(_ activities: [Activity], total: UInt64) -> WatcherEvent { + /// Mirrors the unconfirmed rows core emits while a transaction sits in the mempool: the + /// timestamp advances on every poll even though nothing about the transaction changed. + private func makeUnconfirmedActivity(txId: String, value: UInt64, timestamp: UInt64) -> Activity { + guard case var .onchain(onchain) = makeActivity(txId: txId, value: value, txType: .received) else { + fatalError("makeActivity must produce an onchain activity") + } + onchain.confirmed = false + onchain.confirmTimestamp = nil + onchain.timestamp = timestamp + return .onchain(onchain) + } + + private func makeTransactionDetails(txId: String, walletId: String = "") -> TransactionDetails { + TransactionDetails( + walletId: walletId, + txId: txId, + amountSats: 50000, + inputs: [TxInput(txid: "prev-\(txId)", vout: 0, scriptsig: "", witness: [], sequence: 0)], + outputs: [TxOutput( + scriptpubkey: "", + scriptpubkeyType: "v0_p2wpkh", + scriptpubkeyAddress: "bcrt1qout-\(txId)", + value: 50000, + n: 0 + )] + ) + } + + private func makeEvent( + _ activities: [Activity], + total: UInt64, + transactionDetails: [TransactionDetails] = [] + ) -> WatcherEvent { let balance = WalletBalance( confirmed: total, immature: 0, trustedPending: 0, untrustedPending: 0, spendable: total, total: total ) return .transactionsChanged( activities: activities, - transactionDetails: [], + transactionDetails: transactionDetails, balance: balance, txCount: UInt32(activities.count), blockHeight: 100, @@ -250,6 +297,78 @@ final class HwWalletManagerTests: XCTestCase { XCTAssertEqual(onchain.value, 40000) } + func testTransactionDetailsPersistedScopedToDeviceWalletId() throws { + let xpubs = ["nativeSegwit": "zpubNS"] + let device = makeDevice(id: "dev1", xpubs: xpubs) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: "dev1") + + vm.handleWatcherEvent(watcherId: watcherId("dev1", "nativeSegwit"), event: makeEvent( + [makeActivity(txId: "txABC", value: 50000, txType: .received)], + total: 50000, + transactionDetails: [makeTransactionDetails(txId: "txABC")] + )) + + // Without these, Explore has no inputs/outputs to show for a hardware row. + let expectedWalletId = try HwWalletId.derive(xpubs: xpubs) + XCTAssertEqual(persistedSnapshots.count, 1) + XCTAssertEqual(persistedSnapshots[0].walletId, expectedWalletId) + XCTAssertEqual(persistedSnapshots[0].transactionDetails.map(\.txId), ["txABC"]) + XCTAssertEqual(persistedSnapshots[0].transactionDetails.map(\.walletId), [expectedWalletId]) + } + + func testTransactionDetailsDedupedAcrossAddressTypeWatchers() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let shared = makeActivity(txId: "shared", value: 50000, txType: .received) + let details = makeTransactionDetails(txId: "shared") + + vm.handleWatcherEvent( + watcherId: watcherId("dev1", "nativeSegwit"), + event: makeEvent([shared], total: 50000, transactionDetails: [details]) + ) + vm.handleWatcherEvent( + watcherId: watcherId("dev1", "taproot"), + event: makeEvent([shared], total: 50000, transactionDetails: [details]) + ) + + XCTAssertEqual(persistedSnapshots.last?.transactionDetails.map(\.txId), ["shared"]) + } + + func testMempoolTimestampDriftDoesNotRepersist() { + let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) + let vm = makeViewModel() + vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) + let wid = watcherId("dev1", "nativeSegwit") + + vm.handleWatcherEvent(watcherId: wid, event: makeEvent( + [makeUnconfirmedActivity(txId: "tx1", value: 40000, timestamp: 1_700_000_000)], total: 40000 + )) + XCTAssertEqual(persistedSnapshots.count, 1) + + // Same mempool transaction, later poll: only the timestamp moved, so nothing is re-written. + vm.handleWatcherEvent(watcherId: wid, event: makeEvent( + [makeUnconfirmedActivity(txId: "tx1", value: 40000, timestamp: 1_700_000_030)], total: 40000 + )) + XCTAssertEqual(persistedSnapshots.count, 1) + + // Confirmation is a real change, so it persists. + vm.handleWatcherEvent(watcherId: wid, event: makeEvent( + [makeActivity(txId: "tx1", value: 40000, txType: .received)], total: 40000 + )) + XCTAssertEqual(persistedSnapshots.count, 2) + } + + func testWalletIdForDeviceMatchesDerivedIdAndThrowsForUnknownDevice() throws { + let xpubs = ["nativeSegwit": "zpubNS"] + let vm = makeViewModel() + vm.updateDevices(knownDevices: [makeDevice(id: "dev1", xpubs: xpubs)], connectedDeviceId: nil) + + XCTAssertEqual(try vm.walletId(forDevice: "dev1"), try HwWalletId.derive(xpubs: xpubs)) + XCTAssertThrowsError(try vm.walletId(forDevice: "unknown")) + } + func testUnchangedWatcherEventDoesNotRepersist() { let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zpubNS"]) let vm = makeViewModel() @@ -420,7 +539,7 @@ final class HwWalletManagerTests: XCTestCase { /// The same txid seen by two address-type watchers can carry different wallet-perspective /// directions; the merge must resolve to the same winner regardless of arrival order. func mergedTxType(nativeSegwitFirst: Bool) -> PaymentType? { - persisted = [] + persistedSnapshots = [] let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) let vm = makeViewModel() vm.updateDevices(knownDevices: [device], connectedDeviceId: nil) @@ -498,7 +617,7 @@ final class HwWalletManagerTests: XCTestCase { monitoredTypes: { monitored }, electrumUrl: { electrumCalls += 1; return electrum }, network: { .regtest }, - persistActivities: { _ in }, + persistSnapshot: { _, _, _ in }, deleteActivities: { _ in } ) let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS", "taproot": "zTR"]) @@ -530,7 +649,7 @@ final class HwWalletManagerTests: XCTestCase { monitoredTypes: { monitored }, electrumUrl: { "ssl://test:1" }, network: { .regtest }, - persistActivities: { _ in }, + persistSnapshot: { _, _, _ in }, deleteActivities: { _ in } ) let device = makeDevice(id: "dev1", xpubs: ["nativeSegwit": "zNS"]) From 8a8a160fe8a62ad429dfbcb1446328c966ed7655 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 13:16:50 -0300 Subject: [PATCH 4/7] feat: hardware-scoped transfer to spendings --- Bitkit/Extensions/Activity+Contact.swift | 16 ----- Bitkit/Services/CoreService.swift | 66 +++++++++++++++++-- Bitkit/Services/TransferService.swift | 14 +++- Bitkit/ViewModels/ActivityListViewModel.swift | 10 ++- Bitkit/ViewModels/TransferViewModel.swift | 21 +++++- BitkitTests/ActivityHardwareTests.swift | 26 +++----- BitkitTests/HwTransferMocks.swift | 7 ++ BitkitTests/TransferViewModelHwTests.swift | 17 +++++ 8 files changed, 127 insertions(+), 50 deletions(-) diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift index c50a52f63..a5ee48605 100644 --- a/Bitkit/Extensions/Activity+Contact.swift +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -50,19 +50,3 @@ extension Activity { } } } - -extension [Activity] { - /// Drop hardware-wallet on-chain rows whose tx already exists as a main-wallet activity, so a - /// transfer shows a single (main-wallet) row in the merged home / All Activity lists. The HW row - /// still appears on the hardware wallet detail screen (which fetches scoped to the device). - func withoutHardwareDuplicates() -> [Activity] { - let localTxIds = Set(compactMap { activity -> String? in - guard !activity.isHardwareWallet, case let .onchain(onchain) = activity else { return nil } - return onchain.txId - }) - return filter { activity in - guard activity.isHardwareWallet, case let .onchain(onchain) = activity else { return true } - return !localTxIds.contains(onchain.txId) - } - } -} diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index e7d8a0445..f89439c95 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1170,6 +1170,10 @@ class ActivityService { } /// Create sent onchain activity from send result so it appears immediately; LDK events update it later (e.g. confirmation). + /// + /// `walletId` scopes the row: a transfer funded from a watch-only hardware wallet is written + /// under that wallet's id, so the merged activity list shows one hardware-owned transfer row + /// rather than a main-wallet row plus a hardware duplicate. func createSentOnchainActivityFromSendResult( txid: String, address: String, @@ -1177,17 +1181,26 @@ class ActivityService { fee: UInt64, feeRate: UInt32, isTransfer: Bool = false, - contact: String? = nil + contact: String? = nil, + walletId: String = WalletScope.default ) async { do { try await ServiceQueue.background(.core) { - if let _ = try? BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txid) { + if let existing = try? BitkitCore.getActivityByTxId(walletId: walletId, txId: txid) { + // The watcher can persist a hardware transaction before this call lands, so the + // transfer flag still has to be applied to the row it already created. + if isTransfer, !existing.isTransfer { + var updated = existing + updated.isTransfer = true + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + } Logger.debug("Activity already exists for txid \(txid), skipping immediate creation", context: "ActivityService") return } let now = UInt64(Date().timeIntervalSince1970) let onchain = OnchainActivity( - walletId: WalletScope.default, + walletId: walletId, id: txid, txType: .sent, txId: txid, @@ -1219,13 +1232,24 @@ class ActivityService { } } + /// Every wallet id that currently owns at least one stored activity: the normal Bitkit wallet + /// plus one per paired watch-only hardware wallet. + func getWalletIds() async throws -> Set { + try await ServiceQueue.background(.core) { + try Self.storedWalletIds() + } + } + /// Atomically mark the on-chain activity for `txId` as a transfer associated with `channelId`, /// in a single core-queue transaction so a concurrent watcher sync can't clobber it. No-op when /// no matching activity exists or it is already correctly tagged. + /// + /// The funding transaction can live under the normal wallet or, when it was signed on a + /// hardware wallet, under that device's wallet id, so every scope is searched. func markOnchainActivityAsTransfer(txId: String, channelId: String) async { do { try await ServiceQueue.background(.core) { - guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId) else { return } + guard let existing = try Self.findOnchainActivityAcrossWallets(txId: txId) else { return } if existing.isTransfer, existing.channelId == channelId { return } var updated = existing updated.isTransfer = true @@ -1239,6 +1263,40 @@ class ActivityService { } } + /// Resolve the on-chain activity for `txId`, preferring the row that most likely represents the + /// transfer: one already flagged as a transfer, then a hardware-wallet send (the hardware + /// funding path), then whatever else matches. + private static func findOnchainActivityAcrossWallets(txId: String) throws -> OnchainActivity? { + let walletIds = try [WalletScope.default] + storedWalletIds().subtracting([WalletScope.default]).sorted() + let matches = walletIds.compactMap { try? BitkitCore.getActivityByTxId(walletId: $0, txId: txId) } + + return matches.first { $0.isTransfer } + ?? matches.first { $0.walletId != WalletScope.default && $0.txType == .sent } + ?? matches.first + } + + private static func storedWalletIds() throws -> Set { + let activities = try getActivities( + walletId: nil, + filter: .all, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil + ) + // Not `Activity.walletId`: this file is compiled into the notification and test targets, + // which do not build `Extensions/`. + return Set(activities.map { activity in + switch activity { + case let .lightning(lightning): return lightning.walletId + case let .onchain(onchain): return onchain.walletId + } + }) + } + func setContact(_ publicKey: String?, forActivity id: String) async throws { let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 831f4ff54..3f24ffbe5 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -75,8 +75,15 @@ class TransferService { /// Create the pending on-chain activity for a hardware-wallet transfer to spending. The funding /// tx is broadcast externally (on-device signing), so LDK's own activity sync won't surface it; - /// this makes it appear immediately, marked as a transfer. - func createPendingToSpendingActivity(order: IBtOrder, txId: String, fee: UInt64, feeRate: UInt64) async { + /// this makes it appear immediately, marked as a transfer and scoped to the hardware wallet that + /// funded it, so it reads as one hardware-owned transfer row. + func createPendingToSpendingActivity( + order: IBtOrder, + txId: String, + fee: UInt64, + feeRate: UInt64, + walletId: String = WalletScope.default + ) async { guard let address = order.payment?.onchain?.address, !address.isEmpty else { Logger.error("Order '\(order.id)' has no on-chain payment address", context: "TransferService") return @@ -87,7 +94,8 @@ class TransferService { amount: order.feeSat, fee: fee, feeRate: UInt32(feeRate), - isTransfer: true + isTransfer: true, + walletId: walletId ) } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index bfbbc866f..97bc44675 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -141,7 +141,7 @@ class ActivityListViewModel: ObservableObject { // Fetch extra to account for potential filtering of replaced transactions. // walletId nil → global: merges the Bitkit wallet with watch-only hardware wallets. let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3, walletId: nil) - let filtered = await filterOutReplacedSentTransactions(latest).withoutHardwareDuplicates() + let filtered = await filterOutReplacedSentTransactions(latest) latestActivities = Array(filtered.prefix(Int(limitLatest))) // Fetch all activities @@ -190,8 +190,7 @@ class ActivityListViewModel: ObservableObject { } // Apply base filtering. walletId nil → global so the All Activity list merges the - // Bitkit wallet with watch-only hardware wallets (tag filters exclude hw items, which - // carry no tags, matching bitkit-android). + // Bitkit wallet with watch-only hardware wallets. let baseFilteredActivities = try await coreService.activity.get( filter: .all, tags: selectedTags.isEmpty ? nil : Array(selectedTags), @@ -201,9 +200,8 @@ class ActivityListViewModel: ObservableObject { walletId: nil ) - // Filter out replaced sent transactions that appear in another transaction's boostTxIds, - // then collapse hardware-wallet duplicates of the same funding tx into the main-wallet row. - let filteredOutReplaced = await filterOutReplacedSentTransactions(baseFilteredActivities).withoutHardwareDuplicates() + // Filter out replaced sent transactions that appear in another transaction's boostTxIds. + let filteredOutReplaced = await filterOutReplacedSentTransactions(baseFilteredActivities) // Apply tab filtering filteredActivities = filterActivitiesByTab(filteredOutReplaced, selectedTab: selectedTab) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index b68ab74f5..efdc0ddfd 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -63,6 +63,9 @@ enum HwTransferError: Error, Equatable { /// declared as a protocol so the flow stays testable. @MainActor protocol HwTransferFunding: Sendable { + /// bitkit-core wallet id scoping the device's activities, so a transfer funded from it is + /// recorded against that wallet rather than the normal Bitkit wallet. + func walletId(forDevice deviceId: String) throws -> String func getFundingAccount(deviceId: String, addressType: AddressScriptType) throws -> HwFundingAccount func maxSpendableFunding( deviceId: String, @@ -344,7 +347,8 @@ class TransferViewModel: ObservableObject { fee: UInt64 = 0, feeRate: UInt64 = 0, txTotalSats: UInt64? = nil, - preTransferOnchainSats: UInt64? = nil + preTransferOnchainSats: UInt64? = nil, + activityWalletId: String = WalletScope.default ) async { do { let transferId = try await transferService.createTransfer( @@ -362,7 +366,13 @@ class TransferViewModel: ObservableObject { } if createTransferActivity { - await transferService.createPendingToSpendingActivity(order: order, txId: txId, fee: fee, feeRate: feeRate) + await transferService.createPendingToSpendingActivity( + order: order, + txId: txId, + fee: fee, + feeRate: feeRate, + walletId: activityWalletId + ) } lightningSetupStep = 0 @@ -580,6 +590,10 @@ class TransferViewModel: ObservableObject { } do { + // Resolved before signing: without it the transfer activity would be recorded + // against the wrong wallet, so fail before anything is broadcast. + let activityWalletId = try hwSigner.funding.walletId(forDevice: deviceId) + let signedTx: HwFundingSignedTx if let pending = pendingHwFundingBroadcast, pending.matches(order: order, deviceId: deviceId, address: address) { signedTx = pending.signedTx @@ -608,7 +622,8 @@ class TransferViewModel: ObservableObject { txId: result.txId, createTransferActivity: true, fee: result.miningFeeSats, - feeRate: result.feeRate + feeRate: result.feeRate, + activityWalletId: activityWalletId ) activeHwTransferDeviceId = nil hwFundingComplete = true diff --git a/BitkitTests/ActivityHardwareTests.swift b/BitkitTests/ActivityHardwareTests.swift index 10ad50195..651808349 100644 --- a/BitkitTests/ActivityHardwareTests.swift +++ b/BitkitTests/ActivityHardwareTests.swift @@ -61,24 +61,14 @@ final class ActivityHardwareTests: XCTestCase { XCTAssertFalse(lightning(walletId: WalletScope.default).isHardwareWallet) } - func testWithoutHardwareDuplicatesCollapsesSharedTx() { - let mainTransfer = onchain(walletId: WalletScope.default, txId: "tx1") - let hwDuplicate = onchain(walletId: "trezor:abc", txId: "tx1") - let hwOnly = onchain(walletId: "trezor:abc", txId: "tx2") - let ln = lightning(walletId: WalletScope.default) + /// A hardware transfer's funding tx is now stored only under the hardware wallet id, so the + /// merged list no longer collapses same-txid rows: activity identity is (walletId, activityId). + func testSameTxIdInTwoWalletScopesAreDistinctActivities() { + let main = onchain(walletId: WalletScope.default, txId: "tx1") + let hardware = onchain(walletId: "trezor:abc", txId: "tx1") - let result = [mainTransfer, hwDuplicate, hwOnly, ln].withoutHardwareDuplicates() - - func txIds(hardware: Bool) -> [String] { - result.compactMap { activity in - guard activity.isHardwareWallet == hardware, case let .onchain(onchain) = activity else { return nil } - return onchain.txId - } - } - - XCTAssertEqual(result.count, 3, "the hardware duplicate of tx1 is dropped") - XCTAssertEqual(txIds(hardware: false), ["tx1"], "the main-wallet transfer row is kept") - XCTAssertEqual(txIds(hardware: true), ["tx2"], "hardware-only tx2 is kept, tx1 duplicate removed") - XCTAssertTrue(result.contains { if case .lightning = $0 { return true }; return false }, "lightning rows are untouched") + XCTAssertNotEqual(main, hardware) + XCTAssertEqual(main.activityId, hardware.activityId) + XCTAssertEqual(Set([main, hardware]).count, 2) } } diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index bf6904ac5..e05250c02 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -20,6 +20,8 @@ final class MockHwFunding: HwTransferFunding { var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) var signedTx = HwFundingSignedTx(serializedTx: "rawtx", miningFeeSats: 141, feeRate: 1, totalSpent: 43186) var broadcastTxId = "txid" + var walletId = "trezor:wallet" + var walletIdError: Error? private(set) var composeCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] private(set) var estimateCalls: [(address: String, sats: UInt64, satsPerVByte: UInt64)] = [] @@ -27,6 +29,11 @@ final class MockHwFunding: HwTransferFunding { private(set) var signCalls = 0 private(set) var broadcastCalls = 0 + func walletId(forDevice _: String) throws -> String { + if let walletIdError { throw walletIdError } + return walletId + } + func getFundingAccount(deviceId _: String, addressType _: AddressScriptType) throws -> HwFundingAccount { if let accountError { throw accountError } return account diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 06a786910..87e0c2700 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -96,6 +96,23 @@ final class TransferViewModelHwTests: XCTestCase { XCTAssertTrue(connecting.staleDisconnects.isEmpty) } + func testUnresolvableWalletIdFailsBeforeSigningOrBroadcasting() async { + let funding = MockHwFunding() + funding.walletIdError = MockHwFunding.TestError() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + + vm.onTransferToSpendingHwConfirm(order: .mock(), deviceId: "dev1") + await awaitSigningComplete(vm) + + // Recording the transfer against the wrong wallet is worse than not transferring at all, + // so the flow aborts before the device is asked to sign. + if case .generic = vm.hwTransferError {} else { XCTFail("expected .generic error, got \(String(describing: vm.hwTransferError))") } + XCTAssertEqual(funding.signCalls, 0) + XCTAssertEqual(funding.broadcastCalls, 0) + XCTAssertFalse(vm.hwFundingComplete) + } + func testBroadcastFailureRetainsSignedTransactionForRetry() async { let funding = MockHwFunding() funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") From 723fc2cb6b493d6df7d94710409f51d45318da29 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 13:40:34 -0300 Subject: [PATCH 5/7] feat: remaining wallet-scope core service mutations --- Bitkit/Services/CoreService.swift | 111 ++++++++++-------- Bitkit/ViewModels/ActivityListViewModel.swift | 24 ++-- .../Activity/ActivityExplorerView.swift | 2 +- .../Wallets/Activity/ActivityItemView.swift | 4 +- .../Wallets/Activity/ActivityRowOnchain.swift | 2 +- 5 files changed, 83 insertions(+), 60 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index f89439c95..7b9ed8d3f 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -71,26 +71,26 @@ class ActivityService { // MARK: - BoostTxIds Cache - /// Cached set of transaction IDs that appear in boostTxIds (for filtering replaced transactions) - private var cachedTxIdsInBoostTxIds: Set = [] + /// Cached transaction IDs that appear in boostTxIds, per wallet id (for filtering replaced + /// transactions). Scoped because a boost chain only ever exists within one wallet. + private var cachedTxIdsInBoostTxIds: [String: Set] = [:] /// Get the set of transaction IDs that appear in boostTxIds (cached for performance) - func getTxIdsInBoostTxIds() async -> Set { - if cachedTxIdsInBoostTxIds.isEmpty { - await refreshBoostTxIdsCache() + func getTxIdsInBoostTxIds(walletId: String = WalletScope.default) async -> Set { + if cachedTxIdsInBoostTxIds[walletId] == nil { + await refreshBoostTxIdsCache(walletId: walletId) } - return cachedTxIdsInBoostTxIds + return cachedTxIdsInBoostTxIds[walletId] ?? [] } private func updateBoostTxIdsCache(for activity: Activity) { - if case let .onchain(onchain) = activity { - cachedTxIdsInBoostTxIds.formUnion(onchain.boostTxIds) - } + guard case let .onchain(onchain) = activity, !onchain.boostTxIds.isEmpty else { return } + cachedTxIdsInBoostTxIds[onchain.walletId, default: []].formUnion(onchain.boostTxIds) } - private func refreshBoostTxIdsCache() async { + private func refreshBoostTxIdsCache(walletId: String = WalletScope.default) async { do { - let allOnchainActivities = try await get(filter: .onchain) + let allOnchainActivities = try await get(filter: .onchain, walletId: walletId) var txIds: Set = [] for activity in allOnchainActivities { if case let .onchain(onchain) = activity { @@ -99,10 +99,10 @@ class ActivityService { } let txIdsToCache = txIds await MainActor.run { - self.cachedTxIdsInBoostTxIds = txIdsToCache + self.cachedTxIdsInBoostTxIds[walletId] = txIdsToCache } } catch { - Logger.error("Failed to refresh boostTxIds cache: \(error)", context: "ActivityService") + Logger.error("Failed to refresh boostTxIds cache for '\(walletId)': \(error)", context: "ActivityService") } } @@ -153,9 +153,9 @@ class ActivityService { // MARK: - Seen Tracking - func isActivitySeen(id: String) async -> Bool { + func isActivitySeen(id: String, walletId: String = WalletScope.default) async -> Bool { do { - if let activity = try getActivityById(walletId: WalletScope.default, activityId: id) { + if let activity = try getActivityById(walletId: walletId, activityId: id) { switch activity { case let .onchain(onchain): return onchain.seenAt != nil @@ -169,17 +169,17 @@ class ActivityService { return false } - func isOnchainActivitySeen(txid: String) async -> Bool { - let activity = try? await getOnchainActivityByTxId(txid: txid) + func isOnchainActivitySeen(txid: String, walletId: String = WalletScope.default) async -> Bool { + let activity = try? await getOnchainActivityByTxId(txid: txid, walletId: walletId) return activity?.seenAt != nil } - func markActivityAsSeen(id: String, seenAt: UInt64? = nil) async { + func markActivityAsSeen(id: String, walletId: String = WalletScope.default, seenAt: UInt64? = nil) async { let timestamp = seenAt ?? UInt64(Date().timeIntervalSince1970) do { try await ServiceQueue.background(.core) { - try BitkitCore.markActivityAsSeen(walletId: WalletScope.default, activityId: id, seenAt: timestamp) + try BitkitCore.markActivityAsSeen(walletId: walletId, activityId: id, seenAt: timestamp) self.activitiesChangedSubject.send() } } catch { @@ -187,42 +187,44 @@ class ActivityService { } } - func markOnchainActivityAsSeen(txid: String, seenAt: UInt64? = nil) async { + func markOnchainActivityAsSeen(txid: String, walletId: String = WalletScope.default, seenAt: UInt64? = nil) async { do { - guard let activity = try await getOnchainActivityByTxId(txid: txid) else { + guard let activity = try await getOnchainActivityByTxId(txid: txid, walletId: walletId) else { return } - await markActivityAsSeen(id: activity.id, seenAt: seenAt) + await markActivityAsSeen(id: activity.id, walletId: activity.walletId, seenAt: seenAt) } catch { Logger.error("Failed to mark onchain activity for \(txid) as seen: \(error)", context: "ActivityService") } } + /// Marks every unseen activity across all wallets as seen, each under its own wallet id. func markAllUnseenActivitiesAsSeen() async { let timestamp = UInt64(Date().timeIntervalSince1970) do { - let activities = try await get() + let activities = try await get(walletId: nil) var didMarkAny = false for activity in activities { let id: String + let walletId: String let isSeen: Bool switch activity { case let .onchain(onchain): id = onchain.id + walletId = onchain.walletId isSeen = onchain.seenAt != nil case let .lightning(lightning): id = lightning.id + walletId = lightning.walletId isSeen = lightning.seenAt != nil } if !isSeen { try await ServiceQueue.background(.core) { - try BitkitCore.markActivityAsSeen( - walletId: WalletScope.default, activityId: id, seenAt: timestamp - ) + try BitkitCore.markActivityAsSeen(walletId: walletId, activityId: id, seenAt: timestamp) } didMarkAny = true } @@ -311,19 +313,19 @@ class ActivityService { /// Get doesExist status for boostTxIds to determine RBF vs CPFP. RBF transactions have doesExist = false (replaced), CPFP transactions have /// doesExist = true (child transactions). - func getBoostTxDoesExist(boostTxIds: [String]) async -> [String: Bool] { + func getBoostTxDoesExist(boostTxIds: [String], walletId: String = WalletScope.default) async -> [String: Bool] { var doesExistMap: [String: Bool] = [:] for boostTxId in boostTxIds { - if let boostActivity = try? await getOnchainActivityByTxId(txid: boostTxId) { + if let boostActivity = try? await getOnchainActivityByTxId(txid: boostTxId, walletId: walletId) { doesExistMap[boostTxId] = boostActivity.doesExist } } return doesExistMap } - func isCpfpChildTransaction(txId: String) async -> Bool { - guard await getTxIdsInBoostTxIds().contains(txId), - let activity = try? await getOnchainActivityByTxId(txid: txId) + func isCpfpChildTransaction(txId: String, walletId: String = WalletScope.default) async -> Bool { + guard await getTxIdsInBoostTxIds(walletId: walletId).contains(txId), + let activity = try? await getOnchainActivityByTxId(txid: txId, walletId: walletId) else { return false } @@ -335,11 +337,12 @@ class ActivityService { addressSearchCoordinator = AddressSearchCoordinator() } + /// Deletes every activity in every wallet, including paired hardware wallets. func removeAll() async throws { try await ServiceQueue.background(.core) { // Get all activities and delete them one by one let activities = try getActivities( - walletId: WalletScope.default, + walletId: nil, filter: .all, txType: nil, tags: nil, @@ -350,12 +353,12 @@ class ActivityService { sortDirection: nil ) for activity in activities { - let id: String = switch activity { - case let .lightning(ln): ln.id - case let .onchain(on): on.id + let (id, walletId): (String, String) = switch activity { + case let .lightning(ln): (ln.id, ln.walletId) + case let .onchain(on): (on.id, on.walletId) } - _ = try deleteActivityById(walletId: WalletScope.default, activityId: id) + _ = try deleteActivityById(walletId: walletId, activityId: id) } // Clear cache since all activities are deleted @@ -1077,9 +1080,9 @@ class ActivityService { } } - func getOnchainActivityByTxId(txid: String) async throws -> OnchainActivity? { + func getOnchainActivityByTxId(txid: String, walletId: String = WalletScope.default) async throws -> OnchainActivity? { try await ServiceQueue.background(.core) { - try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txid) + try BitkitCore.getActivityByTxId(walletId: walletId, txId: txid) } } @@ -1297,12 +1300,12 @@ class ActivityService { }) } - func setContact(_ publicKey: String?, forActivity id: String) async throws { + func setContact(_ publicKey: String?, forActivity id: String, walletId: String = WalletScope.default) async throws { let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } try await ServiceQueue.background(.core) { - guard let activity = try getActivityById(walletId: WalletScope.default, activityId: id) ?? (try? BitkitCore.getActivityByTxId( - walletId: WalletScope.default, + guard let activity = try getActivityById(walletId: walletId, activityId: id) ?? (try? BitkitCore.getActivityByTxId( + walletId: walletId, txId: id )).map(Activity.onchain) else { throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found") @@ -1324,7 +1327,11 @@ class ActivityService { try updateActivity(activityId: onchain.id, activity: .onchain(onchain)) } - let replacementContactChanged = try self.updateReplacementContactIfNeeded(for: onchain, normalizedContact: normalizedContact) + let replacementContactChanged = try self.updateReplacementContactIfNeeded( + for: onchain, + normalizedContact: normalizedContact, + walletId: walletId + ) if contactChanged || replacementContactChanged { self.activitiesChangedSubject.send() } @@ -1332,11 +1339,15 @@ class ActivityService { } } - private func updateReplacementContactIfNeeded(for activity: OnchainActivity, normalizedContact: String?) throws -> Bool { + private func updateReplacementContactIfNeeded( + for activity: OnchainActivity, + normalizedContact: String?, + walletId: String = WalletScope.default + ) throws -> Bool { guard !activity.doesExist, activity.txType == .sent else { return false } let activities = try getActivities( - walletId: WalletScope.default, + walletId: walletId, filter: .onchain, txType: nil, tags: nil, @@ -1357,15 +1368,15 @@ class ActivityService { return didUpdate } - func delete(id: String) async throws -> Bool { + func delete(id: String, walletId: String = WalletScope.default) async throws -> Bool { try await ServiceQueue.background(.core) { // Rebuild cache if deleting an onchain activity with boostTxIds - let activity = try? getActivityById(walletId: WalletScope.default, activityId: id) + let activity = try? getActivityById(walletId: walletId, activityId: id) if let activity, case let .onchain(onchain) = activity, !onchain.boostTxIds.isEmpty { - await self.refreshBoostTxIdsCache() + await self.refreshBoostTxIdsCache(walletId: walletId) } - let result = try deleteActivityById(walletId: WalletScope.default, activityId: id) + let result = try deleteActivityById(walletId: walletId, activityId: id) self.activitiesChangedSubject.send() return result } @@ -1399,9 +1410,11 @@ class ActivityService { } } + /// Tags for the normal Bitkit wallet only. Watch-only hardware wallets are re-derived from the + /// device on pairing, so their tags are deliberately left out of the backup payload. func getAllActivitiesTags() async throws -> [ActivityTags] { try await ServiceQueue.background(.core) { - try BitkitCore.getAllActivitiesTags() + try BitkitCore.getAllActivitiesTags().filter { $0.walletId == WalletScope.default } } } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 97bc44675..be5cec21d 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -279,12 +279,17 @@ class ActivityListViewModel: ObservableObject { try await coreService.activity.get(contact: publicKey, sortDirection: .desc) } - func setContact(_ contactPublicKey: String?, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { + func setContact( + _ contactPublicKey: String?, + forPaymentId paymentId: String, + walletId: String = WalletScope.default, + syncLdkPayments: Bool = true + ) async throws { if syncLdkPayments { try? await syncLdkNodePayments() } - try await coreService.activity.setContact(contactPublicKey, forActivity: paymentId) + try await coreService.activity.setContact(contactPublicKey, forActivity: paymentId, walletId: walletId) await syncState() } @@ -292,8 +297,8 @@ class ActivityListViewModel: ObservableObject { try await coreService.activity.allPossibleTags() } - func appendTags(toActivity activityId: String, tags: [String]) async throws { - try await coreService.activity.appendTags(toActivity: activityId, tags) + func appendTags(toActivity activityId: String, tags: [String], walletId: String = WalletScope.default) async throws { + try await coreService.activity.appendTags(toActivity: activityId, tags, walletId: walletId) // Refresh the activities after adding a tag await syncState() } @@ -469,10 +474,15 @@ extension ActivityListViewModel { /// Filter out replaced sent transactions that appear in another transaction's boostTxIds private func filterOutReplacedSentTransactions(_ activities: [Activity]) async -> [Activity] { - // Get cached set of txIds that appear in boostTxIds - let txIdsInBoostTxIds = await coreService.activity.getTxIdsInBoostTxIds() + // Boost chains never cross wallets, so each wallet is checked against its own cached set. + var txIdsInBoostTxIdsByWallet: [String: Set] = [:] + for walletId in Set(activities.map(\.walletId)) { + txIdsInBoostTxIdsByWallet[walletId] = await coreService.activity.getTxIdsInBoostTxIds(walletId: walletId) + } - return activities.filter { !$0.isReplacedSentTransaction(txIdsInBoostTxIds: txIdsInBoostTxIds) } + return activities.filter { + !$0.isReplacedSentTransaction(txIdsInBoostTxIds: txIdsInBoostTxIdsByWallet[$0.walletId] ?? []) + } } /// Filter activities based on the selected tab diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 3a8f0bffb..0f8d10dbb 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -63,7 +63,7 @@ struct ActivityExplorerView: View { private func loadBoostTxDoesExist() async { guard let onchain else { return } - let doesExistMap = await CoreService.shared.activity.getBoostTxDoesExist(boostTxIds: onchain.boostTxIds) + let doesExistMap = await CoreService.shared.activity.getBoostTxDoesExist(boostTxIds: onchain.boostTxIds, walletId: onchain.walletId) await MainActor.run { boostTxDoesExist = doesExistMap } diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 5de9a1084..b38db7b5f 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -177,7 +177,7 @@ struct ActivityItemView: View { private func loadBoostTxDoesExist() async { guard case let .onchain(activity) = viewModel.activity else { return } - let doesExistMap = await CoreService.shared.activity.getBoostTxDoesExist(boostTxIds: activity.boostTxIds) + let doesExistMap = await CoreService.shared.activity.getBoostTxDoesExist(boostTxIds: activity.boostTxIds, walletId: activity.walletId) await MainActor.run { boostTxDoesExist = doesExistMap } @@ -261,7 +261,7 @@ struct ActivityItemView: View { .task { // Check if this is a CPFP child transaction if case let .onchain(activity) = viewModel.activity { - isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: activity.txId) + isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: activity.txId, walletId: activity.walletId) } // Load boostTxIds doesExist status to determine RBF vs CPFP diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index 3695b2fca..4bca724ca 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -97,7 +97,7 @@ struct ActivityRowOnchain: View { MoneyCell(sats: amount, prefix: amountPrefix, enableHide: true) } .task { - isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: item.txId) + isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: item.txId, walletId: item.walletId) } } } From 4587f7be715e1d849bc311221fb475a5c30134da Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 13:47:13 -0300 Subject: [PATCH 6/7] feat: HW tags and contacts integration --- Bitkit/Extensions/Activity+Contact.swift | 5 +- Bitkit/MainNavView.swift | 8 ++- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/ViewModels/NavigationViewModel.swift | 2 +- Bitkit/ViewModels/SheetViewModel.swift | 5 +- .../Activity/ActivityExplorerView.swift | 12 ++++ .../Wallets/Activity/ActivityItemView.swift | 65 ++++++++----------- .../Activity/AssignActivityContactView.swift | 3 +- Bitkit/Views/Wallets/Sheets/AddTagSheet.swift | 17 ++++- 9 files changed, 69 insertions(+), 49 deletions(-) diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift index a5ee48605..d0fe4c67a 100644 --- a/Bitkit/Extensions/Activity+Contact.swift +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -23,9 +23,8 @@ extension Activity { } /// Whether this activity belongs to a watch-only hardware wallet (not the normal Bitkit wallet). - // TODO: Used as an interim feature gate (see ActivityItemView.isHardwareActivity). The - // wallet-id shorthand holds only while CoreService activity mutations are default-scoped; - // replace with a real capability check when wallet_id mutation support lands. + /// Drives the blue hardware icon and the boost gate — a watch-only wallet has no signing keys, + /// so RBF/CPFP is impossible for it. var isHardwareWallet: Bool { walletId != WalletScope.default } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 700f4f0a2..c576850b0 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -475,8 +475,12 @@ struct MainNavView: View { if isPaykitUIActive { ContactDetailView(publicKey: publicKey, showsDeleteAction: true) } else { paykitDisabledRedirectView } case let .contactActivity(publicKey): if isPaykitUIActive { ContactActivityView(publicKey: publicKey) } else { paykitDisabledRedirectView } - case let .assignActivityContact(activityId): - if isPaykitUIActive { AssignActivityContactView(activityId: activityId) } else { paykitDisabledRedirectView } + case let .assignActivityContact(activityId, walletId): + if isPaykitUIActive { + AssignActivityContactView(activityId: activityId, walletId: walletId) + } else { + paykitDisabledRedirectView + } case .contactImportOverview: if !isPaykitUIActive { paykitDisabledRedirectView diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 477a3f230..519e18174 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1392,6 +1392,7 @@ "wallet__activity_address" = "Address"; "wallet__activity_input" = "{count, plural, one {INPUT} other {INPUTS (#)}}"; "wallet__activity_output" = "{count, plural, one {OUTPUT} other {OUTPUTS (#)}}"; +"wallet__activity_details_unavailable" = "Transaction details are not available."; "wallet__activity_boosted_cpfp" = "BOOSTED TRANSACTION {num} (CPFP)"; "wallet__activity_boosted_rbf" = "BOOSTED TRANSACTION {num} (RBF)"; "wallet__activity_boost_fee" = "Boost Fee"; diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 8bde824b8..f8c128097 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -15,7 +15,7 @@ enum Route: Hashable { case contactDetail(publicKey: String) case contactSaved(publicKey: String) case contactActivity(publicKey: String) - case assignActivityContact(activityId: String) + case assignActivityContact(activityId: String, walletId: String = WalletScope.default) case contactImportOverview case contactImportSelect case addContact(publicKey: String) diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 64ebe3d90..e9dda2f3e 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -110,9 +110,8 @@ class SheetViewModel: ObservableObject { var addTagSheetItem: AddTagSheetItem? { get { guard let config = activeSheetConfiguration, config.id == .addTag else { return nil } - let addTagConfig = config.data as? AddTagConfig - guard let activityId = addTagConfig?.activityId else { return nil } - return AddTagSheetItem(activityId: activityId) + guard let addTagConfig = config.data as? AddTagConfig else { return nil } + return AddTagSheetItem(activityId: addTagConfig.activityId, walletId: addTagConfig.walletId) } set { if newValue == nil { diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 0f8d10dbb..52466387d 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -10,6 +10,9 @@ struct ActivityExplorerView: View { @State private var item: Activity @State private var txDetails: BitkitCore.TransactionDetails? + /// Distinguishes "still fetching" from "core has no details for this transaction", so the + /// inputs/outputs section reads as pending rather than silently absent. + @State private var isLoadingTxDetails = true @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist init(item: Activity) { @@ -54,9 +57,11 @@ struct ActivityExplorerView: View { ) await MainActor.run { txDetails = details + isLoadingTxDetails = false } } catch { Logger.error("Failed to load transaction details for \(onchain.txId): \(error)", context: "ActivityExplorerView") + await MainActor.run { isLoadingTxDetails = false } } } @@ -208,6 +213,13 @@ struct ActivityExplorerView: View { } } .padding(.bottom, 16) + } else if isLoadingTxDetails { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.bottom, 16) + } else { + BodySSBText(t("wallet__activity_details_unavailable"), textColor: .white64) + .padding(.bottom, 16) } if !onchain.boostTxIds.isEmpty { diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index b38db7b5f..ec05e45d1 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -135,11 +135,6 @@ struct ActivityItemView: View { return DateFormatterHelpers.formatActivityDetail(activity.timestamp) } - // TODO: Interim feature-gating. Tag/contact/boost are hidden for hardware activities - // because CoreService's activity-mutation methods (markActivityAsSeen, setContact, delete, - // tag, boost) still hardcode WalletScope.default and can't address a non-default wallet id. - // Revisit once those methods are wallet_id-scoped (and wallet_id / hardware activities ship - // to production) so this becomes a real capability check rather than a wallet-id shorthand. private var isHardwareActivity: Bool { viewModel.activity.isHardwareWallet } @@ -527,45 +522,41 @@ struct ActivityItemView: View { private var buttons: some View { VStack(spacing: 16) { - // Contact and tag actions are hidden for watch-only hardware wallets. - if !isHardwareActivity { - HStack(spacing: 16) { - if isPaykitUIActive { - CustomButton( - title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, - icon: Image(assignedContact == nil ? "user-plus" : "user-minus") - .foregroundColor(accentColor), - shouldExpand: true - ) { - if assignedContact == nil { - navigation.navigate(.assignActivityContact(activityId: viewModel.activityId)) - } else { - Task { - await detachContact() - } - } - } - .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") - } - + HStack(spacing: 16) { + if isPaykitUIActive { CustomButton( - title: t("wallet__activity_tag"), size: .small, - icon: Image("tag") + title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, + icon: Image(assignedContact == nil ? "user-plus" : "user-minus") .foregroundColor(accentColor), shouldExpand: true ) { - let activityId: String = switch viewModel.activity { - case let .lightning(activity): - activity.id - case let .onchain(activity): - activity.id + if assignedContact == nil { + navigation.navigate( + .assignActivityContact(activityId: viewModel.activityId, walletId: viewModel.activity.walletId) + ) + } else { + Task { + await detachContact() + } } - sheets.showSheet(.addTag, data: AddTagConfig(activityId: activityId)) } - .accessibilityIdentifier("ActivityTag") + .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") + } + + CustomButton( + title: t("wallet__activity_tag"), size: .small, + icon: Image("tag") + .foregroundColor(accentColor), + shouldExpand: true + ) { + sheets.showSheet( + .addTag, + data: AddTagConfig(activityId: viewModel.activityId, walletId: viewModel.activity.walletId) + ) } - .frame(maxWidth: .infinity) + .accessibilityIdentifier("ActivityTag") } + .frame(maxWidth: .infinity) HStack(spacing: 16) { CustomButton( @@ -615,7 +606,7 @@ struct ActivityItemView: View { private func detachContact() async { do { - try await activityList.setContact(nil, forPaymentId: viewModel.activityId) + try await activityList.setContact(nil, forPaymentId: viewModel.activityId, walletId: viewModel.activity.walletId) await viewModel.refreshActivity() } catch { Logger.error("Failed to detach contact from activity \(viewModel.activityId): \(error)", context: "ActivityItemView") diff --git a/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift b/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift index ee32f2d58..12de7fd3d 100644 --- a/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift +++ b/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift @@ -7,6 +7,7 @@ struct AssignActivityContactView: View { @EnvironmentObject private var navigation: NavigationViewModel let activityId: String + var walletId: String = WalletScope.default @State private var selectedContactKey: String? private var contacts: [PubkyContact] { @@ -59,7 +60,7 @@ struct AssignActivityContactView: View { defer { selectedContactKey = nil } do { - try await activityList.setContact(contact.publicKey, forPaymentId: activityId) + try await activityList.setContact(contact.publicKey, forPaymentId: activityId, walletId: walletId) navigation.navigateBack() } catch { Logger.error("Failed to assign contact to activity \(activityId): \(error)", context: "AssignActivityContactView") diff --git a/Bitkit/Views/Wallets/Sheets/AddTagSheet.swift b/Bitkit/Views/Wallets/Sheets/AddTagSheet.swift index ae99deace..d36d6ef32 100644 --- a/Bitkit/Views/Wallets/Sheets/AddTagSheet.swift +++ b/Bitkit/Views/Wallets/Sheets/AddTagSheet.swift @@ -2,15 +2,28 @@ import SwiftUI struct AddTagConfig { let activityId: String + /// Wallet scoping the activity — an activity id is only unique within its wallet. + let walletId: String + + init(activityId: String, walletId: String = WalletScope.default) { + self.activityId = activityId + self.walletId = walletId + } } struct AddTagSheetItem: SheetItem, Equatable { let id: SheetID = .addTag let size: SheetSize = .small let activityId: String + let walletId: String + + init(activityId: String, walletId: String = WalletScope.default) { + self.activityId = activityId + self.walletId = walletId + } static func == (lhs: AddTagSheetItem, rhs: AddTagSheetItem) -> Bool { - return lhs.activityId == rhs.activityId + return lhs.activityId == rhs.activityId && lhs.walletId == rhs.walletId } } @@ -59,7 +72,7 @@ struct AddTagSheet: View { do { tagManager.addToLastUsedTags(tag) - try await activityListViewModel.appendTags(toActivity: config.activityId, tags: [tag]) + try await activityListViewModel.appendTags(toActivity: config.activityId, tags: [tag], walletId: config.walletId) sheets.hideSheet() } catch { app.toast(type: .error, title: "Failed to add tag", description: error.localizedDescription) From 6682f9460465aa3a579de89d9ceb22c10b1d48db Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 31 Jul 2026 14:17:21 -0300 Subject: [PATCH 7/7] fix: HW refresh flows --- Bitkit/Services/CoreService.swift | 2 +- Bitkit/ViewModels/ActivityItemViewModel.swift | 10 ++++++++-- changelog.d/next/648.changed.md | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 changelog.d/next/648.changed.md diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7b9ed8d3f..5ab705062 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -412,7 +412,7 @@ class ActivityService { try upsertTransactionDetails(detailsList: transactionDetails) } - await self.refreshBoostTxIdsCache() + await self.refreshBoostTxIdsCache(walletId: walletId) self.activitiesChangedSubject.send() return try getActivities( diff --git a/Bitkit/ViewModels/ActivityItemViewModel.swift b/Bitkit/ViewModels/ActivityItemViewModel.swift index 993ca68c4..ca305cda1 100644 --- a/Bitkit/ViewModels/ActivityItemViewModel.swift +++ b/Bitkit/ViewModels/ActivityItemViewModel.swift @@ -67,8 +67,14 @@ class ActivityItemViewModel: ObservableObject { for attempt in 1 ... 3 { Logger.debug("Attempt \(attempt) to find RBF replacement", context: "ActivityItemViewModel.refreshActivity") - // Get all recent activities to find potential replacement - let recentActivities = try await coreService.activity.get(filter: .onchain, limit: 50) + // Get all recent activities to find potential replacement. A hardware row can + // now disappear when its transaction drops out of a watcher snapshot, so the + // lookup has to stay in the activity's own wallet. + let recentActivities = try await coreService.activity.get( + filter: .onchain, + limit: 50, + walletId: activity.walletId + ) // Look for a boosted transaction that could be our replacement for recentActivity in recentActivities { diff --git a/changelog.d/next/648.changed.md b/changelog.d/next/648.changed.md new file mode 100644 index 000000000..1ecf8608d --- /dev/null +++ b/changelog.d/next/648.changed.md @@ -0,0 +1 @@ +Hardware wallet transactions are now stored with the rest of your activity, so they keep their tags, contacts and transaction details, show inputs and outputs under Explore, disappear when a transaction is replaced, and a transfer to spending funded from a hardware wallet shows as a single transfer.