diff --git a/app/src/main/java/io/gnosis/safe/di/modules/DatabaseModule.kt b/app/src/main/java/io/gnosis/safe/di/modules/DatabaseModule.kt index 4b8f3ab5e1..864b72fd62 100644 --- a/app/src/main/java/io/gnosis/safe/di/modules/DatabaseModule.kt +++ b/app/src/main/java/io/gnosis/safe/di/modules/DatabaseModule.kt @@ -13,9 +13,11 @@ import io.gnosis.data.db.HeimdallDatabase.Companion.MIGRATION_5_6 import io.gnosis.data.db.HeimdallDatabase.Companion.MIGRATION_6_7 import io.gnosis.data.db.HeimdallDatabase.Companion.MIGRATION_7_8 import io.gnosis.data.db.HeimdallDatabase.Companion.MIGRATION_8_9 +import io.gnosis.data.db.HeimdallDatabase.Companion.MIGRATION_9_10 import io.gnosis.data.db.daos.ChainDao import io.gnosis.data.db.daos.OwnerDao import io.gnosis.data.db.daos.SafeDao +import io.gnosis.data.db.daos.TransactionLocalDao import io.gnosis.safe.di.ApplicationContext import javax.inject.Singleton @@ -34,7 +36,8 @@ class DatabaseModule { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, - MIGRATION_8_9 + MIGRATION_8_9, + MIGRATION_9_10 ) .build() @@ -49,4 +52,8 @@ class DatabaseModule { @Provides @Singleton fun providesChainDao(heimdallDatabase: HeimdallDatabase): ChainDao = heimdallDatabase.chainDao() + + @Provides + @Singleton + fun providesTransactionLocalDao(heimdallDatabase: HeimdallDatabase): TransactionLocalDao = heimdallDatabase.transactionLocalDao() } diff --git a/app/src/main/java/io/gnosis/safe/di/modules/RepositoryModule.kt b/app/src/main/java/io/gnosis/safe/di/modules/RepositoryModule.kt index c51f07616e..4bd63aa994 100644 --- a/app/src/main/java/io/gnosis/safe/di/modules/RepositoryModule.kt +++ b/app/src/main/java/io/gnosis/safe/di/modules/RepositoryModule.kt @@ -7,6 +7,7 @@ import io.gnosis.data.backend.GatewayApi import io.gnosis.data.backend.rpc.RpcClient import io.gnosis.data.db.daos.ChainDao import io.gnosis.data.db.daos.SafeDao +import io.gnosis.data.db.daos.TransactionLocalDao import io.gnosis.data.repositories.* import io.gnosis.safe.BuildConfig import io.gnosis.safe.workers.WorkRepository @@ -75,6 +76,11 @@ class RepositoryModule { fun providesTransactionRepository(gatewayApi: GatewayApi): TransactionRepository = TransactionRepository(gatewayApi) + @Provides + @Singleton + fun providesTransactionLocalRepository(localTxDao: TransactionLocalDao, rpcClient: RpcClient): TransactionLocalRepository = + TransactionLocalRepository(localTxDao, rpcClient) + @Provides @Singleton fun providesWorkRepository(workManager: WorkManager): WorkRepository = diff --git a/app/src/main/java/io/gnosis/safe/ui/transactions/TransactionListViewModel.kt b/app/src/main/java/io/gnosis/safe/ui/transactions/TransactionListViewModel.kt index f21d5e1434..a43a4addb5 100644 --- a/app/src/main/java/io/gnosis/safe/ui/transactions/TransactionListViewModel.kt +++ b/app/src/main/java/io/gnosis/safe/ui/transactions/TransactionListViewModel.kt @@ -1,6 +1,7 @@ package io.gnosis.safe.ui.transactions import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn @@ -10,17 +11,33 @@ import io.gnosis.data.models.AddressInfo import io.gnosis.data.models.Chain import io.gnosis.data.models.Owner import io.gnosis.data.models.Safe -import io.gnosis.data.models.transaction.* +import io.gnosis.data.models.TransactionLocal +import io.gnosis.data.models.transaction.ConflictType +import io.gnosis.data.models.transaction.SafeAppInfo +import io.gnosis.data.models.transaction.Transaction +import io.gnosis.data.models.transaction.TransactionDirection +import io.gnosis.data.models.transaction.TransactionInfo +import io.gnosis.data.models.transaction.TransactionStatus +import io.gnosis.data.models.transaction.TransferInfo +import io.gnosis.data.models.transaction.TransferType +import io.gnosis.data.models.transaction.TxListEntry +import io.gnosis.data.models.transaction.decimals +import io.gnosis.data.models.transaction.symbol +import io.gnosis.data.models.transaction.value import io.gnosis.data.repositories.CredentialsRepository import io.gnosis.data.repositories.SafeRepository +import io.gnosis.data.repositories.TransactionLocalRepository import io.gnosis.safe.R import io.gnosis.safe.ui.base.AppDispatchers import io.gnosis.safe.ui.base.BaseStateViewModel import io.gnosis.safe.ui.transactions.paging.TransactionPagingProvider import io.gnosis.safe.ui.transactions.paging.TransactionPagingSource -import io.gnosis.safe.utils.* +import io.gnosis.safe.utils.BalanceFormatter +import io.gnosis.safe.utils.DEFAULT_ERC20_SYMBOL +import io.gnosis.safe.utils.DEFAULT_ERC721_SYMBOL +import io.gnosis.safe.utils.formatBackendDateTime +import io.gnosis.safe.utils.formatBackendTimeOfDay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map import pm.gnosis.utils.asEthereumAddressString @@ -30,6 +47,7 @@ import javax.inject.Inject class TransactionListViewModel @Inject constructor( private val transactionsPager: TransactionPagingProvider, + private val transactionLocalRepository: TransactionLocalRepository, private val safeRepository: SafeRepository, private val credentialsRepository: CredentialsRepository, private val balanceFormatter: BalanceFormatter, @@ -66,38 +84,21 @@ class TransactionListViewModel } } - private fun getTransactions( + private suspend fun getTransactions( safe: Safe, safes: List, owners: List, type: TransactionPagingSource.Type ): Flow> { - + var txLocal: TransactionLocal? = null + if (type == TransactionPagingSource.Type.QUEUE ) { + txLocal = transactionLocalRepository.updateLocalTxLatest(safe) + } val safeTxItems: Flow> = transactionsPager.getTransactionsStream(safe, type) .map { pagingData -> pagingData .map { txListEntry -> - when (txListEntry) { - is TxListEntry.Transaction -> { - val isConflict = txListEntry.conflictType != ConflictType.None - val txView = - getTransactionView( - chain = safe.chain, - transaction = txListEntry.transaction, - safes = safes, - needsYourConfirmation = txListEntry.transaction.canBeSignedByAnyOwner(owners), - isConflict = isConflict, - localOwners = owners - ) - if (isConflict) { - TransactionView.Conflict(txView, txListEntry.conflictType) - } else txView - } - is TxListEntry.DateLabel -> TransactionView.SectionDateHeader(date = txListEntry.timestamp) - is TxListEntry.Label -> TransactionView.SectionLabelHeader(label = txListEntry.label) - is TxListEntry.ConflictHeader -> TransactionView.SectionConflictHeader(nonce = txListEntry.nonce) - TxListEntry.Unknown -> TransactionView.Unknown - } + mapTxListEntry(txListEntry, safe, safes, owners, txLocal) } .filter { it !is TransactionView.Unknown } } @@ -106,6 +107,65 @@ class TransactionListViewModel return safeTxItems } + @VisibleForTesting + fun mapTxListEntry( + txListEntry: TxListEntry, + safe: Safe, + safes: List, + owners: List, + txLocal: TransactionLocal? = null + ): TransactionView { + return when (txListEntry) { + is TxListEntry.Transaction -> { + // conflict is resolved if there is local tx with same nonce + // that was submitted for execution + if (txLocal?.safeTxNonce == txListEntry.transaction.executionInfo?.nonce) { + // use submittedAt timestamp to distinguish between conflicting transactions + if (txLocal?.submittedAt == txListEntry.transaction.timestamp.time && txListEntry.transaction.txStatus == TransactionStatus.AWAITING_EXECUTION) { + val tx = txListEntry.transaction.copy(txStatus = TransactionStatus.PENDING) + txListEntry.transaction + getTransactionView( + chain = safe.chain, + transaction = tx, + safes = safes, + needsYourConfirmation = false, + isConflict = false, + localOwners = owners + ) + } else { + TransactionView.Unknown + } + } else { + val isConflict = txListEntry.conflictType != ConflictType.None + val txView = + getTransactionView( + chain = safe.chain, + transaction = txListEntry.transaction, + safes = safes, + needsYourConfirmation = txListEntry.transaction.canBeSignedByAnyOwner(owners), + isConflict = isConflict, + localOwners = owners + ) + if (isConflict) { + TransactionView.Conflict(txView, txListEntry.conflictType) + } else txView + } + } + is TxListEntry.DateLabel -> TransactionView.SectionDateHeader(date = txListEntry.timestamp) + is TxListEntry.Label -> TransactionView.SectionLabelHeader(label = txListEntry.label) + is TxListEntry.ConflictHeader -> { + // conflict is resolved if there is local tx with same nonce + // that was submitted for execution + if (txLocal?.safeTxNonce == txListEntry.nonce.toBigInteger()) { + TransactionView.Unknown + } else { + TransactionView.SectionConflictHeader(nonce = txListEntry.nonce) + } + } + else -> TransactionView.Unknown + } + } + fun getTransactionView( chain: Chain, transaction: Transaction, diff --git a/app/src/main/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModel.kt b/app/src/main/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModel.kt index 72cc6d9432..c773b24493 100644 --- a/app/src/main/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModel.kt +++ b/app/src/main/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModel.kt @@ -3,11 +3,13 @@ package io.gnosis.safe.ui.transactions.details import androidx.annotation.VisibleForTesting import io.gnosis.data.models.Owner import io.gnosis.data.models.Safe +import io.gnosis.data.models.TransactionLocal import io.gnosis.data.models.transaction.DetailedExecutionInfo import io.gnosis.data.models.transaction.TransactionDetails import io.gnosis.data.models.transaction.TransactionStatus import io.gnosis.data.repositories.CredentialsRepository import io.gnosis.data.repositories.SafeRepository +import io.gnosis.data.repositories.TransactionLocalRepository import io.gnosis.data.repositories.TransactionRepository import io.gnosis.data.utils.SemVer import io.gnosis.data.utils.calculateSafeTxHash @@ -28,6 +30,7 @@ import javax.inject.Inject class TransactionDetailsViewModel @Inject constructor( private val transactionRepository: TransactionRepository, + private val transactionLocalRepository: TransactionLocalRepository, private val safeRepository: SafeRepository, private val credentialsRepository: CredentialsRepository, private val settingsHandler: SettingsHandler, @@ -48,6 +51,8 @@ class TransactionDetailsViewModel activeSafe.chainId, txId ) + var txLocal: TransactionLocal? = null + val safes = safeRepository.getSafes() val executionInfo = txDetails?.detailedExecutionInfo @@ -61,20 +66,37 @@ class TransactionDetailsViewModel canExecute = canBeExecutedFromDevice(executionInfo, owners) nextInLine = safeInfo.nonce == executionInfo.nonce safeOwner = isOwner(executionInfo, owners) + txLocal = transactionLocalRepository.updateLocalTx(activeSafe, executionInfo.safeTxHash) + } + + var txDetailsViewData = txDetails?.toTransactionDetailsViewData( + safes = safes, + canSign = canSign, + canExecute = canExecute, + owners = owners, + nextInLine = nextInLine, + hasOwnerKey = safeOwner + ) + + txLocal?.let { + when { + txDetailsViewData?.txStatus == TransactionStatus.AWAITING_EXECUTION -> { + txDetailsViewData = txDetailsViewData?.copy(txStatus = TransactionStatus.PENDING) + } + txDetailsViewData?.txStatus == TransactionStatus.SUCCESS || + txDetailsViewData?.txStatus == TransactionStatus.FAILED -> { + // tx was indexed by the transaction service + // local transaction can be deleted + transactionLocalRepository.delete(it) + } + } } updateState { TransactionDetailsViewState(ViewAction.Loading(false)) } updateState { TransactionDetailsViewState( UpdateDetails( - txDetails?.toTransactionDetailsViewData( - safes = safes, - canSign = canSign, - canExecute = canExecute, - owners = owners, - nextInLine = nextInLine, - hasOwnerKey = safeOwner - ) + txDetailsViewData ) ) } diff --git a/app/src/main/java/io/gnosis/safe/ui/transactions/details/viewdata/TransactionDetailsViewData.kt b/app/src/main/java/io/gnosis/safe/ui/transactions/details/viewdata/TransactionDetailsViewData.kt index 475ab644e5..3620bf423d 100644 --- a/app/src/main/java/io/gnosis/safe/ui/transactions/details/viewdata/TransactionDetailsViewData.kt +++ b/app/src/main/java/io/gnosis/safe/ui/transactions/details/viewdata/TransactionDetailsViewData.kt @@ -2,13 +2,24 @@ package io.gnosis.safe.ui.transactions.details.viewdata import android.os.Parcelable import androidx.annotation.VisibleForTesting +import io.gnosis.data.adapters.SolidityAddressNullableParceler +import io.gnosis.data.adapters.SolidityAddressParceler import io.gnosis.data.models.AddressInfo import io.gnosis.data.models.Owner import io.gnosis.data.models.Safe -import io.gnosis.data.models.transaction.* +import io.gnosis.data.models.transaction.DataDecoded +import io.gnosis.data.models.transaction.DetailedExecutionInfo +import io.gnosis.data.models.transaction.SafeAppInfo +import io.gnosis.data.models.transaction.SettingsInfo +import io.gnosis.data.models.transaction.SettingsInfoType +import io.gnosis.data.models.transaction.TransactionDetails +import io.gnosis.data.models.transaction.TransactionDirection +import io.gnosis.data.models.transaction.TransactionInfo +import io.gnosis.data.models.transaction.TransactionStatus +import io.gnosis.data.models.transaction.TransactionType +import io.gnosis.data.models.transaction.TransferInfo +import io.gnosis.data.models.transaction.TxData import io.gnosis.safe.ui.transactions.AddressInfoData -import io.gnosis.data.adapters.SolidityAddressNullableParceler -import io.gnosis.data.adapters.SolidityAddressParceler import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler @@ -16,7 +27,7 @@ import pm.gnosis.crypto.utils.asEthereumAddressChecksumString import pm.gnosis.model.Solidity import pm.gnosis.utils.asEthereumAddressString import java.math.BigInteger -import java.util.* +import java.util.Date @Parcelize data class TransactionDetailsViewData( diff --git a/app/src/main/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModel.kt b/app/src/main/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModel.kt index 79116da48d..e0629cdc38 100644 --- a/app/src/main/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModel.kt +++ b/app/src/main/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModel.kt @@ -8,6 +8,7 @@ import io.gnosis.data.models.Safe import io.gnosis.data.models.transaction.DetailedExecutionInfo import io.gnosis.data.models.transaction.TxData import io.gnosis.data.repositories.CredentialsRepository +import io.gnosis.data.repositories.TransactionLocalRepository import io.gnosis.data.repositories.SafeRepository import io.gnosis.data.utils.toSignature import io.gnosis.safe.Tracker @@ -36,6 +37,7 @@ class TxReviewViewModel @Inject constructor( private val safeRepository: SafeRepository, private val credentialsRepository: CredentialsRepository, + private val localTxRepository: TransactionLocalRepository, private val settingsHandler: SettingsHandler, private val rpcClient: RpcClient, private val balanceFormatter: BalanceFormatter, @@ -455,6 +457,15 @@ class TxReviewViewModel kotlin.runCatching { rpcClient.send(ethTx!!, it) }.onSuccess { + tracker.logTxExecSubmitted() + val executionInfo = executionInfo as DetailedExecutionInfo.MultisigExecutionDetails + localTxRepository.saveLocally( + tx = ethTx!!, + txHash = it, + safeTxHash = executionInfo.safeTxHash, + safeTxNonce = executionInfo.nonce, + submittedAt = executionInfo.submittedAt.time + ) updateState { TxReviewState( viewAction = diff --git a/app/src/test/java/io/gnosis/safe/ui/transactions/TransactionListViewModelTest.kt b/app/src/test/java/io/gnosis/safe/ui/transactions/TransactionListViewModelTest.kt index 74ab9ffb4b..a28b998d4a 100644 --- a/app/src/test/java/io/gnosis/safe/ui/transactions/TransactionListViewModelTest.kt +++ b/app/src/test/java/io/gnosis/safe/ui/transactions/TransactionListViewModelTest.kt @@ -2,11 +2,31 @@ package io.gnosis.safe.ui.transactions import androidx.paging.PagingData import io.gnosis.data.BuildConfig -import io.gnosis.data.models.* +import io.gnosis.data.models.AddressInfo +import io.gnosis.data.models.Chain +import io.gnosis.data.models.Owner +import io.gnosis.data.models.Page +import io.gnosis.data.models.Safe +import io.gnosis.data.models.TransactionLocal import io.gnosis.data.models.assets.TokenInfo import io.gnosis.data.models.assets.TokenType -import io.gnosis.data.models.transaction.* -import io.gnosis.data.models.transaction.TransactionStatus.* +import io.gnosis.data.models.transaction.ConflictType +import io.gnosis.data.models.transaction.DataDecoded +import io.gnosis.data.models.transaction.ExecutionInfo +import io.gnosis.data.models.transaction.Param +import io.gnosis.data.models.transaction.SettingsInfo +import io.gnosis.data.models.transaction.Transaction +import io.gnosis.data.models.transaction.TransactionDirection +import io.gnosis.data.models.transaction.TransactionInfo +import io.gnosis.data.models.transaction.TransactionStatus +import io.gnosis.data.models.transaction.TransactionStatus.AWAITING_CONFIRMATIONS +import io.gnosis.data.models.transaction.TransactionStatus.AWAITING_EXECUTION +import io.gnosis.data.models.transaction.TransactionStatus.CANCELLED +import io.gnosis.data.models.transaction.TransactionStatus.FAILED +import io.gnosis.data.models.transaction.TransactionStatus.PENDING +import io.gnosis.data.models.transaction.TransactionStatus.SUCCESS +import io.gnosis.data.models.transaction.TransferInfo +import io.gnosis.data.models.transaction.TxListEntry import io.gnosis.data.repositories.CredentialsRepository import io.gnosis.data.repositories.SafeRepository import io.gnosis.data.repositories.SafeRepository.Companion.METHOD_CHANGE_MASTER_COPY @@ -14,8 +34,12 @@ import io.gnosis.data.repositories.SafeRepository.Companion.METHOD_DISABLE_MODUL import io.gnosis.data.repositories.SafeRepository.Companion.METHOD_ENABLE_MODULE import io.gnosis.data.repositories.SafeRepository.Companion.METHOD_REMOVE_OWNER import io.gnosis.data.repositories.SafeRepository.Companion.METHOD_SET_FALLBACK_HANDLER +import io.gnosis.data.repositories.TransactionLocalRepository import io.gnosis.data.repositories.TransactionRepository -import io.gnosis.safe.* +import io.gnosis.safe.R +import io.gnosis.safe.TestLifecycleRule +import io.gnosis.safe.TestLiveDataObserver +import io.gnosis.safe.appDispatchers import io.gnosis.safe.ui.base.BaseStateViewModel import io.gnosis.safe.ui.transactions.TransactionListViewModel.Companion.OPACITY_FULL import io.gnosis.safe.ui.transactions.TransactionListViewModel.Companion.OPACITY_HALF @@ -24,7 +48,11 @@ import io.gnosis.safe.ui.transactions.paging.TransactionPagingSource import io.gnosis.safe.utils.BalanceFormatter import io.gnosis.safe.utils.formatBackendDateTime import io.gnosis.safe.utils.formatBackendTimeOfDay -import io.mockk.* +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import org.junit.Assert.assertEquals @@ -35,7 +63,7 @@ import pm.gnosis.model.Solidity import pm.gnosis.utils.asEthereumAddress import pm.gnosis.utils.asEthereumAddressString import java.math.BigInteger -import java.util.* +import java.util.Date class TransactionListViewModelTest { @@ -46,6 +74,7 @@ class TransactionListViewModelTest { private val safeRepository = mockk() private val transactionRepository = mockk() + private val transactionLocalRepository = mockk() private val credentialsRepository = mockk() private val transactionPagingProvider = mockk() @@ -78,7 +107,7 @@ class TransactionListViewModelTest { val testObserver = TestLiveDataObserver() coEvery { safeRepository.activeSafeFlow() } returns emptyFlow() transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) transactionListViewModel.state.observeForever(testObserver) @@ -97,7 +126,7 @@ class TransactionListViewModelTest { val throwable = Throwable() coEvery { safeRepository.activeSafeFlow() } throws throwable transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) transactionListViewModel.state.observeForever(testObserver) testObserver.assertValueCount(1) @@ -123,7 +152,7 @@ class TransactionListViewModelTest { ) } returns flow { emit(PagingData.empty()) } transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) transactionListViewModel.state.observeForever(testObserver) @@ -152,7 +181,7 @@ class TransactionListViewModelTest { coEvery { transactionRepository.getHistoryTransactions(any()) } throws throwable coEvery { transactionPagingProvider.getTransactionsStream(any(), TransactionPagingSource.Type.HISTORY) } throws throwable transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) transactionListViewModel.load(TransactionPagingSource.Type.HISTORY) transactionListViewModel.state.observeForever(testObserver) @@ -169,11 +198,140 @@ class TransactionListViewModelTest { } } + @Test + fun `load - (queue) should check latest local tx`() { + val safe = Safe(Solidity.Address(BigInteger.ONE), "test_safe").apply { + chain = CHAIN + } + val testObserver = TestLiveDataObserver() + coEvery { safeRepository.activeSafeFlow() } returns flow { emit(safe) } + coEvery { safeRepository.getActiveSafe() } returns safe + coEvery { safeRepository.getSafes() } returns listOf(safe) + coEvery { credentialsRepository.ownerCount() } returns 0 + coEvery { credentialsRepository.owners() } returns listOf() + coEvery { + transactionPagingProvider.getTransactionsStream( + any(), + TransactionPagingSource.Type.QUEUE + ) + } returns flow { emit(PagingData.empty()) } + coEvery { transactionLocalRepository.updateLocalTxLatest(any()) } returns TransactionLocal( + CHAIN.chainId, + Solidity.Address(BigInteger.ZERO), + BigInteger.ONE, + "", + "", + PENDING, + 0 + ) + transactionListViewModel = + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + transactionListViewModel.load(TransactionPagingSource.Type.QUEUE) + + transactionListViewModel.state.observeForever(testObserver) + + with(testObserver.values()[0]) { + assertEquals(true, viewAction is LoadTransactions) + } + coVerifySequence { + safeRepository.activeSafeFlow() + safeRepository.getActiveSafe() + safeRepository.getSafes() + credentialsRepository.owners() + transactionLocalRepository.updateLocalTxLatest(any()) + } + } + + @Test + fun `mapTxListEntry(Transaction, localTx is null) should map to TransactionView with same state`() { + val safe = Safe(Solidity.Address(BigInteger.ONE), "test_safe").apply { + chain = CHAIN + } + + val transfer = buildTransfer(status = AWAITING_EXECUTION, confirmations = 1, threshold = 1) + val txListEnry = TxListEntry.Transaction(transfer, ConflictType.None) + + transactionListViewModel = + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + + val transactionView = transactionListViewModel.mapTxListEntry( + txListEnry, + safe, + listOf(safe), + listOf() + ) + + assertEquals(TransactionView.TransferQueued( + "", + AWAITING_EXECUTION, + Chain.DEFAULT_CHAIN, + R.string.tx_status_needs_execution, + R.color.warning, + "< -0${DS}00001 ETH", + transfer.timestamp, + R.drawable.ic_arrow_red_10dp, + R.string.tx_list_send, + R.color.label_primary, + 1, + 1, + R.color.success, + R.drawable.ic_confirmations_green_16dp, + "1" + ), transactionView) + } + + @Test + fun `mapTxListEntry(Transaction, localTx) should map to TransactionView with pending state`() { + val safe = Safe(Solidity.Address(BigInteger.ONE), "test_safe").apply { + chain = CHAIN + } + + val transfer = buildTransfer(status = AWAITING_EXECUTION, confirmations = 1, threshold = 1) + val txListEnry = TxListEntry.Transaction(transfer, ConflictType.None) + + transactionListViewModel = + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + + val transactionView = transactionListViewModel.mapTxListEntry( + txListEnry, + safe, + listOf(safe), + listOf(), + TransactionLocal( + CHAIN.chainId, + Solidity.Address(BigInteger.ZERO), + BigInteger.ONE, + "", + "", + PENDING, + 0 + ) + ) + + assertEquals(TransactionView.TransferQueued( + "", + PENDING, + Chain.DEFAULT_CHAIN, + R.string.tx_status_pending, + R.color.warning, + "< -0${DS}00001 ETH", + transfer.timestamp, + R.drawable.ic_arrow_red_10dp, + R.string.tx_list_send, + R.color.label_primary, + 1, + 1, + R.color.success, + R.drawable.ic_confirmations_green_16dp, + "1" + ), transactionView) + } + @Test fun `mapToTransactionView (tx list with no transfer) should map to empty list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = createEmptyTransactionList() val transactionViews = transactions.results.map { transactionListViewModel.getTransactionView(CHAIN, it, safes) } @@ -185,7 +343,7 @@ class TransactionListViewModelTest { fun `mapToTransactionView (tx list with queued transfers) should map to queued and ether transfer list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = createTransactionListWithStatus( PENDING, @@ -210,7 +368,7 @@ class TransactionListViewModelTest { fun `mapToTransactionView (tx list with conflicting queued transfers) should map to queued and ether transfer list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = createTransactionListWithStatus( PENDING, @@ -236,7 +394,7 @@ class TransactionListViewModelTest { fun `mapTransactionView (tx list with queued and historic transfer) should map to queued and transfer list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = createTransactionListWithStatus(PENDING, SUCCESS) val transactionViews = transactions.results.map { transactionListViewModel.getTransactionView(CHAIN, it, safes) } @@ -249,7 +407,7 @@ class TransactionListViewModelTest { fun `mapTransactionView (tx list with queued and historic ETH transfers) should map to queued and ETH transfer list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = listOf( buildTransfer( @@ -392,7 +550,7 @@ class TransactionListViewModelTest { fun `mapTransactionView (tx list with historic ether transfers) should map to ether transfer list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = listOf( buildTransfer(serviceTokenInfo = ERC20_TOKEN_INFO_NO_SYMBOL, sender = defaultFromAddress, recipient = defaultSafeAddress), @@ -476,7 +634,7 @@ class TransactionListViewModelTest { fun `mapTransactionView (tx list with historic custom txs) should map to custom transactions list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = listOf( buildCustom(status = AWAITING_EXECUTION, confirmations = 2, actionCount = 2), @@ -578,7 +736,7 @@ class TransactionListViewModelTest { @Test fun `mapTransactionView (tx list with historic setting changes) should map to settings changes list`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = listOf( // queued @@ -801,7 +959,7 @@ class TransactionListViewModelTest { fun `mapToTransactionView (tx list with creation tx) should map to list with creation tx`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = createTransactionListWithCreationTx() val transactionViews = transactions.results.map { transactionListViewModel.getTransactionView(CHAIN, it, safes) } @@ -824,7 +982,7 @@ class TransactionListViewModelTest { fun `mapToTransactionView (tx list with needs confirmation transactions) should map to list with items having correct needs confirmation string`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val safe = Safe(Solidity.Address(BigInteger.ONE), "test_safe") val ownerAddress = AddressInfo(Solidity.Address(BigInteger.ONE)) @@ -938,7 +1096,7 @@ class TransactionListViewModelTest { fun `mapTransactionView () should perform correct known names resolution`() { transactionListViewModel = - TransactionListViewModel(transactionPagingProvider, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) + TransactionListViewModel(transactionPagingProvider, transactionLocalRepository, safeRepository, credentialsRepository, balanceFormatter, appDispatchers) val transactions = listOf( buildCustom(addressInfo = AddressInfo(defaultSafeAddress), actionCount = 1), @@ -1078,7 +1236,8 @@ class TransactionListViewModelTest { value: BigInteger = BigInteger.ONE, date: Date = Date(0), serviceTokenInfo: TokenInfo = NATIVE_CURRENCY_INFO, - nonce: BigInteger = defaultNonce + nonce: BigInteger = defaultNonce, + threshold: Int = defaultThreshold ): Transaction = Transaction( id = "", @@ -1091,7 +1250,7 @@ class TransactionListViewModelTest { ), executionInfo = ExecutionInfo( nonce = nonce, - confirmationsRequired = defaultThreshold, + confirmationsRequired = threshold, confirmationsSubmitted = confirmations, missingSigners = missingSigners ), diff --git a/app/src/test/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModelTest.kt b/app/src/test/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModelTest.kt index 62eeb767b0..5f1ec4b99c 100644 --- a/app/src/test/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModelTest.kt +++ b/app/src/test/java/io/gnosis/safe/ui/transactions/details/TransactionDetailsViewModelTest.kt @@ -8,18 +8,29 @@ import io.gnosis.data.models.Chain import io.gnosis.data.models.Owner import io.gnosis.data.models.Safe import io.gnosis.data.models.SafeInfo +import io.gnosis.data.models.TransactionLocal import io.gnosis.data.models.VersionState import io.gnosis.data.models.transaction.DetailedExecutionInfo import io.gnosis.data.models.transaction.TransactionDetails import io.gnosis.data.models.transaction.TransactionStatus import io.gnosis.data.repositories.CredentialsRepository import io.gnosis.data.repositories.SafeRepository +import io.gnosis.data.repositories.TransactionLocalRepository import io.gnosis.data.repositories.TransactionRepository -import io.gnosis.safe.* +import io.gnosis.safe.TestLifecycleRule +import io.gnosis.safe.TestLiveDataObserver +import io.gnosis.safe.Tracker +import io.gnosis.safe.appDispatchers +import io.gnosis.safe.readJsonFrom +import io.gnosis.safe.test import io.gnosis.safe.ui.base.BaseStateViewModel import io.gnosis.safe.ui.settings.app.SettingsHandler import io.gnosis.safe.ui.transactions.details.viewdata.toTransactionDetailsViewData -import io.mockk.* +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -38,6 +49,7 @@ class TransactionDetailsViewModelTest { val instantExecutorRule = TestLifecycleRule() private val transactionRepository = mockk() + private val transactionLocalRepository = mockk() private val safeRepository = mockk() private val credentialsRepository = mockk() private val settingsHandler = mockk() @@ -47,6 +59,8 @@ class TransactionDetailsViewModelTest { private val adapter = dataMoshi.adapter(TransactionDetails::class.java) + private val someSafeTxHash = "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67" + @Test fun `loadDetails (transactionRepository failure) should emit error`() = runTest(UnconfinedTestDispatcher()) { @@ -76,6 +90,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -97,7 +112,155 @@ class TransactionDetailsViewModelTest { } @Test - fun `loadDetails (successful) should emit txDetails`() = runTest(UnconfinedTestDispatcher()) { + fun `loadDetails (successful) should emit txDetails`() { + runTest(UnconfinedTestDispatcher()) { + val transactionDetailsDto = adapter.readJsonFrom("tx_details_transfer.json") + val transactionDetails = toTransactionDetails(transactionDetailsDto) + val someAddress = "0x1230B3d59858296A31053C1b8562Ecf89A2f888b".asEthereumAddress()!! + val someSafe = Safe(someAddress, "safe_name", CHAIN_ID) + + coEvery { + transactionRepository.getTransactionDetails( + any(), + any() + ) + } returns transactionDetails + coEvery { transactionLocalRepository.updateLocalTx(any(), any()) } returns null + coEvery { safeRepository.getSafes() } returns emptyList() + coEvery { credentialsRepository.owners() } returns listOf() + coEvery { safeRepository.getActiveSafe() } returns someSafe + coEvery { safeRepository.getSafeInfo(any()) } returns SafeInfo( + AddressInfo(Solidity.Address(BigInteger.ONE)), + BigInteger.ONE, + 1, + listOf( + AddressInfo(Solidity.Address(BigInteger.ONE)) + ), + AddressInfo(Solidity.Address(BigInteger.ONE)), + listOf(AddressInfo(Solidity.Address(BigInteger.ONE))), + AddressInfo(Solidity.Address(BigInteger.ONE)), + null, + "1.1.1", + VersionState.OUTDATED + ) + val expectedTransactionInfoViewData = + transactionDetails.toTransactionDetailsViewData( + emptyList(), + canSign = false, + canExecute = false, + nextInLine = false, + owners = emptyList(), + hasOwnerKey = false + ) + + viewModel = TransactionDetailsViewModel( + transactionRepository, + transactionLocalRepository, + safeRepository, + credentialsRepository, + settingsHandler, + tracker, + appDispatchers + ) + + viewModel.loadDetails("tx_details_id") + + with(viewModel.state.test().values()) { + assertEquals( + UpdateDetails(txDetails = expectedTransactionInfoViewData), + this[0].viewAction + ) + } + coVerify(exactly = 1) { + transactionRepository.getTransactionDetails( + CHAIN_ID, + "tx_details_id" + ) + transactionLocalRepository.updateLocalTx(someSafe, someSafeTxHash) + } + } + } + + @Test + fun `loadDetails (successful, awaiting execution with local pending tx) should emit txDetails with pending state`() = runTest(UnconfinedTestDispatcher()) { + val transactionDetailsDto = adapter.readJsonFrom("tx_details_transfer.json") + val transactionDetails = toTransactionDetails(transactionDetailsDto).copy(txStatus = TransactionStatus.AWAITING_EXECUTION) + val someAddress = "0x1230B3d59858296A31053C1b8562Ecf89A2f888b".asEthereumAddress()!! + + coEvery { + transactionRepository.getTransactionDetails( + any(), + any() + ) + } returns transactionDetails + coEvery { transactionLocalRepository.updateLocalTx(any(), any()) } returns TransactionLocal( + CHAIN_ID, + Solidity.Address(BigInteger.ZERO), + BigInteger.ZERO, + (transactionDetails.detailedExecutionInfo as DetailedExecutionInfo.MultisigExecutionDetails).safeTxHash, + "", + TransactionStatus.PENDING, + 0 + ) + coEvery { transactionLocalRepository.delete(any()) } just Runs + coEvery { safeRepository.getSafes() } returns emptyList() + coEvery { credentialsRepository.owners() } returns listOf() + coEvery { safeRepository.getActiveSafe() } returns Safe(someAddress, "safe_name", CHAIN_ID) + coEvery { safeRepository.getSafeInfo(any()) } returns SafeInfo( + AddressInfo(Solidity.Address(BigInteger.ONE)), + BigInteger.ONE, + 1, + listOf( + AddressInfo(Solidity.Address(BigInteger.ONE)) + ), + AddressInfo(Solidity.Address(BigInteger.ONE)), + listOf(AddressInfo(Solidity.Address(BigInteger.ONE))), + AddressInfo(Solidity.Address(BigInteger.ONE)), + null, + "1.1.1", + VersionState.OUTDATED + ) + val expectedTransactionInfoViewData = + transactionDetails.toTransactionDetailsViewData( + emptyList(), + canSign = false, + canExecute = false, + nextInLine = false, + owners = emptyList(), + hasOwnerKey = false + ).copy(txStatus = TransactionStatus.PENDING) + + viewModel = TransactionDetailsViewModel( + transactionRepository, + transactionLocalRepository, + safeRepository, + credentialsRepository, + settingsHandler, + tracker, + appDispatchers + ) + + viewModel.loadDetails("tx_details_id") + + with(viewModel.state.test().values()) { + assertEquals( + UpdateDetails(txDetails = expectedTransactionInfoViewData), + this[0].viewAction + ) + } + coVerify(exactly = 1) { + transactionRepository.getTransactionDetails( + CHAIN_ID, + "tx_details_id" + ) + } + coVerify(exactly = 0) { + transactionLocalRepository.delete(any()) + } + } + + @Test + fun `loadDetails (successful, success with local pending tx) should emit txDetails and delete local tx`() = runTest(UnconfinedTestDispatcher()) { val transactionDetailsDto = adapter.readJsonFrom("tx_details_transfer.json") val transactionDetails = toTransactionDetails(transactionDetailsDto) val someAddress = "0x1230B3d59858296A31053C1b8562Ecf89A2f888b".asEthereumAddress()!! @@ -108,6 +271,16 @@ class TransactionDetailsViewModelTest { any() ) } returns transactionDetails + coEvery { transactionLocalRepository.updateLocalTx(any(), any()) } returns TransactionLocal( + CHAIN_ID, + Solidity.Address(BigInteger.ZERO), + BigInteger.ZERO, + (transactionDetails.detailedExecutionInfo as DetailedExecutionInfo.MultisigExecutionDetails).safeTxHash, + "", + TransactionStatus.PENDING, + 0 + ) + coEvery { transactionLocalRepository.delete(any()) } just Runs coEvery { safeRepository.getSafes() } returns emptyList() coEvery { credentialsRepository.owners() } returns listOf() coEvery { safeRepository.getActiveSafe() } returns Safe(someAddress, "safe_name", CHAIN_ID) @@ -137,6 +310,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -157,6 +331,17 @@ class TransactionDetailsViewModelTest { CHAIN_ID, "tx_details_id" ) + + val localTx = TransactionLocal( + safeAddress = Solidity.Address(BigInteger.ZERO), + chainId = CHAIN_ID, + safeTxNonce = BigInteger.ZERO, + safeTxHash = "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67", + ethTxHash = "", + status = TransactionStatus.PENDING, + submittedAt = 0 + ) + transactionLocalRepository.delete(localTx) } } @@ -169,6 +354,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -195,6 +381,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -223,6 +410,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -258,6 +446,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -293,6 +482,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -328,6 +518,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -362,6 +553,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -407,6 +599,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -426,7 +619,7 @@ class TransactionDetailsViewModelTest { coVerify(exactly = 1) { credentialsRepository.signWithOwner( owner, - "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67".hexToByteArray() + someSafeTxHash.hexToByteArray() ) } coVerify(exactly = 1) { credentialsRepository.owners() } @@ -457,6 +650,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -476,7 +670,7 @@ class TransactionDetailsViewModelTest { coVerify(exactly = 1) { credentialsRepository.signWithOwner( owner, - "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67".hexToByteArray() + someSafeTxHash.hexToByteArray() ) } coVerify(exactly = 1) { credentialsRepository.owners() } @@ -508,6 +702,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -537,7 +732,7 @@ class TransactionDetailsViewModelTest { coVerify(exactly = 1) { credentialsRepository.signWithOwner( owner, - "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67".hexToByteArray() + someSafeTxHash.hexToByteArray() ) } coVerify(exactly = 1) { credentialsRepository.owners() } @@ -556,6 +751,7 @@ class TransactionDetailsViewModelTest { viewModel = TransactionDetailsViewModel( transactionRepository, + transactionLocalRepository, safeRepository, credentialsRepository, settingsHandler, @@ -578,7 +774,7 @@ class TransactionDetailsViewModelTest { ).toTypedArray(), signingMode = SigningMode.CONFIRMATION, chain = Chain.DEFAULT_CHAIN, - safeTxHash = "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67" + safeTxHash = someSafeTxHash ) ).toString(), viewAction.toString() ) diff --git a/app/src/test/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModelTest.kt b/app/src/test/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModelTest.kt new file mode 100644 index 0000000000..e46a6ccba6 --- /dev/null +++ b/app/src/test/java/io/gnosis/safe/ui/transactions/execution/TxReviewViewModelTest.kt @@ -0,0 +1,415 @@ +package io.gnosis.safe.ui.transactions.execution + +import io.gnosis.data.backend.rpc.RpcClient +import io.gnosis.data.backend.rpc.models.EstimationParams +import io.gnosis.data.models.AddressInfo +import io.gnosis.data.models.Chain +import io.gnosis.data.models.Owner +import io.gnosis.data.models.Safe +import io.gnosis.data.models.transaction.DetailedExecutionInfo +import io.gnosis.data.models.transaction.Operation +import io.gnosis.data.models.transaction.TxData +import io.gnosis.data.repositories.CredentialsRepository +import io.gnosis.data.repositories.SafeRepository +import io.gnosis.data.repositories.TransactionLocalRepository +import io.gnosis.safe.TestLifecycleRule +import io.gnosis.safe.Tracker +import io.gnosis.safe.appDispatchers +import io.gnosis.safe.test +import io.gnosis.safe.ui.base.BaseStateViewModel.ViewAction.* +import io.gnosis.safe.ui.settings.app.SettingsHandler +import io.gnosis.safe.ui.settings.owner.list.OwnerViewData +import io.gnosis.safe.utils.BalanceFormatter +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import pm.gnosis.crypto.ECDSASignature +import pm.gnosis.model.Solidity +import pm.gnosis.models.Transaction +import pm.gnosis.models.Wei +import java.math.BigDecimal +import java.math.BigInteger +import java.time.Instant +import java.util.Date + +class TxReviewViewModelTest { + + @get:Rule + val instantExecutorRule = TestLifecycleRule() + + private val safeRepository = mockk() + private val credentialsRepository = mockk() + private val localTxRepository = mockk() + private val settingsHandler = mockk() + private val rpcClient = mockk() + private val balanceFormatter = BalanceFormatter() + private val tracker = mockk() + + private lateinit var viewModel: TxReviewViewModel + + @Test + fun `loadDefaultKey(success, one owner key) should emit executionKey`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1) + coEvery { rpcClient.getBalances(any()) } returns listOf(Wei(BigInteger.TEN.pow(Chain.DEFAULT_CHAIN.currency.decimals))) + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.loadDefaultKey() + + with(viewModel.state.test().values()) { + Assert.assertEquals( + DefaultKey( + OwnerViewData( + TEST_SAFE_OWNER1.address, + TEST_SAFE_OWNER1.name, + Owner.Type.IMPORTED, + "1 ${Chain.DEFAULT_CHAIN.currency.symbol}", + false + ) + ), this[0].viewAction + ) + } + } + + @Test + fun `loadDefaultKey(success, several owner keys) should emit executionKey with highest balance`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1, TEST_SAFE_OWNER2) + coEvery { rpcClient.getBalances(listOf(TEST_SAFE_OWNER1.address, TEST_SAFE_OWNER2.address)) } returns listOf( + Wei(BigInteger.ONE), + Wei(BigInteger.TEN.pow(Chain.DEFAULT_CHAIN.currency.decimals)) + ) + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.loadDefaultKey() + + with(viewModel.state.test().values()) { + Assert.assertEquals( + DefaultKey( + OwnerViewData( + TEST_SAFE_OWNER2.address, + TEST_SAFE_OWNER2.name, + Owner.Type.IMPORTED, + "1 ${Chain.DEFAULT_CHAIN.currency.symbol}", + false + ) + ), this[0].viewAction + ) + } + } + + @Test + fun `loadDefaultKey(getBalances failure) should emit LoadBalancesFailed`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1) + coEvery { rpcClient.getBalances(any()) } throws Throwable() + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.loadDefaultKey() + + with(viewModel.state.test().values()) { + Assert.assertEquals( + ShowError( + LoadBalancesFailed + ), this[0].viewAction + ) + } + } + + @Test + fun `updateDefaultKey(different address) should emit DefaultKey and track key changed`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owner(any()) } returns TEST_SAFE_OWNER1 + coEvery { tracker.logTxExecKeyChanged() } just Runs + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.updateDefaultKey(TEST_SAFE_OWNER2.address) + + with(viewModel.state.test().values()) { + Assert.assertEquals( + DefaultKey( + OwnerViewData( + TEST_SAFE_OWNER1.address, + TEST_SAFE_OWNER1.name, + Owner.Type.IMPORTED, + null, + false + ) + ), this[0].viewAction + ) + } + + coVerify(exactly = 1) { + tracker.logTxExecKeyChanged() + } + } + + @Test + fun `updateDefaultKey(same address) should emit DefaultKey and not track key changed`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1) + coEvery { credentialsRepository.owner(any()) } returns TEST_SAFE_OWNER1 + coEvery { rpcClient.getBalances(any()) } returns listOf(Wei(BigInteger.TEN.pow(Chain.DEFAULT_CHAIN.currency.decimals))) + coEvery { tracker.logTxExecKeyChanged() } just Runs + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.loadDefaultKey() + viewModel.updateDefaultKey(TEST_SAFE_OWNER1.address) + + with(viewModel.state.test().values()) { + Assert.assertEquals( + DefaultKey( + OwnerViewData( + TEST_SAFE_OWNER1.address, + TEST_SAFE_OWNER1.name, + Owner.Type.IMPORTED, + null, + false + ) + ), this[0].viewAction + ) + } + + coVerify(exactly = 0) { + tracker.logTxExecKeyChanged() + } + } + + @Test + fun `updateEstimationParams should emit UpdateFee`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { tracker.logTxExecFieldsEdit(any()) } just Runs + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.updateEstimationParams(BigInteger.ZERO, BigInteger.ZERO, BigDecimal.ZERO, BigDecimal.ZERO) + + with(viewModel.state.test().values()) { + Assert.assertEquals( + UpdateFee( + "0 ${Chain.DEFAULT_CHAIN.currency.symbol}" + ), this[0].viewAction + ) + } + } + + @Test + fun `estimate(success) should emit UpdateFee`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1) + coEvery { rpcClient.getBalances(any()) } returns listOf(Wei(BigInteger.TEN.pow(Chain.DEFAULT_CHAIN.currency.decimals))) + coEvery { rpcClient.updateRpcUrl(any()) } just Runs + coEvery { rpcClient.ethTransaction(any(), any(), any(), any()) } returns Transaction.Legacy( + Chain.DEFAULT_CHAIN.chainId, + Solidity.Address(BigInteger.ZERO), + Solidity.Address(BigInteger.ZERO), + Wei(BigInteger.ZERO) + ) + coEvery { rpcClient.estimate(any()) } returns EstimationParams( + BigInteger.ZERO, + BigInteger.ZERO, + BigInteger.ZERO, + true, + BigInteger.ZERO + ) + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.setTxData( + TxData( + "", + null, + AddressInfo(value = Solidity.Address(BigInteger.ZERO)), + BigInteger.ZERO, + Operation.CALL + ), + DetailedExecutionInfo.MultisigExecutionDetails( + Date.from(Instant.now()), + BigInteger.ZERO + ) + ) + + with(viewModel.state.test().values()) { + Assert.assertEquals( + UpdateFee( + "0 ${Chain.DEFAULT_CHAIN.currency.symbol}" + ), this[0].viewAction + ) + } + } + + @Test + fun `sendForExecution(success) should send ethTx, save it locally, and emit NavigateTo`() { + coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply { + signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN)) + } + coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1) + coEvery { credentialsRepository.owner(any()) } returns TEST_SAFE_OWNER1 + coEvery { credentialsRepository.signWithOwner(any(), any()) } returns ECDSASignature(BigInteger.ONE, BigInteger.ONE) + coEvery { settingsHandler.usePasscode } returns false + coEvery { rpcClient.getBalances(any()) } returns listOf(Wei(BigInteger.TEN.pow(Chain.DEFAULT_CHAIN.currency.decimals))) + coEvery { rpcClient.updateRpcUrl(any()) } just Runs + coEvery { rpcClient.ethTransaction(any(), any(), any(), any()) } returns Transaction.Legacy( + Chain.DEFAULT_CHAIN.chainId, + Solidity.Address(BigInteger.ZERO), + Solidity.Address(BigInteger.ZERO), + Wei(BigInteger.ZERO) + ) + coEvery { rpcClient.estimate(any()) } returns EstimationParams( + BigInteger.ZERO, + BigInteger.ZERO, + BigInteger.ZERO, + true, + BigInteger.ZERO + ) + coEvery { rpcClient.send(any(), any()) } returns "0x0" + coEvery { localTxRepository.saveLocally(any(), any(), any(), any(), any()) } just Runs + coEvery { tracker.logTxExecSubmitted() } just Runs + + viewModel = TxReviewViewModel( + safeRepository, + credentialsRepository, + localTxRepository, + settingsHandler, + rpcClient, + balanceFormatter, + tracker, + appDispatchers + ) + + viewModel.setTxData( + TxData( + "", + null, + AddressInfo(value = Solidity.Address(BigInteger.ZERO)), + BigInteger.ZERO, + Operation.CALL + ), + DetailedExecutionInfo.MultisigExecutionDetails( + Date.from(Instant.now()), + BigInteger.ZERO + ) + ) + viewModel.signAndExecute() + + with(viewModel.state.test().values()) { + Assert.assertEquals( + NavigateTo( + TxReviewFragmentDirections.actionTxReviewFragmentToTxSuccessFragment() + ), this[0].viewAction + ) + } + + coVerify(exactly = 1) { + tracker.logTxExecSubmitted() + localTxRepository.saveLocally(any(), any(), any(), any(), any()) + } + } + + companion object { + val TEST_SAFE = Safe( + Solidity.Address(BigInteger.ZERO), + "safe_name", + Chain.DEFAULT_CHAIN.chainId + ) + + val TEST_SAFE_OWNER1 = Owner( + Solidity.Address(BigInteger.ONE), + "owner1", + Owner.Type.IMPORTED + ) + + val TEST_SAFE_OWNER2 = Owner( + Solidity.Address(BigInteger.TEN), + "owner2", + Owner.Type.IMPORTED + ) + } +} diff --git a/buildsystem/versions.gradle b/buildsystem/versions.gradle index 3e896a69cb..dc8aa854f4 100644 --- a/buildsystem/versions.gradle +++ b/buildsystem/versions.gradle @@ -44,7 +44,7 @@ ext { play_services_auth : '17.0.0', retrofit : '2.6.2', status_keycard : '3.0.1', - svalinn : 'fe7817a4d2',//''v0.16.0', + svalinn : 'v0.16.1', timber : '4.7.1', zxing : '3.3.1', play_core : '1.9.1', diff --git a/data/schemas/io.gnosis.data.db.HeimdallDatabase/10.json b/data/schemas/io.gnosis.data.db.HeimdallDatabase/10.json new file mode 100644 index 0000000000..38aaa63100 --- /dev/null +++ b/data/schemas/io.gnosis.data.db.HeimdallDatabase/10.json @@ -0,0 +1,309 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "2e9a1088f3438046177df042dfb4f0bd", + "entities": [ + { + "tableName": "safes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `local_name` TEXT NOT NULL, `chain_id` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`address`, `chain_id`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localName", + "columnName": "local_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chain_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "address", + "chain_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "owners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `private_key` TEXT, `seed_phrase` TEXT, `derivation_path` TEXT, `source_fingerprint` TEXT, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "private_key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seedPhrase", + "columnName": "seed_phrase", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyDerivationPath", + "columnName": "derivation_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceFingerprint", + "columnName": "source_fingerprint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "address" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chain_id` TEXT NOT NULL, `l2` INTEGER NOT NULL, `chain_name` TEXT NOT NULL, `chain_short_name` TEXT NOT NULL, `text_color` TEXT NOT NULL, `background_color` TEXT NOT NULL, `rpc_uri` TEXT NOT NULL, `rpc_authentication` INTEGER NOT NULL, `block_explorer_address_uri` TEXT NOT NULL, `block_explorer_tx_hash_uri` TEXT NOT NULL, `ens_registry_address` TEXT, `features` TEXT NOT NULL, PRIMARY KEY(`chain_id`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chain_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "l2", + "columnName": "l2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "chain_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "chain_short_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundColor", + "columnName": "background_color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rpcUri", + "columnName": "rpc_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rpcAuthentication", + "columnName": "rpc_authentication", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockExplorerTemplateAddress", + "columnName": "block_explorer_address_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockExplorerTemplateTxHash", + "columnName": "block_explorer_tx_hash_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ensRegistryAddress", + "columnName": "ens_registry_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chain_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "native_currency", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chain_id` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `logo_uri` TEXT NOT NULL, PRIMARY KEY(`chain_id`), FOREIGN KEY(`chain_id`) REFERENCES `chains`(`chain_id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chain_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logoUri", + "columnName": "logo_uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chain_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain_id" + ], + "referencedColumns": [ + "chain_id" + ] + } + ] + }, + { + "tableName": "local_transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chain_id` TEXT NOT NULL, `safe_address` TEXT NOT NULL, `safe_tx_nonce` TEXT NOT NULL, `safe_tx_hash` TEXT NOT NULL, `eth_tx_hash` TEXT NOT NULL, `status` TEXT NOT NULL, `submitted_at` INTEGER NOT NULL, PRIMARY KEY(`safe_address`, `chain_id`, `safe_tx_hash`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chain_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "safeAddress", + "columnName": "safe_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "safeTxNonce", + "columnName": "safe_tx_nonce", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "safeTxHash", + "columnName": "safe_tx_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ethTxHash", + "columnName": "eth_tx_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submittedAt", + "columnName": "submitted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "safe_address", + "chain_id", + "safe_tx_hash" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2e9a1088f3438046177df042dfb4f0bd')" + ] + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/io/gnosis/data/db/HeimdallDatabaseTest.kt b/data/src/androidTest/java/io/gnosis/data/db/HeimdallDatabaseTest.kt index f38d1b0595..f11bf5451d 100644 --- a/data/src/androidTest/java/io/gnosis/data/db/HeimdallDatabaseTest.kt +++ b/data/src/androidTest/java/io/gnosis/data/db/HeimdallDatabaseTest.kt @@ -49,7 +49,8 @@ class HeimdallDatabaseTest { HeimdallDatabase.MIGRATION_5_6, HeimdallDatabase.MIGRATION_6_7, HeimdallDatabase.MIGRATION_7_8, - HeimdallDatabase.MIGRATION_8_9 + HeimdallDatabase.MIGRATION_8_9, + HeimdallDatabase.MIGRATION_9_10 ) // Open latest version of the database. Room will validate the schema @@ -242,6 +243,7 @@ class HeimdallDatabaseTest { fun migrate6To7() { val chain = Chain( Chain.ID_MAINNET, + false, "Mainnet", "eth", "", @@ -250,7 +252,8 @@ class HeimdallDatabaseTest { RpcAuthentication.API_KEY_PATH, "", "", - null + null, + listOf() ) helper.createDatabase(TEST_DB, 6).apply { diff --git a/data/src/main/java/io/gnosis/data/db/HeimdallDatabase.kt b/data/src/main/java/io/gnosis/data/db/HeimdallDatabase.kt index cb06832706..3bb8f79c3b 100644 --- a/data/src/main/java/io/gnosis/data/db/HeimdallDatabase.kt +++ b/data/src/main/java/io/gnosis/data/db/HeimdallDatabase.kt @@ -9,6 +9,7 @@ import io.gnosis.data.BuildConfig import io.gnosis.data.db.daos.ChainDao import io.gnosis.data.db.daos.OwnerDao import io.gnosis.data.db.daos.SafeDao +import io.gnosis.data.db.daos.TransactionLocalDao import io.gnosis.data.models.* import pm.gnosis.svalinn.security.db.EncryptedByteArray import pm.gnosis.svalinn.security.db.EncryptedString @@ -18,7 +19,8 @@ import pm.gnosis.svalinn.security.db.EncryptedString Safe::class, Owner::class, Chain::class, - Chain.Currency::class + Chain.Currency::class, + TransactionLocal::class ], version = HeimdallDatabase.LATEST_DB_VERSION ) @TypeConverters( @@ -38,9 +40,11 @@ abstract class HeimdallDatabase : RoomDatabase() { abstract fun chainDao(): ChainDao + abstract fun transactionLocalDao(): TransactionLocalDao + companion object { const val DB_NAME = "safe_db" - const val LATEST_DB_VERSION = 9 + const val LATEST_DB_VERSION = 10 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { @@ -136,5 +140,13 @@ abstract class HeimdallDatabase : RoomDatabase() { ) } } + + val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `${TransactionLocal.TABLE_NAME}` (`${TransactionLocal.COL_CHAIN_ID}` TEXT NOT NULL, `${TransactionLocal.COL_SAFE_ADDRESS}` TEXT NOT NULL, `${TransactionLocal.COL_SAFE_TX_NONCE}` TEXT NOT NULL, `${TransactionLocal.COL_SAFE_TX_HASH}` TEXT NOT NULL, `${TransactionLocal.COL_ETH_TX_HASH}` TEXT NOT NULL, `${TransactionLocal.COL_STATUS}` TEXT NOT NULL, `${TransactionLocal.COL_SUBMITTED_AT}` INTEGER NOT NULL, PRIMARY KEY(`${TransactionLocal.COL_SAFE_ADDRESS}`, `${TransactionLocal.COL_CHAIN_ID}`, `${TransactionLocal.COL_SAFE_TX_HASH}`))""" + ) + } + } } } diff --git a/data/src/main/java/io/gnosis/data/db/daos/TransactionLocalDao.kt b/data/src/main/java/io/gnosis/data/db/daos/TransactionLocalDao.kt new file mode 100644 index 0000000000..cb305f6b19 --- /dev/null +++ b/data/src/main/java/io/gnosis/data/db/daos/TransactionLocalDao.kt @@ -0,0 +1,29 @@ +package io.gnosis.data.db.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.gnosis.data.models.TransactionLocal +import pm.gnosis.model.Solidity +import java.math.BigInteger + +@Dao +interface TransactionLocalDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(tx: TransactionLocal) + + @Delete + suspend fun delete(tx: TransactionLocal) + + @Query("DELETE FROM ${TransactionLocal.TABLE_NAME} WHERE ${TransactionLocal.COL_CHAIN_ID} = :chainId AND ${TransactionLocal.COL_SAFE_ADDRESS} = :safeAddress") + suspend fun clearOldRecords(chainId: BigInteger, safeAddress: Solidity.Address) + + @Query("SELECT * FROM ${TransactionLocal.TABLE_NAME} WHERE ${TransactionLocal.COL_CHAIN_ID} = :chainId AND ${TransactionLocal.COL_SAFE_ADDRESS} = :safeAddress AND ${TransactionLocal.COL_SAFE_TX_HASH} = :safeTxHash") + suspend fun loadyBySafeTxHash(chainId: BigInteger, safeAddress: Solidity.Address, safeTxHash: String): TransactionLocal? + + @Query("SELECT * FROM ${TransactionLocal.TABLE_NAME} WHERE ${TransactionLocal.COL_CHAIN_ID} = :chainId AND ${TransactionLocal.COL_SAFE_ADDRESS} = :safeAddress ORDER BY ${TransactionLocal.COL_SAFE_TX_NONCE} DESC LIMIT 1") + suspend fun loadLatest(chainId: BigInteger, safeAddress: Solidity.Address): TransactionLocal? +} diff --git a/data/src/main/java/io/gnosis/data/models/TransactionLocal.kt b/data/src/main/java/io/gnosis/data/models/TransactionLocal.kt new file mode 100644 index 0000000000..a5cb7b1c5e --- /dev/null +++ b/data/src/main/java/io/gnosis/data/models/TransactionLocal.kt @@ -0,0 +1,51 @@ +package io.gnosis.data.models + +import androidx.room.ColumnInfo +import androidx.room.Entity +import io.gnosis.data.models.TransactionLocal.Companion.COL_CHAIN_ID +import io.gnosis.data.models.TransactionLocal.Companion.COL_SAFE_ADDRESS +import io.gnosis.data.models.TransactionLocal.Companion.COL_SAFE_TX_HASH +import io.gnosis.data.models.TransactionLocal.Companion.TABLE_NAME +import io.gnosis.data.models.transaction.TransactionStatus +import pm.gnosis.model.Solidity +import java.math.BigInteger + +@Entity( + tableName = TABLE_NAME, + primaryKeys = [COL_SAFE_ADDRESS, COL_CHAIN_ID, COL_SAFE_TX_HASH] +) +data class TransactionLocal( + + @ColumnInfo(name = COL_CHAIN_ID) + val chainId: BigInteger, + + @ColumnInfo(name = COL_SAFE_ADDRESS) + val safeAddress: Solidity.Address, + + @ColumnInfo(name = COL_SAFE_TX_NONCE) + val safeTxNonce: BigInteger, + + @ColumnInfo(name = COL_SAFE_TX_HASH) + val safeTxHash: String, + + @ColumnInfo(name = COL_ETH_TX_HASH) + val ethTxHash: String, + + @ColumnInfo(name = COL_STATUS) + val status: TransactionStatus, + + @ColumnInfo(name = COL_SUBMITTED_AT) + val submittedAt: Long, +) { + companion object { + const val TABLE_NAME = "local_transactions" + + const val COL_CHAIN_ID = "chain_id" + const val COL_SAFE_ADDRESS = "safe_address" + const val COL_SAFE_TX_NONCE = "safe_tx_nonce" + const val COL_SAFE_TX_HASH = "safe_tx_hash" + const val COL_ETH_TX_HASH = "eth_tx_hash" + const val COL_STATUS = "status" + const val COL_SUBMITTED_AT = "submitted_at" + } +} diff --git a/data/src/main/java/io/gnosis/data/repositories/TransactionLocalRepository.kt b/data/src/main/java/io/gnosis/data/repositories/TransactionLocalRepository.kt new file mode 100644 index 0000000000..6f256dd71a --- /dev/null +++ b/data/src/main/java/io/gnosis/data/repositories/TransactionLocalRepository.kt @@ -0,0 +1,70 @@ +package io.gnosis.data.repositories + +import io.gnosis.data.backend.rpc.RpcClient +import io.gnosis.data.db.daos.TransactionLocalDao +import io.gnosis.data.models.Safe +import io.gnosis.data.models.TransactionLocal +import io.gnosis.data.models.transaction.TransactionStatus +import pm.gnosis.models.Transaction +import java.math.BigInteger + +class TransactionLocalRepository( + private val localTxDao: TransactionLocalDao, + private val rpcClient: RpcClient +) { + + suspend fun saveLocally(tx: Transaction, txHash: String, safeTxHash: String, safeTxNonce: BigInteger, submittedAt: Long) { + localTxDao.clearOldRecords(tx.chainId, tx.to) + val localTx = TransactionLocal( + safeAddress = tx.to, + chainId = tx.chainId, + safeTxNonce = safeTxNonce, + safeTxHash = safeTxHash, + ethTxHash = txHash, + status = TransactionStatus.PENDING, + submittedAt = submittedAt + ) + localTxDao.save(localTx) + } + + suspend fun save(localTx: TransactionLocal) { + localTxDao.save(localTx) + } + + suspend fun delete(localTx: TransactionLocal) { + localTxDao.delete(localTx) + } + + suspend fun getLocalTx(safe: Safe, safeTxHash: String): TransactionLocal? = + localTxDao.loadyBySafeTxHash(safe.chainId, safe.address, safeTxHash) + + suspend fun getLocalTxLatest(safe: Safe): TransactionLocal? = + localTxDao.loadLatest(safe.chainId, safe.address) + + suspend fun updateLocalTx(safe: Safe, safeTxHash: String): TransactionLocal? { + return getLocalTx(safe, safeTxHash)?.let { + updateLocalTx(safe, it) + } + } + + suspend fun updateLocalTxLatest(safe: Safe): TransactionLocal? { + return getLocalTxLatest(safe)?.let { + updateLocalTx(safe, it) + } + } + + suspend fun updateLocalTx(safe: Safe, localTx: TransactionLocal): TransactionLocal? { + var localTx = localTx + kotlin.runCatching { + rpcClient.getTransactionReceipt(safe.chain, localTx.ethTxHash) + }.onSuccess { txReceipt -> + localTx = localTx.copy( + status = if (txReceipt.status == BigInteger.ONE) TransactionStatus.SUCCESS else TransactionStatus.FAILED + ) + localTxDao.save(localTx) + }.onFailure { + // fail silently + } + return localTx + } +}