@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import com.synonym.bitkitcore.Activity
66import com.synonym.bitkitcore.ActivityFilter
7+ import com.synonym.bitkitcore.OnchainActivity
78import com.synonym.bitkitcore.PaymentType
89import dagger.hilt.android.lifecycle.HiltViewModel
910import 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