Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 34 additions & 19 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct AccountAddresses {

class ActivityService {
private let coreService: CoreService
private let bumpFeeByRbf: (String, UInt32) async throws -> String

private let activitiesChangedSubject = PassthroughSubject<Void, Never>()

Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
203 changes: 203 additions & 0 deletions BitkitTests/RbfActivityFeeRateTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions changelog.d/next/879.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Show the correct confirmation estimate for outgoing transactions replaced by a fee boost.
11 changes: 11 additions & 0 deletions journeys/onchain-send/README.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions journeys/onchain-send/rbf-replacement-fee-rate.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<journey name="rbf replacement keeps the boosted fee rate">
<description>
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.
</description>
<actions>
<action>On Home, tap Send (id "Send"), dismiss any camera permission dialog with Don't Allow, and tap Enter Manually (id "RecipientManual")</action>
<action>Enter the regtest recipient in id "RecipientInput" and tap Continue (id "AddressContinue")</action>
<action>Choose Savings as the source, enter 10 000 sats with the number pad, and tap Continue (id "ContinueAmount")</action>
<action>On the review screen (id "SendConfirm"), open Fee &amp; Speed, choose Custom, use the number pad to enter 1 sat/vB, and return to review with Continue</action>
<action>Swipe the send control (id "GRAB"), approve any fee warning and PIN prompt, wait for Sent (id "SendSuccess"), and tap Close (id "Close")</action>
<action>Open the unconfirmed sent transaction from Activity, note its transaction ID and total fee (id "ActivityFee"), and tap Boost (id "BoostButton")</action>
<action>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</action>
<action>Swipe the Boost control (id "GRAB") and wait for success (id "BoostSuccessToast") without mining a block</action>
<action>Return to Activity and wait for the replacement transaction to appear; verify its row shows the Fast confirmation estimate from the Boost sheet</action>
<action>Open that row and verify it has a different transaction ID and a higher total fee (id "ActivityFee") than the original</action>
<action>Background and reopen Bitkit, unlock if needed, wait for sync, and verify the replacement's Activity row still shows the Fast estimate</action>
<action>Mine one regtest block, wait for sync, reopen the replacement transaction, and verify it is confirmed with the same higher total fee</action>
</actions>
</journey>
Loading