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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ class ActivityRepo @Inject constructor(

suspend fun notifyPaymentActivityChanged() = notifyActivitiesChanged()

suspend fun recordRbfBoost(
original: OnchainActivity,
replacementTxId: String,
feeRate: ULong,
): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
coreService.activity.recordRbfBoost(
originalActivityId = original.id,
replacementTxId = replacementTxId,
feeRate = feeRate,
walletId = original.walletId,
)
notifyActivitiesChanged(tagsChanged = true)
}
}

suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean {
return coreService.activity.shouldShowReceivedSheet(txid, value)
}
Expand Down
26 changes: 16 additions & 10 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import com.synonym.bitkitcore.upsertActivity
import com.synonym.bitkitcore.upsertCjitEntries
import com.synonym.bitkitcore.upsertClosedChannels
import com.synonym.bitkitcore.upsertInfo
import com.synonym.bitkitcore.upsertOnchainActivityPreservingFeeRate
import com.synonym.bitkitcore.upsertOrders
import com.synonym.bitkitcore.upsertTransactionDetails
import com.synonym.bitkitcore.wipeAllDatabases
Expand Down Expand Up @@ -114,6 +115,7 @@ import com.synonym.bitkitcore.TxOutput as BitkitCoreTxOutput
import com.synonym.bitkitcore.getLnurlInvoiceForPayData as coreGetLnurlInvoiceForPayData
import com.synonym.bitkitcore.getTransactionDetails as getBitkitCoreTransactionDetails
import com.synonym.bitkitcore.markActivityAsSeen as coreMarkActivityAsSeen
import com.synonym.bitkitcore.recordRbfBoost as coreRecordRbfBoost

// region Core

Expand Down Expand Up @@ -407,6 +409,15 @@ class ActivityService(
upsertActivity(activity)
}

suspend fun recordRbfBoost(
originalActivityId: String,
replacementTxId: String,
feeRate: ULong,
walletId: String = defaultWalletId,
) = ServiceQueue.CORE.background {
coreRecordRbfBoost(walletId, originalActivityId, replacementTxId, feeRate)
}

suspend fun upsertList(activities: List<Activity>) = ServiceQueue.CORE.background {
upsertActivities(activities)
}
Expand Down Expand Up @@ -1235,12 +1246,7 @@ class ActivityService(
return
}

if (existingActivity != null && existingActivity is Activity.Onchain) {
val existingOnchain = existingActivity.v1
updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain))
} else {
upsertActivity(Activity.Onchain(onChain))
}
upsertOnchainActivityPreservingFeeRate(onChain)
}

private fun PaymentDirection.toPaymentType(): PaymentType =
Expand Down Expand Up @@ -1494,7 +1500,7 @@ class ActivityService(
isBoosted = false,
updatedAt = System.currentTimeMillis().toULong() / 1000u
)
updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity))
upsertOnchainActivityPreservingFeeRate(updatedActivity)
Logger.info("Marked transaction $txid as replaced", context = TAG)
} else {
Logger.info(
Expand Down Expand Up @@ -1554,7 +1560,7 @@ class ActivityService(
contact = replacementActivity.contact ?: replacedActivity?.contact,
updatedAt = System.currentTimeMillis().toULong() / 1000u,
)
updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity))
upsertOnchainActivityPreservingFeeRate(updatedActivity)

if (replacedActivity != null) {
copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id)
Expand Down Expand Up @@ -1598,7 +1604,7 @@ class ActivityService(
updatedAt = System.currentTimeMillis().toULong() / 1000u
)

updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity))
upsertOnchainActivityPreservingFeeRate(updatedActivity)
}.onFailure { e ->
Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG)
}
Expand All @@ -1618,7 +1624,7 @@ class ActivityService(
updatedAt = System.currentTimeMillis().toULong() / 1000u
)

updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity))
upsertOnchainActivityPreservingFeeRate(updatedActivity)
}.onFailure { e ->
Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG)
}
Expand Down
63 changes: 16 additions & 47 deletions app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ 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
import to.bitkit.repositories.BlocktankRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
Expand Down Expand Up @@ -179,7 +181,7 @@ 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)
Expand All @@ -191,12 +193,16 @@ class BoostTransactionViewModel @Inject constructor(
}

private suspend fun handleRbfBoost(activity: Activity.Onchain) {
val feeRate = _uiState.value.feeRate
lightningRepo.bumpFeeByRbf(
satsPerVByte = _uiState.value.feeRate,
satsPerVByte = feeRate,
originalTxId = activity.v1.txId
).fold(
onSuccess = { newTxId ->
handleBoostSuccess(newTxId, isRBF = true)
handleBoostSuccess(
newTxId,
activityRepo.recordRbfBoost(activity.v1, newTxId, feeRate),
)
},
onFailure = { error ->
handleError("RBF boost failed: ${error.message}", error)
Expand All @@ -211,19 +217,19 @@ class BoostTransactionViewModel @Inject constructor(
destinationAddress = walletRepo.getOnchainAddress(),
).fold(
onSuccess = { newTxId ->
handleBoostSuccess(newTxId, isRBF = false)
handleBoostSuccess(newTxId, updateCpfpActivity(newTxId))
},
onFailure = { error ->
handleError("CPFP boost failed: ${error.message}", error)
}
)
}

private suspend fun handleBoostSuccess(newTxId: Txid, isRBF: Boolean) {
private fun handleBoostSuccess(newTxId: Txid, activityUpdate: Result<Unit>) {
Logger.debug("Boost successful. newTxId: $newTxId", context = TAG)
updateActivity(newTxId = newTxId, isRBF = isRBF)
activityUpdate
.onFailure { error ->
Logger.warn("Boost successful but activity update failed", e = error, context = TAG)
Logger.warn("Failed to update activity after successful boost", error, context = TAG)
}

_uiState.update { it.copy(boosting = false) }
Expand Down Expand Up @@ -286,22 +292,10 @@ 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<Unit> {
Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG)

private suspend fun updateCpfpActivity(newTxId: Txid): Result<Unit> {
val currentActivity = activity?.v1
?: return Result.failure(Exception("Activity required"))

return if (isRBF) {
handleRBFUpdate(currentActivity)
} else {
handleCPFPUpdate(currentActivity, newTxId)
}
?: return Result.failure(AppError(message = "Activity required"))
return handleCPFPUpdate(currentActivity, newTxId)
}

/**
Expand All @@ -324,31 +318,6 @@ 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<Unit> {
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) {
Logger.error(message, error, context = TAG)
_uiState.update {
Expand Down
39 changes: 39 additions & 0 deletions app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

@Suppress("LargeClass")
@OptIn(ExperimentalTime::class)
Expand Down Expand Up @@ -173,6 +174,44 @@ class ActivityRepoTest : BaseUnitTest() {
wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList())
}

@Test
fun `recordRbfBoost passes scoped identifiers and rate to core without rewriting the original`() = test {
val original = createOnchainActivity(walletId = "trezor:test").v1
whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(200))

val result = sut.recordRbfBoost(original, "replacement-tx", 25uL)

assertTrue(result.isSuccess)
verify(coreService.activity).recordRbfBoost(original.id, "replacement-tx", 25uL, original.walletId)
verify(coreService.activity, never()).update(any(), any())
assertEquals(200_000L, sut.activitiesChanged.value)
assertEquals(200_000L, sut.activityTagsChanged.value)
}

@Test
fun `recordRbfBoost propagates storage failure without notifying observers`() = test {
val original = createOnchainActivity().v1
val failure = AppError(message = "storage unavailable")
whenever(coreService.activity.recordRbfBoost(original.id, "replacement-tx", 25uL, original.walletId))
.thenAnswer { throw failure }

val result = sut.recordRbfBoost(original, "replacement-tx", 25uL)

assertEquals(failure, result.exceptionOrNull())
assertEquals(0L, sut.activitiesChanged.value)
assertEquals(0L, sut.activityTagsChanged.value)
}

@Test
fun `recordRbfBoost preserves coroutine cancellation`() = test {
val original = createOnchainActivity().v1
whenever(coreService.activity.recordRbfBoost(original.id, "replacement-tx", 25uL, original.walletId))
.thenThrow(CancellationException("cancelled"))

assertFailsWith<CancellationException> { sut.recordRbfBoost(original, "replacement-tx", 25uL) }
assertEquals(0L, sut.activitiesChanged.value)
}

@Test
fun `syncActivities success flow`() = test {
val payments = listOf(testPaymentDetails)
Expand Down
Loading
Loading