From e7e3c1a618e5ab1926302b2cee667778ef98e071 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 09:51:00 +0100 Subject: [PATCH 1/8] fix: publish rbf replacements before boost success --- .../to/bitkit/repositories/ActivityRepo.kt | 43 +++- .../java/to/bitkit/services/CoreService.kt | 146 +++++++------- app/src/main/java/to/bitkit/ui/ContentView.kt | 21 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 6 +- .../ui/sheets/BoostTransactionViewModel.kt | 117 +++++------ .../viewmodels/ActivityListViewModel.kt | 2 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../bitkit/repositories/ActivityRepoTest.kt | 61 ++++++ .../bitkit/services/ActivityServiceRbfTest.kt | 184 ++++++++++++++++++ .../sheets/BoostTransactionViewModelTest.kt | 138 +++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 36 ++++ journeys/boost/rbf-own-send.xml | 5 +- 12 files changed, 609 insertions(+), 151 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index fd1efa1e80..1ae93db730 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -48,6 +48,7 @@ import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.scopedActivityId import to.bitkit.ext.walletId import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.PubkyPublicKeyFormat @@ -80,6 +81,8 @@ class ActivityRepo @Inject constructor( private val syncMutex = Mutex() private var activeSync: CompletableDeferred>? = null + private val publishedRbfReplacements = MutableStateFlow(RbfPublications()) + private val _state = MutableStateFlow(ActivityState()) val state: StateFlow = _state @@ -105,6 +108,7 @@ class ActivityRepo @Inject constructor( } suspend fun resetState() = withContext(bgDispatcher) { + publishedRbfReplacements.update { RbfPublications(generation = it.generation + 1) } _state.update { ActivityState() } notifyActivitiesChanged() Logger.debug("Activity state reset", context = TAG) @@ -320,9 +324,41 @@ class ActivityRepo @Inject constructor( } } + suspend fun publishRbfBoost(originalTxId: String, replacementTxId: String): Result { + val generation = publishedRbfReplacements.value.generation + return withContext(bgDispatcher) { + runSuspendCatching { + val replacement = coreService.activity.publishRbfBoost(originalTxId, replacementTxId) + publishedRbfReplacements.update { + check(it.generation == generation) { "Wallet activity reset during RBF publication" } + it.copy( + replacements = it.replacements + + (scopedActivityId(replacement.v1.walletId, originalTxId) to replacement) + ) + } + notifyActivitiesChanged(tagsChanged = true) + } + } + } + + fun isLocalRbfBoost(txid: String): Boolean = + scopedActivityId(WalletScope.default, txid) in publishedRbfReplacements.value.replacements + + fun resolvePublishedRbfActivity(activity: Activity): Activity { + val replacements = publishedRbfReplacements.value.replacements + val visited = mutableSetOf() + var current = activity + while (current is Activity.Onchain) { + val key = scopedActivityId(current.v1.walletId, current.v1.txId) + if (!visited.add(key)) return current + current = replacements[key] ?: return current + } + return current + } + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { coreService.activity.handleOnchainTransactionReplaced(txid, conflicts) - notifyActivitiesChanged() + notifyActivitiesChanged(tagsChanged = true) } suspend fun handleOnchainTransactionReorged(txid: String) { @@ -1221,3 +1257,8 @@ private fun OnchainActivity.toPreActivityMetadata(tags: List): PreActivi } private data class SyncedDuplicate(val synced: OnchainActivity, val restoredId: String) + +private data class RbfPublications( + val generation: Long = 0, + val replacements: Map = emptyMap(), +) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9de71dab56..a317e96775 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1664,20 +1664,49 @@ class ActivityService( } } - suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { + suspend fun publishRbfBoost(originalTxId: String, replacementTxId: String): Activity.Onchain = ServiceQueue.CORE.background { - runCatching { - val replacedActivity = getOnchainActivityByTxId(txid) - markReplacedActivity(txid, replacedActivity, conflicts) + require(originalTxId.isNotBlank() && replacementTxId.isNotBlank() && originalTxId != replacementTxId) { + "RBF must replace the original transaction" + } + checkNotNull(getActivityByTxId(defaultWalletId, originalTxId)) { "RBF original is not available" } + checkNotNull(getOrCreateReplacementActivity(replacementTxId)) { "RBF replacement is not available" } + val cache = cacheStore.data.first() + val original = checkNotNull(getActivityByTxId(defaultWalletId, originalTxId)) + val replacement = checkNotNull(getActivityByTxId(defaultWalletId, replacementTxId)) + check(original.txType == PaymentType.SENT && replacement.txType == PaymentType.SENT) + check(!cache.isActivityDeleted(original.id, defaultWalletId)) + check(!cache.isActivityDeleted(replacement.id, defaultWalletId)) + val updated = updateReplacementActivity( + originalTxId, + replacementTxId, + replacement, + original, + cache.isContactDetached(replacement.id, defaultWalletId), + ) + markReplacedActivity(originalTxId, original, listOf(replacementTxId)) + updated + } + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { + ServiceQueue.CORE.background { + runSuspendCatching { + for (conflictTxid in conflicts) getOrCreateReplacementActivity(conflictTxid) + val cache = cacheStore.data.first() + val replacedActivity = getActivityByTxId(defaultWalletId, txid) for (conflictTxid in conflicts) { - val replacementActivity = getOrCreateReplacementActivity(conflictTxid) - if (replacementActivity != null && !replacementActivity.boostTxIds.contains(txid)) { - updateReplacementActivity(txid, conflictTxid, replacementActivity, replacedActivity) - } + val replacement = getActivityByTxId(defaultWalletId, conflictTxid) ?: continue + updateReplacementActivity( + txid, + conflictTxid, + replacement, + replacedActivity, + cache.isContactDetached(replacement.id, defaultWalletId), + ) } - }.onFailure { e -> - Logger.error("Error handling onchain transaction replaced for $txid", e, context = TAG) + markReplacedActivity(txid, replacedActivity, conflicts) + }.onFailure { + Logger.error("Failed to handle replacement for '$txid'", it, context = TAG) } } } @@ -1697,7 +1726,7 @@ class ActivityService( val updatedActivity = replacedActivity.copy( doesExist = false, isBoosted = false, - updatedAt = System.currentTimeMillis().toULong() / 1000u + updatedAt = nowTimestamp().epochSecond.toULong() ) paykitActivityRevision++ updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) @@ -1712,82 +1741,43 @@ class ActivityService( } private suspend fun getOrCreateReplacementActivity(conflictTxid: String): OnchainActivity? { - var replacementActivity = getOnchainActivityByTxId(conflictTxid) - - if (replacementActivity == null) { - val payments = lightningService.listPayments() - val replacementPayment = payments?.firstOrNull { payment -> - (payment.kind as? PaymentKind.Onchain)?.txid == conflictTxid - } - - if (replacementPayment != null) { - Logger.info( - "Processing replacement transaction $conflictTxid that was already in payments list", - context = TAG - ) - val processResult = runCatching { - processOnchainPayment( - kind = replacementPayment.kind as PaymentKind.Onchain, - payment = replacementPayment, - forceUpdate = false, - channelId = null, - ) - getOnchainActivityByTxId(conflictTxid) - } - processResult.onFailure { e -> - Logger.error( - "Failed to process replacement transaction $conflictTxid", - e, - context = TAG - ) - } - replacementActivity = processResult.getOrNull() - } - } - - return replacementActivity + getActivityByTxId(defaultWalletId, conflictTxid)?.let { return it } + val payment = lightningService.listPayments()?.firstOrNull { + (it.kind as? PaymentKind.Onchain)?.txid == conflictTxid + } ?: return null + processOnchainPayment( + kind = payment.kind as PaymentKind.Onchain, + payment = payment, + forceUpdate = false, + channelId = null, + ) + return getActivityByTxId(defaultWalletId, conflictTxid) } - private suspend fun updateReplacementActivity( + private fun updateReplacementActivity( txid: String, conflictTxid: String, replacementActivity: OnchainActivity, replacedActivity: OnchainActivity?, - ) { - val updatedActivity = replacementActivity.copy( - boostTxIds = replacementActivity.boostTxIds + txid, - isBoosted = true, - contact = replacementActivity.contact ?: replacedActivity?.contact, - updatedAt = System.currentTimeMillis().toULong() / 1000u, + contactDetached: Boolean, + ): Activity.Onchain { + if (txid in replacementActivity.boostTxIds) return Activity.Onchain(replacementActivity) + val updated = Activity.Onchain( + replacementActivity.copy( + boostTxIds = (replacementActivity.boostTxIds + txid).distinct(), + isBoosted = true, + contact = replacementActivity.contact ?: replacedActivity?.contact.takeUnless { contactDetached }, + updatedAt = nowTimestamp().epochSecond.toULong(), + ) ) - update(replacementActivity.id, Activity.Onchain(updatedActivity)) - if (replacedActivity != null) { - copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) - } - - Logger.info("Updated replacement transaction $conflictTxid with boostTxId $txid", context = TAG) - } - - private suspend fun copyTagsFromReplacedActivity( - txid: String, - conflictTxid: String, - replacedActivityId: String, - replacementActivityId: String, - ) { - runCatching { - val replacedTags = tags(replacedActivityId) - if (replacedTags.isNotEmpty()) { - appendTags(replacementActivityId, replacedTags) - } - }.onFailure { e -> - Logger.error( - "Failed to copy tags from replaced transaction $txid " + - "to replacement transaction $conflictTxid", - e, - context = TAG - ) + val tags = getTags(defaultWalletId, replacedActivity.id) + if (tags.isNotEmpty()) addTags(defaultWalletId, replacementActivity.id, tags) } + paykitActivityRevision++ + updateActivity(replacementActivity.id, updated) + Logger.info("Updated replacement '$conflictTxid' with boostTxId '$txid'", context = TAG) + return updated } suspend fun handleOnchainTransactionReorged(txid: String) { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d0ac41d51c..51a8ea12bc 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1208,7 +1208,9 @@ private fun NavGraphBuilder.home( SavingsWalletScreen( onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navToActivityDetail(it) }, + onActivityItemClick = { navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, @@ -1224,7 +1226,9 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navToActivityDetail(it) }, + onActivityItemClick = { navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1241,7 +1245,9 @@ private fun NavGraphBuilder.home( val walletId = it.toRoute().walletId HardwareWalletScreen( walletId = walletId, - onActivityItemClick = { navController.navToActivityDetail(it) }, + onActivityItemClick = { navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) }, onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, ) @@ -1256,7 +1262,9 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { navController.navToActivityDetail(it) }, + onActivityItemClick = { navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) }, ) } } @@ -1432,12 +1440,15 @@ private fun NavGraphBuilder.contacts( } } deepLinkableComposable { + val listViewModel = activityListViewModel PaykitRouteGuard(settingsViewModel, navController) { val viewModel: ContactActivityViewModel = hiltViewModel() ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navToActivityDetail(it) }, + onActivityItemClick = { navController.navToActivityDetail( + listViewModel?.resolvePublishedRbfActivity(it) ?: it + ) }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index d398093032..b210b282a1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -391,7 +391,11 @@ fun HomeScreen( onDismissWidgetsOnboardingHint = homeViewModel::dismissWidgetsOnboardingHint, onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onNavigateToActivityItem = { rootNavController.navToActivityDetail(it) }, + onNavigateToActivityItem = { + rootNavController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) + }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index 37caf5c052..b84550f739 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -8,6 +8,8 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -18,6 +20,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.ext.BoostType import to.bitkit.ext.boostType import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.FeeRate import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo @@ -56,14 +59,16 @@ class BoostTransactionViewModel @Inject constructor( private var feeRateRecommended: ULong = 0U private var minFeeRate: ULong = 2U private var activity: Activity.Onchain? = null + private val broadcastRbfReplacements = mutableMapOf>>() fun setupActivity(activity: Activity.Onchain) { Logger.debug("Setup activity $activity", context = TAG) - this.activity = activity + this.activity = Activity.Onchain(activity.v1) _uiState.update { it.copy( loading = true, + boosting = false, isRbf = activity.boostType() == BoostType.RBF, ) } @@ -170,6 +175,7 @@ class BoostTransactionViewModel @Inject constructor( } fun onConfirmBoost() { + if (_uiState.value.boosting) return val currentActivity = activity if (currentActivity == null) { handleError("Cannot boost: activity is null") @@ -179,28 +185,38 @@ class BoostTransactionViewModel @Inject constructor( _uiState.update { it.copy(boosting = true) } viewModelScope.launch { - runCatching { + runSuspendCatching { when (currentActivity.v1.txType) { PaymentType.SENT -> handleRbfBoost(currentActivity) PaymentType.RECEIVED -> handleCpfpBoost(currentActivity) } }.onFailure { - handleError("Unexpected error during boost", it) + handleError("Unexpected error during boost", it, currentActivity) } } } private suspend fun handleRbfBoost(activity: Activity.Onchain) { - lightningRepo.bumpFeeByRbf( - satsPerVByte = _uiState.value.feeRate, - originalTxId = activity.v1.txId - ).fold( - onSuccess = { newTxId -> - handleBoostSuccess(newTxId, isRBF = true) - }, - onFailure = { error -> - handleError("RBF boost failed: ${error.message}", error) + val txid = activity.v1.txId + val broadcast = broadcastRbfReplacements.getOrPut(txid) { + val feeRate = _uiState.value.feeRate + viewModelScope.async { + lightningRepo.bumpFeeByRbf(satsPerVByte = feeRate, originalTxId = txid) + } + } + val result = try { + broadcast.await() + } finally { + if (broadcast.isCancelled && broadcastRbfReplacements[txid] === broadcast) { + broadcastRbfReplacements.remove(txid) } + } + result.fold( + onSuccess = { handleBoostSuccess(activity, it, isRBF = true) }, + onFailure = { + if (broadcastRbfReplacements[txid] === broadcast) broadcastRbfReplacements.remove(txid) + handleError("RBF boost failed", it, activity) + }, ) } @@ -211,23 +227,30 @@ class BoostTransactionViewModel @Inject constructor( destinationAddress = walletRepo.getOnchainAddress(), ).fold( onSuccess = { newTxId -> - handleBoostSuccess(newTxId, isRBF = false) + handleBoostSuccess(activity, newTxId, isRBF = false) }, onFailure = { error -> - handleError("CPFP boost failed: ${error.message}", error) + handleError("CPFP boost failed", error, activity) } ) } - private suspend fun handleBoostSuccess(newTxId: Txid, isRBF: Boolean) { + private suspend fun handleBoostSuccess(origin: Activity.Onchain, newTxId: Txid, isRBF: Boolean) { Logger.debug("Boost successful. newTxId: $newTxId", context = TAG) - updateActivity(newTxId = newTxId, isRBF = isRBF) - .onFailure { error -> - Logger.warn("Boost successful but activity update failed", e = error, context = TAG) - } + val result = updateActivity(origin, newTxId = newTxId, isRBF = isRBF) + if (isRBF && result.isFailure) { + lightningRepo.syncAsync() + handleError("Failed to publish RBF replacement", result.exceptionOrNull(), origin) + return + } + result.onFailure { error -> + Logger.warn("Boost successful but activity update failed", error, context = TAG) + } - _uiState.update { it.copy(boosting = false) } - setBoostTransactionEffect(BoostTransactionEffects.OnBoostSuccess) + if (activity === origin) { + _uiState.update { it.copy(boosting = false) } + setBoostTransactionEffect(BoostTransactionEffects.OnBoostSuccess, origin) + } // Fire-and-forget sync to keep UI responsive even if LDK sync hangs. lightningRepo.syncAsync() @@ -286,23 +309,12 @@ class BoostTransactionViewModel @Inject constructor( } } - /** - * Updates activity based on boost type. - * RBF: Updates current activity with boost data. Event handler will handle replacement. - * CPFP: Updates current activity and appends child txId to parent's boostTxIds. - */ - private suspend fun updateActivity(newTxId: Txid, isRBF: Boolean): Result { - Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG) - - val currentActivity = activity?.v1 - ?: return Result.failure(Exception("Activity required")) - - return if (isRBF) { - handleRBFUpdate(currentActivity) + private suspend fun updateActivity(origin: Activity.Onchain, newTxId: Txid, isRBF: Boolean): Result = + if (isRBF) { + activityRepo.publishRbfBoost(origin.v1.txId, newTxId) } else { - handleCPFPUpdate(currentActivity, newTxId) + handleCPFPUpdate(origin.v1, newTxId) } - } /** * Handles CPFP (Child Pays For Parent) update by updating the current activity @@ -324,44 +336,21 @@ class BoostTransactionViewModel @Inject constructor( ) } - /** - * Handles RBF (Replace By Fee) update by updating current activity to show boost status. - * The event handler (handleOnchainTransactionReplaced) will handle the replacement - * when the OnchainTransactionReplaced event fires. - */ - private suspend fun handleRBFUpdate( - currentActivity: OnchainActivity, - ): Result { - val updatedCurrentActivity = Activity.Onchain( - v1 = currentActivity.copy( - isBoosted = true, - feeRate = _uiState.value.feeRate, - fee = _uiState.value.totalFeeSats, - updatedAt = nowTimestamp().toEpochMilli().toULong() - ) - ) - - activityRepo.updateActivity( - id = updatedCurrentActivity.v1.id, - activity = updatedCurrentActivity - ) - - return Result.success(Unit) - } - - private fun handleError(message: String, error: Throwable? = null) { + private fun handleError(message: String, error: Throwable? = null, origin: Activity.Onchain? = null) { Logger.error(message, error, context = TAG) + if (origin != null && activity !== origin) return _uiState.update { it.copy( boosting = false, loading = false, ) } - setBoostTransactionEffect(BoostTransactionEffects.OnBoostFailed) + setBoostTransactionEffect(BoostTransactionEffects.OnBoostFailed, origin) } - private fun setBoostTransactionEffect(effect: BoostTransactionEffects) { + private fun setBoostTransactionEffect(effect: BoostTransactionEffects, origin: Activity.Onchain? = null) { viewModelScope.launch { + if (origin != null && activity !== origin) return@launch _boostTransactionEffect.emit(effect) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index f7836b6fe2..a75003aa5a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -130,6 +130,8 @@ class ActivityListViewModel @Inject constructor( resync() } + fun resolvePublishedRbfActivity(activity: Activity): Activity = activityRepo.resolvePublishedRbfActivity(activity) + fun resync() = viewModelScope.launch { activityRepo.syncActivities() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b183375a7f..c592a06aa7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3012,6 +3012,7 @@ class AppViewModel @Inject constructor( private suspend fun notifyTransactionReplaced(event: Event.OnchainTransactionReplaced) { val isReceive = activityRepo.isReceivedTransaction(event.txid) + if (activityRepo.isLocalRbfBoost(event.txid)) return toast( type = Toast.ToastType.INFO, title = when (isReceive) { diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index e5b399e138..6e70d8fc6c 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -164,6 +164,67 @@ class ActivityRepoTest : BaseUnitTest() { ) } + @Test + fun `RBF publishes only a complete replacement and resolves stale rows across repeated boosts`() = test { + val original = createOnchainActivity(id = "a", txId = "tx-a", walletId = WalletScope.default) + val replacement = createOnchainActivity(id = "b", txId = "tx-b", walletId = WalletScope.default) + val latest = createOnchainActivity(id = "c", txId = "tx-c", walletId = WalletScope.default) + val published = CompletableDeferred() + whenever(coreService.activity.publishRbfBoost("tx-a", "tx-b")).doSuspendableAnswer { + published.await() + replacement + } + val tagsBefore = sut.activityTagsChanged.value + val pending = async { sut.publishRbfBoost("tx-a", "tx-b") } + assertEquals(original, sut.resolvePublishedRbfActivity(original)) + assertFalse(sut.isLocalRbfBoost("tx-a")) + assertEquals(tagsBefore, sut.activityTagsChanged.value) + published.complete(Unit) + assertTrue(pending.await().isSuccess) + assertEquals(replacement, sut.resolvePublishedRbfActivity(original)) + assertTrue(sut.isLocalRbfBoost("tx-a")) + assertTrue(sut.activityTagsChanged.value > tagsBefore) + whenever(coreService.activity.publishRbfBoost("tx-b", "tx-c")).thenReturn(latest) + sut.publishRbfBoost("tx-b", "tx-c").getOrThrow() + sut.publishRbfBoost("tx-a", "tx-b").getOrThrow() + assertEquals(latest, sut.resolvePublishedRbfActivity(original)) + assertEquals(latest, sut.resolvePublishedRbfActivity(replacement)) + val foreign = original.copy(v1 = original.v1.copy(walletId = "hardware")) + assertEquals(foreign, sut.resolvePublishedRbfActivity(foreign)) + sut.resetState() + assertEquals(original, sut.resolvePublishedRbfActivity(original)) + assertFalse(sut.isLocalRbfBoost("tx-a")) + } + + @Test + fun `wallet reset rejects completion from the old RBF publication`() = test { + val published = CompletableDeferred() + val replacement = createOnchainActivity(txId = "tx-b", walletId = WalletScope.default) + whenever(coreService.activity.publishRbfBoost("tx-a", "tx-b")).doSuspendableAnswer { + published.await() + replacement + } + val pending = async { sut.publishRbfBoost("tx-a", "tx-b") } + sut.resetState() + published.complete(Unit) + assertTrue(pending.await().isFailure) + assertFalse(sut.isLocalRbfBoost("tx-a")) + } + + @Test + fun `failed or cancelled RBF publication never publishes a replacement route`() = test { + val original = createOnchainActivity(txId = "tx-a", walletId = WalletScope.default) + whenever(coreService.activity.publishRbfBoost("tx-a", "tx-b")) + .thenThrow(IllegalStateException("storage failed"), CancellationException("cancelled")) + val before = sut.activitiesChanged.value + assertTrue(sut.publishRbfBoost("tx-a", "tx-b").isFailure) + assertEquals(before, sut.activitiesChanged.value) + assertEquals(original, sut.resolvePublishedRbfActivity(original)) + assertFalse(sut.isLocalRbfBoost("tx-a")) + assertFailsWith { sut.publishRbfBoost("tx-a", "tx-b") } + assertEquals(before, sut.activitiesChanged.value) + } + @Test fun `observed hardware payment completes only after original Sent activity is durable`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt new file mode 100644 index 0000000000..227cc29dc5 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt @@ -0,0 +1,184 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.ext.create +import to.bitkit.ext.scopedActivityId +import to.bitkit.models.WalletScope +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActivityServiceRbfTest : BaseUnitTest() { + private val binding = Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt") + private val cache = mock() + private val lightning = mock() + private val sut by lazy { ActivityService(mock(), cache, lightning, mock(), mock()) } + + @Test + fun `publication rereads fresh metadata after suspension and preserves real transaction fees`() = test { + val entered = CompletableDeferred() + val resume = CompletableDeferred() + whenever(cache.data).thenReturn( + flow { + entered.complete(Unit) + resume.await() + emit(AppCacheData()) + } + ) + withActivities { saved, tags -> + val publishing = async { sut.publishRbfBoost("a", "b") } + entered.await() + assertFalse(saved.getValue("b").isBoosted) + assertTrue(saved.getValue("a").doesExist) + saved["a"] = saved.getValue("a").copy(contact = "fresh-original") + saved["b"] = saved.getValue("b").copy(contact = "fresh-recipient") + val result = publishing.also { resume.complete(Unit) }.await().v1 + assertEquals("payment-b", result.id) + assertEquals("b", result.txId) + assertEquals(422uL, result.fee) + assertEquals("fresh-recipient", result.contact) + assertEquals(141uL, saved.getValue("a").fee) + assertFalse(saved.getValue("a").doesExist) + assertTrue(result.isBoosted) + assertEquals(listOf("a"), result.boostTxIds) + assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-b")) + } + } + + @Test + fun `duplicate and delayed events preserve retired successors and repair partial tag writes`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + withActivities(failFirstTagWrite = true) { saved, tags -> + assertFailsWith { sut.publishRbfBoost("a", "b") } + assertFalse(saved.getValue("b").isBoosted) + sut.handleOnchainTransactionReplaced("a", listOf("b")) + assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-b")) + sut.publishRbfBoost("b", "c") + saved["b"] = saved.getValue("b").copy(contact = "later-contact") + sut.handleOnchainTransactionReplaced("a", listOf("b")) + assertFalse(saved.getValue("a").doesExist) + assertFalse(saved.getValue("b").doesExist) + assertEquals("later-contact", saved.getValue("b").contact) + assertEquals(listOf("a"), saved.getValue("b").boostTxIds) + assertEquals(listOf("b"), saved.getValue("c").boostTxIds) + assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-c")) + } + } + + @Test + fun `duplicate replacement event preserves edits made after completed publication`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + withActivities { saved, tags -> + sut.publishRbfBoost("a", "b") + tags["payment-b"] = setOf("replacement-tag") + saved["b"] = saved.getValue("b").copy(contact = null) + sut.handleOnchainTransactionReplaced("a", listOf("b")) + assertEquals(setOf("replacement-tag"), tags.getValue("payment-b")) + assertEquals(null, saved.getValue("b").contact) + } + } + + @Test + fun `manual contact detachment and existing replacement metadata survive publication`() = test { + whenever(cache.data).thenReturn( + flowOf( + AppCacheData( + detachedActivityContacts = setOf(scopedActivityId(WalletScope.default, "payment-b")), + ) + ) + ) + withActivities { saved, _ -> + saved["b"] = saved.getValue("b").copy(boostTxIds = listOf("earlier")) + val result = sut.publishRbfBoost("a", "b").v1 + assertEquals(null, result.contact) + assertEquals(listOf("earlier", "a"), result.boostTxIds) + } + } + + @Test + fun `missing replacement fails without fabricating original state`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + whenever(lightning.listPayments()).thenReturn(emptyList()) + withActivities { saved, _ -> + saved.remove("b") + val original = saved.getValue("a") + assertFailsWith { sut.publishRbfBoost("a", "b") } + assertEquals(original, saved.getValue("a")) + assertFalse("b" in saved) + sut.handleOnchainTransactionReplaced("a", listOf("b")) + assertFalse(saved.getValue("a").doesExist) + } + } + + @Test + fun `cancelled publication does not retire the original`() = test { + whenever(cache.data).thenReturn(flow { throw CancellationException("cancelled") }) + withActivities { saved, _ -> + assertFailsWith { sut.publishRbfBoost("a", "b") } + assertTrue(saved.getValue("a").doesExist) + assertFalse(saved.getValue("b").isBoosted) + } + } + + private suspend fun withActivities( + failFirstTagWrite: Boolean = false, + block: suspend CoroutineScope.(MutableMap, MutableMap>) -> Unit, + ) = ServiceQueue.CORE.background { + val wallet = WalletScope.default + val saved = listOf("a", "b", "c").associateWith { txid -> + OnchainActivity.create( + walletId = wallet, id = "payment-$txid", txId = txid, txType = PaymentType.SENT, + value = 10_000uL, fee = if (txid == "a") 141uL else 422uL, + address = "recipient", timestamp = 1uL, contact = if (txid == "a") "original" else null, + ) + }.toMutableMap() + val tags = mutableMapOf("payment-a" to setOf("original-tag"), "payment-b" to setOf("replacement-tag")) + var failTags = failFirstTagWrite + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val update = binding.getMethod("updateActivity", String::class.java, Activity::class.java) + val getTags = binding.getMethod("getTags", String::class.java, String::class.java) + val addTags = binding.getMethod("addTags", String::class.java, String::class.java, List::class.java) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, anyString(), anyString()) }.thenAnswer { + saved[it.getArgument(1)].takeIf { activity -> activity?.walletId == it.getArgument(0) } + } + native.`when` { update.invoke(null, anyString(), any()) }.thenAnswer { + val activity = it.getArgument(1).v1 + saved[activity.txId] = activity + Unit + } + native.`when` { getTags.invoke(null, anyString(), anyString()) }.thenAnswer { + tags[it.getArgument(1)].orEmpty().toList() + } + native.`when` { addTags.invoke(null, anyString(), anyString(), any>()) }.thenAnswer { + if (failTags) { + failTags = false + error("tag write failed") + } + val id = it.getArgument(1) + tags[id] = tags[id].orEmpty() + it.getArgument>(2) + Unit + } + block(saved, tags) + } + } +} diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 46862ec10b..b5084bfbcd 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -8,6 +8,8 @@ import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOnchain import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -16,9 +18,11 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R @@ -239,6 +243,140 @@ class BoostTransactionViewModelTest : BaseUnitTest() { verify(activityRepo, never()).deleteActivity(any(), any()) } + @Test + fun `RBF success waits for publication and never overwrites the original fee`() = runTest { + prepareRbf() + val published = CompletableDeferred() + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)).doSuspendableAnswer { + published.await() + Result.success(Unit) + } + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + sut.onConfirmBoost() + assertTrue(sut.uiState.value.boosting) + expectNoEvents() + published.complete(Unit) + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + assertFalse(sut.uiState.value.boosting) + } + verify(lightningRepo, times(1)).bumpFeeByRbf(mockTxId, feeRate.coerceAtLeast(12u)) + verify(activityRepo, never()).updateActivity(any(), any(), any()) + } + + @Test + fun `RBF publication failure does not report success or broadcast again`() = runTest { + prepareRbf() + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)) + .thenReturn(Result.failure(IllegalStateException("not published"))) + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) + expectNoEvents() + } + verify(lightningRepo, times(1)).bumpFeeByRbf(any(), any()) + verify(lightningRepo).syncAsync() + assertFalse(sut.uiState.value.boosting) + } + + @Test + fun `reopening the original while broadcast is pending shares the same broadcast`() = runTest { + prepareRbf() + val broadcast = CompletableDeferred() + whenever(lightningRepo.bumpFeeByRbf(any(), any())).doSuspendableAnswer { + broadcast.await() + Result.success(newTxId) + } + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)).thenReturn(Result.success(Unit)) + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + sut.setupActivity(activitySent) + sut.onConfirmBoost() + broadcast.complete(Unit) + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + expectNoEvents() + } + verify(lightningRepo, times(1)).bumpFeeByRbf(any(), any()) + } + + @Test + fun `cancelled broadcast can be explicitly retried after reopening`() = runTest { + prepareRbf() + var attempts = 0 + whenever(lightningRepo.bumpFeeByRbf(any(), any())).doSuspendableAnswer { + if (attempts++ == 0) throw CancellationException("node stopped before broadcast") + Result.success(newTxId) + } + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)).thenReturn(Result.success(Unit)) + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + expectNoEvents() + assertEquals(1, attempts) + sut.setupActivity(activitySent) + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + assertEquals(2, attempts) + } + + @Test + fun `retry after publication failure reuses the broadcast replacement`() = runTest { + prepareRbf() + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)) + .thenReturn(Result.failure(IllegalStateException("storage failed")), Result.success(Unit)) + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) + expectNoEvents() + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + verify(lightningRepo, times(1)).bumpFeeByRbf(any(), any()) + verify(activityRepo, times(2)).publishRbfBoost(mockTxId, newTxId) + } + + @Test + fun `RBF publication cancellation is not converted into boost failure or success`() = runTest { + prepareRbf() + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)).doSuspendableAnswer { + throw CancellationException("dismissed") + } + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + expectNoEvents() + } + sut.setupActivity(activitySent.copy(v1 = onchainActivity.copy(txId = "next"))) + assertFalse(sut.uiState.value.boosting) + verify(lightningRepo, times(1)).bumpFeeByRbf(any(), any()) + } + + @Test + fun `pending RBF keeps original ownership when another activity opens`() = runTest { + prepareRbf() + val published = CompletableDeferred() + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)).doSuspendableAnswer { + published.await() + Result.success(Unit) + } + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + sut.setupActivity(Activity.Onchain(onchainActivity.copy(id = "other", txId = "other-tx"))) + assertFalse(sut.uiState.value.boosting) + published.complete(Unit) + expectNoEvents() + } + verify(activityRepo).publishRbfBoost(mockTxId, newTxId) + verify(lightningRepo, times(1)).bumpFeeByRbf(any(), any()) + } + + private suspend fun prepareRbf() { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + whenever(lightningRepo.bumpFeeByRbf(any(), any())).thenReturn(Result.success(newTxId)) + sut.setupActivity(activitySent) + } + // region estimateTime dynamic tier tests @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f19870423c..fc5bb29dda 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8947,6 +8947,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(sheet, sut.currentSheet.value) } + @Test + fun `local RBF publication during receipt lookup cannot interrupt the boost success toast`() = test { + val entered = CompletableDeferred() + val resume = CompletableDeferred() + var published = false + whenever(activityRepo.isReceivedTransaction("original")).doSuspendableAnswer { + entered.complete(Unit) + resume.await() + false + } + whenever(activityRepo.isLocalRbfBoost("original")).thenAnswer { published } + advanceUntilIdle() + emitNodeEvent(Event.OnchainTransactionReplaced("original", listOf("replacement"))) + runCurrent() + assertTrue(entered.isCompleted) + published = true + resume.complete(Unit) + advanceUntilIdle() + verify(toastManager, never()).enqueue(argThat { testTag == "TransactionReplacedToast" }) + verify(activityRepo).handleOnchainTransactionReplaced("original", listOf("replacement")) + } + + @Test + fun `external replacement still shows incoming and outgoing notifications`() = test { + advanceUntilIdle() + for (received in listOf(false, true)) { + val txid = "external-$received" + whenever(activityRepo.isReceivedTransaction(txid)).thenReturn(received) + whenever(activityRepo.isLocalRbfBoost(txid)).thenReturn(false) + emitNodeEvent(Event.OnchainTransactionReplaced(txid, listOf("replacement-$received"))) + advanceUntilIdle() + val expectedTag = if (received) "ReceivedTransactionReplacedToast" else "TransactionReplacedToast" + verify(toastManager).enqueue(argThat { testTag == expectedTag }) + } + } + @Test fun `confirmed-only onchain receive shows the sheet after updating the activity`() = test { val sheetDetails = NewTransactionSheetDetails( diff --git a/journeys/boost/rbf-own-send.xml b/journeys/boost/rbf-own-send.xml index 8b533ce09c..349ec949b9 100644 --- a/journeys/boost/rbf-own-send.xml +++ b/journeys/boost/rbf-own-send.xml @@ -14,9 +14,10 @@ Tap Transaction details (testTag "ActivityTxDetails"), record the TXID, then go back Tap Boost (testTag "BoostButton") and verify the replacement boost sheet (testTag "RBFBoost") is visible Swipe the boost handle (testTag "GRAB") fully to the right and verify the boost success toast (testTag "BoostSuccessToast") appears - Verify the activity list shows the boosting icon (testTag "BoostingIcon") and still exactly one outgoing entry for this send - Open that entry and verify Boosted (testTag "BoostedButton") and the Boosting status (testTag "StatusBoosting"), 10,000 sats, and a fee higher than the recorded one + As soon as the success toast appears, immediately open the newest outgoing entry (testTag "ActivityShort-0"), even if the home list has not redrawn; do not add a settling delay + Verify Boosted (testTag "BoostedButton") and the Boosting status (testTag "StatusBoosting"), 10,000 sats, and a fee higher than the recorded one Tap Transaction details (testTag "ActivityTxDetails") and verify the TXID differs from the recorded one and the replaced transaction is listed (testTag "RBFBoosted") + Return to the activity list and verify the boosting icon (testTag "BoostingIcon") and still exactly one outgoing entry for this send Mine one regtest block with ./lsp POST /regtest/chain/mine '{"count":1}' from the Android checkout; a successful command exits with status 0 and prints nothing Return to the activity list, open the send again and verify its status is Confirmed (testTag "StatusConfirmed") From 30ffbee500c68f2d631d8f68e9669f291db3883b Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 09:55:14 +0100 Subject: [PATCH 2/8] fix: retire replaced activity when metadata fails --- .../main/java/to/bitkit/services/CoreService.kt | 2 +- .../to/bitkit/services/ActivityServiceRbfTest.kt | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a317e96775..74a7e6c893 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1691,6 +1691,7 @@ class ActivityService( suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { ServiceQueue.CORE.background { runSuspendCatching { + markReplacedActivity(txid, getActivityByTxId(defaultWalletId, txid), conflicts) for (conflictTxid in conflicts) getOrCreateReplacementActivity(conflictTxid) val cache = cacheStore.data.first() val replacedActivity = getActivityByTxId(defaultWalletId, txid) @@ -1704,7 +1705,6 @@ class ActivityService( cache.isContactDetached(replacement.id, defaultWalletId), ) } - markReplacedActivity(txid, replacedActivity, conflicts) }.onFailure { Logger.error("Failed to handle replacement for '$txid'", it, context = TAG) } diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt index 227cc29dc5..6df3949755 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt @@ -97,6 +97,22 @@ class ActivityServiceRbfTest : BaseUnitTest() { } } + @Test + fun `external replacement retires original even if metadata fails and repairs on retry`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + withActivities(failFirstTagWrite = true) { saved, tags -> + saved["a"] = saved.getValue("a").copy(txType = PaymentType.RECEIVED) + saved["b"] = saved.getValue("b").copy(txType = PaymentType.RECEIVED) + sut.handleOnchainTransactionReplaced("a", listOf("b")) + assertFalse(saved.getValue("a").doesExist) + assertFalse(saved.getValue("b").isBoosted) + sut.handleOnchainTransactionReplaced("a", listOf("b")) + assertTrue(saved.getValue("b").isBoosted) + assertEquals(PaymentType.RECEIVED, saved.getValue("b").txType) + assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-b")) + } + } + @Test fun `manual contact detachment and existing replacement metadata survive publication`() = test { whenever(cache.data).thenReturn( From 45d7494c7f2949f4fd64bd22156b0e04001e76ba Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 10:00:37 +0100 Subject: [PATCH 3/8] style: wrap activity navigation callbacks --- app/src/main/java/to/bitkit/ui/ContentView.kt | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 51a8ea12bc..e0481fb263 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1208,9 +1208,11 @@ private fun NavGraphBuilder.home( SavingsWalletScreen( onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navToActivityDetail( - activityListViewModel.resolvePublishedRbfActivity(it) - ) }, + onActivityItemClick = { + navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) + }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, @@ -1226,9 +1228,11 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navToActivityDetail( - activityListViewModel.resolvePublishedRbfActivity(it) - ) }, + onActivityItemClick = { + navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) + }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1245,9 +1249,11 @@ private fun NavGraphBuilder.home( val walletId = it.toRoute().walletId HardwareWalletScreen( walletId = walletId, - onActivityItemClick = { navController.navToActivityDetail( - activityListViewModel.resolvePublishedRbfActivity(it) - ) }, + onActivityItemClick = { + navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) + }, onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, ) @@ -1262,9 +1268,11 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { navController.navToActivityDetail( - activityListViewModel.resolvePublishedRbfActivity(it) - ) }, + onActivityItemClick = { + navController.navToActivityDetail( + activityListViewModel.resolvePublishedRbfActivity(it) + ) + }, ) } } @@ -1446,9 +1454,11 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navToActivityDetail( - listViewModel?.resolvePublishedRbfActivity(it) ?: it - ) }, + onActivityItemClick = { + navController.navToActivityDetail( + listViewModel?.resolvePublishedRbfActivity(it) ?: it + ) + }, ) } } From 18b041cfae190dbf680b8125d5042f9063b7882a Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 10:08:48 +0100 Subject: [PATCH 4/8] docs: add boost publication changelog --- changelog.d/next/1813.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1813.fixed.md diff --git a/changelog.d/next/1813.fixed.md b/changelog.d/next/1813.fixed.md new file mode 100644 index 0000000000..f50dffe955 --- /dev/null +++ b/changelog.d/next/1813.fixed.md @@ -0,0 +1 @@ +Boosted Bitcoin sends now open the replacement transaction with the correct fee and payment history. From d28d15c54200679d2abd3c38a8a8d04068a14d77 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 10:34:18 +0100 Subject: [PATCH 5/8] fix: await wallet sync before publishing rbf replacement --- .../java/to/bitkit/services/CoreService.kt | 6 +- .../bitkit/services/ActivityServiceRbfTest.kt | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 74a7e6c893..bc1285f270 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1670,7 +1670,11 @@ class ActivityService( "RBF must replace the original transaction" } checkNotNull(getActivityByTxId(defaultWalletId, originalTxId)) { "RBF original is not available" } - checkNotNull(getOrCreateReplacementActivity(replacementTxId)) { "RBF replacement is not available" } + if (getOrCreateReplacementActivity(replacementTxId) == null) { + // RBF returns after broadcast, before wallet sync discovers the new payment. + lightningService.sync() + checkNotNull(getOrCreateReplacementActivity(replacementTxId)) { "RBF replacement is not available" } + } val cache = cacheStore.data.first() val original = checkNotNull(getActivityByTxId(defaultWalletId, originalTxId)) val replacement = checkNotNull(getActivityByTxId(defaultWalletId, replacementTxId)) diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt index 6df3949755..a8630552d9 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt @@ -13,7 +13,10 @@ import org.junit.Test import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue import to.bitkit.data.AppCacheData @@ -61,6 +64,69 @@ class ActivityServiceRbfTest : BaseUnitTest() { assertTrue(result.isBoosted) assertEquals(listOf("a"), result.boostTxIds) assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-b")) + verify(lightning, never()).sync() + } + } + + @Test + fun `publication waits for wallet sync to discover the broadcast replacement`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + whenever(lightning.listPayments()).thenReturn(emptyList()) + val entered = CompletableDeferred() + val resume = CompletableDeferred() + withActivities { saved, tags -> + val replacement = saved.remove("b")!! + whenever(lightning.sync()).doSuspendableAnswer { + entered.complete(Unit) + resume.await() + saved["b"] = replacement + } + val publishing = async { sut.publishRbfBoost("a", "b") } + entered.await() + assertFalse(publishing.isCompleted) + assertTrue(saved.getValue("a").doesExist) + assertFalse("b" in saved) + saved["a"] = saved.getValue("a").copy(contact = "edited-during-sync") + val result = publishing.also { resume.complete(Unit) }.await().v1 + assertEquals("b", result.txId) + assertEquals(422uL, result.fee) + assertEquals("edited-during-sync", result.contact) + assertEquals(listOf("a"), result.boostTxIds) + assertTrue(result.isBoosted) + assertFalse(saved.getValue("a").doesExist) + assertEquals(141uL, saved.getValue("a").fee) + assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-b")) + verify(lightning).sync() + } + } + + @Test + fun `wallet sync failure preserves original and can retry publication without a broadcast`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + whenever(lightning.listPayments()).thenReturn(emptyList()) + withActivities { saved, _ -> + val replacement = saved.remove("b")!! + val original = saved.getValue("a") + whenever(lightning.sync()).thenThrow(IllegalStateException("sync unavailable")) + assertFailsWith { sut.publishRbfBoost("a", "b") } + assertEquals(original, saved.getValue("a")) + saved["b"] = replacement + assertEquals("b", sut.publishRbfBoost("a", "b").v1.txId) + verify(lightning).sync() + } + } + + @Test + fun `cancellation while discovering the replacement does not publish it`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + whenever(lightning.listPayments()).thenReturn(emptyList()) + whenever(lightning.sync()).thenThrow(CancellationException("cancelled sync")) + withActivities { saved, _ -> + saved.remove("b") + val original = saved.getValue("a") + assertFailsWith { sut.publishRbfBoost("a", "b") } + assertEquals(original, saved.getValue("a")) + assertFalse("b" in saved) } } From 6fab411889988d49ce597a12ad2bb55daad6fbd3 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 12:07:06 +0100 Subject: [PATCH 6/8] fix: isolate replacement metadata failures --- .../java/to/bitkit/services/CoreService.kt | 27 ++++---- .../bitkit/services/ActivityServiceRbfTest.kt | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index bc1285f270..64c94ada97 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1696,18 +1696,23 @@ class ActivityService( ServiceQueue.CORE.background { runSuspendCatching { markReplacedActivity(txid, getActivityByTxId(defaultWalletId, txid), conflicts) - for (conflictTxid in conflicts) getOrCreateReplacementActivity(conflictTxid) - val cache = cacheStore.data.first() - val replacedActivity = getActivityByTxId(defaultWalletId, txid) for (conflictTxid in conflicts) { - val replacement = getActivityByTxId(defaultWalletId, conflictTxid) ?: continue - updateReplacementActivity( - txid, - conflictTxid, - replacement, - replacedActivity, - cache.isContactDetached(replacement.id, defaultWalletId), - ) + runSuspendCatching { + getOrCreateReplacementActivity(conflictTxid) ?: return@runSuspendCatching + val cache = cacheStore.data.first() + val replacedActivity = getActivityByTxId(defaultWalletId, txid) + val replacement = getActivityByTxId(defaultWalletId, conflictTxid) + ?: return@runSuspendCatching + updateReplacementActivity( + txid, + conflictTxid, + replacement, + replacedActivity, + cache.isContactDetached(replacement.id, defaultWalletId), + ) + }.onFailure { + Logger.error("Failed to handle replacement '$conflictTxid' for '$txid'", it, context = TAG) + } } }.onFailure { Logger.error("Failed to handle replacement for '$txid'", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt index a8630552d9..c1725d0655 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceRbfTest.kt @@ -179,6 +179,62 @@ class ActivityServiceRbfTest : BaseUnitTest() { } } + @Test + fun `external replacement metadata failures do not skip later conflicts and remain repairable`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + for (failRead in listOf(false, true)) { + withActivities(failFirstTagWrite = !failRead, failFirstTagRead = failRead) { saved, tags -> + sut.handleOnchainTransactionReplaced("a", listOf("b", "c")) + assertFalse(saved.getValue("a").doesExist) + assertFalse(saved.getValue("b").isBoosted) + assertEquals(emptyList(), saved.getValue("b").boostTxIds) + assertTrue(saved.getValue("c").isBoosted) + assertEquals(listOf("a"), saved.getValue("c").boostTxIds) + assertEquals("original", saved.getValue("c").contact) + assertEquals(setOf("original-tag"), tags.getValue("payment-c")) + saved["c"] = saved.getValue("c").copy(contact = "edited-contact") + tags["payment-c"] = setOf("edited-tag") + sut.handleOnchainTransactionReplaced("a", listOf("b", "c")) + assertTrue(saved.getValue("b").isBoosted) + assertEquals(listOf("a"), saved.getValue("b").boostTxIds) + assertEquals(setOf("original-tag", "replacement-tag"), tags.getValue("payment-b")) + assertEquals("edited-contact", saved.getValue("c").contact) + assertEquals(setOf("edited-tag"), tags.getValue("payment-c")) + assertEquals(141uL, saved.getValue("a").fee) + assertEquals(422uL, saved.getValue("c").fee) + } + } + } + + @Test + fun `external replacement discovery failure does not skip an available later conflict`() = test { + whenever(cache.data).thenReturn(flowOf(AppCacheData())) + whenever(lightning.listPayments()).thenThrow(IllegalStateException("payments unavailable")) + withActivities { saved, tags -> + saved.remove("b") + sut.handleOnchainTransactionReplaced("a", listOf("b", "c")) + assertFalse(saved.getValue("a").doesExist) + assertFalse("b" in saved) + assertTrue(saved.getValue("c").isBoosted) + assertEquals(listOf("a"), saved.getValue("c").boostTxIds) + assertEquals("original", saved.getValue("c").contact) + assertEquals(setOf("original-tag"), tags.getValue("payment-c")) + } + } + + @Test + fun `external replacement cancellation stops processing later conflicts`() = test { + whenever(cache.data).thenReturn(flow { throw CancellationException("cancelled") }) + withActivities { saved, _ -> + assertFailsWith { + sut.handleOnchainTransactionReplaced("a", listOf("b", "c")) + } + assertFalse(saved.getValue("a").doesExist) + assertFalse(saved.getValue("b").isBoosted) + assertFalse(saved.getValue("c").isBoosted) + } + } + @Test fun `manual contact detachment and existing replacement metadata survive publication`() = test { whenever(cache.data).thenReturn( @@ -223,6 +279,7 @@ class ActivityServiceRbfTest : BaseUnitTest() { private suspend fun withActivities( failFirstTagWrite: Boolean = false, + failFirstTagRead: Boolean = false, block: suspend CoroutineScope.(MutableMap, MutableMap>) -> Unit, ) = ServiceQueue.CORE.background { val wallet = WalletScope.default @@ -235,6 +292,7 @@ class ActivityServiceRbfTest : BaseUnitTest() { }.toMutableMap() val tags = mutableMapOf("payment-a" to setOf("original-tag"), "payment-b" to setOf("replacement-tag")) var failTags = failFirstTagWrite + var failTagRead = failFirstTagRead val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) val update = binding.getMethod("updateActivity", String::class.java, Activity::class.java) val getTags = binding.getMethod("getTags", String::class.java, String::class.java) @@ -249,6 +307,10 @@ class ActivityServiceRbfTest : BaseUnitTest() { Unit } native.`when` { getTags.invoke(null, anyString(), anyString()) }.thenAnswer { + if (failTagRead) { + failTagRead = false + error("tag read failed") + } tags[it.getArgument(1)].orEmpty().toList() } native.`when` { addTags.invoke(null, anyString(), anyString(), any>()) }.thenAnswer { From d36882269c190c8db81c394667b63d46e9e7508c Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 12:22:35 +0100 Subject: [PATCH 7/8] fix: preserve rbf retry quotes and confirmed routing --- .../to/bitkit/repositories/ActivityRepo.kt | 1 + .../wallets/activity/ActivityDetailScreen.kt | 9 ++ .../bitkit/ui/sheets/BoostTransactionSheet.kt | 17 ++- .../ui/sheets/BoostTransactionViewModel.kt | 130 ++++++++++++------ app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-b+es+419/strings.xml | 2 + app/src/main/res/values-ca/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-el/strings.xml | 2 + app/src/main/res/values-es-rES/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-pt/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../bitkit/repositories/ActivityRepoTest.kt | 14 ++ .../sheets/BoostTransactionViewModelTest.kt | 99 ++++++++++++- 22 files changed, 252 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 1ae93db730..f6359b7a5e 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -349,6 +349,7 @@ class ActivityRepo @Inject constructor( val visited = mutableSetOf() var current = activity while (current is Activity.Onchain) { + if (current.v1.confirmed) return current val key = scopedActivityId(current.v1.walletId, current.v1.txId) if (!visited.add(key)) return current current = replacements[key] ?: return current diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 0a2c07a8ca..e1f923fbb6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -292,6 +292,15 @@ fun ActivityDetailScreen( ) detailViewModel.onDismissBoostSheet() }, + onPending = { + app.toast( + type = Toast.ToastType.WARNING, + title = resources.getString(R.string.wallet__boost_pending_title), + description = resources.getString(R.string.wallet__boost_pending_msg), + testTag = "BoostPendingToast" + ) + detailViewModel.onDismissBoostSheet() + }, onMaxFee = { app.toast( type = Toast.ToastType.ERROR, diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index cfc66c8254..de33660995 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -62,6 +62,7 @@ fun BoostTransactionSheet( viewModel: BoostTransactionViewModel = hiltViewModel(), onSuccess: () -> Unit, onFailure: () -> Unit, + onPending: () -> Unit, onMaxFee: () -> Unit, onMinFee: () -> Unit, onDismiss: () -> Unit, @@ -83,6 +84,11 @@ fun BoostTransactionSheet( onFailure() } + BoostTransactionEffects.OnBoostPending -> { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onPending() + } + BoostTransactionEffects.OnBoostSuccess -> { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onSuccess() @@ -202,7 +208,7 @@ private fun DefaultModeContent( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClickEdit() } + .clickableAlpha(enabled = !uiState.feeLocked) { onClickEdit() } .testTag(BoostTransactionTestTags.EDIT_FEE_ROW) ) { Image( @@ -272,7 +278,7 @@ private fun DefaultModeContent( FillHeight(min = 16.dp) SwipeToConfirm( - text = stringResource(R.string.wallet__boost_swipe), + text = stringResource(if (uiState.feeLocked) R.string.common__retry else R.string.wallet__boost_swipe), color = Colors.Yellow, endIcon = R.drawable.ic_timer_alt_yellow, onConfirm = onSwipe, @@ -303,7 +309,7 @@ private fun CustomModeContent( icon = painterResource(R.drawable.ic_minus), iconColor = Colors.Red, backgroundColor = Colors.Red16, - enabled = uiState.decreaseEnabled, + enabled = uiState.decreaseEnabled && !uiState.feeLocked, onClick = { onChangeAmount(false) }, contentDescription = stringResource(R.string.wallet__boost_decrease_fee), modifier = Modifier.testTag(BoostTransactionTestTags.DECREASE_FEE_BUTTON) @@ -347,7 +353,7 @@ private fun CustomModeContent( icon = painterResource(R.drawable.ic_plus), iconColor = Colors.Green, backgroundColor = Colors.Green16, - enabled = uiState.increaseEnabled, + enabled = uiState.increaseEnabled && !uiState.feeLocked, onClick = { onChangeAmount(true) }, contentDescription = stringResource(R.string.wallet__boost_increase_fee), modifier = Modifier.testTag(BoostTransactionTestTags.INCREASE_FEE_BUTTON) @@ -360,6 +366,7 @@ private fun CustomModeContent( text = stringResource(R.string.wallet__boost_recomended_button), fullWidth = false, onClick = onClickUseSuggestedFee, + enabled = !uiState.feeLocked, size = ButtonSize.Small, modifier = Modifier.testTag(BoostTransactionTestTags.USE_SUGGESTED_FEE_BUTTON) ) @@ -367,7 +374,7 @@ private fun CustomModeContent( FillHeight(min = 16.dp) SwipeToConfirm( - text = stringResource(R.string.wallet__boost_swipe), + text = stringResource(if (uiState.feeLocked) R.string.common__retry else R.string.wallet__boost_swipe), color = Colors.Yellow, endIcon = R.drawable.ic_timer_alt_yellow, onConfirm = onSwipe, diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index b84550f739..870f6e65eb 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.sheets import android.content.Context +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity @@ -59,7 +60,7 @@ class BoostTransactionViewModel @Inject constructor( private var feeRateRecommended: ULong = 0U private var minFeeRate: ULong = 2U private var activity: Activity.Onchain? = null - private val broadcastRbfReplacements = mutableMapOf>>() + private val broadcastRbfReplacements = mutableMapOf() fun setupActivity(activity: Activity.Onchain) { Logger.debug("Setup activity $activity", context = TAG) @@ -70,18 +71,29 @@ class BoostTransactionViewModel @Inject constructor( loading = true, boosting = false, isRbf = activity.boostType() == BoostType.RBF, + feeLocked = false, ) } - initializeFeeEstimates() + val broadcast = broadcastRbfReplacements[activity.v1.txId] + if (broadcast != null && activity.boostType() == BoostType.RBF) { + restoreBroadcastFee(broadcast) + } else { + initializeFeeEstimates() + } + } + + private fun restoreBroadcastFee(broadcast: RbfBroadcastAttempt) { + _uiState.update { broadcast.quote.copy(boosting = it.boosting, loading = false, feeLocked = true) } } private fun initializeFeeEstimates() { + val origin = activity viewModelScope.launch { - runCatching { - val activityContent = activity?.v1 ?: run { + runSuspendCatching { + val activityContent = origin?.v1 ?: run { handleError("Activity value is null") - return@launch + return@runSuspendCatching } // Calculate max fee (50% of transaction value) @@ -116,6 +128,7 @@ class BoostTransactionViewModel @Inject constructor( ), ) + if (activity !== origin) return@runSuspendCatching when { totalFeeResult.isSuccess && feeRateResult.isSuccess -> { totalFeeSatsRecommended = totalFeeResult.getOrThrow() @@ -131,16 +144,20 @@ class BoostTransactionViewModel @Inject constructor( else -> { val error = totalFeeResult.exceptionOrNull() ?: feeRateResult.exceptionOrNull() - handleError("Failed to get fee estimates: ${error?.message}", error) + handleError("Failed to get fee estimates: ${error?.message}", error, origin) } } }.onFailure { - handleError("Unexpected error during fee estimation", it) + handleError("Unexpected error during fee estimation", it, origin) } } } private fun updateUiStateWithFeeData(totalFee: ULong, feeRate: ULong) { + broadcastRbfReplacements[activity?.v1?.txId]?.let { + restoreBroadcastFee(it) + return + } val currentFee = activity?.v1?.fee ?: 0u val isIncreaseEnabled = totalFee < maxTotalFee && feeRate < MAX_FEE_RATE val isDecreaseEnabled = totalFee > currentFee && feeRate > minFeeRate @@ -163,10 +180,12 @@ class BoostTransactionViewModel @Inject constructor( } fun onClickEdit() { + if (_uiState.value.feeLocked) return _uiState.update { it.copy(isDefaultMode = false) } } fun onClickUseSuggestedFee() { + if (_uiState.value.feeLocked) return updateUiStateWithFeeData( totalFee = totalFeeSatsRecommended, feeRate = feeRateRecommended @@ -199,27 +218,37 @@ class BoostTransactionViewModel @Inject constructor( private suspend fun handleRbfBoost(activity: Activity.Onchain) { val txid = activity.v1.txId val broadcast = broadcastRbfReplacements.getOrPut(txid) { - val feeRate = _uiState.value.feeRate - viewModelScope.async { - lightningRepo.bumpFeeByRbf(satsPerVByte = feeRate, originalTxId = txid) - } + val quote = _uiState.value + RbfBroadcastAttempt( + quote = quote, + result = viewModelScope.async { + lightningRepo.bumpFeeByRbf(satsPerVByte = quote.feeRate, originalTxId = txid) + }, + ) } + if (this.activity === activity) restoreBroadcastFee(broadcast) val result = try { - broadcast.await() + broadcast.result.await() } finally { - if (broadcast.isCancelled && broadcastRbfReplacements[txid] === broadcast) { - broadcastRbfReplacements.remove(txid) - } + if (broadcast.result.isCancelled) removeRbfBroadcast(txid, broadcast) } result.fold( onSuccess = { handleBoostSuccess(activity, it, isRBF = true) }, onFailure = { - if (broadcastRbfReplacements[txid] === broadcast) broadcastRbfReplacements.remove(txid) + removeRbfBroadcast(txid, broadcast) handleError("RBF boost failed", it, activity) }, ) } + private fun removeRbfBroadcast(txid: String, broadcast: RbfBroadcastAttempt) { + if (broadcastRbfReplacements[txid] !== broadcast) return + broadcastRbfReplacements.remove(txid) + if (activity?.v1?.txId == txid) { + _uiState.update { it.copy(feeLocked = false, boosting = false) } + } + } + private suspend fun handleCpfpBoost(activity: Activity.Onchain) { lightningRepo.accelerateByCpfp( satsPerVByte = _uiState.value.feeRate, @@ -240,7 +269,11 @@ class BoostTransactionViewModel @Inject constructor( val result = updateActivity(origin, newTxId = newTxId, isRBF = isRBF) if (isRBF && result.isFailure) { lightningRepo.syncAsync() - handleError("Failed to publish RBF replacement", result.exceptionOrNull(), origin) + Logger.warn("Failed to publish RBF replacement", result.exceptionOrNull(), context = TAG) + if (activity === origin) { + _uiState.update { it.copy(boosting = false) } + setBoostTransactionEffect(BoostTransactionEffects.OnBoostPending, origin) + } return } result.onFailure { error -> @@ -257,6 +290,8 @@ class BoostTransactionViewModel @Inject constructor( } fun onChangeAmount(increase: Boolean) { + if (_uiState.value.feeLocked) return + val origin = activity ?: return val currentFeeRate = _uiState.value.feeRate val newFeeRate = if (increase) { (currentFeeRate + FEE_RATE_STEP).coerceAtMost(MAX_FEE_RATE) @@ -278,34 +313,34 @@ class BoostTransactionViewModel @Inject constructor( _uiState.update { it.copy(feeRate = newFeeRate) } viewModelScope.launch { - lightningRepo - .calculateTotalFee( - amountSats = requireNotNull(activity).v1.value, - speed = TransactionSpeed.Custom(newFeeRate.toUInt()), - ) - .fold( - onSuccess = { newTotalFee -> - val currentFee = activity?.v1?.fee ?: 0u - val maxFeeReached = newTotalFee >= maxTotalFee - val minFeeReached = newTotalFee <= currentFee - - updateUiStateWithFeeData(newTotalFee, newFeeRate) - - // Send appropriate effect if we hit a limit - when { - maxFeeReached && increase -> { - setBoostTransactionEffect(BoostTransactionEffects.OnMaxFee) - } - - minFeeReached && !increase -> { - setBoostTransactionEffect(BoostTransactionEffects.OnMinFee) - } + val result = lightningRepo.calculateTotalFee( + amountSats = origin.v1.value, + speed = TransactionSpeed.Custom(newFeeRate.toUInt()), + ) + if (activity !== origin || _uiState.value.feeLocked) return@launch + result.fold( + onSuccess = { newTotalFee -> + val currentFee = activity?.v1?.fee ?: 0u + val maxFeeReached = newTotalFee >= maxTotalFee + val minFeeReached = newTotalFee <= currentFee + + updateUiStateWithFeeData(newTotalFee, newFeeRate) + + // Send appropriate effect if we hit a limit + when { + maxFeeReached && increase -> { + setBoostTransactionEffect(BoostTransactionEffects.OnMaxFee) + } + + minFeeReached && !increase -> { + setBoostTransactionEffect(BoostTransactionEffects.OnMinFee) } - }, - onFailure = { error -> - handleError("Failed to estimate fee for rate $newFeeRate", error) } - ) + }, + onFailure = { error -> + handleError("Failed to estimate fee for rate $newFeeRate", error) + } + ) } } @@ -343,6 +378,7 @@ class BoostTransactionViewModel @Inject constructor( it.copy( boosting = false, loading = false, + feeLocked = broadcastRbfReplacements[activity?.v1?.txId] != null, ) } setBoostTransactionEffect(BoostTransactionEffects.OnBoostFailed, origin) @@ -359,10 +395,17 @@ class BoostTransactionViewModel @Inject constructor( sealed interface BoostTransactionEffects { data object OnBoostSuccess : BoostTransactionEffects data object OnBoostFailed : BoostTransactionEffects + data object OnBoostPending : BoostTransactionEffects data object OnMaxFee : BoostTransactionEffects data object OnMinFee : BoostTransactionEffects } +private data class RbfBroadcastAttempt( + val quote: BoostTransactionUiState, + val result: Deferred>, +) + +@Immutable data class BoostTransactionUiState( val totalFeeSats: ULong = 0U, val feeRate: ULong = 0U, @@ -373,4 +416,5 @@ data class BoostTransactionUiState( val loading: Boolean = false, val estimateTime: String = "", val isRbf: Boolean = false, + val feeLocked: Boolean = false, ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7157df23d3..c3a89abc4e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -878,6 +878,8 @@ قد تتم تسوية معاملتك بشكل أسرع إذا أضفت رسوم شبكة إضافية. عيّن رسومك المخصصة أدناه. قد تتم تسوية معاملتك بشكل أسرع إذا أضفت رسوم شبكة إضافية. إليك توصية: زيادة الرسوم + تم بث تسريع المعاملة. سجل النشاط غير جاهز بعد. + تسريع المعاملة قيد الانتظار استخدم الرسوم المقترحة تم تعزيز المعاملة بنجاح. تم التعزيز! diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 62bd0a0f1b..46111edd43 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -908,6 +908,8 @@ Tu transacción podría confirmarse más rápido si incluyes una comisión de red adicional. Establece tu comisión personalizada abajo. Tu transacción podría confirmarse más rápido si incluyes una comisión de red adicional. Aquí tienes una recomendación: Aumentar comisión + Se transmitió la aceleración. El registro de actividad aún no está listo. + Aceleración pendiente Usar Tasa sugerida La transacción fue impulsada con éxito. ¡Impulsada! diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index c6f9c388b9..636c91f1f8 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -902,6 +902,8 @@ La teva transacció podria confirmar-se més ràpid si inclouen una tarifa de xarxa addicional. Estableix la teva tarifa personalitzada a continuació. La teva transacció podria confirmar-se més ràpid si inclouen una tarifa de xarxa addicional. Aquí tens una recomanació: Augmentar tarifa + S’ha transmès l’acceleració. El registre d’activitat encara no està llest. + Acceleració pendent Usar tarifa suggerida La transacció s\'ha impulsat correctament. Impulsat! diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b6bd8d3b17..9389484314 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -906,6 +906,8 @@ Vaše transakce se může vypořádat rychleji, pokud zahrnete další síťový poplatek. Níže si nastavte vlastní poplatek. Vaše transakce se může vypořádat rychleji, pokud zahrnete další síťový poplatek. Zde je doporučení: Zvýšit poplatek + Zrychlení bylo odvysíláno. Záznam aktivity zatím není připraven. + Zrychlení čeká na dokončení Použít navrhovaný poplatek Transakce byla úspěšně posílena. Posíleno! diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b45acef595..1f16014ac8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1030,6 +1030,8 @@ Bitkit konnte die Transaktion nicht beschleunigen. Deine Transaktion könnte schneller abgeschlossen werden, wenn du eine zusätzliche Netzwerkgebühr einbeziehst. Lege deine benutzerdefinierte Gebühr unten fest. Deine Transaktion könnte schneller abgeschlossen werden, wenn du eine zusätzliche Netzwerkgebühr einbeziehst. Hier ist eine Empfehlung: + Die Beschleunigung wurde gesendet. Der Aktivitätseintrag ist noch nicht bereit. + Beschleunigung ausstehend Empfohlene Gebühr verwenden Gebühr reduzieren Gebühr erhöhen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index be27aee396..7b3f0b3c72 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -883,6 +883,8 @@ Η συναλλαγή σου μπορεί να ολοκληρωθεί πιο γρήγορα αν συμπεριλάβεις επιπλέον τέλος δικτύου. Όρισε το προσαρμοσμένο τέλος παρακάτω. Η συναλλαγή σου μπορεί να ολοκληρωθεί πιο γρήγορα αν συμπεριλάβεις επιπλέον τέλος δικτύου. Εδώ είναι μια πρόταση: Αύξηση τέλους + Η επιτάχυνση μεταδόθηκε. Η εγγραφή δραστηριότητας δεν είναι ακόμη έτοιμη. + Εκκρεμής επιτάχυνση Χρήση προτεινόμενου τέλους Η συναλλαγή ενισχύθηκε επιτυχώς. Ενισχύθηκε! diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d51da65431..74fd0c110c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -903,6 +903,8 @@ Su transacción puede liquidarse más rápidamente si incluye una tasa de red adicional. Establezca su tarifa personalizada a continuación. Su transacción puede liquidarse más rápidamente si incluye una comisión de red adicional. He aquí una recomendación: Aumentar comisión + Se ha transmitido la aceleración. El registro de actividad aún no está listo. + Aceleración pendiente Usar Comisión Sugerida La transacción se ha impulsado satisfactoriamente. Impulsada! diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c67b788e3c..8fdf7378b7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1016,6 +1016,8 @@ Bitkit no pudo potenciar la transacción. Su transacción puede liquidarse más rápidamente si incluye una tasa de red adicional. Establezca su tarifa personalizada a continuación. Su transacción puede liquidarse más rápidamente si incluye una comisión de red adicional. He aquí una recomendación: + Se ha transmitido la aceleración. El registro de actividad aún no está listo. + Aceleración pendiente Usar Comisión Sugerida Deslizar para impulsar Recibido Bitcoin Instantáneo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b9d86083d3..70cb8e6d54 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1021,6 +1021,8 @@ Augmenter les frais Votre transaction peut être confirmée plus rapidement si vous incluez des frais de réseau supplémentaires. Définissez vos frais personnalisés ci-dessous. Votre transaction peut être confirmée plus rapidement si vous incluez des frais de réseau supplémentaires. Voici une recommandation : + L’accélération a été diffusée. Son activité n’est pas encore prête. + Accélération en attente Utilise les frais suggérés Balayer pour booster Bitcoin reçus diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1b9544f4db..9e3c700347 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -904,6 +904,8 @@ La transazione può essere regolata più velocemente se include una commissione di rete aggiuntiva. Imposta la tua tariffa personalizzata qui sotto. La transazione può essere regolata più velocemente se includi una fee di rete aggiuntiva. Ecco una raccomandazione: Aumenta commissione + L’accelerazione è stata trasmessa. Il registro dell’attività non è ancora pronto. + Accelerazione in attesa Utilizza la tariffa suggerita La transazione è stata potenziata con successo. Potenziata! diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 60745893b4..0791f783a6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -899,6 +899,8 @@ Je transactie kan sneller afgehandeld worden als je een extra netwerkvergoeding toevoegt. Stel hieronder je aangepaste vergoeding in. Je transactie kan sneller afgehandeld worden als je een extra netwerkvergoeding toevoegt. Hier is een aanbeveling: Vergoeding verhogen + De versnelling is verzonden. De activiteit is nog niet gereed. + Versnelling in behandeling Aanbevolen vergoeding gebruiken De transactie is succesvol geboost. Geboost! diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 43b6cde23b..5f2c8c4be4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1027,6 +1027,8 @@ Zwiększ opłatę Twoja transakcja może zostać rozliczona szybciej, jeśli uwzględnisz dodatkową opłatę sieciową. Ustaw niestandardową opłatę poniżej. Twoja transakcja może zostać rozliczona szybciej, jeśli uwzględnisz dodatkową opłatę sieciową. Oto zalecenie: + Przyspieszenie zostało rozgłoszone. Wpis aktywności nie jest jeszcze gotowy. + Przyspieszenie oczekuje Zastosuj sugerowaną opłatę Przesuń, aby przyspieszyć Otrzymano Bitcoinów diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1f58c229a5..aa6c533943 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -908,6 +908,8 @@ Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Configure sua taxa personalizada abaixo. Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Aqui está uma recomendação: Aumentar taxa + A aceleração foi transmitida. O registro de atividade ainda não está pronto. + Aceleração pendente Usar Taxa Sugerida A transação foi acelerada com sucesso. Acelerado! diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a157ac38fe..e4f17df4c9 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -865,6 +865,8 @@ A Bitkit não conseguiu acelerar a transação. Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Configure sua taxa personalizada abaixo. Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Aqui está uma recomendação: + A aceleração foi transmitida. O registo de atividade ainda não está pronto. + Aceleração pendente Usar Taxa Sugerida Arraste para acelerar Aumentar taxa diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8415b1d10d..97aa755a2d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -921,6 +921,8 @@ Ваша транзакция может быть подтверждена быстрее, если вы включите дополнительную комиссию. Установите значение ниже. Ваша транзакция может быть подтверждена быстрее, если вы включите дополнительную комиссию. Вот рекомендация: Увеличить комиссию + Ускорение отправлено в сеть. Запись об операции ещё не готова. + Ускорение ожидает обработки Использовать рекомендованную комиссию Транзакция успешно ускорена. Ускорена! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49512cdbbe..4443cae28e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1362,6 +1362,8 @@ Your transaction may settle faster if you include an additional network fee. Set your custom fee below. Your transaction may settle faster if you include an additional network fee. Here is a recommendation: Increase fee + The boost was broadcast. Its activity is not ready yet. + Boost Pending Use Suggested Fee The transaction was successfully boosted. Boosted! diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 6e70d8fc6c..ec87bcfdb3 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -196,6 +196,20 @@ class ActivityRepoTest : BaseUnitTest() { assertFalse(sut.isLocalRbfBoost("tx-a")) } + @Test + fun `confirmed original is not redirected to its evicted replacement`() = test { + val replacement = createOnchainActivity(id = "b", txId = "tx-b", walletId = WalletScope.default) + whenever(coreService.activity.publishRbfBoost("tx-a", "tx-b")).thenReturn(replacement) + sut.publishRbfBoost("tx-a", "tx-b").getOrThrow() + val confirmedOriginal = createOnchainActivity( + id = "a", + txId = "tx-a", + walletId = WalletScope.default, + confirmed = true, + ) + assertEquals(confirmedOriginal, sut.resolvePublishedRbfActivity(confirmedOriginal)) + } + @Test fun `wallet reset rejects completion from the old RBF publication`() = test { val published = CompletableDeferred() diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index b5084bfbcd..fc954cd2a5 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -271,7 +271,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { .thenReturn(Result.failure(IllegalStateException("not published"))) sut.boostTransactionEffect.test { sut.onConfirmBoost() - assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) + assertEquals(BoostTransactionEffects.OnBoostPending, awaitItem()) expectNoEvents() } verify(lightningRepo, times(1)).bumpFeeByRbf(any(), any()) @@ -326,7 +326,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { .thenReturn(Result.failure(IllegalStateException("storage failed")), Result.success(Unit)) sut.boostTransactionEffect.test { sut.onConfirmBoost() - assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) + assertEquals(BoostTransactionEffects.OnBoostPending, awaitItem()) expectNoEvents() sut.onConfirmBoost() assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) @@ -335,6 +335,101 @@ class BoostTransactionViewModelTest : BaseUnitTest() { verify(activityRepo, times(2)).publishRbfBoost(mockTxId, newTxId) } + @Test + fun `publication retry keeps the approved fee quote after reopening and ignores fee edits`() = runTest { + prepareRbf() + val approvedRate = sut.uiState.value.feeRate + val approvedFee = sut.uiState.value.totalFeeSats + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)) + .thenReturn(Result.failure(IllegalStateException("sync failed")), Result.success(Unit)) + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostPending, awaitItem()) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25uL)) + sut.setupActivity(activitySent) + sut.onClickEdit() + sut.onChangeAmount(increase = true) + sut.onChangeAmount(increase = true) + sut.onClickUseSuggestedFee() + assertEquals(approvedRate, sut.uiState.value.feeRate) + assertEquals(approvedFee, sut.uiState.value.totalFeeSats) + assertTrue(sut.uiState.value.feeLocked) + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + verify(lightningRepo, times(1)).bumpFeeByRbf(mockTxId, approvedRate) + verify(activityRepo, times(2)).publishRbfBoost(mockTxId, newTxId) + } + + @Test + fun `late fee calculation cannot change the quote of an existing broadcast`() = runTest { + prepareRbf() + val estimated = CompletableDeferred() + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .doSuspendableAnswer { Result.success(estimated.await()) } + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)) + .thenReturn(Result.failure(IllegalStateException("sync failed"))) + sut.onChangeAmount(increase = true) + val approved = sut.uiState.value + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostPending, awaitItem()) + estimated.complete(2_000uL) + assertEquals(approved.feeRate, sut.uiState.value.feeRate) + assertEquals(approved.totalFeeSats, sut.uiState.value.totalFeeSats) + assertTrue(sut.uiState.value.feeLocked) + expectNoEvents() + } + verify(lightningRepo).bumpFeeByRbf(mockTxId, approved.feeRate) + } + + @Test + fun `definite broadcast failure unlocks fee changes for a new attempt`() = runTest { + prepareRbf() + whenever(lightningRepo.bumpFeeByRbf(any(), any())) + .thenReturn(Result.failure(IllegalStateException("broadcast rejected")), Result.success(newTxId)) + whenever(activityRepo.publishRbfBoost(mockTxId, newTxId)).thenReturn(Result.success(Unit)) + val firstRate = sut.uiState.value.feeRate + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) + assertFalse(sut.uiState.value.feeLocked) + sut.onClickEdit() + sut.onChangeAmount(increase = true) + assertEquals(firstRate + 1uL, sut.uiState.value.feeRate) + sut.onConfirmBoost() + assertEquals(BoostTransactionEffects.OnBoostSuccess, awaitItem()) + } + verify(lightningRepo).bumpFeeByRbf(mockTxId, firstRate) + verify(lightningRepo).bumpFeeByRbf(mockTxId, firstRate + 1uL) + } + + @Test + fun `reopening without confirming again unlocks when the shared broadcast fails or cancels`() = runTest { + for (cancelled in listOf(false, true)) { + prepareRbf() + val broadcast = CompletableDeferred>() + whenever(lightningRepo.bumpFeeByRbf(any(), any())).doSuspendableAnswer { broadcast.await() } + sut.boostTransactionEffect.test { + sut.onConfirmBoost() + sut.setupActivity(activitySent) + assertTrue(sut.uiState.value.feeLocked) + if (cancelled) { + broadcast.cancel() + } else { + broadcast.complete(Result.failure(IllegalStateException("broadcast rejected"))) + } + assertFalse(sut.uiState.value.feeLocked) + assertFalse(sut.uiState.value.boosting) + val previousRate = sut.uiState.value.feeRate + sut.onClickEdit() + sut.onChangeAmount(increase = true) + assertEquals(previousRate + 1uL, sut.uiState.value.feeRate) + expectNoEvents() + } + } + } + @Test fun `RBF publication cancellation is not converted into boost failure or success`() = runTest { prepareRbf() From 2e78e25683d4f08ed4679146c72e87cace0ebf8d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 9 Nov 2026 17:16:57 +0100 Subject: [PATCH 8/8] fix: avoid claiming pending boosts were broadcast --- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-b+es+419/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es-rES/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c3a89abc4e..a980a803e2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -878,7 +878,7 @@ قد تتم تسوية معاملتك بشكل أسرع إذا أضفت رسوم شبكة إضافية. عيّن رسومك المخصصة أدناه. قد تتم تسوية معاملتك بشكل أسرع إذا أضفت رسوم شبكة إضافية. إليك توصية: زيادة الرسوم - تم بث تسريع المعاملة. سجل النشاط غير جاهز بعد. + تسريع المعاملة قيد الانتظار. تفاصيل المعاملة المحدّثة غير جاهزة بعد. تسريع المعاملة قيد الانتظار استخدم الرسوم المقترحة تم تعزيز المعاملة بنجاح. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 46111edd43..8f861e9747 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -908,7 +908,7 @@ Tu transacción podría confirmarse más rápido si incluyes una comisión de red adicional. Establece tu comisión personalizada abajo. Tu transacción podría confirmarse más rápido si incluyes una comisión de red adicional. Aquí tienes una recomendación: Aumentar comisión - Se transmitió la aceleración. El registro de actividad aún no está listo. + La aceleración está pendiente. Los detalles actualizados de la transacción aún no están listos. Aceleración pendiente Usar Tasa sugerida La transacción fue impulsada con éxito. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 636c91f1f8..a196b80a43 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -902,7 +902,7 @@ La teva transacció podria confirmar-se més ràpid si inclouen una tarifa de xarxa addicional. Estableix la teva tarifa personalitzada a continuació. La teva transacció podria confirmar-se més ràpid si inclouen una tarifa de xarxa addicional. Aquí tens una recomanació: Augmentar tarifa - S’ha transmès l’acceleració. El registre d’activitat encara no està llest. + L’acceleració està pendent. Els detalls actualitzats de la transacció encara no estan llestos. Acceleració pendent Usar tarifa suggerida La transacció s\'ha impulsat correctament. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9389484314..fbbcd39836 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -906,7 +906,7 @@ Vaše transakce se může vypořádat rychleji, pokud zahrnete další síťový poplatek. Níže si nastavte vlastní poplatek. Vaše transakce se může vypořádat rychleji, pokud zahrnete další síťový poplatek. Zde je doporučení: Zvýšit poplatek - Zrychlení bylo odvysíláno. Záznam aktivity zatím není připraven. + Zrychlení čeká na dokončení. Aktualizované údaje o transakci zatím nejsou připravené. Zrychlení čeká na dokončení Použít navrhovaný poplatek Transakce byla úspěšně posílena. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1f16014ac8..cc4b02c58d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1030,7 +1030,7 @@ Bitkit konnte die Transaktion nicht beschleunigen. Deine Transaktion könnte schneller abgeschlossen werden, wenn du eine zusätzliche Netzwerkgebühr einbeziehst. Lege deine benutzerdefinierte Gebühr unten fest. Deine Transaktion könnte schneller abgeschlossen werden, wenn du eine zusätzliche Netzwerkgebühr einbeziehst. Hier ist eine Empfehlung: - Die Beschleunigung wurde gesendet. Der Aktivitätseintrag ist noch nicht bereit. + Die Beschleunigung steht noch aus. Die aktualisierten Transaktionsdetails sind noch nicht verfügbar. Beschleunigung ausstehend Empfohlene Gebühr verwenden Gebühr reduzieren diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 7b3f0b3c72..c1f1dbf073 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -883,7 +883,7 @@ Η συναλλαγή σου μπορεί να ολοκληρωθεί πιο γρήγορα αν συμπεριλάβεις επιπλέον τέλος δικτύου. Όρισε το προσαρμοσμένο τέλος παρακάτω. Η συναλλαγή σου μπορεί να ολοκληρωθεί πιο γρήγορα αν συμπεριλάβεις επιπλέον τέλος δικτύου. Εδώ είναι μια πρόταση: Αύξηση τέλους - Η επιτάχυνση μεταδόθηκε. Η εγγραφή δραστηριότητας δεν είναι ακόμη έτοιμη. + Η επιτάχυνση εκκρεμεί. Οι ενημερωμένες λεπτομέρειες της συναλλαγής δεν είναι ακόμη έτοιμες. Εκκρεμής επιτάχυνση Χρήση προτεινόμενου τέλους Η συναλλαγή ενισχύθηκε επιτυχώς. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 74fd0c110c..e5936b5c0c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -903,7 +903,7 @@ Su transacción puede liquidarse más rápidamente si incluye una tasa de red adicional. Establezca su tarifa personalizada a continuación. Su transacción puede liquidarse más rápidamente si incluye una comisión de red adicional. He aquí una recomendación: Aumentar comisión - Se ha transmitido la aceleración. El registro de actividad aún no está listo. + La aceleración está pendiente. Los detalles actualizados de la transacción aún no están listos. Aceleración pendiente Usar Comisión Sugerida La transacción se ha impulsado satisfactoriamente. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8fdf7378b7..753d66b199 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1016,7 +1016,7 @@ Bitkit no pudo potenciar la transacción. Su transacción puede liquidarse más rápidamente si incluye una tasa de red adicional. Establezca su tarifa personalizada a continuación. Su transacción puede liquidarse más rápidamente si incluye una comisión de red adicional. He aquí una recomendación: - Se ha transmitido la aceleración. El registro de actividad aún no está listo. + La aceleración está pendiente. Los detalles actualizados de la transacción aún no están listos. Aceleración pendiente Usar Comisión Sugerida Deslizar para impulsar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 70cb8e6d54..07571291b7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1021,7 +1021,7 @@ Augmenter les frais Votre transaction peut être confirmée plus rapidement si vous incluez des frais de réseau supplémentaires. Définissez vos frais personnalisés ci-dessous. Votre transaction peut être confirmée plus rapidement si vous incluez des frais de réseau supplémentaires. Voici une recommandation : - L’accélération a été diffusée. Son activité n’est pas encore prête. + L’accélération est en attente. Les détails mis à jour de la transaction ne sont pas encore disponibles. Accélération en attente Utilise les frais suggérés Balayer pour booster diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9e3c700347..394bb8d182 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -904,7 +904,7 @@ La transazione può essere regolata più velocemente se include una commissione di rete aggiuntiva. Imposta la tua tariffa personalizzata qui sotto. La transazione può essere regolata più velocemente se includi una fee di rete aggiuntiva. Ecco una raccomandazione: Aumenta commissione - L’accelerazione è stata trasmessa. Il registro dell’attività non è ancora pronto. + L’accelerazione è in sospeso. I dettagli aggiornati della transazione non sono ancora disponibili. Accelerazione in attesa Utilizza la tariffa suggerita La transazione è stata potenziata con successo. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0791f783a6..8157a2c01e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -899,7 +899,7 @@ Je transactie kan sneller afgehandeld worden als je een extra netwerkvergoeding toevoegt. Stel hieronder je aangepaste vergoeding in. Je transactie kan sneller afgehandeld worden als je een extra netwerkvergoeding toevoegt. Hier is een aanbeveling: Vergoeding verhogen - De versnelling is verzonden. De activiteit is nog niet gereed. + De versnelling is in behandeling. De bijgewerkte transactiegegevens zijn nog niet beschikbaar. Versnelling in behandeling Aanbevolen vergoeding gebruiken De transactie is succesvol geboost. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5f2c8c4be4..4c12b45f7e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1027,7 +1027,7 @@ Zwiększ opłatę Twoja transakcja może zostać rozliczona szybciej, jeśli uwzględnisz dodatkową opłatę sieciową. Ustaw niestandardową opłatę poniżej. Twoja transakcja może zostać rozliczona szybciej, jeśli uwzględnisz dodatkową opłatę sieciową. Oto zalecenie: - Przyspieszenie zostało rozgłoszone. Wpis aktywności nie jest jeszcze gotowy. + Przyspieszenie oczekuje. Zaktualizowane szczegóły transakcji nie są jeszcze dostępne. Przyspieszenie oczekuje Zastosuj sugerowaną opłatę Przesuń, aby przyspieszyć diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index aa6c533943..a789fa52d9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -908,7 +908,7 @@ Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Configure sua taxa personalizada abaixo. Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Aqui está uma recomendação: Aumentar taxa - A aceleração foi transmitida. O registro de atividade ainda não está pronto. + A aceleração está pendente. Os detalhes atualizados da transação ainda não estão disponíveis. Aceleração pendente Usar Taxa Sugerida A transação foi acelerada com sucesso. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e4f17df4c9..69f0d8b2d6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -865,7 +865,7 @@ A Bitkit não conseguiu acelerar a transação. Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Configure sua taxa personalizada abaixo. Sua transação pode ser confirmada mais rapidamente se você incluir uma taxa adicional de rede. Aqui está uma recomendação: - A aceleração foi transmitida. O registo de atividade ainda não está pronto. + A aceleração está pendente. Os detalhes atualizados da transação ainda não estão disponíveis. Aceleração pendente Usar Taxa Sugerida Arraste para acelerar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 97aa755a2d..57daa7442f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -921,7 +921,7 @@ Ваша транзакция может быть подтверждена быстрее, если вы включите дополнительную комиссию. Установите значение ниже. Ваша транзакция может быть подтверждена быстрее, если вы включите дополнительную комиссию. Вот рекомендация: Увеличить комиссию - Ускорение отправлено в сеть. Запись об операции ещё не готова. + Ускорение ожидает обработки. Обновлённые сведения о транзакции ещё не готовы. Ускорение ожидает обработки Использовать рекомендованную комиссию Транзакция успешно ускорена. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4443cae28e..c3772cdd6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1362,7 +1362,7 @@ Your transaction may settle faster if you include an additional network fee. Set your custom fee below. Your transaction may settle faster if you include an additional network fee. Here is a recommendation: Increase fee - The boost was broadcast. Its activity is not ready yet. + The boost is pending. Updated transaction details are not ready yet. Boost Pending Use Suggested Fee The transaction was successfully boosted.