zcash-android-wallet-sdk/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/PersistentTransactionManage...

314 lines
12 KiB
Kotlin
Raw Normal View History

package cash.z.ecc.android.sdk.internal.transaction
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.db.entity.PendingTransactionEntity
import cash.z.ecc.android.sdk.db.entity.isCancelled
import cash.z.ecc.android.sdk.db.entity.isFailedEncoding
import cash.z.ecc.android.sdk.db.entity.isSubmitted
2021-11-18 04:10:30 -08:00
import cash.z.ecc.android.sdk.internal.db.PendingTransactionDao
import cash.z.ecc.android.sdk.internal.db.PendingTransactionDb
import cash.z.ecc.android.sdk.internal.service.LightWalletService
2021-11-18 04:10:30 -08:00
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
2019-11-01 13:25:28 -07:00
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.PrintWriter
import java.io.StringWriter
2019-11-01 13:25:28 -07:00
import kotlin.math.max
/**
* Facilitates persistent attempts to ensure that an outbound transaction is completed.
*
* @param db the database where the wallet can freely write information related to pending
* transactions. This database effectively serves as the mempool for transactions created by this
* wallet.
* @property encoder responsible for encoding a transaction by taking all the inputs and returning
* an [cash.z.ecc.android.sdk.entity.EncodedTransaction] object containing the raw bytes and transaction
* id.
* @property service the lightwallet service used to submit transactions.
*/
2019-11-01 13:25:28 -07:00
class PersistentTransactionManager(
db: PendingTransactionDb,
internal val encoder: TransactionEncoder,
2019-11-01 13:25:28 -07:00
private val service: LightWalletService
) : OutboundTransactionManager {
2019-07-14 15:13:12 -07:00
2019-11-01 13:25:28 -07:00
private val daoMutex = Mutex()
/**
* Internal reference to the dao that is only accessed after locking the [daoMutex] in order
* to enforce DB access in both a threadsafe and coroutinesafe way.
*/
private val _dao: PendingTransactionDao = db.pendingTransactionDao()
/**
* Constructor that creates the database and then executes a callback on it.
*/
constructor(
appContext: Context,
2019-11-01 13:25:28 -07:00
encoder: TransactionEncoder,
service: LightWalletService,
2019-07-14 15:13:12 -07:00
dataDbName: String = "PendingTransactions.db"
) : this(
2019-07-14 15:13:12 -07:00
Room.databaseBuilder(
appContext,
PendingTransactionDb::class.java,
dataDbName
2019-11-01 13:25:28 -07:00
).setJournalMode(RoomDatabase.JournalMode.TRUNCATE).build(),
encoder,
service
2019-07-14 15:13:12 -07:00
)
//
// OutboundTransactionManager implementation
//
override suspend fun initSpend(
value: Zatoshi,
toAddress: String,
2019-11-01 13:25:28 -07:00
memo: String,
fromAccountIndex: Int
): PendingTransaction = withContext(Dispatchers.IO) {
twig("constructing a placeholder transaction")
2019-11-01 13:25:28 -07:00
var tx = PendingTransactionEntity(
value = value.value,
2019-11-01 13:25:28 -07:00
toAddress = toAddress,
memo = memo.toByteArray(),
2019-11-01 13:25:28 -07:00
accountIndex = fromAccountIndex
)
try {
safeUpdate("creating tx in DB") {
tx = findById(create(tx))!!
twig("successfully created TX in DB with id: ${tx.id}")
2019-11-01 13:25:28 -07:00
}
} catch (t: Throwable) {
twig(
"Unknown error while attempting to create and fetch pending transaction:" +
" ${t.message} caused by: ${t.cause}"
)
}
2019-11-01 13:25:28 -07:00
tx
}
override suspend fun applyMinedHeight(pendingTx: PendingTransaction, minedHeight: Int) {
twig("a pending transaction has been mined!")
safeUpdate("updating mined height for pending tx id: ${pendingTx.id} to $minedHeight") {
updateMinedHeight(pendingTx.id, minedHeight)
}
2019-11-01 13:25:28 -07:00
}
override suspend fun encode(
2019-11-01 13:25:28 -07:00
spendingKey: String,
pendingTx: PendingTransaction
): PendingTransaction = withContext(Dispatchers.IO) {
twig("managing the creation of a transaction")
2019-11-01 13:25:28 -07:00
var tx = pendingTx as PendingTransactionEntity
try {
twig("beginning to encode transaction with : $encoder")
2019-11-01 13:25:28 -07:00
val encodedTx = encoder.createTransaction(
spendingKey,
tx.value,
tx.toAddress,
tx.memo,
2019-11-01 13:25:28 -07:00
tx.accountIndex
)
twig("successfully encoded transaction!")
2021-07-29 10:18:55 -07:00
safeUpdate("updating transaction encoding", -1) {
updateEncoding(tx.id, encodedTx.raw, encodedTx.txId, encodedTx.expiryHeight)
}
} catch (t: Throwable) {
2021-05-25 13:02:27 -07:00
var message = "failed to encode transaction due to : ${t.message}"
t.cause?.let { message += " caused by: $it" }
twig(message)
safeUpdate("updating transaction error info") {
updateError(tx.id, message, ERROR_ENCODING)
}
} finally {
2021-07-29 10:18:55 -07:00
safeUpdate("incrementing transaction encodeAttempts (from: ${tx.encodeAttempts})", -1) {
updateEncodeAttempts(tx.id, max(1, tx.encodeAttempts + 1))
tx = findById(tx.id)!!
}
}
2019-11-01 13:25:28 -07:00
tx
}
override suspend fun encode(
spendingKey: String,
transparentSecretKey: String,
pendingTx: PendingTransaction
): PendingTransaction {
twig("managing the creation of a shielding transaction")
var tx = pendingTx as PendingTransactionEntity
try {
twig("beginning to encode shielding transaction with : $encoder")
val encodedTx = encoder.createShieldingTransaction(
spendingKey,
transparentSecretKey,
tx.memo
)
twig("successfully encoded shielding transaction!")
safeUpdate("updating shielding transaction encoding") {
updateEncoding(tx.id, encodedTx.raw, encodedTx.txId, encodedTx.expiryHeight)
}
} catch (t: Throwable) {
2021-05-25 13:02:27 -07:00
var message = "failed to encode auto-shielding transaction due to : ${t.message}"
t.cause?.let { message += " caused by: $it" }
twig(message)
safeUpdate("updating shielding transaction error info") {
updateError(tx.id, message, ERROR_ENCODING)
}
} finally {
safeUpdate("incrementing shielding transaction encodeAttempts (from: ${tx.encodeAttempts})") {
updateEncodeAttempts(tx.id, max(1, tx.encodeAttempts + 1))
tx = findById(tx.id)!!
}
}
return tx
}
override suspend fun submit(pendingTx: PendingTransaction): PendingTransaction = withContext(Dispatchers.IO) {
// reload the tx to check for cancellation
var tx = pendingTransactionDao { findById(pendingTx.id) }
?: throw IllegalStateException(
"Error while submitting transaction. No pending" +
" transaction found that matches the one being submitted. Verify that the" +
" transaction still exists among the set of pending transactions."
)
try {
// do nothing if failed or cancelled
when {
tx.isFailedEncoding() -> twig("Warning: this transaction will not be submitted because it failed to be encoded.")
tx.isCancelled() -> twig("Warning: ignoring cancelled transaction with id ${tx.id}. We will not submit it to the network because it has been cancelled.")
else -> {
2021-07-29 10:18:55 -07:00
twig("submitting transaction with memo: ${tx.memo} amount: ${tx.value}", -1)
val response = service.submitTransaction(tx.raw)
val error = response.errorCode < 0
twig(
"${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with" +
" response: ${response.errorCode}: ${response.errorMessage}"
)
2021-07-29 10:18:55 -07:00
safeUpdate("updating submitted transaction (hadError: $error)", -1) {
updateError(tx.id, if (error) response.errorMessage else null, response.errorCode)
updateSubmitAttempts(tx.id, max(1, tx.submitAttempts + 1))
}
}
2019-11-01 13:25:28 -07:00
}
} catch (t: Throwable) {
2019-11-01 13:25:28 -07:00
// a non-server error has occurred
2021-05-25 13:02:27 -07:00
var message =
"Unknown error while submitting transaction: ${t.message}"
t.cause?.let { message += " caused by: $it" }
2019-11-01 13:25:28 -07:00
twig(message)
safeUpdate("updating submission failure") {
updateError(tx.id, t.message, ERROR_SUBMITTING)
updateSubmitAttempts(tx.id, max(1, tx.submitAttempts + 1))
}
} finally {
2021-07-29 10:18:55 -07:00
safeUpdate("fetching latest tx info", -1) {
tx = findById(tx.id)!!
}
}
tx
}
override suspend fun monitorById(id: Long): Flow<PendingTransaction> {
return pendingTransactionDao { monitorById(id) }
}
override suspend fun isValidShieldedAddress(address: String) =
encoder.isValidShieldedAddress(address)
override suspend fun isValidTransparentAddress(address: String) =
encoder.isValidTransparentAddress(address)
override suspend fun cancel(pendingId: Long): Boolean {
2019-11-01 13:25:28 -07:00
return pendingTransactionDao {
val tx = findById(pendingId)
2019-11-01 13:25:28 -07:00
if (tx?.isSubmitted() == true) {
twig("Attempt to cancel transaction failed because it has already been submitted!")
2019-11-01 13:25:28 -07:00
false
} else {
twig("Cancelling unsubmitted transaction id: $pendingId")
cancel(pendingId)
2019-11-01 13:25:28 -07:00
true
}
}
}
override suspend fun findById(id: Long) = pendingTransactionDao {
findById(id)
}
override suspend fun markForDeletion(id: Long) = pendingTransactionDao {
withContext(IO) {
twig("[cleanup] marking pendingTx $id for deletion")
removeRawTransactionId(id)
updateError(id, "safe to delete", -9090)
}
}
/**
* Remove a transaction and pretend it never existed.
*
* @return the final number of transactions that were removed from the database.
*/
override suspend fun abort(existingTransaction: PendingTransaction): Int {
return pendingTransactionDao {
twig("[cleanup] Deleting pendingTxId: ${existingTransaction.id}")
delete(existingTransaction as PendingTransactionEntity)
}
}
override fun getAll() = _dao.getAll()
//
// Helper functions
//
/**
2019-11-01 13:25:28 -07:00
* Updating the pending transaction is often done at the end of a function but still should
* happen within a try/catch block, surrounded by logging. So this helps with that while also
* ensuring that no other coroutines are concurrently interacting with the DAO.
*/
2021-07-29 10:18:55 -07:00
private suspend fun <R> safeUpdate(logMessage: String = "", priority: Int = 0, block: suspend PendingTransactionDao.() -> R): R? {
2019-11-01 13:25:28 -07:00
return try {
twig(logMessage)
pendingTransactionDao { block() }
2019-11-01 13:25:28 -07:00
} catch (t: Throwable) {
val stacktrace = StringWriter().also { t.printStackTrace(PrintWriter(it)) }.toString()
twig(
"Unknown error while attempting to '$logMessage':" +
" ${t.message} caused by: ${t.cause} stacktrace: $stacktrace"
)
null
2019-11-01 13:25:28 -07:00
}
}
private suspend fun <T> pendingTransactionDao(block: suspend PendingTransactionDao.() -> T): T {
return daoMutex.withLock {
withContext(IO) {
_dao.block()
}
2019-11-01 13:25:28 -07:00
}
}
companion object {
/** Error code for an error while encoding a transaction */
const val ERROR_ENCODING = 2000
/** Error code for an error while submitting a transaction */
const val ERROR_SUBMITTING = 3000
}
2019-11-01 13:25:28 -07:00
}