Skip to content
Merged
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
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
)
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/ext/Activities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ fun Activity.matchesPaymentId(paymentHashOrTxId: String): Boolean = when (this)

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

fun Activity.doesExist() = this is Activity.Onchain && this.v1.doesExist

fun Activity.Onchain.boostType() = when (this.v1.txType) {
PaymentType.SENT -> BoostType.RBF
PaymentType.RECEIVED -> BoostType.CPFP
Expand Down
48 changes: 42 additions & 6 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ import javax.inject.Singleton

private const val SYNC_TIMEOUT_MS = 40_000L

@Suppress("LongParameterList")
@Singleton
@Suppress("LargeClass", "LongParameterList")
class ActivityRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val coreService: CoreService,
Expand Down Expand Up @@ -248,7 +248,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 @@ -262,21 +263,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 @@ -293,7 +296,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 @@ -434,9 +437,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 @@ -462,6 +472,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 @@ -455,11 +455,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 @@ -495,6 +505,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 @@ -3,7 +3,7 @@ package to.bitkit.ui.screens.wallets.activity.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
Expand All @@ -24,6 +24,7 @@ import com.synonym.bitkitcore.OnchainActivity
import com.synonym.bitkitcore.PaymentState
import com.synonym.bitkitcore.PaymentType
import to.bitkit.R
import to.bitkit.ext.doesExist
import to.bitkit.ext.isBoosted
import to.bitkit.ext.isFinished
import to.bitkit.ext.isTransfer
Expand All @@ -46,7 +47,7 @@ fun ActivityIcon(
val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received)

when {
activity.isBoosted() && !activity.isFinished() -> {
activity.isBoosted() && !activity.isFinished() && activity.doesExist() -> {
CircularIcon(
icon = painterResource(R.drawable.ic_timer_alt),
iconColor = Colors.Yellow,
Expand Down Expand Up @@ -90,9 +91,14 @@ fun ActivityIcon(
}
}

// onchain
else -> {
CircularIcon(
icon = if (activity.isTransfer()) painterResource(R.drawable.ic_transfer) else arrowIcon,
icon = when {
!activity.doesExist() -> painterResource(R.drawable.ic_x)
activity.isTransfer() -> painterResource(R.drawable.ic_transfer)
else -> arrowIcon
},
iconColor = Colors.Brand,
backgroundColor = Colors.Brand16,
size = size,
Expand Down Expand Up @@ -129,8 +135,8 @@ fun CircularIcon(
@Composable
private fun Preview() {
AppThemeSurface {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(16.dp),
) {
// Lightning Sent Succeeded
Expand Down Expand Up @@ -293,6 +299,32 @@ private fun Preview() {
)
)
)

// Onchain Removed
ActivityIcon(
activity = Activity.Onchain(
v1 = OnchainActivity(
id = "test-onchain-2",
txType = PaymentType.SENT,
txId = "abc123",
value = 100000uL,
fee = 500uL,
feeRate = 8uL,
address = "bc1...",
confirmed = true,
timestamp = (System.currentTimeMillis() / 1000).toULong(),
isBoosted = true,
boostTxIds = emptyList(),
isTransfer = false,
doesExist = false,
confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(),
channelId = null,
transferTxId = "transferTxId",
createdAt = null,
updatedAt = null,
)
)
)
}
}
}
Loading
Loading