diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt index b0f84c0a30..e74bb09002 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -63,7 +64,7 @@ class TransactionsRepositoryImpl( private val sessionRepository: SessionRepository, private val transactionsDao: TransactionsDao, private val transactionStatusService: TransactionStatusService, - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), ) : TransactionRepository, GetChangedTransactions, GetPendingTransactionsCount, @@ -109,11 +110,21 @@ class TransactionsRepositoryImpl( addSwapMetadata(transactions) } - private suspend fun updateTransactions(transactions: List) = withContext(Dispatchers.IO) { - val updatedAt = System.currentTimeMillis() - val records = transactions.map { it.transaction.copy(updatedAt = updatedAt) } - transactionsDao.insert(records) - addSwapMetadata(records.map { it.toDTO() }) + private suspend fun updateTransaction(transaction: DbTransactionExtended): DbTransactionExtended? = withContext(Dispatchers.IO) { + val transactionRecord = transaction.transaction.copy(updatedAt = System.currentTimeMillis()) + val updatedRows = transactionsDao.updateTransaction( + id = transactionRecord.id, + walletId = transactionRecord.walletId, + state = transactionRecord.state, + fee = transactionRecord.fee, + metadata = transactionRecord.metadata, + updatedAt = transactionRecord.updatedAt, + ) + if (updatedRows == 0) { + return@withContext null + } + addSwapMetadata(listOf(transactionRecord.toDTO())) + transaction.copy(transaction = transactionRecord) } override suspend fun clearPending() { @@ -206,6 +217,11 @@ class TransactionsRepositoryImpl( delay(pollingDelay.toLong()) pollingDelay = jobConfig.nextIntervalMs(pollingDelay) + val transactionRecord = currentTransaction.transaction + if (transactionsDao.getTransactionState(transactionRecord.id, transactionRecord.walletId) == null) { + return@launch + } + checkTransaction(currentTransaction)?.let { updatedTransaction -> if (updatedTransaction.transaction.id != currentTransaction.transaction.id) { coroutineContext[Job]?.let { runningJob -> @@ -216,14 +232,14 @@ class TransactionsRepositoryImpl( currentTransaction = storeTransactionUpdate( currentTransaction = currentTransaction, updatedTransaction = updatedTransaction, - ) + ) ?: return@launch } val hasTimedOut = !currentTransaction.transaction.state.isCompleted() && currentTransaction.transaction.createdAt < System.currentTimeMillis() - transactionTimeout(currentTransaction.transaction) if (hasTimedOut) { currentTransaction = currentTransaction.copy(transaction = currentTransaction.transaction.copy(state = TransactionState.Failed)) - updateTransactions(listOf(currentTransaction)) + updateTransaction(currentTransaction) ?: return@launch break } if (currentTransaction.transaction.state.isCompleted()) { @@ -309,13 +325,12 @@ class TransactionsRepositoryImpl( } } - private suspend fun storeTransactionUpdate( + internal suspend fun storeTransactionUpdate( currentTransaction: DbTransactionExtended, updatedTransaction: DbTransactionExtended, - ): DbTransactionExtended { + ): DbTransactionExtended? { if (updatedTransaction.transaction.id == currentTransaction.transaction.id) { - updateTransactions(listOf(updatedTransaction)) - return updatedTransaction + return updateTransaction(updatedTransaction) } val existingState = transactionsDao.getTransactionState( @@ -333,8 +348,7 @@ class TransactionsRepositoryImpl( walletId = currentTransaction.transaction.walletId, hash = updatedTransaction.transaction.hash, ) - updateTransactions(listOf(updatedTransaction)) - return updatedTransaction + return updateTransaction(updatedTransaction) } transactionsDao.deleteSwapMetadata(currentTransaction.transaction.id.identifier) diff --git a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImplTest.kt b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImplTest.kt new file mode 100644 index 0000000000..421379eee6 --- /dev/null +++ b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImplTest.kt @@ -0,0 +1,104 @@ +package com.gemwallet.android.data.repositories.transactions + +import com.gemwallet.android.blockchain.services.TransactionStatusService +import com.gemwallet.android.data.repositories.session.SessionRepository +import com.gemwallet.android.data.service.store.database.TransactionsDao +import com.gemwallet.android.data.service.store.database.entities.DbAssetProjection +import com.gemwallet.android.data.service.store.database.entities.DbTransactionExtended +import com.gemwallet.android.data.service.store.database.entities.toRecord +import com.gemwallet.android.model.Session +import com.gemwallet.android.testkit.mockTransaction +import com.gemwallet.android.testkit.mockTransactionId +import com.wallet.core.primitives.AssetType +import com.wallet.core.primitives.Transaction +import com.wallet.core.primitives.TransactionState +import com.wallet.core.primitives.WalletId +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TransactionsRepositoryImplTest { + + private val transactionsDao = mockk(relaxed = true) + private val sessionRepository = mockk { + every { session() } returns MutableStateFlow(null) + } + + private val subject = TransactionsRepositoryImpl( + sessionRepository = sessionRepository, + transactionsDao = transactionsDao, + transactionStatusService = mockk(), + ) + + @Test + fun storeTransactionUpdate_rowRemoved_terminatesWithoutInsert() = runBlocking { + every { transactionsDao.updateTransaction(any(), any(), any(), any(), any(), any()) } returns 0 + + val result = subject.storeTransactionUpdate( + currentTransaction = extended(mockTransaction(state = TransactionState.Pending)), + updatedTransaction = extended(mockTransaction(state = TransactionState.Confirmed)), + ) + + assertNull(result) + verify(exactly = 0) { transactionsDao.insert(any()) } + } + + @Test + fun storeTransactionUpdate_rowPresent_storesUpdate() = runBlocking { + every { transactionsDao.updateTransaction(any(), any(), any(), any(), any(), any()) } returns 1 + + val result = subject.storeTransactionUpdate( + currentTransaction = extended(mockTransaction(state = TransactionState.Pending)), + updatedTransaction = extended(mockTransaction(state = TransactionState.Confirmed)), + ) + + assertEquals(TransactionState.Confirmed, result?.transaction?.state) + verify(exactly = 0) { transactionsDao.insert(any()) } + } + + @Test + fun storeTransactionUpdate_hashChangedAndRowRemoved_terminatesWithoutInsert() = runBlocking { + every { transactionsDao.getTransactionState(any(), any()) } returns null + every { transactionsDao.updateTransaction(any(), any(), any(), any(), any(), any()) } returns 0 + val current = mockTransaction(state = TransactionState.Pending) + val updated = current.copy( + id = mockTransactionId(hash = "replaced-tx-id"), + state = TransactionState.Confirmed, + ) + + val result = subject.storeTransactionUpdate( + currentTransaction = extended(current), + updatedTransaction = extended(updated), + ) + + assertNull(result) + verify(exactly = 0) { transactionsDao.insert(any()) } + } + + private fun extended(transaction: Transaction) = DbTransactionExtended( + transaction = transaction.toRecord(WalletId("wallet-1")), + asset = assetProjection(), + feeAsset = assetProjection(), + priceValue = null, + priceDayChanged = null, + feePriceValue = null, + feePriceDayChanged = null, + fromAsset = null, + toAsset = null, + fromAddress = null, + toAddress = null, + ) + + private fun assetProjection() = DbAssetProjection( + id = "asset", + name = "Asset", + symbol = "A", + decimals = 8, + type = AssetType.NATIVE, + ) +} diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/TransactionsDao.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/TransactionsDao.kt index a8bb8bc45b..381803b15f 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/TransactionsDao.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/TransactionsDao.kt @@ -120,6 +120,16 @@ interface TransactionsDao { @Query("UPDATE transactions SET metadata = :metadata, updatedAt = :updatedAt WHERE id = :id AND walletId = :walletId") fun updateMetadata(id: TransactionId, walletId: WalletId, metadata: String, updatedAt: Long = System.currentTimeMillis()) + @Query("UPDATE transactions SET state = :state, fee = :fee, metadata = :metadata, updatedAt = :updatedAt WHERE id = :id AND walletId = :walletId") + fun updateTransaction( + id: TransactionId, + walletId: WalletId, + state: TransactionState, + fee: String, + metadata: String?, + updatedAt: Long = System.currentTimeMillis(), + ): Int + @Insert(entity = DbTxSwapMetadata::class, onConflict = OnConflictStrategy.REPLACE) fun addSwapMetadata(metadata: List) diff --git a/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift b/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift index 9226e06eb0..aa04044675 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift @@ -215,6 +215,20 @@ struct TransactionStateServiceTests { #expect(swapRequests.map(\.state) == [TransactionState.pending, .inTransit]) } + @Test + func jobStopsWhenWalletRemoved() async throws { + let fixture = try makeFixture(stateChanges: TransactionChanges(state: .confirmed)) + let job = TransactionStateJob( + wallet: TransactionWallet(transaction: fixture.transaction, wallet: fixture.wallet), + service: fixture.service, + ) + _ = try WalletStore.mock(db: fixture.db).deleteWallet(for: fixture.walletId) + + await expectCancelled(job.run()) + + #expect(try fixture.store.getTransactions(states: [.confirmed]).isEmpty) + } + @Test func postProcessingRefreshesSwapBalances() async throws { let fromAsset = AssetId.mock(.bitcoin) @@ -282,6 +296,7 @@ struct TransactionStateServiceTests { private extension TransactionStateServiceTests { struct Fixture { + let db: DB let store: TransactionStore let walletId: WalletId let wallet: Wallet @@ -327,7 +342,7 @@ private extension TransactionStateServiceTests { postProcessingService: postProcessingService, statusService: statusService, ) - return Fixture(store: store, walletId: walletId, wallet: wallet, transaction: transaction, service: service) + return Fixture(db: db, store: store, walletId: walletId, wallet: wallet, transaction: transaction, service: service) } func makeSwapTransaction( @@ -385,6 +400,13 @@ private extension TransactionStateServiceTests { return } } + + func expectCancelled(_ status: JobStatus) { + guard case .cancelled = status else { + Issue.record("Expected cancelled") + return + } + } } private actor TransactionStatusServiceMock: TransactionStatusServiceable { diff --git a/ios/Packages/FeatureServices/TransactionStateService/TransactionStateJob.swift b/ios/Packages/FeatureServices/TransactionStateService/TransactionStateJob.swift index 2ec8a492b5..ce1ff0038c 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/TransactionStateJob.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/TransactionStateJob.swift @@ -19,12 +19,13 @@ struct TransactionStateJob: Job { func run() async -> JobStatus { let transactionWallet = await context.transactionWallet() let result = await service.update(for: transactionWallet.transaction) - if let currentTransactionWallet = try? service.transactionWallet( + guard let currentTransactionWallet = try? service.transactionWallet( walletId: transactionWallet.wallet.id, transactionId: result.transactionId, - ) { - await context.update(currentTransactionWallet) + ) else { + return .cancelled } + await context.update(currentTransactionWallet) return result.status } diff --git a/ios/Packages/Primitives/Sources/JobRunner/JobRunner.swift b/ios/Packages/Primitives/Sources/JobRunner/JobRunner.swift index 7f09509dd6..4888863037 100644 --- a/ios/Packages/Primitives/Sources/JobRunner/JobRunner.swift +++ b/ios/Packages/Primitives/Sources/JobRunner/JobRunner.swift @@ -52,6 +52,9 @@ extension JobRunner { debugLog("transaction status complete: id=\(job.id), status=complete, error=\(error)") } return + case .cancelled: + debugLog("transaction status cancelled: id=\(job.id), status=cancelled") + return case let .retry(error): let sleepUntil = attemptStart.advanced(by: .milliseconds(Int(intervalMs))) if clock.now < sleepUntil { diff --git a/ios/Packages/Primitives/Sources/JobRunner/JobStatus.swift b/ios/Packages/Primitives/Sources/JobRunner/JobStatus.swift index 7e48083e2f..1fa0992e90 100644 --- a/ios/Packages/Primitives/Sources/JobRunner/JobStatus.swift +++ b/ios/Packages/Primitives/Sources/JobRunner/JobStatus.swift @@ -4,5 +4,6 @@ import Foundation public enum JobStatus: Sendable { case complete + case cancelled case retry(error: String? = nil) }