diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 831b7ba9d..510d49b17 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -36,6 +36,7 @@ struct AccountAddresses { class ActivityService { private let coreService: CoreService + private let bumpFeeByRbf: (String, UInt32) async throws -> String private let activitiesChangedSubject = PassthroughSubject() @@ -360,8 +361,14 @@ class ActivityService { return activity.doesExist && !activity.isBoosted } - init(coreService: CoreService) { + init( + coreService: CoreService, + bumpFeeByRbf: @escaping (String, UInt32) async throws -> String = { txid, feeRate in + try await LightningService.shared.bumpFeeByRbf(txid: txid, satsPerVbyte: feeRate) + } + ) { self.coreService = coreService + self.bumpFeeByRbf = bumpFeeByRbf addressSearchCoordinator = AddressSearchCoordinator() } @@ -679,11 +686,7 @@ class ActivityService { seenAt: seenAt ) - if let existingActivity, case let .onchain(existing) = existingActivity { - try await update(id: existing.id, activity: .onchain(onchain)) - } else { - try await upsert(.onchain(onchain)) - } + try await upsertOnchainActivityPreservingFeeRate(onchain) } // MARK: - Onchain Event Handlers @@ -743,7 +746,7 @@ class ActivityService { existing.doesExist = false existing.isBoosted = false existing.updatedAt = UInt64(Date().timeIntervalSince1970) - try await self.update(id: existing.id, activity: .onchain(existing)) + try await self.upsertOnchainActivityPreservingFeeRate(existing) Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") } else { Logger.info( @@ -790,7 +793,7 @@ class ActivityService { activity.isBoosted = true activity.contact = activity.contact ?? replacedActivity?.contact activity.updatedAt = UInt64(Date().timeIntervalSince1970) - try await self.update(id: activity.id, activity: .onchain(activity)) + try await self.upsertOnchainActivityPreservingFeeRate(activity) // Move tags from the replaced transaction if let replacedActivity { @@ -829,7 +832,7 @@ class ActivityService { onchain.confirmTimestamp = nil onchain.updatedAt = UInt64(Date().timeIntervalSince1970) - try await self.update(id: onchain.id, activity: .onchain(onchain)) + try await self.upsertOnchainActivityPreservingFeeRate(onchain) } } @@ -843,7 +846,7 @@ class ActivityService { onchain.doesExist = false onchain.updatedAt = UInt64(Date().timeIntervalSince1970) - try await self.update(id: onchain.id, activity: .onchain(onchain)) + try await self.upsertOnchainActivityPreservingFeeRate(onchain) } } @@ -1260,6 +1263,25 @@ class ActivityService { } } + func upsertOnchainActivityPreservingFeeRate(_ onchain: OnchainActivity) async throws { + try await ServiceQueue.background(.core) { + try BitkitCore.upsertOnchainActivityPreservingFeeRate(activity: onchain) + self.updateBoostTxIdsCache(for: .onchain(onchain)) + self.activitiesChangedSubject.send() + } + } + + private func recordRbfBoost(originalActivityId: String, replacementTxid: String, feeRate: UInt32) async throws { + try await ServiceQueue.background(.core) { + try BitkitCore.recordRbfBoost( + walletId: WalletScope.default, originalActivityId: originalActivityId, + replacementTxId: replacementTxid, feeRate: UInt64(feeRate) + ) + self.metadataChangedSubject.send() + self.activitiesChangedSubject.send() + } + } + /// Create sent onchain activity from send result so it appears immediately; LDK events update it later (e.g. confirmation). /// /// `walletId` scopes the row: a transfer funded from a watch-only hardware wallet is written @@ -1667,18 +1689,11 @@ class ActivityService { Logger.debug("Original transaction ID: \(onchainActivity.txId)", context: "CoreService.boostOnchainTransaction") // Use RBF for outgoing transactions - txid = try await LightningService.shared.bumpFeeByRbf( - txid: onchainActivity.txId, - satsPerVbyte: feeRate - ) + txid = try await self.bumpFeeByRbf(onchainActivity.txId, feeRate) Logger.info("RBF transaction created successfully: \(txid)", context: "CoreService.boostOnchainTransaction") - // For RBF, mark the original activity as boosted and update the fee rate - // so the UI shows the correct confirmation time estimate until the replacement arrives - onchainActivity.isBoosted = true - onchainActivity.feeRate = UInt64(feeRate) - try await self.update(id: activityId, activity: .onchain(onchainActivity)) + try await self.recordRbfBoost(originalActivityId: activityId, replacementTxid: txid, feeRate: feeRate) Logger.info( "Successfully marked activity \(activityId) as replaced by fee", context: "CoreService.boostOnchainTransaction" diff --git a/BitkitTests/RbfActivityFeeRateTests.swift b/BitkitTests/RbfActivityFeeRateTests.swift new file mode 100644 index 000000000..a3a3d0763 --- /dev/null +++ b/BitkitTests/RbfActivityFeeRateTests.swift @@ -0,0 +1,203 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +final class RbfActivityFeeRateTests: XCTestCase { + private let testDbPath = FileManager.default.temporaryDirectory + .appendingPathComponent("RbfActivityFeeRateTests-\(UUID().uuidString)", isDirectory: true) + private let activity = Bitkit.CoreService.shared.activity + private let originalTxid = String(repeating: "1", count: 64) + private let replacementTxid = String(repeating: "2", count: 64) + private let timestamp = UInt64(Date().timeIntervalSince1970) + 100 + + override func setUp() async throws { + try await super.setUp() + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: testDbPath, withIntermediateDirectories: true) + _ = try initDb(basePath: testDbPath.path) + try await activity.insert(.onchain(onchain(txid: originalTxid))) + } + + override func tearDown() async throws { + await drainCoreServiceQueue() + await repointCoreToAppStorage() + try FileManager.default.removeItem(at: testDbPath) + try await super.tearDown() + } + + func testBoostBeforeReplacementArrivesPersistsRateThroughSyncAndConfirmation() async throws { + let service = makeService() + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + + let pending = try await service.getPreActivityMetadata(searchKey: replacementTxid) + XCTAssertEqual(pending?.feeRate, 25, "The fee rate must be keyed by the replacement txid, not just stored on the original") + XCTAssertEqual(pending?.txId, replacementTxid) + + try await service.syncLdkNodePayments([payment(txid: replacementTxid)]) + try await assertReplacement(service, rate: 25) + let consumed = try await service.getPreActivityMetadata(searchKey: replacementTxid) + XCTAssertNil(consumed, "Core should consume the pending fee metadata when it creates the replacement") + + let newService = makeService() + try await newService.syncLdkNodePayments([payment(txid: replacementTxid, confirmed: true)]) + try await assertReplacement(newService, rate: 25) + let stored = try await newService.getOnchainActivityByTxId(txid: replacementTxid) + XCTAssertEqual(stored?.confirmed, true) + } + + func testReplacementArrivingBeforeBoostReturnsIsCorrectedWithoutLosingMetadata() async throws { + let service = makeService { _, _ in + var replacement = self.onchain(txid: self.replacementTxid, fee: 2500) + replacement.id = "replacement-payment-id" + replacement.contact = "saved-contact" + replacement.seenAt = self.timestamp + replacement.isBoosted = true + replacement.boostTxIds = [self.originalTxid] + try await self.activity.insert(.onchain(replacement)) + await self.activity.markActivityAsSeen(id: replacement.id, seenAt: self.timestamp) + try await self.activity.appendTags(toActivity: replacement.id, ["saved-tag"]) + return self.replacementTxid + } + + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + try await assertReplacement(service, rate: 25) + let stored = try await service.getOnchainActivityByTxId(txid: replacementTxid) + let replacement = try XCTUnwrap(stored) + XCTAssertEqual(replacement.id, "replacement-payment-id") + XCTAssertEqual(replacement.contact, "saved-contact") + XCTAssertEqual(replacement.seenAt, timestamp) + XCTAssertEqual(replacement.boostTxIds, [originalTxid]) + let tags = try await service.tags(forActivity: replacement.id) + XCTAssertEqual(tags, ["saved-tag"]) + } + + func testReplacementEventBeforeBoostReturnsDoesNotResurrectOriginal() async throws { + let service = makeService { _, _ in + try await self.activity.insert(.onchain(self.onchain(txid: self.replacementTxid, fee: 2500))) + try await self.activity.handleOnchainTransactionReplaced(txid: self.originalTxid, conflicts: [self.replacementTxid]) + return self.replacementTxid + } + + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + try await assertReplacement(service, rate: 25) + let original = try await service.getOnchainActivityByTxId(txid: originalTxid) + XCTAssertEqual(original?.doesExist, false) + XCTAssertEqual(original?.isBoosted, false) + } + + func testPendingReplacementMetadataKeepsExistingTags() async throws { + let service = makeService() + let metadata = PreActivityMetadata( + walletId: Bitkit.WalletScope.default, paymentId: replacementTxid, tags: ["pending-tag"], paymentHash: nil, + txId: replacementTxid, address: "bcrt1qrecipient", isReceive: false, feeRate: 1, + isTransfer: false, channelId: nil, createdAt: timestamp + ) + try await service.addPreActivityMetadata(metadata) + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + try await service.syncLdkNodePayments([payment(txid: replacementTxid)]) + + try await assertReplacement(service, rate: 25) + let stored = try await service.getOnchainActivityByTxId(txid: replacementTxid) + let replacement = try XCTUnwrap(stored) + let tags = try await service.tags(forActivity: replacement.id) + XCTAssertEqual(tags, ["pending-tag"]) + } + + func testRepeatedBoostRecordsEachReplacementRateSeparately() async throws { + let nextTxid = String(repeating: "3", count: 64) + let service = makeService { txid, _ in txid == self.originalTxid ? self.replacementTxid : nextTxid } + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + try await service.syncLdkNodePayments([payment(txid: replacementTxid)]) + let stored = try await service.getOnchainActivityByTxId(txid: replacementTxid) + let replacement = try XCTUnwrap(stored) + + _ = try await service.boostOnchainTransaction(activityId: replacement.id, feeRate: 40) + try await service.syncLdkNodePayments([payment(txid: nextTxid, fee: 4000)]) + let next = try await service.getOnchainActivityByTxId(txid: nextTxid) + XCTAssertEqual(next?.feeRate, 40) + XCTAssertEqual(next?.fee, 4000) + } + + func testFailedBoostDoesNotRecordReplacementMetadata() async throws { + let service = makeService { _, _ in throw TestError.bumpFailed } + do { + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + XCTFail("Expected a failed boost") + } catch { + let wrapped = try XCTUnwrap(error as? Bitkit.AppError) + XCTAssertTrue(wrapped.underlyingError is TestError) + } + let metadata = try await service.getPreActivityMetadata(searchKey: replacementTxid) + XCTAssertNil(metadata) + let original = try await service.getOnchainActivityByTxId(txid: originalTxid) + XCTAssertEqual(original?.feeRate, 1) + XCTAssertEqual(original?.isBoosted, false) + } + + func testStaleEventSnapshotCannotOverwriteCorrectedRate() async throws { + let service = makeService() + let stale = onchain(txid: replacementTxid, fee: 2500) + try await service.insert(.onchain(stale)) + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + + try await service.upsertOnchainActivityPreservingFeeRate(stale) + try await assertReplacement(service, rate: 25) + } + + func testSameTxidInHardwareWalletIsNotModifiedByNativeBoost() async throws { + let service = makeService() + var hardware = onchain(txid: replacementTxid, fee: 2500) + hardware.walletId = "trezor:test" + hardware.feeRate = 7 + try await service.insert(.onchain(hardware)) + _ = try await service.boostOnchainTransaction(activityId: originalTxid, feeRate: 25) + try await service.syncLdkNodePayments([payment(txid: replacementTxid)]) + try await assertReplacement(service, rate: 25) + + let stored = try await service.getOnchainActivityByTxId(txid: replacementTxid, walletId: hardware.walletId) + XCTAssertEqual(stored?.feeRate, 7) + } + + private func makeService( + bump: ((String, UInt32) async throws -> String)? = nil + ) -> Bitkit.ActivityService { + Bitkit.ActivityService(coreService: .shared, bumpFeeByRbf: bump ?? { _, _ in self.replacementTxid }) + } + + private func assertReplacement(_ service: Bitkit.ActivityService, rate: UInt64) async throws { + let stored = try await service.getOnchainActivityByTxId(txid: replacementTxid) + let replacement = try XCTUnwrap(stored) + XCTAssertEqual(replacement.feeRate, rate) + XCTAssertEqual(replacement.fee, 2500, "The replacement's real total fee must not be copied from its parent") + XCTAssertEqual( + TransactionSpeed.feeTierKeyComponent(for: replacement.feeRate, feeEstimates: FeeRates(fast: 20, mid: 10, slow: 5)), + "fast" + ) + } + + private func payment(txid: String, fee: UInt64 = 2500, confirmed: Bool = false) -> PaymentDetails { + PaymentDetails( + id: "payment-\(txid)", + kind: .onchain( + txid: txid, + status: confirmed ? .confirmed(blockHash: String(repeating: "0", count: 64), height: 100, timestamp: timestamp) : .unconfirmed + ), + amountMsat: 10000 * 1000, feePaidMsat: fee * 1000, direction: .outbound, status: .pending, + latestUpdateTimestamp: timestamp + (confirmed ? 100 : 0) + ) + } + + private func onchain(txid: String, fee: UInt64 = 100) -> OnchainActivity { + OnchainActivity( + walletId: Bitkit.WalletScope.default, id: txid, txType: .sent, txId: txid, value: 10000, fee: fee, feeRate: 1, + address: "bcrt1qrecipient", confirmed: false, timestamp: timestamp, isBoosted: false, boostTxIds: [], + isTransfer: false, doesExist: true, confirmTimestamp: nil, channelId: nil, transferTxId: nil, contact: nil, + createdAt: timestamp, updatedAt: timestamp, seenAt: nil + ) + } + + private enum TestError: Error { + case bumpFailed + } +} diff --git a/changelog.d/next/879.fixed.md b/changelog.d/next/879.fixed.md new file mode 100644 index 000000000..8b0717a1a --- /dev/null +++ b/changelog.d/next/879.fixed.md @@ -0,0 +1 @@ +Show the correct confirmation estimate for outgoing transactions replaced by a fee boost. diff --git a/journeys/onchain-send/README.md b/journeys/onchain-send/README.md new file mode 100644 index 000000000..dbcaac562 --- /dev/null +++ b/journeys/onchain-send/README.md @@ -0,0 +1,11 @@ +# Onchain send + +`rbf-replacement-fee-rate.xml` is shared with bitkit-android under the same name and action sequence. +Use a disposable regtest wallet and a recipient belonging to another wallet. Never use mainnet funds. + +Android dev uses staging regtest; iOS E2E builds use the configured regtest backend. Both expose the +journey's identifiers. Both platforms edit custom Boost fees with plus/minus buttons; the journey +only inspects that screen and keeps the recommended rate on both platforms. + +Until the core fix is released, this journey needs the local core SDK exposing `recordRbfBoost` +and `upsertOnchainActivityPreservingFeeRate`; the published 0.5.18 SDK does not contain those APIs. diff --git a/journeys/onchain-send/rbf-replacement-fee-rate.xml b/journeys/onchain-send/rbf-replacement-fee-rate.xml new file mode 100644 index 000000000..f42ff834c --- /dev/null +++ b/journeys/onchain-send/rbf-replacement-fee-rate.xml @@ -0,0 +1,26 @@ + + + Requires an English E2E_BUILD app with a disposable native wallet holding at least 100 000 + confirmed Savings sats, and a regtest recipient address belonging to another wallet. Use the + regtest backend in journeys/README.md and do not mine until the confirmation step below. + This is an outgoing RBF boost, not incoming CPFP or a watch-only hardware payment. The + recommended Boost rate is at least the Fast tier, so its replacement should show that tier's + confirmation estimate, not the 1 sat/vB fallback. Shared with the matching Android journey; + Android dev uses staging regtest, while iOS E2E_BUILD uses its configured regtest backend. + Both platforms use plus/minus controls for custom Boost edits; this journey only inspects the rate. + + + On Home, tap Send (id "Send"), dismiss any camera permission dialog with Don't Allow, and tap Enter Manually (id "RecipientManual") + Enter the regtest recipient in id "RecipientInput" and tap Continue (id "AddressContinue") + Choose Savings as the source, enter 10 000 sats with the number pad, and tap Continue (id "ContinueAmount") + On the review screen (id "SendConfirm"), open Fee & Speed, choose Custom, use the number pad to enter 1 sat/vB, and return to review with Continue + Swipe the send control (id "GRAB"), approve any fee warning and PIN prompt, wait for Sent (id "SendSuccess"), and tap Close (id "Close") + Open the unconfirmed sent transaction from Activity, note its transaction ID and total fee (id "ActivityFee"), and tap Boost (id "BoostButton") + Wait for the RBF Boost sheet (id "RBFBoost") to load its recommended Fast fee, open the pencil (id "CustomFeeButton"), and note the selected sat/vB rate and confirmation estimate + Swipe the Boost control (id "GRAB") and wait for success (id "BoostSuccessToast") without mining a block + Return to Activity and wait for the replacement transaction to appear; verify its row shows the Fast confirmation estimate from the Boost sheet + Open that row and verify it has a different transaction ID and a higher total fee (id "ActivityFee") than the original + Background and reopen Bitkit, unlock if needed, wait for sync, and verify the replacement's Activity row still shows the Fast estimate + Mine one regtest block, wait for sync, reopen the replacement transaction, and verify it is confirmed with the same higher total fee + +