diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 114784b05..e9e209645 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -83,22 +83,6 @@ class CacheStore @Inject constructor( } } - suspend fun addActivityToPendingDelete(activityId: String) { - if (activityId.isBlank()) return - if (activityId in store.data.first().activitiesPendingDelete) return - store.updateData { - it.copy(activitiesPendingDelete = it.activitiesPendingDelete + activityId) - } - } - - suspend fun removeActivityFromPendingDelete(activityId: String) { - if (activityId.isBlank()) return - if (activityId !in store.data.first().activitiesPendingDelete) return - store.updateData { - it.copy(activitiesPendingDelete = it.activitiesPendingDelete - activityId) - } - } - suspend fun addActivityToPendingBoost(pendingBoostActivity: PendingBoostActivity) { if (pendingBoostActivity in store.data.first().pendingBoostActivities) return store.updateData { @@ -137,7 +121,6 @@ data class AppCacheData( val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), - val activitiesPendingDelete: List = listOf(), val lastLightningPaymentId: String? = null, val pendingBoostActivities: List = listOf(), ) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 206e3276d..c27001d83 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -39,7 +39,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( is NotifyPaymentReceived.Command.Lightning -> true is NotifyPaymentReceived.Command.Onchain -> { delay(DELAY_FOR_ACTIVITY_SYNC_MS) - activityRepo.shouldShowPaymentReceived(command.paymentHashOrTxId, command.sats) + activityRepo.shouldShowReceivedSheet(command.paymentHashOrTxId, command.sats) } } diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index c7c1e60b2..73153cf40 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -33,11 +33,6 @@ fun Activity.totalValue() = when (this) { } } -fun Activity.canBeBoosted() = when (this) { - is Activity.Onchain -> !v1.confirmed && v1.doesExist && !v1.isBoosted && !v1.isTransfer && v1.value > 0uL - else -> false -} - fun Activity.isBoosted() = when (this) { is Activity.Onchain -> v1.isBoosted else -> false diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 0369d9e3a..0f7b6382c 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -26,6 +26,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher @@ -36,7 +37,6 @@ import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.models.ActivityBackupV1 import to.bitkit.services.CoreService -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -50,7 +50,6 @@ class ActivityRepo @Inject constructor( private val coreService: CoreService, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, - private val addressChecker: AddressChecker, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val clock: Clock, @@ -83,8 +82,6 @@ class ActivityRepo @Inject constructor( isSyncingLdkNodePayments.update { true } - deletePendingActivities() - lightningRepo.getPayments().mapCatching { payments -> Logger.debug("Got payments with success, syncing activities", context = TAG) syncLdkNodePayments(payments).getOrThrow() @@ -167,67 +164,13 @@ class ActivityRepo @Inject constructor( } private suspend fun findClosedChannelForTransaction(txid: String): String? { - return try { - val closedChannelsResult = getClosedChannels(SortDirection.DESC) - val closedChannels = closedChannelsResult.getOrNull() ?: return null - if (closedChannels.isEmpty()) return null - - val txDetails = addressChecker.getTransaction(txid) - - txDetails.vin.firstNotNullOfOrNull { input -> - val inputTxid = input.txid ?: return@firstNotNullOfOrNull null - val inputVout = input.vout ?: return@firstNotNullOfOrNull null - - closedChannels.firstOrNull { channel -> - channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() - }?.channelId - } - } catch (e: Exception) { - Logger.warn( - "Failed to check if transaction $txid spends closed channel funding UTXO", - e, - context = TAG - ) - null - } + return coreService.activity.findClosedChannelForTransaction(txid, null) } - private suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? { + suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? { return coreService.activity.getOnchainActivityByTxId(txid) } - /** - * Determines whether to show the payment received UI for an onchain transaction. - * Returns false for: - * - Zero value transactions - * - Channel closure transactions (transfers to savings) - * - RBF replacement transactions with the same value as the original - */ - suspend fun shouldShowPaymentReceived(txid: String, value: ULong): Boolean = withContext(bgDispatcher) { - if (value == 0uL) return@withContext false - - if (findClosedChannelForTransaction(txid) != null) { - Logger.debug("Skipping payment received UI for channel closure tx: $txid", context = TAG) - return@withContext false - } - - val onchainActivity = getOnchainActivityByTxId(txid) - if (onchainActivity != null && onchainActivity.boostTxIds.isNotEmpty()) { - for (replacedTxid in onchainActivity.boostTxIds) { - val replacedActivity = getOnchainActivityByTxId(replacedTxid) - if (replacedActivity != null && replacedActivity.value == value) { - Logger.info( - "Skipping payment received UI for RBF replacement $txid with same value as $replacedTxid", - context = TAG - ) - return@withContext false - } - } - } - - return@withContext true - } - /** * Checks if a transaction is inbound (received) by looking up the payment direction. */ @@ -247,6 +190,58 @@ class ActivityRepo @Inject constructor( return@withContext !onchainActivity.doesExist } + suspend fun handleOnchainTransactionReceived( + txid: String, + details: TransactionDetails, + ) { + coreService.activity.handleOnchainTransactionReceived(txid, details) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionConfirmed( + txid: String, + details: TransactionDetails, + ) { + coreService.activity.handleOnchainTransactionConfirmed(txid, details) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { + coreService.activity.handleOnchainTransactionReplaced(txid, conflicts) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionReorged(txid: String) { + coreService.activity.handleOnchainTransactionReorged(txid) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionEvicted(txid: String) { + coreService.activity.handleOnchainTransactionEvicted(txid) + notifyActivitiesChanged() + } + + suspend fun handlePaymentEvent(paymentHash: String) { + coreService.activity.handlePaymentEvent(paymentHash) + notifyActivitiesChanged() + } + + suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean { + return coreService.activity.shouldShowReceivedSheet(txid, value) + } + + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + return coreService.activity.getBoostTxDoesExist(boostTxIds) + } + + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return coreService.activity.isCpfpChildTransaction(txId) + } + + suspend fun getTxIdsInBoostTxIds(): Set { + return coreService.activity.getTxIdsInBoostTxIds() + } + /** * Gets a specific activity by payment hash or txID with retry logic */ @@ -392,22 +387,13 @@ class ActivityRepo @Inject constructor( ).fold( onSuccess = { Logger.debug( - "Activity $id updated with success. new data: $activity. " + - "Marking activity $activityIdToDelete as removed from mempool", + "Activity $id updated with success. new data: $activity", context = TAG ) val tags = coreService.activity.tags(activityIdToDelete) addTagsToActivity(activityId = id, tags = tags) - markActivityAsRemovedFromMempool(activityIdToDelete).onFailure { e -> - Logger.warn( - "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 -> @@ -422,16 +408,6 @@ class ActivityRepo @Inject constructor( ) } - private suspend fun deletePendingActivities() = withContext(bgDispatcher) { - cacheStore.data.first().activitiesPendingDelete.map { activityId -> - async { - markActivityAsRemovedFromMempool(activityId).onSuccess { - cacheStore.removeActivityFromPendingDelete(activityId) - } - } - }.awaitAll() - } - private suspend fun boostPendingActivities() = withContext(bgDispatcher) { cacheStore.data.first().pendingBoostActivities.map { pendingBoostActivity -> async { @@ -484,32 +460,6 @@ 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 = 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 */ diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2e5ff6586..44badaab7 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -35,6 +35,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.Txid import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -717,6 +718,18 @@ class LightningRepo @Inject constructor( Result.success(payments) } + suspend fun getTransactionDetails(txid: Txid): Result = executeWhenNodeRunning( + "Get transaction details by txid" + ) { + Result.success(lightningService.getTransactionDetails(txid)) + } + + suspend fun getAddressBalance(address: String): Result = executeWhenNodeRunning("Get address balance") { + runCatching { + lightningService.getAddressBalance(address) + } + } + suspend fun listSpendableOutputs(): Result> = executeWhenNodeRunning("List spendable outputs") { lightningService.listSpendableOutputs() } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index ec281e9f1..5f0386aef 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -29,7 +29,6 @@ import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -43,7 +42,6 @@ class WalletRepo @Inject constructor( private val keychain: Keychain, private val coreService: CoreService, private val settingsStore: SettingsStore, - private val addressChecker: AddressChecker, private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, private val preActivityMetadataRepo: PreActivityMetadataRepo, @@ -83,9 +81,8 @@ class WalletRepo @Inject constructor( suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { return@withContext try { - val addressInfo = addressChecker.getAddressInfo(address) - val hasTransactions = addressInfo.chain_stats.tx_count > 0 || addressInfo.mempool_stats.tx_count > 0 - Result.success(hasTransactions) + val result = coreService.isAddressUsed(address) + Result.success(result) } catch (e: Exception) { Logger.error("checkAddressUsage error", e, context = TAG) Result.failure(e) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index f19f7f1f2..76c621cf2 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -57,22 +57,22 @@ import io.ktor.http.HttpStatusCode import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.first +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.models.toCoreNetwork -import to.bitkit.utils.AddressChecker import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError -import to.bitkit.utils.TxDetails import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -84,7 +84,6 @@ class CoreService @Inject constructor( private val lightningService: LightningService, private val httpClient: HttpClient, private val cacheStore: CacheStore, - private val addressChecker: AddressChecker, ) { private var walletIndex: Int = 0 @@ -92,7 +91,6 @@ class CoreService @Inject constructor( ActivityService( coreService = this, cacheStore = cacheStore, - addressChecker = addressChecker, lightningService = lightningService ) } @@ -190,6 +188,12 @@ class CoreService @Inject constructor( } } + suspend fun isAddressUsed(address: String): Boolean { + return ServiceQueue.CORE.background { + com.synonym.bitkitcore.isAddressUsed(address = address) + } + } + companion object { private const val TAG = "CoreService" } @@ -200,10 +204,10 @@ class CoreService @Inject constructor( // region Activity private const val CHUNK_SIZE = 50 +@Suppress("LargeClass") class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, - private val addressChecker: AddressChecker, private val lightningService: LightningService, ) { suspend fun removeAll() { @@ -362,20 +366,27 @@ class ActivityService( getAllClosedChannels(sortDirection) } - /** - * Maps all `PaymentDetails` from LDK Node to bitkit-core [Activity] records. - * - * Payments are parallelly processed in chunks, handling both on-chain and Lightning payments - * to create new activity records or updating existing ones based on the payment's status and details. - * - * It's designed to be idempotent, meaning it can be called multiple times with the same payment - * list without creating duplicate entries. It checks the `updatedAt` timestamp to avoid overwriting - * newer local data with older data from LDK. - * - * @param payments The list of `PaymentDetails` from the LDK node to be processed. - * @param forceUpdate If true, it will also update activities previously marked as deleted. - * @param channelIdsByTxId Map of transaction IDs to channel IDs for identifying transfer activities. - */ + suspend fun handlePaymentEvent(paymentHash: String) { + ServiceQueue.CORE.background { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for hash $paymentHash", context = TAG) + return@background + } + + val payment = payments.firstOrNull { it.id == paymentHash } + if (payment != null) { + // Lightning payments don't need channel IDs, only onchain payments do + val channelIdsByTxId = emptyMap() + processSinglePayment(payment, forceUpdate = false, channelIdsByTxId = channelIdsByTxId) + } else { + Logger.info("Payment not found for hash $paymentHash - syncing all payments", context = TAG) + // For full sync, we need channel IDs for onchain payments + // This will be handled by ActivityRepo.syncLdkNodePayments which calls findChannelsForPayments + syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = emptyMap()) + } + } + } + suspend fun syncLdkNodePaymentsToActivities( payments: List, forceUpdate: Boolean = false, @@ -488,10 +499,15 @@ class ActivityService( * Check pre-activity metadata for addresses in the transaction * Returns the first address found in pre-activity metadata that matches a transaction output */ - private suspend fun findAddressInPreActivityMetadata(txDetails: TxDetails): String? { - for (output in txDetails.vout) { - val address = output.scriptpubkey_address ?: continue - val metadata = coreService.activity.getPreActivityMetadata(searchKey = address, searchByAddress = true) + private suspend fun findAddressInPreActivityMetadata( + details: TransactionDetails + ): String? { + for (output in details.outputs) { + val address = output.scriptpubkeyAddress ?: continue + val metadata = coreService.activity.getPreActivityMetadata( + searchKey = address, + searchByAddress = true + ) if (metadata != null && metadata.isReceive) { return address } @@ -503,22 +519,20 @@ class ActivityService( kind: PaymentKind.Onchain, existingActivity: Activity?, payment: PaymentDetails, + transactionDetails: TransactionDetails? = null, ): String? { if (existingActivity != null || payment.direction != PaymentDirection.INBOUND) { return null } - return try { - val txDetails = addressChecker.getTransaction(kind.txid) - findAddressInPreActivityMetadata(txDetails) - } catch (e: Exception) { - Logger.verbose( - "Failed to get transaction details for address lookup: ${kind.txid}", - e, - context = TAG - ) - null + // Get transaction details if not provided + val details = transactionDetails ?: lightningService.getTransactionDetails(kind.txid) + if (details == null) { + Logger.verbose("Transaction details not available for txid: ${kind.txid}", context = TAG) + return null } + + return findAddressInPreActivityMetadata(details) } private data class ConfirmationData( @@ -527,7 +541,7 @@ class ActivityService( val timestamp: ULong, ) - private suspend fun getConfirmationStatus( + private fun getConfirmationStatus( kind: PaymentKind.Onchain, timestamp: ULong, ): ConfirmationData { @@ -547,15 +561,11 @@ class ActivityService( return ConfirmationData(isConfirmed, confirmedTimestamp, timestamp) } - private suspend fun buildUpdatedOnchainActivity( + private fun buildUpdatedOnchainActivity( existingActivity: Activity.Onchain, confirmationData: ConfirmationData, - txid: String, channelId: String? = null, ): OnchainActivity { - val wasRemoved = !existingActivity.v1.doesExist - val shouldRestore = wasRemoved && confirmationData.isConfirmed - var preservedIsTransfer = existingActivity.v1.isTransfer var preservedChannelId = existingActivity.v1.channelId @@ -564,23 +574,21 @@ class ActivityService( preservedIsTransfer = true } + val finalDoesExist = if (confirmationData.isConfirmed) true else existingActivity.v1.doesExist + val updatedOnChain = existingActivity.v1.copy( confirmed = confirmationData.isConfirmed, confirmTimestamp = confirmationData.confirmedTimestamp, - doesExist = if (shouldRestore) true else existingActivity.v1.doesExist, + doesExist = finalDoesExist, updatedAt = confirmationData.timestamp, isTransfer = preservedIsTransfer, channelId = preservedChannelId, ) - if (wasRemoved && confirmationData.isConfirmed) { - markReplacementTransactionsAsRemoved(originalTxId = txid) - } - return updatedOnChain } - private suspend fun buildNewOnchainActivity( + private fun buildNewOnchainActivity( payment: PaymentDetails, kind: PaymentKind.Onchain, confirmationData: ConfirmationData, @@ -616,6 +624,7 @@ class ActivityService( payment: PaymentDetails, forceUpdate: Boolean, channelId: String? = null, + transactionDetails: TransactionDetails? = null, ) { val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) @@ -628,14 +637,27 @@ class ActivityService( return } - val resolvedAddress = resolveAddressForInboundPayment(kind, existingActivity, payment) + var resolvedChannelId = channelId + + // Check if this transaction is a channel transfer + if (resolvedChannelId == null) { + val foundChannelId = findChannelForTransaction( + txid = kind.txid, + direction = payment.direction, + transactionDetails = transactionDetails + ) + if (foundChannelId != null) { + resolvedChannelId = foundChannelId + } + } + + val resolvedAddress = resolveAddressForInboundPayment(kind, existingActivity, payment, transactionDetails) val onChain = if (existingActivity is Activity.Onchain) { buildUpdatedOnchainActivity( existingActivity = existingActivity, confirmationData = confirmationData, - txid = kind.txid, - channelId = channelId, + channelId = resolvedChannelId, ) } else { buildNewOnchainActivity( @@ -643,7 +665,7 @@ class ActivityService( kind = kind, confirmationData = confirmationData, resolvedAddress = resolvedAddress, - channelId = channelId, + channelId = resolvedChannelId, ) } @@ -659,54 +681,6 @@ 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 @@ -805,6 +779,394 @@ class ActivityService( } } + suspend fun handleOnchainTransactionReceived(txid: String, details: TransactionDetails) { + ServiceQueue.CORE.background { + runCatching { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for transaction $txid", context = TAG) + return@background + } + + val payment = payments.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == txid + } ?: run { + Logger.warn("Payment not found for transaction $txid", context = TAG) + return@background + } + + processOnchainPayment( + kind = payment.kind as PaymentKind.Onchain, + payment = payment, + forceUpdate = false, + channelId = null, + transactionDetails = details, + ) + }.onFailure { e -> + Logger.error("Error handling onchain transaction received for $txid", e, context = TAG) + } + } + } + + suspend fun handleOnchainTransactionConfirmed(txid: String, details: TransactionDetails) { + ServiceQueue.CORE.background { + runCatching { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for transaction $txid", context = TAG) + return@background + } + + val payment = payments.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == txid + } ?: run { + Logger.warn("Payment not found for transaction $txid", context = TAG) + return@background + } + + processOnchainPayment( + kind = payment.kind as PaymentKind.Onchain, + payment = payment, + forceUpdate = false, + channelId = null, + transactionDetails = details, + ) + }.onFailure { e -> + Logger.error("Error handling onchain transaction confirmed for $txid", e, context = TAG) + } + } + } + + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { + ServiceQueue.CORE.background { + runCatching { + val replacedActivity = getOnchainActivityByTxId(txid) + markReplacedActivity(txid, replacedActivity, conflicts) + + for (conflictTxid in conflicts) { + val replacementActivity = getOrCreateReplacementActivity(conflictTxid) + if (replacementActivity != null && !replacementActivity.boostTxIds.contains(txid)) { + updateReplacementActivity(txid, conflictTxid, replacementActivity, replacedActivity) + } + } + }.onFailure { e -> + Logger.error("Error handling onchain transaction replaced for $txid", e, context = TAG) + } + } + } + + private fun markReplacedActivity( + txid: String, + replacedActivity: OnchainActivity?, + conflicts: List, + ) { + if (replacedActivity != null) { + Logger.info( + "Transaction $txid replaced by ${conflicts.size} conflict(s): " + + conflicts.joinToString(", "), + context = TAG + ) + + val updatedActivity = replacedActivity.copy( + doesExist = false, + isBoosted = false, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + updateActivity(replacedActivity.id, Activity.Onchain(updatedActivity)) + Logger.info("Marked transaction $txid as replaced", context = TAG) + } else { + Logger.info( + "Activity not found for replaced transaction $txid - " + + "will be created when transaction is processed", + context = TAG + ) + } + } + + private suspend fun getOrCreateReplacementActivity(conflictTxid: String): OnchainActivity? { + var replacementActivity = getOnchainActivityByTxId(conflictTxid) + + if (replacementActivity == null) { + val payments = lightningService.payments + val replacementPayment = payments?.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == conflictTxid + } + + if (replacementPayment != null) { + Logger.info( + "Processing replacement transaction $conflictTxid that was already in payments list", + context = TAG + ) + val processResult = runCatching { + processOnchainPayment( + kind = replacementPayment.kind as PaymentKind.Onchain, + payment = replacementPayment, + forceUpdate = false, + channelId = null, + ) + getOnchainActivityByTxId(conflictTxid) + } + processResult.onFailure { e -> + Logger.error( + "Failed to process replacement transaction $conflictTxid", + e, + context = TAG + ) + } + replacementActivity = processResult.getOrNull() + } + } + + return replacementActivity + } + + private suspend fun updateReplacementActivity( + txid: String, + conflictTxid: String, + replacementActivity: OnchainActivity, + replacedActivity: OnchainActivity?, + ) { + val updatedActivity = replacementActivity.copy( + boostTxIds = replacementActivity.boostTxIds + txid, + isBoosted = true, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity)) + + if (replacedActivity != null) { + copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) + } + + Logger.info("Updated replacement transaction $conflictTxid with boostTxId $txid", context = TAG) + } + + private suspend fun copyTagsFromReplacedActivity( + txid: String, + conflictTxid: String, + replacedActivityId: String, + replacementActivityId: String, + ) { + runCatching { + val replacedTags = tags(replacedActivityId) + if (replacedTags.isNotEmpty()) { + appendTags(replacementActivityId, replacedTags) + } + }.onFailure { e -> + Logger.error( + "Failed to copy tags from replaced transaction $txid " + + "to replacement transaction $conflictTxid", + e, + context = TAG + ) + } + } + + suspend fun handleOnchainTransactionReorged(txid: String) { + ServiceQueue.CORE.background { + runCatching { + val onchain = getOnchainActivityByTxId(txid) ?: run { + Logger.warn("Activity not found for reorged transaction $txid", context = TAG) + return@background + } + + val updatedActivity = onchain.copy( + confirmed = false, + confirmTimestamp = null, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + + updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + }.onFailure { e -> + Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) + } + } + } + + suspend fun handleOnchainTransactionEvicted(txid: String) { + ServiceQueue.CORE.background { + runCatching { + val onchain = getOnchainActivityByTxId(txid) ?: run { + Logger.warn("Activity not found for evicted transaction $txid", context = TAG) + return@background + } + + val updatedActivity = onchain.copy( + doesExist = false, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + + updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + }.onFailure { e -> + Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) + } + } + } + + suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean { + return ServiceQueue.CORE.background { + if (value == 0uL) { + return@background false + } + + if (findClosedChannelForTransaction(txid, null) != null) { + return@background false + } + + runCatching { + val onchain = getOnchainActivityByTxId(txid) ?: return@background true + + if (onchain.boostTxIds.isEmpty()) { + return@background true + } + + for (replacedTxid in onchain.boostTxIds) { + val replaced = getOnchainActivityByTxId(replacedTxid) + if (replaced != null && replaced.value == value) { + Logger.info( + "Skipping received sheet for replacement transaction $txid " + + "with same value as replaced transaction $replacedTxid", + context = TAG + ) + return@background false + } + } + }.onFailure { e -> + Logger.error("Failed to check existing activities for replacement", e, context = TAG) + } + + return@background true + } + } + + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + return ServiceQueue.CORE.background { + val doesExistMap = mutableMapOf() + for (boostTxId in boostTxIds) { + val boostActivity = getOnchainActivityByTxId(boostTxId) + if (boostActivity != null) { + doesExistMap[boostTxId] = boostActivity.doesExist + } + } + return@background doesExistMap + } + } + + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return ServiceQueue.CORE.background { + val txIdsInBoostTxIds = getTxIdsInBoostTxIds() + if (!txIdsInBoostTxIds.contains(txId)) { + return@background false + } + + val activity = getOnchainActivityByTxId(txId) ?: return@background false + return@background activity.doesExist + } + } + + suspend fun getTxIdsInBoostTxIds(): Set { + return ServiceQueue.CORE.background { + val allOnchainActivities = get( + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ) + + allOnchainActivities + .filterIsInstance() + .flatMap { it.v1.boostTxIds } + .toSet() + } + } + + private suspend fun findChannelForTransaction( + txid: String, + direction: PaymentDirection, + transactionDetails: TransactionDetails? = null, + ): String? { + return when (direction) { + PaymentDirection.INBOUND -> { + // Check if this transaction is a channel close by checking if it spends + // a closed channel's funding UTXO + findClosedChannelForTransaction(txid, transactionDetails) + } + PaymentDirection.OUTBOUND -> { + // Check if this transaction is a channel open by checking if it's + // the funding transaction for an open channel + findOpenChannelForTransaction(txid) + } + } + } + + private suspend fun findOpenChannelForTransaction(txid: String): String? { + val channels = lightningService.channels ?: return null + if (channels.isEmpty()) return null + + // First, check if the transaction matches any channel's funding transaction directly + val directMatch = channels.firstOrNull { channel -> + channel.fundingTxo?.txid == txid + } + if (directMatch != null) { + return directMatch.channelId + } + + // If no direct match, check Blocktank orders for payment transactions + return findChannelFromBlocktankOrders(txid, channels) + } + + private suspend fun findChannelFromBlocktankOrders( + txid: String, + channels: List, + ): String? { + return runCatching { + val blocktank = coreService.blocktank + val orders = blocktank.orders(orderIds = null, filter = null, refresh = false) + val matchingOrder = orders.firstOrNull { order -> + order.payment?.onchain?.transactions?.any { transaction -> transaction.txId == txid } == true + } ?: return null + + val orderChannel = matchingOrder.channel ?: return null + channels.firstOrNull { channel -> + channel.fundingTxo?.txid == orderChannel.fundingTx.id + }?.channelId + }.onFailure { e -> + Logger.warn("Failed to fetch Blocktank orders: $e", context = TAG) + }.getOrNull() + } + + suspend fun findClosedChannelForTransaction(txid: String, transactionDetails: TransactionDetails? = null): String? { + return runCatching { + val closedChannelsList = closedChannels(SortDirection.DESC) + if (closedChannelsList.isEmpty()) { + return null + } + + // Use provided transaction details if available, otherwise try node + val details = transactionDetails ?: lightningService.getTransactionDetails(txid) ?: run { + Logger.warn("Transaction details not available for $txid", context = TAG) + return null + } + + for (input in details.inputs) { + val inputTxid = input.txid + val inputVout = input.vout.toInt() + + val matchingChannel = closedChannelsList.firstOrNull { channel -> + channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() + } + + if (matchingChannel != null) { + return matchingChannel.channelId + } + } + null + }.onFailure { e -> + Logger.warn("Failed to check if transaction $txid spends closed channel funding UTXO", e, context = TAG) + }.getOrNull() + } + companion object { private const val TAG = "ActivityService" } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index cca66765b..ea0d559ef 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -33,6 +33,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope @@ -694,6 +695,33 @@ class LightningService @Inject constructor( } // endregion + // region transaction details + suspend fun getTransactionDetails(txid: Txid): TransactionDetails? { + val node = this.node ?: return null + return ServiceQueue.LDK.background { + try { + node.getTransactionDetails(txid) + } catch (e: Exception) { + Logger.error("Error getting transaction details by txid: $txid", e, context = TAG) + null + } + } + } + + suspend fun getAddressBalance(address: String): ULong { + val node = this.node ?: throw ServiceError.NodeNotSetup + return ServiceQueue.LDK.background { + try { + node.getAddressBalance(addressStr = address) + } catch (e: Exception) { + Logger.error("Error getting address balance for address: $address", e, context = TAG) + throw e + } + } + } + + // endregion + // region state val nodeId: String? get() = node?.nodeId() val balances: BalanceDetails? get() = node?.listBalances() diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a6d4b9139..89e5f5c5b 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -320,11 +320,6 @@ fun ContentView( val balance by walletViewModel.balanceState.collectAsStateWithLifecycle() val currencies by currencyViewModel.uiState.collectAsState() - LaunchedEffect(balance) { - // Anytime we receive a balance update, we should sync the payments to activity list - activityListViewModel.resync() - } - // Keep backups in sync LaunchedEffect(backupsViewModel) { backupsViewModel.observeAndSyncBackups() } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index a8ea25a6b..064c0f6aa 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -31,7 +31,6 @@ import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import javax.inject.Inject @@ -45,7 +44,6 @@ class ExternalNodeViewModel @Inject constructor( private val settingsStore: SettingsStore, private val transferRepo: to.bitkit.repositories.TransferRepo, private val preActivityMetadataRepo: to.bitkit.repositories.PreActivityMetadataRepo, - private val addressChecker: AddressChecker, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -171,8 +169,9 @@ class ExternalNodeViewModel @Inject constructor( channelAmountSats = _uiState.value.amount.sats.toULong(), ).mapCatching { result -> awaitChannelPendingEvent(result.userChannelId).mapCatching { event -> - val txId = event.fundingTxo.txid - val address = addressChecker.getOutputAddress(event.fundingTxo).getOrDefault("") + val (txId, vout) = event.fundingTxo + val transactionDetails = lightningRepo.getTransactionDetails(txId).getOrNull() + val address = transactionDetails?.outputs?.getOrNull(vout.toInt())?.scriptpubkeyAddress ?: "" val feeRate = _uiState.value.customFeeRate ?: 0u preActivityMetadataRepo.savePreActivityMetadata( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 2d8473f7f..aaa109967 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,9 +45,7 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.canBeBoosted import to.bitkit.ext.ellipsisMiddle -import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId @@ -103,9 +102,29 @@ fun ActivityDetailScreen( val tags by detailViewModel.tags.collectAsStateWithLifecycle() val boostSheetVisible by detailViewModel.boostSheetVisible.collectAsStateWithLifecycle() var showAddTagSheet by remember { mutableStateOf(false) } + var isCpfpChild by remember { mutableStateOf(false) } + var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { detailViewModel.setActivity(item) + if (item is Activity.Onchain) { + isCpfpChild = detailViewModel.isCpfpChildTransaction(item.v1.txId) + boostTxDoesExist = if (item.v1.boostTxIds.isNotEmpty()) { + detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + } else { + emptyMap() + } + } else { + isCpfpChild = false + boostTxDoesExist = emptyMap() + } + } + + // Update boostTxDoesExist when boostTxIds change + LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else emptyList()) { + if (item is Activity.Onchain && item.v1.boostTxIds.isNotEmpty()) { + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + } } val context = LocalContext.current @@ -117,7 +136,13 @@ fun ActivityDetailScreen( modifier = Modifier.background(Colors.Black) ) { AppTopBar( - titleText = stringResource(item.getScreenTitleRes()), + titleText = stringResource( + if (isCpfpChild) { + R.string.wallet__activity_boost_fee + } else { + item.getScreenTitleRes() + } + ), onBackClick = onBackClick, actions = { CloseNavIcon(onClick = onCloseClick) }, ) @@ -130,6 +155,8 @@ fun ActivityDetailScreen( onExploreClick = onExploreClick, onChannelClick = onChannelClick, detailViewModel = detailViewModel, + isCpfpChild = isCpfpChild, + boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( type = Toast.ToastType.SUCCESS, @@ -200,6 +227,8 @@ private fun ActivityDetailContent( onExploreClick: (String) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, + isCpfpChild: Boolean = false, + boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit, ) { val isLightning = item is Activity.Lightning @@ -273,7 +302,11 @@ private fun ActivityDetailContent( useSwipeToHide = false, modifier = Modifier.weight(1f) ) - ActivityIcon(activity = item, size = 48.dp) // TODO Display the user avatar when selfSend + ActivityIcon( + activity = item, + size = 48.dp, + isCpfpChild = isCpfpChild + ) // TODO Display the user avatar when selfSend } Spacer(modifier = Modifier.height(16.dp)) @@ -505,9 +538,28 @@ private fun ActivityDetailContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { + val hasCompletedBoost = when (item) { + is Activity.Lightning -> false + is Activity.Onchain -> { + val activity = item.v1 + if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { + val hasCPFP = activity.boostTxIds.any { boostTxDoesExist[it] == true } + if (hasCPFP) { + true + } else if (activity.txType == PaymentType.SENT) { + activity.boostTxIds.any { boostTxDoesExist[it] == false } + } else { + false + } + } else { + false + } + } + } + val shouldEnable = shouldEnableBoostButton(item, isCpfpChild, boostTxDoesExist) PrimaryButton( text = stringResource( - if (item.isBoosted()) { + if (hasCompletedBoost) { R.string.wallet__activity_boosted } else { R.string.wallet__activity_boost @@ -515,7 +567,7 @@ private fun ActivityDetailContent( ), size = ButtonSize.Small, onClick = onClickBoost, - enabled = item.canBeBoosted(), + enabled = shouldEnable, icon = { Icon( painter = painterResource(R.drawable.ic_timer_alt), @@ -528,8 +580,8 @@ private fun ActivityDetailContent( .weight(1f) .testTag( when { - item.isBoosted() -> "BoostedButton" - item.canBeBoosted() -> "BoostButton" + hasCompletedBoost -> "BoostedButton" + shouldEnable -> "BoostButton" else -> "BoostDisabled" } ) @@ -811,3 +863,46 @@ private fun PreviewSheetSmallScreen() { } } } + +@ReadOnlyComposable +@Composable +private fun shouldEnableBoostButton( + item: Activity, + isCpfpChild: Boolean, + boostTxDoesExist: Map, +): Boolean { + if (item !is Activity.Onchain) return false + + val activity = item.v1 + + // Check all disable conditions + val shouldDisable = isCpfpChild || !activity.doesExist || activity.confirmed || + (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) + + if (shouldDisable) return false + + // Enable if not a transfer and has value + return !activity.isTransfer && activity.value > 0uL +} + +@ReadOnlyComposable +@Composable +private fun isBoostCompleted( + activity: OnchainActivity, + boostTxDoesExist: Map, +): Boolean { + // If boostTxIds is empty, boost is in progress (RBF case) + if (activity.boostTxIds.isEmpty()) return true + + // Check if CPFP boost is completed + val hasCPFP = activity.boostTxIds.any { boostTxDoesExist[it] == true } + if (hasCPFP) return true + + // For sent transactions, check if RBF boost is completed + if (activity.txType == PaymentType.SENT) { + val hasRBF = activity.boostTxIds.any { boostTxDoesExist[it] == false } + if (hasRBF) return true + } + + return false +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index c93303fbf..b1ed51e92 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -19,6 +19,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -35,9 +38,8 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.R -import to.bitkit.ext.BoostType -import to.bitkit.ext.boostType import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent @@ -61,7 +63,6 @@ import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.ui.utils.localizedPlural -import to.bitkit.utils.TxDetails import to.bitkit.viewmodels.ActivityDetailViewModel import to.bitkit.viewmodels.ActivityListViewModel @@ -80,10 +81,14 @@ fun ActivityExploreScreen( val context = LocalContext.current val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() + var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { if (item is Activity.Onchain) { detailViewModel.fetchTransactionDetails(item.v1.txId) + if (item.v1.boostTxIds.isNotEmpty()) { + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + } } else { detailViewModel.clearTransactionDetails() } @@ -104,6 +109,7 @@ fun ActivityExploreScreen( ActivityExploreContent( item = item, txDetails = txDetails, + boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( type = Toast.ToastType.SUCCESS, @@ -123,7 +129,8 @@ fun ActivityExploreScreen( @Composable private fun ActivityExploreContent( item: Activity, - txDetails: TxDetails? = null, + txDetails: TransactionDetails? = null, + boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, onClickExplore: (String) -> Unit = {}, ) { @@ -156,6 +163,7 @@ private fun ActivityExploreContent( onchain = item, onCopy = onCopy, txDetails = txDetails, + boostTxDoesExist = boostTxDoesExist, ) Spacer(modifier = Modifier.weight(1f)) PrimaryButton( @@ -216,7 +224,8 @@ private fun LightningDetails( private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, onCopy: (String) -> Unit, - txDetails: TxDetails?, + txDetails: TransactionDetails?, + boostTxDoesExist: Map = emptyMap(), ) { val txId = onchain.v1.txId Section( @@ -232,22 +241,22 @@ private fun ColumnScope.OnchainDetails( ) if (txDetails != null) { Section( - title = localizedPlural(R.string.wallet__activity_input, mapOf("count" to txDetails.vin.size)), + title = localizedPlural(R.string.wallet__activity_input, mapOf("count" to txDetails.inputs.size)), valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - txDetails.vin.forEach { input -> + txDetails.inputs.forEach { input -> val text = "${input.txid}:${input.vout}" - BodySSB(text = text) + BodySSB(text = text, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } }, ) Section( - title = localizedPlural(R.string.wallet__activity_output, mapOf("count" to txDetails.vout.size)), + title = localizedPlural(R.string.wallet__activity_output, mapOf("count" to txDetails.outputs.size)), valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - txDetails.vout.forEach { output -> - val address = output.scriptpubkey_address.orEmpty() + txDetails.outputs.forEach { output -> + val address = output.scriptpubkeyAddress ?: "" BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } @@ -268,8 +277,9 @@ private fun ColumnScope.OnchainDetails( // 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 boostTxIds.forEachIndexed { index, boostedTxId -> + val boostTxDoesExistValue = boostTxDoesExist[boostedTxId] ?: true + val isRbf = !boostTxDoesExistValue Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 75f93c857..3ed33f550 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -37,6 +37,7 @@ fun ActivityIcon( activity: Activity, size: Dp = 32.dp, modifier: Modifier = Modifier, + isCpfpChild: Boolean = false, ) { val isLightning = activity is Activity.Lightning val isBoosting = activity.isBoosting() @@ -45,7 +46,7 @@ fun ActivityIcon( val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { - isBoosting -> { + isCpfpChild || isBoosting -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 7f0a7911e..a7e330f1f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -10,7 +10,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode @@ -35,6 +39,7 @@ import to.bitkit.ext.txType import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB import to.bitkit.ui.currencyViewModel @@ -74,6 +79,17 @@ fun ActivityRow( } val isTransfer = item.isTransfer() + val activityListViewModel = activityListViewModel + var isCpfpChild by remember { mutableStateOf(false) } + + LaunchedEffect(item) { + isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { + activityListViewModel.isCpfpChildTransaction(item.v1.txId) + } else { + false + } + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -83,7 +99,7 @@ fun ActivityRow( .padding(16.dp) .testTag(testTag) ) { - ActivityIcon(activity = item, size = 40.dp) + ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild) Spacer(modifier = Modifier.width(16.dp)) Column( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -93,7 +109,8 @@ fun ActivityRow( txType = txType, isLightning = isLightning, status = status, - isTransfer = isTransfer + isTransfer = isTransfer, + isCpfpChild = isCpfpChild ) val subtitleText = when (item) { is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } @@ -101,6 +118,8 @@ fun ActivityRow( when { !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) + isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) + isTransfer && isSent -> if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_spending_done) } else { @@ -141,15 +160,18 @@ fun ActivityRow( } } +@Suppress("CyclomaticComplexMethod") @Composable private fun TransactionStatusText( txType: PaymentType, isLightning: Boolean, status: PaymentState?, isTransfer: Boolean, + isCpfpChild: Boolean = false, ) { when { isTransfer -> BodyMSB(text = stringResource(R.string.wallet__activity_transfer)) + isCpfpChild -> BodyMSB(text = stringResource(R.string.wallet__activity_boost_fee)) isLightning -> { when (txType) { PaymentType.SENT -> when (status) { diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt index 3a4aa77b4..657821d71 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt @@ -16,15 +16,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import to.bitkit.di.BgDispatcher import to.bitkit.models.AddressModel +import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.utils.AddressChecker -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class AddressViewerViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val addressChecker: AddressChecker, + private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, ) : ViewModel() { @@ -166,15 +165,8 @@ class AddressViewerViewModel @Inject constructor( } } - suspend fun getBalanceForAddress(address: String): Result = withContext(bgDispatcher) { - return@withContext runCatching { - val utxos = addressChecker.getUtxosForAddress(address) - val balance = utxos.sumOf { it.value } - return@runCatching balance - }.onFailure { e -> - Logger.error("Error getting balance for address $address", e) - } - } + suspend fun getBalanceForAddress(address: String): Result = + lightningRepo.getAddressBalance(address).map { it.toLong() } } data class UiState( diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 371edfdb2..86d1f6901 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -77,7 +77,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.walletViewModel -import to.bitkit.utils.TxDetails import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -109,11 +108,20 @@ fun ChannelDetailScreen( } } + // Fetch activity timestamp for transfer activity with matching channel ID + LaunchedEffect(channel.details.channelId) { + channel.details.channelId?.let { channelId -> + viewModel.fetchActivityTimestamp(channelId) + } + } + + val txTime by viewModel.txTime.collectAsStateWithLifecycle() + Content( channel = channel, blocktankOrders = paidOrders.paidOrders, cjitEntries = paidOrders.cjitEntries, - txDetails = txDetails, + txTime = txTime, isRefreshing = uiState.isRefreshing, isClosedChannel = isClosedChannel, onBack = { navController.popBackStack() }, @@ -146,7 +154,7 @@ private fun Content( channel: ChannelUi, blocktankOrders: List = emptyList(), cjitEntries: List = emptyList(), - txDetails: TxDetails? = null, + txTime: ULong? = null, isRefreshing: Boolean = false, isClosedChannel: Boolean = false, onBack: () -> Unit = {}, @@ -378,17 +386,12 @@ private fun Content( ) val fundingTxId = channel.details.fundingTxo?.txid - val txTime = if (fundingTxId != null && txDetails?.txid == fundingTxId) { - txDetails.status.block_time - } else { - null - } txTime?.let { SectionRow( name = stringResource(R.string.lightning__opened_on), valueContent = { - CaptionB(text = formatUnixTimestamp(txTime)) + CaptionB(text = formatUnixTimestamp(txTime.toLong())) } ) } @@ -645,7 +648,6 @@ private fun PreviewOpenChannel() { isUsable = true, ), ), - txDetails = null, ) } } @@ -732,7 +734,6 @@ private fun PreviewChannelWithOrder() { createdAt = "2024-01-15T10:30:00.000Z" ) ), - txDetails = null, ) } } @@ -820,7 +821,6 @@ private fun PreviewPendingOrder() { createdAt = "2024-01-15T14:20:00.000Z" ) ), - txDetails = null, ) } } @@ -891,7 +891,6 @@ private fun PreviewExpiredOrder() { createdAt = "2024-01-14T11:45:00.000Z" ) ), - txDetails = null, ) } } @@ -965,7 +964,6 @@ private fun PreviewChannelWithCjit() { createdAt = "2024-01-16T11:30:00.000Z" ) ), - txDetails = null, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index e484e3174..752cc01e0 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -4,9 +4,12 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -20,6 +23,7 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.R import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountOnClose @@ -35,12 +39,10 @@ import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger -import to.bitkit.utils.TxDetails import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class LightningConnectionsViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -48,7 +50,6 @@ class LightningConnectionsViewModel @Inject constructor( private val lightningRepo: LightningRepo, internal val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, - private val addressChecker: AddressChecker, private val ldkNodeEventBus: LdkNodeEventBus, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, @@ -60,9 +61,12 @@ class LightningConnectionsViewModel @Inject constructor( private val _selectedChannel = MutableStateFlow(null) val selectedChannel = _selectedChannel.asStateFlow() - private val _txDetails = MutableStateFlow(null) + private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() + private val _txTime = MutableStateFlow(null) + val txTime = _txTime.asStateFlow() + private val _closeConnectionUiState = MutableStateFlow(CloseConnectionUiState()) val closeConnectionUiState = _closeConnectionUiState.asStateFlow() @@ -388,18 +392,48 @@ class LightningConnectionsViewModel @Inject constructor( fun fetchTransactionDetails(txid: String) { viewModelScope.launch(bgDispatcher) { - try { - // TODO replace with bitkit-core method when available - _txDetails.value = addressChecker.getTransaction(txid) - Logger.debug("fetchTransactionDetails success for: '$txid'") - } catch (e: Exception) { - Logger.warn("fetchTransactionDetails error for: '$txid'", e) - _txDetails.value = null + runCatching { + val transactionDetails = lightningRepo.getTransactionDetails(txid).getOrNull() + _txDetails.update { transactionDetails } + if (transactionDetails != null) { + Logger.debug("fetchTransactionDetails success for: '$txid'", context = TAG) + } else { + Logger.warn("Transaction details not found for: '$txid'", context = TAG) + } + }.onFailure { e -> + Logger.warn("fetchTransactionDetails error for: '$txid'", e, context = TAG) + _txDetails.update { null } } } } - fun clearTransactionDetails() = _txDetails.update { null } + fun clearTransactionDetails() { + _txDetails.update { null } + _txTime.update { null } + } + + fun fetchActivityTimestamp(channelId: String) { + viewModelScope.launch { + val activities = activityRepo.getActivities( + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ).getOrNull() ?: emptyList() + + val transferActivity = activities.firstOrNull { activity -> + activity is Activity.Onchain && + activity.v1.isTransfer && + activity.v1.channelId == channelId + } as? Activity.Onchain + + _txTime.update { transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp } + } + } fun clearCloseConnectionState() { _closeConnectionUiState.update { CloseConnectionUiState() } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index f5f78da26..50f226f42 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -3,7 +3,6 @@ package to.bitkit.ui.sheets import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Txid -import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.ext.BoostType import to.bitkit.ext.boostType import to.bitkit.ext.nowTimestamp @@ -39,7 +37,6 @@ class BoostTransactionViewModel @Inject constructor( private val _boostTransactionEffect = MutableSharedFlow(extraBufferCapacity = 1) val boostTransactionEffect = _boostTransactionEffect.asSharedFlow() - // Configuration constants private companion object { const val TAG = "BoostTransactionViewModel" const val MAX_FEE_PERCENTAGE = 0.5 @@ -48,7 +45,6 @@ class BoostTransactionViewModel @Inject constructor( const val RBF_MIN_INCREASE = 2UL } - // State variables private var totalFeeSatsRecommended: ULong = 0U private var maxTotalFee: ULong = 0U private var feeRateRecommended: ULong = 0U @@ -281,9 +277,9 @@ class BoostTransactionViewModel @Inject constructor( } /** - * Updates activity based on boost type: - * - RBF: Updates current activity with boost data, then replaces with new transaction - * - CPFP: Updates the current activity and appends child txId to parent's boostTxIds + * Updates activity based on boost type. + * RBF: Updates current activity with boost data. Event handler will handle replacement. + * CPFP: Updates current activity and appends child txId to parent's boostTxIds. */ private suspend fun updateActivity(newTxId: Txid, isRBF: Boolean): Result { Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG) @@ -292,7 +288,7 @@ class BoostTransactionViewModel @Inject constructor( ?: return Result.failure(Exception("Activity required")) return if (isRBF) { - handleRBFUpdate(newTxId, currentActivity) + handleRBFUpdate(currentActivity) } else { handleCPFPUpdate(currentActivity, newTxId) } @@ -300,7 +296,7 @@ class BoostTransactionViewModel @Inject constructor( /** * Handles CPFP (Child Pays For Parent) update by updating the current activity - * and appending the child transaction ID to the parent's boostTxIds + * and appending the child transaction ID to the parent's boostTxIds. */ private suspend fun handleCPFPUpdate(currentActivity: OnchainActivity, childTxId: Txid): Result { val updatedBoostTxIds = currentActivity.boostTxIds + childTxId @@ -319,15 +315,13 @@ class BoostTransactionViewModel @Inject constructor( } /** - * Handles RBF (Replace By Fee) update by updating current activity and replacing with new one - * For RBF, we need to store the parent txId (currentActivity.txId) so it can be added to - * the replacement activity's boostTxIds when it syncs + * Handles RBF (Replace By Fee) update by updating current activity to show boost status. + * The event handler (handleOnchainTransactionReplaced) will handle the replacement + * when the OnchainTransactionReplaced event fires. */ private suspend fun handleRBFUpdate( - newTxId: Txid, currentActivity: OnchainActivity, ): Result { - // First update the current activity to show boost status val updatedCurrentActivity = Activity.Onchain( v1 = currentActivity.copy( isBoosted = true, @@ -342,101 +336,7 @@ class BoostTransactionViewModel @Inject constructor( activity = updatedCurrentActivity ) - // Then find and replace with the new activity - return findAndReplaceWithNewActivity(newTxId, currentActivity.id, currentActivity.txId) - } - - /** - * Finds the new activity and replaces the old one, handling failures gracefully - */ - private suspend fun findAndReplaceWithNewActivity( - newTxId: Txid, - oldActivityId: String, - parentTxId: String, - ): Result { - return activityRepo.findActivityByPaymentId( - paymentHashOrTxId = newTxId, - type = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT - ).fold( - onSuccess = { newActivity -> - replaceActivityWithNewOne(newActivity, oldActivityId, newTxId, parentTxId) - }, - onFailure = { error -> - handleActivityNotFound(error, newTxId, oldActivityId, parentTxId) - } - ) - } - - /** - * Replaces the old activity with the new boosted one - * For RBF, adds the parent txId to the new activity's boostTxIds - */ - private suspend fun replaceActivityWithNewOne( - newActivity: Activity, - oldActivityId: String, - newTxId: Txid, - parentTxId: String, - ): Result { - Logger.debug("Activity found: $newActivity", context = TAG) - - val newOnChainActivity = newActivity as? Activity.Onchain - ?: return Result.failure(Exception("Activity is not onchain type")) - - val updatedBoostTxIds = newOnChainActivity.v1.boostTxIds + parentTxId - val updatedNewActivity = Activity.Onchain( - v1 = newOnChainActivity.v1.copy( - isBoosted = true, - boostTxIds = updatedBoostTxIds, - feeRate = _uiState.value.feeRate, - updatedAt = nowTimestamp().toEpochMilli().toULong() - ) - ) - - return activityRepo.replaceActivity( - id = updatedNewActivity.v1.id, - activityIdToDelete = oldActivityId, - activity = updatedNewActivity, - ).onFailure { - cachePendingBoostActivity(newTxId, oldActivityId, parentTxId) - } - } - - /** - * Handles the case when new activity is not found by caching for later retry - */ - private suspend fun handleActivityNotFound( - error: Throwable, - newTxId: Txid, - oldActivityId: String?, - parentTxId: String, - ): Result { - Logger.error( - "Activity $newTxId not found. Caching data to try again on next sync", - e = error, - context = TAG - ) - - cachePendingBoostActivity(newTxId, oldActivityId, parentTxId) - return Result.failure(error) - } - - /** - * Caches activity data for pending boost operation - */ - private suspend fun cachePendingBoostActivity( - newTxId: Txid, - activityToDelete: String?, - parentTxId: String? = null - ) { - activityRepo.addActivityToPendingBoost( - PendingBoostActivity( - txId = newTxId, - updatedAt = nowTimestamp().toEpochMilli().toULong(), - activityToDelete = activityToDelete, - parentTxId = parentTxId - ) - ) + return Result.success(Unit) } private fun handleError(message: String, error: Throwable? = null) { diff --git a/app/src/main/java/to/bitkit/utils/AddressChecker.kt b/app/src/main/java/to/bitkit/utils/AddressChecker.kt deleted file mode 100644 index 9ec130630..000000000 --- a/app/src/main/java/to/bitkit/utils/AddressChecker.kt +++ /dev/null @@ -1,131 +0,0 @@ -package to.bitkit.utils - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import kotlinx.serialization.Serializable -import org.lightningdevkit.ldknode.OutPoint -import to.bitkit.env.Env -import javax.inject.Inject -import javax.inject.Singleton - -/** - * TEMPORARY IMPLEMENTATION - * This is a short-term solution for getting address information using electrs. - * Eventually, this will be replaced by similar features in bitkit-core or ldk-node - * when they support native address lookup. - */ -@Singleton -class AddressChecker @Inject constructor( - private val client: HttpClient, -) { - suspend fun getAddressInfo(address: String): AddressInfo { - try { - val response = client.get("${Env.esploraServerUrl}/address/$address") - - return response.body() - } catch (e: Exception) { - throw AddressCheckerError.NetworkError(e) - } - } - - suspend fun getTransaction(txid: String): TxDetails { - try { - val response = client.get("${Env.esploraServerUrl}/tx/$txid") - - return response.body() - } catch (e: Exception) { - throw AddressCheckerError.NetworkError(e) - } - } - - suspend fun getUtxosForAddress(address: String): List { - try { - val response = client.get("${Env.esploraServerUrl}/address/$address/utxo") - - return response.body>() - } catch (e: Exception) { - throw AddressCheckerError.NetworkError(e) - } - } - - suspend fun getOutputAddress(outPoint: OutPoint): Result = runCatching { - val (txid, vout) = outPoint - getTransaction(txid).vout.find { it.n == vout.toInt() }?.scriptpubkey_address ?: let { - throw AddressCheckerError.OutputNotFound("$txid:$vout").also { - Logger.warn("Failed to fetch funding address: ${it.message}", e = it) - } - } - } -} - -@Suppress("PropertyName") -@Serializable -data class AddressStats( - val funded_txo_count: Int, - val funded_txo_sum: Int, - val spent_txo_count: Int, - val spent_txo_sum: Int, - val tx_count: Int, -) - -@Suppress("PropertyName") -@Serializable -data class AddressInfo( - val address: String, - val chain_stats: AddressStats, - val mempool_stats: AddressStats, -) - -@Suppress("SpellCheckingInspection", "PropertyName") -@Serializable -data class TxInput( - val txid: String? = null, - val vout: Int? = null, - val prevout: TxOutput? = null, - val scriptsig: String? = null, - val scriptsig_asm: String? = null, - val witness: List? = null, - val is_coinbase: Boolean? = null, - val sequence: Long? = null, -) - -@Suppress("SpellCheckingInspection", "PropertyName") -@Serializable -data class TxOutput( - val scriptpubkey: String, - val scriptpubkey_asm: String? = null, - val scriptpubkey_type: String? = null, - val scriptpubkey_address: String? = null, - val value: Long, - val n: Int? = null, -) - -@Suppress("PropertyName") -@Serializable -data class TxStatus( - val confirmed: Boolean, - val block_height: Int? = null, - val block_hash: String? = null, - val block_time: Long? = null, -) - -@Serializable -data class TxDetails( - val txid: String, - val vin: List, - val vout: List, - val status: TxStatus, -) - -@Serializable -data class EsploraUtxo( - val txid: String, - val vout: Int, - val value: Long, -) - -sealed class AddressCheckerError(message: String? = null) : AppError(message) { - data class NetworkError(val error: Throwable) : AddressCheckerError(error.message) - data class OutputNotFound(val outpoint: String) : AddressCheckerError("Output not found: $outpoint") -} diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 509a7a820..d7bfc3797 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -11,25 +11,26 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo -import to.bitkit.utils.AddressChecker +import to.bitkit.repositories.LightningRepo import to.bitkit.utils.Logger -import to.bitkit.utils.TxDetails import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class ActivityDetailViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val addressChecker: AddressChecker, private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, + private val lightningRepo: LightningRepo, ) : ViewModel() { - private val _txDetails = MutableStateFlow(null) + private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() private val _tags = MutableStateFlow>(emptyList()) @@ -88,18 +89,18 @@ class ActivityDetailViewModel @Inject constructor( fun fetchTransactionDetails(txid: String) { viewModelScope.launch(bgDispatcher) { - try { - // TODO replace with bitkit-core method when available - _txDetails.value = addressChecker.getTransaction(txid) - } catch (e: Throwable) { + runCatching { + val transactionDetails = lightningRepo.getTransactionDetails(txid).getOrNull() + _txDetails.update { transactionDetails } + }.onFailure { e -> Logger.error("fetchTransactionDetails error", e, context = TAG) - _txDetails.value = null + _txDetails.update { null } } } } fun clearTransactionDetails() { - _txDetails.value = null + _txDetails.update { null } } fun onClickBoost() { @@ -110,6 +111,14 @@ class ActivityDetailViewModel @Inject constructor( _boostSheetVisible.update { false } } + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + return activityRepo.getBoostTxDoesExist(boostTxIds) + } + + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId) + } + suspend fun findOrderForTransfer( channelId: String?, txId: String?, diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index bebd66a76..34ab68295 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -22,15 +22,14 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.ext.isTransfer import to.bitkit.repositories.ActivityRepo -import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val ldkNodeEventBus: LdkNodeEventBus, private val activityRepo: ActivityRepo, ) : ViewModel() { private val _filteredActivities = MutableStateFlow?>(null) @@ -68,7 +67,6 @@ class ActivityListViewModel @Inject constructor( init { observeActivities() observeFilters() - observerNodeEvents() resync() } @@ -93,18 +91,12 @@ class ActivityListViewModel @Inject constructor( }.collect { _filteredActivities.value = it } } - private fun observerNodeEvents() = viewModelScope.launch { - ldkNodeEventBus.events.collect { - // TODO: resync only on specific events for better performance - resync() - } - } - private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() - _latestActivities.value = all.take(SIZE_LATEST) - _lightningActivities.value = all.filter { it is Activity.Lightning } - _onchainActivities.value = all.filter { it is Activity.Onchain } + val filtered = filterOutReplacedSentTransactions(all) + _latestActivities.update { filtered.take(SIZE_LATEST) } + _lightningActivities.update { filtered.filter { it is Activity.Lightning } } + _onchainActivities.update { filtered.filter { it is Activity.Onchain } } } private suspend fun fetchFilteredActivities(filters: ActivityFilters): List? { @@ -126,10 +118,29 @@ class ActivityListViewModel @Inject constructor( return null } - return when (filters.tab) { + val filteredByTab = when (filters.tab) { ActivityTab.OTHER -> activities.filter { it.isTransfer() } else -> activities } + + return filterOutReplacedSentTransactions(filteredByTab) + } + + private suspend fun filterOutReplacedSentTransactions(activities: List): List { + val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds() + + return activities.filter { activity -> + if (activity is Activity.Onchain) { + val onchain = activity.v1 + if (!onchain.doesExist && + onchain.txType == PaymentType.SENT && + txIdsInBoostTxIds.contains(onchain.txId) + ) { + return@filter false + } + } + true + } } fun updateAvailableTags() { @@ -156,6 +167,10 @@ class ActivityListViewModel @Inject constructor( activityRepo.removeAllActivities() } + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId) + } + private fun Flow.stateInScope( initialValue: T, started: SharingStarted = SharingStarted.WhileSubscribed(MS_TIMEOUT_SUB), diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 19af300d4..b76035cdc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -231,24 +231,23 @@ class AppViewModel @Inject constructor( ldkNodeEventBus.events.collect { event -> if (!walletRepo.walletExists()) return@collect - launch(bgDispatcher) { walletRepo.syncNodeAndWallet() } runCatching { when (event) { - is Event.BalanceChanged -> Unit + is Event.BalanceChanged -> handleBalanceChanged() + is Event.SyncCompleted -> handleSyncCompleted() is Event.ChannelClosed -> Unit is Event.ChannelPending -> Unit is Event.ChannelReady -> notifyChannelReady(event) - is Event.OnchainTransactionConfirmed -> Unit - is Event.OnchainTransactionEvicted -> notifyTransactionRemoved(event) - is Event.OnchainTransactionReceived -> notifyPaymentReceived(event) - is Event.OnchainTransactionReorged -> notifyTransactionUnconfirmed() - is Event.OnchainTransactionReplaced -> notifyTransactionReplaced(event) + is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) + is Event.OnchainTransactionEvicted -> handleOnchainTransactionEvicted(event) + is Event.OnchainTransactionReceived -> handleOnchainTransactionReceived(event) + is Event.OnchainTransactionReorged -> handleOnchainTransactionReorged(event) + is Event.OnchainTransactionReplaced -> handleOnchainTransactionReplaced(event) is Event.PaymentClaimable -> Unit - is Event.PaymentFailed -> notifyPaymentFailed() + is Event.PaymentFailed -> handlePaymentFailed(event) is Event.PaymentForwarded -> Unit - is Event.PaymentReceived -> notifyPaymentReceived(event) - is Event.PaymentSuccessful -> notifyPaymentSentOnLightning(event) - is Event.SyncCompleted -> Unit + is Event.PaymentReceived -> handlePaymentReceived(event) + is Event.PaymentSuccessful -> handlePaymentSuccessful(event) is Event.SyncProgress -> Unit } }.onFailure { e -> @@ -258,6 +257,90 @@ class AppViewModel @Inject constructor( } } + private fun handleBalanceChanged() { + viewModelScope.launch(bgDispatcher) { + walletRepo.syncBalances() + } + } + + private fun handleSyncCompleted() { + viewModelScope.launch(bgDispatcher) { + walletRepo.syncNodeAndWallet() + } + } + + private fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) + } + } + + private fun handleOnchainTransactionEvicted(event: Event.OnchainTransactionEvicted) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionEvicted(event.txid) + } + notifyTransactionRemoved(event) + } + + private fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionReceived(event.txid, event.details) + } + if (event.details.amountSats > 0) { + val sats = event.details.amountSats.toULong() + viewModelScope.launch { + delay(DELAY_FOR_ACTIVITY_SYNC_MS) + val shouldShow = activityRepo.shouldShowReceivedSheet(event.txid, sats) + if (shouldShow) { + notifyPaymentReceived(event) + } + } + } + } + + private fun handleOnchainTransactionReorged(event: Event.OnchainTransactionReorged) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionReorged(event.txid) + } + notifyTransactionUnconfirmed() + } + + private fun handleOnchainTransactionReplaced(event: Event.OnchainTransactionReplaced) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionReplaced(event.txid, event.conflicts) + } + notifyTransactionReplaced(event) + } + + private fun handlePaymentFailed(event: Event.PaymentFailed) { + event.paymentHash?.let { paymentHash -> + viewModelScope.launch(bgDispatcher) { + activityRepo.handlePaymentEvent(paymentHash) + } + } + notifyPaymentFailed() + } + + private fun handlePaymentReceived(event: Event.PaymentReceived) { + event.paymentHash?.let { paymentHash -> + viewModelScope.launch(bgDispatcher) { + activityRepo.handlePaymentEvent(paymentHash) + } + } + notifyPaymentReceived(event) + } + + private fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { + event.paymentHash?.let { paymentHash -> + viewModelScope.launch(bgDispatcher) { + activityRepo.handlePaymentEvent(paymentHash) + } + } + viewModelScope.launch { + notifyPaymentSentOnLightning(event) + } + } + private fun notifyPaymentFailed() = toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__toast_payment_failed_title), @@ -1879,6 +1962,9 @@ class AppViewModel @Inject constructor( /**How long user needs to stay on the home screen before he see this prompt*/ private const val CHECK_DELAY_MILLIS = 2000L + + /** Delay to allow activity sync before checking if received sheet should be shown */ + private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d23f4208c..63723f3fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -995,6 +995,8 @@ Received Pending Failed + Boost Fee + Boosted incoming transaction Transfer From Spending (±{duration}) From Spending diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 334577b0f..ce119b840 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -97,8 +97,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment returns ShowSheet when shouldShowPaymentReceived returns true`() = test { - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + fun `onchain payment returns ShowSheet when shouldShowReceivedSheet returns true`() = test { + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") val result = sut(command) @@ -114,8 +114,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment returns Skip when shouldShowPaymentReceived is false`() = test { - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(false) + fun `onchain payment returns Skip when shouldShowReceivedSheet is false`() = test { + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(false) val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") val result = sut(command) @@ -126,21 +126,21 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment calls shouldShowPaymentReceived with correct parameters`() = test { - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + fun `onchain payment calls shouldShowReceivedSheet with correct parameters`() = test { + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentHashOrTxId = "txid789") sut(command) - verify(activityRepo).shouldShowPaymentReceived("txid789", 7500uL) + verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) } @Test - fun `lightning payment does not call shouldShowPaymentReceived`() = test { + fun `lightning payment does not call shouldShowReceivedSheet`() = test { val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentHashOrTxId = "hash123") sut(command) - verify(activityRepo, never()).shouldShowPaymentReceived(any(), any()) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) } } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d958a40d3..02afdb72b 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -9,7 +9,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AddressChecker import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals import kotlin.test.assertNull @@ -19,7 +18,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() - private val addressChecker = mock() + private val lightningRepo = mock() private lateinit var sut: ActivityDetailViewModel @@ -32,7 +31,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, - addressChecker = addressChecker, + lightningRepo = lightningRepo, ) } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 6e27059d1..87fb93a98 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -27,7 +27,6 @@ import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AddressChecker import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -41,7 +40,6 @@ class ActivityRepoTest : BaseUnitTest() { private val blocktankRepo = mock() private val transferRepo = mock() private val cacheStore = mock() - private val addressChecker = mock() private val clock = mock() private lateinit var sut: ActivityRepo @@ -135,7 +133,6 @@ class ActivityRepoTest : BaseUnitTest() { coreService = coreService, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, - addressChecker = addressChecker, cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, @@ -315,24 +312,17 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `replaceActivity updates and marks old activity as removed from mempool`() = test { + fun `replaceActivity updates activity and copies tags`() = test { val activityId = "activity123" val activityToDeleteId = "activity456" val tagsMock = listOf("tag1", "tag2") val cacheData = AppCacheData(deletedActivities = emptyList()) whenever(cacheStore.data).thenReturn(flowOf(cacheData)) - // Mock the activity to be marked as removed (must be Onchain) - val onchainActivityToDelete = createOnchainActivity(id = activityToDeleteId, txId = "tx123") - // Mock update for the new activity wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) // Mock getActivity to return the new activity (for addTagsToActivity check) wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - // Mock getActivity to return the onchain activity to be marked as removed - wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) - // Mock update for the old activity (with doesExist=false) - wheneverBlocking { coreService.activity.update(eq(activityToDeleteId), any()) }.thenReturn(Unit) // Mock tags retrieval from the old activity wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsMock) // Mock tags retrieval from the new activity (should be empty so all tags are considered new) @@ -345,19 +335,10 @@ class ActivityRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) // Verify the new activity is updated verify(coreService.activity).update(activityId, testActivity) - // Verify the old activity is retrieved - verify(coreService.activity).getActivity(activityToDeleteId) // Verify tags are retrieved from the old activity verify(coreService.activity).tags(activityToDeleteId) // Verify tags are added to the new activity verify(coreService.activity).appendTags(activityId, tagsMock) - // Verify the old activity is updated (marked as removed from mempool with doesExist=false) - verify(coreService.activity).update( - eq(activityToDeleteId), - argThat { activity -> - activity is Activity.Onchain && !activity.v1.doesExist - } - ) // Verify delete is NOT called verify(coreService.activity, never()).delete(any()) // Verify addActivityToDeletedList is NOT called @@ -573,50 +554,6 @@ class ActivityRepoTest : BaseUnitTest() { verify(cacheStore).addActivityToPendingBoost(pendingBoost) } - @Test - fun `markActivityAsRemovedFromMempool successfully marks onchain activity as removed`() = test { - val activityId = "activity456" - val onchainActivity = createOnchainActivity( - id = activityId, - txId = "tx123", - doesExist = true // Initially exists - ) - - val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) - setupSyncActivitiesMocks(cacheData) - wheneverBlocking { - coreService.activity.get( - filter = anyOrNull(), - txType = anyOrNull(), - tags = anyOrNull(), - search = anyOrNull(), - minDate = anyOrNull(), - maxDate = anyOrNull(), - limit = anyOrNull(), - sortDirection = anyOrNull() - ) - }.thenReturn(emptyList()) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(onchainActivity) - wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) - wheneverBlocking { cacheStore.removeActivityFromPendingDelete(activityId) }.thenReturn(Unit) - - val result = sut.syncActivities() - - assertTrue(result.isSuccess) - // Verify the activity was marked as removed (doesExist = false) - verify(coreService.activity).update( - eq(activityId), - argThat { activity -> - activity is Activity.Onchain && - !activity.v1.doesExist && - activity.v1.id == activityId && - activity.v1.txId == "tx123" - } - ) - // Verify it was removed from pending delete after successful marking - verify(cacheStore).removeActivityFromPendingDelete(activityId) - } - @Test fun `boostPendingActivities adds parentTxId to boostTxIds when parentTxId is provided`() = test { val txId = "tx123" @@ -785,16 +722,6 @@ class ActivityRepoTest : BaseUnitTest() { updatedAt = 1000uL ) - val onchainActivityToDelete = createOnchainActivity( - id = activityToDeleteId, - txId = "oldTx123", - value = 500uL, - fee = 50uL, - feeRate = 5uL, - address = "bc1old", - timestamp = 1234560000uL - ) - val pendingBoost = PendingBoostActivity( txId = txId, updatedAt = updatedAt, @@ -816,26 +743,22 @@ class ActivityRepoTest : BaseUnitTest() { sortDirection = anyOrNull() ) }.thenReturn(listOf(existingActivity)) + val tagsToCopy = listOf("tag1", "tag2") wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(existingActivity) - wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) - wheneverBlocking { coreService.activity.update(eq(activityToDeleteId), any()) }.thenReturn(Unit) - wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsToCopy) wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.appendTags(activityId, tagsToCopy) }.thenReturn(Result.success(Unit)) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) val result = sut.syncActivities() assertTrue(result.isSuccess) - // Verify replaceActivity was called (indirectly by checking both activities were updated) + // Verify replaceActivity was called (indirectly by checking the new activity was updated) verify(coreService.activity).update(eq(activityId), any()) - // Verify the old activity was marked as removed (doesExist = false) - verify(coreService.activity).update( - eq(activityToDeleteId), - argThat { activity -> - activity is Activity.Onchain && !activity.v1.doesExist - } - ) + // Verify tags were copied from old activity to new activity + verify(coreService.activity).tags(activityToDeleteId) + verify(coreService.activity).appendTags(activityId, tagsToCopy) verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) } @@ -882,59 +805,4 @@ class ActivityRepoTest : BaseUnitTest() { // Verify pending boost was removed (skipped) verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) } - - @Test - fun `markActivityAsRemovedFromMempool fails when activity not found`() = test { - val activityId = "activity456" - val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) - setupSyncActivitiesMocks(cacheData) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) - - val result = sut.syncActivities() - - assertTrue(result.isSuccess) - // Verify update was NOT called (activity not found) - verify(coreService.activity, never()).update(eq(activityId), any()) - // Verify it was NOT removed from pending delete (operation failed, will retry next sync) - verify(cacheStore, never()).removeActivityFromPendingDelete(activityId) - } - - @Test - fun `markActivityAsRemovedFromMempool fails when activity is not Onchain`() = test { - val activityId = "activity456" - val lightningActivity = testActivity - val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) - setupSyncActivitiesMocks(cacheData) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(lightningActivity) - - val result = sut.syncActivities() - - assertTrue(result.isSuccess) - // Verify update was NOT called (Lightning activities can't be marked as removed) - verify(coreService.activity, never()).update(eq(activityId), any()) - // Verify it was NOT removed from pending delete (operation failed, will retry next sync) - verify(cacheStore, never()).removeActivityFromPendingDelete(activityId) - } - - @Test - fun `replaceActivity caches to pending delete when markActivityAsRemovedFromMempool fails`() = test { - val activityId = "activity123" - val activityToDeleteId = "activity456" - val cacheData = AppCacheData(deletedActivities = emptyList()) - whenever(cacheStore.data).thenReturn(flowOf(cacheData)) - - // Activity to delete doesn't exist (will cause markActivityAsRemovedFromMempool to fail) - wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(null) - wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(emptyList()) - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) - wheneverBlocking { cacheStore.addActivityToPendingDelete(activityToDeleteId) }.thenReturn(Unit) - - val result = sut.replaceActivity(activityId, activityToDeleteId, testActivity) - - assertTrue(result.isSuccess) - // Verify it was added to pending delete when marking failed - verify(cacheStore).addActivityToPendingDelete(activityToDeleteId) - } } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 9ba73cfac..3aec48ba8 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -26,9 +26,6 @@ import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase -import to.bitkit.utils.AddressChecker -import to.bitkit.utils.AddressInfo -import to.bitkit.utils.AddressStats import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -42,7 +39,6 @@ class WalletRepoTest : BaseUnitTest() { private val coreService = mock() private val onchainService = mock() private val settingsStore = mock() - private val addressChecker = mock() private val lightningRepo = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() @@ -87,7 +83,6 @@ class WalletRepoTest : BaseUnitTest() { keychain = keychain, coreService = coreService, settingsStore = settingsStore, - addressChecker = addressChecker, lightningRepo = lightningRepo, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, @@ -153,7 +148,6 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21 should generate new address when current is empty`() = test { whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) val result = sut.refreshBip21() @@ -165,7 +159,6 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should set receiveOnSpendingBalance false when shouldBlockLightning is true`() = test { wheneverBlocking { coreService.checkGeoBlock() }.thenReturn(Pair(true, true)) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) val result = sut.refreshBip21() @@ -177,7 +170,6 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should set receiveOnSpendingBalance true when shouldBlockLightning is false`() = test { wheneverBlocking { coreService.checkGeoBlock() }.thenReturn(Pair(true, false)) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) val result = sut.refreshBip21() @@ -190,14 +182,7 @@ class WalletRepoTest : BaseUnitTest() { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn( - mockAddressInfo().let { addressInfo -> - addressInfo.copy( - chain_stats = addressInfo.chain_stats.copy(tx_count = 5), - mempool_stats = addressInfo.mempool_stats.copy(tx_count = 5) - ) - } - ) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(true) val result = sut.refreshBip21() @@ -209,7 +194,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should keep address when current has no transactions`() = test { val existingAddress = "existingAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = existingAddress))) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(false) sut = createSut() sut.loadFromCache() @@ -649,13 +634,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) - whenever(addressChecker.getAddressInfo(any())).thenReturn( - mockAddressInfo().let { addressInfo -> - addressInfo.copy( - chain_stats = addressInfo.chain_stats.copy(tx_count = 1) - ) - } - ) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(true) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) sut = createSut() sut.loadFromCache() @@ -676,7 +655,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should not refresh address if not used`() = test { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(false) sut = createSut() sut.loadFromCache() @@ -712,21 +691,3 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } } - -private fun mockAddressInfo() = AddressInfo( - address = "testAddress", - chain_stats = AddressStats( - funded_txo_count = 1, - funded_txo_sum = 2, - spent_txo_count = 1, - spent_txo_sum = 1, - tx_count = 0 - ), - mempool_stats = AddressStats( - funded_txo_count = 1, - funded_txo_sum = 2, - spent_txo_count = 1, - spent_txo_sum = 1, - tx_count = 0 - ) -)