Skip to content

Commit 9a03139

Browse files
authored
Merge pull request #470 from synonymdev/feat/save-boosted-txs
Save boosted txs
2 parents 912cdd2 + 706d9a2 commit 9a03139

File tree

11 files changed

+698
-84
lines changed

11 files changed

+698
-84
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/ext/Activities.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ fun Activity.matchesPaymentId(paymentHashOrTxId: String): Boolean = when (this)
6060

6161
fun Activity.isTransfer() = this is Activity.Onchain && this.v1.isTransfer
6262

63+
fun Activity.doesExist() = this is Activity.Onchain && this.v1.doesExist
64+
6365
fun Activity.Onchain.boostType() = when (this.v1.txType) {
6466
PaymentType.SENT -> BoostType.RBF
6567
PaymentType.RECEIVED -> BoostType.CPFP

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ import javax.inject.Singleton
4343

4444
private const val SYNC_TIMEOUT_MS = 40_000L
4545

46-
@Suppress("LongParameterList")
4746
@Singleton
47+
@Suppress("LargeClass", "LongParameterList")
4848
class ActivityRepo @Inject constructor(
4949
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
5050
private val coreService: CoreService,
@@ -248,7 +248,8 @@ class ActivityRepo @Inject constructor(
248248
}
249249

250250
/**
251-
* Updates an activity and delete other one. In case of failure in the update or deletion, the data will be cached
251+
* Updates an activity and marks the old one as removed from mempool (for RBF).
252+
* In case of failure in the update or marking as removed, the data will be cached
252253
* to try again on the next sync
253254
*/
254255
suspend fun replaceActivity(
@@ -262,21 +263,23 @@ class ActivityRepo @Inject constructor(
262263
).fold(
263264
onSuccess = {
264265
Logger.debug(
265-
"Activity $id updated with success. new data: $activity. Deleting activity $activityIdToDelete",
266+
"Activity $id updated with success. new data: $activity. " +
267+
"Marking activity $activityIdToDelete as removed from mempool",
266268
context = TAG
267269
)
268270

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

272-
deleteActivity(activityIdToDelete).onFailure { e ->
274+
markActivityAsRemovedFromMempool(activityIdToDelete).onFailure { e ->
273275
Logger.warn(
274-
"Failed to delete $activityIdToDelete caching to retry on next sync",
276+
"Failed to mark $activityIdToDelete as removed from mempool, caching to retry on next sync",
275277
e = e,
276278
context = TAG
277279
)
278280
cacheStore.addActivityToPendingDelete(activityId = activityIdToDelete)
279281
}
282+
Result.success(Unit)
280283
},
281284
onFailure = { e ->
282285
Logger.error(
@@ -293,7 +296,7 @@ class ActivityRepo @Inject constructor(
293296
private suspend fun deletePendingActivities() = withContext(bgDispatcher) {
294297
cacheStore.data.first().activitiesPendingDelete.map { activityId ->
295298
async {
296-
deleteActivity(id = activityId).onSuccess {
299+
markActivityAsRemovedFromMempool(activityId).onSuccess {
297300
cacheStore.removeActivityFromPendingDelete(activityId)
298301
}
299302
}
@@ -434,9 +437,16 @@ class ActivityRepo @Inject constructor(
434437
return@onSuccess
435438
}
436439

440+
val updatedBoostTxIds = if (pendingBoostActivity.parentTxId != null) {
441+
newOnChainActivity.v1.boostTxIds + pendingBoostActivity.parentTxId
442+
} else {
443+
newOnChainActivity.v1.boostTxIds
444+
}
445+
437446
val updatedActivity = Activity.Onchain(
438447
v1 = newOnChainActivity.v1.copy(
439448
isBoosted = true,
449+
boostTxIds = updatedBoostTxIds,
440450
updatedAt = pendingBoostActivity.updatedAt
441451
)
442452
)
@@ -462,6 +472,32 @@ class ActivityRepo @Inject constructor(
462472
}.awaitAll()
463473
}
464474

475+
/**
476+
* Marks an activity as removed from mempool (sets doesExist = false).
477+
* Used for RBFed transactions that are replaced.
478+
*/
479+
private suspend fun markActivityAsRemovedFromMempool(activityId: String): Result<Unit> = withContext(bgDispatcher) {
480+
return@withContext runCatching {
481+
val existingActivity = getActivity(activityId).getOrNull()
482+
?: return@withContext Result.failure(Exception("Activity $activityId not found"))
483+
484+
if (existingActivity is Activity.Onchain) {
485+
val updatedActivity = Activity.Onchain(
486+
v1 = existingActivity.v1.copy(
487+
doesExist = false,
488+
updatedAt = nowTimestamp().toEpochMilli().toULong()
489+
)
490+
)
491+
updateActivity(id = activityId, activity = updatedActivity, forceUpdate = true).getOrThrow()
492+
notifyActivitiesChanged()
493+
} else {
494+
return@withContext Result.failure(Exception("Activity $activityId is not an onchain activity"))
495+
}
496+
}.onFailure { e ->
497+
Logger.error("markActivityAsRemovedFromMempool error for ID: $activityId", e, context = TAG)
498+
}
499+
}
500+
465501
/**
466502
* Deletes an activity
467503
*/

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,21 @@ class ActivityService(
455455
}
456456

457457
val onChain = if (existingActivity is Activity.Onchain) {
458-
existingActivity.v1.copy(
458+
val wasRemoved = !existingActivity.v1.doesExist
459+
val shouldRestore = wasRemoved && isConfirmed
460+
val updatedOnChain = existingActivity.v1.copy(
459461
confirmed = isConfirmed,
460462
confirmTimestamp = confirmedTimestamp,
463+
doesExist = if (shouldRestore) true else existingActivity.v1.doesExist,
461464
updatedAt = timestamp,
462465
)
466+
467+
// If a removed transaction confirms, mark its replacement transactions as removed
468+
if (wasRemoved && isConfirmed) {
469+
markReplacementTransactionsAsRemoved(originalTxId = kind.txid)
470+
}
471+
472+
updatedOnChain
463473
} else {
464474
OnchainActivity(
465475
id = payment.id,
@@ -495,6 +505,54 @@ class ActivityService(
495505
}
496506
}
497507

508+
/**
509+
* Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms.
510+
* This is called when a removed RBFed transaction gets confirmed.
511+
*/
512+
private suspend fun markReplacementTransactionsAsRemoved(originalTxId: String) {
513+
try {
514+
val allActivities = getActivities(
515+
filter = ActivityFilter.ONCHAIN,
516+
txType = null,
517+
tags = null,
518+
search = null,
519+
minDate = null,
520+
maxDate = null,
521+
limit = null,
522+
sortDirection = null
523+
)
524+
525+
for (activity in allActivities) {
526+
if (activity !is Activity.Onchain) continue
527+
528+
val onchainActivity = activity.v1
529+
val isReplacement = onchainActivity.boostTxIds.contains(originalTxId) &&
530+
onchainActivity.doesExist &&
531+
!onchainActivity.confirmed
532+
533+
if (isReplacement) {
534+
Logger.debug(
535+
"Marking replacement transaction ${onchainActivity.txId} as doesExist = false " +
536+
"(original $originalTxId confirmed)",
537+
context = TAG
538+
)
539+
540+
val updatedActivity = onchainActivity.copy(
541+
doesExist = false,
542+
updatedAt = System.currentTimeMillis().toULong() / 1000u
543+
)
544+
updateActivity(activityId = onchainActivity.id, activity = Activity.Onchain(updatedActivity))
545+
}
546+
}
547+
} catch (e: Exception) {
548+
Logger.error(
549+
"Error marking replacement transactions as removed for originalTxId: $originalTxId",
550+
e,
551+
context = TAG
552+
)
553+
}
554+
}
555+
498556
private fun PaymentDirection.toPaymentType(): PaymentType =
499557
if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED
500558

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/ActivityIcon.kt

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package to.bitkit.ui.screens.wallets.activity.components
33
import androidx.compose.foundation.background
44
import androidx.compose.foundation.layout.Arrangement
55
import androidx.compose.foundation.layout.Box
6-
import androidx.compose.foundation.layout.Row
6+
import androidx.compose.foundation.layout.Column
77
import androidx.compose.foundation.layout.padding
88
import androidx.compose.foundation.layout.size
99
import androidx.compose.foundation.shape.CircleShape
@@ -24,6 +24,7 @@ import com.synonym.bitkitcore.OnchainActivity
2424
import com.synonym.bitkitcore.PaymentState
2525
import com.synonym.bitkitcore.PaymentType
2626
import to.bitkit.R
27+
import to.bitkit.ext.doesExist
2728
import to.bitkit.ext.isBoosted
2829
import to.bitkit.ext.isFinished
2930
import to.bitkit.ext.isTransfer
@@ -46,7 +47,7 @@ fun ActivityIcon(
4647
val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received)
4748

4849
when {
49-
activity.isBoosted() && !activity.isFinished() -> {
50+
activity.isBoosted() && !activity.isFinished() && activity.doesExist() -> {
5051
CircularIcon(
5152
icon = painterResource(R.drawable.ic_timer_alt),
5253
iconColor = Colors.Yellow,
@@ -90,9 +91,14 @@ fun ActivityIcon(
9091
}
9192
}
9293

94+
// onchain
9395
else -> {
9496
CircularIcon(
95-
icon = if (activity.isTransfer()) painterResource(R.drawable.ic_transfer) else arrowIcon,
97+
icon = when {
98+
!activity.doesExist() -> painterResource(R.drawable.ic_x)
99+
activity.isTransfer() -> painterResource(R.drawable.ic_transfer)
100+
else -> arrowIcon
101+
},
96102
iconColor = Colors.Brand,
97103
backgroundColor = Colors.Brand16,
98104
size = size,
@@ -129,8 +135,8 @@ fun CircularIcon(
129135
@Composable
130136
private fun Preview() {
131137
AppThemeSurface {
132-
Row(
133-
horizontalArrangement = Arrangement.spacedBy(16.dp),
138+
Column(
139+
verticalArrangement = Arrangement.spacedBy(16.dp),
134140
modifier = Modifier.padding(16.dp),
135141
) {
136142
// Lightning Sent Succeeded
@@ -293,6 +299,32 @@ private fun Preview() {
293299
)
294300
)
295301
)
302+
303+
// Onchain Removed
304+
ActivityIcon(
305+
activity = Activity.Onchain(
306+
v1 = OnchainActivity(
307+
id = "test-onchain-2",
308+
txType = PaymentType.SENT,
309+
txId = "abc123",
310+
value = 100000uL,
311+
fee = 500uL,
312+
feeRate = 8uL,
313+
address = "bc1...",
314+
confirmed = true,
315+
timestamp = (System.currentTimeMillis() / 1000).toULong(),
316+
isBoosted = true,
317+
boostTxIds = emptyList(),
318+
isTransfer = false,
319+
doesExist = false,
320+
confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(),
321+
channelId = null,
322+
transferTxId = "transferTxId",
323+
createdAt = null,
324+
updatedAt = null,
325+
)
326+
)
327+
)
296328
}
297329
}
298330
}

0 commit comments

Comments
 (0)