New: Additional APIs and functionality.

Added new functions that made things easier while testing, making the SDK a bit more usable.
This commit is contained in:
Kevin Gorham 2020-06-09 22:05:30 -04:00
parent c585ed93ff
commit 5cc8a38a5f
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
12 changed files with 344 additions and 113 deletions

View File

@ -1,8 +1,9 @@
package cash.z.wallet.sdk
import android.content.Context
import cash.z.wallet.sdk.Synchronizer.AddressType
import cash.z.wallet.sdk.Synchronizer.AddressType.Shielded
import cash.z.wallet.sdk.Synchronizer.AddressType.Transparent
import cash.z.wallet.sdk.Synchronizer.ConsensusMatchType
import cash.z.wallet.sdk.Synchronizer.Status.*
import cash.z.wallet.sdk.block.CompactBlockDbStore
import cash.z.wallet.sdk.block.CompactBlockDownloader
@ -12,17 +13,17 @@ import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.block.CompactBlockStore
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.exception.SynchronizerException
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.ext.twigTask
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.rpc.Service
import cash.z.wallet.sdk.service.LightWalletGrpcService
import cash.z.wallet.sdk.service.LightWalletService
import cash.z.wallet.sdk.transaction.*
import io.grpc.ManagedChannel
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.*
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* A Synchronizer that attempts to remain operational, despite any number of errors that can occur.
@ -50,10 +51,32 @@ class SdkSynchronizer internal constructor(
* The lifespan of this Synchronizer. This scope is initialized once the Synchronizer starts
* because it will be a child of the parentScope that gets passed into the [start] function.
* Everything launched by this Synchronizer will be cancelled once the Synchronizer or its
* parentScope stops. This is a lateinit rather than nullable property so that it fails early
* parentScope stops. This coordinates with [isStarted] so that it fails early
* rather than silently, whenever the scope is used before the Synchronizer has been started.
*/
lateinit var coroutineScope: CoroutineScope
var coroutineScope: CoroutineScope = CoroutineScope(EmptyCoroutineContext)
get() {
if (!isStarted) {
throw SynchronizerException.NotYetStarted
} else {
return field
}
}
set(value) {
field = value
if (value.coroutineContext !is EmptyCoroutineContext) isStarted = true
}
/**
* The channel that this Synchronizer uses to communicate with lightwalletd. In most cases, this
* should not be needed or used. Instead, APIs should be added to the synchronizer to
* enable the desired behavior. In the rare case, such as testing, it can be helpful to share
* the underlying channel to connect to the same service, and use other APIs
* (such as darksidewalletd) because channels are heavyweight.
*/
val channel: ManagedChannel get() = (processor.downloader.lightwalletService as LightWalletGrpcService).channel
var isStarted = false
//
@ -93,6 +116,7 @@ class SdkSynchronizer internal constructor(
*/
override val processorInfo: Flow<CompactBlockProcessor.ProcessorInfo> = processor.processorInfo
//
// Error Handling
//
@ -125,13 +149,27 @@ class SdkSynchronizer internal constructor(
* A callback to invoke whenever a chain error is encountered. These occur whenever the
* processor detects a missing or non-chain-sequential block (i.e. a reorg).
*/
override var onChainErrorHandler: ((Int, Int) -> Any)? = null
override var onChainErrorHandler: ((errorHeight: Int, rewindHeight: Int) -> Any)? = null
//
// Public API
//
/**
* Convenience function for the latest balance. Instead of using this, a wallet will more likely
* want to consume the flow of balances using [balances].
*/
override val latestBalance: WalletBalance get() = _balances.value
/**
* Convenience function for the latest height. Specifically, this value represents the last
* height that the synchronizer has observed from the lightwalletd server. Instead of using
* this, a wallet will more likely want to consume the flow of processor info using
* [processorInfo].
*/
override val latestHeight: Int get() = processor.currentInfo.networkBlockHeight
/**
* Starts this synchronizer within the given scope. For simplicity, attempting to start an
* instance that has already been started will throw a [SynchronizerException.FalseStart]
@ -148,15 +186,15 @@ class SdkSynchronizer internal constructor(
* @return an instance of this class so that this function can be used fluidly.
*/
override fun start(parentScope: CoroutineScope?): Synchronizer {
if (::coroutineScope.isInitialized) throw SynchronizerException.FalseStart
if (isStarted) throw SynchronizerException.FalseStart
// base this scope on the parent so that when the parent's job cancels, everything here
// cancels as well also use a supervisor job so that one failure doesn't bring down the
// whole synchronizer
val supervisorJob = SupervisorJob(parentScope?.coroutineContext?.get(Job))
coroutineScope =
CoroutineScope(supervisorJob + Dispatchers.Main)
coroutineScope.onReady()
CoroutineScope(supervisorJob + Dispatchers.Main).let { scope ->
coroutineScope = scope
scope.onReady()
}
return this
}
@ -174,6 +212,32 @@ class SdkSynchronizer internal constructor(
}
}
/**
* Convenience function that exposes the underlying server information, like its name and
* consensus branch id. Most wallets should already have a different source of truth for the
* server(s) with which they operate.
*/
override suspend fun getServerInfo(): Service.LightdInfo = processor.downloader.getServerInfo()
//
// Storage APIs
//
// TODO: turn this section into the data access API. For now, just aggregate all the things that we want to do with the underlying data
fun findBlockHash(height: Int): ByteArray? {
return (storage as? PagedTransactionRepository)?.findBlockHash(height)
}
fun findBlockHashAsHex(height: Int): String? {
return findBlockHash(height)?.toHexReversed()
}
fun getTransactionCount(): Int {
return (storage as? PagedTransactionRepository)?.getTransactionCount() ?: 0
}
//
// Private API

View File

@ -3,7 +3,9 @@ package cash.z.wallet.sdk
import androidx.paging.PagedList
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.entity.ConfirmedTransaction
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.ext.ConsensusBranchId
import cash.z.wallet.sdk.rpc.Service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@ -34,6 +36,12 @@ interface Synchronizer {
/**
* Stop this synchronizer. Implementations should ensure that calling this method cancels all
* jobs that were created by this instance.
*
* Note that in most cases, there is no need to call [stop] because the Synchronizer will
* automatically stop whenever the parentScope is cancelled. For instance, if that scope is
* bound to the lifecycle of the activity, the Synchronizer will stop when the activity stops.
* However, if no scope is provided to the start method, then the Synchronizer must be stopped
* with this function.
*/
fun stop()
@ -93,6 +101,21 @@ interface Synchronizer {
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>>
//
// Latest Properties
//
/**
* An in-memory reference to the latest height seen on the network.
*/
val latestHeight: Int
/**
* An in-memory reference to the most recently calculated balance.
*/
val latestBalance: WalletBalance
//
// Operations
//
@ -154,6 +177,19 @@ interface Synchronizer {
*/
suspend fun isValidTransparentAddr(address: String): Boolean
/**
* Validate whether the server and this SDK share the same consensus branch. This is
* particularly important to check around network updates so that any wallet that's connected to
* an incompatible server can surface that information effectively. For the SDK, the consensus
* branch is used when creating transactions as each one needs to target a specific branch. This
* function compares the server's branch id to this SDK's and returns information that helps
* determine whether they match.
*
* @return an instance of [ConsensusMatchType] that is essentially a wrapper for both branch ids
* and provides helper functions for communicating detailed errors to the user.
*/
suspend fun validateConsensusBranch(): ConsensusMatchType
/**
* Validates the given address, returning information about why it is invalid. This is a
* convenience method that combines the behavior of [isValidShieldedAddr] and
@ -177,6 +213,13 @@ interface Synchronizer {
*/
suspend fun cancelSpend(transaction: PendingTransaction): Boolean
/**
* Convenience function that exposes the underlying server information, like its name and
* consensus branch id. Most wallets should already have a different source of truth for the
* server(s) with which they operate and thereby not need this function.
*/
suspend fun getServerInfo(): Service.LightdInfo
//
// Error Handling
@ -294,4 +337,38 @@ interface Synchronizer {
val isNotValid get() = this !is Valid
}
/**
* Helper class that provides consensus branch information for this SDK and the server to which
* it is connected and whether they are aligned. Essentially a wrapper for both branch ids with
* helper functions for communicating detailed error information to the end-user.
*/
class ConsensusMatchType(val sdkBranch: ConsensusBranchId?, val serverBranch: ConsensusBranchId?) {
val hasServerBranch = serverBranch != null
val hasSdkBranch = sdkBranch != null
val isValid = hasServerBranch && sdkBranch == serverBranch
val hasBoth = hasServerBranch && hasSdkBranch
val hasNeither = !hasServerBranch && !hasSdkBranch
val isServerNewer = hasBoth && serverBranch!!.ordinal > sdkBranch!!.ordinal
val isSdkNewer = hasBoth && sdkBranch!!.ordinal > serverBranch!!.ordinal
val errorMessage
get() = when {
isValid -> null
hasNeither -> "Our branch is unknown and the server branch is unknown. Verify" +
" that they are both using the latest consensus branch ID."
hasServerBranch -> "The server is on $serverBranch but our branch is unknown." +
" Verify that we are fully synced."
hasSdkBranch -> "We are on $sdkBranch but the server branch is unknown. Verify" +
" the network connection."
else -> {
val newerBranch = if (isServerNewer) serverBranch else sdkBranch
val olderBranch = if (isSdkNewer) serverBranch else sdkBranch
val newerDevice = if (isServerNewer) "the server has" else "we have"
val olderDevice = if (isSdkNewer) "the server has" else "we have"
"Incompatible consensus: $newerDevice upgraded to $newerBranch but" +
" $olderDevice $olderBranch."
}
}
}
}

View File

@ -44,6 +44,10 @@ class CompactBlockDbStore(
if (lastBlock < SAPLING_ACTIVATION_HEIGHT) -1 else lastBlock
}
override suspend fun findCompactBlock(height: Int): CompactFormats.CompactBlock? {
return cacheDao.findCompactBlock(height)?.let { CompactFormats.CompactBlock.parseFrom(it) }
}
override suspend fun write(result: List<CompactFormats.CompactBlock>) = withContext(IO) {
cacheDao.insert(result.map { CompactBlockEntity(it.height.toInt(), it.toByteArray()) })
}

View File

@ -1,5 +1,6 @@
package cash.z.wallet.sdk.block
import cash.z.wallet.sdk.rpc.Service
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
@ -61,6 +62,10 @@ open class CompactBlockDownloader(
compactBlockStore.getLatestHeight()
}
suspend fun getServerInfo(): Service.LightdInfo = withContext(IO) {
lightwalletService.getServerInfo()
}
/**
* Stop this downloader and cleanup any resources being used.
*/

View File

@ -13,6 +13,13 @@ interface CompactBlockStore {
*/
suspend fun getLatestHeight(): Int
/**
* Fetch the compact block for the given height, if it exists.
*
* @return the compact block or null when it did not exist.
*/
suspend fun findCompactBlock(height: Int): CompactFormats.CompactBlock?
/**
* Write the given blocks to this store, which may be anything from an in-memory cache to a DB.
*

View File

@ -44,4 +44,7 @@ interface CompactBlockDao {
@Query("SELECT MAX(height) FROM compactblocks")
fun latestBlockHeight(): Int
@Query("SELECT data FROM compactblocks WHERE height = :height")
fun findCompactBlock(height: Int): ByteArray?
}

View File

@ -5,6 +5,8 @@ import androidx.room.Dao
import androidx.room.Database
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import cash.z.wallet.sdk.entity.*
//
@ -34,6 +36,99 @@ abstract class DerivedDataDb : RoomDatabase() {
abstract fun blockDao(): BlockDao
abstract fun receivedDao(): ReceivedDao
abstract fun sentDao(): SentDao
//
// Migrations
//
companion object {
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL("""
CREATE TABLE IF NOT EXISTS received_notes_new (
id_note INTEGER PRIMARY KEY, tx INTEGER NOT NULL,
output_index INTEGER NOT NULL, account INTEGER NOT NULL,
diversifier BLOB NOT NULL, value INTEGER NOT NULL,
rcm BLOB NOT NULL, nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL, memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
); """.trimIndent()
)
database.execSQL("INSERT INTO received_notes_new SELECT * FROM received_notes;")
database.execSQL("DROP TABLE received_notes;")
database.execSQL("ALTER TABLE received_notes_new RENAME TO received_notes;")
database.execSQL("PRAGMA foreign_keys = ON;")
}
}
val MIGRATION_4_3 = object : Migration(4, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS received_notes_new (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_index INTEGER NOT NULL,
account INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rcm BLOB NOT NULL,
nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
); """.trimIndent()
)
database.execSQL("INSERT INTO received_notes_new SELECT * FROM received_notes;")
database.execSQL("DROP TABLE received_notes;")
database.execSQL("ALTER TABLE received_notes_new RENAME TO received_notes;")
database.execSQL("PRAGMA foreign_keys = ON;")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS received_notes_new (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_index INTEGER NOT NULL,
account INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rcm BLOB NOT NULL,
nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
); """.trimIndent()
)
database.execSQL("INSERT INTO received_notes_new SELECT * FROM received_notes;")
database.execSQL("DROP TABLE received_notes;")
database.execSQL("ALTER TABLE received_notes_new RENAME TO received_notes;")
database.execSQL("PRAGMA foreign_keys = ON;")
}
}
}
}
@ -51,6 +146,9 @@ interface BlockDao {
@Query("SELECT MAX(height) FROM blocks")
fun lastScannedHeight(): Int
@Query( "SELECT hash FROM BLOCKS WHERE height = :height")
fun findHashByHeight(height: Int): ByteArray?
}
/**
@ -237,4 +335,3 @@ interface TransactionDao {
suspend fun findAllTransactionsByRange(blockRangeStart: Int, blockRangeEnd: Int = blockRangeStart, limit: Int = Int.MAX_VALUE): List<ConfirmedTransaction>
}

View File

@ -0,0 +1,28 @@
package cash.z.wallet.sdk.ext
fun ByteArray.toHex(): String {
val sb = StringBuilder(size * 2)
for (b in this)
sb.append(String.format("%02x", b))
return sb.toString()
}
fun ByteArray.toHexReversed(): String {
val sb = StringBuilder(size * 2)
var i = size - 1
while (i >= 0)
sb.append(String.format("%02x", this[i--]))
return sb.toString()
}
fun String.fromHex(): ByteArray {
val len = length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] =
((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte()
i += 2
}
return data
}

View File

@ -0,0 +1,9 @@
package cash.z.wallet.sdk.ext
internal inline fun <R> tryNull(block: () -> R): R? {
return try {
block()
} catch (t: Throwable) {
null
}
}

View File

@ -24,7 +24,7 @@ import java.util.concurrent.TimeUnit
* created for streaming requests, it will use a deadline that is after the given duration from now.
*/
class LightWalletGrpcService private constructor(
private var channel: ManagedChannel,
var channel: ManagedChannel,
private val singleRequestTimeoutSec: Long = 10L,
private val streamingRequestTimeoutSec: Long = 90L
) : LightWalletService {
@ -63,6 +63,10 @@ class LightWalletGrpcService private constructor(
return channel.createStub(singleRequestTimeoutSec).getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt()
}
override fun getServerInfo(): Service.LightdInfo {
channel.resetConnectBackoff()
return channel.createStub(singleRequestTimeoutSec).getLightdInfo(Service.Empty.newBuilder().build())
}
override fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse {
channel.resetConnectBackoff()
val request = Service.RawTransaction.newBuilder().setData(ByteString.copyFrom(spendTransaction)).build()
@ -79,6 +83,8 @@ class LightWalletGrpcService private constructor(
}
override fun reconnect() {
twig("closing existing channel and then reconnecting to" +
" ${connectionInfo.host}:${connectionInfo.port}?usePlaintext=${connectionInfo.usePlaintext}")
channel.shutdown()
channel = createDefaultChannel(
connectionInfo.appContext,
@ -132,7 +138,7 @@ class LightWalletGrpcService private constructor(
port: Int,
usePlaintext: Boolean
): ManagedChannel {
twig("Creating channel that will connect to $host:$port")
twig("Creating channel that will connect to $host:$port?usePlaintext=$usePlaintext")
return AndroidChannelBuilder
.forAddress(host, port)
.context(appContext)

View File

@ -26,6 +26,25 @@ interface LightWalletService {
*/
fun getLatestBlockHeight(): Int
/**
* Return basic information about the server such as:
*
* ```
* {
* "version": "0.2.1",
* "vendor": "ECC LightWalletD",
* "taddrSupport": true,
* "chainName": "main",
* "saplingActivationHeight": 419200,
* "consensusBranchId": "2bb40e60",
* "blockHeight": 861272
* }
* ```
*
* @return useful server details.
*/
fun getServerInfo(): Service.LightdInfo
/**
* Submit a raw transaction.
*

View File

@ -38,9 +38,9 @@ open class PagedTransactionRepository(
) : this(
Room.databaseBuilder(context, DerivedDataDb::class.java, dataDbName)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_3)
.addMigrations(MIGRATION_4_5)
.addMigrations(DerivedDataDb.MIGRATION_3_4)
.addMigrations(DerivedDataDb.MIGRATION_4_3)
.addMigrations(DerivedDataDb.MIGRATION_4_5)
.build(),
pageSize
)
@ -86,6 +86,7 @@ open class PagedTransactionRepository(
transactions.findMinedHeight(rawTransactionId)
}
/**
* Close the underlying database.
*/
@ -93,96 +94,7 @@ open class PagedTransactionRepository(
derivedDataDb.close()
}
//
// Migrations
//
companion object {
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL("""
CREATE TABLE IF NOT EXISTS received_notes_new (
id_note INTEGER PRIMARY KEY, tx INTEGER NOT NULL,
output_index INTEGER NOT NULL, account INTEGER NOT NULL,
diversifier BLOB NOT NULL, value INTEGER NOT NULL,
rcm BLOB NOT NULL, nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL, memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
); """.trimIndent()
)
database.execSQL("INSERT INTO received_notes_new SELECT * FROM received_notes;")
database.execSQL("DROP TABLE received_notes;")
database.execSQL("ALTER TABLE received_notes_new RENAME TO received_notes;")
database.execSQL("PRAGMA foreign_keys = ON;")
}
}
private val MIGRATION_4_3 = object : Migration(4, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS received_notes_new (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_index INTEGER NOT NULL,
account INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rcm BLOB NOT NULL,
nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
); """.trimIndent()
)
database.execSQL("INSERT INTO received_notes_new SELECT * FROM received_notes;")
database.execSQL("DROP TABLE received_notes;")
database.execSQL("ALTER TABLE received_notes_new RENAME TO received_notes;")
database.execSQL("PRAGMA foreign_keys = ON;")
}
}
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS received_notes_new (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_index INTEGER NOT NULL,
account INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rcm BLOB NOT NULL,
nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
); """.trimIndent()
)
database.execSQL("INSERT INTO received_notes_new SELECT * FROM received_notes;")
database.execSQL("DROP TABLE received_notes;")
database.execSQL("ALTER TABLE received_notes_new RENAME TO received_notes;")
database.execSQL("PRAGMA foreign_keys = ON;")
}
}
}
}
// TODO: begin converting these into Data Access API. For now, just collect the desired operations and iterate/refactor, later
fun findBlockHash(height: Int): ByteArray? = blocks.findHashByHeight(height)
fun getTransactionCount(): Int = transactions.count()
}