From c9c0f1469d342ef8ea01909274e09737740a7835 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 25 Nov 2025 19:52:58 -0500 Subject: [PATCH 01/17] Integrate LDK onchain events --- Bitkit.xcodeproj/project.pbxproj | 36 +- .../xcshareddata/swiftpm/Package.resolved | 6 +- Bitkit/AppScene.swift | 11 - Bitkit/Services/CoreService.swift | 770 ++++++++++++------ Bitkit/Services/LightningService.swift | 123 ++- Bitkit/Utilities/AddressChecker.swift | 110 --- Bitkit/ViewModels/AppViewModel.swift | 98 ++- Bitkit/ViewModels/WalletViewModel.swift | 84 +- .../Activity/ActivityExplorerView.swift | 53 +- .../Wallets/Activity/ActivityItemView.swift | 39 +- Bitkit/Views/Wallets/Sheets/BoostSheet.swift | 4 - BitkitNotification/NotificationService.swift | 35 + 12 files changed, 889 insertions(+), 480 deletions(-) delete mode 100644 Bitkit/Utilities/AddressChecker.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 720f1086..3318f43e 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 186523ED2ED7365100485B41 /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 186523EC2ED7365100485B41 /* LDKNode */; }; 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; @@ -17,7 +18,6 @@ 96204B782DE9AA43007BAA26 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 96204B772DE9AA43007BAA26 /* SQLite */; }; 966DE6702C51210000A7B0EF /* LightningDevKit in Frameworks */ = {isa = PBXBuildFile; productRef = 966DE66F2C51210000A7B0EF /* LightningDevKit */; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; - 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; 96E20CD42CB6D91A00C24149 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 96E20CD32CB6D91A00C24149 /* CodeScanner */; }; @@ -94,7 +94,6 @@ Services/MigrationsService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, - Utilities/AddressChecker.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, @@ -118,7 +117,6 @@ Services/LightningService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, - Utilities/AddressChecker.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, @@ -148,8 +146,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */, 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */, + 186523ED2ED7365100485B41 /* LDKNode in Frameworks */, 4AFCA3722E0596D900205CAE /* Zip in Frameworks */, 96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */, 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */, @@ -241,8 +239,8 @@ 96E493A72C943184000E8BC2 /* secp256k1 */, 96DEA03B2DE8BBAB009932BF /* BitkitCore */, 4AFCA3712E0596D900205CAE /* Zip */, - 968FE13F2DFB016B0053CD7F /* LDKNode */, 18D65E012EB964BD00252335 /* VssRustClientFfi */, + 186523EC2ED7365100485B41 /* LDKNode */, ); productName = BitkitNotification; productReference = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; @@ -383,9 +381,9 @@ 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */, 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */, 4AFCA36E2E05933800205CAE /* XCRemoteSwiftPackageReference "Zip" */, - 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */, 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, + 186523EB2ED7365100485B41 /* XCRemoteSwiftPackageReference "ldk-node" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -876,6 +874,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 186523EB2ED7365100485B41 /* XCRemoteSwiftPackageReference "ldk-node" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/synonymdev/ldk-node"; + requirement = { + branch = main; + kind = branch; + }; + }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; @@ -924,14 +930,6 @@ minimumVersion = 0.0.123; }; }; - 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/synonymdev/ldk-node"; - requirement = { - branch = main; - kind = branch; - }; - }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; @@ -959,6 +957,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 186523EC2ED7365100485B41 /* LDKNode */ = { + isa = XCSwiftPackageProductDependency; + package = 186523EB2ED7365100485B41 /* XCRemoteSwiftPackageReference "ldk-node" */; + productName = LDKNode; + }; 18D65DFF2EB964B500252335 /* VssRustClientFfi */ = { isa = XCSwiftPackageProductDependency; package = 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */; @@ -1004,11 +1007,6 @@ package = 966DE66E2C51210000A7B0EF /* XCRemoteSwiftPackageReference "ldk-swift" */; productName = LightningDevKit; }; - 968FE13F2DFB016B0053CD7F /* LDKNode */ = { - isa = XCSwiftPackageProductDependency; - package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */; - productName = LDKNode; - }; 96DEA0392DE8BBA1009932BF /* BitkitCore */ = { isa = XCSwiftPackageProductDependency; package = 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 398a9a60..d9c5bd63 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "afebf53471e2eb2f8c45a9c3fbd45b779930aea0b8a7bf2fd223d03d490867a3", + "originHash" : "986c104f099eeeb614245353ef0f880b2f062a10340e337e439d6f066790bffd", "pins" : [ { "identity" : "bitkit-core", @@ -7,7 +7,7 @@ "location" : "https://github.com/synonymdev/bitkit-core", "state" : { "branch" : "master", - "revision" : "d2b12e1e5c1724f8a00facc6247009986408c9d9" + "revision" : "c16b360d45518474769803545a45920e6bcd1fed" } }, { @@ -25,7 +25,7 @@ "location" : "https://github.com/synonymdev/ldk-node", "state" : { "branch" : "main", - "revision" : "508f038bfcda46b4bc621cd3df0bc08dcf592fa7" + "revision" : "90cab7b4839cc1108fafcfd95f5dadcdd0f8e745" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index bee619c5..83df9fe3 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -71,7 +71,6 @@ struct AppScene: View { .onChange(of: currency.hasStaleData, perform: handleCurrencyStaleData) .onChange(of: wallet.walletExists, perform: handleWalletExistsChange) .onChange(of: wallet.nodeLifecycleState, perform: handleNodeLifecycleChange) - .onChange(of: wallet.totalBalanceSats, perform: handleBalanceChange) .onChange(of: scenePhase, perform: handleScenePhaseChange) .environmentObject(app) .environmentObject(navigation) @@ -215,11 +214,6 @@ struct AppScene: View { app?.handleLdkNodeEvent(lightningEvent) } - wallet.addOnEvent(id: "activity-sync") { [weak activity] (_: Event) in - // TODO: this might not be the best for performace to sync all payments on every event. Could switch to habdling the specific event. - Task { try? await activity?.syncLdkNodePayments() } - } - if wallet.isRestoringWallet { Task { await BackupService.shared.performFullRestoreFromLatestBackup() @@ -284,11 +278,6 @@ struct AppScene: View { } } - private func handleBalanceChange(_: Int) { - // Anytime we receive a balance update, we should sync the payments to activity list - Task { try? await activity.syncLdkNodePayments() } - } - private func handleScenePhaseChange(_: ScenePhase) { // If PIN is enabled, lock the app when the app goes to the background if scenePhase == .background && settings.pinEnabled { diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 20441fde..c2b95294 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -20,15 +20,128 @@ class ActivityService { metadataChangedSubject.eraseToAnyPublisher() } - // Maximum address index to search when current address exists - // This provides a reasonable upper bound for address derivation search + // MARK: - Constants + + /// Maximum address index to search when current address exists private static let maxAddressSearchIndex: UInt32 = 100_000 - // Track replacement transactions (RBF): newTxId -> parent/original txIds - private static var replacementTransactions: [String: [String]] = [:] + // MARK: - Transaction Status Checks + + func wasTransactionReplaced(txid: String) async -> Bool { + // Check if the activity exists and is marked as replaced + if let onchain = try? getOnchainActivityByTxId(txid: txid), + !onchain.doesExist + { + return true + } + + return false + } + + func shouldShowReceivedSheet(txid: String, value: UInt64) async -> Bool { + if value == 0 { + return false + } + + // Don't show sheet for channel closure transactions + if await findClosedChannelForTransaction(txid: txid, transactionDetails: nil) != nil { + return false + } + + do { + // Check if this transaction's activity has boostTxIds (meaning it replaced other transactions) + // If any of the replaced transactions have the same value, don't show the sheet + guard let onchain = try? getOnchainActivityByTxId(txid: txid), + !onchain.boostTxIds.isEmpty + else { + return true + } + + // This transaction replaced others - check if any have the same value + let twoMinutesAgo = UInt64(Date().timeIntervalSince1970) - 120 + let activities = try getActivities( + filter: .onchain, + txType: nil, + tags: nil, + search: nil, + minDate: twoMinutesAgo, + maxDate: nil, + limit: 50, + sortDirection: nil + ) + + for replacedTxid in onchain.boostTxIds { + if let replacedActivity = activities.first(where: { activity in + if case let .onchain(existing) = activity { + return existing.txId == replacedTxid + } + return false + }), + case let .onchain(replaced) = replacedActivity + { + // Check if the replaced transaction has the same value + if replaced.value == value { + Logger.info( + "Skipping received sheet for replacement transaction \(txid) with same value as replaced transaction \(replacedTxid)", + context: "CoreService.shouldShowReceivedSheet" + ) + return false + } + } + } + } catch { + Logger.error("Failed to check existing activities for replacement: \(error)", context: "CoreService.shouldShowReceivedSheet") + } + + return true + } + + func isReceivedTransaction(txid: String) async -> Bool { + guard let payments = LightningService.shared.payments, + let payment = payments.first(where: { payment in + if case let .onchain(paymentTxid, _) = payment.kind { + return paymentTxid == txid + } + return false + }) + else { return false } - // Track replaced transactions that should be ignored during sync - private static var replacedTransactions: Set = [] + return payment.direction == .inbound + } + + // MARK: - Activity Lookup + + // TODO: Add id filter in bitkit-core + func getOnchainActivityByTxId(txid: String) throws -> OnchainActivity? { + let activities = try getActivities( + filter: .onchain, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil + ) + for activity in activities { + if case let .onchain(onchain) = activity, onchain.txId == txid { + return onchain + } + } + return nil + } + + /// 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] { + var doesExistMap: [String: Bool] = [:] + for boostTxId in boostTxIds { + if let boostActivity = try? getOnchainActivityByTxId(txid: boostTxId) { + doesExistMap[boostTxId] = boostActivity.doesExist + } + } + return doesExistMap + } init(coreService: CoreService) { self.coreService = coreService @@ -89,212 +202,364 @@ class ActivityService { } } - func syncLdkNodePayments(_ payments: [PaymentDetails]) async throws { + // MARK: - Payment Processing + + private func processOnchainPayment( + _ payment: PaymentDetails, + transactionDetails: TransactionDetails? = nil + ) async throws { + guard case let .onchain(txid, _) = payment.kind else { return } + + let paymentTimestamp = payment.latestUpdateTimestamp + let existingActivity = try getActivityById(activityId: payment.id) + + // Skip if existing activity has newer timestamp to avoid overwriting local data + if let existingActivity, case let .onchain(existing) = existingActivity { + let existingUpdatedAt = existing.updatedAt ?? 0 + if existingUpdatedAt > paymentTimestamp { + return + } + } + + // Determine confirmation status from payment's txStatus + let value = payment.amountSats ?? 0 + + // Determine confirmation status from payment's txStatus + // Ensure confirmTimestamp is at least equal to paymentTimestamp when confirmed + // This handles cases where payment.latestUpdateTimestamp is more recent than blockTimestamp + let (isConfirmed, confirmedTimestamp): (Bool, UInt64?) = + if case let .onchain(_, txStatus) = payment.kind, + case let .confirmed(_, _, blockTimestamp) = txStatus { + (true, max(blockTimestamp, paymentTimestamp)) + } else { + (false, nil) + } + + // Extract existing activity data + let existingOnchain: OnchainActivity? = { + if let existingActivity, case let .onchain(existing) = existingActivity { + return existing + } + return nil + }() + + let isBoosted = existingOnchain?.isBoosted ?? false + let boostTxIds = existingOnchain?.boostTxIds ?? [] + var isTransfer = existingOnchain?.isTransfer ?? false + var channelId = existingOnchain?.channelId + let transferTxId = existingOnchain?.transferTxId + let feeRate = existingOnchain?.feeRate ?? 1 + let preservedAddress = existingOnchain?.address ?? "Loading..." + let doesExist = existingOnchain?.doesExist ?? true + + // Check if this transaction is a channel transfer + if channelId == nil || !isTransfer { + let foundChannelId = await findChannelForTransaction( + txid: txid, + direction: payment.direction, + transactionDetails: transactionDetails + ) + if let foundChannelId { + channelId = foundChannelId + isTransfer = true + } + } + + // Find receiving address for inbound transactions + var address = preservedAddress + if payment.direction == .inbound { + do { + if let foundAddress = try await findReceivingAddress( + for: txid, + value: value, + transactionDetails: transactionDetails + ) { + address = foundAddress + } + } catch { + Logger.error("Failed to find address for txid \(txid): \(error)", context: "CoreService.processOnchainPayment") + } + } + + // Build and save the activity + let finalDoesExist = isConfirmed ? true : doesExist + + let onchain = OnchainActivity( + id: payment.id, + txType: payment.direction == .outbound ? .sent : .received, + txId: txid, + value: value, + fee: (payment.feePaidMsat ?? 0) / 1000, + feeRate: feeRate, + address: address, + confirmed: isConfirmed, + timestamp: paymentTimestamp, + isBoosted: isBoosted, + boostTxIds: boostTxIds, + isTransfer: isTransfer, + doesExist: finalDoesExist, + confirmTimestamp: confirmedTimestamp, + channelId: channelId, + transferTxId: transferTxId, + createdAt: UInt64(payment.creationTime.timeIntervalSince1970), + updatedAt: paymentTimestamp + ) + + if existingActivity != nil { + try await update(id: payment.id, activity: .onchain(onchain)) + } else { + try await upsert(.onchain(onchain)) + } + } + + // MARK: - Onchain Event Handlers + + private func processOnchainTransaction(txid: String, details: TransactionDetails, context: String) async throws { + guard let payments = LightningService.shared.payments else { + Logger.warn("No payments available for transaction \(txid)", context: context) + return + } + + guard let payment = payments.first(where: { payment in + if case let .onchain(paymentTxid, _) = payment.kind { + return paymentTxid == txid + } + return false + }) else { + Logger.warn("Payment not found for transaction \(txid) - LDK should have updated payment store before emitting event", context: context) + return + } + + try await processOnchainPayment(payment, transactionDetails: details) + } + + func handleOnchainTransactionReceived(txid: String, details: TransactionDetails) async throws { try await ServiceQueue.background(.core) { - var addedCount = 0 - var updatedCount = 0 - var latestCaughtError: Error? + try await self.processOnchainTransaction(txid: txid, details: details, context: "CoreService.handleOnchainTransactionReceived") + } + } - for payment in payments { - do { - let state: BitkitCore.PaymentState = switch payment.status { - case .failed: - .failed - case .pending: - .pending - case .succeeded: - .succeeded + func handleOnchainTransactionConfirmed(txid: String, details: TransactionDetails) async throws { + try await ServiceQueue.background(.core) { + try await self.processOnchainTransaction(txid: txid, details: details, context: "CoreService.handleOnchainTransactionConfirmed") + } + } + + func handleOnchainTransactionReplaced(txid: String, conflicts: [String]) async throws { + try await ServiceQueue.background(.core) { + // Find the activity for the replaced transaction + let replacedActivity = try self.getOnchainActivityByTxId(txid: txid) + + if var existing = replacedActivity { + Logger.info( + "Transaction \(txid) replaced by \(conflicts.count) conflict(s): \(conflicts.joined(separator: ", "))", + context: "CoreService.handleOnchainTransactionReplaced" + ) + + // Mark the replaced transaction as not existing + existing.doesExist = false + existing.updatedAt = UInt64(Date().timeIntervalSince1970) + try await self.update(id: existing.id, activity: .onchain(existing)) + Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") + } else { + Logger.info( + "Activity not found for replaced transaction \(txid) - will be created when transaction is processed", + context: "CoreService.handleOnchainTransactionReplaced" + ) + } + + // For each replacement transaction, update its boostTxIds to include the replaced txid + for conflictTxid in conflicts { + // Try to get the replacement activity, or process it if it doesn't exist + var replacementActivity = try? self.getOnchainActivityByTxId(txid: conflictTxid) + + if replacementActivity == nil, + let payments = LightningService.shared.payments, + let replacementPayment = payments.first(where: { payment in + if case let .onchain(paymentTxid, _) = payment.kind { + return paymentTxid == conflictTxid + } + return false + }) + { + Logger.info( + "Processing replacement transaction \(conflictTxid) that was already in payments list", + context: "CoreService.handleOnchainTransactionReplaced" + ) + do { + try await self.processOnchainPayment(replacementPayment, transactionDetails: nil) + replacementActivity = try? self.getOnchainActivityByTxId(txid: conflictTxid) + } catch { + Logger.error( + "Failed to process replacement transaction \(conflictTxid): \(error)", + context: "CoreService.handleOnchainTransactionReplaced" + ) + continue } + } - if case let .onchain(txid, txStatus) = payment.kind { - // Check if this transaction was replaced by RBF and should be ignored - if ActivityService.replacedTransactions.contains(txid) { - Logger.debug("Ignoring replaced transaction \(txid) during sync", context: "CoreService.syncLdkNodePayments") - continue - } + // Update the replacement transaction's boostTxIds to include the replaced txid + if var activity = replacementActivity, + !activity.boostTxIds.contains(txid) + { + activity.boostTxIds.append(txid) + activity.isBoosted = true + activity.updatedAt = UInt64(Date().timeIntervalSince1970) + try await self.update(id: activity.id, activity: .onchain(activity)) + Logger.info( + "Updated replacement transaction \(conflictTxid) with boostTxId \(txid)", + context: "CoreService.handleOnchainTransactionReplaced" + ) + } + } - let paymentTimestamp = payment.latestUpdateTimestamp + self.activitiesChangedSubject.send() + } + } - let existingActivity = try getActivityById(activityId: payment.id) + func handleOnchainTransactionReorged(txid: String) async throws { + try await ServiceQueue.background(.core) { + guard var onchain = try self.getOnchainActivityByTxId(txid: txid) else { + Logger.warn("Activity not found for reorged transaction \(txid)", context: "CoreService.handleOnchainTransactionReorged") + return + } - // Check if existing activity has newer updatedAt timestamp - skip update to avoid overwriting newer local data - if let existingActivity, case let .onchain(existing) = existingActivity { - let existingUpdatedAt = existing.updatedAt ?? 0 - if existingUpdatedAt > paymentTimestamp { - continue - } - } + onchain.confirmed = false + onchain.confirmTimestamp = nil + onchain.updatedAt = UInt64(Date().timeIntervalSince1970) - var isConfirmed = false - var confirmedTimestamp: UInt64? - if case let .confirmed(blockHash, height, blockTimestamp) = txStatus { - isConfirmed = true - confirmedTimestamp = blockTimestamp - } + try await self.update(id: onchain.id, activity: .onchain(onchain)) + } + } - // Ensure confirmTimestamp is at least equal to paymentTimestamp when confirmed - if isConfirmed && confirmedTimestamp != nil && confirmedTimestamp! < paymentTimestamp { - confirmedTimestamp = paymentTimestamp - } + func handleOnchainTransactionEvicted(txid: String) async throws { + try await ServiceQueue.background(.core) { + guard var onchain = try self.getOnchainActivityByTxId(txid: txid) else { + Logger.warn("Activity not found for evicted transaction \(txid)", context: "CoreService.handleOnchainTransactionEvicted") + return + } - let existingOnchain: OnchainActivity? = { - if let existingActivity, case let .onchain(existing) = existingActivity { - return existing - } - return nil - }() - let preservedIsBoosted = existingOnchain?.isBoosted ?? false - let preservedBoostTxIds = existingOnchain?.boostTxIds ?? [] - var preservedIsTransfer = existingOnchain?.isTransfer ?? false - var preservedChannelId = existingOnchain?.channelId - let preservedTransferTxId = existingOnchain?.transferTxId - let preservedFeeRate = existingOnchain?.feeRate ?? 1 - let preservedAddress = existingOnchain?.address ?? "Loading..." - let preservedDoesExist = existingOnchain?.doesExist ?? true - - // Check if this transaction is a channel transfer (open or close) - if preservedChannelId == nil || !preservedIsTransfer { - let channelId = await self.findChannelForTransaction(txid: txid, direction: payment.direction) - - if let channelId { - preservedChannelId = channelId - preservedIsTransfer = true - } - } + onchain.doesExist = false + onchain.updatedAt = UInt64(Date().timeIntervalSince1970) - // Check if this is a replacement transaction (RBF) that should be marked as boosted - let isReplacementTransaction = ActivityService.replacementTransactions.keys.contains(txid) - let shouldMarkAsBoosted = preservedIsBoosted || isReplacementTransaction + try await self.update(id: onchain.id, activity: .onchain(onchain)) + } + } - // Capture tracked parents for replacement transactions (RBF) before removing from tracking - let trackedParents: [String] = { - if isReplacementTransaction { - return ActivityService.replacementTransactions[txid] ?? [] - } - return [] - }() - - // Use tracked parents when this is a replacement; otherwise keep preserved - let boostTxIds = isReplacementTransaction ? trackedParents : preservedBoostTxIds - - if isReplacementTransaction { - Logger.debug("Found replacement transaction \(txid), marking as boosted", context: "CoreService.syncLdkNodePayments") - // Remove from tracking map since we've processed it - ActivityService.replacementTransactions.removeValue(forKey: txid) - - // Also clean up any old replaced transactions that might be lingering - // This helps prevent the replacedTransactions set from growing indefinitely - if ActivityService.replacedTransactions.count > 10 { - Logger.debug("Cleaning up old replaced transactions", context: "CoreService.syncLdkNodePayments") - ActivityService.replacedTransactions.removeAll() - } - } + // MARK: - Lightning Event Handlers - guard let value = payment.amountSats, value > 0 else { - Logger.warn("Ignoring payment with missing value, probably additional boosted tx") - return - } + /// Handle a single payment event by processing the specific payment + func handlePaymentEvent(paymentHash: String) async throws { + try await ServiceQueue.background(.core) { + guard let payments = LightningService.shared.payments else { + Logger.warn("No payments available for hash \(paymentHash)", context: "CoreService.handlePaymentEvent") + return + } - // Find the address for the transaction - // Outbound txs have address set in bitkit-core automatically from the pre-activity metadata - var address: String? = nil - if payment.direction == .inbound { - do { - address = try await self.findReceivingAddress(for: txid, value: value) - } catch { - Logger.warn("Failed to find address for txid \(txid): \(error)", context: "CoreService.syncLdkNodePayments") - } - } + if let payment = payments.first(where: { $0.id == paymentHash }) { + try await self.processLightningPayment(payment) + } else { + Logger.info("Payment not found for hash \(paymentHash) - syncing all payments", context: "CoreService.handlePaymentEvent") + try await self.syncLdkNodePayments(payments) + } + } + } - let finalAddress = address ?? preservedAddress - let finalFeeRate = preservedFeeRate - let finalIsTransfer = preservedIsTransfer - let finalChannelId = preservedChannelId - let finalTransferTxId = preservedTransferTxId - - // If confirmed, set doesExist to true; otherwise preserve existing value - let finalDoesExist = isConfirmed ? true : preservedDoesExist - - let onchain = OnchainActivity( - id: payment.id, - txType: payment.direction == .outbound ? .sent : .received, - txId: txid, - value: value, - fee: (payment.feePaidMsat ?? 0) / 1000, - feeRate: finalFeeRate, - address: finalAddress, - confirmed: isConfirmed, - timestamp: paymentTimestamp, - isBoosted: shouldMarkAsBoosted, // Mark as boosted if it's a replacement transaction - boostTxIds: boostTxIds, - isTransfer: finalIsTransfer, - doesExist: finalDoesExist, - confirmTimestamp: confirmedTimestamp, - channelId: finalChannelId, - transferTxId: finalTransferTxId, - createdAt: UInt64(payment.creationTime.timeIntervalSince1970), - updatedAt: paymentTimestamp - ) + private func processLightningPayment(_ payment: PaymentDetails) async throws { + guard case let .bolt11(hash, preimage, secret, description, bolt11) = payment.kind else { return } - if existingActivity != nil { - try updateActivity(activityId: payment.id, activity: .onchain(onchain)) - print(payment) - updatedCount += 1 - } else { - try upsertActivity(activity: .onchain(onchain)) - print(payment) - addedCount += 1 - } + // Skip pending inbound payments - just means they created an invoice + guard !(payment.status == .pending && payment.direction == .inbound) else { return } - // If a removed transaction confirms, mark its replacement transactions as removed - if !preservedDoesExist && isConfirmed { - try await self.markReplacementTransactionsAsRemoved(originalTxId: txid) - } - } else if case let .bolt11(hash, preimage, secret, description, bolt11) = payment.kind { - // Skip pending inbound payments, just means they created an invoice - guard !(payment.status == .pending && payment.direction == .inbound) else { continue } + let paymentTimestamp = UInt64(payment.latestUpdateTimestamp) + let existingActivity = try getActivityById(activityId: payment.id) - let paymentTimestamp = UInt64(payment.latestUpdateTimestamp) + // Skip if existing activity has newer timestamp to avoid overwriting local data + if let existingActivity, case let .lightning(existing) = existingActivity { + let existingUpdatedAt = existing.updatedAt ?? 0 + if existingUpdatedAt > paymentTimestamp { + return + } + } - // Get existing activity early to check updatedAt before processing - let existingActivity = try getActivityById(activityId: payment.id) + let state: BitkitCore.PaymentState = switch payment.status { + case .failed: .failed + case .pending: .pending + case .succeeded: .succeeded + } + + let ln = LightningActivity( + id: payment.id, + txType: payment.direction == .outbound ? .sent : .received, + status: state, + value: UInt64(payment.amountSats ?? 0), + fee: (payment.feePaidMsat ?? 0) / 1000, + invoice: bolt11 ?? "No invoice", + message: description ?? "", + timestamp: paymentTimestamp, + preimage: preimage, + createdAt: paymentTimestamp, + updatedAt: paymentTimestamp + ) - // Check if existing activity has newer updatedAt timestamp - skip update to avoid overwriting newer local data - if let existingActivity, case let .lightning(existing) = existingActivity { - let existingUpdatedAt = existing.updatedAt ?? 0 - if existingUpdatedAt > paymentTimestamp { - continue - } - } + if existingActivity != nil { + try await update(id: payment.id, activity: .lightning(ln)) + } else { + try await upsert(.lightning(ln)) + } + } - let ln = LightningActivity( - id: payment.id, - txType: payment.direction == .outbound ? .sent : .received, - status: state, - value: UInt64(payment.amountSats ?? 0), - fee: (payment.feePaidMsat ?? 0) / 1000, - invoice: bolt11 ?? "No invoice", - message: description ?? "", - timestamp: paymentTimestamp, - preimage: preimage, - createdAt: paymentTimestamp, - updatedAt: paymentTimestamp - ) + /// Sync all LDK node payments to activities + /// Use for initial wallet load, manual refresh, or after operations that create new payments. + /// Events handle individual payment updates, so this should not be called on every event. + func syncLdkNodePayments(_ payments: [PaymentDetails]) async throws { + try await ServiceQueue.background(.core) { + var addedCount = 0 + var updatedCount = 0 + var latestCaughtError: Error? + + for payment in payments { + do { + let state: BitkitCore.PaymentState = switch payment.status { + case .failed: + .failed + case .pending: + .pending + case .succeeded: + .succeeded + } - if existingActivity != nil { - try updateActivity(activityId: payment.id, activity: .lightning(ln)) - updatedCount += 1 - } else { - try upsertActivity(activity: .lightning(ln)) - addedCount += 1 + if case let .onchain(txid, _) = payment.kind { + do { + let hadExistingActivity = try getActivityById(activityId: payment.id) != nil + try await self.processOnchainPayment(payment, transactionDetails: nil) + if hadExistingActivity { + updatedCount += 1 + } else { + addedCount += 1 + } + } catch { + Logger.error("Error processing onchain payment \(txid): \(error)", context: "CoreService.syncLdkNodePayments") + latestCaughtError = error + } + } else if case .bolt11 = payment.kind { + do { + let hadExistingActivity = try getActivityById(activityId: payment.id) != nil + try await self.processLightningPayment(payment) + if hadExistingActivity { + updatedCount += 1 + } else { + addedCount += 1 + } + } catch { + Logger.error("Error processing lightning payment \(payment.id): \(error)", context: "CoreService.syncLdkNodePayments") + latestCaughtError = error } } } catch { Logger.error("Error syncing LDK payment: \(error)", context: "CoreService") latestCaughtError = error } - - // case spontaneous(hash: PaymentHash, preimage: PaymentPreimage?) } // If any of the inserts failed, we want to throw the error up @@ -308,40 +573,13 @@ class ActivityService { } /// Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms - private func markReplacementTransactionsAsRemoved(originalTxId: String) async throws { - let allActivities = try getActivities( - filter: .onchain, - txType: nil, - tags: nil, - search: nil, - minDate: nil, - maxDate: nil, - limit: nil, - sortDirection: nil - ) - - for activity in allActivities { - guard case let .onchain(onchainActivity) = activity else { continue } - - if onchainActivity.boostTxIds.contains(originalTxId) && onchainActivity.doesExist { - Logger.info( - "Marking replacement transaction \(onchainActivity.txId) as doesExist = false (original \(originalTxId) confirmed)", - context: "CoreService.markReplacementTransactionsAsRemoved" - ) - - var updatedActivity = onchainActivity - updatedActivity.doesExist = false - try updateActivity(activityId: onchainActivity.id, activity: .onchain(updatedActivity)) - } - } - } - /// Finds the channel ID associated with a transaction based on its direction - private func findChannelForTransaction(txid: String, direction: PaymentDirection) async -> String? { + private func findChannelForTransaction(txid: String, direction: PaymentDirection, transactionDetails: TransactionDetails? = nil) async -> String? + { switch direction { case .inbound: // Check if this transaction is a channel close by checking if it spends a closed channel's funding UTXO - return await findClosedChannelForTransaction(txid: txid) + return await findClosedChannelForTransaction(txid: txid, transactionDetails: transactionDetails) case .outbound: // Check if this transaction is a channel open by checking if it's the funding transaction for an open channel return await findOpenChannelForTransaction(txid: txid) @@ -349,16 +587,21 @@ class ActivityService { } /// Check if a transaction spends a closed channel's funding UTXO - private func findClosedChannelForTransaction(txid: String) async -> String? { + private func findClosedChannelForTransaction(txid: String, transactionDetails: TransactionDetails? = nil) async -> String? { do { let closedChannels = try await getAllClosedChannels(sortDirection: .desc) guard !closedChannels.isEmpty else { return nil } - let txDetails = try await AddressChecker.getTransaction(txid: txid) + // Use provided transaction details if available, otherwise try node + guard let details = transactionDetails ?? LightningService.shared.getTransactionDetails(txid: txid) else { + Logger.warn("Transaction details not available for \(txid)", context: "CoreService.findClosedChannelForTransaction") + return nil + } // Check if any input spends a closed channel's funding UTXO - for input in txDetails.vin { - guard let inputTxid = input.txid, let inputVout = input.vout else { continue } + for input in details.inputs { + let inputTxid = input.txid + let inputVout = Int(input.vout) if let matchingChannel = closedChannels.first(where: { channel in channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == UInt32(inputVout) @@ -419,9 +662,9 @@ class ActivityService { } /// Check pre-activity metadata for addresses in the transaction - private func findAddressInPreActivityMetadata(txDetails: TxDetails, value: UInt64) async -> String? { - for output in txDetails.vout { - guard let address = output.scriptpubkey_address else { continue } + private func findAddressInPreActivityMetadata(details: TransactionDetails, value: UInt64) async -> String? { + for output in details.outputs { + guard let address = output.scriptpubkeyAddress else { continue } if let metadata = try? await getPreActivityMetadata(searchKey: address, searchByAddress: true), metadata.isReceive { @@ -433,15 +676,20 @@ class ActivityService { } /// Find the receiving address for an onchain transaction - private func findReceivingAddress(for txid: String, value: UInt64) async throws -> String? { - let txDetails = try await AddressChecker.getTransaction(txid: txid) + private func findReceivingAddress(for txid: String, value: UInt64, transactionDetails: TransactionDetails? = nil) async throws -> String? { + // Use provided transaction details if available, otherwise try node + guard let details = transactionDetails ?? LightningService.shared.getTransactionDetails(txid: txid) else { + Logger.warn("Transaction details not available for \(txid)", context: "CoreService.findReceivingAddress") + return nil + } + let batchSize: UInt32 = 20 let currentWalletAddress = UserDefaults.standard.string(forKey: "onchainAddress") ?? "" // Check if an address matches any transaction output func matchesTransaction(_ address: String) -> Bool { - txDetails.vout.contains { output in - output.scriptpubkey_address == address + details.outputs.contains { output in + output.scriptpubkeyAddress == address } } @@ -449,9 +697,9 @@ class ActivityService { func findMatch(in addresses: [String]) -> String? { // Try exact value match first for address in addresses { - for output in txDetails.vout { - if output.scriptpubkey_address == address, - output.value == Int64(value) + for output in details.outputs { + if output.scriptpubkeyAddress == address, + output.value == value { return address } @@ -467,7 +715,7 @@ class ActivityService { } // First, check pre-activity metadata for addresses in the transaction - if let address = await findAddressInPreActivityMetadata(txDetails: txDetails, value: value) { + if let address = await findAddressInPreActivityMetadata(details: details, value: value) { return address } @@ -527,7 +775,7 @@ class ActivityService { } // Fallback: return first output address - return txDetails.vout.first?.scriptpubkey_address + return details.outputs.first?.scriptpubkeyAddress } func getActivity(id: String) async throws -> Activity? { @@ -567,6 +815,13 @@ class ActivityService { } } + func upsert(_ activity: Activity) async throws { + try await ServiceQueue.background(.core) { + try upsertActivity(activity: activity) + self.activitiesChangedSubject.send() + } + } + func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { let result = try deleteActivityById(activityId: id) @@ -701,9 +956,8 @@ class ActivityService { // For CPFP, mark the original activity as boosted (parent transaction still exists) onchainActivity.isBoosted = true onchainActivity.boostTxIds.append(txid) - try updateActivity(activityId: activityId, activity: .onchain(onchainActivity)) + try await self.update(id: activityId, activity: .onchain(onchainActivity)) Logger.info("Successfully marked activity \(activityId) as boosted via CPFP", context: "CoreService.boostOnchainTransaction") - self.activitiesChangedSubject.send() } else { Logger.info("Executing RBF boost for outgoing transaction", context: "CoreService.boostOnchainTransaction") Logger.debug("Original transaction ID: \(onchainActivity.txId)", context: "CoreService.boostOnchainTransaction") @@ -716,42 +970,14 @@ class ActivityService { Logger.info("RBF transaction created successfully: \(txid)", context: "CoreService.boostOnchainTransaction") - // Track the replacement transaction with its full parent chain - // Include existing boostTxIds (from previous boosts) plus the current txId being replaced - let boostedParentsTxIds = onchainActivity.boostTxIds + [onchainActivity.txId] - ActivityService.replacementTransactions[txid] = boostedParentsTxIds - Logger.debug( - "Added replacement transaction \(txid) to tracking list with boosted parents txids: \(boostedParentsTxIds)", - context: "CoreService.boostOnchainTransaction" - ) - - // Track the original transaction ID so we can ignore it during sync - ActivityService.replacedTransactions.insert(onchainActivity.txId) - Logger.debug( - "Added original transaction \(onchainActivity.txId) to replaced transactions list", context: "CoreService.boostOnchainTransaction" - ) - - // For RBF, mark the old activity as boosted before marking it as replaced - onchainActivity.isBoosted = true - Logger.debug( - "Marked original activity \(activityId) as boosted before RBF replacement", - context: "CoreService.boostOnchainTransaction" - ) - // For RBF, mark the original activity as doesExist = false instead of deleting it // This allows it to be displayed with the "removed" status - Logger.debug( - "Marking original activity \(activityId) as doesExist = false (replaced by RBF)", context: "CoreService.boostOnchainTransaction" - ) - onchainActivity.doesExist = false - try updateActivity(activityId: activityId, activity: .onchain(onchainActivity)) + try await self.update(id: activityId, activity: .onchain(onchainActivity)) Logger.info( "Successfully marked activity \(activityId) as doesExist = false (replaced by RBF)", context: "CoreService.boostOnchainTransaction" ) - - self.activitiesChangedSubject.send() } return txid @@ -1293,21 +1519,23 @@ class UtilityService { } } - /// Get balance for a specific address in satoshis using AddressChecker utility + /// Check if an address has been used (has any transactions) + /// - Parameter address: The Bitcoin address to check + /// - Returns: true if the address has been used, false otherwise + func isAddressUsed(address: String) async throws -> Bool { + return try await ServiceQueue.background(.core) { + try BitkitCore.isAddressUsed(address: address) + } + } + + /// Get balance for a specific address in satoshis /// - Parameter address: The Bitcoin address to check /// - Returns: The current balance in satoshis func getAddressBalance(address: String) async throws -> UInt64 { - let addressInfo = try await AddressChecker.getAddressInfo(address: address) - - // Calculate current balance: received - spent - let received = UInt64(addressInfo.chain_stats.funded_txo_sum) - let spent = UInt64(addressInfo.chain_stats.spent_txo_sum) - - // Handle potential underflow - return received >= spent ? received - spent : 0 + return try await LightningService.shared.getAddressBalance(address: address) } - /// Get balances for multiple addresses using AddressChecker utility + /// Get balances for multiple addresses /// - Parameter addresses: Array of Bitcoin addresses to check /// - Returns: Dictionary mapping addresses to their balances in satoshis func getMultipleAddressBalances(addresses: [String]) async throws -> [String: UInt64] { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 971692c4..a8732f47 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -561,6 +561,26 @@ extension LightningService { var channels: [ChannelDetails]? { node?.listChannels() } var payments: [PaymentDetails]? { node?.listPayments() } + /// Get transaction details from the node for a given transaction ID + /// Returns nil if the transaction is not found in the wallet + func getTransactionDetails(txid: String) -> TransactionDetails? { + return node?.getTransactionDetails(txid: txid) + } + + /// Get balance for a specific address in satoshis + /// - Parameter address: The Bitcoin address to check + /// - Returns: The current balance in satoshis + /// - Throws: AppError if node is not setup + func getAddressBalance(address: String) async throws -> UInt64 { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + try node.getAddressBalance(addressStr: address) + } + } + /// Returns LSP (Blocktank) peer node IDs func getLspPeerNodeIds() -> [String] { return Env.trustedLnPeers.map(\.nodeId) @@ -608,28 +628,58 @@ extension LightningService { onEvent?(event) - // TODO: actual event handler switch event { case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat): Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)") + Task { + let hash = paymentId ?? paymentHash + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment success for \(hash): \(error)", context: "LightningService") + } + } case let .paymentFailed(paymentId, paymentHash, reason): Logger.info( "❌ Payment failed: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash ?? "") reason: \(reason.debugDescription)" ) + Task { + if let hash = paymentId ?? paymentHash { + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment failure for \(hash): \(error)", context: "LightningService") + } + } else { + Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService") + } + } case let .paymentReceived(paymentId, paymentHash, amountMsat, feePaidMsat): Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)") + Task { + let hash = paymentId ?? paymentHash + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService") + } + } case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, claimDeadline, customRecords): Logger.info( "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" ) + // Payment claimable doesn't need activity update - it's still pending + // The payment will be updated when it succeeds or fails via paymentSuccessful/paymentFailed events case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): Logger.info( "⏳ Channel pending: channelId: \(channelId) userChannelId: \(userChannelId) formerTemporaryChannelId: \(formerTemporaryChannelId) counterpartyNodeId: \(counterpartyNodeId) fundingTxo: \(fundingTxo)" ) + await refreshChannelCache() case let .channelReady(channelId, userChannelId, counterpartyNodeId): Logger.info( "👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?")" ) + await refreshChannelCache() case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason): let reasonString = reason.map { String(describing: $0) } ?? "" Logger.info( @@ -654,9 +704,76 @@ extension LightningService { } case .paymentForwarded: break - } - await refreshChannelCache() + // MARK: New Onchain Transaction Events + + case let .onchainTransactionReceived(txid, details): + Logger.info("📥 Onchain transaction received: txid=\(txid) amountSats=\(details.amountSats)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReceived(txid: txid, details: details) + } catch { + Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService") + } + } + case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details): + Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionConfirmed( + txid: txid, + details: details + ) + } catch { + Logger.error("Failed to handle transaction confirmed for \(txid): \(error)", context: "LightningService") + } + } + case let .onchainTransactionReplaced(txid, conflicts): + Logger.info("🔄 Onchain transaction replaced (RBF): txid=\(txid) by \(conflicts.count) conflict(s)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReplaced(txid: txid, conflicts: conflicts) + } catch { + Logger.error("Failed to handle transaction replaced for \(txid): \(error)", context: "LightningService") + } + } + case let .onchainTransactionReorged(txid): + Logger.warn("⚠️ Onchain transaction reorged (unconfirmed): txid=\(txid)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReorged(txid: txid) + } catch { + Logger.error("Failed to handle transaction reorged for \(txid): \(error)", context: "LightningService") + } + } + case let .onchainTransactionEvicted(txid): + Logger.warn("🗑️ Onchain transaction removed from mempool: txid=\(txid)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionEvicted(txid: txid) + } catch { + Logger.error("Failed to handle transaction evicted for \(txid): \(error)", context: "LightningService") + } + } + + // MARK: Sync Events + + case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): + Logger + .debug( + "🔄 Sync progress: type=\(syncType) progress=\(progressPercent)% current=\(currentBlockHeight) target=\(targetBlockHeight)" + ) + case let .syncCompleted(syncType, syncedBlockHeight): + Logger.info("✅ Sync completed: type=\(syncType) height=\(syncedBlockHeight)") + // Send sync status update - PassthroughSubject is thread-safe + syncStatusChangedSubject.send(UInt64(Date().timeIntervalSince1970)) + + // MARK: Balance Events + + case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning): + Logger + .info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)") + } } } } diff --git a/Bitkit/Utilities/AddressChecker.swift b/Bitkit/Utilities/AddressChecker.swift deleted file mode 100644 index 54c2ab7b..00000000 --- a/Bitkit/Utilities/AddressChecker.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation - -struct AddressStats: Codable { - let funded_txo_count: Int - let funded_txo_sum: Int - let spent_txo_count: Int - let spent_txo_sum: Int - let tx_count: Int -} - -struct AddressInfo: Codable { - let address: String - let chain_stats: AddressStats - let mempool_stats: AddressStats -} - -struct TxInput: Codable { - let txid: String? - let vout: Int? - let prevout: TxOutput? - let scriptsig: String? - let scriptsig_asm: String? - let witness: [String]? - let is_coinbase: Bool? - let sequence: Int64? -} - -struct TxOutput: Codable { - let scriptpubkey: String - let scriptpubkey_asm: String? - let scriptpubkey_type: String? - let scriptpubkey_address: String? - let value: Int64 - let n: Int? -} - -struct TxStatus: Codable { - let confirmed: Bool - let block_height: Int? - let block_hash: String? - let block_time: Int64? -} - -struct TxDetails: Codable { - let txid: String - let vin: [TxInput] - let vout: [TxOutput] - let status: TxStatus -} - -enum AddressCheckerError: Error { - case invalidUrl - case networkError(Error) - case invalidResponse -} - -/// TEMPORARY IMPLEMENTATION -/// This is a short-term solution for getting address information using electrs. -/// Eventually, this will be replaced by similar features in bitkit-core or ldk-node -/// when they support native address lookup. -class AddressChecker { - static func getAddressInfo(address: String) async throws -> AddressInfo { - guard let url = URL(string: "\(Env.esploraServerUrl)/address/\(address)") else { - throw AddressCheckerError.invalidUrl - } - - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw AddressCheckerError.invalidResponse - } - - let decoder = JSONDecoder() - return try decoder.decode(AddressInfo.self, from: data) - } catch let error as DecodingError { - throw AddressCheckerError.invalidResponse - } catch { - throw AddressCheckerError.networkError(error) - } - } - - /// Fetches full transaction details from the Esplora endpoint for the given txid. - /// - Parameter txid: Hex transaction identifier. - static func getTransaction(txid: String) async throws -> TxDetails { - guard let url = URL(string: "\(Env.esploraServerUrl)/tx/\(txid)") else { - throw AddressCheckerError.invalidUrl - } - - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw AddressCheckerError.invalidResponse - } - - let decoder = JSONDecoder() - return try decoder.decode(TxDetails.self, from: data) - } catch let error as DecodingError { - Logger.error("decoding error \(error)") - throw AddressCheckerError.invalidResponse - } catch { - throw AddressCheckerError.networkError(error) - } - } -} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e8dd0a1d..a7de9ff3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -332,7 +332,6 @@ extension AppViewModel { let cjitOrder = try await CoreService.shared.blocktank.getCjit(channel: channel) if cjitOrder != nil { let amount = channel.spendableBalanceSats - sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .lightning, sats: amount)) let now = UInt64(Date().timeIntervalSince1970) let ln = LightningActivity( @@ -368,6 +367,103 @@ extension AppViewModel { break case .paymentForwarded: break + + // MARK: New Onchain Transaction Events + + case let .onchainTransactionReceived(txid, details): + // Show notification for incoming transactions + if details.amountSats > 0 { + let sats = UInt64(abs(Int64(details.amountSats))) + + Task { + // Show sheet for new transactions or replacements with value changes + try? await Task.sleep(nanoseconds: 500_000_000) // 500ms delay + let shouldShow = await CoreService.shared.activity.shouldShowReceivedSheet(txid: txid, value: sats) + + await MainActor.run { + if !shouldShow { + Logger.info( + "Skipping received sheet for RBF replacement with same value: \(txid)", + context: "AppViewModel" + ) + return + } + + sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, sats: sats)) + } + } + } + case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details): + Logger.info("Transaction confirmed: \(txid) at block \(blockHeight)") + Task { + if await CoreService.shared.activity.isReceivedTransaction(txid: txid) { + await MainActor.run { + toast( + type: .info, + title: "Payment Confirmed", + description: "Your received payment has been confirmed" + ) + } + } else { + await MainActor.run { + toast( + type: .info, + title: "Transaction Confirmed", + description: "Your transaction has been confirmed" + ) + } + } + } + case let .onchainTransactionReplaced(txid, conflicts): + Logger.info("Transaction replaced: \(txid) by \(conflicts.count) conflict(s)") + Task { + if await CoreService.shared.activity.isReceivedTransaction(txid: txid) { + await MainActor.run { + toast( + type: .info, + title: "Received Transaction Replaced", + description: "Your received transaction was replaced by a fee bump" + ) + } + } else { + await MainActor.run { + toast( + type: .info, + title: "Transaction Replaced", + description: "Your transaction was replaced by a fee bump" + ) + } + } + } + case let .onchainTransactionReorged(txid): + Logger.warn("Transaction reorged: \(txid)") + toast(type: .warning, title: "Transaction Unconfirmed", description: "Transaction became unconfirmed due to blockchain reorganization") + case let .onchainTransactionEvicted(txid): + Task { + let wasReplaced = await CoreService.shared.activity.wasTransactionReplaced(txid: txid) + + await MainActor.run { + if wasReplaced { + Logger.info("Transaction \(txid) was replaced, skipping evicted toast", context: "AppViewModel") + return + } + + Logger.warn("Transaction removed from mempool: \(txid)") + toast(type: .warning, title: "Transaction Removed", description: "Transaction was removed from mempool") + } + } + + // MARK: Sync Events + + case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): + Logger.debug("Sync progress: \(syncType) \(progressPercent)%") + case let .syncCompleted(syncType, syncedBlockHeight): + Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)") + + // MARK: Balance Events + + case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning): + Logger.debug("Balance changed: onchain \(oldSpendableOnchain)->\(newSpendableOnchain) lightning \(oldLightning)->\(newLightning)") } } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index bf0e14ca..3a4af3d8 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -40,7 +40,6 @@ class WalletViewModel: ObservableObject { @Published var peers: [PeerDetails]? @Published var channels: [ChannelDetails]? private var eventHandlers: [String: (Event) -> Void] = [:] - private var syncTimer: Timer? private let lightningService: LightningService private let coreService: CoreService @@ -81,12 +80,6 @@ class WalletViewModel: ObservableObject { self.init(transferService: transferService) } - deinit { - Task { [weak self] in - await self?.stopPolling() - } - } - func setWalletExistsState() throws { walletExists = try Keychain.exists(key: .bip39Mnemonic(index: 0)) } @@ -115,21 +108,48 @@ class WalletViewModel: ObservableObject { rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl ) try await lightningService.start(onEvent: { event in - // On every lightning event just sync UI Task { @MainActor in - self.syncState() // Notify all event handlers for handler in self.eventHandlers.values { handler(event) } - // If payment received or new channel events, refresh BIP21 for instantly usable QR in receive view + // Handle specific events for targeted UI updates switch event { case .paymentReceived, .channelReady, .channelClosed: + self.syncChannelsAndPeers() self.bolt11 = "" Task { try? await self.refreshBip21() } + + // MARK: New Onchain Transaction Events + + case .onchainTransactionReceived, .onchainTransactionConfirmed, .onchainTransactionReplaced, .onchainTransactionReorged, + .onchainTransactionEvicted: + Task { + await self.updateBalanceState() + } + + // MARK: Sync Events + + case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): + self.isSyncingWallet = true + Logger.debug("Sync progress: \(syncType) \(progressPercent)%") + case let .syncCompleted(syncType, syncedBlockHeight): + self.isSyncingWallet = false + Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)") + self.syncState() + Task { + await self.updateBalanceState() + } + + // MARK: Balance Events + + case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning): + Task { + await self.updateBalanceState() + } default: break } @@ -142,8 +162,6 @@ class WalletViewModel: ObservableObject { nodeLifecycleState = .running - startPolling() - syncState() do { @@ -164,7 +182,6 @@ class WalletViewModel: ObservableObject { func stopLightningNode() async throws { nodeLifecycleState = .stopping - stopPolling() try await lightningService.stop() nodeLifecycleState = .stopped syncState() @@ -447,22 +464,38 @@ class WalletViewModel: ObservableObject { syncState() } + /// Sync all state (node status, channels, peers, balances) + /// Use this for initial load or after sync operations func syncState() { + syncNodeStatus() + syncChannelsAndPeers() + syncBalances() + } + + /// Sync node status and ID only + private func syncNodeStatus() { nodeStatus = lightningService.status nodeId = lightningService.nodeId - balanceDetails = lightningService.balances + } + + /// Sync channels and peers only + private func syncChannelsAndPeers() { peers = lightningService.peers channels = lightningService.channels if let channels { channelCount = channels.count } + } + + /// Sync balance details only + private func syncBalances() { + balanceDetails = lightningService.balances if let balanceDetails { spendableOnchainBalanceSats = Int(balanceDetails.spendableOnchainBalanceSats) } - // Update balance state with pending transfers Task { @MainActor in await updateBalanceState() } @@ -532,8 +565,7 @@ class WalletViewModel: ObservableObject { onchainAddress = try await lightningService.newAddress() } else { // Check if current address has been used - let addressInfo = try await AddressChecker.getAddressInfo(address: onchainAddress) - let hasTransactions = addressInfo.chain_stats.tx_count > 0 || addressInfo.mempool_stats.tx_count > 0 + let hasTransactions = try await coreService.utility.isAddressUsed(address: onchainAddress) if hasTransactions { // Address has been used, generate a new one @@ -629,24 +661,6 @@ class WalletViewModel: ObservableObject { try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) } - private func startPolling() { - stopPolling() - - syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor in - guard let self else { return } - if self.nodeLifecycleState == .running { - self.syncState() - } - } - } - } - - private func stopPolling() { - syncTimer?.invalidate() - syncTimer = nil - } - /// Formats satoshi amount to Bitcoin decimal format for BIP21 URIs /// - Parameter sats: Amount in satoshis /// - Returns: Formatted Bitcoin amount as string (e.g., "0.00123000") diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index b429d4fc..0a4abb7c 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -8,7 +8,8 @@ struct ActivityExplorerView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel - @State private var txDetails: TxDetails? + @State private var txDetails: TransactionDetails? + @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist private var onchain: OnchainActivity? { guard case let .onchain(activity) = item else { return nil } @@ -37,13 +38,22 @@ struct ActivityExplorerView: View { private func loadTransactionDetails() async { guard let onchain else { return } - do { - let details = try await AddressChecker.getTransaction(txid: onchain.txId) + // Try to get transaction details from node + if let nodeDetails = LightningService.shared.getTransactionDetails(txid: onchain.txId) { await MainActor.run { - txDetails = details + txDetails = nodeDetails } - } catch { - Logger.warn("Failed to load transaction details: \(error)") + } else { + Logger.warn("Transaction details not available from node for \(onchain.txId)") + } + } + + private func loadBoostTxDoesExist() async { + guard let onchain else { return } + + let doesExistMap = await CoreService.shared.activity.getBoostTxDoesExist(boostTxIds: onchain.boostTxIds) + await MainActor.run { + boostTxDoesExist = doesExistMap } } @@ -125,12 +135,12 @@ struct ActivityExplorerView: View { ) if let txDetails { - CaptionMText(tPlural("wallet__activity_input", arguments: ["count": txDetails.vin.count])) + CaptionMText(tPlural("wallet__activity_input", arguments: ["count": txDetails.inputs.count])) .padding(.bottom, 8) VStack(alignment: .leading, spacing: 4) { - ForEach(Array(txDetails.vin.enumerated()), id: \.offset) { _, input in - let txId = input.txid ?? "" - let vout = input.vout ?? 0 + ForEach(Array(txDetails.inputs.enumerated()), id: \.offset) { _, input in + let txId = input.txid + let vout = Int(input.vout) BodySSBText("\(txId):\(vout)") .lineLimit(1) .truncationMode(.middle) @@ -140,11 +150,11 @@ struct ActivityExplorerView: View { Divider() .padding(.vertical, 16) - CaptionMText(tPlural("wallet__activity_output", arguments: ["count": txDetails.vout.count])) + CaptionMText(tPlural("wallet__activity_output", arguments: ["count": txDetails.outputs.count])) .padding(.bottom, 8) VStack(alignment: .leading, spacing: 4) { - ForEach(txDetails.vout.indices, id: \.self) { i in - BodySSBText(txDetails.vout[i].scriptpubkey_address ?? "") + ForEach(txDetails.outputs.indices, id: \.self) { i in + BodySSBText(txDetails.outputs[i].scriptpubkeyAddress ?? "") .lineLimit(1) .truncationMode(.middle) } @@ -156,14 +166,17 @@ struct ActivityExplorerView: View { Divider() .padding(.bottom, 16) ForEach(Array(onchain.boostTxIds.enumerated()), id: \.offset) { index, boostTxId in - let key = onchain.txType == .received - ? "wallet__activity_boosted_cpfp" - : "wallet__activity_boosted_rbf" + // Determine if this is RBF (doesExist = false, replaced) or CPFP (doesExist = true, child transaction) + let boostTxDoesExistValue = boostTxDoesExist[boostTxId] ?? true + let isRBF = !boostTxDoesExistValue + let key = isRBF + ? "wallet__activity_boosted_rbf" + : "wallet__activity_boosted_cpfp" InfoSection( title: t(key, variables: ["num": String(index + 1)]), content: boostTxId, - testId: onchain.txType == .received ? "CPFPBoosted" : "RBFBoosted" + testId: isRBF ? "RBFBoosted" : "CPFPBoosted" ) } } @@ -204,8 +217,10 @@ struct ActivityExplorerView: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .task { - if onchain != nil { - await loadTransactionDetails() + guard let onchain else { return } + await loadTransactionDetails() + if !onchain.boostTxIds.isEmpty { + await loadBoostTxDoesExist() } } } diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index af6551c6..ee60cff1 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -13,6 +13,7 @@ struct ActivityItemView: View { @EnvironmentObject var blocktank: BlocktankViewModel @EnvironmentObject var channelDetails: ChannelDetailsViewModel @StateObject private var viewModel: ActivityItemViewModel + @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist init(item: Activity) { self.item = item @@ -114,8 +115,31 @@ struct ActivityItemView: View { // Lightning transactions can never be boosted return true case let .onchain(activity): - // Disable boost for onchain if transaction is confirmed or already boosted - return activity.confirmed == true || activity.isBoosted + if activity.confirmed == true { + return true + } + if activity.isBoosted && !activity.boostTxIds.isEmpty { + let hasCPFP = activity.boostTxIds.contains { boostTxDoesExist[$0] == true } + if hasCPFP { + return true + } + + if activity.txType == .sent { + let hasRBF = activity.boostTxIds.contains { boostTxDoesExist[$0] == false } + return hasRBF + } + } + + return false + } + } + + private func loadBoostTxDoesExist() async { + guard case let .onchain(activity) = viewModel.activity else { return } + + let doesExistMap = await CoreService.shared.activity.getBoostTxDoesExist(boostTxIds: activity.boostTxIds) + await MainActor.run { + boostTxDoesExist = doesExistMap } } @@ -194,9 +218,16 @@ struct ActivityItemView: View { } } .task { + // Load boostTxIds doesExist status to determine RBF vs CPFP + if case let .onchain(activity) = viewModel.activity, + !activity.boostTxIds.isEmpty + { + await loadBoostTxDoesExist() + } // Load channel if this is a transfer - guard isTransfer, let channelId = transferChannelId else { return } - await channelDetails.findChannel(channelId: channelId, wallet: wallet) + if isTransfer, let channelId = transferChannelId { + await channelDetails.findChannel(channelId: channelId, wallet: wallet) + } } } diff --git a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift index b61f4a51..ba7d308b 100644 --- a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift +++ b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift @@ -381,10 +381,6 @@ struct BoostSheet: View { try await wallet.sync() Logger.debug("Wallet sync completed after boost", context: "BoostSheet.performBoost") - // Sync LDK node payments to process the new RBF transaction - try await activityList.syncLdkNodePayments() - Logger.debug("LDK node payments synced after boost", context: "BoostSheet.performBoost") - // Refresh activity list state await activityList.syncState() Logger.debug("Activity list state synced after boost", context: "BoostSheet.performBoost") diff --git a/BitkitNotification/NotificationService.swift b/BitkitNotification/NotificationService.swift index 6b2653e8..6706a43e 100644 --- a/BitkitNotification/NotificationService.swift +++ b/BitkitNotification/NotificationService.swift @@ -221,6 +221,41 @@ class NotificationService: UNNotificationServiceExtension { } case .paymentForwarded: break + + // MARK: New Onchain Transaction Events + + case let .onchainTransactionReceived(txid, details): + // Show notification for incoming onchain transactions + if details.amountSats > 0 { + let sats = UInt64(abs(Int64(details.amountSats))) + bestAttemptContent?.title = "Payment Received" + bestAttemptContent?.body = "₿ \(sats) (unconfirmed)" + ReceivedTxSheetDetails(type: .onchain, sats: sats).save() // Save for UI to pick up + deliver() + } + case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details): + // Transaction confirmed - could show notification if it was previously unconfirmed + if details.amountSats > 0 { + let sats = UInt64(abs(Int64(details.amountSats))) + bestAttemptContent?.title = "Payment Confirmed" + bestAttemptContent?.body = "₿ \(sats) confirmed at block \(blockHeight)" + deliver() + } + case .onchainTransactionReplaced, .onchainTransactionReorged, .onchainTransactionEvicted: + // These events are less critical for notifications, but could be logged + os_log("🔔 Onchain transaction state changed: %{public}@", log: notificationLogger, type: .error, String(describing: event)) + + // MARK: Sync Events + + case .syncProgress, .syncCompleted: + // Sync events are not critical for notifications + break + + // MARK: Balance Events + + case .balanceChanged: + // Balance changes are handled by other events, not critical for notifications + break } } From 7daff7dc53fcafd5dda32f7a3597461f29a49780 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Nov 2025 10:27:13 -0500 Subject: [PATCH 02/17] Auto sync activities list --- Bitkit/ViewModels/ActivityListViewModel.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 29cc8e91..063969b2 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -43,6 +43,7 @@ class ActivityListViewModel: ObservableObject { private var dateRangeCancellable: AnyCancellable? private var tagsCancellable: AnyCancellable? private var tabCancellable: AnyCancellable? + private var activitiesChangedCancellable: AnyCancellable? @Published private(set) var availableTags: [String] = [] @Published private(set) var feeEstimates: FeeRates? = nil @@ -110,6 +111,14 @@ class ActivityListViewModel: ObservableObject { } } + activitiesChangedCancellable = coreService.activity.activitiesChangedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { [weak self] in + await self?.syncState() + } + } + Task { await syncState() } From 47e13c0563fa0d693065a43f3bdc17e95402241a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Nov 2025 11:22:42 -0500 Subject: [PATCH 03/17] Improve onchain tx search by id --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Bitkit/Services/CoreService.swift | 78 +++++-------------- 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d9c5bd63..33891e89 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/synonymdev/bitkit-core", "state" : { "branch" : "master", - "revision" : "c16b360d45518474769803545a45920e6bcd1fed" + "revision" : "aa2ad84cd4ced707ede1ed8efe036c0ddb696241" } }, { diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index c2b95294..e8a90429 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -29,7 +29,7 @@ class ActivityService { func wasTransactionReplaced(txid: String) async -> Bool { // Check if the activity exists and is marked as replaced - if let onchain = try? getOnchainActivityByTxId(txid: txid), + if let onchain = try? await getOnchainActivityByTxId(txid: txid), !onchain.doesExist { return true @@ -51,42 +51,22 @@ class ActivityService { do { // Check if this transaction's activity has boostTxIds (meaning it replaced other transactions) // If any of the replaced transactions have the same value, don't show the sheet - guard let onchain = try? getOnchainActivityByTxId(txid: txid), + guard let onchain = try? await getOnchainActivityByTxId(txid: txid), !onchain.boostTxIds.isEmpty else { return true } // This transaction replaced others - check if any have the same value - let twoMinutesAgo = UInt64(Date().timeIntervalSince1970) - 120 - let activities = try getActivities( - filter: .onchain, - txType: nil, - tags: nil, - search: nil, - minDate: twoMinutesAgo, - maxDate: nil, - limit: 50, - sortDirection: nil - ) - for replacedTxid in onchain.boostTxIds { - if let replacedActivity = activities.first(where: { activity in - if case let .onchain(existing) = activity { - return existing.txId == replacedTxid - } - return false - }), - case let .onchain(replaced) = replacedActivity + if let replaced = try? await getOnchainActivityByTxId(txid: replacedTxid), + replaced.value == value { - // Check if the replaced transaction has the same value - if replaced.value == value { - Logger.info( - "Skipping received sheet for replacement transaction \(txid) with same value as replaced transaction \(replacedTxid)", - context: "CoreService.shouldShowReceivedSheet" - ) - return false - } + Logger.info( + "Skipping received sheet for replacement transaction \(txid) with same value as replaced transaction \(replacedTxid)", + context: "CoreService.shouldShowReceivedSheet" + ) + return false } } } catch { @@ -109,34 +89,12 @@ class ActivityService { return payment.direction == .inbound } - // MARK: - Activity Lookup - - // TODO: Add id filter in bitkit-core - func getOnchainActivityByTxId(txid: String) throws -> OnchainActivity? { - let activities = try getActivities( - filter: .onchain, - txType: nil, - tags: nil, - search: nil, - minDate: nil, - maxDate: nil, - limit: nil, - sortDirection: nil - ) - for activity in activities { - if case let .onchain(onchain) = activity, onchain.txId == txid { - return onchain - } - } - return nil - } - /// 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] { var doesExistMap: [String: Bool] = [:] for boostTxId in boostTxIds { - if let boostActivity = try? getOnchainActivityByTxId(txid: boostTxId) { + if let boostActivity = try? await getOnchainActivityByTxId(txid: boostTxId) { doesExistMap[boostTxId] = boostActivity.doesExist } } @@ -348,7 +306,7 @@ class ActivityService { func handleOnchainTransactionReplaced(txid: String, conflicts: [String]) async throws { try await ServiceQueue.background(.core) { // Find the activity for the replaced transaction - let replacedActivity = try self.getOnchainActivityByTxId(txid: txid) + let replacedActivity = try await self.getOnchainActivityByTxId(txid: txid) if var existing = replacedActivity { Logger.info( @@ -371,7 +329,7 @@ class ActivityService { // For each replacement transaction, update its boostTxIds to include the replaced txid for conflictTxid in conflicts { // Try to get the replacement activity, or process it if it doesn't exist - var replacementActivity = try? self.getOnchainActivityByTxId(txid: conflictTxid) + var replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) if replacementActivity == nil, let payments = LightningService.shared.payments, @@ -388,7 +346,7 @@ class ActivityService { ) do { try await self.processOnchainPayment(replacementPayment, transactionDetails: nil) - replacementActivity = try? self.getOnchainActivityByTxId(txid: conflictTxid) + replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) } catch { Logger.error( "Failed to process replacement transaction \(conflictTxid): \(error)", @@ -419,7 +377,7 @@ class ActivityService { func handleOnchainTransactionReorged(txid: String) async throws { try await ServiceQueue.background(.core) { - guard var onchain = try self.getOnchainActivityByTxId(txid: txid) else { + guard var onchain = try await self.getOnchainActivityByTxId(txid: txid) else { Logger.warn("Activity not found for reorged transaction \(txid)", context: "CoreService.handleOnchainTransactionReorged") return } @@ -434,7 +392,7 @@ class ActivityService { func handleOnchainTransactionEvicted(txid: String) async throws { try await ServiceQueue.background(.core) { - guard var onchain = try self.getOnchainActivityByTxId(txid: txid) else { + guard var onchain = try await self.getOnchainActivityByTxId(txid: txid) else { Logger.warn("Activity not found for evicted transaction \(txid)", context: "CoreService.handleOnchainTransactionEvicted") return } @@ -784,6 +742,12 @@ class ActivityService { } } + func getOnchainActivityByTxId(txid: String) async throws -> OnchainActivity? { + try await ServiceQueue.background(.core) { + try BitkitCore.getActivityByTxId(txId: txid) + } + } + func get( filter: ActivityFilter? = nil, txType: PaymentType? = nil, From 4e7b391ca6f9537d3398d35ea9cb09711f65d71a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Nov 2025 11:41:22 -0500 Subject: [PATCH 04/17] Update project.pbxproj --- Bitkit.xcodeproj/project.pbxproj | 34 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 3318f43e..61f86ba7 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 186523ED2ED7365100485B41 /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 186523EC2ED7365100485B41 /* LDKNode */; }; 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; @@ -18,6 +17,7 @@ 96204B782DE9AA43007BAA26 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 96204B772DE9AA43007BAA26 /* SQLite */; }; 966DE6702C51210000A7B0EF /* LightningDevKit in Frameworks */ = {isa = PBXBuildFile; productRef = 966DE66F2C51210000A7B0EF /* LightningDevKit */; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; + 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; 96E20CD42CB6D91A00C24149 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 96E20CD32CB6D91A00C24149 /* CodeScanner */; }; @@ -146,8 +146,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */, 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */, - 186523ED2ED7365100485B41 /* LDKNode in Frameworks */, 4AFCA3722E0596D900205CAE /* Zip in Frameworks */, 96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */, 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */, @@ -239,8 +239,8 @@ 96E493A72C943184000E8BC2 /* secp256k1 */, 96DEA03B2DE8BBAB009932BF /* BitkitCore */, 4AFCA3712E0596D900205CAE /* Zip */, + 968FE13F2DFB016B0053CD7F /* LDKNode */, 18D65E012EB964BD00252335 /* VssRustClientFfi */, - 186523EC2ED7365100485B41 /* LDKNode */, ); productName = BitkitNotification; productReference = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; @@ -381,9 +381,9 @@ 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */, 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */, 4AFCA36E2E05933800205CAE /* XCRemoteSwiftPackageReference "Zip" */, + 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */, 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, - 186523EB2ED7365100485B41 /* XCRemoteSwiftPackageReference "ldk-node" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -874,14 +874,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 186523EB2ED7365100485B41 /* XCRemoteSwiftPackageReference "ldk-node" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/synonymdev/ldk-node"; - requirement = { - branch = main; - kind = branch; - }; - }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; @@ -930,6 +922,14 @@ minimumVersion = 0.0.123; }; }; + 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/synonymdev/ldk-node"; + requirement = { + branch = main; + kind = branch; + }; + }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; @@ -957,11 +957,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 186523EC2ED7365100485B41 /* LDKNode */ = { - isa = XCSwiftPackageProductDependency; - package = 186523EB2ED7365100485B41 /* XCRemoteSwiftPackageReference "ldk-node" */; - productName = LDKNode; - }; 18D65DFF2EB964B500252335 /* VssRustClientFfi */ = { isa = XCSwiftPackageProductDependency; package = 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */; @@ -1007,6 +1002,11 @@ package = 966DE66E2C51210000A7B0EF /* XCRemoteSwiftPackageReference "ldk-swift" */; productName = LightningDevKit; }; + 968FE13F2DFB016B0053CD7F /* LDKNode */ = { + isa = XCSwiftPackageProductDependency; + package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */; + productName = LDKNode; + }; 96DEA0392DE8BBA1009932BF /* BitkitCore */ = { isa = XCSwiftPackageProductDependency; package = 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */; From 4f15e148e282b48369ad611eab50b18c87fc712f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Nov 2025 14:23:16 -0300 Subject: [PATCH 05/17] fix: swift cached package manager invalidation on changes --- .github/workflows/integration-tests.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 81681961..d7e5ef61 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -55,7 +55,7 @@ jobs: - name: Install dependencies run: | echo "⏱️ Starting dependency resolution at $(date)" - xcodebuild -resolvePackageDependencies | xcbeautify + xcodebuild -resolvePackageDependencies -onlyUsePackageVersionsFromResolvedFile | xcbeautify echo "✅ Dependencies resolved at $(date)" - name: Pre-start simulator diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9ec54d56..4eb8d244 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -54,7 +54,7 @@ jobs: - name: Install dependencies run: | echo "⏱️ Starting dependency resolution at $(date)" - xcodebuild -resolvePackageDependencies | xcbeautify + xcodebuild -resolvePackageDependencies -onlyUsePackageVersionsFromResolvedFile | xcbeautify echo "✅ Dependencies resolved at $(date)" - name: Pre-start simulator From 01011de67e0181367e86e06c9d5e9db147375f93 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Nov 2025 13:16:32 -0500 Subject: [PATCH 06/17] Localizations and fix boost on removed tx --- .../Localization/en.lproj/Localizable.strings | 12 ++++++++ Bitkit/ViewModels/AppViewModel.swift | 28 ++++++++++++------- .../Wallets/Activity/ActivityItemView.swift | 3 ++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 86c90483..822c7277 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -968,6 +968,18 @@ "wallet__toast_payment_success_description" = "Your instant payment was sent successfully."; "wallet__toast_payment_failed_title" = "Payment Failed"; "wallet__toast_payment_failed_description" = "Your instant payment failed. Please try again."; +"wallet__toast_payment_confirmed_title" = "Payment Confirmed"; +"wallet__toast_payment_confirmed_description" = "Your received payment has been confirmed"; +"wallet__toast_transaction_confirmed_title" = "Transaction Confirmed"; +"wallet__toast_transaction_confirmed_description" = "Your transaction has been confirmed"; +"wallet__toast_received_transaction_replaced_title" = "Received Transaction Replaced"; +"wallet__toast_received_transaction_replaced_description" = "Your received transaction was replaced by a fee bump"; +"wallet__toast_transaction_replaced_title" = "Transaction Replaced"; +"wallet__toast_transaction_replaced_description" = "Your transaction was replaced by a fee bump"; +"wallet__toast_transaction_unconfirmed_title" = "Transaction Unconfirmed"; +"wallet__toast_transaction_unconfirmed_description" = "Transaction became unconfirmed due to blockchain reorganization"; +"wallet__toast_transaction_removed_title" = "Transaction Removed"; +"wallet__toast_transaction_removed_description" = "Transaction was removed from mempool"; "wallet__selection_title" = "Coin Selection"; "wallet__selection_auto" = "Auto"; "wallet__selection_total_required" = "Total required"; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index a7de9ff3..3aa5d304 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -400,16 +400,16 @@ extension AppViewModel { await MainActor.run { toast( type: .info, - title: "Payment Confirmed", - description: "Your received payment has been confirmed" + title: t("wallet__toast_payment_confirmed_title"), + description: t("wallet__toast_payment_confirmed_description") ) } } else { await MainActor.run { toast( type: .info, - title: "Transaction Confirmed", - description: "Your transaction has been confirmed" + title: t("wallet__toast_transaction_confirmed_title"), + description: t("wallet__toast_transaction_confirmed_description") ) } } @@ -421,23 +421,27 @@ extension AppViewModel { await MainActor.run { toast( type: .info, - title: "Received Transaction Replaced", - description: "Your received transaction was replaced by a fee bump" + title: t("wallet__toast_received_transaction_replaced_title"), + description: t("wallet__toast_received_transaction_replaced_description") ) } } else { await MainActor.run { toast( type: .info, - title: "Transaction Replaced", - description: "Your transaction was replaced by a fee bump" + title: t("wallet__toast_transaction_replaced_title"), + description: t("wallet__toast_transaction_replaced_description") ) } } } case let .onchainTransactionReorged(txid): Logger.warn("Transaction reorged: \(txid)") - toast(type: .warning, title: "Transaction Unconfirmed", description: "Transaction became unconfirmed due to blockchain reorganization") + toast( + type: .warning, + title: t("wallet__toast_transaction_unconfirmed_title"), + description: t("wallet__toast_transaction_unconfirmed_description") + ) case let .onchainTransactionEvicted(txid): Task { let wasReplaced = await CoreService.shared.activity.wasTransactionReplaced(txid: txid) @@ -449,7 +453,11 @@ extension AppViewModel { } Logger.warn("Transaction removed from mempool: \(txid)") - toast(type: .warning, title: "Transaction Removed", description: "Transaction was removed from mempool") + toast( + type: .warning, + title: t("wallet__toast_transaction_removed_title"), + description: t("wallet__toast_transaction_removed_description") + ) } } diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index ee60cff1..c18ffc2d 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -115,6 +115,9 @@ struct ActivityItemView: View { // Lightning transactions can never be boosted return true case let .onchain(activity): + if !activity.doesExist { + return true + } if activity.confirmed == true { return true } From b6312523923b8e48d2011dbd991d855e6bfcc141 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 26 Nov 2025 19:30:39 +0100 Subject: [PATCH 07/17] ReceivedTransaction and ReceivedTransactionButton --- Bitkit/Views/Wallets/Sheets/ReceivedTx.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift b/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift index 81b8f875..6a19fb84 100644 --- a/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift +++ b/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift @@ -51,8 +51,10 @@ struct ReceivedTx: View { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: title) MoneyStack(sats: Int(config.details.sats), showSymbol: true) + .accessibilityIdentifier("ReceivedTransaction") Spacer() CustomButton(title: buttonText) { sheets.hideSheet() } + .accessibilityIdentifier("ReceivedTransactionButton") } .padding(.horizontal, 16) } From 61b45a58a8d06fd5a96ca48bfae8170a77c213c3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Nov 2025 14:21:57 -0500 Subject: [PATCH 08/17] Add toast accessibility identifiers --- Bitkit/Components/ToastView.swift | 4 ++- Bitkit/Models/Toast.swift | 1 + Bitkit/ViewModels/AppViewModel.swift | 43 ++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/Bitkit/Components/ToastView.swift b/Bitkit/Components/ToastView.swift index b025d4b2..c0c14936 100644 --- a/Bitkit/Components/ToastView.swift +++ b/Bitkit/Components/ToastView.swift @@ -48,6 +48,7 @@ struct ToastView: View { ) .cornerRadius(8) .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + .accessibilityIdentifierIfPresent(toast.accessibilityIdentifier) } private var accentColor: Color { @@ -68,7 +69,8 @@ struct ToastView: View { title: "Hey toast", description: "This is a toast message", autoHide: true, - visibilityTime: 4.0 + visibilityTime: 4.0, + accessibilityIdentifier: nil ), onDismiss: {} ) .preferredColorScheme(.dark) diff --git a/Bitkit/Models/Toast.swift b/Bitkit/Models/Toast.swift index 7e23ea32..201880da 100644 --- a/Bitkit/Models/Toast.swift +++ b/Bitkit/Models/Toast.swift @@ -10,4 +10,5 @@ struct Toast: Equatable { let description: String? let autoHide: Bool let visibilityTime: Double + let accessibilityIdentifier: String? } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 3aa5d304..f58384ac 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -114,7 +114,14 @@ class AppViewModel: ObservableObject { // MARK: Toast notifications extension AppViewModel { - func toast(type: Toast.ToastType, title: String, description: String? = nil, autoHide: Bool = true, visibilityTime: Double = 4.0) { + func toast( + type: Toast.ToastType, + title: String, + description: String? = nil, + autoHide: Bool = true, + visibilityTime: Double = 4.0, + accessibilityIdentifier: String? = nil + ) { switch type { case .error: Haptics.notify(.error) @@ -128,7 +135,14 @@ extension AppViewModel { Haptics.notify(.warning) } - let toast = Toast(type: type, title: title, description: description, autoHide: autoHide, visibilityTime: visibilityTime) + let toast = Toast( + type: type, + title: title, + description: description, + autoHide: autoHide, + visibilityTime: visibilityTime, + accessibilityIdentifier: accessibilityIdentifier + ) ToastWindowManager.shared.showToast(toast) } @@ -364,7 +378,12 @@ extension AppViewModel { case .paymentClaimable: break case .paymentFailed(paymentId: _, paymentHash: _, reason: _): - break + toast( + type: .error, + title: t("wallet__toast_payment_failed_title"), + description: t("wallet__toast_payment_failed_description"), + accessibilityIdentifier: "PaymentFailedToast" + ) case .paymentForwarded: break @@ -401,7 +420,8 @@ extension AppViewModel { toast( type: .info, title: t("wallet__toast_payment_confirmed_title"), - description: t("wallet__toast_payment_confirmed_description") + description: t("wallet__toast_payment_confirmed_description"), + accessibilityIdentifier: "PaymentConfirmedToast" ) } } else { @@ -409,7 +429,8 @@ extension AppViewModel { toast( type: .info, title: t("wallet__toast_transaction_confirmed_title"), - description: t("wallet__toast_transaction_confirmed_description") + description: t("wallet__toast_transaction_confirmed_description"), + accessibilityIdentifier: "TransactionConfirmedToast" ) } } @@ -422,7 +443,8 @@ extension AppViewModel { toast( type: .info, title: t("wallet__toast_received_transaction_replaced_title"), - description: t("wallet__toast_received_transaction_replaced_description") + description: t("wallet__toast_received_transaction_replaced_description"), + accessibilityIdentifier: "ReceivedTransactionReplacedToast" ) } } else { @@ -430,7 +452,8 @@ extension AppViewModel { toast( type: .info, title: t("wallet__toast_transaction_replaced_title"), - description: t("wallet__toast_transaction_replaced_description") + description: t("wallet__toast_transaction_replaced_description"), + accessibilityIdentifier: "TransactionReplacedToast" ) } } @@ -440,7 +463,8 @@ extension AppViewModel { toast( type: .warning, title: t("wallet__toast_transaction_unconfirmed_title"), - description: t("wallet__toast_transaction_unconfirmed_description") + description: t("wallet__toast_transaction_unconfirmed_description"), + accessibilityIdentifier: "TransactionUnconfirmedToast" ) case let .onchainTransactionEvicted(txid): Task { @@ -456,7 +480,8 @@ extension AppViewModel { toast( type: .warning, title: t("wallet__toast_transaction_removed_title"), - description: t("wallet__toast_transaction_removed_description") + description: t("wallet__toast_transaction_removed_description"), + accessibilityIdentifier: "TransactionRemovedToast" ) } } From 4a758d2035d75ebd41f79c2419502560c90d876b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Nov 2025 17:33:34 -0300 Subject: [PATCH 09/17] fix: include package.resolved on cache key --- .github/workflows/integration-tests.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d7e5ef61..578c85c0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -48,7 +48,7 @@ jobs: uses: actions/cache@v4 with: path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/*.m', '**/*.h', 'Bitkit.xcodeproj/project.pbxproj') }} + key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/*.m', '**/*.h', 'Bitkit.xcodeproj/project.pbxproj', '**/Package.resolved') }} restore-keys: | ${{ runner.os }}-deriveddata- diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4eb8d244..e598ac70 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -47,7 +47,7 @@ jobs: uses: actions/cache@v4 with: path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/*.m', '**/*.h', 'Bitkit.xcodeproj/project.pbxproj') }} + key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/*.m', '**/*.h', 'Bitkit.xcodeproj/project.pbxproj', '**/Package.resolved') }} restore-keys: | ${{ runner.os }}-deriveddata- From f057bdb29dca5c12ca8bfa1ebb169c67ab2033f4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Nov 2025 17:53:51 -0300 Subject: [PATCH 10/17] fix: remove cache temprarily --- .github/workflows/integration-tests.yml | 8 -------- .github/workflows/unit-tests.yml | 8 -------- 2 files changed, 16 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 578c85c0..a5e11d1e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -44,14 +44,6 @@ jobs: restore-keys: | ${{ runner.os }}-spm- - - name: Cache DerivedData - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/*.m', '**/*.h', 'Bitkit.xcodeproj/project.pbxproj', '**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-deriveddata- - - name: Install dependencies run: | echo "⏱️ Starting dependency resolution at $(date)" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e598ac70..f54ccbc0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -43,14 +43,6 @@ jobs: restore-keys: | ${{ runner.os }}-spm- - - name: Cache DerivedData - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/*.m', '**/*.h', 'Bitkit.xcodeproj/project.pbxproj', '**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-deriveddata- - - name: Install dependencies run: | echo "⏱️ Starting dependency resolution at $(date)" From 61818b1de78e7f6731bee3db8b8f5205e99e545f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Nov 2025 17:55:33 -0500 Subject: [PATCH 11/17] Auto update activity details screen --- Bitkit/ViewModels/ActivityItemViewModel.swift | 11 ++++++ .../Activity/ActivityExplorerView.swift | 36 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/ActivityItemViewModel.swift b/Bitkit/ViewModels/ActivityItemViewModel.swift index e76da3a9..a0e45515 100644 --- a/Bitkit/ViewModels/ActivityItemViewModel.swift +++ b/Bitkit/ViewModels/ActivityItemViewModel.swift @@ -1,9 +1,11 @@ import BitkitCore +import Combine import SwiftUI @MainActor class ActivityItemViewModel: ObservableObject { private let coreService: CoreService = .shared + private var activitiesChangedCancellable: AnyCancellable? @Published private(set) var activity: Activity @Published private(set) var tags: [String] = [] @@ -19,6 +21,15 @@ class ActivityItemViewModel: ObservableObject { return activity.id } }() + + activitiesChangedCancellable = coreService.activity.activitiesChangedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { [weak self] in + await self?.refreshActivity() + } + } + Task { await loadTags() } diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 0a4abb7c..1844adac 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -1,16 +1,30 @@ import BitkitCore +import Combine import Foundation import LDKNode import SwiftUI struct ActivityExplorerView: View { - let item: Activity @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel + @State private var item: Activity @State private var txDetails: TransactionDetails? @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist + init(item: Activity) { + _item = State(initialValue: item) + } + + private var activityId: String { + switch item { + case let .lightning(activity): + return activity.id + case let .onchain(activity): + return activity.id + } + } + private var onchain: OnchainActivity? { guard case let .onchain(activity) = item else { return nil } return activity @@ -57,6 +71,21 @@ struct ActivityExplorerView: View { } } + private func refreshActivity() async { + do { + if let updatedActivity = try await CoreService.shared.activity.getActivity(id: activityId) { + await MainActor.run { + item = updatedActivity + } + if case let .onchain(onchainActivity) = updatedActivity, !onchainActivity.boostTxIds.isEmpty { + await loadBoostTxDoesExist() + } + } + } catch { + Logger.error(error, context: "Failed to refresh activity \(activityId) in ActivityExplorerView") + } + } + private var amountPrefix: String { switch item { case let .lightning(activity): @@ -216,6 +245,11 @@ struct ActivityExplorerView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() + .onReceive(CoreService.shared.activity.activitiesChangedPublisher) { _ in + Task { + await refreshActivity() + } + } .task { guard let onchain else { return } await loadTransactionDetails() From 5adc1f6baa57d9bb7083ef807ee238d247443d1a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 27 Nov 2025 08:17:58 -0300 Subject: [PATCH 12/17] fix: remove spm packages --- .github/workflows/integration-tests.yml | 8 ++++++-- .github/workflows/unit-tests.yml | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a5e11d1e..02190acb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -41,8 +41,6 @@ jobs: ~/Library/org.swift.swiftpm Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - name: Install dependencies run: | @@ -56,6 +54,12 @@ jobs: xcrun simctl boot "iPhone 16" || true echo "✅ Simulator started at $(date)" + - name: Clean build + run: | + echo "⏱️ Cleaning build at $(date)" + xcodebuild clean -scheme Bitkit | xcbeautify + echo "✅ Build cleaned at $(date)" + - name: Run integration tests run: | echo "⏱️ Starting integration tests at $(date)" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f54ccbc0..20a1a489 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -40,8 +40,6 @@ jobs: ~/Library/org.swift.swiftpm Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - name: Install dependencies run: | @@ -55,6 +53,12 @@ jobs: xcrun simctl boot "iPhone 16" || true echo "✅ Simulator started at $(date)" + - name: Clean build + run: | + echo "⏱️ Cleaning build at $(date)" + xcodebuild clean -scheme Bitkit | xcbeautify + echo "✅ Build cleaned at $(date)" + - name: Run unit tests run: | echo "⏱️ Starting unit tests at $(date)" From 4ab3122e98e06157ed833066d22a56376c17e505 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Nov 2025 07:26:00 -0500 Subject: [PATCH 13/17] Remove tx confirmed toast --- .../Localization/en.lproj/Localizable.strings | 4 ---- Bitkit/ViewModels/AppViewModel.swift | 21 ------------------- 2 files changed, 25 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 822c7277..1c86bcd8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -968,10 +968,6 @@ "wallet__toast_payment_success_description" = "Your instant payment was sent successfully."; "wallet__toast_payment_failed_title" = "Payment Failed"; "wallet__toast_payment_failed_description" = "Your instant payment failed. Please try again."; -"wallet__toast_payment_confirmed_title" = "Payment Confirmed"; -"wallet__toast_payment_confirmed_description" = "Your received payment has been confirmed"; -"wallet__toast_transaction_confirmed_title" = "Transaction Confirmed"; -"wallet__toast_transaction_confirmed_description" = "Your transaction has been confirmed"; "wallet__toast_received_transaction_replaced_title" = "Received Transaction Replaced"; "wallet__toast_received_transaction_replaced_description" = "Your received transaction was replaced by a fee bump"; "wallet__toast_transaction_replaced_title" = "Transaction Replaced"; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f58384ac..7da6fce3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -414,27 +414,6 @@ extension AppViewModel { } case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details): Logger.info("Transaction confirmed: \(txid) at block \(blockHeight)") - Task { - if await CoreService.shared.activity.isReceivedTransaction(txid: txid) { - await MainActor.run { - toast( - type: .info, - title: t("wallet__toast_payment_confirmed_title"), - description: t("wallet__toast_payment_confirmed_description"), - accessibilityIdentifier: "PaymentConfirmedToast" - ) - } - } else { - await MainActor.run { - toast( - type: .info, - title: t("wallet__toast_transaction_confirmed_title"), - description: t("wallet__toast_transaction_confirmed_description"), - accessibilityIdentifier: "TransactionConfirmedToast" - ) - } - } - } case let .onchainTransactionReplaced(txid, conflicts): Logger.info("Transaction replaced: \(txid) by \(conflicts.count) conflict(s)") Task { From 2ce3f039e088183e4f92bc681a9f12eb90067cc9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Nov 2025 08:24:18 -0500 Subject: [PATCH 14/17] Delete boosted activity and pass tags for replaced txs --- Bitkit/Services/CoreService.swift | 52 +++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index e8a90429..dc48b152 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -308,7 +308,10 @@ class ActivityService { // Find the activity for the replaced transaction let replacedActivity = try await self.getOnchainActivityByTxId(txid: txid) + let replacedTags: [String] if var existing = replacedActivity { + replacedTags = await (try? self.tags(forActivity: existing.id)) ?? [] + Logger.info( "Transaction \(txid) replaced by \(conflicts.count) conflict(s): \(conflicts.joined(separator: ", "))", context: "CoreService.handleOnchainTransactionReplaced" @@ -320,8 +323,9 @@ class ActivityService { try await self.update(id: existing.id, activity: .onchain(existing)) Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") } else { + replacedTags = [] Logger.info( - "Activity not found for replaced transaction \(txid) - will be created when transaction is processed", + "Activity not found for replaced transaction \(txid) - was deleted by initiated RBF, tags in pre-activity metadata", context: "CoreService.handleOnchainTransactionReplaced" ) } @@ -364,6 +368,19 @@ class ActivityService { activity.isBoosted = true activity.updatedAt = UInt64(Date().timeIntervalSince1970) try await self.update(id: activity.id, activity: .onchain(activity)) + + // Apply tags from the replaced transaction + if !replacedTags.isEmpty { + do { + try await self.appendTags(toActivity: activity.id, replacedTags) + } catch { + Logger.error( + "Failed to apply tags from replaced transaction \(txid) to replacement transaction \(conflictTxid): \(error)", + context: "CoreService.handleOnchainTransactionReplaced" + ) + } + } + Logger.info( "Updated replacement transaction \(conflictTxid) with boostTxId \(txid)", context: "CoreService.handleOnchainTransactionReplaced" @@ -934,12 +951,35 @@ class ActivityService { Logger.info("RBF transaction created successfully: \(txid)", context: "CoreService.boostOnchainTransaction") - // For RBF, mark the original activity as doesExist = false instead of deleting it - // This allows it to be displayed with the "removed" status - onchainActivity.doesExist = false - try await self.update(id: activityId, activity: .onchain(onchainActivity)) + // Get tags from the old activity before deleting it + let oldTags = await (try? self.tags(forActivity: activityId)) ?? [] + + // Create pre-activity metadata for the replacement transaction with tags from the old activity + if !oldTags.isEmpty { + let currentTime = UInt64(Date().timeIntervalSince1970) + let preActivityMetadata = BitkitCore.PreActivityMetadata( + paymentId: txid, + tags: oldTags, + paymentHash: nil, + txId: txid, + address: onchainActivity.address, + isReceive: false, + feeRate: UInt64(feeRate), + isTransfer: onchainActivity.isTransfer, + channelId: onchainActivity.channelId, + createdAt: currentTime + ) + try? await self.addPreActivityMetadata(preActivityMetadata) + Logger.info( + "Created pre-activity metadata with \(oldTags.count) tag(s) for RBF replacement transaction \(txid)", + context: "CoreService.boostOnchainTransaction" + ) + } + + // For RBF we initiated, delete the old activity + _ = try await self.delete(id: activityId) Logger.info( - "Successfully marked activity \(activityId) as doesExist = false (replaced by RBF)", + "Successfully deleted activity \(activityId) (replaced by RBF transaction \(txid))", context: "CoreService.boostOnchainTransaction" ) } From 574a8f905931d03003b72a87318ee5b259c5f582 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Nov 2025 09:07:17 -0500 Subject: [PATCH 15/17] Filter RBF txs --- Bitkit/Services/CoreService.swift | 98 ++++++++++++------- Bitkit/ViewModels/ActivityListViewModel.swift | 37 ++++++- 2 files changed, 94 insertions(+), 41 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index dc48b152..de48295e 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -25,6 +25,42 @@ class ActivityService { /// Maximum address index to search when current address exists private static let maxAddressSearchIndex: UInt32 = 100_000 + // MARK: - BoostTxIds Cache + + // Cached set of transaction IDs that appear in boostTxIds (for filtering replaced transactions) + private var cachedTxIdsInBoostTxIds: Set = [] + + /// Get the set of transaction IDs that appear in boostTxIds (cached for performance) + func getTxIdsInBoostTxIds() async -> Set { + if cachedTxIdsInBoostTxIds.isEmpty { + await refreshBoostTxIdsCache() + } + return cachedTxIdsInBoostTxIds + } + + private func updateBoostTxIdsCache(for activity: Activity) { + if case let .onchain(onchain) = activity { + cachedTxIdsInBoostTxIds.formUnion(onchain.boostTxIds) + } + } + + private func refreshBoostTxIdsCache() async { + do { + let allOnchainActivities = try await get(filter: .onchain) + var txIds: Set = [] + for activity in allOnchainActivities { + if case let .onchain(onchain) = activity { + txIds.formUnion(onchain.boostTxIds) + } + } + await MainActor.run { + self.cachedTxIdsInBoostTxIds = txIds + } + } catch { + Logger.error("Failed to refresh boostTxIds cache: \(error)", context: "ActivityService") + } + } + // MARK: - Transaction Status Checks func wasTransactionReplaced(txid: String) async -> Bool { @@ -125,6 +161,8 @@ class ActivityService { _ = try deleteActivityById(activityId: id) } + // Clear cache since all activities are deleted + self.cachedTxIdsInBoostTxIds.removeAll() self.activitiesChangedSubject.send() } } @@ -132,6 +170,7 @@ class ActivityService { func insert(_ activity: Activity) async throws { try await ServiceQueue.background(.core) { try insertActivity(activity: activity) + self.updateBoostTxIdsCache(for: activity) self.activitiesChangedSubject.send() } } @@ -139,6 +178,7 @@ class ActivityService { func upsertList(_ activities: [Activity]) async throws { try await ServiceQueue.background(.core) { try upsertActivities(activities: activities) + await self.refreshBoostTxIdsCache() } } @@ -308,10 +348,7 @@ class ActivityService { // Find the activity for the replaced transaction let replacedActivity = try await self.getOnchainActivityByTxId(txid: txid) - let replacedTags: [String] if var existing = replacedActivity { - replacedTags = await (try? self.tags(forActivity: existing.id)) ?? [] - Logger.info( "Transaction \(txid) replaced by \(conflicts.count) conflict(s): \(conflicts.joined(separator: ", "))", context: "CoreService.handleOnchainTransactionReplaced" @@ -323,9 +360,8 @@ class ActivityService { try await self.update(id: existing.id, activity: .onchain(existing)) Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") } else { - replacedTags = [] Logger.info( - "Activity not found for replaced transaction \(txid) - was deleted by initiated RBF, tags in pre-activity metadata", + "Activity not found for replaced transaction \(txid) - will be created when transaction is processed", context: "CoreService.handleOnchainTransactionReplaced" ) } @@ -369,13 +405,16 @@ class ActivityService { activity.updatedAt = UInt64(Date().timeIntervalSince1970) try await self.update(id: activity.id, activity: .onchain(activity)) - // Apply tags from the replaced transaction - if !replacedTags.isEmpty { + // Move tags from the replaced transaction + if let replacedActivity { do { - try await self.appendTags(toActivity: activity.id, replacedTags) + let replacedTags = try await self.tags(forActivity: replacedActivity.id) + if !replacedTags.isEmpty { + try await self.appendTags(toActivity: activity.id, replacedTags) + } } catch { Logger.error( - "Failed to apply tags from replaced transaction \(txid) to replacement transaction \(conflictTxid): \(error)", + "Failed to copy tags from replaced transaction \(txid) to replacement transaction \(conflictTxid): \(error)", context: "CoreService.handleOnchainTransactionReplaced" ) } @@ -792,6 +831,7 @@ class ActivityService { func update(id: String, activity: Activity) async throws { try await ServiceQueue.background(.core) { try updateActivity(activityId: id, activity: activity) + self.updateBoostTxIdsCache(for: activity) self.activitiesChangedSubject.send() } } @@ -799,12 +839,19 @@ class ActivityService { func upsert(_ activity: Activity) async throws { try await ServiceQueue.background(.core) { try upsertActivity(activity: activity) + self.updateBoostTxIdsCache(for: activity) self.activitiesChangedSubject.send() } } func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { + // Rebuild cache if deleting an onchain activity with boostTxIds + let activity = try? getActivityById(activityId: id) + if let activity, case let .onchain(onchain) = activity, !onchain.boostTxIds.isEmpty { + await self.refreshBoostTxIdsCache() + } + let result = try deleteActivityById(activityId: id) self.activitiesChangedSubject.send() return result @@ -951,35 +998,12 @@ class ActivityService { Logger.info("RBF transaction created successfully: \(txid)", context: "CoreService.boostOnchainTransaction") - // Get tags from the old activity before deleting it - let oldTags = await (try? self.tags(forActivity: activityId)) ?? [] - - // Create pre-activity metadata for the replacement transaction with tags from the old activity - if !oldTags.isEmpty { - let currentTime = UInt64(Date().timeIntervalSince1970) - let preActivityMetadata = BitkitCore.PreActivityMetadata( - paymentId: txid, - tags: oldTags, - paymentHash: nil, - txId: txid, - address: onchainActivity.address, - isReceive: false, - feeRate: UInt64(feeRate), - isTransfer: onchainActivity.isTransfer, - channelId: onchainActivity.channelId, - createdAt: currentTime - ) - try? await self.addPreActivityMetadata(preActivityMetadata) - Logger.info( - "Created pre-activity metadata with \(oldTags.count) tag(s) for RBF replacement transaction \(txid)", - context: "CoreService.boostOnchainTransaction" - ) - } - - // For RBF we initiated, delete the old activity - _ = try await self.delete(id: activityId) + // For RBF, mark the original activity as doesExist = false instead of deleting it + // This allows it to be displayed with the "removed" status + onchainActivity.doesExist = false + try await self.update(id: activityId, activity: .onchain(onchainActivity)) Logger.info( - "Successfully deleted activity \(activityId) (replaced by RBF transaction \(txid))", + "Successfully marked activity \(activityId) as doesExist = false (replaced by RBF)", context: "CoreService.boostOnchainTransaction" ) } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 063969b2..972d4278 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -144,12 +144,18 @@ class ActivityListViewModel: ObservableObject { do { // Get latest activities first as that's displayed on the home view let limitLatest: UInt32 = 3 - latestActivities = try await coreService.activity.get(filter: .all, limit: limitLatest) + // Fetch extra to account for potential filtering of replaced transactions + let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3) + let filtered = await filterOutReplacedSentTransactions(latest) + latestActivities = Array(filtered.prefix(Int(limitLatest))) // Fetch all activities await updateFilteredActivities() - lightningActivities = try await coreService.activity.get(filter: .lightning) - onchainActivities = try await coreService.activity.get(filter: .onchain) + + let lightningActivities = try await coreService.activity.get(filter: .lightning) + + let onchain = try await coreService.activity.get(filter: .onchain) + onchainActivities = await filterOutReplacedSentTransactions(onchain) // Update available tags and fee estimates await updateAvailableTags() @@ -198,8 +204,11 @@ class ActivityListViewModel: ObservableObject { maxDate: maxDate ) + // Filter out replaced sent transactions that appear in another transaction's boostTxIds + let filteredOutReplaced = await filterOutReplacedSentTransactions(baseFilteredActivities) + // Apply tab filtering - filteredActivities = filterActivitiesByTab(baseFilteredActivities, selectedTab: selectedTab) + filteredActivities = filterActivitiesByTab(filteredOutReplaced, selectedTab: selectedTab) // Update grouped activities updateGroupedActivities() @@ -449,6 +458,26 @@ 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() + + // Filter out activities that: + // 1. Are onchain + // 2. Have doesExist = false + // 3. Are sent transactions + // 4. Appear in another transaction's boostTxIds + return activities.filter { activity in + if case let .onchain(onchain) = activity { + if !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId) { + return false + } + } + return true + } + } + /// Filter activities based on the selected tab private func filterActivitiesByTab(_ activities: [Activity], selectedTab: ActivityTab) -> [Activity] { switch selectedTab { From 7f7076d1a4f1dcd029a8da60dcc7a592192fa161 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Nov 2025 11:14:09 -0500 Subject: [PATCH 16/17] Fix RBF until events arrive --- Bitkit/Services/CoreService.swift | 8 ++++---- Bitkit/ViewModels/AppViewModel.swift | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index de48295e..53afed50 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -356,6 +356,7 @@ class ActivityService { // Mark the replaced transaction as not existing existing.doesExist = false + existing.isBoosted = false existing.updatedAt = UInt64(Date().timeIntervalSince1970) try await self.update(id: existing.id, activity: .onchain(existing)) Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") @@ -998,12 +999,11 @@ class ActivityService { Logger.info("RBF transaction created successfully: \(txid)", context: "CoreService.boostOnchainTransaction") - // For RBF, mark the original activity as doesExist = false instead of deleting it - // This allows it to be displayed with the "removed" status - onchainActivity.doesExist = false + // For RBF, mark the original activity as boosted until the replacement comes + onchainActivity.isBoosted = true try await self.update(id: activityId, activity: .onchain(onchainActivity)) Logger.info( - "Successfully marked activity \(activityId) as doesExist = false (replaced by RBF)", + "Successfully marked activity \(activityId) as replaced by fee", context: "CoreService.boostOnchainTransaction" ) } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 7da6fce3..39e11944 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -401,10 +401,6 @@ extension AppViewModel { await MainActor.run { if !shouldShow { - Logger.info( - "Skipping received sheet for RBF replacement with same value: \(txid)", - context: "AppViewModel" - ) return } From 1b49bd8eb09620b2c8c0368bde16b3401daede1d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Nov 2025 13:31:35 -0500 Subject: [PATCH 17/17] feat: Show CPFC txs as boost fee --- .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/Services/CoreService.swift | 9 +++++++++ .../Views/Wallets/Activity/ActivityIcon.swift | 6 ++++-- .../Wallets/Activity/ActivityItemView.swift | 16 +++++++++++++-- .../Wallets/Activity/ActivityRowOnchain.swift | 20 ++++++++++++++++--- 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 1c86bcd8..84df6832 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1054,6 +1054,8 @@ "wallet__activity_output" = "{count, plural, one {OUTPUT} other {OUTPUTS (#)}}"; "wallet__activity_boosted_cpfp" = "BOOSTED TRANSACTION {num} (CPFP)"; "wallet__activity_boosted_rbf" = "BOOSTED TRANSACTION {num} (RBF)"; +"wallet__activity_boost_fee" = "Boost Fee"; +"wallet__activity_boost_fee_description" = "Boosted incoming transaction"; "wallet__activity_explorer" = "Open Block Explorer"; "wallet__activity_successful" = "Successful"; "wallet__activity_invoice_note" = "Invoice note"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 53afed50..6cf0f424 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -137,6 +137,15 @@ class ActivityService { return doesExistMap } + func isCpfpChildTransaction(txId: String) async -> Bool { + guard await getTxIdsInBoostTxIds().contains(txId), + let activity = try? await getOnchainActivityByTxId(txid: txId) + else { + return false + } + return activity.doesExist + } + init(coreService: CoreService) { self.coreService = coreService } diff --git a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift index 75a0620a..5a1bec36 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift @@ -10,9 +10,11 @@ struct ActivityIcon: View { let isBoosted: Bool let isTransfer: Bool let doesExist: Bool + let isCpfpChild: Bool - init(activity: Activity, size: CGFloat = 32) { + init(activity: Activity, size: CGFloat = 32, isCpfpChild: Bool = false) { self.size = size + self.isCpfpChild = isCpfpChild switch activity { case let .lightning(ln): isLightning = true @@ -65,7 +67,7 @@ struct ActivityIcon: View { backgroundColor: .red16, size: size ) - } else if isBoosted && !(confirmed ?? false) { + } else if isCpfpChild || (isBoosted && !(confirmed ?? false)) { CircularIcon( icon: "timer-alt", iconColor: .yellow, diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index c18ffc2d..4b6234ba 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -14,6 +14,7 @@ struct ActivityItemView: View { @EnvironmentObject var channelDetails: ChannelDetailsViewModel @StateObject private var viewModel: ActivityItemViewModel @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist + @State private var isCpfpChild: Bool = false init(item: Activity) { self.item = item @@ -100,6 +101,10 @@ struct ActivityItemView: View { : t("wallet__activity_transfer_savings_done") } + if isCpfpChild { + return t("wallet__activity_boost_fee") + } + return isSent ? t("wallet__activity_bitcoin_sent") : t("wallet__activity_bitcoin_received") @@ -112,9 +117,11 @@ struct ActivityItemView: View { private var shouldDisableBoostButton: Bool { switch viewModel.activity { case .lightning: - // Lightning transactions can never be boosted return true case let .onchain(activity): + if isCpfpChild { + return true + } if !activity.doesExist { return true } @@ -186,7 +193,7 @@ struct ActivityItemView: View { HStack(alignment: .bottom) { MoneyStack(sats: amount, prefix: amountPrefix, showSymbol: false) Spacer() - ActivityIcon(activity: viewModel.activity, size: 48) + ActivityIcon(activity: viewModel.activity, size: 48, isCpfpChild: isCpfpChild) } .padding(.bottom, 16) @@ -221,6 +228,11 @@ 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) + } + // Load boostTxIds doesExist status to determine RBF vs CPFP if case let .onchain(activity) = viewModel.activity, !activity.boostTxIds.isEmpty diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index b09c8027..f3f97daa 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -5,12 +5,15 @@ private struct ActivityStatus: View { let txType: PaymentType let confirmed: Bool let isTransfer: Bool + let isCpfpChild: Bool var body: some View { if isTransfer { BodyMSBText(t("wallet__activity_transfer")) } else { - if txType == .sent { + if isCpfpChild { + BodyMSBText(t("wallet__activity_boost_fee")) + } else if txType == .sent { BodyMSBText(t("wallet__activity_sent")) } else { BodyMSBText(t("wallet__activity_received")) @@ -22,6 +25,7 @@ private struct ActivityStatus: View { struct ActivityRowOnchain: View { let item: OnchainActivity let feeEstimates: FeeRates? + @State private var isCpfpChild: Bool = false private var amountPrefix: String { return item.txType == .sent ? "-" : "+" @@ -53,6 +57,10 @@ struct ActivityRowOnchain: View { return t("wallet__activity_removed") } + if isCpfpChild { + return t("wallet__activity_boost_fee_description") + } + if item.isTransfer { switch item.txType { case .sent: @@ -75,16 +83,22 @@ struct ActivityRowOnchain: View { var body: some View { HStack(spacing: 16) { - ActivityIcon(activity: .onchain(item), size: 40) + ActivityIcon(activity: .onchain(item), size: 40, isCpfpChild: isCpfpChild) VStack(alignment: .leading, spacing: 2) { - ActivityStatus(txType: item.txType, confirmed: item.confirmed, isTransfer: item.isTransfer) + ActivityStatus(txType: item.txType, confirmed: item.confirmed, isTransfer: item.isTransfer, isCpfpChild: isCpfpChild) + .lineLimit(1) CaptionBText(description) + .lineLimit(1) } + .fixedSize(horizontal: false, vertical: true) Spacer() MoneyCell(sats: amount, prefix: amountPrefix) } + .task { + isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: item.txId) + } } }