Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/data/dto/PendingBoostActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable
data class PendingBoostActivity(
val txId: String,
val updatedAt: ULong,
val activityToDelete: String?
val activityToDelete: String?,
val parentTxId: String? = null
)
46 changes: 41 additions & 5 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ class ActivityRepo @Inject constructor(
}

/**
* Updates an activity and delete other one. In case of failure in the update or deletion, the data will be cached
* Updates an activity and marks the old one as removed from mempool (for RBF).
* In case of failure in the update or marking as removed, the data will be cached
* to try again on the next sync
*/
suspend fun replaceActivity(
Expand All @@ -255,21 +256,23 @@ class ActivityRepo @Inject constructor(
).fold(
onSuccess = {
Logger.debug(
"Activity $id updated with success. new data: $activity. Deleting activity $activityIdToDelete",
"Activity $id updated with success. new data: $activity. " +
"Marking activity $activityIdToDelete as removed from mempool",
context = TAG
)

val tags = coreService.activity.tags(activityIdToDelete)
addTagsToActivity(activityId = id, tags = tags)

deleteActivity(activityIdToDelete).onFailure { e ->
markActivityAsRemovedFromMempool(activityIdToDelete).onFailure { e ->
Logger.warn(
"Failed to delete $activityIdToDelete caching to retry on next sync",
"Failed to mark $activityIdToDelete as removed from mempool, caching to retry on next sync",
e = e,
context = TAG
)
cacheStore.addActivityToPendingDelete(activityId = activityIdToDelete)
}
Result.success(Unit)
},
onFailure = { e ->
Logger.error(
Expand All @@ -286,7 +289,7 @@ class ActivityRepo @Inject constructor(
private suspend fun deletePendingActivities() = withContext(bgDispatcher) {
cacheStore.data.first().activitiesPendingDelete.map { activityId ->
async {
deleteActivity(id = activityId).onSuccess {
markActivityAsRemovedFromMempool(activityId).onSuccess {
cacheStore.removeActivityFromPendingDelete(activityId)
}
}
Expand Down Expand Up @@ -425,9 +428,16 @@ class ActivityRepo @Inject constructor(
return@onSuccess
}

val updatedBoostTxIds = if (pendingBoostActivity.parentTxId != null) {
newOnChainActivity.v1.boostTxIds + pendingBoostActivity.parentTxId
} else {
newOnChainActivity.v1.boostTxIds
}

val updatedActivity = Activity.Onchain(
v1 = newOnChainActivity.v1.copy(
isBoosted = true,
boostTxIds = updatedBoostTxIds,
updatedAt = pendingBoostActivity.updatedAt
)
)
Expand All @@ -453,6 +463,32 @@ class ActivityRepo @Inject constructor(
}.awaitAll()
}

/**
* Marks an activity as removed from mempool (sets doesExist = false).
* Used for RBFed transactions that are replaced.
*/
private suspend fun markActivityAsRemovedFromMempool(activityId: String): Result<Unit> = withContext(bgDispatcher) {
return@withContext runCatching {
val existingActivity = getActivity(activityId).getOrNull()
?: return@withContext Result.failure(Exception("Activity $activityId not found"))

if (existingActivity is Activity.Onchain) {
val updatedActivity = Activity.Onchain(
v1 = existingActivity.v1.copy(
doesExist = false,
updatedAt = nowTimestamp().toEpochMilli().toULong()
)
)
updateActivity(id = activityId, activity = updatedActivity, forceUpdate = true).getOrThrow()
notifyActivitiesChanged()
} else {
return@withContext Result.failure(Exception("Activity $activityId is not an onchain activity"))
}
}.onFailure { e ->
Logger.error("markActivityAsRemovedFromMempool error for ID: $activityId", e, context = TAG)
}
}

/**
* Deletes an activity
*/
Expand Down
60 changes: 59 additions & 1 deletion app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -428,11 +428,21 @@ class ActivityService(
}

val onChain = if (existingActivity is Activity.Onchain) {
existingActivity.v1.copy(
val wasRemoved = !existingActivity.v1.doesExist
val shouldRestore = wasRemoved && isConfirmed
val updatedOnChain = existingActivity.v1.copy(
confirmed = isConfirmed,
confirmTimestamp = confirmedTimestamp,
doesExist = if (shouldRestore) true else existingActivity.v1.doesExist,
updatedAt = timestamp,
)

// If a removed transaction confirms, mark its replacement transactions as removed
if (wasRemoved && isConfirmed) {
markReplacementTransactionsAsRemoved(originalTxId = kind.txid)
}

updatedOnChain
} else {
OnchainActivity(
id = payment.id,
Expand Down Expand Up @@ -468,6 +478,54 @@ class ActivityService(
}
}

/**
* Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms.
* This is called when a removed RBFed transaction gets confirmed.
*/
private suspend fun markReplacementTransactionsAsRemoved(originalTxId: String) {
try {
val allActivities = getActivities(
filter = ActivityFilter.ONCHAIN,
txType = null,
tags = null,
search = null,
minDate = null,
maxDate = null,
limit = null,
sortDirection = null
)

for (activity in allActivities) {
if (activity !is Activity.Onchain) continue

val onchainActivity = activity.v1
val isReplacement = onchainActivity.boostTxIds.contains(originalTxId) &&
onchainActivity.doesExist &&
!onchainActivity.confirmed

if (isReplacement) {
Logger.debug(
"Marking replacement transaction ${onchainActivity.txId} as doesExist = false " +
"(original $originalTxId confirmed)",
context = TAG
)

val updatedActivity = onchainActivity.copy(
doesExist = false,
updatedAt = System.currentTimeMillis().toULong() / 1000u
)
updateActivity(activityId = onchainActivity.id, activity = Activity.Onchain(updatedActivity))
}
}
} catch (e: Exception) {
Logger.error(
"Error marking replacement transactions as removed for originalTxId: $originalTxId",
e,
context = TAG
)
}
}

private fun PaymentDirection.toPaymentType(): PaymentType =
if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ private fun StatusSection(item: Activity) {
statusIcon = painterResource(R.drawable.ic_x)
statusColor = Colors.Red
statusText = stringResource(R.string.wallet__activity_removed)
statusTestTag = "StatusRemoved"
}

StatusRow(statusIcon, statusText, statusColor, statusTestTag)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,6 @@ fun ActivityExploreScreen(
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
},
onClickParent = { id ->
app.toast(
type = Toast.ToastType.WARNING,
title = "TODO",
description = "Navigate to Activity Detail for: $id",
)
},
)
}
}
Expand All @@ -133,7 +126,6 @@ private fun ActivityExploreContent(
txDetails: TxDetails? = null,
onCopy: (String) -> Unit = {},
onClickExplore: (String) -> Unit = {},
onClickParent: (String) -> Unit = {},
) {
Column(
modifier = Modifier
Expand Down Expand Up @@ -164,7 +156,6 @@ private fun ActivityExploreContent(
onchain = item,
onCopy = onCopy,
txDetails = txDetails,
onClickParent = onClickParent,
)
Spacer(modifier = Modifier.weight(1f))
PrimaryButton(
Expand Down Expand Up @@ -226,7 +217,6 @@ private fun ColumnScope.OnchainDetails(
onchain: Activity.Onchain,
onCopy: (String) -> Unit,
txDetails: TxDetails?,
onClickParent: (String) -> Unit,
) {
val txId = onchain.v1.txId
Section(
Expand Down Expand Up @@ -271,29 +261,33 @@ private fun ColumnScope.OnchainDetails(
.size(16.dp)
.align(Alignment.CenterHorizontally)
)
} // TODO use real boosted parents from bitkit-core/ldk-node when available
val boostedParents = listOfNotNull(
"todo_first_parent_txid".takeIf { onchain.isBoosted() && !onchain.v1.confirmed },
"todo_second_parent_txid".takeIf { onchain.isBoosted() && onchain.v1.confirmed },
)
}

boostedParents.forEachIndexed { index, parent ->
// Display boosted transaction IDs from boostTxIds
// For CPFP (RECEIVED): shows child transaction IDs that boosted this parent
// For RBF (SENT): shows parent transaction IDs that this replacement replaced
val boostTxIds = onchain.v1.boostTxIds
if (boostTxIds.isNotEmpty()) {
val isRbf = onchain.boostType() == BoostType.RBF
Section(
title = stringResource(
if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp
).replace("{num}", "${index + 1}"),
valueContent = {
Column {
BodySSB(text = parent, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
}
},
modifier = Modifier
.clickableAlpha {
onClickParent(parent)
}
.testTag(if (isRbf) "RBFBoosted" else "CPFPBoosted")
)
boostTxIds.forEachIndexed { index, boostedTxId ->
Section(
title = stringResource(
if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp
).replace("{num}", "${index + 1}"),
valueContent = {
Column {
BodySSB(text = boostedTxId, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
}
},
modifier = Modifier
.clickableAlpha(
onClick = copyToClipboard(boostedTxId) {
onCopy(it)
}
)
.testTag(if (isRbf) "RBFBoosted" else "CPFPBoosted")
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ fun ActivityRow(
is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) }
is Activity.Onchain -> {
when {
!item.v1.doesExist -> {
stringResource(R.string.wallet__activity_removed)
}

isTransfer && isSent -> {
if (item.v1.confirmed) {
stringResource(R.string.wallet__activity_transfer_spending_done)
Expand Down
Loading
Loading