diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 578a17fc3..308a289d2 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable import to.bitkit.data.dto.PendingBoostActivity +import to.bitkit.data.dto.TransactionMetadata import to.bitkit.data.serializers.AppCacheSerializer import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -113,6 +114,22 @@ class CacheStore @Inject constructor( } } + suspend fun addTransactionMetadata(item: TransactionMetadata) { + if (item.txId in store.data.first().transactionsMetadata.map { it.txId }) return + + store.updateData { + it.copy(transactionsMetadata = it.transactionsMetadata + item) + } + } + + suspend fun removeTransactionMetadata(item: TransactionMetadata) { + if (item.txId !in store.data.first().transactionsMetadata.map { it.txId }) return + + store.updateData { + it.copy(transactionsMetadata = it.transactionsMetadata - item) + } + } + suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -135,4 +152,5 @@ data class AppCacheData( val deletedActivities: List = listOf(), val activitiesPendingDelete: List = listOf(), val pendingBoostActivities: List = listOf(), + val transactionsMetadata: List = listOf(), ) diff --git a/app/src/main/java/to/bitkit/data/dto/TransactionMetadata.kt b/app/src/main/java/to/bitkit/data/dto/TransactionMetadata.kt new file mode 100644 index 000000000..6bbb13a59 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/dto/TransactionMetadata.kt @@ -0,0 +1,13 @@ +package to.bitkit.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class TransactionMetadata( + val txId: String, + val feeRate: UInt, + val address: String, + val isTransfer: Boolean, + val channelId: String?, + val transferTxId: String?, +) diff --git a/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt index 1c48c78f5..c68e356b7 100644 --- a/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt +++ b/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt @@ -3,7 +3,6 @@ package to.bitkit.data.serializers import androidx.datastore.core.Serializer import kotlinx.serialization.SerializationException import to.bitkit.data.AppCacheData -import to.bitkit.data.SettingsData import to.bitkit.di.json import to.bitkit.utils.Logger import java.io.InputStream diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 1016729b2..0ebb40a6b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.Activity.Onchain import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection @@ -46,6 +47,7 @@ class ActivityRepo @Inject constructor( .onSuccess { payments -> Logger.debug("Got payments with success, syncing activities", context = TAG) syncLdkNodePayments(payments = payments) + updateActivitiesMetadata() boostPendingActivities() isSyncingLdkNodePayments = false return@withContext Result.success(Unit) @@ -99,7 +101,11 @@ class ActivityRepo @Inject constructor( type: ActivityFilter, txType: PaymentType, ): Result = withContext(bgDispatcher) { - if (paymentHashOrTxId.isEmpty()) return@withContext Result.failure(IllegalArgumentException("paymentHashOrTxId is empty")) + if (paymentHashOrTxId.isEmpty()) { + return@withContext Result.failure( + IllegalArgumentException("paymentHashOrTxId is empty") + ) + } return@withContext try { suspend fun findActivity(): Activity? = getActivities( @@ -131,10 +137,15 @@ class ActivityRepo @Inject constructor( } } - if (activity != null) Result.success(activity) else Result.failure(IllegalStateException("Activity not found")) + if (activity != null) { + Result.success(activity) + } else { + Result.failure(IllegalStateException("Activity not found")) + } } catch (e: Exception) { Logger.error( - "findActivityByPaymentId error. Parameters:\n paymentHashOrTxId:$paymentHashOrTxId type:$type txType:$txType", + "findActivityByPaymentId error. Parameters:" + + "\n paymentHashOrTxId:$paymentHashOrTxId type:$type txType:$txType", context = TAG ) Result.failure(e) @@ -158,7 +169,15 @@ class ActivityRepo @Inject constructor( coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) }.onFailure { e -> Logger.error( - "getActivities error. Parameters:\nfilter:$filter txType:$txType tags:$tags search:$search minDate:$minDate maxDate:$maxDate limit:$limit sortDirection:$sortDirection", + "getActivities error. Parameters:" + + "\nfilter:$filter " + + "txType:$txType " + + "tags:$tags " + + "search:$search " + + "minDate:$minDate " + + "maxDate:$maxDate " + + "limit:$limit " + + "sortDirection:$sortDirection", e = e, context = TAG ) @@ -183,12 +202,16 @@ class ActivityRepo @Inject constructor( suspend fun updateActivity( id: String, activity: Activity, - forceUpdate: Boolean = false + forceUpdate: Boolean = false, ): Result = withContext(bgDispatcher) { return@withContext runCatching { if (id in cacheStore.data.first().deletedActivities && !forceUpdate) { Logger.debug("Activity $id was deleted", context = TAG) - return@withContext Result.failure(Exception("Activity $id was deleted. If you want update it, set forceUpdate as true")) + return@withContext Result.failure( + Exception( + "Activity $id was deleted. If you want update it, set forceUpdate as true" + ) + ) } coreService.activity.update(id, activity) }.onFailure { e -> @@ -197,7 +220,8 @@ class ActivityRepo @Inject constructor( } /** - * Updates an activity and delete other one. In case of failure in the update or deletion, the data will be cached to try again on the next sync + * Updates an activity and delete other one. In case of failure in the update or deletion, the data will be cached + * to try again on the next sync */ suspend fun replaceActivity( id: String, @@ -224,7 +248,8 @@ class ActivityRepo @Inject constructor( }, onFailure = { e -> Logger.error( - "Update activity fail. Parameters: id:$id, activityIdToDelete:$activityIdToDelete activity:$activity", + "Update activity fail. Parameters: id:$id, " + + "activityIdToDelete:$activityIdToDelete activity:$activity", e = e, context = TAG ) @@ -241,6 +266,41 @@ class ActivityRepo @Inject constructor( } } + private suspend fun updateActivitiesMetadata() = withContext(bgDispatcher) { + cacheStore.data.first().transactionsMetadata.forEach { activityMetaData -> + findActivityByPaymentId( + paymentHashOrTxId = activityMetaData.txId, + type = ActivityFilter.ALL, + txType = PaymentType.SENT + ).onSuccess { activityToUpdate -> + Logger.debug("updateActivitiesMetaData = Activity found: ${activityToUpdate.rawId()}", context = TAG) + + when (activityToUpdate) { + is Activity.Onchain -> { + val updatedActivity = Onchain( + v1 = activityToUpdate.v1.copy( + feeRate = activityMetaData.feeRate.toULong(), + address = activityMetaData.address, + isTransfer = activityMetaData.isTransfer, + channelId = activityMetaData.channelId, + transferTxId = activityMetaData.transferTxId + ) + ) + + updateActivity( + id = updatedActivity.v1.id, + activity = updatedActivity + ).onSuccess { + cacheStore.removeTransactionMetadata(activityMetaData) + } + } + + is Activity.Lightning -> Unit + } + } + } + } + private suspend fun boostPendingActivities() = withContext(bgDispatcher) { cacheStore.data.first().pendingBoostActivities.forEach { pendingBoostActivity -> findActivityByPaymentId( @@ -353,7 +413,7 @@ class ActivityRepo @Inject constructor( paymentHashOrTxId: String, type: ActivityFilter, txType: PaymentType, - tags: List + tags: List, ): Result = withContext(bgDispatcher) { if (tags.isEmpty()) return@withContext Result.failure(IllegalArgumentException("No tags selected")) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 614fb501b..997150079 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -27,6 +27,7 @@ import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.dto.TransactionMetadata import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -495,6 +496,8 @@ class LightningRepo @Inject constructor( sats: ULong, speed: TransactionSpeed? = null, utxosToSpend: List? = null, + isTransfer: Boolean = false, + channelId: String? = null, ): Result = executeWhenNodeRunning("Send on-chain") { val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed @@ -514,6 +517,16 @@ class LightningRepo @Inject constructor( satsPerVByte = satsPerVByte, utxosToSpend = finalUtxosToSpend, ) + cacheStore.addTransactionMetadata( + TransactionMetadata( + txId = txId, + feeRate = satsPerVByte, + address = address, + isTransfer = isTransfer, + channelId = channelId, + transferTxId = txId.takeIf { isTransfer } + ) + ) syncState() Result.success(txId) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 8a9e56bd1..b7f99faca 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -52,7 +52,6 @@ import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.models.LnPeer @@ -70,7 +69,6 @@ import kotlin.random.Random class CoreService @Inject constructor( private val lightningService: LightningService, private val httpClient: HttpClient, - private val settingsStore: SettingsStore, private val cacheStore: CacheStore, ) { private var walletIndex: Int = 0 @@ -282,8 +280,9 @@ class ActivityService( } val existingActivity = getActivityById(payment.id) - if (existingActivity != null && existingActivity is Activity.Onchain && (existingActivity.v1.updatedAt - ?: 0u) > payment.latestUpdateTimestamp + if (existingActivity != null && + existingActivity is Activity.Onchain && + (existingActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp ) { continue } @@ -302,7 +301,7 @@ class ActivityService( value = payment.amountSats ?: 0u, fee = (payment.feePaidMsat ?: 0u) / 1000u, feeRate = 1u, // TODO: get from somewhere - address = "todo_find_address", // TODO: find address + address = "Loading...", // TODO: find address confirmed = isConfirmed, timestamp = timestamp, isBoosted = false, @@ -330,23 +329,41 @@ class ActivityService( is PaymentKind.Bolt11 -> { // Skip pending inbound payments, just means they created an invoice - if (payment.status == PaymentStatus.PENDING && payment.direction == PaymentDirection.INBOUND) { + if ( + payment.status == PaymentStatus.PENDING && + payment.direction == PaymentDirection.INBOUND + ) { + continue + } + + val existingActivity = getActivityById(payment.id) + if ( + existingActivity as? Activity.Lightning != null && + (existingActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp + ) { continue } - val ln = LightningActivity( - id = payment.id, - txType = payment.direction.toPaymentType(), - status = state, - value = payment.amountSats ?: 0u, - fee = (payment.feePaidMsat ?: 0u) / 1000u, - invoice = kind.bolt11 ?: "Loading…", - message = kind.description.orEmpty(), - timestamp = payment.latestUpdateTimestamp, - preimage = kind.preimage, - createdAt = payment.latestUpdateTimestamp, - updatedAt = payment.latestUpdateTimestamp, - ) + val ln = if (existingActivity is Activity.Lightning) { + existingActivity.v1.copy( + updatedAt = payment.latestUpdateTimestamp, + status = state + ) + } else { + LightningActivity( + id = payment.id, + txType = payment.direction.toPaymentType(), + status = state, + value = payment.amountSats ?: 0u, + fee = (payment.feePaidMsat ?: 0u) / 1000u, + invoice = kind.bolt11 ?: "Loading...", + message = kind.description.orEmpty(), + timestamp = payment.latestUpdateTimestamp, + preimage = kind.preimage, + createdAt = payment.latestUpdateTimestamp, + updatedAt = payment.latestUpdateTimestamp, + ) + } if (getActivityById(payment.id) != null) { updateActivity(payment.id, Activity.Lightning(ln)) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 7575fc2ad..1bc625fa5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -172,6 +172,8 @@ class TransferViewModel @Inject constructor( address = order.payment.onchain.address, sats = order.feeSat, speed = speed, + isTransfer = true, + channelId = order.channel?.shortChannelId, ) .onSuccess { txId -> cacheStore.addPaidOrder(orderId = order.id, txId = txId) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a88bf9851..13427a20b 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -10,20 +10,26 @@ import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.spy import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.dto.TransactionMetadata import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails import to.bitkit.models.ElectrumServer import to.bitkit.models.LnPeer import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.TransactionSpeed import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus @@ -340,6 +346,59 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `sendOnChain should cache activity meta data`() = test { + val mockSettingsData = SettingsData( + defaultTransactionSpeed = TransactionSpeed.Fast, + coinSelectAuto = false // Disable auto coin selection to simplify the test + ) + whenever(settingsStore.data).thenReturn(flowOf(mockSettingsData)) + + wheneverBlocking { cacheStore.addTransactionMetadata(any()) }.thenReturn(Unit) + + whenever( + lightningService.send( + address = any(), + sats = any(), + satsPerVByte = any(), + utxosToSpend = anyOrNull() + ) + ).thenReturn("testPaymentId") + + startNodeForTesting() + + // Create a spy to mock the getFeeRateForSpeed method + val spySut = spy(sut) + doReturn(Result.success(10uL)).whenever(spySut).getFeeRateForSpeed(any()) + + val result = spySut.sendOnChain( + address = "test_address", + sats = 1000uL, + speed = TransactionSpeed.Fast, + utxosToSpend = null, // This was the missing parameter! + isTransfer = true, + channelId = "test_channel_id" + ) + + // Verify the result is successful + assertTrue(result.isSuccess) + assertEquals("testPaymentId", result.getOrNull()) + + // Verify the cache call + val captor = argumentCaptor() + verifyBlocking(cacheStore) { + addTransactionMetadata(captor.capture()) + } + + val capturedActivity = captor.firstValue + assertEquals("testPaymentId", capturedActivity.txId) + assertEquals("test_address", capturedActivity.address) + assertEquals(true, capturedActivity.isTransfer) + assertEquals("test_channel_id", capturedActivity.channelId) + assertEquals("testPaymentId", capturedActivity.transferTxId) + assertEquals(10u, capturedActivity.feeRate) + } + @Test fun `registerForNotifications should fail when node is not running`() = test { val result = sut.registerForNotifications()