Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -109,11 +110,21 @@ class TransactionsRepositoryImpl(
addSwapMetadata(transactions)
}

private suspend fun updateTransactions(transactions: List<DbTransactionExtended>) = 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() {
Expand Down Expand Up @@ -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 ->
Expand All @@ -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()) {
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TransactionsDao>(relaxed = true)
private val sessionRepository = mockk<SessionRepository> {
every { session() } returns MutableStateFlow<Session?>(null)
}

private val subject = TransactionsRepositoryImpl(
sessionRepository = sessionRepository,
transactionsDao = transactionsDao,
transactionStatusService = mockk<TransactionStatusService>(),
)

@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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DbTxSwapMetadata>)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -282,6 +296,7 @@ struct TransactionStateServiceTests {

private extension TransactionStateServiceTests {
struct Fixture {
let db: DB
let store: TransactionStore
let walletId: WalletId
let wallet: Wallet
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions ios/Packages/Primitives/Sources/JobRunner/JobRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions ios/Packages/Primitives/Sources/JobRunner/JobStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import Foundation

public enum JobStatus: Sendable {
case complete
case cancelled
case retry(error: String? = nil)
}
Loading