Skip to content

Commit 59cfbcf

Browse files
authored
Merge pull request #278 from synonymdev/fix/boost-ux-improvements
Boost UX improvements
2 parents 43d847f + f42744e commit 59cfbcf

File tree

7 files changed

+249
-109
lines changed

7 files changed

+249
-109
lines changed

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import kotlinx.serialization.Serializable
55
@Serializable
66
data class PendingBoostActivity(
77
val txId: String,
8-
val feeRate: ULong,
9-
val fee: ULong,
108
val updatedAt: ULong,
119
val activityToDelete: String?
1210
)

app/src/main/java/to/bitkit/ext/Activities.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package to.bitkit.ext
22

33
import com.synonym.bitkitcore.Activity
4+
import com.synonym.bitkitcore.PaymentState
45
import com.synonym.bitkitcore.PaymentType
56

67
fun Activity.rawId(): String = when (this) {
@@ -19,7 +20,7 @@ fun Activity.rawId(): String = when (this) {
1920
*
2021
* @return The total value as an `ULong`.
2122
*/
22-
fun Activity.totalValue() = when(this) {
23+
fun Activity.totalValue() = when (this) {
2324
is Activity.Lightning -> v1.value + (v1.fee ?: 0u)
2425
is Activity.Onchain -> when (v1.txType) {
2526
PaymentType.SENT -> v1.value + v1.fee
@@ -37,6 +38,11 @@ fun Activity.isBoosted() = when (this) {
3738
else -> false
3839
}
3940

41+
fun Activity.isFinished() = when (this) {
42+
is Activity.Onchain -> v1.confirmed
43+
is Activity.Lightning -> v1.status != PaymentState.PENDING
44+
}
45+
4046
fun Activity.matchesPaymentId(paymentHashOrTxId: String): Boolean = when (this) {
4147
is Activity.Lightning -> paymentHashOrTxId == v1.id
4248
is Activity.Onchain -> paymentHashOrTxId == v1.txId

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,6 @@ class ActivityRepo @Inject constructor(
320320
val updatedActivity = Activity.Onchain(
321321
v1 = newOnChainActivity.v1.copy(
322322
isBoosted = true,
323-
feeRate = pendingBoostActivity.feeRate,
324-
fee = pendingBoostActivity.fee,
325323
updatedAt = pendingBoostActivity.updatedAt
326324
)
327325
)

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

Lines changed: 134 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import com.synonym.bitkitcore.Activity
66
import com.synonym.bitkitcore.ActivityFilter
7+
import com.synonym.bitkitcore.OnchainActivity
78
import com.synonym.bitkitcore.PaymentType
89
import dagger.hilt.android.lifecycle.HiltViewModel
910
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -81,9 +82,13 @@ class BoostTransactionViewModel @Inject constructor(
8182
PaymentType.RECEIVED -> lightningRepo.calculateCpfpFeeRate(activityContent.txId)
8283
}
8384

84-
// TODO ideally include utxos for a better fee estimate
85+
val sortedUtxos = lightningRepo.listSpendableOutputs()
86+
.getOrDefault(emptyList())
87+
.sortedByDescending { it.valueSats }
88+
8589
val totalFeeResult = lightningRepo.calculateTotalFee(
8690
amountSats = activityContent.value,
91+
utxosToSpend = sortedUtxos,
8792
speed = TransactionSpeed.Custom(feeRateResult.getOrDefault(0u).toUInt()),
8893
)
8994

@@ -256,81 +261,148 @@ class BoostTransactionViewModel @Inject constructor(
256261
}
257262
}
258263

264+
/**
265+
* Updates activity based on boost type:
266+
* - RBF: Updates current activity with boost data, then replaces with new transaction
267+
* - CPFP: Simply updates the current activity
268+
*/
259269
private suspend fun updateActivity(newTxId: Txid, isRBF: Boolean): Result<Unit> {
260-
Logger.debug("Updating activity for txId: $newTxId. isRBF:$isRBF", context = TAG)
270+
Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG)
271+
272+
val currentActivity = activity?.v1
273+
?: return Result.failure(Exception("Activity required"))
274+
275+
return if (isRBF) {
276+
handleRBFUpdate(newTxId, currentActivity)
277+
} else {
278+
handleCPFPUpdate(currentActivity)
279+
}
280+
}
261281

282+
/**
283+
* Handles CPFP (Child Pays For Parent) update by simply updating the current activity
284+
*/
285+
private suspend fun handleCPFPUpdate(currentActivity: OnchainActivity): Result<Unit> {
286+
val updatedActivity = Activity.Onchain(
287+
v1 = currentActivity.copy(
288+
isBoosted = true,
289+
updatedAt = nowTimestamp().toEpochMilli().toULong()
290+
)
291+
)
292+
293+
return activityRepo.updateActivity(
294+
id = updatedActivity.v1.id,
295+
activity = updatedActivity
296+
)
297+
}
298+
299+
/**
300+
* Handles RBF (Replace By Fee) update by updating current activity and replacing with new one
301+
*/
302+
private suspend fun handleRBFUpdate(
303+
newTxId: Txid,
304+
currentActivity: OnchainActivity,
305+
): Result<Unit> {
306+
// First update the current activity to show boost status
307+
val updatedCurrentActivity = Activity.Onchain(
308+
v1 = currentActivity.copy(
309+
isBoosted = true,
310+
feeRate = _uiState.value.feeRate,
311+
updatedAt = nowTimestamp().toEpochMilli().toULong()
312+
)
313+
)
314+
315+
activityRepo.updateActivity(
316+
id = updatedCurrentActivity.v1.id,
317+
activity = updatedCurrentActivity
318+
)
319+
320+
// Then find and replace with the new activity
321+
return findAndReplaceWithNewActivity(newTxId, currentActivity.id)
322+
}
323+
324+
/**
325+
* Finds the new activity and replaces the old one, handling failures gracefully
326+
*/
327+
private suspend fun findAndReplaceWithNewActivity(
328+
newTxId: Txid,
329+
oldActivityId: String,
330+
): Result<Unit> {
262331
return activityRepo.findActivityByPaymentId(
263332
paymentHashOrTxId = newTxId,
264333
type = ActivityFilter.ONCHAIN,
265334
txType = PaymentType.SENT
266335
).fold(
267336
onSuccess = { newActivity ->
268-
Logger.debug("Activity found: $newActivity", context = TAG)
269-
270-
val newOnChainActivity = newActivity as? Activity.Onchain
271-
?: return Result.failure(Exception("Activity is not onchain type"))
272-
273-
val updatedActivity = Activity.Onchain(
274-
v1 = newOnChainActivity.v1.copy(
275-
isBoosted = true,
276-
txId = newTxId,
277-
feeRate = _uiState.value.feeRate,
278-
fee = _uiState.value.totalFeeSats,
279-
updatedAt = nowTimestamp().toEpochMilli().toULong()
280-
)
281-
)
282-
283-
if (isRBF) {
284-
// For RBF, update new activity and delete old one
285-
activityRepo.replaceActivity(
286-
id = updatedActivity.v1.id,
287-
activityIdToDelete = activity?.v1?.id.orEmpty(),
288-
activity = updatedActivity,
289-
).onFailure {
290-
activityRepo.addActivityToPendingBoost(
291-
PendingBoostActivity(
292-
txId = newTxId,
293-
feeRate = _uiState.value.feeRate,
294-
fee = _uiState.value.totalFeeSats,
295-
updatedAt = nowTimestamp().toEpochMilli().toULong(),
296-
activityToDelete = activity?.v1?.id
297-
)
298-
)
299-
}
300-
} else {
301-
// For CPFP, just update the activity
302-
activityRepo.updateActivity(
303-
id = updatedActivity.v1.id,
304-
activity = updatedActivity
305-
).onFailure {
306-
activityRepo.addActivityToPendingBoost(
307-
PendingBoostActivity(
308-
txId = newTxId,
309-
feeRate = _uiState.value.feeRate,
310-
fee = _uiState.value.totalFeeSats,
311-
updatedAt = nowTimestamp().toEpochMilli().toULong(),
312-
activityToDelete = null
313-
)
314-
)
315-
}
316-
}
337+
replaceActivityWithNewOne(newActivity, oldActivityId, newTxId)
317338
},
318339
onFailure = { error ->
319-
Logger.error("Activity $newTxId not found. Caching data to try again on next sync", e = error, context = TAG)
320-
activityRepo.addActivityToPendingBoost(
321-
PendingBoostActivity(
322-
txId = newTxId,
323-
feeRate = _uiState.value.feeRate,
324-
fee = _uiState.value.totalFeeSats,
325-
updatedAt = nowTimestamp().toEpochMilli().toULong(),
326-
activityToDelete = activity?.v1?.id.takeIf { isRBF }
327-
)
328-
)
329-
Result.failure(error)
340+
handleActivityNotFound(error, newTxId, oldActivityId)
330341
}
331342
)
332343
}
333344

345+
/**
346+
* Replaces the old activity with the new boosted one
347+
*/
348+
private suspend fun replaceActivityWithNewOne(
349+
newActivity: Activity,
350+
oldActivityId: String,
351+
newTxId: Txid,
352+
): Result<Unit> {
353+
Logger.debug("Activity found: $newActivity", context = TAG)
354+
355+
val newOnChainActivity = newActivity as? Activity.Onchain
356+
?: return Result.failure(Exception("Activity is not onchain type"))
357+
358+
val updatedNewActivity = Activity.Onchain(
359+
v1 = newOnChainActivity.v1.copy(
360+
isBoosted = true,
361+
feeRate = _uiState.value.feeRate,
362+
updatedAt = nowTimestamp().toEpochMilli().toULong()
363+
)
364+
)
365+
366+
return activityRepo.replaceActivity(
367+
id = updatedNewActivity.v1.id,
368+
activityIdToDelete = oldActivityId,
369+
activity = updatedNewActivity,
370+
).onFailure {
371+
cachePendingBoostActivity(newTxId, oldActivityId)
372+
}
373+
}
374+
375+
/**
376+
* Handles the case when new activity is not found by caching for later retry
377+
*/
378+
private suspend fun handleActivityNotFound(
379+
error: Throwable,
380+
newTxId: Txid,
381+
oldActivityId: String?,
382+
): Result<Unit> {
383+
Logger.error(
384+
"Activity $newTxId not found. Caching data to try again on next sync",
385+
e = error,
386+
context = TAG
387+
)
388+
389+
cachePendingBoostActivity(newTxId, oldActivityId)
390+
return Result.failure(error)
391+
}
392+
393+
/**
394+
* Caches activity data for pending boost operation
395+
*/
396+
private suspend fun cachePendingBoostActivity(newTxId: Txid, activityToDelete: String?) {
397+
activityRepo.addActivityToPendingBoost(
398+
PendingBoostActivity(
399+
txId = newTxId,
400+
updatedAt = nowTimestamp().toEpochMilli().toULong(),
401+
activityToDelete = activityToDelete
402+
)
403+
)
404+
}
405+
334406
private fun handleError(message: String, error: Throwable? = null) {
335407
Logger.error(message, error, context = TAG)
336408
_uiState.update {

0 commit comments

Comments
 (0)