Skip to content

Commit 5f70e06

Browse files
committed
Save boosted txs
1 parent d7ca7ad commit 5f70e06

File tree

7 files changed

+158
-49
lines changed

7 files changed

+158
-49
lines changed

app/src/main/java/to/bitkit/data/dto/PendingBoostActivity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable
66
data class PendingBoostActivity(
77
val txId: String,
88
val updatedAt: ULong,
9-
val activityToDelete: String?
9+
val activityToDelete: String?,
10+
val parentTxId: String? = null
1011
)

app/src/main/java/to/bitkit/repositories/ActivityRepo.kt

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,8 @@ class ActivityRepo @Inject constructor(
241241
}
242242

243243
/**
244-
* Updates an activity and delete other one. In case of failure in the update or deletion, the data will be cached
244+
* Updates an activity and marks the old one as removed from mempool (for RBF).
245+
* In case of failure in the update or marking as removed, the data will be cached
245246
* to try again on the next sync
246247
*/
247248
suspend fun replaceActivity(
@@ -255,16 +256,17 @@ class ActivityRepo @Inject constructor(
255256
).fold(
256257
onSuccess = {
257258
Logger.debug(
258-
"Activity $id updated with success. new data: $activity. Deleting activity $activityIdToDelete",
259+
"Activity $id updated with success. new data: $activity. " +
260+
"Marking activity $activityIdToDelete as removed from mempool",
259261
context = TAG
260262
)
261263

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

265-
deleteActivity(activityIdToDelete).onFailure { e ->
267+
markActivityAsRemovedFromMempool(activityIdToDelete).onFailure { e ->
266268
Logger.warn(
267-
"Failed to delete $activityIdToDelete caching to retry on next sync",
269+
"Failed to mark $activityIdToDelete as removed from mempool, caching to retry on next sync",
268270
e = e,
269271
context = TAG
270272
)
@@ -286,7 +288,7 @@ class ActivityRepo @Inject constructor(
286288
private suspend fun deletePendingActivities() = withContext(bgDispatcher) {
287289
cacheStore.data.first().activitiesPendingDelete.map { activityId ->
288290
async {
289-
deleteActivity(id = activityId).onSuccess {
291+
markActivityAsRemovedFromMempool(activityId).onSuccess {
290292
cacheStore.removeActivityFromPendingDelete(activityId)
291293
}
292294
}
@@ -425,9 +427,16 @@ class ActivityRepo @Inject constructor(
425427
return@onSuccess
426428
}
427429

430+
val updatedBoostTxIds = if (pendingBoostActivity.parentTxId != null) {
431+
newOnChainActivity.v1.boostTxIds + pendingBoostActivity.parentTxId
432+
} else {
433+
newOnChainActivity.v1.boostTxIds
434+
}
435+
428436
val updatedActivity = Activity.Onchain(
429437
v1 = newOnChainActivity.v1.copy(
430438
isBoosted = true,
439+
boostTxIds = updatedBoostTxIds,
431440
updatedAt = pendingBoostActivity.updatedAt
432441
)
433442
)
@@ -453,6 +462,32 @@ class ActivityRepo @Inject constructor(
453462
}.awaitAll()
454463
}
455464

465+
/**
466+
* Marks an activity as removed from mempool (sets doesExist = false).
467+
* Used for RBFed transactions that are replaced.
468+
*/
469+
private suspend fun markActivityAsRemovedFromMempool(activityId: String): Result<Unit> = withContext(bgDispatcher) {
470+
return@withContext runCatching {
471+
val existingActivity = getActivity(activityId).getOrNull()
472+
?: return@withContext Result.failure(Exception("Activity $activityId not found"))
473+
474+
if (existingActivity is Activity.Onchain) {
475+
val updatedActivity = Activity.Onchain(
476+
v1 = existingActivity.v1.copy(
477+
doesExist = false,
478+
updatedAt = nowTimestamp().toEpochMilli().toULong()
479+
)
480+
)
481+
updateActivity(id = activityId, activity = updatedActivity, forceUpdate = true).getOrThrow()
482+
notifyActivitiesChanged()
483+
} else {
484+
return@withContext Result.failure(Exception("Activity $activityId is not an onchain activity"))
485+
}
486+
}.onFailure { e ->
487+
Logger.error("markActivityAsRemovedFromMempool error for ID: $activityId", e, context = TAG)
488+
}
489+
}
490+
456491
/**
457492
* Deletes an activity
458493
*/

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,11 +428,21 @@ class ActivityService(
428428
}
429429

430430
val onChain = if (existingActivity is Activity.Onchain) {
431-
existingActivity.v1.copy(
431+
val wasRemoved = !existingActivity.v1.doesExist
432+
val shouldRestore = wasRemoved && isConfirmed
433+
val updatedOnChain = existingActivity.v1.copy(
432434
confirmed = isConfirmed,
433435
confirmTimestamp = confirmedTimestamp,
436+
doesExist = if (shouldRestore) true else existingActivity.v1.doesExist,
434437
updatedAt = timestamp,
435438
)
439+
440+
// If a removed transaction confirms, mark its replacement transactions as removed
441+
if (wasRemoved && isConfirmed) {
442+
markReplacementTransactionsAsRemoved(originalTxId = kind.txid)
443+
}
444+
445+
updatedOnChain
436446
} else {
437447
OnchainActivity(
438448
id = payment.id,
@@ -468,6 +478,54 @@ class ActivityService(
468478
}
469479
}
470480

481+
/**
482+
* Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms.
483+
* This is called when a removed RBFed transaction gets confirmed.
484+
*/
485+
private suspend fun markReplacementTransactionsAsRemoved(originalTxId: String) {
486+
try {
487+
val allActivities = getActivities(
488+
filter = ActivityFilter.ONCHAIN,
489+
txType = null,
490+
tags = null,
491+
search = null,
492+
minDate = null,
493+
maxDate = null,
494+
limit = null,
495+
sortDirection = null
496+
)
497+
498+
for (activity in allActivities) {
499+
if (activity !is Activity.Onchain) continue
500+
501+
val onchainActivity = activity.v1
502+
val isReplacement = onchainActivity.boostTxIds.contains(originalTxId) &&
503+
onchainActivity.doesExist &&
504+
!onchainActivity.confirmed
505+
506+
if (isReplacement) {
507+
Logger.debug(
508+
"Marking replacement transaction ${onchainActivity.txId} as doesExist = false " +
509+
"(original $originalTxId confirmed)",
510+
context = TAG
511+
)
512+
513+
val updatedActivity = onchainActivity.copy(
514+
doesExist = false,
515+
updatedAt = System.currentTimeMillis().toULong() / 1000u
516+
)
517+
updateActivity(activityId = onchainActivity.id, activity = Activity.Onchain(updatedActivity))
518+
}
519+
}
520+
} catch (e: Exception) {
521+
Logger.error(
522+
"Error marking replacement transactions as removed for originalTxId: $originalTxId",
523+
e,
524+
context = TAG
525+
)
526+
}
527+
}
528+
471529
private fun PaymentDirection.toPaymentType(): PaymentType =
472530
if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED
473531

app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ private fun StatusSection(item: Activity) {
583583
statusIcon = painterResource(R.drawable.ic_x)
584584
statusColor = Colors.Red
585585
statusText = stringResource(R.string.wallet__activity_removed)
586+
statusTestTag = "StatusRemoved"
586587
}
587588

588589
StatusRow(statusIcon, statusText, statusColor, statusTestTag)

app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,6 @@ fun ActivityExploreScreen(
116116
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
117117
context.startActivity(intent)
118118
},
119-
onClickParent = { id ->
120-
app.toast(
121-
type = Toast.ToastType.WARNING,
122-
title = "TODO",
123-
description = "Navigate to Activity Detail for: $id",
124-
)
125-
},
126119
)
127120
}
128121
}
@@ -133,7 +126,6 @@ private fun ActivityExploreContent(
133126
txDetails: TxDetails? = null,
134127
onCopy: (String) -> Unit = {},
135128
onClickExplore: (String) -> Unit = {},
136-
onClickParent: (String) -> Unit = {},
137129
) {
138130
Column(
139131
modifier = Modifier
@@ -164,7 +156,6 @@ private fun ActivityExploreContent(
164156
onchain = item,
165157
onCopy = onCopy,
166158
txDetails = txDetails,
167-
onClickParent = onClickParent,
168159
)
169160
Spacer(modifier = Modifier.weight(1f))
170161
PrimaryButton(
@@ -226,7 +217,6 @@ private fun ColumnScope.OnchainDetails(
226217
onchain: Activity.Onchain,
227218
onCopy: (String) -> Unit,
228219
txDetails: TxDetails?,
229-
onClickParent: (String) -> Unit,
230220
) {
231221
val txId = onchain.v1.txId
232222
Section(
@@ -271,29 +261,33 @@ private fun ColumnScope.OnchainDetails(
271261
.size(16.dp)
272262
.align(Alignment.CenterHorizontally)
273263
)
274-
} // TODO use real boosted parents from bitkit-core/ldk-node when available
275-
val boostedParents = listOfNotNull(
276-
"todo_first_parent_txid".takeIf { onchain.isBoosted() && !onchain.v1.confirmed },
277-
"todo_second_parent_txid".takeIf { onchain.isBoosted() && onchain.v1.confirmed },
278-
)
264+
}
279265

280-
boostedParents.forEachIndexed { index, parent ->
266+
// Display boosted transaction IDs from boostTxIds
267+
// For CPFP (RECEIVED): shows child transaction IDs that boosted this parent
268+
// For RBF (SENT): shows parent transaction IDs that this replacement replaced
269+
val boostTxIds = onchain.v1.boostTxIds
270+
if (boostTxIds.isNotEmpty()) {
281271
val isRbf = onchain.boostType() == BoostType.RBF
282-
Section(
283-
title = stringResource(
284-
if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp
285-
).replace("{num}", "${index + 1}"),
286-
valueContent = {
287-
Column {
288-
BodySSB(text = parent, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
289-
}
290-
},
291-
modifier = Modifier
292-
.clickableAlpha {
293-
onClickParent(parent)
294-
}
295-
.testTag(if (isRbf) "RBFBoosted" else "CPFPBoosted")
296-
)
272+
boostTxIds.forEachIndexed { index, boostedTxId ->
273+
Section(
274+
title = stringResource(
275+
if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp
276+
).replace("{num}", "${index + 1}"),
277+
valueContent = {
278+
Column {
279+
BodySSB(text = boostedTxId, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
280+
}
281+
},
282+
modifier = Modifier
283+
.clickableAlpha(
284+
onClick = copyToClipboard(boostedTxId) {
285+
onCopy(it)
286+
}
287+
)
288+
.testTag(if (isRbf) "RBFBoosted" else "CPFPBoosted")
289+
)
290+
}
297291
}
298292
}
299293

app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ fun ActivityRow(
101101
is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) }
102102
is Activity.Onchain -> {
103103
when {
104+
!item.v1.doesExist -> {
105+
stringResource(R.string.wallet__activity_removed)
106+
}
107+
104108
isTransfer && isSent -> {
105109
if (item.v1.confirmed) {
106110
stringResource(R.string.wallet__activity_transfer_spending_done)

0 commit comments

Comments
 (0)