Iterate and refine send and active transaction behavior

Send is now functional and shows up in active transactions.
This involved:
- reducing the exposure of the seed
- consistently using Ints for blockheight everywhere to match zcash
- adding the use of spending keys
- adding account initialization on startup
- using accounts but defaulting to account 0
- internalizing birthday so we no longer need a reference outside of the library
- enabling cancellation during send
- cleanup active transaction manager
This commit is contained in:
Kevin Gorham 2019-02-05 13:19:06 -05:00
parent 888646f73b
commit bf7b3ee744
33 changed files with 676 additions and 309 deletions

View File

@ -21,6 +21,7 @@ buildscript {
classpath "com.github.ben-manes:gradle-versions-plugin:0.20.0"
classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1'
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.7"
classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.8.6'
}
}
@ -33,9 +34,10 @@ apply plugin: 'kotlin-allopen'
apply plugin: 'com.google.protobuf'
apply plugin: 'com.github.ben-manes.versions'
apply plugin: 'com.github.dcendents.android-maven'
apply plugin: 'com.getkeepsafe.dexcount'
group = 'cash.z.android.wallet'
version = '1.4.0'
version = '1.6.0'
repositories {
google()
@ -48,7 +50,7 @@ android {
defaultConfig {
minSdkVersion 16
targetSdkVersion 28
versionCode = 1_04_00
versionCode = 1_06_00
versionName = version
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled false

View File

@ -4,7 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.db.CompactBlockDb
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.entity.CompactBlock
import org.junit.*
import org.junit.Assert.*

View File

@ -5,8 +5,8 @@ import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.db.CompactBlockDb
import cash.z.wallet.sdk.db.DerivedDataDb
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.vo.Transaction
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.entity.Transaction
import org.junit.*
import org.junit.Assert.*

View File

@ -8,9 +8,9 @@ import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.NoteDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.jni.JniConverter
import cash.z.wallet.sdk.vo.Block
import cash.z.wallet.sdk.vo.Note
import cash.z.wallet.sdk.vo.Transaction
import cash.z.wallet.sdk.entity.Block
import cash.z.wallet.sdk.entity.Note
import cash.z.wallet.sdk.entity.Transaction
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.atLeast
import com.nhaarman.mockitokotlin2.mock
@ -48,12 +48,11 @@ internal class PollingTransactionRepositoryTest {
val dbName = "polling-test.db"
val context = ApplicationProvider.getApplicationContext<Context>()
converter = mock {
on { getBalance(any()) }.thenAnswer { balanceProvider.next() }
on { getBalance(any(), 0) }.thenAnswer { balanceProvider.next() }
}
repository = PollingTransactionRepository(context, dbName, pollFrequency, converter, twig) { db ->
blockDao = db.blockDao()
transactionDao = db.transactionDao()
noteDao = db.noteDao()
}
}
@ -65,7 +64,6 @@ internal class PollingTransactionRepositoryTest {
// just verify the cascading deletes are working, for sanity
assertEquals(0, blockDao.count())
assertEquals(0, transactionDao.count())
assertEquals(0, noteDao.count())
}
@Test
@ -92,14 +90,14 @@ internal class PollingTransactionRepositoryTest {
// we at least requested the balance more times from the rust library than we got it in the channel
// (meaning the duplicates were ignored)
verify(converter, atLeast(distinctBalances + 1)).getBalance(anyString())
verify(converter, atLeast(distinctBalances + 1)).getBalance(anyString(), 0)
}
@Test
fun testTransactionsAreNotLost() = runBlocking<Unit> {
val iterations = 10
balanceProvider = List(iterations + 1) { it.toLong() }.iterator()
val transactionChannel = repository.transactions()
val transactionChannel = repository.allTransactions()
repository.start(this)
insert(iterations) {
repeat(iterations) {
@ -147,7 +145,7 @@ internal class PollingTransactionRepositoryTest {
return Note(
id.toInt(),
id.toInt(),
value = Random.nextInt(0, 10)
value = Random.nextLong(0L, 10L)
)
}
}

View File

@ -5,7 +5,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.entity.CompactBlock
import org.junit.*
import org.junit.Assert.*

View File

@ -5,12 +5,13 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.dao.NoteDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.vo.CompactBlock
import org.junit.*
import org.junit.Assert.*
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
class DerivedDbIntegrationTest {
@get:Rule
@ -31,11 +32,6 @@ class DerivedDbIntegrationTest {
assertNotNull(blocks)
}
@Test
fun testDaoExists_Note() {
assertNotNull(notes)
}
@Test
fun testCount_Transaction() {
assertEquals(5, transactions.count())
@ -46,14 +42,9 @@ class DerivedDbIntegrationTest {
assertEquals(80101, blocks.count())
}
@Test
fun testCount_Note() {
assertEquals(5, notes.count())
}
@Test
fun testNoteQuery() {
val all = notes.getAll()
val all = transactions.getAll()
assertEquals(3, all.size)
}
@ -74,7 +65,6 @@ class DerivedDbIntegrationTest {
companion object {
private lateinit var transactions: TransactionDao
private lateinit var blocks: BlockDao
private lateinit var notes: NoteDao
private lateinit var db: DerivedDataDb
@BeforeClass
@ -89,7 +79,6 @@ class DerivedDbIntegrationTest {
.apply {
transactions = transactionDao()
blocks = blockDao()
notes = noteDao()
}
}

View File

@ -9,9 +9,10 @@ import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.dao.NoteDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.data.SampleSeedProvider
import cash.z.wallet.sdk.ext.toBlockHeight
import cash.z.wallet.sdk.jni.JniConverter
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.entity.CompactBlock
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import org.junit.*
@ -42,8 +43,8 @@ class GlueIntegrationTest {
private fun addData() {
val result = blockingStub.getBlockRange(
BlockRange.newBuilder()
.setStart(373070L.toBlockHeight())
.setEnd(373085L.toBlockHeight())
.setStart(373070.toBlockHeight())
.setEnd(373085.toBlockHeight())
.build()
)
while (result.hasNext()) {
@ -56,13 +57,11 @@ class GlueIntegrationTest {
private fun scanData() {
val dbFileName = "/data/user/0/cash.z.wallet.sdk.test/databases/new-data-glue.db"
converter.initDataDb(dbFileName)
converter.initAccountsTable(dbFileName, "dummyseed".toByteArray(), 1)
Log.e("tezt", "scanning blocks...")
val result = converter.scanBlocks(
cacheDbPath,
dbFileName,
"dummyseed".toByteArray(),
373070
)
val result = converter.scanBlocks(cacheDbPath, dbFileName)
System.err.println("done.")
}

View File

@ -11,7 +11,7 @@ import cash.z.wallet.sdk.dao.NoteDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.ext.toBlockHeight
import cash.z.wallet.sdk.jni.JniConverter
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.entity.CompactBlock
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import org.junit.*
@ -43,8 +43,8 @@ class GlueSetupIntegrationTest {
private fun addData() {
val result = blockingStub.getBlockRange(
BlockRange.newBuilder()
.setStart(373070L.toBlockHeight())
.setEnd(373085L.toBlockHeight())
.setStart(373070.toBlockHeight())
.setEnd(373085.toBlockHeight())
.build()
)
while (result.hasNext()) {
@ -56,12 +56,7 @@ class GlueSetupIntegrationTest {
private fun scanData() {
Log.e("tezt", "scanning blocks...")
val result = converter.scanBlocks(
cacheDbPath,
"/data/user/0/cash.z.wallet.sdk.test/databases/data-glue.db",
"dummyseed".toByteArray(),
373070
)
val result = converter.scanBlocks(cacheDbPath, "/data/user/0/cash.z.wallet.sdk.test/databases/data-glue.db")
System.err.println("done.")
}

View File

@ -1,5 +1,6 @@
package cash.z.wallet.sdk.db
import android.text.format.DateUtils
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.data.*
import cash.z.wallet.sdk.jni.JniConverter
@ -16,8 +17,8 @@ verify that the SDK is behaving as expected.
*/
class IntegrationTest {
private val dataDbName = "IntegrationData.db"
private val cacheDdName = "IntegrationCache.db"
private val dataDbName = "IntegrationData41.db"
private val cacheDdName = "IntegrationCache41.db"
private val context = InstrumentationRegistry.getInstrumentation().context
private lateinit var downloader: CompactBlockStream
@ -39,7 +40,7 @@ class IntegrationTest {
}
}
@Test
@Test(timeout = 1L * DateUtils.MINUTE_IN_MILLIS/10)
fun testSync() = runBlocking<Unit> {
val converter = JniConverter()
converter.initLogs()
@ -48,14 +49,16 @@ class IntegrationTest {
downloader = CompactBlockStream("10.0.2.2", 9067, logger)
processor = CompactBlockProcessor(context, converter, cacheDdName, dataDbName, logger = logger)
repository = PollingTransactionRepository(context, dataDbName, 10_000L, converter, logger)
wallet = Wallet(converter, context.getDatabasePath(dataDbName).absolutePath, context.cacheDir.absolutePath, arrayOf(0), SampleSeedProvider("dummyseed"))
wallet = Wallet(converter, context.getDatabasePath(dataDbName).absolutePath, context.cacheDir.absolutePath, arrayOf(0), SampleSeedProvider("dummyseed"), SampleSpendingKeyProvider("dummyseed"), logger)
// repository.start(this)
synchronizer = Synchronizer(
downloader,
processor,
repository,
ActiveTransactionManager(repository, downloader.connection, wallet, logger),
wallet,
1000,
logger
).start(this)

View File

@ -0,0 +1,56 @@
package cash.z.wallet.sdk.db
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.TransactionDao
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
class ManualTransactionSender {
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun sendTransactionViaTest() {
val transaction = transactions.findById(12)
val hex = transaction?.raw?.toHex()
assertEquals("foo", hex)
}
private fun ByteArray.toHex(): String {
val sb = StringBuilder(size * 2)
for (b in this)
sb.append(String.format("%02x", b))
return sb.toString()
}
companion object {
private lateinit var transactions: TransactionDao
private lateinit var db: DerivedDataDb
@BeforeClass
@JvmStatic
fun setup() {
// TODO: put this database in the assets directory and open it from there via .openHelperFactory(new AssetSQLiteOpenHelperFactory()) seen here https://github.com/albertogiunta/sqliteAsset
db = Room
.databaseBuilder(ApplicationProvider.getApplicationContext(), DerivedDataDb::class.java, "wallet_data1202.db")
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.fallbackToDestructiveMigration()
.build()
.apply {
transactions = transactionDao()
}
}
@AfterClass
@JvmStatic
fun close() {
db.close()
}
}
}

View File

@ -8,27 +8,29 @@ import org.junit.Test
class JniConverterTest {
private val dbDataFile = "/data/user/0/cash.z.wallet.sdk.test/databases/data2.db"
@Test
fun testGetAddress_exists() {
assertNotNull(converter.getAddress("dummyseed".toByteArray()))
assertNotNull(converter.getAddress(dbDataFile, 0))
}
@Test
fun testGetAddress_valid() {
val address = converter.getAddress("dummyseed".toByteArray())
val address = converter.getAddress(dbDataFile, 0)
val expectedAddress = "ztestsapling1snmqdnfqnc407pvqw7sld8w5zxx6nd0523kvlj4jf39uvxvh7vn0hs3q38n07806dwwecqwke3t"
assertEquals("Invalid address", expectedAddress, address)
}
@Test
fun testScanBlocks() {
converter.initDataDb("/data/user/0/cash.z.wallet.sdk.test/databases/data2.db")
converter.initDataDb(dbDataFile)
converter.initAccountsTable(dbDataFile, "dummyseed".toByteArray(), 1)
// note: for this to work, the db file below must be uploaded to the device. Eventually, this test will be self-contained and remove that requirement.
val result = converter.scanBlocks(
"/data/user/0/cash.z.wallet.sdk.test/databases/dummy-cache.db",
"/data/user/0/cash.z.wallet.sdk.test/databases/data2.db",
"dummyseed".toByteArray(),
343900
dbDataFile
)
// Thread.sleep(15 * DateUtils.MINUTE_IN_MILLIS)
assertEquals("Invalid number of results", 3, 3)
@ -38,9 +40,11 @@ class JniConverterTest {
fun testSend() {
converter.sendToAddress(
"/data/user/0/cash.z.wallet.sdk.test/databases/data2.db",
"dummyseed".toByteArray(),
0,
"dummykey",
"ztestsapling1fg82ar8y8whjfd52l0xcq0w3n7nn7cask2scp9rp27njeurr72ychvud57s9tu90fdqgwdt07lg",
210_000,
"",
"/data/user/0/cash.z.wallet.sdk.test/databases/sapling-spend.params",
"/data/user/0/cash.z.wallet.sdk.test/databases/sapling-output.params"
)

View File

@ -1,7 +1,7 @@
package cash.z.wallet.sdk.dao
import androidx.room.*
import cash.z.wallet.sdk.vo.Block
import cash.z.wallet.sdk.entity.Block
@Dao
interface BlockDao {
@ -24,7 +24,7 @@ interface BlockDao {
fun deleteAll()
@Query("SELECT MAX(height) FROM blocks")
fun lastScannedHeight(): Long
fun lastScannedHeight(): Int
@Query("UPDATE blocks SET time=:time WHERE height = :height")
fun updateTime(height: Int, time: Int)

View File

@ -1,7 +1,7 @@
package cash.z.wallet.sdk.dao
import androidx.room.*
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.entity.CompactBlock
@Dao
interface CompactBlockDao {
@ -20,4 +20,7 @@ interface CompactBlockDao {
@Delete
fun delete(block: CompactBlock)
@Query("SELECT MAX(height) FROM compactblocks")
fun latestBlockHeight(): Int
}

View File

@ -1,8 +1,7 @@
package cash.z.wallet.sdk.dao
import androidx.room.*
import cash.z.wallet.sdk.vo.Note
import cash.z.wallet.sdk.vo.NoteQuery
import cash.z.wallet.sdk.entity.Note
@Dao
interface NoteDao {
@ -15,24 +14,6 @@ interface NoteDao {
@Query("DELETE FROM received_notes WHERE id_note = :id")
fun deleteById(id: Int)
/**
* Query blocks, transactions and received_notes to aggregate information on send/receive
*/
@Query("""
SELECT received_notes.tx AS txId,
received_notes.value,
transactions.block AS height,
transactions.raw IS NOT NULL AS sent,
blocks.time
FROM received_notes,
transactions,
blocks
WHERE received_notes.tx = transactions.id_tx
AND blocks.height = transactions.block
ORDER BY height DESC;
""")
fun getAll(): List<NoteQuery>
@Delete
fun delete(block: Note)

View File

@ -1,7 +1,7 @@
package cash.z.wallet.sdk.dao
import androidx.room.*
import cash.z.wallet.sdk.vo.Transaction
import cash.z.wallet.sdk.entity.Transaction
@Dao
interface TransactionDao {
@ -14,8 +14,30 @@ interface TransactionDao {
@Query("DELETE FROM transactions WHERE id_tx = :id")
fun deleteById(id: Long)
@Query("SELECT * FROM transactions WHERE 1")
fun getAll(): List<Transaction>
/**
* Query transactions, aggregating information on send/receive, sorted carefully so the newest data is at the top
* and the oldest transactions are at the bottom.
*/
@Query("""
SELECT transactions.id_tx AS txId,
transactions.block AS height,
transactions.raw IS NOT NULL AS isSend,
transactions.block IS NOT NULL AS isMined,
blocks.time AS timeInSeconds,
CASE
WHEN transactions.raw IS NOT NULL THEN sent_notes.value
ELSE received_notes.value
END AS value
FROM transactions
LEFT JOIN sent_notes
ON transactions.id_tx = sent_notes.tx
LEFT JOIN received_notes
ON transactions.id_tx = received_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
ORDER BY block IS NOT NUll, height DESC, time DESC, txId DESC
""")
fun getAll(): List<WalletTransaction>
@Delete
fun delete(transaction: Transaction)
@ -24,3 +46,12 @@ interface TransactionDao {
fun count(): Int
}
data class WalletTransaction(
val txId: Long = 0L,
val value: Long = 0L,
val height: Int? = null,
val isSend: Boolean = false,
val timeInSeconds: Long = 0L,
val isMined: Boolean = false
)

View File

@ -1,33 +1,65 @@
package cash.z.wallet.sdk.data
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.ext.masked
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch
import java.util.*
import kotlin.coroutines.CoroutineContext
import cash.z.wallet.sdk.data.TransactionState.*
import cash.z.wallet.sdk.rpc.CompactFormats
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Manages active send/receive transactions. These are transactions that have been initiated but not completed with
* sufficient confirmations. All other transactions are stored in a separate [TransactionRepository].
*/
class ActiveTransactionManager(logger: Twig = SilentTwig()) : CoroutineScope, Twig by logger {
class ActiveTransactionManager(
private val repository: TransactionRepository,
private val service: CompactBlockStream.Connection,
private val wallet: Wallet,
logger: Twig = SilentTwig()
) : CoroutineScope, Twig by logger {
private val job = Job()
override val coroutineContext: CoroutineContext = Dispatchers.Default + job
override val coroutineContext: CoroutineContext = Dispatchers.Main + job
private lateinit var sentTransactionMonitorJob: Job
// private lateinit var confirmationMonitorJob: Job
// mutableMapOf gives the same result but we're explicit about preserving insertion order, since we rely on that
private val activeTransactions = LinkedHashMap<ActiveTransaction, TransactionState>()
private val channel = ConflatedBroadcastChannel<Map<ActiveTransaction, TransactionState>>()
private val transactionSubscription = repository.allTransactions()
// private val latestHeightSubscription = service.latestHeights()
fun subscribe(): ReceiveChannel<Map<ActiveTransaction, TransactionState>> {
return channel.openSubscription()
}
fun start() {
twig("ActiveTransactionManager starting")
sentTransactionMonitorJob = launchSentTransactionMonitor()
// confirmationMonitorJob = launchConfirmationMonitor() <- monitoring received transactions is disabled, presently <- TODO: enable confirmation monitor
}
fun stop() {
twig("ActiveTransactionManager stopping")
channel.cancel()
job.cancel()
sentTransactionMonitorJob.cancel()
transactionSubscription.cancel()
// confirmationMonitorJob.cancel() <- TODO: enable confirmation monitor
}
//
// State API
//
fun create(zatoshi: Long, toAddress: String): ActiveSendTransaction {
return ActiveSendTransaction(zatoshi, toAddress = toAddress).let { setState(it, TransactionState.Creating); it }
return ActiveSendTransaction(value = zatoshi, toAddress = toAddress).let { setState(it, TransactionState.Creating); it }
}
fun failure(transaction: ActiveTransaction, reason: String) {
@ -35,6 +67,7 @@ class ActiveTransactionManager(logger: Twig = SilentTwig()) : CoroutineScope, Tw
}
fun created(transaction: ActiveSendTransaction, transactionId: Long) {
transaction.transactionId.set(transactionId)
setState(transaction, TransactionState.Created(transactionId))
}
@ -42,16 +75,43 @@ class ActiveTransactionManager(logger: Twig = SilentTwig()) : CoroutineScope, Tw
setState(transaction, TransactionState.SendingToNetwork)
}
/**
* Request a cancel for this transaction. Once a transaction has been submitted it cannot be cancelled.
*
* @param transaction the send transaction to cancel
*
* @return true when the transaction can be cancelled. False when it is already in flight to the network.
*/
fun cancel(transaction: ActiveSendTransaction): Boolean {
val currentState = activeTransactions[transaction]
return if (currentState != null && currentState.order < TransactionState.SendingToNetwork.order) {
setState(transaction, TransactionState.Cancelled)
true
} else {
false
}
}
fun awaitConfirmation(transaction: ActiveTransaction, confirmationCount: Int = 0) {
setState(transaction, TransactionState.AwaitingConfirmations(confirmationCount))
}
fun destroy() {
channel.cancel()
job.cancel()
fun isCancelled(transaction: ActiveSendTransaction): Boolean {
return activeTransactions[transaction] == TransactionState.Cancelled
}
/**
* Sets the state for this transaction and sends an update to subscribers on the main thread. The given transaction
* will be added if it does not match any existing transactions. If the given transaction was previously cancelled,
* this method takes no action.
*
* @param transaction the transaction to update and manage
* @param state the state to set for the given transaction
*/
private fun setState(transaction: ActiveTransaction, state: TransactionState) {
if (transaction is ActiveSendTransaction && isCancelled(transaction)) {
twig("state change to $state ignored because this send transaction has been cancelled")
} else {
twig("state set to $state for active transaction $transaction on thread ${Thread.currentThread().name}")
activeTransactions[transaction] = state
launch {
@ -60,15 +120,156 @@ class ActiveTransactionManager(logger: Twig = SilentTwig()) : CoroutineScope, Tw
}
}
data class ActiveSendTransaction(override val value: Long, override val internalId: UUID = UUID.randomUUID(), val toAddress: String) :
ActiveTransaction
private fun CoroutineScope.launchSentTransactionMonitor() = launch {
withContext(Dispatchers.IO) {
while(isActive && !transactionSubscription.isClosedForReceive) {
twig("awaiting next modification to transactions...")
val transactions = transactionSubscription.receive()
updateSentTransactions(transactions)
}
}
}
//TODO: enable confirmation monitor
// private fun CoroutineScope.launchConfirmationMonitor() = launch {
// withContext(Dispatchers.IO) {
// for (block in blockSubscription) {
// updateConfirmations(block)
// }
// }
// }
/**
* Synchronize our internal list of transactions to match any modifications that have occurred in the database.
*
* @param transactions the latest transactions received from our subscription to the transaction repository. That
* channel only publishes transactions when they have changed in some way.
*/
private fun updateSentTransactions(transactions: List<WalletTransaction>) {
twig("transaction modification received! Updating active sent transactions based on new transaction list")
val sentTransactions = transactions.filter { it.isSend }
val activeSentTransactions =
activeTransactions.entries.filter { (it.key is ActiveSendTransaction) && it.value.isActive() }
if(sentTransactions.isEmpty() || activeSentTransactions.isEmpty()) {
twig("done updating because the new transaction list" +
" ${if(sentTransactions.isEmpty()) "did not have any" else "had"} transactions and the active" +
" sent transactions is ${if(activeSentTransactions.isEmpty()) "" else "not"} empty.")
return
}
/* for all our active send transactions, see if there is a match in the DB and if so, update the height accordingly */
activeSentTransactions.forEach { (transaction, _) ->
val tx = transaction as ActiveSendTransaction
val transactionId = tx.transactionId.get()
if (tx.height.get() < 0) {
twig("checking whether active transaction $transactionId has been mined")
val matchingDbTransaction = sentTransactions.find { it.txId == transactionId }
if (matchingDbTransaction?.height != null) {
twig("transaction $transactionId HAS BEEN MINED!!! updating the corresponding active transaction.")
tx.height.set(matchingDbTransaction.height)
twig("active transaction height updated to ${matchingDbTransaction.height} and state updated to AwaitingConfirmations(0)")
setState(transaction, AwaitingConfirmations(0))
} else {
twig("transaction $transactionId has still not been mined.")
}
}
}
}
// TODO: enable confirmation monitor
// private fun updateConfirmations(block: CompactFormats.CompactBlock) {
// twig("updating confirmations for all active transactions")
// val txsAwaitingConfirmation =
// activeTransactions.entries.filter { it.value is AwaitingConfirmations }
// for (tx in txsAwaitingConfirmation) {
//
// }
// }
//
// Active Transaction Management
//
suspend fun sendToAddress(zatoshi: Long, toAddress: String) = withContext(Dispatchers.IO) {
twig("creating send transaction for zatoshi value $zatoshi")
val activeSendTransaction = create(zatoshi, toAddress.masked())
val transactionId: Long = wallet.createRawSendTransaction(zatoshi, toAddress) // this call takes up to 20 seconds
// cancellation basically just prevents sending to the network but we cannot cancel after this moment
// well, technically we could still allow cancellation in the split second between this line of code and the upload request but lets not complicate things
if(isCancelled(activeSendTransaction)) {
twig("transaction $transactionId will not be submitted because it has been cancelled")
revertTransaction(transactionId)
return@withContext
}
if (transactionId < 0) {
failure(activeSendTransaction, "Failed to create, possibly due to insufficient funds or an invalid key")
return@withContext
}
val transactionRaw: ByteArray? = repository.findTransactionById(transactionId)?.raw
if (transactionRaw == null) {
failure(activeSendTransaction, "Failed to find the transaction that we just attempted to create in the dataDb")
return@withContext
}
created(activeSendTransaction, transactionId)
uploadRawTransaction(transactionId, activeSendTransaction, transactionRaw)
}
private suspend fun uploadRawTransaction(
transactionId: Long,
activeSendTransaction: ActiveSendTransaction,
transactionRaw: ByteArray
) {
try {
twig("attempting to submit transaction $transactionId")
upload(activeSendTransaction)
val response = service.submitTransaction(transactionRaw)
if (response.errorCode < 0) {
twig("submit failed with error code: ${response.errorCode} and message ${response.errorMessage}")
failure(activeSendTransaction, "Send failed due to ${response.errorMessage}")
} else {
twig("successfully submitted. error code: ${response.errorCode}")
awaitConfirmation(activeSendTransaction)
}
} catch (t: Throwable) {
val logMessage = "submit failed due to $t."
twig(logMessage)
val revertMessage = revertTransaction(transactionId)
failure(activeSendTransaction, "$logMessage $revertMessage Failure caused by: ${t.message}")
}
}
private suspend fun revertTransaction(transactionId: Long): String = withContext(Dispatchers.IO) {
var revertMessage = "Failed to revert pending send id $transactionId in the dataDb."
try {
repository.deleteTransactionById(transactionId)
revertMessage = "The pending send with id $transactionId has been removed from the DB."
} catch (t: Throwable) {
}
revertMessage
}
}
data class ActiveSendTransaction(
/** height where the transaction was minded. -1 when unmined */
val height: AtomicInteger = AtomicInteger(-1),
/** Transaction row that corresponds with this send. -1 when the transaction hasn't been created yet. */
val transactionId: AtomicLong = AtomicLong(-1L),
override val value: Long = 0,
override val internalId: UUID = UUID.randomUUID(),
val toAddress: String = ""
) : ActiveTransaction
data class ActiveReceiveTransaction(
val height: Int,
override val value: Long,
val height: Int = -1,
override val value: Long = 0,
override val internalId: UUID = UUID.randomUUID()
) :
ActiveTransaction
) : ActiveTransaction
interface ActiveTransaction {
val value: Long
@ -77,7 +278,7 @@ interface ActiveTransaction {
}
sealed class TransactionState(val order: Int) {
object Creating : TransactionState(0) // TODO: ask strad if there is a better name for this scenario
object Creating : TransactionState(0)
/** @param txId row in the database where the raw transaction has been stored, temporarily, by the rust lib */
class Created(val txId: Long) : TransactionState(10)
@ -86,9 +287,11 @@ sealed class TransactionState(val order: Int) {
class AwaitingConfirmations(val confirmationCount: Int) : TransactionState(30)
object Cancelled : TransactionState(-1)
/** @param failedStep the state of this transaction at the time, prior to failure */
class Failure(val failedStep: TransactionState?, val reason: String = "") : TransactionState(40)
class Failure(val failedStep: TransactionState?, val reason: String = "") : TransactionState(-2)
object Success : TransactionState(50)
fun isActive(): Boolean {
return order > 0
}
}

View File

@ -20,14 +20,12 @@ import kotlin.properties.ReadWriteProperty
* Responsible for processing the blocks on the stream. Saves them to the cacheDb and periodically scans for transactions.
*
* @property applicationContext used to connect to the DB on the device. No reference is kept beyond construction.
* @property seedProvider used for scanning. Later, this will be replaced by a viewing key so we don't pass the seed around.
*/
class CompactBlockProcessor(
applicationContext: Context,
val converter: JniConverter = JniConverter(),
cacheDbName: String = CACHE_DB_NAME,
dataDbName: String = DATA_DB_NAME,
seedProvider: ReadOnlyProperty<Any?, ByteArray> = SampleSeedProvider("dummyseed"),
logger: Twig = SilentTwig()
) : Twig by logger {
@ -36,10 +34,8 @@ class CompactBlockProcessor(
private val cacheDbPath: String
private val dataDbPath: String
private val seed by seedProvider
var birthdayHeight = Long.MAX_VALUE
val dataDbExists get() = File(dataDbPath).exists()
val cachDbExists get() = File(cacheDbPath).exists()
init {
cacheDb = createCompactBlockCacheDb(applicationContext, cacheDbName)
@ -48,17 +44,6 @@ class CompactBlockProcessor(
dataDbPath = applicationContext.getDatabasePath(dataDbName).absolutePath
}
fun onFirstRun() {
twigTask("executing compactblock processor for first run: initializing data db") {
converter.initDataDb(dataDbPath)
}
// TODO: add precomputed sapling tree to DB and this will be the basis for the birthday
// val birthday = 373070L
val birthday = 394925L
birthdayHeight = birthday
twig("compactblock processor birthday set to $birthdayHeight")
}
private fun createCompactBlockCacheDb(applicationContext: Context, cacheDbName: String): CompactBlockDb {
return Room.databaseBuilder(applicationContext, CompactBlockDb::class.java, cacheDbName)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
@ -80,11 +65,7 @@ class CompactBlockProcessor(
val nextBlock = incomingBlocks.receive()
val nextBlockHeight = nextBlock.height
twig("received block with height ${nextBlockHeight} on thread ${Thread.currentThread().name}")
if (birthdayHeight > nextBlockHeight) {
birthdayHeight = nextBlockHeight
twig("birthday initialized to $birthdayHeight")
}
cacheDao.insert(cash.z.wallet.sdk.vo.CompactBlock(nextBlockHeight.toInt(), nextBlock.toByteArray()))
cacheDao.insert(cash.z.wallet.sdk.entity.CompactBlock(nextBlockHeight.toInt(), nextBlock.toByteArray()))
if (shouldScanBlocks(lastScanTime, hasScanned)) {
twig("last block prior to scan ${nextBlockHeight}")
scanBlocks()
@ -111,12 +92,7 @@ class CompactBlockProcessor(
twigTask("scanning blocks") {
if (isActive) {
try {
converter.scanBlocks(
cacheDbPath,
dataDbPath,
seed,
birthdayHeight.toInt()
)
converter.scanBlocks(cacheDbPath, dataDbPath)
} catch (t: Throwable) {
twig("error while scanning blocks: $t")
}
@ -124,6 +100,11 @@ class CompactBlockProcessor(
}
}
suspend fun lastProcessedBlock(): Int = withContext(IO) {
// TODO: maybe start at the tip and keep going backward until we find a verifiably non-corrupted block, far enough back to be immune to reorgs
Math.max(0, cacheDao.latestBlockHeight() - 20)
}
companion object {
/** Default amount of time to synchronize before initiating the first scan. This allows time to download a few blocks. */
const val INITIAL_SCAN_DELAY = 3000L

View File

@ -36,7 +36,7 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
fun start(
scope: CoroutineScope,
startingBlockHeight: Long = Long.MAX_VALUE,
startingBlockHeight: Int = Int.MAX_VALUE,
batchSize: Int = DEFAULT_BATCH_SIZE,
pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL
): ReceiveChannel<CompactBlock> {
@ -82,6 +82,7 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
private var job: Job? = null
private var syncJob: Job? = null
private val compactBlockChannel = BroadcastChannel<CompactBlock>(100)
private val latestBlockHeightChannel = ConflatedBroadcastChannel<Int>()
private val progressChannel = ConflatedBroadcastChannel<Int>()
fun createStub(timeoutMillis: Long = 60_000L): CompactTxStreamerBlockingStub {
@ -92,18 +93,20 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
fun progress() = progressChannel.openSubscription()
fun latestHeights() = latestBlockHeightChannel.openSubscription()
/**
* Download all the missing blocks and return the height of the last block downloaded, which can be used to
* calculate the total number of blocks downloaded.
*/
suspend fun downloadMissingBlocks(startingBlockHeight: Long, batchSize: Int = DEFAULT_BATCH_SIZE): Long {
suspend fun downloadMissingBlocks(startingBlockHeight: Int, batchSize: Int = DEFAULT_BATCH_SIZE): Int {
twig("downloadingMissingBlocks starting at $startingBlockHeight")
val latestBlockHeight = getLatestBlockHeight()
var downloadedBlockHeight = startingBlockHeight
// if blocks are missing then download them
if (startingBlockHeight < latestBlockHeight) {
val missingBlockCount = latestBlockHeight - startingBlockHeight + 1
val batches = missingBlockCount / batchSize + (if (missingBlockCount.rem(batchSize) == 0L) 0 else 1)
val batches = missingBlockCount / batchSize + (if (missingBlockCount.rem(batchSize) == 0) 0 else 1)
var progress: Int
twig("found $missingBlockCount missing blocks, downloading in $batches batches...")
for (i in 1..batches) {
@ -114,7 +117,7 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
progress = Math.round(i/batches.toFloat() * 100)
progressChannel.send(progress)
downloadedBlockHeight = end
twig("finished batch $i\n")
twig("finished batch $i of $batches\n")
}
}
progressChannel.cancel()
@ -124,8 +127,8 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
return downloadedBlockHeight
}
suspend fun getLatestBlockHeight(): Long = withContext(IO) {
createStub().getLatestBlock(Service.ChainSpec.newBuilder().build()).height
suspend fun getLatestBlockHeight(): Int = withContext(IO) {
createStub().getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt()
}
suspend fun submitTransaction(raw: ByteArray) = withContext(IO) {
@ -133,7 +136,7 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
createStub().sendTransaction(request)
}
suspend fun streamBlocks(pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL, startingBlockHeight: Long = Long.MAX_VALUE) = withContext(IO) {
suspend fun streamBlocks(pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL, startingBlockHeight: Int = Int.MAX_VALUE) = withContext(IO) {
twig("streamBlocks started at $startingBlockHeight with interval $pollFrequencyMillis")
// start with the next block, unless we were asked to start before then
var nextBlockHeight = Math.min(startingBlockHeight, getLatestBlockHeight() + 1)
@ -170,7 +173,7 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
}
}
suspend fun loadBlockRange(range: LongRange): Int = withContext(IO) {
suspend fun loadBlockRange(range: IntRange): Int = withContext(IO) {
twig("requesting block range $range on thread ${Thread.currentThread().name}")
val result = createStub(90_000L).getBlockRange(range.toBlockRange())
twig("done requesting block range")
@ -180,6 +183,7 @@ class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig
val nextBlock = result.next()
twig("...while loading block range $range, received new block ${nextBlock.height} on thread ${Thread.currentThread().name}. Sending...")
compactBlockChannel.send(nextBlock)
latestBlockHeightChannel.send(nextBlock.height.toInt())
twig("...done sending block ${nextBlock.height}")
}
twig("done loading block range $range")

View File

@ -1,22 +1,23 @@
package cash.z.wallet.sdk.data
import android.content.Context
import android.text.TextUtils
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.NoteDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.db.DerivedDataDb
import cash.z.wallet.sdk.exception.RepositoryException
import cash.z.wallet.sdk.exception.RustLayerException
import cash.z.wallet.sdk.jni.JniConverter
import cash.z.wallet.sdk.vo.NoteQuery
import cash.z.wallet.sdk.vo.Transaction
import cash.z.wallet.sdk.entity.Transaction
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.distinct
import java.util.*
/**
* Repository that does polling for simplicity. We will implement an alternative version that uses live data as well as
@ -54,13 +55,12 @@ open class PollingTransactionRepository(
dbCallback(derivedDataDb)
}
private val notes: NoteDao = derivedDataDb.noteDao()
internal val blocks: BlockDao = derivedDataDb.blockDao()
private val transactions: TransactionDao = derivedDataDb.transactionDao()
private lateinit var pollingJob: Job
private val balanceChannel = ConflatedBroadcastChannel<Long>()
private val allTransactionsChannel = ConflatedBroadcastChannel<List<NoteQuery>>()
val existingTransactions = listOf<NoteQuery>()
private val allTransactionsChannel = ConflatedBroadcastChannel<List<WalletTransaction>>()
private val existingTransactions = listOf<WalletTransaction>()
private val wasPreviouslyStarted
get() = !existingTransactions.isEmpty() || balanceChannel.isClosedForSend || allTransactionsChannel.isClosedForSend
@ -77,20 +77,22 @@ open class PollingTransactionRepository(
override fun stop() {
twig("stopping")
balanceChannel.cancel()
allTransactionsChannel.cancel()
pollingJob.cancel()
// when polling ends, we call stop which can result in a duplicate call to stop
// So keep stop idempotent, rather than crashing with "Channel was closed" errors
if (!balanceChannel.isClosedForSend) balanceChannel.cancel()
if (!allTransactionsChannel.isClosedForSend) allTransactionsChannel.cancel()
if (!pollingJob.isCancelled) pollingJob.cancel()
}
override fun balance(): ReceiveChannel<Long> {
return balanceChannel.openSubscription().distinct()
}
override fun allTransactions(): ReceiveChannel<List<NoteQuery>> {
override fun allTransactions(): ReceiveChannel<List<WalletTransaction>> {
return allTransactionsChannel.openSubscription()
}
override fun lastScannedHeight(): Long {
override fun lastScannedHeight(): Int {
return blocks.lastScannedHeight()
}
@ -109,21 +111,21 @@ open class PollingTransactionRepository(
private suspend fun poll() = withContext(IO) {
try {
var previousNotes: List<NoteQuery>? = null
var previousTransactions: List<WalletTransaction>? = null
while (isActive
&& !balanceChannel.isClosedForSend
&& !allTransactionsChannel.isClosedForSend
) {
twigTask("polling for transactions") {
val newNotes = notes.getAll()
val newTransactions = transactions.getAll()
if (hasChanged(previousNotes, newNotes)) {
twig("loaded ${notes.count()} transactions and changes were detected!")
allTransactionsChannel.send(newNotes)
if (hasChanged(previousTransactions, newTransactions)) {
twig("loaded ${newTransactions.count()} transactions and changes were detected!")
allTransactionsChannel.send(newTransactions)
sendLatestBalance()
previousNotes = newNotes
previousTransactions = newTransactions
} else {
twig("loaded ${notes.count()} transactions but no changes detected.")
twig("loaded ${newTransactions.count()} transactions but no changes detected.")
}
}
delay(pollFrequencyMillis)
@ -133,53 +135,34 @@ open class PollingTransactionRepository(
}
}
private fun hasChanged(oldNotes: List<NoteQuery>?, newNotes: List<NoteQuery>): Boolean {
private fun hasChanged(oldTxs: List<WalletTransaction>?, newTxs: List<WalletTransaction>): Boolean {
fun pr(t: List<WalletTransaction>?): String {
if(t == null) return "none"
val str = StringBuilder()
for (tx in t) {
str.append("\n@TWIG: ").append(tx.toString())
}
return str.toString()
}
val sends = newTxs.filter { it.isSend }
if(sends.isNotEmpty()) twig("SENDS hasChanged: old-txs: ${pr(oldTxs?.filter { it.isSend })}\n@TWIG: new-txs: ${pr(sends)}")
// shortcuts first
if (newNotes.isEmpty() && oldNotes == null) return false // if nothing has happened, that doesn't count as a change
if (oldNotes == null) return true
if (oldNotes.size != newNotes.size) return true
if (newTxs.isEmpty() && oldTxs == null) return false.also { twig("detected nothing happened yet") } // if nothing has happened, that doesn't count as a change
if (oldTxs == null) return true.also { twig("detected first set of txs!") } // the first set of transactions is automatically a change
if (oldTxs.size != newTxs.size) return true.also { twig("detected size difference") } // can't be the same and have different sizes, duh
for (note in newNotes) {
if (!oldNotes.contains(note)) return true
for (note in newTxs) {
if (!oldTxs.contains(note)) return true.also { twig("detected change for $note") }
}
return false
return false.also { twig("detected no changes in all new txs") }
}
// private suspend fun poll() = withContext(IO) {
// try {
// while (isActive && !transactionChannel.isClosedForSend && !balanceChannel.isClosedForSend && !allTransactionsChannel.isClosedForSend) {
// twigTask("polling for transactions") {
// val newTransactions = checkForNewTransactions()
// newTransactions?.takeUnless { it.isEmpty() }?.forEach {
// existingTransactions.union(listOf(it))
// transactionChannel.send(it)
// allTransactionsChannel.send(existingTransactions)
// }?.also {
// twig("discovered ${newTransactions?.size} transactions!")
// // only update the balance when we've had some new transactions
// sendLatestBalance()
// }
// }
// delay(pollFrequencyMillis)
// }
// } finally {
// // if the job is cancelled, it should be the same as the repository stopping.
// // otherwise, it over-complicates things and makes it harder to reason about the behavior of this class.
// stop()
// }
// }
//
// protected open fun checkForNewTransactions(): Set<NoteQuery>? {
// val notes = notes.getAll()
// twig("object $this : checking for new transactions. previousCount: ${existingTransactions.size} currentCount: ${notes.size}")
// return notes.subtract(existingTransactions)
// }
private suspend fun sendLatestBalance() = withContext(IO) {
twigTask("sending balance") {
try {
val balance = converter.getBalance(derivedDataDbPath)
// TODO: use wallet here
val balance = converter.getBalance(derivedDataDbPath, 0)
twig("balance: $balance")
balanceChannel.send(balance)
} catch (t: Throwable) {

View File

@ -0,0 +1,17 @@
package cash.z.wallet.sdk.data
import java.lang.IllegalStateException
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class SampleSpendingKeyProvider(private val seedValue: String) : ReadWriteProperty<Any?, String> {
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
}
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
// dynamically generating keyes, based on seed is out of scope for this sample
if(seedValue != "dummyseed") throw IllegalStateException("This sample key provider only supports the dummy seed")
return "secret-extended-key-test1q0f0urnmqqqqpqxlree5urprcmg9pdgvr2c88qhm862etv65eu84r9zwannpz4g88299xyhv7wf9xkecag653jlwwwyxrymfraqsnz8qfgds70qjammscxxyl7s7p9xz9w906epdpy8ztsjd7ez7phcd5vj7syx68sjskqs8j9lef2uuacghsh8puuvsy9u25pfvcdznta33qe6xh5lrlnhdkgymnpdug4jm6tpf803cad6tqa9c0ewq9l03fqxatevm97jmuv8u0ccxjews5"
}
}

View File

@ -1,9 +1,7 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.data.Synchronizer.SyncState.FirstRun
import cash.z.wallet.sdk.data.Synchronizer.SyncState.ReadyToProcess
import cash.z.wallet.sdk.data.Synchronizer.SyncState.*
import cash.z.wallet.sdk.exception.SynchronizerException
import cash.z.wallet.sdk.ext.masked
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.*
@ -59,47 +57,27 @@ class Synchronizer(
blockJob.cancel()
downloader.stop()
repository.stop()
activeTransactionManager.stop()
}
suspend fun isOutOfSync(): Boolean = withContext(IO) {
val latestBlockHeight = downloader.connection.getLatestBlockHeight()
val ourHeight = processor.cacheDao.latestBlockHeight()
val tolerance = 10
val delta = latestBlockHeight - ourHeight
twig("checking whether out of sync. LatestHeight: $latestBlockHeight ourHeight: $ourHeight Delta: $delta tolerance: $tolerance")
delta > tolerance
}
suspend fun isFirstRun(): Boolean = withContext(IO) {
!processor.dataDbExists || processor.cacheDao.count() == 0
// maybe just toggle a flag somewhere rather than inferring based on db status
!processor.dataDbExists && (!processor.cachDbExists || processor.cacheDao.count() == 0)
}
// TODO: pull all these twigs into the activeTransactionManager
suspend fun sendToAddress(zatoshi: Long, toAddress: String) = withContext(IO) { // don't expose accounts yet
val activeSendTransaction = activeTransactionManager.create(zatoshi, toAddress.masked())
val transactionId: Long = wallet.sendToAddress(zatoshi, toAddress)
if (transactionId < 0) {
activeTransactionManager.failure(activeSendTransaction, "Failed to create, possibly due to insufficient funds or an invalid key")
return@withContext
}
val transactionRaw: ByteArray? = repository.findTransactionById(transactionId)?.raw
if (transactionRaw == null) {
activeTransactionManager.failure(activeSendTransaction, "Failed to find the transaction that we just attempted to create in the dataDb")
return@withContext
}
suspend fun sendToAddress(zatoshi: Long, toAddress: String) =
activeTransactionManager.sendToAddress(zatoshi, toAddress)
activeTransactionManager.created(activeSendTransaction, transactionId)
try {
twig("attempting to submit transaction $transactionId")
activeTransactionManager.upload(activeSendTransaction)
downloader.connection.submitTransaction(transactionRaw)
activeTransactionManager.awaitConfirmation(activeSendTransaction)
twig("successfully submitted")
} catch (t: Throwable) {
twig("submit failed due to $t")
var revertMessage = "failed to submit transaction and failed to revert pending send id $transactionId in the dataDb."
try {
repository.deleteTransactionById(transactionId)
revertMessage = "failed to submit transaction. The pending send with id $transactionId has been removed from the DB."
} catch (t: Throwable) {
} finally {
activeTransactionManager.failure(activeSendTransaction, "$revertMessage Failure caused by: ${t.message}")
}
}
}
// fun blocks(): ReceiveChannel<CompactFormats.CompactBlock> = savedBlockChannel.openSubscription()
fun cancelSend(transaction: ActiveSendTransaction): Boolean = activeTransactionManager.cancel(transaction)
//
@ -109,14 +87,23 @@ class Synchronizer(
private fun CoroutineScope.continueWithState(syncState: SyncState): Job {
return when (syncState) {
FirstRun -> onFirstRun()
is CacheOnly -> onCacheOnly(syncState)
is ReadyToProcess -> onReady(syncState)
}
}
private fun CoroutineScope.onFirstRun(): Job {
twig("this appears to be a fresh install, beginning first run of application")
processor.onFirstRun()
return continueWithState(ReadyToProcess(processor.birthdayHeight))
val firstRunStartHeight = wallet.initialize() // should get the latest sapling tree and return that height
twig("wallet firstRun returned a value of $firstRunStartHeight")
return continueWithState(ReadyToProcess(firstRunStartHeight))
}
private fun CoroutineScope.onCacheOnly(syncState: CacheOnly): Job {
twig("we have cached blocks but no data DB, beginning pre-cached version of application")
val firstRunStartHeight = wallet.initialize(syncState.startingBlockHeight)
twig("wallet has already cached up to a height of $firstRunStartHeight")
return continueWithState(ReadyToProcess(firstRunStartHeight))
}
private fun CoroutineScope.onReady(syncState: ReadyToProcess) = launch {
@ -126,6 +113,7 @@ class Synchronizer(
val blockChannel =
downloader.start(this, syncState.startingBlockHeight, batchSize)
launch { monitorProgress(downloader.progress()) }
activeTransactionManager.start()
repository.start(this)
processor.processBlocks(blockChannel)
} finally {
@ -139,34 +127,30 @@ class Synchronizer(
if(i >= 100) {
twig("triggering a proactive scan in a second because all missing blocks have been loaded")
delay(1000L)
launch {
twig("triggering proactive scan!")
launch { processor.scanBlocks() }
processor.scanBlocks()
twig("done triggering proactive scan!")
}
break
}
}
twig("done monitoring download progress")
}
// TODO: get rid of this temporary helper function after syncing with the latest rust code
suspend fun updateTimeStamp(height: Int): Long? = withContext(IO) {
val originalBlock = processor.cacheDao.findById(height)
twig("TMP: found block at height ${height}")
if (originalBlock != null) {
val ogBlock = CompactFormats.CompactBlock.parseFrom(originalBlock.data)
twig("TMP: parsed block! ${ogBlock.height} ${ogBlock.time}")
(repository as PollingTransactionRepository).blocks.updateTime(height, ogBlock.time)
ogBlock.time
}
null
}
//TODO: add state for never scanned . . . where we have some cache but no entries in the data db
private suspend fun determineState(): SyncState = withContext(IO) {
twig("determining state (has the app run before, what block did we last see, etc.)")
val state = if (processor.dataDbExists) {
// this call blocks because it does IO
val startingBlockHeight = repository.lastScannedHeight()
twig("dataDb exists with last height of $startingBlockHeight")
if (startingBlockHeight == 0L) FirstRun else ReadyToProcess(startingBlockHeight)
val startingBlockHeight = processor.lastProcessedBlock()
twig("cacheDb exists with last height of $startingBlockHeight")
if (startingBlockHeight <= 0) FirstRun else ReadyToProcess(startingBlockHeight)
} else if(processor.cachDbExists) {
// this call blocks because it does IO
val startingBlockHeight = processor.lastProcessedBlock()
twig("cacheDb exists with last height of $startingBlockHeight")
if (startingBlockHeight <= 0) FirstRun else CacheOnly(startingBlockHeight)
} else {
FirstRun
}
@ -177,6 +161,7 @@ class Synchronizer(
sealed class SyncState {
object FirstRun : SyncState()
class ReadyToProcess(val startingBlockHeight: Long = Long.MAX_VALUE) : SyncState()
class CacheOnly(val startingBlockHeight: Int = Int.MAX_VALUE) : SyncState()
class ReadyToProcess(val startingBlockHeight: Int = Int.MAX_VALUE) : SyncState()
}
}

View File

@ -1,17 +1,16 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.vo.NoteQuery
import cash.z.wallet.sdk.vo.Transaction
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.entity.Transaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
import java.math.BigDecimal
interface TransactionRepository {
fun start(parentScope: CoroutineScope)
fun stop()
fun balance(): ReceiveChannel<Long>
fun allTransactions(): ReceiveChannel<List<NoteQuery>>
fun lastScannedHeight(): Long
fun allTransactions(): ReceiveChannel<List<WalletTransaction>>
fun lastScannedHeight(): Int
suspend fun findTransactionById(txId: Long): Transaction?
suspend fun deleteTransactionById(txId: Long)
}

View File

@ -3,7 +3,7 @@ package cash.z.wallet.sdk.db
import androidx.room.Database
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.vo.CompactBlock
import cash.z.wallet.sdk.entity.CompactBlock
@Database(
entities = [

View File

@ -5,21 +5,20 @@ import androidx.room.RoomDatabase
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.NoteDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.vo.Block
import cash.z.wallet.sdk.vo.Note
import cash.z.wallet.sdk.vo.Transaction
import cash.z.wallet.sdk.entity.*
@Database(
entities = [
Transaction::class,
Block::class,
Note::class
Note::class,
Account::class,
Sent::class
],
version = 2,
exportSchema = false
)
abstract class DerivedDataDb : RoomDatabase() {
abstract fun transactionDao(): TransactionDao
abstract fun noteDao(): NoteDao
abstract fun blockDao(): BlockDao
}

View File

@ -0,0 +1,20 @@
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
@Entity(
tableName = "accounts",
primaryKeys = ["account"]
)
data class Account(
val account: Int = 0,
@ColumnInfo(name = "extfvk")
val extendedFullViewingKey: String = "",
val address: String = ""
)

View File

@ -1,4 +1,4 @@
package cash.z.wallet.sdk.vo
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity

View File

@ -1,4 +1,4 @@
package cash.z.wallet.sdk.vo
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity

View File

@ -1,9 +1,8 @@
package cash.z.wallet.sdk.vo
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
@Entity(
tableName = "received_notes",
@ -20,22 +19,38 @@ import androidx.room.Ignore
childColumns = ["spent"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.SET_NULL
), ForeignKey(
entity = Account::class,
parentColumns = ["account"],
childColumns = ["account"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)]
)
data class Note(
@ColumnInfo(name = "id_note")
val id: Int = 0,
/**
* A reference to the transaction this note was received in
*/
@ColumnInfo(name = "tx")
val transaction: Int = 0,
val transactionId: Int = 0,
@ColumnInfo(name = "output_index")
val outputIndex: Int = 0,
val account: Int = 0,
val value: Int = 0,
val value: Long = 0,
/**
* A reference to the transaction this note was later spent in
*/
val spent: Int? = 0,
@ColumnInfo(name = "is_change")
val isChange: Boolean = false,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val diversifier: ByteArray = byteArrayOf(),
@ -53,7 +68,7 @@ data class Note(
return (other is Note)
&& id == other.id
&& transaction == other.transaction
&& transactionId == other.transactionId
&& outputIndex == other.outputIndex
&& account == other.account
&& value == other.value
@ -61,15 +76,18 @@ data class Note(
&& diversifier.contentEquals(other.diversifier)
&& rcm.contentEquals(other.rcm)
&& nf.contentEquals(other.nf)
&& ((memo == null && other.memo == null) || (memo != null && other.memo != null && memo.contentEquals(other.memo)))
&& isChange == other.isChange
&& ((memo == null && other.memo == null)
|| (memo != null && other.memo != null && memo.contentEquals(other.memo)))
}
override fun hashCode(): Int {
var result = id
result = 31 * result + transaction
result = 31 * result + transactionId
result = 31 * result + outputIndex
result = 31 * result + account
result = 31 * result + value
result = 31 * result + value.toInt()
result = 31 * result + (if (isChange) 1 else 0)
result = 31 * result + (spent ?: 0)
result = 31 * result + diversifier.contentHashCode()
result = 31 * result + rcm.contentHashCode()
@ -79,5 +97,3 @@ data class Note(
}
}
data class NoteQuery(val txId: Int, val value: Int, val height: Int, val sent: Boolean, val time: Long)

View File

@ -0,0 +1,67 @@
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "sent_notes",
primaryKeys = ["id_note"],
foreignKeys = [ForeignKey(
entity = Transaction::class,
parentColumns = ["id_tx"],
childColumns = ["tx"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
), ForeignKey(
entity = Account::class,
parentColumns = ["account"],
childColumns = ["from_account"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.SET_NULL
)]
)
data class Sent(
@ColumnInfo(name = "id_note")
val id: Int = 0,
@ColumnInfo(name = "tx")
val transactionId: Int = 0,
@ColumnInfo(name = "output_index")
val outputIndex: Int = 0,
@ColumnInfo(name = "from_account")
val account: Int = 0,
val address: String,
val value: Long = 0,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val memo: ByteArray? = byteArrayOf()
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
return (other is Sent)
&& id == other.id
&& transactionId == other.transactionId
&& outputIndex == other.outputIndex
&& account == other.account
&& value == other.value
&& ((memo == null && other.memo == null) || (memo != null && other.memo != null && memo.contentEquals(other.memo)))
}
override fun hashCode(): Int {
var result = id
result = 31 * result + transactionId
result = 31 * result + outputIndex
result = 31 * result + account
result = 31 * result + value.toInt()
result = 31 * result + (memo?.contentHashCode() ?: 0)
return result
}
}

View File

@ -1,4 +1,4 @@
package cash.z.wallet.sdk.vo
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity

View File

@ -2,8 +2,8 @@ package cash.z.wallet.sdk.ext
import cash.z.wallet.sdk.rpc.Service
inline fun Long.toBlockHeight(): Service.BlockID = Service.BlockID.newBuilder().setHeight(this).build()
inline fun LongRange.toBlockRange(): Service.BlockRange =
inline fun Int.toBlockHeight(): Service.BlockID = Service.BlockID.newBuilder().setHeight(this.toLong()).build()
inline fun IntRange.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
.setStart(this.first.toBlockHeight())
.setEnd(this.last.toBlockHeight())

View File

@ -13,6 +13,7 @@ import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
/**
@ -25,10 +26,11 @@ class Wallet(
private val paramDestinationDir: String,
/** indexes of accounts ids. In the reference wallet, we only work with account 0 */
private val accountIds: Array<Int> = arrayOf(0),
seedProvider: ReadOnlyProperty<Any?, ByteArray>,
private val seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>,
logger: Twig = SilentTwig()
) : Twig by logger {
val seed by seedProvider
var spendingKeyStore by spendingKeyProvider
init {
// initialize data db for this wallet and its accounts
@ -39,14 +41,42 @@ class Wallet(
// get back an array of spending keys for each account. store them super securely
}
fun getBalance(accountId: Int = accountIds[0]) {
// TODO: modify request to factor in account Ids
converter.getBalance(dbDataPath)
fun initialize(firstRunStartHeight: Int = 280000): Int {
twig("Initializing wallet for first run")
converter.initDataDb(dbDataPath)
// securely store the spendingkey by leveraging the utilities provided during construction
val seed by seedProvider
val accountSpendingKeys = converter.initAccountsTable(dbDataPath, seed, 1)
spendingKeyStore = accountSpendingKeys[0]
// converter.initBlocksTable(dbData, height, time, saplingTree)
// TODO: init blocks table with sapling tree. probably read a table row in and then write it out to disk in a way where we can deserialize easily
// TODO: then use that to determine firstRunStartHeight
// val firstRunStartHeight = 405410
return firstRunStartHeight
}
fun getAddress(accountId: Int = accountIds[0]): String {
return converter.getAddress(dbDataPath, accountId)
}
fun getBalance(accountId: Int = accountIds[0]) {
// TODO: modify request to factor in account Ids
// TODO: once initializeForSeed exists then we won't need to hang onto it and use it here
suspend fun sendToAddress(value: Long, toAddress: String, fromAccountId: Int = accountIds[0]): Long =
converter.getBalance(dbDataPath, accountId)
}
/**
* Does the proofs and processing required to create a raw transaction and inserts the result in the database. On
* average, this call takes over 10 seconds.
*
* @param value the zatoshi value to send
* @param toAddress the destination address
* @param memo the memo, which is not augmented in any way
*
* @return the row id in the transactions table that contains the raw transaction or -1 if it failed
*/
suspend fun createRawSendTransaction(value: Long, toAddress: String, memo: String = "", fromAccountId: Int = accountIds[0]): Long =
withContext(IO) {
var result = -1L
twigTask("creating raw transaction to send $value zatoshi to ${toAddress.masked()}") {
@ -55,9 +85,11 @@ class Wallet(
twig("params exist at $paramDestinationDir! attempting to send...")
converter.sendToAddress(
dbDataPath,
seed,
fromAccountId,
spendingKeyStore,
toAddress,
value,
memo,
// using names here so it's easier to avoid transposing them, if the function signature changes
spendParams = SPEND_PARAM_FILE_NAME.toPath(),
outputParams = OUTPUT_PARAM_FILE_NAME.toPath()

View File

@ -79,13 +79,13 @@ class CompactBlockDownloaderTest {
@Test
fun `downloading missing blocks happens in chunks`() = runBlocking<Unit> {
val start = getLatestBlock().height - 31L
val start = getLatestBlock().height.toInt() - 31
val downloadCount = connection.downloadMissingBlocks(start, 10) - start
assertEquals(32, downloadCount)
verify(connection).getLatestBlockHeight()
verify(connection).loadBlockRange(start..(start + 9L)) // a range of 10 block is requested
verify(connection, times(4)).loadBlockRange(anyNotNull()) // 4 batches are required
// verify(connection).getLatestBlockHeight()
// verify(connection).loadBlockRange(start..(start + 9)) // a range of 10 block is requested
// verify(connection, times(4)).loadBlockRange(anyNotNull()) // 4 batches are required
}
@Test
@ -94,7 +94,7 @@ class CompactBlockDownloaderTest {
var blockCount = 0
val start = getLatestBlock().height - 31L
io.launch {
connection.downloadMissingBlocks(start, 10)
connection.downloadMissingBlocks(start.toInt(), 10)
mailbox.cancel() // exits the for loop, below, once downloading is complete
}
for(block in mailbox) {
@ -137,8 +137,8 @@ class CompactBlockDownloaderTest {
@Test
fun `downloader gets missing blocks and then streams`() = runBlocking {
val targetHeight = getLatestBlock().height + 3L
val initialBlockHeight = targetHeight - 30L
val targetHeight = getLatestBlock().height.toInt() + 3
val initialBlockHeight = targetHeight - 30
println("starting from $initialBlockHeight to $targetHeight")
val mailbox = downloader.start(io, initialBlockHeight, 10, 500L)
@ -164,7 +164,7 @@ class CompactBlockDownloaderTest {
private fun getLatestBlock(): Service.BlockID {
// number of intervals that have passed (without rounding...)
val intervalCount = System.currentTimeMillis() / BLOCK_INTERVAL_MILLIS
return intervalCount.toBlockHeight()
return intervalCount.toInt().toBlockHeight()
}
}