Merge branch 'fast-spendability'

This commit is contained in:
Kris Nuttycombe 2023-09-12 16:20:44 -06:00
commit 4d99ad10ac
102 changed files with 3602 additions and 2088 deletions

View File

@ -199,7 +199,7 @@ jobs:
timeout-minutes: 30
uses: ./.github/actions/setup
- name: Build and test
timeout-minutes: 25
timeout-minutes: 30
run: |
./gradlew test
- name: Collect Artifacts
@ -292,7 +292,7 @@ jobs:
timeout-minutes: 30
uses: ./.github/actions/setup
- name: Build and test
timeout-minutes: 25
timeout-minutes: 30
env:
ORG_GRADLE_PROJECT_ZCASH_EMULATOR_WTF_API_KEY: ${{ secrets.EMULATOR_WTF_API_KEY }}
run: |
@ -336,7 +336,7 @@ jobs:
run: |
keytool -genkey -v -keystore $SIGNING_KEY_PATH -keypass android -storepass android -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 100000 -dname "CN=, OU=, O=Test, L=, S=, C=" -noprompt
- name: Build
timeout-minutes: 30
timeout-minutes: 35
env:
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PATH: ${{ format('{0}/release.jks', env.home) }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PASSWORD: android
@ -395,7 +395,7 @@ jobs:
with:
name: Demo app release binaries
- name: Robo test
timeout-minutes: 15
timeout-minutes: 20
env:
# Path depends on `release_build` job, plus path of `Download a single artifact` step
BINARIES_ZIP_PATH: binaries.zip

1
.gitignore vendored
View File

@ -51,6 +51,7 @@ captures/
.idea/workspace.xml
.idea/protoeditor.xml
.idea/appInsightsSettings.xml
.idea/migrations.xml
*.iml
# Keystore files

View File

@ -1,8 +1,60 @@
# Change Log
# Changelog
All notable changes to this library will be documented in this file.
## 1.21.0-beta01
Note: This is the last _1.x_ version release. The upcoming version _2.0_ brings the **Spend-before-Sync** feature,
which speeds up discovering the wallet's spendable balance.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.0.0-rc.1] - 2023-09-12
### Notable Changes
- `CompactBlockProcessor` now processes compact blocks from the lightwalletd
server using the **Spend-before-Sync** algorithm, which allows scanning of
wallet blocks to be performed in arbitrary order and optimized to make it
possible to spend received notes without waiting for synchronization to be
complete. This feature shortens the time until a wallet's spendable balance
can be used.
- The block synchronization mechanism is additionally about one-third faster
thanks to an optimized `CompactBlockProcessor.SYNC_BATCH_SIZE` (issue **#1206**).
### Removed
- `CompactBlockProcessor.ProcessorInfo.lastSyncHeight` no longer had a
well-defined meaning after implementation of the **SpendBeforeSync**
synchronization algorithm and has been removed.
`CompactBlockProcessor.ProcessorInfo.overallSyncRange` provides related
information.
- `CompactBlockProcessor.ProcessorInfo.isSyncing`. Use `Synchronizer.status` instead.
- `CompactBlockProcessor.ProcessorInfo.syncProgress`. Use `Synchronizer.progress` instead.
- `alsoClearBlockCache` parameter from rewind functions of `Synchronizer` and
`CompactBlockProcessor`, as it has no effect on the current behaviour of
these functions.
- Internally, we removed access to the shared block table from the Kotlin
layer, which resulted in eliminating these APIs:
- `SdkSynchronizer.findBlockHash()`
- `SdkSynchronizer.findBlockHashAsHex()`
### Changed
- `CompactBlockProcessor.quickRewind()` and `CompactBlockProcessor.rewindToNearestHeight()`
now might fail due to internal changes in getting scanned height. Thus, these
functions now return `Boolean` results.
- `Synchronizer.new()` and `PersistableWallet` APIs require a new
`walletInitMode` parameter of type `WalletInitMode`, which describes wallet
initialization mode. See related function and sealed class documentation.
### Fixed
- `Synchronizer.getMemos()` now correctly returns a flow of strings for sent
and received transactions. Issue **#1154**.
- `CompactBlockProcessor` now triggers transaction polling while block
synchronization is in progress as expected. Clients will be notified shortly
after every new transaction is discovered via `Synchronizer.transactions`
API. Issue **#1170**.
## [1.21.0-beta01]
Note: This is the last _1.x_ version release. The upcoming version _2.0_ brings the **Spend-before-Sync** feature,
which speeds up discovering the wallet's spendable balance.
### Changed
- Updated dependencies:
@ -18,7 +70,7 @@ which speeds up discovering the wallet's spendable balance.
## 1.20.0-beta01
- The SDK internally migrated from `BackendExt` rust backend extension functions to more type-safe `TypesafeBackend`.
- `Synchronizer.getMemos()` now internally handles expected `RuntimeException` from the rust layer and transforms it
- `Synchronizer.getMemos()` now internally handles expected `RuntimeException` from the rust layer and transforms it
in an empty string.
## 1.19.0-beta01
@ -28,16 +80,16 @@ which speeds up discovering the wallet's spendable balance.
### Fixed
- `TransactionOverview` object returned with `SdkSynchronizer.transactions` now contains a correct `TransactionState.
Pending` in case of the transaction is mined,but not fully confirmed.
- When the SDK internally works with a recently created transaction there was a moment in which could the transaction
- When the SDK internally works with a recently created transaction there was a moment in which could the transaction
causes the SDK to crash, because of its invalid mined height. Fixed now.
## 1.18.0-beta01
- Synchronizer's functions `getUnifiedAddress`, `getSaplingAddress`, `getTransparentAddress`, and `refreshUtxos` now
do not provide `Account.DEFAULT` value for the account argument. As accounts are not fully supported by the SDK
yet, the caller should explicitly set Account.DEFAULT as the account argument to keep the same behavior.
- Synchronizer's functions `getUnifiedAddress`, `getSaplingAddress`, `getTransparentAddress`, and `refreshUtxos` now
do not provide `Account.DEFAULT` value for the account argument. As accounts are not fully supported by the SDK
yet, the caller should explicitly set Account.DEFAULT as the account argument to keep the same behavior.
- Gradle 8.1.1
- AGP 8.0.2
## 1.17.0-beta01
- Transparent fund balances are now displayed almost immediately
- Synchronization of shielded balances and transaction history is about 30% faster
@ -46,7 +98,7 @@ which speeds up discovering the wallet's spendable balance.
- `Synchronizer.progress` now returns `Flow<PercentDecimal>` instead of `Flow<Int>`. PercentDecimal is a type-safe model. Use `PercentDecimal.toPercentage()` to get a number within 0-100% scale.
- `Synchronizer.clearedTransactions` has been renamed to `Synchronizer.transactions` and includes sent, received, and pending transactions. Synchronizer APIs for listing sent, received, and pending transactions have been removed. Clients can determine whether a transaction is sent, received, or pending by filtering the `TransactionOverview` objects returned by `Synchronizer.transactions`
- `Synchronizer.send()` and `shieldFunds()` are now `suspend` functions with `Long` return values representing the ID of the newly created transaction. Errors are reported by thrown exceptions.
- `DerivationTool` is now an interface, rather than an `object`, which makes it easier to inject alternative implementations into tests. To adapt to the new API, replace calls to `DerivationTool.methodName()` with `DerivationTool.getInstance().methodName()`.
- `DerivationTool` is now an interface, rather than an `object`, which makes it easier to inject alternative implementations into tests. To adapt to the new API, replace calls to `DerivationTool.methodName()` with `DerivationTool.getInstance().methodName()`.
- `DerivationTool` methods are no longer suspending, which should make it easier to call them in various situations. Obtaining a `DerivationTool` instance via `DerivationTool.getInstance()` frontloads the need for a suspending call.
- `DerivationTool.deriveUnifiedFullViewingKeys()` no longer has a default argument for `numberOfAccounts`. Clients should now pass `DerivationTool.DEFAULT_NUMBER_OF_ACCOUNTS` as the value. Note that the SDK does not currently have proper support for multiple accounts.
- The SDK's internals for connecting with librustzcash have been refactored to a separate Gradle module `backend-lib` (and therefore a separate artifact) which is a transitive dependency of the Zcash Android SDK. SDK consumers that use Gradle dependency locks may notice this difference, but otherwise it should be mostly an invisible change.
@ -59,7 +111,7 @@ which speeds up discovering the wallet's spendable balance.
## 1.15.0-beta01
### Changed
- A new package `sdk-incubator-lib` is now available as a public API. This package contains experimental APIs that may be promoted to the SDK in the future. The APIs in this package are not guaranteed to be stable, and may change at any time.
- `Synchronizer.refreshUtxos` now takes `Account` type as first parameter instead of transparent address of type
- `Synchronizer.refreshUtxos` now takes `Account` type as first parameter instead of transparent address of type
`String`, and thus it downloads all UTXOs for the given account addresses. The Account object provides a default `0` index Account with `Account.DEFAULT`.
## 1.14.0-beta01
@ -68,11 +120,11 @@ which speeds up discovering the wallet's spendable balance.
## 1.13.0-beta01
### Changed
- The SDK's internal networking has been refactored to a separate Gradle module `lightwallet-client-lib` (and
- The SDK's internal networking has been refactored to a separate Gradle module `lightwallet-client-lib` (and
therefore a separate artifact) which is a transitive dependency of the Zcash Android SDK.
- The `z.cash.ecc.android.sdk.model.LightWalletEndpoint` class has been moved to `co.electriccoin.lightwallet.client.model.LightWalletEndpoint`
- The new networking module now provides a `LightWalletClient` for asynchronous calls.
- Most unary calls respond with the new `Response` class and its subclasses. Streaming calls will be updated
- Most unary calls respond with the new `Response` class and its subclasses. Streaming calls will be updated
with the Response class later.
- SDK clients should avoid using generated GRPC objects, as these are an internal implementation detail and are in process of being removed from the public API. Any clients using GRPC objects will find these have been repackaged from `cash.z.wallet.sdk.rpc` to `cash.z.wallet.sdk.internal.rpc` to signal they are not a public API.
@ -129,7 +181,7 @@ which speeds up discovering the wallet's spendable balance.
- `Synchronizer.sendToAddress()` and `Synchronizer.shieldFunds()` return flows that can now be collected multiple times. Prior versions of the SDK had a bug that could submit transactions multiple times if the flow was collected more than once.
- Updated dependencies:
- Kotlin 1.7.21
- AndroidX
- AndroidX
- etc.
- Updated checkpoints
@ -154,14 +206,14 @@ which speeds up discovering the wallet's spendable balance.
- `DerivationTool.deriveTransparentSecretKey` (use `DerivationTool.deriveUnifiedSpendingKey` instead).
- `DerivationTool.deriveShieldedAddress`
- `DerivationTool.deriveUnifiedViewingKeys` (use `DerivationTool.deriveUnifiedFullViewingKey` instead)
- `DerivationTool.validateUnifiedViewingKey`
- `DerivationTool.validateUnifiedViewingKey`
## Version 1.9.0-beta05
- The minimum version of Android supported is now API 21
- Fixed R8/ProGuard consumer rule, which eliminates a runtime crash for minified apps
## Version 1.9.0-beta04
- The SDK now stores sapling param files in `no_backup/co.electricoin.zcash` folder instead of the `cache/params`
- The SDK now stores sapling param files in `no_backup/co.electricoin.zcash` folder instead of the `cache/params`
folder. Besides that, `SaplingParamTool` also does validation of downloaded sapling param file hash and size.
**No action required from client app**.
@ -177,7 +229,7 @@ which speeds up discovering the wallet's spendable balance.
- Updated checkpoints
## Version 1.8.0-beta01
- Enabled automated unit tests run on the CI server
- Enabled automated unit tests run on the CI server
- Added `BlockHeight` typesafe object to represent block heights
- Significantly reduced memory usage, fixing potential OutOfMemoryError during block download
- Kotlin 1.7.10

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2017-2021 Electric Coin Company
Copyright (c) 2017-2023 Electric Coin Company
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -53,3 +53,9 @@ Note that we aim for the main branch of this repository to be stable and releasa
1. Intel-based machines may have trouble building in Android Studio. The workaround is to add the following line to `~/.gradle/gradle.properties`: `ZCASH_IS_DEPENDENCY_LOCKING_ENABLED=false`
1. During builds, a warning will be printed that says "Unable to detect AGP versions for included builds. All projects in the build should use the same AGP version." This can be safely ignored. The version under build-conventions is the same as the version used elsewhere in the application.
1. Android Studio will warn about the Gradle checksum. This is a [known issue](https://github.com/gradle/gradle/issues/9361) and can be safely ignored.
## Unstable Features
### Spend-before-Sync compact blocks synchronization algorithm
`CompactBlockProcessor` now processes compact blocks from the lightwalletd server in non-linear order with the
**Spend-before-Sync** algorithm. This feature speeds up discovering the wallet's spendable balance. Please note that
this new block synchronization algorithm is still under development.

1040
backend-lib/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,19 +12,21 @@ rust-version = "1.60"
[dependencies]
failure = "0.1"
hdwallet = "0.3.1"
hdwallet-bitcoin = "0.3"
hdwallet = "0.4"
hdwallet-bitcoin = "0.4"
hex = "0.4"
jni = { version = "0.20", default-features = false }
prost = "0.12"
rusqlite = "0.29"
schemer = "0.2"
secp256k1 = "0.21"
secp256k1 = "0.26"
secrecy = "0.8"
zcash_address = "0.2"
zcash_client_backend = { version = "0.9", features = ["transparent-inputs", "unstable"] }
zcash_client_sqlite = { version = "0.7.1", features = ["transparent-inputs", "unstable"] }
zcash_primitives = "0.11"
zcash_proofs = "0.11"
orchard = { version = "0.4", default-features = false }
zcash_address = "0.3"
zcash_client_backend = { version = "=0.10.0-rc.2", features = ["transparent-inputs", "unstable"] }
zcash_client_sqlite = { version = "=0.8.0-rc.3", features = ["transparent-inputs", "unstable"] }
zcash_primitives = "=0.13.0-rc.1"
zcash_proofs = "=0.13.0-rc.1"
orchard = { version = "0.6", default-features = false }
# Initialization
rayon = "1.7"
@ -36,7 +38,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
# Conditional access to newer NDK features
dlopen2 = "0.4"
dlopen2 = "0.6"
libc = "0.2"
## Uncomment this to test librustzcash changes locally

View File

@ -104,6 +104,9 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
// Tests
testImplementation(libs.kotlin.test)
androidTestImplementation(libs.androidx.multidex)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.junit)

View File

@ -1,7 +1,6 @@
package cash.z.ecc.android.sdk.internal.jni
import cash.z.ecc.android.bip39.Mnemonics
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertContentEquals
@ -15,7 +14,6 @@ class RustDerivationToolTest {
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun create_spending_key_does_not_mutate_passed_bytes() = runTest {
val bytesOne = Mnemonics.MnemonicCode(SEED_PHRASE).toEntropy()
val bytesTwo = Mnemonics.MnemonicCode(SEED_PHRASE).toEntropy()

View File

@ -1,9 +1,10 @@
package cash.z.ecc.android.sdk.internal
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.internal.model.JniScanProgress
import cash.z.ecc.android.sdk.internal.model.JniScanRange
import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot
import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey
import java.lang.RuntimeException
import kotlin.jvm.Throws
/**
* Contract defining the exposed capabilities of the Rust backend.
@ -25,37 +26,34 @@ interface Backend {
to: String,
value: Long,
memo: ByteArray? = byteArrayOf()
): Long
): ByteArray
suspend fun shieldToAddress(
account: Int,
unifiedSpendingKey: ByteArray,
memo: ByteArray? = byteArrayOf()
): Long
): ByteArray
suspend fun decryptAndStoreTransaction(tx: ByteArray)
/**
* @param keys A list of UFVKs to initialize the accounts table with
* Sets up the internal structure of the data database.
*
* If `seed` is `null`, database migrations will be attempted without it.
*
* @return 0 if successful, 1 if the seed must be provided in order to execute the requested migrations, or -1
* otherwise.
*
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun initAccountsTable(vararg keys: String)
suspend fun initDataDb(seed: ByteArray?): Int
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun initBlocksTable(
checkpointHeight: Long,
checkpointHash: String,
checkpointTime: Long,
checkpointSaplingTree: String,
)
suspend fun initDataDb(seed: ByteArray?): Int
suspend fun createAccount(seed: ByteArray): JniUnifiedSpendingKey
suspend fun createAccount(seed: ByteArray, treeState: ByteArray, recoverUntil: Long?): JniUnifiedSpendingKey
fun isValidShieldedAddr(addr: String): Boolean
@ -71,6 +69,10 @@ interface Backend {
suspend fun listTransparentReceivers(account: Int): List<String>
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun getBalance(account: Int): Long
fun getBranchIdForHeight(height: Long): Long
@ -79,14 +81,12 @@ interface Backend {
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun getReceivedMemoAsUtf8(idNote: Long): String?
suspend fun getMemoAsUtf8(txId: ByteArray, outputIndex: Int): String?
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun getSentMemoAsUtf8(idNote: Long): String?
suspend fun getVerifiedBalance(account: Int): Long
suspend fun getNearestRewindHeight(height: Long): Long
@ -101,7 +101,57 @@ interface Backend {
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun scanBlocks(limit: Long?)
suspend fun putSaplingSubtreeRoots(
startIndex: Long,
roots: List<JniSubtreeRoot>,
)
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun updateChainTip(height: Long)
/**
* Returns the height to which the wallet has been fully scanned.
*
* This is the height for which the wallet has fully trial-decrypted this and all
* preceding blocks above the wallet's birthday height.
*
* @return The height to which the wallet has been fully scanned, or Null if no blocks have been scanned.
* @throws RuntimeException as a common indicator of the operation failure
*/
suspend fun getFullyScannedHeight(): Long?
/**
* Returns the maximum height that the wallet has scanned.
*
* If the wallet is fully synced, this will be equivalent to `getFullyScannedHeight`;
* otherwise the maximal scanned height is likely to be greater than the fully scanned
* height due to the fact that out-of-order scanning can leave gaps.
*
* @return The maximum height that the wallet has scanned, or Null if no blocks have been scanned.
* @throws RuntimeException as a common indicator of the operation failure
*/
suspend fun getMaxScannedHeight(): Long?
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun getScanProgress(): JniScanProgress?
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun suggestScanRanges(): List<JniScanRange>
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun scanBlocks(fromHeight: Long, limit: Long)
/**
* @throws RuntimeException as a common indicator of the operation failure
@ -112,18 +162,12 @@ interface Backend {
/**
* @return The latest height in the CompactBlock cache metadata DB, or Null if no blocks have been cached.
*/
suspend fun getLatestHeight(): Long?
suspend fun getLatestCacheHeight(): Long?
suspend fun findBlockMetadata(height: Long): JniBlockMeta?
suspend fun rewindBlockMetadataToHeight(height: Long)
/**
* @param limit The limit provides an efficient way how to restrict the portion of blocks, which will be validated.
* @return Null if successful. If an error occurs, the height will be the blockheight where the error was detected.
*/
suspend fun validateCombinedChainOrErrorHeight(limit: Long?): Long?
suspend fun getVerifiedTransparentBalance(address: String): Long
suspend fun getTotalTransparentBalance(address: String): Long

View File

@ -0,0 +1,7 @@
@file:Suppress("ktlint:standard:filename")
package cash.z.ecc.android.sdk.internal.ext
fun Long.isInUIntRange(): Boolean {
return this >= 0L && this <= UInt.MAX_VALUE.toLong()
}

View File

@ -5,6 +5,9 @@ import cash.z.ecc.android.sdk.internal.SdkDispatchers
import cash.z.ecc.android.sdk.internal.ext.deleteRecursivelySuspend
import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.internal.model.JniScanProgress
import cash.z.ecc.android.sdk.internal.model.JniScanRange
import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot
import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey
import kotlinx.coroutines.withContext
import java.io.File
@ -66,42 +69,17 @@ class RustBackend private constructor(
)
}
override suspend fun createAccount(seed: ByteArray): JniUnifiedSpendingKey {
override suspend fun createAccount(
seed: ByteArray,
treeState: ByteArray,
recoverUntil: Long?
): JniUnifiedSpendingKey {
return withContext(SdkDispatchers.DATABASE_IO) {
createAccount(
dataDbFile.absolutePath,
seed,
networkId = networkId
)
}
}
/**
* @param keys A list of UFVKs to initialize the accounts table with
*/
override suspend fun initAccountsTable(vararg keys: String) {
return withContext(SdkDispatchers.DATABASE_IO) {
initAccountsTableWithKeys(
dataDbFile.absolutePath,
keys,
networkId = networkId
)
}
}
override suspend fun initBlocksTable(
checkpointHeight: Long,
checkpointHash: String,
checkpointTime: Long,
checkpointSaplingTree: String,
) {
return withContext(SdkDispatchers.DATABASE_IO) {
initBlocksTable(
dataDbFile.absolutePath,
checkpointHeight,
checkpointHash,
checkpointTime,
checkpointSaplingTree,
treeState,
recoverUntil ?: -1,
networkId = networkId
)
}
@ -154,20 +132,12 @@ class RustBackend private constructor(
return longValue
}
override suspend fun getReceivedMemoAsUtf8(idNote: Long) =
override suspend fun getMemoAsUtf8(txId: ByteArray, outputIndex: Int) =
withContext(SdkDispatchers.DATABASE_IO) {
getReceivedMemoAsUtf8(
getMemoAsUtf8(
dataDbFile.absolutePath,
idNote,
networkId = networkId
)
}
override suspend fun getSentMemoAsUtf8(idNote: Long) =
withContext(SdkDispatchers.DATABASE_IO) {
getSentMemoAsUtf8(
dataDbFile.absolutePath,
idNote,
txId,
outputIndex,
networkId = networkId
)
}
@ -180,9 +150,9 @@ class RustBackend private constructor(
)
}
override suspend fun getLatestHeight() =
override suspend fun getLatestCacheHeight() =
withContext(SdkDispatchers.DATABASE_IO) {
val height = getLatestHeight(fsBlockDbRoot.absolutePath)
val height = getLatestCacheHeight(fsBlockDbRoot.absolutePath)
if (-1L == height) {
null
@ -207,22 +177,6 @@ class RustBackend private constructor(
)
}
override suspend fun validateCombinedChainOrErrorHeight(limit: Long?) =
withContext(SdkDispatchers.DATABASE_IO) {
val validationResult = validateCombinedChain(
dbCachePath = fsBlockDbRoot.absolutePath,
dbDataPath = dataDbFile.absolutePath,
limit = limit ?: -1,
networkId = networkId
)
if (-1L == validationResult) {
null
} else {
validationResult
}
}
override suspend fun getVerifiedTransparentBalance(address: String): Long =
withContext(SdkDispatchers.DATABASE_IO) {
getVerifiedTransparentBalance(
@ -264,12 +218,79 @@ class RustBackend private constructor(
)
}
override suspend fun scanBlocks(limit: Long?) {
override suspend fun putSaplingSubtreeRoots(
startIndex: Long,
roots: List<JniSubtreeRoot>,
) = withContext(SdkDispatchers.DATABASE_IO) {
putSaplingSubtreeRoots(
dataDbFile.absolutePath,
startIndex,
roots.toTypedArray(),
networkId = networkId
)
}
override suspend fun updateChainTip(height: Long) =
withContext(SdkDispatchers.DATABASE_IO) {
updateChainTip(
dataDbFile.absolutePath,
height,
networkId = networkId
)
}
override suspend fun getFullyScannedHeight() =
withContext(SdkDispatchers.DATABASE_IO) {
val height = getFullyScannedHeight(
dataDbFile.absolutePath,
networkId = networkId
)
if (-1L == height) {
null
} else {
height
}
}
override suspend fun getMaxScannedHeight() =
withContext(SdkDispatchers.DATABASE_IO) {
val height = getMaxScannedHeight(
dataDbFile.absolutePath,
networkId = networkId
)
if (-1L == height) {
null
} else {
height
}
}
override suspend fun getScanProgress(): JniScanProgress? =
withContext(SdkDispatchers.DATABASE_IO) {
getScanProgress(
dataDbFile.absolutePath,
networkId = networkId
)
}
override suspend fun suggestScanRanges(): List<JniScanRange> {
return withContext(SdkDispatchers.DATABASE_IO) {
suggestScanRanges(
dataDbFile.absolutePath,
networkId = networkId
).asList()
}
}
override suspend fun scanBlocks(fromHeight: Long, limit: Long) {
return withContext(SdkDispatchers.DATABASE_IO) {
scanBlocks(
fsBlockDbRoot.absolutePath,
dataDbFile.absolutePath,
limit ?: -1,
fromHeight,
limit,
networkId = networkId
)
}
@ -290,7 +311,7 @@ class RustBackend private constructor(
to: String,
value: Long,
memo: ByteArray?
): Long = withContext(SdkDispatchers.DATABASE_IO) {
): ByteArray = withContext(SdkDispatchers.DATABASE_IO) {
createToAddress(
dataDbFile.absolutePath,
unifiedSpendingKey,
@ -308,7 +329,7 @@ class RustBackend private constructor(
account: Int,
unifiedSpendingKey: ByteArray,
memo: ByteArray?
): Long {
): ByteArray {
return withContext(SdkDispatchers.DATABASE_IO) {
shieldToAddress(
dataDbFile.absolutePath,
@ -403,25 +424,13 @@ class RustBackend private constructor(
private external fun initDataDb(dbDataPath: String, seed: ByteArray?, networkId: Int): Int
@JvmStatic
private external fun initAccountsTableWithKeys(
private external fun createAccount(
dbDataPath: String,
ufvks: Array<out String>,
seed: ByteArray,
treeState: ByteArray,
recoverUntil: Long,
networkId: Int
)
@JvmStatic
@Suppress("LongParameterList")
private external fun initBlocksTable(
dbDataPath: String,
height: Long,
hash: String,
time: Long,
saplingTree: String,
networkId: Int
)
@JvmStatic
private external fun createAccount(dbDataPath: String, seed: ByteArray, networkId: Int): JniUnifiedSpendingKey
): JniUnifiedSpendingKey
@JvmStatic
private external fun getCurrentAddress(
@ -439,8 +448,7 @@ class RustBackend private constructor(
@JvmStatic
private external fun listTransparentReceivers(dbDataPath: String, account: Int, networkId: Int): Array<String>
fun validateUnifiedSpendingKey(bytes: ByteArray) =
isValidSpendingKey(bytes)
fun validateUnifiedSpendingKey(bytes: ByteArray) = isValidSpendingKey(bytes)
@JvmStatic
private external fun isValidSpendingKey(bytes: ByteArray): Boolean
@ -465,16 +473,10 @@ class RustBackend private constructor(
): Long
@JvmStatic
private external fun getReceivedMemoAsUtf8(
private external fun getMemoAsUtf8(
dbDataPath: String,
idNote: Long,
networkId: Int
): String?
@JvmStatic
private external fun getSentMemoAsUtf8(
dbDataPath: String,
dNote: Long,
txId: ByteArray,
outputIndex: Int,
networkId: Int
): String?
@ -485,7 +487,7 @@ class RustBackend private constructor(
)
@JvmStatic
private external fun getLatestHeight(dbCachePath: String): Long
private external fun getLatestCacheHeight(dbCachePath: String): Long
@JvmStatic
private external fun findBlockMetadata(
@ -499,14 +501,6 @@ class RustBackend private constructor(
height: Long
)
@JvmStatic
private external fun validateCombinedChain(
dbCachePath: String,
dbDataPath: String,
limit: Long,
networkId: Int
): Long
@JvmStatic
private external fun getNearestRewindHeight(
dbDataPath: String,
@ -521,10 +515,50 @@ class RustBackend private constructor(
networkId: Int
)
@JvmStatic
private external fun putSaplingSubtreeRoots(
dbDataPath: String,
startIndex: Long,
roots: Array<JniSubtreeRoot>,
networkId: Int
)
@JvmStatic
private external fun updateChainTip(
dbDataPath: String,
height: Long,
networkId: Int
)
@JvmStatic
private external fun getFullyScannedHeight(
dbDataPath: String,
networkId: Int
): Long
@JvmStatic
private external fun getMaxScannedHeight(
dbDataPath: String,
networkId: Int
): Long
@JvmStatic
private external fun getScanProgress(
dbDataPath: String,
networkId: Int
): JniScanProgress?
@JvmStatic
private external fun suggestScanRanges(
dbDataPath: String,
networkId: Int
): Array<JniScanRange>
@JvmStatic
private external fun scanBlocks(
dbCachePath: String,
dbDataPath: String,
fromHeight: Long,
limit: Long,
networkId: Int
)
@ -548,7 +582,7 @@ class RustBackend private constructor(
outputParamsPath: String,
networkId: Int,
useZip317Fees: Boolean
): Long
): ByteArray
@JvmStatic
@Suppress("LongParameterList")
@ -560,7 +594,7 @@ class RustBackend private constructor(
outputParamsPath: String,
networkId: Int,
useZip317Fees: Boolean
): Long
): ByteArray
@JvmStatic
private external fun branchIdForHeight(height: Long, networkId: Int): Long

View File

@ -1,6 +1,7 @@
package cash.z.ecc.android.sdk.internal.model
import androidx.annotation.Keep
import cash.z.ecc.android.sdk.internal.ext.isInUIntRange
import co.electriccoin.lightwallet.client.model.CompactBlockUnsafe
/**
@ -23,20 +24,18 @@ class JniBlockMeta(
init {
// We require some of the parameters below to be in the range of unsigned integer, because of the Rust layer
// implementation.
require(UINT_RANGE.contains(height)) {
"Height $height is outside of allowed range $UINT_RANGE"
require(height.isInUIntRange()) {
"Height $height is outside of allowed UInt range"
}
require(UINT_RANGE.contains(saplingOutputsCount)) {
"SaplingOutputsCount $saplingOutputsCount is outside of allowed range $UINT_RANGE"
require(saplingOutputsCount.isInUIntRange()) {
"SaplingOutputsCount $saplingOutputsCount is outside of allowed UInt range"
}
require(UINT_RANGE.contains(orchardOutputsCount)) {
"SaplingOutputsCount $orchardOutputsCount is outside of allowed range $UINT_RANGE"
require(orchardOutputsCount.isInUIntRange()) {
"SaplingOutputsCount $orchardOutputsCount is outside of allowed UInt range"
}
}
companion object {
private val UINT_RANGE = 0.toLong()..UInt.MAX_VALUE.toLong()
fun new(block: CompactBlockUnsafe): JniBlockMeta {
return JniBlockMeta(
height = block.height,

View File

@ -0,0 +1,32 @@
package cash.z.ecc.android.sdk.internal.model
import androidx.annotation.Keep
/**
* Serves as cross layer (Kotlin, Rust) communication class.
*
* @throws IllegalArgumentException unless (numerator is nonnegative, denominator is
* positive, and the represented ratio is in the range 0.0 to 1.0 inclusive).
* @param numerator the numerator of the progress ratio
* @param denominator the denominator of the progress ratio
*/
@Keep
class JniScanProgress(
val numerator: Long,
val denominator: Long
) {
init {
require(numerator >= 0L) {
"Numerator $numerator is outside of allowed range [0, Long.MAX_VALUE]"
}
require(denominator >= 1L) {
"Denominator $denominator is outside of allowed range [1, Long.MAX_VALUE]"
}
require(numerator.toFloat().div(denominator) >= 0f) {
"Result of ${numerator.toFloat()}/$denominator is outside of allowed range"
}
require(numerator.toFloat().div(denominator) <= 1f) {
"Result of ${numerator.toFloat()}/$denominator is outside of allowed range"
}
}
}

View File

@ -0,0 +1,32 @@
package cash.z.ecc.android.sdk.internal.model
import androidx.annotation.Keep
import cash.z.ecc.android.sdk.internal.ext.isInUIntRange
/**
* Serves as cross layer (Kotlin, Rust) communication class.
*
* @param startHeight the minimum height in the range (inclusive) - although it's type Long, it needs to be a UInt
* @param endHeight the maximum height in the range (exclusive) - although it's type Long, it needs to be a UInt
* @param priority the priority of the range for scanning
*/
@Keep
class JniScanRange(
val startHeight: Long,
val endHeight: Long,
val priority: Long
) {
init {
// We require some of the parameters below to be in the range of unsigned integer, because of the Rust layer
// implementation.
require(startHeight.isInUIntRange()) {
"Height $startHeight is outside of allowed UInt range"
}
require(endHeight.isInUIntRange()) {
"Height $endHeight is outside of allowed UInt range"
}
require(endHeight >= startHeight) {
"End height $endHeight must be greater than start height $startHeight."
}
}
}

View File

@ -0,0 +1,34 @@
package cash.z.ecc.android.sdk.internal.model
import androidx.annotation.Keep
import cash.z.ecc.android.sdk.internal.ext.isInUIntRange
/**
* Serves as cross layer (Kotlin, Rust) communication class.
*
* @param rootHash the subtree's root hash
* @param completingBlockHeight the block height in which the subtree was completed - although it's type Long, it needs
* to be in UInt range
*/
@Keep
class JniSubtreeRoot(
val rootHash: ByteArray,
val completingBlockHeight: Long
) {
init {
// We require some of the parameters below to be in the range of unsigned integer,
// because of the Rust layer implementation.
require(completingBlockHeight.isInUIntRange()) {
"Height $completingBlockHeight is outside of allowed UInt range"
}
}
companion object {
fun new(rootHash: ByteArray, completingBlockHeight: Long): JniSubtreeRoot {
return JniSubtreeRoot(
rootHash = rootHash,
completingBlockHeight = completingBlockHeight
)
}
}
}

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::num::NonZeroU32;
use std::panic;
use std::path::Path;
use std::ptr;
@ -11,34 +11,40 @@ use jni::{
sys::{jboolean, jbyteArray, jint, jlong, jobject, jobjectArray, jstring, JNI_FALSE, JNI_TRUE},
JNIEnv,
};
use prost::Message;
use schemer::MigratorError;
use secrecy::{ExposeSecret, SecretVec};
use tracing::{debug, error};
use tracing_subscriber::prelude::*;
use tracing_subscriber::reload;
use zcash_address::{ToAddress, ZcashAddress};
use zcash_client_backend::data_api::{
scanning::{ScanPriority, ScanRange},
AccountBirthday, NoteId, Ratio, ShieldedProtocol,
};
use zcash_client_backend::keys::{DecodingError, UnifiedSpendingKey};
use zcash_client_backend::{
address::{RecipientAddress, UnifiedAddress},
data_api::{
chain::{self, scan_cached_blocks, validate_chain},
chain::{scan_cached_blocks, CommitmentTreeRoot},
wallet::{
decrypt_and_store_transaction, input_selection::GreedyInputSelector,
shield_transparent_funds, spend,
},
WalletRead, WalletWrite,
WalletCommitmentTrees, WalletRead, WalletWrite,
},
encoding::AddressCodec,
fees::DustOutputPolicy,
keys::{Era, UnifiedFullViewingKey},
proto::service::TreeState,
wallet::{OvkPolicy, WalletTransparentOutput},
zip321::{Payment, TransactionRequest},
};
use zcash_client_sqlite::chain::init::init_blockmeta_db;
use zcash_client_sqlite::{
chain::BlockMeta,
wallet::init::{init_accounts_table, init_blocks_table, init_wallet_db, WalletMigrationError},
FsBlockDb, NoteId, WalletDb,
wallet::init::{init_wallet_db, WalletMigrationError},
FsBlockDb, WalletDb,
};
use zcash_primitives::consensus::Network::{MainNetwork, TestNetwork};
use zcash_primitives::{
@ -46,9 +52,11 @@ use zcash_primitives::{
consensus::{BlockHeight, BranchId, Network, Parameters},
legacy::{Script, TransparentAddress},
memo::{Memo, MemoBytes},
merkle_tree::HashSer,
sapling,
transaction::{
components::{amount::NonNegativeAmount, Amount, OutPoint, TxOut},
Transaction,
Transaction, TxId,
},
zip32::{AccountId, DiversifierIndex},
};
@ -68,7 +76,8 @@ mod zip317 {
pub(super) use zcash_primitives::transaction::fees::zip317::*;
}
const ANCHOR_OFFSET: u32 = 10;
const ANCHOR_OFFSET_U32: u32 = 10;
const ANCHOR_OFFSET: NonZeroU32 = unsafe { NonZeroU32::new_unchecked(ANCHOR_OFFSET_U32) };
#[cfg(debug_assertions)]
fn print_debug_state() {
@ -84,7 +93,7 @@ fn wallet_db<P: Parameters>(
env: &JNIEnv<'_>,
params: P,
db_data: JString<'_>,
) -> Result<WalletDb<P>, failure::Error> {
) -> Result<WalletDb<rusqlite::Connection, P>, failure::Error> {
WalletDb::for_path(utils::java_string_to_rust(&env, db_data), params)
.map_err(|e| format_err!("Error opening wallet database connection: {}", e))
}
@ -252,16 +261,32 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_cr
_: JClass<'_>,
db_data: JString<'_>,
seed: jbyteArray,
treestate: jbyteArray,
recover_until: jlong,
network_id: jint,
) -> jobject {
use zcash_client_backend::data_api::BirthdayError;
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = wallet_db(&env, network, db_data)?;
let seed = SecretVec::new(env.convert_byte_array(seed).unwrap());
let treestate = TreeState::decode(&env.convert_byte_array(treestate).unwrap()[..])
.map_err(|e| format_err!("Invalid TreeState: {}", e))?;
let recover_until = recover_until.try_into().ok();
let mut db_ops = db_data.get_update_ops()?;
let (account, usk) = db_ops
.create_account(&seed)
let birthday =
AccountBirthday::from_treestate(treestate, recover_until).map_err(|e| match e {
BirthdayError::HeightInvalid(e) => {
format_err!("Invalid TreeState: Invalid height: {}", e)
}
BirthdayError::Decode(e) => {
format_err!("Invalid TreeState: Invalid frontier encoding: {}", e)
}
})?;
let (account, usk) = db_data
.create_account(&seed, birthday)
.map_err(|e| format_err!("Error while initializing accounts: {}", e))?;
encode_usk(&env, account, usk)
@ -269,52 +294,6 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_cr
unwrap_exc_or(&env, res, ptr::null_mut())
}
/// Initialises the data database with the given set of unified full viewing keys.
///
/// This should only be used in special cases for implementing wallet recovery; prefer
/// `RustBackend.createAccount` for normal account creation purposes.
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_initAccountsTableWithKeys(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
ufvks_arr: jobjectArray,
network_id: jint,
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
// TODO: avoid all this unwrapping and also surface errors, better
let count = env.get_array_length(ufvks_arr).unwrap();
let ufvks = (0..count)
.map(|i| env.get_object_array_element(ufvks_arr, i))
.map(|jstr| utils::java_string_to_rust(&env, jstr.unwrap().into()))
.map(|ufvkstr| {
UnifiedFullViewingKey::decode(&network, &ufvkstr).map_err(|e| {
if e.starts_with("UFVK is for network") {
let (network_name, other) = if network == TestNetwork {
("testnet", "mainnet")
} else {
("mainnet", "testnet")
};
format_err!("Error: Wrong network! Unable to decode viewing key for {}. Check whether this is a key for {}.", network_name, other)
} else {
format_err!("Invalid Unified Full Viewing Key: {}", e)
}
})
})
.enumerate() // TODO: Pass account IDs across the FFI.
.map(|(i, res)| res.map(|ufvk| (AccountId::from(i as u32), ufvk)))
.collect::<Result<HashMap<_,_>, _>>()?;
match init_accounts_table(&db_data, &ufvks) {
Ok(()) => Ok(JNI_TRUE),
Err(e) => Err(format_err!("Error while initializing accounts: {}", e)),
}
});
unwrap_exc_or(&env, res, JNI_FALSE)
}
/// Derives and returns a unified spending key from the given seed for the given account ID.
///
/// Returns the newly created [ZIP 316] account identifier, along with the binary encoding
@ -472,48 +451,6 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustDerivation
unwrap_exc_or(&env, res, ptr::null_mut())
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_initBlocksTable(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
height: jlong,
hash_string: JString<'_>,
time: jlong,
sapling_tree_string: JString<'_>,
network_id: jint,
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let hash = {
let mut hash = hex::decode(utils::java_string_to_rust(&env, hash_string)).unwrap();
hash.reverse();
BlockHash::from_slice(&hash)
};
let time = if time >= 0 && time <= jlong::from(u32::max_value()) {
time as u32
} else {
return Err(format_err!("time argument must fit in a u32"));
};
let sapling_tree =
hex::decode(utils::java_string_to_rust(&env, sapling_tree_string)).unwrap();
debug!("initializing blocks table with height {}", height);
match init_blocks_table(
&db_data,
(height as u32).try_into()?,
hash,
time,
&sapling_tree,
) {
Ok(()) => Ok(JNI_TRUE),
Err(e) => Err(format_err!("Error while initializing blocks table: {}", e)),
}
});
unwrap_exc_or(&env, res, JNI_FALSE)
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getCurrentAddress(
env: JNIEnv<'_>,
@ -720,27 +657,20 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_ge
let db_data = wallet_db(&env, network, db_data)?;
let account = AccountId::from(u32::try_from(accountj)?);
// We query the unverified balance including unmined transactions. Shielded notes
// in unmined transactions are never spendable, but this ensures that the balance
// reported to users does not drop temporarily in a way that they don't expect.
// `getVerifiedBalance` requires `ANCHOR_OFFSET` confirmations, which means it
// always shows a spendable balance.
let min_confirmations = 0;
(&db_data)
.get_target_and_anchor_heights(min_confirmations)
.map_err(|e| format_err!("Error while fetching anchor height: {}", e))
.and_then(|opt_anchor| {
opt_anchor
.map(|(_, a)| a)
.ok_or(format_err!("Anchor height not available; scan required."))
})
.and_then(|anchor| {
(&db_data)
.get_balance_at(account, anchor)
.map_err(|e| format_err!("Error while fetching verified balance: {}", e))
})
.map(|amount| amount.into())
if let Some(wallet_summary) = db_data
.get_wallet_summary(0)
.map_err(|e| format_err!("Error while fetching balance: {}", e))?
{
wallet_summary
.account_balances()
.get(&account)
.ok_or_else(|| format_err!("Unknown account"))
.map(|balances| Amount::from(balances.sapling_balance.total()).into())
} else {
// `None` means that the caller has not yet called `updateChainTip` on a
// brand-new wallet, so we can assume the balance is zero.
Ok(0)
}
});
unwrap_exc_or(&env, res, -1)
}
@ -797,16 +727,14 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_ge
let addr = utils::java_string_to_rust(&env, address);
let taddr = TransparentAddress::decode(&network, &addr).unwrap();
// We select all transparent funds including unmined coins, as that is the same
// set of UTXOs we shield from.
let min_confirmations = 0;
let min_confirmations = NonZeroU32::new(1).unwrap();
let amount = (&db_data)
.get_target_and_anchor_heights(min_confirmations)
.map_err(|e| format_err!("Error while fetching anchor height: {}", e))
.and_then(|opt_anchor| {
opt_anchor
.map(|(_, a)| a)
.map(|(target, _)| target) // Include unconfirmed funds.
.ok_or(format_err!("Anchor height not available; scan required."))
})
.and_then(|anchor| {
@ -838,70 +766,49 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_ge
let db_data = wallet_db(&env, network, db_data)?;
let account = AccountId::from(u32::try_from(account)?);
(&db_data)
.get_target_and_anchor_heights(ANCHOR_OFFSET)
.map_err(|e| format_err!("Error while fetching anchor height: {}", e))
.and_then(|opt_anchor| {
opt_anchor
.map(|(_, a)| a)
.ok_or(format_err!("Anchor height not available; scan required."))
})
.and_then(|anchor| {
(&db_data)
.get_balance_at(account, anchor)
.map_err(|e| format_err!("Error while fetching verified balance: {}", e))
})
.map(|amount| amount.into())
if let Some(wallet_summary) = db_data
.get_wallet_summary(ANCHOR_OFFSET_U32)
.map_err(|e| format_err!("Error while fetching verified balance: {}", e))?
{
wallet_summary
.account_balances()
.get(&account)
.ok_or_else(|| format_err!("Unknown account"))
.map(|balances| Amount::from(balances.sapling_balance.spendable_value).into())
} else {
// `None` means that the caller has not yet called `updateChainTip` on a
// brand-new wallet, so we can assume the balance is zero.
Ok(0)
}
});
unwrap_exc_or(&env, res, -1)
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getReceivedMemoAsUtf8(
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getMemoAsUtf8(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
id_note: jlong,
txid_bytes: jbyteArray,
output_index: jint,
network_id: jint,
) -> jstring {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let memo = (&db_data)
.get_memo(NoteId::ReceivedNoteId(id_note))
.map_err(|e| format_err!("An error occurred retrieving the memo, {}", e))
.and_then(|memo| match memo {
Memo::Empty => Ok("".to_string()),
Memo::Text(memo) => Ok(memo.into()),
_ => Err(format_err!("This memo does not contain UTF-8 text")),
})?;
let output = env.new_string(memo).expect("Couldn't create Java string!");
Ok(output.into_raw())
});
unwrap_exc_or(&env, res, ptr::null_mut())
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getSentMemoAsUtf8(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
id_note: jlong,
network_id: jint,
) -> jstring {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let txid_bytes = env.convert_byte_array(txid_bytes)?;
let txid = TxId::read(&txid_bytes[..])?;
let output_index = u16::try_from(output_index)?;
let memo = (&db_data)
.get_memo(NoteId::SentNoteId(id_note))
.get_memo(NoteId::new(txid, ShieldedProtocol::Sapling, output_index))
.map_err(|e| format_err!("An error occurred retrieving the memo, {}", e))
.and_then(|memo| match memo {
Memo::Empty => Ok("".to_string()),
Memo::Text(memo) => Ok(memo.into()),
Some(Memo::Empty) => Ok("".to_string()),
Some(Memo::Text(memo)) => Ok(memo.into()),
None => Err(format_err!("Memo not available")),
_ => Err(format_err!("This memo does not contain UTF-8 text")),
})?;
@ -984,7 +891,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_wr
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getLatestHeight(
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getLatestCacheHeight(
env: JNIEnv<'_>,
_: JClass<'_>,
fsblockdb_root: JString<'_>,
@ -1051,44 +958,6 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_re
unwrap_exc_or(&env, res, ())
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_validateCombinedChain(
env: JNIEnv<'_>,
_: JClass<'_>,
db_cache: JString<'_>,
db_data: JString<'_>,
limit: jlong,
network_id: jint,
) -> jlong {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let block_db = block_db(&env, db_cache)?;
let db_data = wallet_db(&env, network, db_data)?;
let validate_limit = u32::try_from(limit).ok();
let validate_from = (&db_data)
.get_max_height_hash()
.map_err(|e| format_err!("Error while validating chain: {}", e))?;
let val_res = validate_chain(&block_db, validate_from, validate_limit);
if let Err(e) = val_res {
match e {
chain::error::Error::Chain(e) => {
let upper_bound_u32 = u32::from(e.at_height());
Ok(upper_bound_u32 as i64)
}
_ => Err(format_err!("Error while validating chain: {:?}", e)),
}
} else {
// All blocks are valid, so "highest invalid block height" is below genesis.
Ok(-1)
}
});
unwrap_exc_or(&env, res, 0) as jlong
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getNearestRewindHeight(
env: JNIEnv<'_>,
@ -1135,8 +1004,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_re
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = db_data.get_update_ops()?;
let mut db_data = wallet_db(&env, network, db_data)?;
let height = BlockHeight::try_from(height)?;
db_data
@ -1148,23 +1016,247 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_re
unwrap_exc_or(&env, res, JNI_FALSE)
}
fn decode_sapling_subtree_root(
env: &JNIEnv<'_>,
obj: JObject<'_>,
) -> Result<CommitmentTreeRoot<sapling::Node>, failure::Error> {
let long_as_u32 = |name| -> Result<u32, failure::Error> {
Ok(u32::try_from(env.get_field(obj, name, "J")?.j()?)?)
};
fn byte_array(
env: &JNIEnv<'_>,
obj: JObject<'_>,
name: &str,
) -> Result<Vec<u8>, failure::Error> {
let field = env.get_field(obj, name, "[B")?.l()?.into_raw();
Ok(env.convert_byte_array(field)?[..].try_into()?)
}
Ok(CommitmentTreeRoot::from_parts(
BlockHeight::from_u32(long_as_u32("completingBlockHeight")?),
sapling::Node::read(&byte_array(env, obj, "rootHash")?[..])?,
))
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_putSaplingSubtreeRoots(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
start_index: jlong,
roots: jobjectArray,
network_id: jint,
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let mut db_data = wallet_db(&env, network, db_data)?;
let start_index = if start_index >= 0 {
start_index as u64
} else {
return Err(format_err!("Start index must be nonnegative."));
};
let roots = {
let count = env.get_array_length(roots).unwrap();
(0..count)
.map(|i| {
env.get_object_array_element(roots, i)
.map_err(|e| e.into())
.and_then(|jobj| decode_sapling_subtree_root(&env, jobj))
})
.collect::<Result<Vec<_>, _>>()?
};
db_data
.put_sapling_subtree_roots(start_index, &roots)
.map(|()| JNI_TRUE)
.map_err(|e| format_err!("Error while storing Sapling subtree roots: {}", e))
});
unwrap_exc_or(&env, res, JNI_FALSE)
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_updateChainTip(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
height: jlong,
network_id: jint,
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let mut db_data = wallet_db(&env, network, db_data)?;
let height = BlockHeight::try_from(height)?;
db_data
.update_chain_tip(height)
.map(|()| JNI_TRUE)
.map_err(|e| format_err!("Error while updating chain tip to height {}: {}", height, e))
});
unwrap_exc_or(&env, res, JNI_FALSE)
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getFullyScannedHeight(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
network_id: jint,
) -> jlong {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
match db_data.block_fully_scanned() {
Ok(Some(metadata)) => Ok(i64::from(u32::from(metadata.block_height()))),
// Use -1 to return null across the FFI.
Ok(None) => Ok(-1),
Err(e) => Err(format_err!(
"Failed to read block metadata from WalletDb: {:?}",
e
)),
}
});
unwrap_exc_or(&env, res, -1)
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getMaxScannedHeight(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
network_id: jint,
) -> jlong {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
match db_data.block_max_scanned() {
Ok(Some(metadata)) => Ok(i64::from(u32::from(metadata.block_height()))),
// Use -1 to return null across the FFI.
Ok(None) => Ok(-1),
Err(e) => Err(format_err!(
"Failed to read block metadata from WalletDb: {:?}",
e
)),
}
});
unwrap_exc_or(&env, res, -1)
}
/// Returns a `JniScanProgress` object, provided that numerator is nonnegative, denominator
/// is positive, and the represented ratio is in the range 0.0 to 1.0 inclusive.
fn encode_scan_progress(env: &JNIEnv<'_>, progress: Ratio<u64>) -> Result<jobject, failure::Error> {
let output = env.new_object(
"cash/z/ecc/android/sdk/internal/model/JniScanProgress",
"(JJ)V",
&[
JValue::Long(*progress.numerator() as i64),
JValue::Long(*progress.denominator() as i64),
],
)?;
Ok(output.into_raw())
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_getScanProgress(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
network_id: jint,
) -> jobject {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
match db_data
.get_wallet_summary(0)
.map_err(|e| format_err!("Error while fetching scan progress: {}", e))?
.and_then(|summary| summary.scan_progress().filter(|r| r.denominator() > &0))
{
Some(progress) => encode_scan_progress(&env, progress),
None => Ok(ptr::null_mut()),
}
});
unwrap_exc_or(&env, res, ptr::null_mut())
}
fn encode_scan_range<'a>(
env: &JNIEnv<'a>,
scan_range: ScanRange,
) -> jni::errors::Result<JObject<'a>> {
let priority = match scan_range.priority() {
ScanPriority::Ignored => 0,
ScanPriority::Scanned => 10,
ScanPriority::Historic => 20,
ScanPriority::OpenAdjacent => 30,
ScanPriority::FoundNote => 40,
ScanPriority::ChainTip => 50,
ScanPriority::Verify => 60,
};
env.new_object(
"cash/z/ecc/android/sdk/internal/model/JniScanRange",
"(JJJ)V",
&[
JValue::Long(i64::from(u32::from(scan_range.block_range().start))),
JValue::Long(i64::from(u32::from(scan_range.block_range().end))),
JValue::Long(priority),
],
)
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_suggestScanRanges(
env: JNIEnv<'_>,
_: JClass<'_>,
db_data: JString<'_>,
network_id: jint,
) -> jobjectArray {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let ranges = db_data
.suggest_scan_ranges()
.map_err(|e| format_err!("Error while fetching suggested scan ranges: {}", e))?;
Ok(utils::rust_vec_to_java(
&env,
ranges,
"cash/z/ecc/android/sdk/internal/model/JniScanRange",
|env, scan_range| encode_scan_range(env, scan_range),
|env| {
encode_scan_range(
env,
ScanRange::from_parts((0.into())..(0.into()), ScanPriority::Scanned),
)
},
))
});
unwrap_exc_or(&env, res, ptr::null_mut())
}
#[no_mangle]
pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_scanBlocks(
env: JNIEnv<'_>,
_: JClass<'_>,
db_cache: JString<'_>,
db_data: JString<'_>,
from_height: jlong,
limit: jlong,
network_id: jint,
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_cache = block_db(&env, db_cache)?;
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = db_data.get_update_ops()?;
let limit = u32::try_from(limit).ok();
let mut db_data = wallet_db(&env, network, db_data)?;
let from_height = BlockHeight::try_from(from_height)?;
let limit = usize::try_from(limit)?;
match scan_cached_blocks(&network, &db_cache, &mut db_data, limit) {
match scan_cached_blocks(&network, &db_cache, &mut db_data, from_height, limit) {
Ok(()) => Ok(JNI_TRUE),
Err(e) => Err(format_err!(
"Rust error while scanning blocks (limit {:?}): {:?}",
@ -1199,8 +1291,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_pu
txid.copy_from_slice(&txid_bytes);
let script_pubkey = Script(env.convert_byte_array(script).unwrap());
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = db_data.get_update_ops()?;
let mut db_data = wallet_db(&env, network, db_data)?;
let addr = utils::java_string_to_rust(&env, address);
let _address = TransparentAddress::decode(&network, &addr).unwrap();
@ -1233,8 +1324,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_de
) -> jboolean {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = db_data.get_update_ops()?;
let mut db_data = wallet_db(&env, network, db_data)?;
let tx_bytes = env.convert_byte_array(tx).unwrap();
// The consensus branch ID passed in here does not matter:
// - v4 and below cache it internally, but all we do with this transaction while
@ -1289,11 +1379,10 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_cr
output_params: JString<'_>,
network_id: jint,
use_zip317_fees: jboolean,
) -> jlong {
) -> jbyteArray {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = db_data.get_update_ops()?;
let mut db_data = wallet_db(&env, network, db_data)?;
let usk = decode_usk(&env, usk)?;
let to = utils::java_string_to_rust(&env, to);
let value =
@ -1334,7 +1423,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_cr
}])
.map_err(|e| format_err!("Error creating transaction request: {:?}", e))?;
zip317_helper(
let txid = zip317_helper(
(&mut db_data, prover, request),
use_zip317_fees,
|(wallet_db, prover, request), input_selector| {
@ -1363,9 +1452,11 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_cr
)
.map_err(|e| format_err!("Error while creating transaction: {}", e))
},
)
)?;
utils::rust_bytes_to_java(&env, txid.as_ref())
});
unwrap_exc_or(&env, res, -1)
unwrap_exc_or(&env, res, ptr::null_mut())
}
#[no_mangle]
@ -1379,17 +1470,16 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_sh
output_params: JString<'_>,
network_id: jint,
use_zip317_fees: jboolean,
) -> jlong {
) -> jbyteArray {
let res = panic::catch_unwind(|| {
let network = parse_network(network_id as u32)?;
let db_data = wallet_db(&env, network, db_data)?;
let mut db_data = db_data.get_update_ops()?;
let mut db_data = wallet_db(&env, network, db_data)?;
let usk = decode_usk(&env, usk)?;
let memo_bytes = env.convert_byte_array(memo).unwrap();
let spend_params = utils::java_string_to_rust(&env, spend_params);
let output_params = utils::java_string_to_rust(&env, output_params);
let min_confirmations = 0;
let min_confirmations = NonZeroU32::new(1).unwrap();
let account = db_data
.get_account_for_ufvk(&usk.to_unified_full_viewing_key())?
@ -1400,7 +1490,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_sh
.map_err(|e| format_err!("Error while fetching anchor height: {}", e))
.and_then(|opt_anchor| {
opt_anchor
.map(|(_, a)| a)
.map(|(target, _)| target) // Include unconfirmed funds.
.ok_or(format_err!("Anchor height not available; scan required."))
})
.and_then(|anchor| {
@ -1423,7 +1513,7 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_sh
let shielding_threshold = NonNegativeAmount::from_u64(100000).unwrap();
zip317_helper(
let txid = zip317_helper(
(&mut db_data, prover),
use_zip317_fees,
|(wallet_db, prover), input_selector| {
@ -1454,9 +1544,11 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_sh
)
.map_err(|e| format_err!("Error while shielding transaction: {}", e))
},
)
)?;
utils::rust_bytes_to_java(&env, txid.as_ref())
});
unwrap_exc_or(&env, res, -1)
unwrap_exc_or(&env, res, ptr::null_mut())
}
#[no_mangle]

View File

@ -1,13 +1,13 @@
use core::slice;
use jni::{
descriptors::Desc,
errors::Result as JNIResult,
objects::{JClass, JObject, JString},
sys::{jobjectArray, jsize},
sys::{jbyteArray, jobjectArray, jsize},
JNIEnv,
};
use std::ops::Deref;
pub(crate) mod exception;
pub(crate) mod target_ndk;
pub(crate) mod trace;
@ -18,6 +18,17 @@ pub(crate) fn java_string_to_rust(env: &JNIEnv<'_>, jstring: JString<'_>) -> Str
.into()
}
pub(crate) fn rust_bytes_to_java(
env: &JNIEnv<'_>,
data: &[u8],
) -> Result<jbyteArray, failure::Error> {
// SAFETY: jbyte (i8) has the same size and alignment as u8.
let buf = unsafe { slice::from_raw_parts(data.as_ptr().cast(), data.len()) };
let jret = env.new_byte_array(data.len() as jsize)?;
env.set_byte_array_region(jret, 0, buf)?;
Ok(jret)
}
pub(crate) fn rust_vec_to_java<'a, T, U, V, F, G>(
env: &JNIEnv<'a>,
data: Vec<T>,
@ -27,17 +38,17 @@ pub(crate) fn rust_vec_to_java<'a, T, U, V, F, G>(
) -> jobjectArray
where
U: Desc<'a, JClass<'a>>,
V: Deref<Target = JObject<'a>>,
V: Into<JObject<'a>>,
F: Fn(&JNIEnv<'a>, T) -> JNIResult<V>,
G: Fn(&JNIEnv<'a>) -> JNIResult<V>,
{
let jempty = empty_element(env).expect("Couldn't create Java string!");
let jret = env
.new_object_array(data.len() as jsize, element_class, *jempty)
.new_object_array(data.len() as jsize, element_class, jempty.into())
.expect("Couldn't create Java array!");
for (i, elem) in data.into_iter().enumerate() {
let jelem = element_map(env, elem).expect("Couldn't map element to Java!");
env.set_object_array_element(jret, i as jsize, *jelem)
env.set_object_array_element(jret, i as jsize, jelem.into())
.expect("Couldn't set Java array element!");
}
jret

View File

@ -0,0 +1,24 @@
package cash.z.ecc.android.sdk.internal.ext
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NumberExtTest {
@Test
fun is_in_range_test() {
assertTrue(1L.isInUIntRange())
assertTrue(0L.isInUIntRange())
assertTrue(UInt.MAX_VALUE.toLong().isInUIntRange())
assertTrue(UInt.MIN_VALUE.toLong().isInUIntRange())
}
@Test
fun is_not_in_range_test() {
assertFalse(0L.minus(1L).isInUIntRange())
assertFalse(Long.MAX_VALUE.isInUIntRange())
assertFalse(Long.MIN_VALUE.isInUIntRange())
assertFalse(UInt.MAX_VALUE.toLong().plus(1L).isInUIntRange())
assertFalse(UInt.MIN_VALUE.toLong().minus(1L).isInUIntRange())
}
}

View File

@ -0,0 +1,32 @@
package cash.z.ecc.android.sdk.internal.model
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
class JniBlockMetaTest {
@Test
fun attributes_within_constraints() {
val instance = JniBlockMeta(
height = UInt.MAX_VALUE.toLong(),
hash = byteArrayOf(),
time = 0L,
saplingOutputsCount = UInt.MIN_VALUE.toLong(),
orchardOutputsCount = UInt.MIN_VALUE.toLong()
)
assertIs<JniBlockMeta>(instance)
}
@Test
fun attributes_not_in_constraints() {
assertFailsWith(IllegalArgumentException::class) {
JniBlockMeta(
height = Long.MAX_VALUE,
hash = byteArrayOf(),
time = 0L,
saplingOutputsCount = Long.MIN_VALUE,
orchardOutputsCount = Long.MIN_VALUE
)
}
}
}

View File

@ -0,0 +1,28 @@
package cash.z.ecc.android.sdk.internal.model
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
class JniScanRangeTest {
@Test
fun attributes_within_constraints() {
val instance = JniScanRange(
startHeight = UInt.MIN_VALUE.toLong(),
endHeight = UInt.MAX_VALUE.toLong(),
priority = 10
)
assertIs<JniScanRange>(instance)
}
@Test
fun attributes_not_in_constraints() {
assertFailsWith(IllegalArgumentException::class) {
JniScanRange(
startHeight = Long.MIN_VALUE,
endHeight = Long.MAX_VALUE,
priority = 10
)
}
}
}

View File

@ -0,0 +1,26 @@
package cash.z.ecc.android.sdk.internal.model
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
class JniSubtreeRootTest {
@Test
fun attributes_within_constraints() {
val instance = JniSubtreeRoot(
rootHash = byteArrayOf(),
completingBlockHeight = UInt.MAX_VALUE.toLong()
)
assertIs<JniSubtreeRoot>(instance)
}
@Test
fun attributes_not_in_constraints() {
assertFailsWith(IllegalArgumentException::class) {
JniSubtreeRoot(
rootHash = byteArrayOf(),
completingBlockHeight = Long.MAX_VALUE
)
}
}
}

View File

@ -10,11 +10,13 @@ import org.junit.runner.RunWith
/**
* Integration test to run in order to catch any regressions in transparent behavior.
*/
// TODO [#1224]: Refactor and re-enable disabled darkside tests
// TODO [#1224]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1224
@RunWith(AndroidJUnit4::class)
class TransparentIntegrationTest : DarksideTest() {
@Before
fun setup() = runOnce {
sithLord.await()
// sithLord.await()
}
@Test

View File

@ -6,15 +6,19 @@ import cash.z.ecc.android.sdk.darkside.test.ScopedTest
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import org.junit.BeforeClass
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
// TODO [#1224]: Refactor and re-enable disabled darkside tests
// TODO [#1224]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1224
@RunWith(AndroidJUnit4::class)
class InboundTxTests : ScopedTest() {
@Test
@Ignore("Temporarily disabled")
fun testTargetBlock_synced() {
validator.validateMinHeightSynced(firstBlock)
// validator.validateMinHeightSynced(firstBlock)
}
@Test
@ -28,10 +32,11 @@ class InboundTxTests : ScopedTest() {
}
@Test
@Ignore("Temporarily disabled")
fun testTxCountAfter() {
// add 2 transactions to block 663188 and 'mine' that block
addTransactions(targetTxBlock, tx663174, tx663188)
sithLord.await(timeout = 30_000L, targetHeight = targetTxBlock)
// sithLord.await(timeout = 30_000L, targetHeight = targetTxBlock)
validator.validateTxCount(2)
}
@ -91,7 +96,7 @@ class InboundTxTests : ScopedTest() {
.stageEmptyBlocks(firstBlock + 1, 100)
.applyTipHeight(BlockHeight.new(ZcashNetwork.Mainnet, targetTxBlock.value - 1))
sithLord.await()
// sithLord.await()
}
}
}

View File

@ -3,34 +3,39 @@ package cash.z.ecc.android.sdk.darkside.reorgs
import androidx.test.ext.junit.runners.AndroidJUnit4
import cash.z.ecc.android.sdk.darkside.test.DarksideTestCoordinator
import cash.z.ecc.android.sdk.darkside.test.ScopedTest
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
// TODO [#1224]: Refactor and re-enable disabled darkside tests
// TODO [#1224]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1224
@RunWith(AndroidJUnit4::class)
class ReorgSetupTest : ScopedTest() {
/*
private val birthdayHeight = BlockHeight.new(ZcashNetwork.Mainnet, 663150)
private val targetHeight = BlockHeight.new(ZcashNetwork.Mainnet, 663250)
*/
@Before
fun setup() {
sithLord.await()
// sithLord.await()
}
@Test
@Ignore("Temporarily disabled")
fun testBeforeReorg_minHeight() = timeout(30_000L) {
// validate that we are synced, at least to the birthday height
validator.validateMinHeightSynced(birthdayHeight)
// validator.validateMinHeightSynced(birthdayHeight)
}
@Test
@Ignore("Temporarily disabled")
fun testBeforeReorg_maxHeight() = timeout(30_000L) {
// validate that we are not synced beyond the target height
validator.validateMaxHeightSynced(targetHeight)
// validator.validateMaxHeightSynced(targetHeight)
}
companion object {

View File

@ -3,45 +3,49 @@ package cash.z.ecc.android.sdk.darkside.reorgs
import androidx.test.ext.junit.runners.AndroidJUnit4
import cash.z.ecc.android.sdk.darkside.test.DarksideTestCoordinator
import cash.z.ecc.android.sdk.darkside.test.ScopedTest
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReorgSmallTest : ScopedTest() {
/*
private val targetHeight = BlockHeight.new(
ZcashNetwork.Mainnet,
663250
)
private val hashBeforeReorg = "09ec0d5de30d290bc5a2318fbf6a2427a81c7db4790ce0e341a96aeac77108b9"
private val hashAfterReorg = "tbd"
*/
@Before
fun setup() {
sithLord.await()
// sithLord.await()
}
@Test
@Ignore("Temporarily disabled")
fun testBeforeReorg_latestBlockHash() = timeout(30_000L) {
validator.validateBlockHash(targetHeight, hashBeforeReorg)
// validator.validateBlockHash(targetHeight, hashBeforeReorg)
}
@Test
@Ignore("Temporarily disabled")
fun testAfterReorg_callbackTriggered() = timeout(30_000L) {
hadReorg = false
// sithLord.triggerSmallReorg()
sithLord.await()
// sithLord.await()
assertTrue(hadReorg)
}
@Test
@Ignore("Temporarily disabled")
fun testAfterReorg_latestBlockHash() = timeout(30_000L) {
validator.validateBlockHash(targetHeight, hashAfterReorg)
// validator.validateBlockHash(targetHeight, hashAfterReorg)
}
companion object {

View File

@ -2,7 +2,6 @@ package cash.z.ecc.android.sdk.darkside.test
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.Darkside
@ -13,15 +12,14 @@ import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
// TODO [#1224]: Refactor and re-enable disabled darkside tests
// TODO [#1224]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1224
class DarksideTestCoordinator(val wallet: TestWallet) {
constructor(
alias: String = "DarksideTestCoordinator",
@ -95,6 +93,7 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
* Waits for, at most, the given amount of time for the synchronizer to download and scan blocks
* and reach a 'SYNCED' status.
*/
/*
fun await(timeout: Long = 60_000L, targetHeight: BlockHeight? = null) = runBlocking {
ScopedTest.timeoutWith(this, timeout) {
synchronizer.status.map { status ->
@ -110,6 +109,7 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
}.filter { it == Synchronizer.Status.SYNCED }.first()
}
}
*/
// /**
// * Send a transaction and wait until it has been fully created and successfully submitted, which
@ -135,13 +135,6 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
inner class DarksideTestValidator {
fun validateHasBlock(height: BlockHeight) {
runBlocking {
assertTrue(synchronizer.findBlockHashAsHex(height) != null)
assertTrue(synchronizer.findBlockHash(height)?.size ?: 0 > 0)
}
}
fun validateLatestHeight(height: BlockHeight) = runBlocking<Unit> {
val info = synchronizer.processorInfo.first()
val networkBlockHeight = info.networkBlockHeight
@ -152,6 +145,7 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
)
}
/*
fun validateMinHeightSynced(minHeight: BlockHeight) = runBlocking<Unit> {
val info = synchronizer.processorInfo.first()
val lastSyncedHeight = info.lastSyncedHeight
@ -177,6 +171,7 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
val hash = runBlocking { synchronizer.findBlockHashAsHex(height) }
assertEquals(expectedHash, hash)
}
*/
fun onReorg(callback: (errorHeight: BlockHeight, rewindHeight: BlockHeight) -> Unit) {
synchronizer.onChainErrorHandler = callback

View File

@ -5,6 +5,7 @@ import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
@ -65,7 +66,9 @@ class TestWallet(
alias,
endpoint,
seed,
startHeight
startHeight,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
) as SdkSynchronizer
val available get() = synchronizer.saplingBalances.value?.available
@ -105,7 +108,7 @@ class TestWallet(
}
suspend fun rewindToHeight(height: BlockHeight): TestWallet {
synchronizer.rewindToNearestHeight(height, false)
synchronizer.rewindToNearestHeight(height)
return this
}

View File

@ -137,6 +137,7 @@ dependencies {
implementation(libs.bundles.grpc)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.immutable)
}
fladle {

View File

@ -2,6 +2,7 @@ package cash.z.wallet.sdk.sample.demoapp
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import cash.z.ecc.android.sdk.ext.toHex
@ -202,7 +203,9 @@ class SampleCodeTest {
network,
lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network),
seed = seed,
birthday = null
birthday = null,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
)
}

View File

@ -40,7 +40,7 @@ class ComposeActivity : ComponentActivity() {
}
SecretState.None -> {
Seed(
ZcashNetwork.fromResources(applicationContext),
zcashNetwork = ZcashNetwork.fromResources(applicationContext),
onExistingWallet = { walletViewModel.persistExistingWallet(it) },
onNewWallet = { walletViewModel.persistNewWallet() }
)

View File

@ -63,7 +63,8 @@ internal fun ComposeActivity.Navigation() {
}
},
goTransactions = { navController.navigateJustOnce(TRANSACTIONS) },
resetSdk = { walletViewModel.resetSdk() }
resetSdk = { walletViewModel.resetSdk() },
rewind = { walletViewModel.rewind() }
)
}
}

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.internal.Twig
@ -82,6 +83,8 @@ class SharedViewModel(application: Application) : AndroidViewModel(application)
} else {
birthdayHeight.value
},
// We use restore mode as this is always initialization with an older seed
walletInitMode = WalletInitMode.RestoreWallet,
alias = OLD_UI_SYNCHRONIZER_ALIAS
)

View File

@ -20,7 +20,10 @@ private val lazy = LazyWithArgument<Context, WalletCoordinator> {
emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider))
}
WalletCoordinator(it, persistableWalletFlow)
WalletCoordinator(
context = it,
persistableWallet = persistableWalletFlow
)
}
fun WalletCoordinator.Companion.getInstance(context: Context) = lazy.getInstance(context)

View File

@ -9,7 +9,6 @@ import androidx.lifecycle.repeatOnLifecycle
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBalanceBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
@ -89,12 +88,6 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
.flatMapLatest { it.progress }
.collect { onProgress(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
.flatMapLatest { it.processorInfo }
.collect { onProcessorInfoUpdated(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
@ -179,10 +172,6 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
binding.textStatus.text = "Syncing blocks...${percent.toPercentage()}%"
}
}
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
if (info.isSyncing) binding.textStatus.text = "Syncing blocks...${info.syncProgress}%"
}
}
@Suppress("MagicNumber")

View File

@ -8,7 +8,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListTransactionsBinding
import cash.z.ecc.android.sdk.internal.Twig
@ -55,12 +54,6 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
.flatMapLatest { it.progress }
.collect { onProgress(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
.flatMapLatest { it.processorInfo }
.collect { onProcessorInfoUpdated(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
@ -75,10 +68,6 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
// Change listeners
//
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
if (info.isSyncing) binding.textInfo.text = "Syncing blocks...${info.syncProgress}%"
}
@Suppress("MagicNumber")
private fun onProgress(percent: PercentDecimal) {
if (percent.isLessThanHundredPercent()) binding.textInfo.text = "Syncing blocks...${percent.toPercentage()}%"

View File

@ -10,7 +10,6 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListUtxosBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
@ -173,12 +172,6 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
.flatMapLatest { it.progress }
.collect { onProgress(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
.flatMapLatest { it.processorInfo }
.collect { onProcessorInfoUpdated(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
@ -198,10 +191,6 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
}
}
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
if (info.isSyncing) binding.textStatus.text = "Syncing blocks...${info.syncProgress}%"
}
@Suppress("MagicNumber")
private fun onProgress(percent: PercentDecimal) {
if (percent.isLessThanHundredPercent()) binding.textStatus.text = "Syncing blocks...${percent.toPercentage()}%"

View File

@ -9,7 +9,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.DemoConstants
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentSendBinding
@ -93,12 +92,6 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
.flatMapLatest { it.progress }
.collect { onProgress(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
.flatMapLatest { it.processorInfo }
.collect { onProcessorInfoUpdated(it) }
}
launch {
sharedViewModel.synchronizerFlow
.filterNotNull()
@ -133,10 +126,6 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
}
}
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
if (info.isSyncing) binding.textStatus.text = "Syncing blocks...${info.syncProgress}%"
}
@Suppress("MagicNumber")
private fun onBalance(balance: WalletBalance?) {
this.balance = balance

View File

@ -1,7 +1,7 @@
package cash.z.ecc.android.sdk.demoapp.fixture
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.SynchronizerError
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletSnapshot
import cash.z.ecc.android.sdk.model.PercentDecimal
@ -22,7 +22,6 @@ object WalletSnapshotFixture {
fun new(
status: Synchronizer.Status = STATUS,
processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(
null,
null,
null,
null

View File

@ -43,7 +43,8 @@ private fun ComposablePreviewHome() {
goAddressDetails = {},
goTransactions = {},
goTestnetFaucet = {},
resetSdk = {}
resetSdk = {},
rewind = {},
)
}
}
@ -59,9 +60,15 @@ fun Home(
goTransactions: () -> Unit,
goTestnetFaucet: () -> Unit,
resetSdk: () -> Unit,
rewind: () -> Unit,
) {
Scaffold(topBar = {
HomeTopAppBar(isTestnet, goTestnetFaucet, resetSdk)
HomeTopAppBar(
isTestnet,
goTestnetFaucet,
resetSdk,
rewind
)
}) { paddingValues ->
HomeMainContent(
paddingValues = paddingValues,
@ -79,7 +86,8 @@ fun Home(
private fun HomeTopAppBar(
isTestnet: Boolean,
goTestnetFaucet: () -> Unit,
resetSdk: () -> Unit
resetSdk: () -> Unit,
rewind: () -> Unit
) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) },
@ -87,7 +95,8 @@ private fun HomeTopAppBar(
DebugMenu(
isTestnet,
goTestnetFaucet = goTestnetFaucet,
resetSdk = resetSdk
resetSdk = resetSdk,
rewind = rewind,
)
}
)
@ -97,7 +106,8 @@ private fun HomeTopAppBar(
private fun DebugMenu(
isTestnet: Boolean,
goTestnetFaucet: () -> Unit,
resetSdk: () -> Unit
resetSdk: () -> Unit,
rewind: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
@ -117,6 +127,14 @@ private fun DebugMenu(
}
)
}
DropdownMenuItem(
text = { Text("Quick Rewind") },
onClick = {
rewind()
expanded = false
}
)
DropdownMenuItem(
text = { Text("Reset SDK") },
onClick = {

View File

@ -1,7 +1,7 @@
package cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.WalletBalance

View File

@ -7,7 +7,8 @@ import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.getInstance
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceKeys
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceSingleton
@ -48,7 +49,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
@ -101,7 +101,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
null
)
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
@OptIn(ExperimentalCoroutinesApi::class)
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
.flatMapLatest {
if (null == it) {
@ -143,8 +143,12 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
val application = getApplication<Application>()
viewModelScope.launch {
val newWallet = PersistableWallet.new(application, ZcashNetwork.fromResources(application))
persistExistingWallet(newWallet)
val newWallet = PersistableWallet.new(
application,
ZcashNetwork.fromResources(application),
WalletInitMode.NewWallet
)
persistWallet(newWallet)
}
}
@ -153,6 +157,13 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
* to see the side effects. This would be used for a user restoring a wallet from a backup.
*/
fun persistExistingWallet(persistableWallet: PersistableWallet) {
persistWallet(persistableWallet)
}
/**
* Persists a wallet asynchronously. Clients observe [secretState] to see the side effects.
*/
private fun persistWallet(persistableWallet: PersistableWallet) {
val application = getApplication<Application>()
viewModelScope.launch {
@ -234,6 +245,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
fun resetSdk() {
walletCoordinator.resetSdk()
}
/**
* This rewinds to the nearest height, i.e. 14 days back from the current chain tip.
*/
fun rewind() {
val synchronizer = synchronizer.value
if (null != synchronizer) {
viewModelScope.launch {
synchronizer.quickRewind()
}
}
}
}
/**

View File

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.PersistableWallet
@ -76,7 +77,8 @@ private fun ConfigureSeedMainContent(
val newWallet = PersistableWallet(
zcashNetwork,
WalletFixture.Alice.getBirthday(zcashNetwork),
SeedPhrase.new(WalletFixture.Alice.seedPhrase)
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
WalletInitMode.RestoreWallet
)
onExistingWallet(newWallet)
}
@ -88,7 +90,8 @@ private fun ConfigureSeedMainContent(
val newWallet = PersistableWallet(
zcashNetwork,
WalletFixture.Ben.getBirthday(zcashNetwork),
SeedPhrase.new(WalletFixture.Ben.seedPhrase)
SeedPhrase.new(WalletFixture.Ben.seedPhrase),
WalletInitMode.RestoreWallet
)
onExistingWallet(newWallet)
}

View File

@ -31,6 +31,8 @@ import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.WalletAddresses
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
@ -41,7 +43,7 @@ private fun ComposablePreview() {
MaterialTheme {
// TODO [#1090]: Demo: Add Addresses and Transactions Compose Previews
// TODO [#1090]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1090
// Transactions()
// TransactionsView()
}
}
@ -72,6 +74,7 @@ fun Transactions(
paddingValues = paddingValues,
synchronizer,
synchronizer.transactions.collectAsStateWithLifecycle(initialValue = emptyList()).value
.toPersistentList()
)
}
}
@ -110,7 +113,7 @@ private fun TransactionsTopAppBar(
private fun TransactionsMainContent(
paddingValues: PaddingValues,
synchronizer: Synchronizer,
transactions: List<TransactionOverview>
transactions: ImmutableList<TransactionOverview>
) {
val queryScope = rememberCoroutineScope()
Column(
@ -127,7 +130,7 @@ private fun TransactionsMainContent(
val memos = synchronizer.getMemos(it)
queryScope.launch {
memos.toList().run {
Twig.debug {
Twig.info {
"Transaction memos: count: $size, contains: ${joinToString().ifEmpty { "-" }}"
}
}

View File

@ -26,7 +26,7 @@ ZCASH_ASCII_GPG_KEY=
# Configures whether release is an unstable snapshot, therefore published to the snapshot repository.
IS_SNAPSHOT=true
LIBRARY_VERSION=1.21.0-beta01
LIBRARY_VERSION=2.0.0-rc.1
# Kotlin compiler warnings can be considered errors, failing the build.
ZCASH_IS_TREAT_WARNINGS_AS_ERRORS=true
@ -136,6 +136,7 @@ JAVAX_ANNOTATION_VERSION=1.3.2
JUNIT_VERSION=5.9.3
KOTLINX_COROUTINES_VERSION=1.7.3
KOTLINX_DATETIME_VERSION=0.4.0
KOTLINX_IMMUTABLE_COLLECTIONS_VERSION=0.3.5
KOTLIN_VERSION=1.9.10
MOCKITO_KOTLIN_VERSION=2.2.0
MOCKITO_VERSION=5.4.0

View File

@ -0,0 +1,28 @@
package co.electriccoin.lightwallet.client.model
import cash.z.wallet.sdk.internal.rpc.Service.TreeState
class TreeStateUnsafe(
val encoded: ByteArray
) {
companion object {
fun new(treeState: TreeState): TreeStateUnsafe {
return TreeStateUnsafe(treeState.toByteArray())
}
fun fromParts(
height: Long,
hash: String,
time: Int,
tree: String
): TreeStateUnsafe {
val treeState = TreeState.newBuilder()
.setHeight(height)
.setHash(hash)
.setTime(time)
.setSaplingTree(tree)
.build()
return new(treeState)
}
}
}

View File

@ -1,5 +1,6 @@
package cash.z.ecc.android.sdk.fixture
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.SeedPhrase
@ -15,9 +16,12 @@ object PersistableWalletFixture {
val SEED_PHRASE = SeedPhraseFixture.new()
val WALLET_INIT_MODE = WalletInitMode.ExistingWallet
fun new(
network: ZcashNetwork = NETWORK,
birthday: BlockHeight = BIRTHDAY,
seedPhrase: SeedPhrase = SEED_PHRASE
) = PersistableWallet(network, birthday, seedPhrase)
seedPhrase: SeedPhrase = SEED_PHRASE,
walletInitMode: WalletInitMode = WALLET_INIT_MODE
) = PersistableWallet(network, birthday, seedPhrase, walletInitMode)
}

View File

@ -80,6 +80,7 @@ class WalletCoordinator(
lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(persistableWallet.network),
birthday = persistableWallet.birthday,
seed = persistableWallet.seedPhrase.toByteArray(),
walletInitMode = persistableWallet.walletInitMode,
)
trySend(InternalSynchronizerStatus.Available(closeableSynchronizer))
@ -127,7 +128,7 @@ class WalletCoordinator(
synchronizerMutex.withLock {
synchronizer.value?.let {
it.latestBirthdayHeight?.let { height ->
it.rewindToNearestHeight(height, true)
it.rewindToNearestHeight(height)
return true
}
}

View File

@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.model
import android.app.Application
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toEntropy
import cash.z.ecc.android.sdk.WalletInitMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
@ -13,8 +14,12 @@ import org.json.JSONObject
data class PersistableWallet(
val network: ZcashNetwork,
val birthday: BlockHeight?,
val seedPhrase: SeedPhrase
val seedPhrase: SeedPhrase,
val walletInitMode: WalletInitMode
) {
init {
_walletInitMode = walletInitMode
}
/**
* @return Wallet serialized to JSON format, suitable for long-term encrypted storage.
@ -41,6 +46,10 @@ data class PersistableWallet(
internal const val KEY_BIRTHDAY = "birthday"
internal const val KEY_SEED_PHRASE = "seed_phrase"
// Note: This is not the ideal way to hold such a value. But we also want to avoid persisting the wallet
// initialization mode with the persistable wallet.
private var _walletInitMode: WalletInitMode = WalletInitMode.ExistingWallet
fun from(jsonObject: JSONObject): PersistableWallet {
when (val version = jsonObject.getInt(KEY_VERSION)) {
VERSION_1 -> {
@ -56,7 +65,12 @@ data class PersistableWallet(
}
val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE)
return PersistableWallet(network, birthday, SeedPhrase.new(seedPhrase))
return PersistableWallet(
network = network,
birthday = birthday,
seedPhrase = SeedPhrase.new(seedPhrase),
walletInitMode = _walletInitMode
)
}
else -> {
throw IllegalArgumentException("Unsupported version $version")
@ -67,12 +81,21 @@ data class PersistableWallet(
/**
* @return A new PersistableWallet with a random seed phrase.
*/
suspend fun new(application: Application, zcashNetwork: ZcashNetwork): PersistableWallet {
suspend fun new(
application: Application,
zcashNetwork: ZcashNetwork,
walletInitMode: WalletInitMode
): PersistableWallet {
val birthday = BlockHeight.ofLatestCheckpoint(application, zcashNetwork)
val seedPhrase = newSeedPhrase()
return PersistableWallet(zcashNetwork, birthday, seedPhrase)
return PersistableWallet(
zcashNetwork,
birthday,
seedPhrase,
walletInitMode
)
}
}
}

View File

@ -0,0 +1,8 @@
package cash.z.ecc.android.sdk.block.processor
// TODO [#1094]: Consider fake SDK sync related components
// TODO [#1094]: Testing the CompactBlockProcessor is only available once we can mock the necessary core components like
// [CompactBlockDownloader], [DerivedDataRepository], or [TypesafeBackend]
// TODO [#1094]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1094
@Suppress("EmptyClassBlock")
class CompactBlockProcessorTest

View File

@ -4,6 +4,7 @@ import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.Synchronizer.Status.SYNCED
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.internal.Twig
@ -141,7 +142,9 @@ class TestnetIntegrationTest : ScopedTest() {
lightWalletEndpoint =
lightWalletEndpoint,
seed = seed,
birthday = BlockHeight.new(ZcashNetwork.Testnet, birthdayHeight)
birthday = BlockHeight.new(ZcashNetwork.Testnet, birthdayHeight),
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
)
}
}

View File

@ -4,6 +4,7 @@ import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
@ -29,7 +30,9 @@ class SdkSynchronizerTest {
alias,
LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet),
Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(),
birthday = null
birthday = null,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
).use {
assertFailsWith<IllegalStateException> {
Synchronizer.new(
@ -38,7 +41,9 @@ class SdkSynchronizerTest {
alias,
LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet),
Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(),
birthday = null
birthday = null,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
)
}
}
@ -51,6 +56,8 @@ class SdkSynchronizerTest {
// Random alias so that repeated invocations of this test will have a clean starting state
val alias = UUID.randomUUID().toString()
// TODO [#1094]: Consider fake SDK sync related components
// TODO [#1094]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1094
// In the future, inject fake networking component so that it doesn't require hitting the network
Synchronizer.new(
InstrumentationRegistry.getInstrumentation().context,
@ -58,7 +65,9 @@ class SdkSynchronizerTest {
alias,
LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet),
Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(),
birthday = null
birthday = null,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
).use {}
// Second instance should succeed because first one was closed
@ -68,7 +77,9 @@ class SdkSynchronizerTest {
alias,
LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet),
Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(),
birthday = null
birthday = null,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
).use {}
}
}

View File

@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.util
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.CloseableSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
import cash.z.ecc.android.sdk.internal.model.Checkpoint
@ -59,9 +60,6 @@ class BalancePrinterUtil {
// val lastDownloaded = downloader.getLastDownloadedHeight()
// val blockRange = (Math.max(birthday, lastDownloaded))..latestBlockHeight
// downloadNewBlocks(blockRange)
// val error = validateNewBlocks(blockRange)
// twig("validation completed with result $error")
// assertEquals(-1, error)
}
private suspend fun deleteDb(dbName: String) {
@ -99,7 +97,9 @@ class BalancePrinterUtil {
lightWalletEndpoint = LightWalletEndpoint
.defaultForNetwork(network),
seed = seed,
birthday = birthdayHeight
birthday = birthdayHeight,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
)
// deleteDb(dataDbPath)
@ -150,13 +150,4 @@ class BalancePrinterUtil {
// }
// }
// }
// private fun validateNewBlocks(range: IntRange?): Int {
// // val dummyWallet = initWallet("dummySeed")
// Twig.sprout("validating")
// twig("validating blocks in range $range")
// // val result = rustBackend.validateCombinedChain()
// Twig.clip("validating")
// return result
// }
}

View File

@ -4,6 +4,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.CloseableSynchronizer
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
@ -72,7 +73,9 @@ class DataDbScannerUtil {
birthday = BlockHeight.new(
ZcashNetwork.Mainnet,
birthdayHeight
)
),
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
)
println("sync!")

View File

@ -5,6 +5,7 @@ import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.deriveUnifiedSpendingKey
import cash.z.ecc.android.sdk.internal.jni.RustDerivationTool
@ -66,7 +67,9 @@ class TestWallet(
alias,
lightWalletEndpoint = endpoint,
seed = seed,
startHeight
startHeight,
// Using existing wallet init mode as simplification for the test
walletInitMode = WalletInitMode.ExistingWallet
) as SdkSynchronizer
val available get() = synchronizer.saplingBalances.value?.available
@ -106,7 +109,7 @@ class TestWallet(
}
suspend fun rewindToHeight(height: BlockHeight): TestWallet {
synchronizer.rewindToNearestHeight(height, false)
synchronizer.rewindToNearestHeight(height)
return this
}

View File

@ -2,6 +2,9 @@ package cash.z.ecc.fixture
import cash.z.ecc.android.sdk.internal.Backend
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.internal.model.JniScanProgress
import cash.z.ecc.android.sdk.internal.model.JniScanRange
import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot
import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey
internal class FakeRustBackend(
@ -17,11 +20,35 @@ internal class FakeRustBackend(
metadata.removeAll { it.height > height }
}
override suspend fun getLatestHeight(): Long = metadata.maxOf { it.height }
override suspend fun validateCombinedChainOrErrorHeight(limit: Long?): Long? {
override suspend fun putSaplingSubtreeRoots(
startIndex: Long,
roots: List<JniSubtreeRoot>,
) {
TODO("Not yet implemented")
}
override suspend fun updateChainTip(height: Long) {
TODO("Not yet implemented")
}
override suspend fun getFullyScannedHeight(): Long? {
TODO("Not yet implemented")
}
override suspend fun getMaxScannedHeight(): Long? {
TODO("Not yet implemented")
}
override suspend fun getScanProgress(): JniScanProgress {
TODO("Not yet implemented")
}
override suspend fun suggestScanRanges(): List<JniScanRange> {
TODO("Not yet implemented")
}
override suspend fun getLatestCacheHeight(): Long = metadata.maxOf { it.height }
override suspend fun getVerifiedTransparentBalance(address: String): Long {
TODO("Not yet implemented")
}
@ -58,34 +85,25 @@ internal class FakeRustBackend(
to: String,
value: Long,
memo: ByteArray?
): Long {
): ByteArray {
TODO("Not yet implemented")
}
override suspend fun shieldToAddress(account: Int, unifiedSpendingKey: ByteArray, memo: ByteArray?): Long {
override suspend fun shieldToAddress(account: Int, unifiedSpendingKey: ByteArray, memo: ByteArray?): ByteArray {
TODO("Not yet implemented")
}
override suspend fun decryptAndStoreTransaction(tx: ByteArray) =
error("Intentionally not implemented in mocked FakeRustBackend implementation.")
override suspend fun initAccountsTable(vararg keys: String) {
TODO("Not yet implemented")
}
override suspend fun initBlocksTable(
checkpointHeight: Long,
checkpointHash: String,
checkpointTime: Long,
checkpointSaplingTree: String
) {
TODO("Not yet implemented")
}
override suspend fun initDataDb(seed: ByteArray?): Int =
error("Intentionally not implemented in mocked FakeRustBackend implementation.")
override suspend fun createAccount(seed: ByteArray): JniUnifiedSpendingKey =
override suspend fun createAccount(
seed: ByteArray,
treeState: ByteArray,
recoverUntil: Long?
): JniUnifiedSpendingKey =
error("Intentionally not implemented in mocked FakeRustBackend implementation.")
override fun isValidShieldedAddr(addr: String): Boolean =
@ -119,10 +137,7 @@ internal class FakeRustBackend(
TODO("Not yet implemented")
}
override suspend fun getReceivedMemoAsUtf8(idNote: Long): String? =
error("Intentionally not implemented in mocked FakeRustBackend implementation.")
override suspend fun getSentMemoAsUtf8(idNote: Long): String? =
override suspend fun getMemoAsUtf8(txId: ByteArray, outputIndex: Int): String? =
error("Intentionally not implemented in mocked FakeRustBackend implementation.")
override suspend fun getVerifiedBalance(account: Int): Long {
@ -133,6 +148,6 @@ internal class FakeRustBackend(
TODO("Not yet implemented")
}
override suspend fun scanBlocks(limit: Long?) =
override suspend fun scanBlocks(fromHeight: Long, limit: Long) =
error("Intentionally not implemented in mocked FakeRustBackend implementation.")
}

View File

@ -5,12 +5,12 @@ import cash.z.ecc.android.sdk.Synchronizer.Status.DISCONNECTED
import cash.z.ecc.android.sdk.Synchronizer.Status.STOPPED
import cash.z.ecc.android.sdk.Synchronizer.Status.SYNCED
import cash.z.ecc.android.sdk.Synchronizer.Status.SYNCING
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Disconnected
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Initialized
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Stopped
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Synced
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Syncing
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Disconnected
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Initialized
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Stopped
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Synced
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Syncing
import cash.z.ecc.android.sdk.exception.TransactionEncoderException
import cash.z.ecc.android.sdk.exception.TransactionSubmitException
import cash.z.ecc.android.sdk.ext.ConsensusBranchId
@ -24,10 +24,11 @@ import cash.z.ecc.android.sdk.internal.db.DatabaseCoordinator
import cash.z.ecc.android.sdk.internal.db.derived.DbDerivedDataRepository
import cash.z.ecc.android.sdk.internal.db.derived.DerivedDataDb
import cash.z.ecc.android.sdk.internal.ext.isNullOrEmpty
import cash.z.ecc.android.sdk.internal.ext.toHexReversed
import cash.z.ecc.android.sdk.internal.ext.tryNull
import cash.z.ecc.android.sdk.internal.jni.RustBackend
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.internal.model.TreeState
import cash.z.ecc.android.sdk.internal.model.ext.toBlockHeight
import cash.z.ecc.android.sdk.internal.repository.CompactBlockRepository
import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository
import cash.z.ecc.android.sdk.internal.storage.block.FileCompactBlockRepository
@ -40,7 +41,6 @@ import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
@ -52,6 +52,7 @@ import cash.z.ecc.android.sdk.type.AddressType.Unified
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import co.electriccoin.lightwallet.client.LightWalletClient
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.new
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@ -109,6 +110,10 @@ class SdkSynchronizer private constructor(
private val mutex = Mutex()
/**
* Convenience method to create new SdkSynchronizer instance.
*
* @return Synchronizer instance as CloseableSynchronizer
*
* @throws IllegalStateException If multiple instances of synchronizer with the same network+alias are
* active at the same time. Call `close` to finish one synchronizer before starting another one with the same
* network+alias.
@ -172,21 +177,17 @@ class SdkSynchronizer private constructor(
}
}
// pools
private val _orchardBalances = MutableStateFlow<WalletBalance?>(null)
private val _saplingBalances = MutableStateFlow<WalletBalance?>(null)
private val _transparentBalances = MutableStateFlow<WalletBalance?>(null)
private val _status = MutableStateFlow<Synchronizer.Status>(DISCONNECTED)
var coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override val orchardBalances = _orchardBalances.asStateFlow()
override val saplingBalances = _saplingBalances.asStateFlow()
override val transparentBalances = _transparentBalances.asStateFlow()
override val orchardBalances = processor.orchardBalances.asStateFlow()
override val saplingBalances = processor.saplingBalances.asStateFlow()
override val transparentBalances = processor.transparentBalances.asStateFlow()
override val transactions
get() = combine(processor.networkHeight, storage.allTransactions) { networkHeight, allTransactions ->
val latestBlockHeight = networkHeight ?: storage.lastScannedHeight()
val latestBlockHeight = networkHeight ?: backend.getMaxScannedHeight()
allTransactions.map { TransactionOverview.new(it, latestBlockHeight) }
}
@ -305,8 +306,8 @@ class SdkSynchronizer private constructor(
override suspend fun getNearestRewindHeight(height: BlockHeight): BlockHeight =
processor.getNearestRewindHeight(height)
override suspend fun rewindToNearestHeight(height: BlockHeight, alsoClearBlockCache: Boolean) {
processor.rewindToNearestHeight(height, alsoClearBlockCache)
override suspend fun rewindToNearestHeight(height: BlockHeight) {
processor.rewindToNearestHeight(height)
}
override suspend fun quickRewind() {
@ -314,18 +315,10 @@ class SdkSynchronizer private constructor(
}
override fun getMemos(transactionOverview: TransactionOverview): Flow<String> {
return storage.getNoteIds(transactionOverview.id).map {
return storage.getSaplingOutputIndices(transactionOverview.id).map {
runCatching {
when (transactionOverview.isSentTransaction) {
true -> {
backend.getSentMemoAsUtf8(it)
}
false -> {
backend.getReceivedMemoAsUtf8(it)
}
}
backend.getMemoAsUtf8(transactionOverview.rawId.byteArray, it)
}.onFailure {
// https://github.com/zcash/librustzcash/issues/834
Twig.error { "Failed to get memo with: $it" }
}.onSuccess {
Twig.debug { "Transaction memo queried: $it" }
@ -350,14 +343,6 @@ class SdkSynchronizer private constructor(
// to do with the underlying data
// TODO [#682]: https://github.com/zcash/zcash-android-wallet-sdk/issues/682
suspend fun findBlockHash(height: BlockHeight): ByteArray? {
return storage.findBlockHash(height)
}
suspend fun findBlockHashAsHex(height: BlockHeight): String? {
return findBlockHash(height)?.toHexReversed()
}
suspend fun getTransactionCount(): Int {
return storage.getTransactionCount().toInt()
}
@ -366,44 +351,49 @@ class SdkSynchronizer private constructor(
storage.invalidate()
}
//
// Private API
//
/**
* Calculate the latest balance, based on the blocks that have been scanned and transmit this
* information into the flow of [balances].
* Calculate the latest balance based on the blocks that have been scanned and transmit this information into the
* [transparentBalances] and [saplingBalances] flow. The [orchardBalances] flow is still not filled with proper data
* because of the current limited Orchard support.
*/
suspend fun refreshAllBalances() {
refreshSaplingBalance()
refreshTransparentBalance()
processor.checkAllBalances()
// TODO [#682]: refresh orchard balance
// TODO [#682]: https://github.com/zcash/zcash-android-wallet-sdk/issues/682
Twig.warn { "Warning: Orchard balance does not yet refresh. Only some of the plumbing is in place." }
}
/**
* Calculate the latest Sapling balance based on the blocks that have been scanned and transmit this information
* into the [saplingBalances] flow.
*/
suspend fun refreshSaplingBalance() {
Twig.debug { "refreshing sapling balance" }
_saplingBalances.value = processor.getBalanceInfo(Account.DEFAULT)
processor.checkSaplingBalance()
}
/**
* Calculate the latest Transparent balance based on the blocks that have been scanned and transmit this information
* into the [saplingBalances] flow.
*/
suspend fun refreshTransparentBalance() {
Twig.debug { "refreshing transparent balance" }
_transparentBalances.value = processor.getUtxoCacheBalance(getTransparentAddress(Account.DEFAULT))
processor.checkTransparentBalance()
}
suspend fun isValidAddress(address: String): Boolean {
return !validateAddress(address).isNotValid
}
//
// Private API
//
private fun CoroutineScope.onReady() {
Twig.debug { "Starting synchronizer…" }
// Triggering UTXOs fetch and transparent balance update at the beginning of the block sync right after the app
// start, as it makes the transparent transactions appearance faster
// Triggering UTXOs and transactions fetching at the beginning of the block synchronization right after the
// app starts makes the transparent transactions appear faster.
launch(CoroutineExceptionHandler(::onCriticalError)) {
refreshUtxos(Account.DEFAULT)
refreshTransparentBalance()
refreshTransactions()
}
@ -520,8 +510,21 @@ class SdkSynchronizer private constructor(
//
// Not ready to be a public API; internal for testing only
internal suspend fun createAccount(seed: ByteArray): UnifiedSpendingKey =
backend.createAccountAndGetSpendingKey(seed)
internal suspend fun createAccount(
seed: ByteArray,
treeState: TreeState,
recoverUntil: BlockHeight?
): UnifiedSpendingKey? {
return runCatching {
backend.createAccountAndGetSpendingKey(
seed = seed,
treeState = treeState,
recoverUntil = recoverUntil
)
}.onFailure {
Twig.error(it) { "Create account failed." }
}.getOrNull()
}
/**
* Returns the current Unified Address for this account.
@ -623,9 +626,30 @@ class SdkSynchronizer private constructor(
override suspend fun validateConsensusBranch(): ConsensusMatchType {
val serverBranchId = tryNull { processor.downloader.getServerInfo()?.consensusBranchId }
val sdkBranchId = tryNull {
(txManager as OutboundTransactionManagerImpl).encoder.getConsensusBranchId()
val currentChainTip = when (
val response =
processor.downloader.getLatestBlockHeight()
) {
is Response.Success -> {
Twig.info { "Chain tip for validate consensus branch action fetched: ${response.result.value}" }
runCatching { response.result.toBlockHeight(network) }.getOrNull()
}
is Response.Failure -> {
Twig.error {
"Chain tip fetch failed for validate consensus branch action with:" +
" ${response.toThrowable()}"
}
null
}
}
val sdkBranchId = currentChainTip?.let {
tryNull {
(txManager as OutboundTransactionManagerImpl).encoder.getConsensusBranchId(currentChainTip)
}
}
return ConsensusMatchType(
sdkBranchId?.let { ConsensusBranchId.fromId(it) },
serverBranchId?.let { ConsensusBranchId.fromHex(it) }
@ -665,7 +689,8 @@ internal object DefaultSynchronizerFactory {
zcashNetwork: ZcashNetwork,
checkpoint: Checkpoint,
seed: ByteArray?,
viewingKeys: List<UnifiedFullViewingKey>
numberOfAccounts: Int,
recoverUntil: BlockHeight?
): DerivedDataRepository =
DbDerivedDataRepository(
DerivedDataDb.new(
@ -675,7 +700,8 @@ internal object DefaultSynchronizerFactory {
zcashNetwork,
checkpoint,
seed,
viewingKeys
numberOfAccounts,
recoverUntil
)
)
@ -718,10 +744,10 @@ internal object DefaultSynchronizerFactory {
repository: DerivedDataRepository,
birthdayHeight: BlockHeight
): CompactBlockProcessor = CompactBlockProcessor(
downloader,
repository,
backend,
birthdayHeight
downloader = downloader,
repository = repository,
backend = backend,
minimumHeight = birthdayHeight
)
}

View File

@ -1,11 +1,13 @@
package cash.z.ecc.android.sdk
import android.content.Context
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.internal.Derivation
import cash.z.ecc.android.sdk.internal.SaplingParamTool
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.db.DatabaseCoordinator
import cash.z.ecc.android.sdk.internal.model.ext.toBlockHeight
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PercentDecimal
@ -16,10 +18,10 @@ import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.CheckpointTool
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.Response
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
@ -163,7 +165,7 @@ interface Synchronizer {
* Sends zatoshi.
*
* @param usk the unified spending key associated with the notes that will be spent.
* @param zatoshi the amount of zatoshi to send.
* @param amount the amount of zatoshi to send.
* @param toAddress the recipient's address.
* @param memo the optional memo to include as part of the transaction.
*
@ -268,16 +270,27 @@ interface Synchronizer {
*/
suspend fun getTransparentBalance(tAddr: String): WalletBalance
suspend fun getNearestRewindHeight(height: BlockHeight): BlockHeight
/**
* Returns the safest height to which we can rewind, given a desire to rewind to the height
* provided. Due to how witness incrementing works, a wallet cannot simply rewind to any
* arbitrary height. This handles all that complexity yet remains flexible in the future as
* improvements are made.
*/
suspend fun rewindToNearestHeight(height: BlockHeight, alsoClearBlockCache: Boolean = false)
suspend fun getNearestRewindHeight(height: BlockHeight): BlockHeight
/**
* Rewinds to the safest height to which we can rewind, given a desire to rewind to the height
* provided. Due to how witness incrementing works, a wallet cannot simply rewind to any
* arbitrary height. This handles all that complexity yet remains flexible in the future as
* improvements are made.
*/
suspend fun rewindToNearestHeight(height: BlockHeight)
/**
* Rewinds to the safest height approximately 14 days backward from the current chain tip. Due to how witness
* incrementing works, a wallet cannot simply rewind to any arbitrary height. This handles all that complexity
* yet remains flexible in the future as improvements are made.
*/
suspend fun quickRewind()
/**
@ -398,20 +411,32 @@ interface Synchronizer {
* Primary method that SDK clients will use to construct a synchronizer.
*
* @param zcashNetwork the network to use.
*
* @param alias A string used to segregate multiple wallets in the filesystem. This implies the string
* should not contain characters unsuitable for the platform's filesystem. The default value is
* generally used unless an SDK client needs to support multiple wallets.
*
* @param lightWalletEndpoint Server endpoint. See [cash.z.ecc.android.sdk.model.defaultForNetwork]. If a
* client wishes to change the server endpoint, the active synchronizer will need to be stopped and a new
* instance created with a new value.
*
* @param seed the wallet's seed phrase. This is required the first time a new wallet is set up. For
* subsequent calls, seed is only needed if [InitializerException.SeedRequired] is thrown.
*
* @param birthday Block height representing the "birthday" of the wallet. When creating a new wallet, see
* [BlockHeight.ofLatestCheckpoint]. When restoring an existing wallet, use block height that was first used
* to create the wallet. If that value is unknown, null is acceptable but will result in longer
* sync times. After sync completes, the birthday can be determined from [Synchronizer.latestBirthdayHeight].
*
* @param walletInitMode a required parameter with one of [WalletInitMode] values. Use
* [WalletInitMode.NewWallet] when starting synchronizer for a newly created wallet. Or use
* [WalletInitMode.RestoreWallet] when restoring an existing wallet that was created at some point in the
* past. Or use the last [WalletInitMode.ExistingWallet] type for a wallet which is already initialized
* and needs follow-up block synchronization.
*
* @throws InitializerException.SeedRequired Indicates clients need to call this method again, providing the
* seed bytes.
*
* @throws IllegalStateException If multiple instances of synchronizer with the same network+alias are
* active at the same time. Call `close` to finish one synchronizer before starting another one with the same
* network+alias.
@ -427,7 +452,8 @@ interface Synchronizer {
alias: String = ZcashSdk.DEFAULT_ALIAS,
lightWalletEndpoint: LightWalletEndpoint,
seed: ByteArray?,
birthday: BlockHeight?
birthday: BlockHeight?,
walletInitMode: WalletInitMode
): CloseableSynchronizer {
val applicationContext = context.applicationContext
@ -456,46 +482,57 @@ interface Synchronizer {
DefaultSynchronizerFactory
.defaultCompactBlockRepository(coordinator.fsBlockDbRoot(zcashNetwork, alias), backend)
val viewingKeys = seed?.let {
DerivationTool.getInstance().deriveUnifiedFullViewingKeys(
seed,
zcashNetwork,
Derivation.DEFAULT_NUMBER_OF_ACCOUNTS
).toList()
} ?: emptyList()
val service = DefaultSynchronizerFactory.defaultService(applicationContext, lightWalletEndpoint)
val downloader = DefaultSynchronizerFactory.defaultDownloader(service, blockStore)
val chainTip = when (walletInitMode) {
is WalletInitMode.RestoreWallet -> {
when (val response = downloader.getLatestBlockHeight()) {
is Response.Success -> {
Twig.info { "Chain tip for recovery until param fetched: ${response.result.value}" }
runCatching { response.result.toBlockHeight(zcashNetwork) }.getOrNull()
}
is Response.Failure -> {
Twig.error { "Chain tip fetch for recovery until failed with: ${response.toThrowable()}" }
null
}
}
}
else -> {
null
}
}
val repository = DefaultSynchronizerFactory.defaultDerivedDataRepository(
applicationContext,
backend,
coordinator.dataDbFile(zcashNetwork, alias),
zcashNetwork,
loadedCheckpoint,
seed,
viewingKeys
context = applicationContext,
rustBackend = backend,
databaseFile = coordinator.dataDbFile(zcashNetwork, alias),
zcashNetwork = zcashNetwork,
checkpoint = loadedCheckpoint,
seed = seed,
numberOfAccounts = Derivation.DEFAULT_NUMBER_OF_ACCOUNTS,
recoverUntil = chainTip,
)
val service = DefaultSynchronizerFactory.defaultService(applicationContext, lightWalletEndpoint)
val encoder = DefaultSynchronizerFactory.defaultEncoder(backend, saplingParamTool, repository)
val downloader = DefaultSynchronizerFactory.defaultDownloader(service, blockStore)
val txManager = DefaultSynchronizerFactory.defaultTxManager(
encoder,
service
)
val processor = DefaultSynchronizerFactory.defaultProcessor(
backend,
downloader,
repository,
birthday
?: zcashNetwork.saplingActivationHeight
backend = backend,
downloader = downloader,
repository = repository,
birthdayHeight = birthday ?: zcashNetwork.saplingActivationHeight
)
return SdkSynchronizer.new(
zcashNetwork,
alias,
repository,
txManager,
processor,
backend
zcashNetwork = zcashNetwork,
alias = alias,
repository = repository,
txManager = txManager,
processor = processor,
backend = backend
)
}
@ -513,9 +550,10 @@ interface Synchronizer {
alias: String = ZcashSdk.DEFAULT_ALIAS,
lightWalletEndpoint: LightWalletEndpoint,
seed: ByteArray?,
birthday: BlockHeight?
birthday: BlockHeight?,
walletInitMode: WalletInitMode
): CloseableSynchronizer = runBlocking {
new(context, zcashNetwork, alias, lightWalletEndpoint, seed, birthday)
new(context, zcashNetwork, alias, lightWalletEndpoint, seed, birthday, walletInitMode)
}
/**
@ -540,6 +578,23 @@ interface Synchronizer {
}
}
/**
* Sealed class describing wallet initialization mode.
*
* Use [NewWallet] type if the seed was just created as part of a
* new wallet initialization.
*
* Use [RestoreWallet] type if an existed wallet is initialized
* from a restored seed with older birthday height.
*
* Use [ExistingWallet] type if the wallet is already initialized.
*/
sealed class WalletInitMode {
data object NewWallet : WalletInitMode()
data object RestoreWallet : WalletInitMode()
data object ExistingWallet : WalletInitMode()
}
interface CloseableSynchronizer : Synchronizer, Closeable
/**

View File

@ -0,0 +1,10 @@
package cash.z.ecc.android.sdk.block.processor.model
/**
* Progress model class for sharing the whole batch synchronization progress out of the synchronization process.
*/
internal data class BatchSyncProgress(
val order: Long = 0,
val resultState: SyncingResult =
SyncingResult.AllSuccess
)

View File

@ -0,0 +1,12 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.model.BlockHeight
/**
* Internal class for sharing get max scanned height action result.
*/
internal sealed class GetMaxScannedHeightResult {
data class Success(val height: BlockHeight) : GetMaxScannedHeightResult()
data object None : GetMaxScannedHeightResult()
data class Failure(val exception: Throwable) : GetMaxScannedHeightResult()
}

View File

@ -0,0 +1,16 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.internal.model.ScanProgress
import cash.z.ecc.android.sdk.model.PercentDecimal
/**
* Internal class for sharing get scan progress action result.
*/
internal sealed class GetScanProgressResult {
data class Success(val scanProgress: ScanProgress) : GetScanProgressResult() {
fun toPercentDecimal() = PercentDecimal(scanProgress.getSafeRation())
}
data object None : GetScanProgressResult()
data class Failure(val exception: Throwable) : GetScanProgressResult()
}

View File

@ -0,0 +1,13 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.internal.model.SubtreeRoot
/**
* Internal class for get subtree roots action result.
*/
internal sealed class GetSubtreeRootsResult {
data class SpendBeforeSync(val subTreeRootList: List<SubtreeRoot>) : GetSubtreeRootsResult()
data object Linear : GetSubtreeRootsResult()
data object FailureConnection : GetSubtreeRootsResult()
data class OtherFailure(val exception: Throwable) : GetSubtreeRootsResult()
}

View File

@ -0,0 +1,11 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.model.BlockHeight
/**
* Internal class for sharing put sapling subtree roots action result.
*/
internal sealed class PutSaplingSubtreeRootsResult {
object Success : PutSaplingSubtreeRootsResult()
data class Failure(val failedAtHeight: BlockHeight, val exception: Throwable) : PutSaplingSubtreeRootsResult()
}

View File

@ -0,0 +1,26 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.BlockHeight
/**
* Internal class for sharing pre-synchronization steps result.
*/
internal sealed class SbSPreparationResult {
object ConnectionFailure : SbSPreparationResult()
data class ProcessFailure(
val failedAtHeight: BlockHeight,
val exception: Throwable
) : SbSPreparationResult() {
fun toBlockProcessingResult(): CompactBlockProcessor.BlockProcessingResult =
CompactBlockProcessor.BlockProcessingResult.SyncFailure(
this.failedAtHeight,
this.exception
)
}
data class Success(
val suggestedRangesResult: SuggestScanRangesResult,
val verifyRangeResult: VerifySuggestedScanRange
) : SbSPreparationResult()
object NoMoreBlocksToProcess : SbSPreparationResult()
}

View File

@ -0,0 +1,12 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.internal.model.ScanRange
import cash.z.ecc.android.sdk.model.BlockHeight
/**
* Internal class for sharing suggested scan ranges action result.
*/
internal sealed class SuggestScanRangesResult {
data class Success(val ranges: List<ScanRange>) : SuggestScanRangesResult()
data class Failure(val failedAtHeight: BlockHeight, val exception: Throwable) : SuggestScanRangesResult()
}

View File

@ -0,0 +1,11 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.internal.model.BlockBatch
/**
* Common progress model class for sharing a batch synchronization stage result internally in the synchronization loop.
*/
internal data class SyncStageResult(
val batch: BlockBatch,
val stageResult: SyncingResult
)

View File

@ -0,0 +1,52 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.model.BlockHeight
/**
* Internal class for the overall synchronization process result reporting.
*/
internal sealed class SyncingResult {
override fun toString(): String = this::class.java.simpleName
object AllSuccess : SyncingResult()
object RestartSynchronization : SyncingResult()
data class DownloadSuccess(val downloadedBlocks: List<JniBlockMeta>?) : SyncingResult() {
override fun toString() = "${this::class.java.simpleName} with ${downloadedBlocks?.size ?: "none"} blocks"
}
interface Failure {
val failedAtHeight: BlockHeight?
val exception: CompactBlockProcessorException
fun toBlockProcessingResult(): CompactBlockProcessor.BlockProcessingResult =
CompactBlockProcessor.BlockProcessingResult.SyncFailure(
this.failedAtHeight,
this.exception
)
}
data class DownloadFailed(
override val failedAtHeight: BlockHeight,
override val exception: CompactBlockProcessorException
) : Failure, SyncingResult()
object ScanSuccess : SyncingResult()
data class ScanFailed(
override val failedAtHeight: BlockHeight,
override val exception: CompactBlockProcessorException
) : Failure, SyncingResult()
object DeleteSuccess : SyncingResult()
data class DeleteFailed(
override val failedAtHeight: BlockHeight?,
override val exception: CompactBlockProcessorException
) : Failure, SyncingResult()
object EnhanceSuccess : SyncingResult()
data class EnhanceFailed(
override val failedAtHeight: BlockHeight,
override val exception: CompactBlockProcessorException
) : Failure, SyncingResult()
object UpdateBirthday : SyncingResult()
data class ContinuityError(
override val failedAtHeight: BlockHeight,
override val exception: CompactBlockProcessorException
) : Failure, SyncingResult()
}

View File

@ -0,0 +1,11 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.model.BlockHeight
/**
* Internal class for sharing update chain tip action result.
*/
internal sealed class UpdateChainTipResult {
data class Success(val height: BlockHeight) : UpdateChainTipResult()
data class Failure(val failedAtHeight: BlockHeight, val exception: Throwable) : UpdateChainTipResult()
}

View File

@ -0,0 +1,11 @@
package cash.z.ecc.android.sdk.block.processor.model
import cash.z.ecc.android.sdk.internal.model.ScanRange
/**
* Internal class for sharing verify suggested scan range action result.
*/
internal sealed class VerifySuggestedScanRange {
data class ShouldVerify(val scanRange: ScanRange) : VerifySuggestedScanRange()
object NoRangeToVerify : VerifySuggestedScanRange()
}

View File

@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.exception
import cash.z.ecc.android.sdk.internal.SaplingParameters
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
@ -79,22 +80,32 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
null
)
class FailedReorgRepair(message: String) : CompactBlockProcessorException(message)
class FailedDownload(cause: Throwable? = null) : CompactBlockProcessorException(
"Error while downloading blocks. This most " +
"likely means the server is down or slow to respond. See logs for details.",
cause
)
class Disconnected(cause: Throwable? = null) :
CompactBlockProcessorException("Disconnected Error. Unable to download blocks due to ${cause?.message}", cause)
object Uninitialized : CompactBlockProcessorException(
class Uninitialized(cause: Throwable? = null) : CompactBlockProcessorException(
"Cannot process blocks because the wallet has not been" +
" initialized. Verify that the seed phrase was properly created or imported. If so, then this problem" +
" can be fixed by re-importing the wallet."
" can be fixed by re-importing the wallet.",
cause
)
object NoAccount : CompactBlockProcessorException(
"Attempting to scan without an account. This is probably a setup error or a race condition."
)
class FailedDownloadException(cause: Throwable? = null) : CompactBlockProcessorException(
"Error while downloading blocks. This most likely means the server is down or slow to respond. " +
"See logs for details.",
cause
)
class FailedScanException(cause: Throwable? = null) : CompactBlockProcessorException(
"Error while scanning blocks. This most likely means a problem with locally persisted data. " +
"See logs for details.",
cause
)
class FailedDeleteException(cause: Throwable? = null) : CompactBlockProcessorException(
"Error while deleting block files. This most likely means the data are not persisted correctly." +
" See logs for details.",
cause
)
open class EnhanceTransactionError(
message: String,
val height: BlockHeight,
@ -240,10 +251,20 @@ sealed class LightWalletException(message: String, cause: Throwable? = null) : S
cause
)
class GetSubtreeRootsException(code: Int, description: String?, cause: Throwable) : SdkException(
"Failed to get subtree roots with code: $code due to: ${description ?: "-"}",
cause
)
class FetchUtxosException(code: Int, description: String?, cause: Throwable) : SdkException(
"Failed to fetch UTXOs with code: $code due to: ${description ?: "-"}",
cause
)
class GetLatestBlockHeightException(code: Int, description: String?, cause: Throwable) : SdkException(
"Failed to fetch latest block height with code: $code due to: ${description ?: "-"}",
cause
)
}
/**
@ -264,7 +285,7 @@ sealed class TransactionEncoderException(
object MissingParamsException : TransactionEncoderException(
"Cannot send funds due to missing spend or output params and attempting to download them failed."
)
class TransactionNotFoundException(transactionId: Long) : TransactionEncoderException(
class TransactionNotFoundException(transactionId: FirstClassByteArray) : TransactionEncoderException(
"Unable to find transactionId $transactionId in the repository. This means the wallet created a transaction " +
"and then returned a row ID that does not actually exist. This is a scenario where the wallet should " +
"have thrown an exception but failed to do so."
@ -274,7 +295,7 @@ sealed class TransactionEncoderException(
" with id $transactionId, does not have any raw data. This is a scenario where the wallet should have " +
"thrown an exception but failed to do so."
)
class IncompleteScanException(lastScannedHeight: BlockHeight) : TransactionEncoderException(
class IncompleteScanException(lastScannedHeight: BlockHeight?) : TransactionEncoderException(
"Cannot" +
" create spending transaction because scanning is incomplete. We must scan up to the" +
" latest height to know which consensus rules to apply. However, the last scanned" +

View File

@ -1,32 +1,28 @@
package cash.z.ecc.android.sdk.internal
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.internal.model.ScanProgress
import cash.z.ecc.android.sdk.internal.model.ScanRange
import cash.z.ecc.android.sdk.internal.model.SubtreeRoot
import cash.z.ecc.android.sdk.internal.model.TreeState
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import java.lang.RuntimeException
import kotlin.jvm.Throws
@Suppress("TooManyFunctions")
internal interface TypesafeBackend {
val network: ZcashNetwork
suspend fun initAccountsTable(vararg keys: UnifiedFullViewingKey)
suspend fun initAccountsTable(
suspend fun createAccountAndGetSpendingKey(
seed: ByteArray,
numberOfAccounts: Int
): List<UnifiedFullViewingKey>
suspend fun initBlocksTable(checkpoint: Checkpoint)
suspend fun createAccountAndGetSpendingKey(seed: ByteArray): UnifiedSpendingKey
treeState: TreeState,
recoverUntil: BlockHeight?
): UnifiedSpendingKey
@Suppress("LongParameterList")
suspend fun createToAddress(
@ -34,12 +30,12 @@ internal interface TypesafeBackend {
to: String,
value: Long,
memo: ByteArray? = byteArrayOf()
): Long
): FirstClassByteArray
suspend fun shieldToAddress(
usk: UnifiedSpendingKey,
memo: ByteArray? = byteArrayOf()
): Long
): FirstClassByteArray
suspend fun getCurrentAddress(account: Account): String
@ -55,18 +51,12 @@ internal interface TypesafeBackend {
suspend fun rewindToHeight(height: BlockHeight)
suspend fun getLatestBlockHeight(): BlockHeight?
suspend fun getLatestCacheHeight(): BlockHeight?
suspend fun findBlockMetadata(height: BlockHeight): JniBlockMeta?
suspend fun rewindBlockMetadataToHeight(height: BlockHeight)
/**
* @param limit The limit provides an efficient way how to restrict the portion of blocks, which will be validated.
* @return Null if successful. If an error occurs, the height will be the height where the error was detected.
*/
suspend fun validateCombinedChainOrErrorBlockHeight(limit: Long?): BlockHeight?
suspend fun getDownloadedUtxoBalance(address: String): WalletBalance
@Suppress("LongParameterList")
@ -79,9 +69,7 @@ internal interface TypesafeBackend {
height: BlockHeight
)
suspend fun getSentMemoAsUtf8(idNote: Long): String?
suspend fun getReceivedMemoAsUtf8(idNote: Long): String?
suspend fun getMemoAsUtf8(txId: ByteArray, outputIndex: Int): String?
suspend fun initDataDb(seed: ByteArray?): Int
@ -89,7 +77,57 @@ internal interface TypesafeBackend {
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun scanBlocks(limit: Long?)
suspend fun putSaplingSubtreeRoots(
startIndex: Long,
roots: List<SubtreeRoot>,
)
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun updateChainTip(height: BlockHeight)
/**
* Returns the height to which the wallet has been fully scanned.
*
* This is the height for which the wallet has fully trial-decrypted this and all
* preceding blocks above the wallet's birthday height.
*
* @return The height to which the wallet has been fully scanned, or Null if no blocks have been scanned.
* @throws RuntimeException as a common indicator of the operation failure
*/
suspend fun getFullyScannedHeight(): BlockHeight?
/**
* Returns the maximum height that the wallet has scanned.
*
* If the wallet is fully synced, this will be equivalent to `getFullyScannedHeight`;
* otherwise the maximal scanned height is likely to be greater than the fully scanned
* height due to the fact that out-of-order scanning can leave gaps.
*
* @return The maximum height that the wallet has scanned, or Null if no blocks have been scanned.
* @throws RuntimeException as a common indicator of the operation failure
*/
suspend fun getMaxScannedHeight(): BlockHeight?
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun scanBlocks(fromHeight: BlockHeight, limit: Long)
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun getScanProgress(): ScanProgress?
/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun suggestScanRanges(): List<ScanRange>
suspend fun decryptAndStoreTransaction(tx: ByteArray)

View File

@ -1,15 +1,18 @@
package cash.z.ecc.android.sdk.internal
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot
import cash.z.ecc.android.sdk.internal.model.ScanProgress
import cash.z.ecc.android.sdk.internal.model.ScanRange
import cash.z.ecc.android.sdk.internal.model.SubtreeRoot
import cash.z.ecc.android.sdk.internal.model.TreeState
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import kotlinx.coroutines.withContext
@Suppress("TooManyFunctions")
@ -18,53 +21,45 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
override val network: ZcashNetwork
get() = ZcashNetwork.from(backend.networkId)
override suspend fun initAccountsTable(vararg keys: UnifiedFullViewingKey) {
val ufvks = Array(keys.size) { keys[it].encoding }
@Suppress("SpreadOperator")
backend.initAccountsTable(*ufvks)
}
override suspend fun initAccountsTable(
override suspend fun createAccountAndGetSpendingKey(
seed: ByteArray,
numberOfAccounts: Int
): List<UnifiedFullViewingKey> {
return DerivationTool.getInstance().deriveUnifiedFullViewingKeys(seed, network, numberOfAccounts)
}
override suspend fun initBlocksTable(checkpoint: Checkpoint) {
backend.initBlocksTable(
checkpoint.height.value,
checkpoint.hash,
checkpoint.epochSeconds,
checkpoint.tree
treeState: TreeState,
recoverUntil: BlockHeight?
): UnifiedSpendingKey {
return UnifiedSpendingKey(
backend.createAccount(
seed = seed,
treeState = treeState.encoded,
recoverUntil = recoverUntil?.value
)
)
}
override suspend fun createAccountAndGetSpendingKey(seed: ByteArray): UnifiedSpendingKey {
return UnifiedSpendingKey(backend.createAccount(seed))
}
@Suppress("LongParameterList")
override suspend fun createToAddress(
usk: UnifiedSpendingKey,
to: String,
value: Long,
memo: ByteArray?
): Long = backend.createToAddress(
usk.account.value,
usk.copyBytes(),
to,
value,
memo
): FirstClassByteArray = FirstClassByteArray(
backend.createToAddress(
usk.account.value,
usk.copyBytes(),
to,
value,
memo
)
)
override suspend fun shieldToAddress(
usk: UnifiedSpendingKey,
memo: ByteArray?
): Long = backend.shieldToAddress(
usk.account.value,
usk.copyBytes(),
memo
): FirstClassByteArray = FirstClassByteArray(
backend.shieldToAddress(
usk.account.value,
usk.copyBytes(),
memo
)
)
override suspend fun getCurrentAddress(account: Account): String {
@ -98,8 +93,8 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
backend.rewindToHeight(height.value)
}
override suspend fun getLatestBlockHeight(): BlockHeight? {
return backend.getLatestHeight()?.let {
override suspend fun getLatestCacheHeight(): BlockHeight? {
return backend.getLatestCacheHeight()?.let {
BlockHeight.new(
ZcashNetwork.from(backend.networkId),
it
@ -115,19 +110,6 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
backend.rewindBlockMetadataToHeight(height.value)
}
/**
* @param limit The limit provides an efficient way how to restrict the portion of blocks, which will be validated.
* @return Null if successful. If an error occurs, the height will be the height where the error was detected.
*/
override suspend fun validateCombinedChainOrErrorBlockHeight(limit: Long?): BlockHeight? {
return backend.validateCombinedChainOrErrorHeight(limit)?.let {
BlockHeight.new(
ZcashNetwork.from(backend.networkId),
it
)
}
}
override suspend fun getDownloadedUtxoBalance(address: String): WalletBalance {
// Note this implementation is not ideal because it requires two database queries without a transaction, which
// makes the data potentially inconsistent. However the verified amount is queried first which makes this less
@ -162,13 +144,54 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
)
}
override suspend fun getSentMemoAsUtf8(idNote: Long) = backend.getSentMemoAsUtf8(idNote)
override suspend fun getReceivedMemoAsUtf8(idNote: Long): String? = backend.getReceivedMemoAsUtf8(idNote)
override suspend fun getMemoAsUtf8(txId: ByteArray, outputIndex: Int): String? =
backend.getMemoAsUtf8(txId, outputIndex)
override suspend fun initDataDb(seed: ByteArray?): Int = backend.initDataDb(seed)
override suspend fun scanBlocks(limit: Long?) = backend.scanBlocks(limit)
override suspend fun putSaplingSubtreeRoots(startIndex: Long, roots: List<SubtreeRoot>) =
backend.putSaplingSubtreeRoots(
startIndex = startIndex,
roots = roots.map {
JniSubtreeRoot.new(
rootHash = it.rootHash,
completingBlockHeight = it.completingBlockHeight.value
)
}
)
override suspend fun updateChainTip(height: BlockHeight) = backend.updateChainTip(height.value)
override suspend fun getFullyScannedHeight(): BlockHeight? {
return backend.getFullyScannedHeight()?.let {
BlockHeight.new(
ZcashNetwork.from(backend.networkId),
it
)
}
}
override suspend fun getMaxScannedHeight(): BlockHeight? {
return backend.getMaxScannedHeight()?.let {
BlockHeight.new(
ZcashNetwork.from(backend.networkId),
it
)
}
}
override suspend fun scanBlocks(fromHeight: BlockHeight, limit: Long) = backend.scanBlocks(fromHeight.value, limit)
override suspend fun getScanProgress(): ScanProgress? = backend.getScanProgress()?.let { jniScanProgress ->
ScanProgress.new(jniScanProgress)
}
override suspend fun suggestScanRanges(): List<ScanRange> = backend.suggestScanRanges().map { jniScanRange ->
ScanRange.new(
jniScanRange,
network
)
}
override suspend fun decryptAndStoreTransaction(tx: ByteArray) = backend.decryptAndStoreTransaction(tx)

View File

@ -2,7 +2,7 @@ package cash.z.ecc.android.sdk.internal.block
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.ext.retryUpTo
import cash.z.ecc.android.sdk.internal.ext.retryUpToAndThrow
import cash.z.ecc.android.sdk.internal.model.JniBlockMeta
import cash.z.ecc.android.sdk.internal.model.ext.from
import cash.z.ecc.android.sdk.internal.repository.CompactBlockRepository
@ -12,7 +12,7 @@ import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.CompactBlockUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.Response
import kotlinx.coroutines.Dispatchers
import co.electriccoin.lightwallet.client.model.ShieldedProtocolEnum
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
@ -31,8 +31,7 @@ import kotlinx.coroutines.withContext
*/
open class CompactBlockDownloader private constructor(val compactBlockRepository: CompactBlockRepository) {
lateinit var lightWalletClient: LightWalletClient
private set
private lateinit var lightWalletClient: LightWalletClient
constructor(
lightWalletClient: LightWalletClient,
@ -112,7 +111,7 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
compactBlockRepository.getLatestHeight()
suspend fun getServerInfo(): LightWalletEndpointInfoUnsafe? = withContext(IO) {
retryUpTo(GET_SERVER_INFO_RETRIES) {
retryUpToAndThrow(GET_SERVER_INFO_RETRIES) {
when (val response = lightWalletClient.getServerInfo()) {
is Response.Success -> return@withContext response.result
else -> {
@ -129,11 +128,21 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
* Stop this downloader and cleanup any resources being used.
*/
suspend fun stop() {
withContext(Dispatchers.IO) {
withContext(IO) {
lightWalletClient.shutdown()
}
}
/**
* Reconnect to the same or a different server. This is useful when the connection is
* unrecoverable. That might be time to switch to a mirror or just reconnect.
*/
suspend fun reconnect() {
withContext(IO) {
lightWalletClient.reconnect()
}
}
/**
* Fetch the details of a known transaction.
*
@ -141,6 +150,34 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
*/
suspend fun fetchTransaction(txId: ByteArray) = lightWalletClient.fetchTransaction(txId)
/**
* Fetch all UTXOs for the given addresses and from the given height.
*
* @return Flow of UTXOs for the given [tAddresses] from the [startHeight]
*/
suspend fun fetchUtxos(
tAddresses: List<String>,
startHeight: BlockHeightUnsafe
) = lightWalletClient.fetchUtxos(
tAddresses = tAddresses,
startHeight = startHeight
)
/**
* Returns a stream of information about roots of subtrees of the Sapling and Orchard note commitment trees.
*
* @return a flow of information about roots of subtrees of the Sapling and Orchard note commitment trees.
*/
suspend fun getSubtreeRoots(
startIndex: Int,
shieldedProtocol: ShieldedProtocolEnum,
maxEntries: Int
) = lightWalletClient.getSubtreeRoots(
startIndex = startIndex,
shieldedProtocol = shieldedProtocol,
maxEntries = maxEntries
)
companion object {
private const val GET_SERVER_INFO_RETRIES = 6
}

View File

@ -1,88 +0,0 @@
package cash.z.ecc.android.sdk.internal.db.derived
import androidx.sqlite.db.SupportSQLiteDatabase
import cash.z.ecc.android.sdk.internal.db.queryAndMap
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import java.util.Locale
internal class BlockTable(private val zcashNetwork: ZcashNetwork, private val sqliteDatabase: SupportSQLiteDatabase) {
companion object {
private val SELECTION_MIN_HEIGHT = arrayOf(
String.format(
Locale.ROOT,
"MIN(%s)", // $NON-NLS
BlockTableDefinition.COLUMN_LONG_HEIGHT
)
)
private val SELECTION_MAX_HEIGHT = arrayOf(
String.format(
Locale.ROOT,
"MAX(%s)", // $NON-NLS
BlockTableDefinition.COLUMN_LONG_HEIGHT
)
)
private val SELECTION_BLOCK_HEIGHT = String.format(
Locale.ROOT,
"%s = ?", // $NON-NLS
BlockTableDefinition.COLUMN_LONG_HEIGHT
)
private val PROJECTION_COUNT = arrayOf("COUNT(*)") // $NON-NLS
private val PROJECTION_HASH = arrayOf(BlockTableDefinition.COLUMN_BLOB_HASH)
}
suspend fun count() = sqliteDatabase.queryAndMap(
BlockTableDefinition.TABLE_NAME,
columns = PROJECTION_COUNT,
cursorParser = { it.getLong(0) }
).first()
suspend fun firstScannedHeight(): BlockHeight {
// Note that we assume the Rust layer will add the birthday height as the first block
val heightLong =
sqliteDatabase.queryAndMap(
table = BlockTableDefinition.TABLE_NAME,
columns = SELECTION_MIN_HEIGHT,
cursorParser = { it.getLong(0) }
).first()
return BlockHeight.new(zcashNetwork, heightLong)
}
suspend fun lastScannedHeight(): BlockHeight {
// Note that we assume the Rust layer will add the birthday height as the first block
val heightLong =
sqliteDatabase.queryAndMap(
table = BlockTableDefinition.TABLE_NAME,
columns = SELECTION_MAX_HEIGHT,
cursorParser = { it.getLong(0) }
).first()
return BlockHeight.new(zcashNetwork, heightLong)
}
suspend fun findBlockHash(blockHeight: BlockHeight): ByteArray? {
return sqliteDatabase.queryAndMap(
table = BlockTableDefinition.TABLE_NAME,
columns = PROJECTION_HASH,
selection = SELECTION_BLOCK_HEIGHT,
selectionArgs = arrayOf(blockHeight.value),
cursorParser = { it.getBlob(0) }
).firstOrNull()
}
}
internal object BlockTableDefinition {
const val TABLE_NAME = "blocks" // $NON-NLS
const val COLUMN_LONG_HEIGHT = "height" // $NON-NLS
const val COLUMN_BLOB_HASH = "hash" // $NON-NLS
}

View File

@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.internal.model.DbTransactionOverview
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionRecipient
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -17,24 +18,12 @@ internal class DbDerivedDataRepository(
) : DerivedDataRepository {
private val invalidatingFlow = MutableStateFlow(UUID.randomUUID())
override suspend fun lastScannedHeight(): BlockHeight {
return derivedDataDb.blockTable.lastScannedHeight()
}
override suspend fun firstUnenhancedHeight(): BlockHeight? {
return derivedDataDb.allTransactionView.firstUnenhancedHeight()
}
override suspend fun firstScannedHeight(): BlockHeight {
return derivedDataDb.blockTable.firstScannedHeight()
}
override suspend fun isInitialized(): Boolean {
return derivedDataDb.blockTable.count() > 0
}
override suspend fun findEncodedTransactionById(txId: Long): EncodedTransaction? {
return derivedDataDb.transactionTable.findEncodedTransactionById(txId)
override suspend fun findEncodedTransactionByTxId(txId: FirstClassByteArray): EncodedTransaction? {
return derivedDataDb.transactionTable.findEncodedTransactionByTxId(txId)
}
override suspend fun findNewTransactions(blockHeightRange: ClosedRange<BlockHeight>): List<DbTransactionOverview> =
@ -48,8 +37,6 @@ internal class DbDerivedDataRepository(
override suspend fun findMatchingTransactionId(rawTransactionId: ByteArray) = derivedDataDb.transactionTable
.findDatabaseId(rawTransactionId)
override suspend fun findBlockHash(height: BlockHeight) = derivedDataDb.blockTable.findBlockHash(height)
override suspend fun getTransactionCount() = derivedDataDb.transactionTable.count()
override fun invalidate() {
@ -63,7 +50,8 @@ internal class DbDerivedDataRepository(
override val allTransactions: Flow<List<DbTransactionOverview>>
get() = invalidatingFlow.map { derivedDataDb.allTransactionView.getAllTransactions().toList() }
override fun getNoteIds(transactionId: Long) = derivedDataDb.txOutputsView.getNoteIds(transactionId)
override fun getSaplingOutputIndices(transactionId: Long) =
derivedDataDb.txOutputsView.getSaplingOutputIndices(transactionId)
override fun getRecipients(transactionId: Long): Flow<TransactionRecipient> {
return derivedDataDb.txOutputsView.getRecipients(transactionId)

View File

@ -2,13 +2,13 @@ package cash.z.ecc.android.sdk.internal.db.derived
import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
import cash.z.ecc.android.sdk.internal.NoBackupContextWrapper
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.TypesafeBackend
import cash.z.ecc.android.sdk.internal.db.ReadOnlySupportSqliteOpenHelper
import cash.z.ecc.android.sdk.internal.ext.tryWarn
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -20,8 +20,6 @@ internal class DerivedDataDb private constructor(
) {
val accountTable = AccountTable(sqliteDatabase)
val blockTable = BlockTable(zcashNetwork, sqliteDatabase)
val transactionTable = TransactionTable(zcashNetwork, sqliteDatabase)
val allTransactionView = AllTransactionView(zcashNetwork, sqliteDatabase)
@ -39,7 +37,7 @@ internal class DerivedDataDb private constructor(
// SqliteOpenHelper is happy
private const val DATABASE_VERSION = 8
@Suppress("LongParameterList", "SpreadOperator")
@Suppress("LongParameterList")
suspend fun new(
context: Context,
backend: TypesafeBackend,
@ -47,27 +45,16 @@ internal class DerivedDataDb private constructor(
zcashNetwork: ZcashNetwork,
checkpoint: Checkpoint,
seed: ByteArray?,
viewingKeys: List<UnifiedFullViewingKey>
numberOfAccounts: Int,
recoverUntil: BlockHeight?
): DerivedDataDb {
backend.initDataDb(seed)
runCatching {
// TODO [#681]: consider converting these to typed exceptions in the welding layer
// TODO [#681]: https://github.com/zcash/zcash-android-wallet-sdk/issues/681
tryWarn(
message = "Did not initialize the blocks table. It probably was already initialized.",
ifContains = "table is not empty"
) {
backend.initBlocksTable(checkpoint)
}
tryWarn(
message = "Did not initialize the accounts table. It probably was already initialized.",
ifContains = "table is not empty"
) {
backend.initAccountsTable(*viewingKeys.toTypedArray())
val result = backend.initDataDb(seed)
if (result < 0) {
throw CompactBlockProcessorException.Uninitialized()
}
}.onFailure {
Twig.error { "Failed to init derived data database with $it" }
throw CompactBlockProcessorException.Uninitialized(it)
}
val database = ReadOnlySupportSqliteOpenHelper.openExistingDatabaseAsReadOnly(
@ -79,7 +66,29 @@ internal class DerivedDataDb private constructor(
DATABASE_VERSION
)
return DerivedDataDb(zcashNetwork, database)
val dataDb = DerivedDataDb(zcashNetwork, database)
// If a seed is provided, fill in the accounts.
seed?.let { checkedSeed ->
// toInt() should be safe because we expect very few accounts
val missingAccounts = numberOfAccounts - dataDb.accountTable.count().toInt()
require(missingAccounts >= 0) {
"Unexpected number of accounts: $missingAccounts"
}
repeat(missingAccounts) {
runCatching {
backend.createAccountAndGetSpendingKey(
seed = checkedSeed,
treeState = checkpoint.treeState(),
recoverUntil = recoverUntil
)
}.onFailure {
Twig.error(it) { "Create account failed." }
}
}
}
return dataDb
}
}
}

View File

@ -45,7 +45,7 @@ internal class TransactionTable(
private val SELECTION_TRANSACTION_ID_AND_RAW_NOT_NULL = String.format(
Locale.ROOT,
"%s = ? AND %s IS NOT NULL", // $NON-NLS
TransactionTableDefinition.COLUMN_INTEGER_ID,
TransactionTableDefinition.COLUMN_BLOB_TRANSACTION_ID,
TransactionTableDefinition.COLUMN_BLOB_RAW
)
}
@ -66,18 +66,16 @@ internal class TransactionTable(
cursorParser = { it.getLong(0) }
).first()
suspend fun findEncodedTransactionById(id: Long): EncodedTransaction? {
suspend fun findEncodedTransactionByTxId(txId: FirstClassByteArray): EncodedTransaction? {
return sqliteDatabase.queryAndMap(
table = TransactionTableDefinition.TABLE_NAME,
columns = PROJECTION_ENCODED_TRANSACTION,
selection = SELECTION_TRANSACTION_ID_AND_RAW_NOT_NULL,
selectionArgs = arrayOf(id)
selectionArgs = arrayOf(txId.byteArray)
) {
val txIdIndex = it.getColumnIndexOrThrow(TransactionTableDefinition.COLUMN_BLOB_TRANSACTION_ID)
val rawIndex = it.getColumnIndexOrThrow(TransactionTableDefinition.COLUMN_BLOB_RAW)
val heightIndex = it.getColumnIndexOrThrow(TransactionTableDefinition.COLUMN_INTEGER_EXPIRY_HEIGHT)
val txid = it.getBlob(txIdIndex)
val raw = it.getBlob(rawIndex)
val expiryHeight = if (it.isNull(heightIndex)) {
null
@ -86,7 +84,7 @@ internal class TransactionTable(
}
EncodedTransaction(
FirstClassByteArray(txid),
txId,
FirstClassByteArray(raw),
expiryHeight
)

View File

@ -20,7 +20,7 @@ internal class TxOutputsView(
TxOutputsViewDefinition.COLUMN_INTEGER_TRANSACTION_ID
)
private val PROJECTION_ID = arrayOf(TxOutputsViewDefinition.COLUMN_INTEGER_TRANSACTION_ID)
private val PROJECTION_OUTPUT_INDEX = arrayOf(TxOutputsViewDefinition.COLUMN_INTEGER_OUTPUT_INDEX)
private val PROJECTION_RECIPIENT = arrayOf(
TxOutputsViewDefinition.COLUMN_STRING_TO_ADDRESS,
@ -35,17 +35,17 @@ internal class TxOutputsView(
)
}
fun getNoteIds(transactionId: Long) =
fun getSaplingOutputIndices(transactionId: Long) =
sqliteDatabase.queryAndMap(
table = TxOutputsViewDefinition.VIEW_NAME,
columns = PROJECTION_ID,
columns = PROJECTION_OUTPUT_INDEX,
selection = SELECT_BY_TRANSACTION_ID_AND_NOT_CHANGE,
selectionArgs = arrayOf(transactionId),
orderBy = ORDER_BY,
cursorParser = {
val idColumnIndex = it.getColumnIndex(TxOutputsViewDefinition.COLUMN_INTEGER_TRANSACTION_ID)
val idColumnOutputIndex = it.getColumnIndex(TxOutputsViewDefinition.COLUMN_INTEGER_OUTPUT_INDEX)
it.getLong(idColumnIndex)
it.getInt(idColumnOutputIndex)
}
)

View File

@ -44,3 +44,30 @@ internal inline fun <R> tryWarn(
}
}
}
// Note: Do NOT change these texts as they match the ones from ScanError in
// librustzcash/zcash_client_backend/src/scanning.rs
internal const val PREV_HASH_MISMATCH = "The parent hash of proposed block does not correspond to the block hash at " +
"height" // $NON-NLS
internal const val BLOCK_HEIGHT_DISCONTINUITY = "Block height discontinuity at height" // $NON-NLS
internal const val TREE_SIZE_MISMATCH = "note commitment tree size provided by a compact block did not match the " +
"expected size at height" // $NON-NLS
/**
* Check whether this error is the result of a failed continuity while scanning new blocks in the Rust layer.
*
* @return true in case of the check match, false otherwise
*/
internal fun Throwable.isScanContinuityError(): Boolean {
val errorMessages = listOf(
PREV_HASH_MISMATCH,
BLOCK_HEIGHT_DISCONTINUITY,
TREE_SIZE_MISMATCH
)
errorMessages.forEach { errMessage ->
if (this.message?.lowercase()?.contains(errMessage.lowercase()) == true) {
return true
}
}
return false
}

View File

@ -1,10 +1,9 @@
package cash.z.ecc.android.sdk.internal.ext
import android.content.Context
import cash.z.ecc.android.sdk.ext.ZcashSdk.MAX_BACKOFF_INTERVAL
import cash.z.ecc.android.sdk.internal.Twig
import kotlinx.coroutines.delay
import java.io.File
import kotlin.math.pow
import kotlin.random.Random
/**
@ -18,7 +17,7 @@ import kotlin.random.Random
* @param block the code to execute, which will be wrapped in a try/catch and retried whenever an
* exception is thrown up to [retries] attempts.
*/
suspend inline fun retryUpTo(
suspend inline fun retryUpToAndThrow(
retries: Int,
exceptionWrapper: (Throwable) -> Throwable = { it },
initialDelayMillis: Long = 500L,
@ -35,7 +34,43 @@ suspend inline fun retryUpTo(
if (failedAttempts > retries) {
throw exceptionWrapper(t)
}
val duration = (initialDelayMillis.toDouble() * Math.pow(2.0, failedAttempts.toDouble() - 1)).toLong()
val duration = (initialDelayMillis.toDouble() * 2.0.pow(failedAttempts.toDouble() - 1)).toLong()
Twig.warn(t) { "Retrying ($failedAttempts/$retries) in ${duration}s..." }
delay(duration)
}
}
}
/**
* Execute the given block and if it fails, retry up to [retries] more times. If none of the
* retries succeed, then leave the block execution unfinished and continue.
*
* @param retries the number of times to retry the block after the first attempt fails.
* @param exceptionWrapper a function that can wrap the final failure to add more useful information
* * or context. Default behavior is to just return the final exception.
* @param initialDelayMillis the initial amount of time to wait before the first retry.
* @param block the code to execute, which will be wrapped in a try/catch and retried whenever an
* exception is thrown up to [retries] attempts.
*/
suspend inline fun retryUpToAndContinue(
retries: Int,
exceptionWrapper: (Throwable) -> Throwable = { it },
initialDelayMillis: Long = 500L,
block: (Int) -> Unit
) {
var failedAttempts = 0
while (failedAttempts < retries) {
@Suppress("TooGenericExceptionCaught")
try {
block(failedAttempts)
return
} catch (t: Throwable) {
failedAttempts++
if (failedAttempts == retries) {
exceptionWrapper(t)
return
}
val duration = (initialDelayMillis.toDouble() * 2.0.pow(failedAttempts.toDouble() - 1)).toLong()
Twig.warn(t) { "Retrying ($failedAttempts/$retries) in ${duration}s..." }
delay(duration)
}
@ -73,10 +108,8 @@ suspend inline fun retryWithBackoff(
sequence++
// initialDelay^(sequence/4) + jitter
var duration = Math.pow(
initialDelayMillis.toDouble(),
(sequence.toDouble() / 4.0)
).toLong() + Random.nextLong(1000L)
var duration = initialDelayMillis.toDouble().pow((sequence.toDouble() / 4.0)).toLong() +
Random.nextLong(1000L)
if (duration > maxDelayMillis) {
duration = maxDelayMillis - Random.nextLong(1000L) // include jitter but don't exceed max delay
sequence /= 2
@ -86,12 +119,3 @@ suspend inline fun retryWithBackoff(
}
}
}
/**
* Return true if the given database already exists.
*
* @return true when the given database exists in the given context.
*/
internal fun dbExists(appContext: Context, dbFileName: String): Boolean {
return File(appContext.getDatabasePath(dbFileName).absolutePath).exists()
}

View File

@ -1,5 +1,6 @@
package cash.z.ecc.android.sdk.internal.model
import cash.z.ecc.android.sdk.internal.ext.isInUIntRange
import cash.z.ecc.android.sdk.model.BlockHeight
/**
@ -18,5 +19,12 @@ internal data class Checkpoint(
// Note: this field does NOT match the name of the JSON, so will break with field-based JSON parsing
val tree: String
) {
fun treeState(): TreeState {
require(epochSeconds.isInUIntRange()) {
"epochSeconds $epochSeconds is outside of allowed UInt range"
}
return TreeState.fromParts(height.value, hash, epochSeconds.toInt(), tree)
}
internal companion object
}

View File

@ -0,0 +1,28 @@
package cash.z.ecc.android.sdk.internal.model
internal data class ScanProgress(
private val numerator: Long,
private val denominator: Long
) {
override fun toString() = "ScanProgress($numerator/$denominator) -> ${getSafeRation()}"
/**
* Returns progress ratio in [0, 1] range. Any out-of-range value is treated as 0.
*/
fun getSafeRation() = numerator.toFloat().div(denominator).let { ration ->
if (ration < 0f || ration > 1f) {
0f
} else {
ration
}
}
companion object {
fun new(jni: JniScanProgress): ScanProgress {
return ScanProgress(
numerator = jni.numerator,
denominator = jni.denominator
)
}
}
}

View File

@ -0,0 +1,41 @@
package cash.z.ecc.android.sdk.internal.model
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
internal data class ScanRange(
val range: ClosedRange<BlockHeight>,
val priority: Long
) {
override fun toString() = "ScanRange(range=$range, priority=${getSuggestScanRangePriority()})"
internal fun getSuggestScanRangePriority(): SuggestScanRangePriority {
return SuggestScanRangePriority.entries
.firstOrNull { it.priority == priority } ?: SuggestScanRangePriority.Ignored
}
companion object {
/**
* Note that this function subtracts 1 from [JniScanRange.endHeight] as the rest of the logic works with
* [ClosedRange] and the endHeight is exclusive.
*/
fun new(jni: JniScanRange, zcashNetwork: ZcashNetwork): ScanRange {
return ScanRange(
range =
BlockHeight.new(zcashNetwork, jni.startHeight)..(BlockHeight.new(zcashNetwork, jni.endHeight) - 1),
priority = jni.priority
)
}
}
}
@Suppress("MagicNumber")
internal enum class SuggestScanRangePriority(val priority: Long) {
Ignored(0),
Scanned(10),
Historic(20),
OpenAdjacent(30),
FoundNote(40),
ChainTip(50),
Verify(60)
}

View File

@ -0,0 +1,43 @@
package cash.z.ecc.android.sdk.internal.model
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.SubtreeRootUnsafe
internal data class SubtreeRoot(
val rootHash: ByteArray,
val completingBlockHash: ByteArray,
val completingBlockHeight: BlockHeight
) {
override fun toString() = "SubtreeRoot(completingBlockHeight=${completingBlockHeight.value})"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SubtreeRoot
if (!rootHash.contentEquals(other.rootHash)) return false
if (!completingBlockHash.contentEquals(other.completingBlockHash)) return false
if (completingBlockHeight != other.completingBlockHeight) return false
return true
}
override fun hashCode(): Int {
var result = rootHash.contentHashCode()
result = 31 * result + completingBlockHash.contentHashCode()
result = 31 * result + completingBlockHeight.hashCode()
return result
}
companion object {
fun new(unsafe: SubtreeRootUnsafe, zcashNetwork: ZcashNetwork): SubtreeRoot {
return SubtreeRoot(
rootHash = unsafe.rootHash,
completingBlockHash = unsafe.completingBlockHash,
completingBlockHeight = BlockHeight.new(zcashNetwork, unsafe.completingBlockHeight.value)
)
}
}
}

View File

@ -0,0 +1,26 @@
package cash.z.ecc.android.sdk.internal.model
import co.electriccoin.lightwallet.client.model.TreeStateUnsafe
class TreeState(
val encoded: ByteArray
) {
companion object {
fun new(unsafe: TreeStateUnsafe): TreeState {
// Potential validation comes here
return TreeState(
encoded = unsafe.encoded
)
}
fun fromParts(
height: Long,
hash: String,
time: Int,
tree: String
): TreeState {
val unsafeTreeState = TreeStateUnsafe.fromParts(height, hash, time, tree)
return TreeState.new(unsafeTreeState)
}
}
}

View File

@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.internal.repository
import cash.z.ecc.android.sdk.internal.model.DbTransactionOverview
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionRecipient
import kotlinx.coroutines.flow.Flow
@ -12,13 +13,6 @@ import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
internal interface DerivedDataRepository {
/**
* The last height scanned by this repository.
*
* @return the last height scanned by this repository.
*/
suspend fun lastScannedHeight(): BlockHeight
/**
* The height of the first transaction that hasn't been enhanced yet.
*
@ -27,18 +21,6 @@ internal interface DerivedDataRepository {
*/
suspend fun firstUnenhancedHeight(): BlockHeight?
/**
* The height of the first block in this repository. This is typically the checkpoint that was
* used to initialize this wallet. If we overwrite this block, it breaks our ability to spend
* funds.
*/
suspend fun firstScannedHeight(): BlockHeight
/**
* @return true when this repository has been initialized and seeded with the initial checkpoint.
*/
suspend fun isInitialized(): Boolean
/**
* Find the encoded transaction associated with the given id.
*
@ -46,7 +28,7 @@ internal interface DerivedDataRepository {
*
* @return the transaction or null when it cannot be found.
*/
suspend fun findEncodedTransactionById(txId: Long): EncodedTransaction?
suspend fun findEncodedTransactionByTxId(txId: FirstClassByteArray): EncodedTransaction?
/**
* Find all the newly scanned transactions in the given range, including transactions (like
@ -75,11 +57,6 @@ internal interface DerivedDataRepository {
suspend fun findMatchingTransactionId(rawTransactionId: ByteArray): Long?
// TODO [#681]: begin converting these into Data Access API. For now, just collect the desired
// operations and iterate/refactor, later
// TODO [#681]: https://github.com/zcash/zcash-android-wallet-sdk/issues/681
suspend fun findBlockHash(height: BlockHeight): ByteArray?
suspend fun getTransactionCount(): Long
/**
@ -105,7 +82,7 @@ internal interface DerivedDataRepository {
val allTransactions: Flow<List<DbTransactionOverview>>
fun getNoteIds(transactionId: Long): Flow<Long>
fun getSaplingOutputIndices(transactionId: Long): Flow<Int>
fun getRecipients(transactionId: Long): Flow<TransactionRecipient>

View File

@ -25,7 +25,7 @@ internal class FileCompactBlockRepository(
private val backend: TypesafeBackend
) : CompactBlockRepository {
override suspend fun getLatestHeight() = backend.getLatestBlockHeight()
override suspend fun getLatestHeight() = backend.getLatestCacheHeight()
override suspend fun findCompactBlock(height: BlockHeight) = backend.findBlockMetadata(height)

View File

@ -1,6 +1,7 @@
package cash.z.ecc.android.sdk.internal.transaction
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
@ -69,6 +70,10 @@ internal interface TransactionEncoder {
/**
* Return the consensus branch that the encoder is using when making transactions.
*
* @param height the height at which we want to get the consensus branch
*
* @return id of consensus branch
*/
suspend fun getConsensusBranchId(): Long
suspend fun getConsensusBranchId(height: BlockHeight): Long
}

View File

@ -7,6 +7,8 @@ import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.TypesafeBackend
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
@ -47,7 +49,7 @@ internal class TransactionEncoderImpl(
require(recipient is TransactionRecipient.Address)
val transactionId = createSpend(usk, amount, recipient.addressValue, memo)
return repository.findEncodedTransactionById(transactionId)
return repository.findEncodedTransactionByTxId(transactionId)
?: throw TransactionEncoderException.TransactionNotFoundException(transactionId)
}
@ -59,7 +61,7 @@ internal class TransactionEncoderImpl(
require(recipient is TransactionRecipient.Account)
val transactionId = createShieldingSpend(usk, memo)
return repository.findEncodedTransactionById(transactionId)
return repository.findEncodedTransactionByTxId(transactionId)
?: throw TransactionEncoderException.TransactionNotFoundException(transactionId)
}
@ -96,8 +98,16 @@ internal class TransactionEncoderImpl(
override suspend fun isValidUnifiedAddress(address: String): Boolean =
backend.isValidUnifiedAddr(address)
override suspend fun getConsensusBranchId(): Long {
val height = repository.lastScannedHeight()
/**
* Return the consensus branch that the encoder is using when making transactions.
*
* @param height the height at which we want to get the consensus branch
*
* @return id of consensus branch
*
* @throws TransactionEncoderException.IncompleteScanException if the [height] is less than activation height
*/
override suspend fun getConsensusBranchId(height: BlockHeight): Long {
if (height < backend.network.saplingActivationHeight) {
throw TransactionEncoderException.IncompleteScanException(height)
}
@ -121,10 +131,10 @@ internal class TransactionEncoderImpl(
amount: Zatoshi,
toAddress: String,
memo: ByteArray? = byteArrayOf()
): Long {
): FirstClassByteArray {
Twig.debug {
"creating transaction to spend $amount zatoshi to" +
" ${toAddress.masked()} with memo $memo"
" ${toAddress.masked()} with memo: ${memo?.decodeToString()}"
}
@Suppress("TooGenericExceptionCaught")
@ -148,7 +158,7 @@ internal class TransactionEncoderImpl(
private suspend fun createShieldingSpend(
usk: UnifiedSpendingKey,
memo: ByteArray? = byteArrayOf()
): Long {
): FirstClassByteArray {
@Suppress("TooGenericExceptionCaught")
return try {
saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory)

View File

@ -39,6 +39,24 @@ data class BlockHeight internal constructor(val value: Long) : Comparable<BlockH
return BlockHeight(value + other)
}
operator fun minus(other: BlockHeight) = BlockHeight(value - other.value)
operator fun minus(other: Int): BlockHeight {
require(other >= 0) {
"Cannot subtract negative value $other to BlockHeight"
}
return BlockHeight(value - other.toLong())
}
operator fun minus(other: Long): BlockHeight {
require(other >= 0) {
"Cannot subtract negative value $other to BlockHeight"
}
return BlockHeight(value - other)
}
companion object {
private val UINT_RANGE = 0.toLong()..UInt.MAX_VALUE.toLong()

View File

@ -33,7 +33,7 @@ data class TransactionOverview internal constructor(
companion object {
internal fun new(
dbTransactionOverview: DbTransactionOverview,
latestBlockHeight: BlockHeight
latestBlockHeight: BlockHeight?
): TransactionOverview {
return TransactionOverview(
dbTransactionOverview.id,
@ -69,11 +69,13 @@ enum class TransactionState {
private const val MIN_CONFIRMATIONS = 10
internal fun new(
latestBlockHeight: BlockHeight,
latestBlockHeight: BlockHeight?,
minedHeight: BlockHeight?,
expiryHeight: BlockHeight?
): TransactionState {
return if (minedHeight != null && (latestBlockHeight.value - minedHeight.value) >= MIN_CONFIRMATIONS) {
return if (latestBlockHeight == null) {
Pending
} else if (minedHeight != null && (latestBlockHeight.value - minedHeight.value) >= MIN_CONFIRMATIONS) {
Confirmed
} else if (minedHeight != null && (latestBlockHeight.value - minedHeight.value) < MIN_CONFIRMATIONS) {
Pending

View File

@ -11,6 +11,9 @@ data class ZcashNetwork(
val saplingActivationHeight: BlockHeight,
val orchardActivationHeight: BlockHeight
) {
fun isMainnet() = id == ID_MAINNET
fun isTestnet() = id == ID_TESTNET
@Suppress("MagicNumber")
companion object {

View File

@ -0,0 +1,30 @@
package cash.z.ecc.android.sdk.block.processor
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CompactBlockProcessorTest {
@Test
fun should_refresh_preparation_test() {
assertTrue {
CompactBlockProcessor.shouldRefreshPreparation(
lastPreparationTime = CompactBlockProcessor.SYNCHRONIZATION_RESTART_TIMEOUT,
currentTimeMillis = CompactBlockProcessor.SYNCHRONIZATION_RESTART_TIMEOUT * 2,
limitTime = CompactBlockProcessor.SYNCHRONIZATION_RESTART_TIMEOUT
)
}
}
@Test
fun should_not_refresh_preparation_test() {
assertFalse {
CompactBlockProcessor.shouldRefreshPreparation(
lastPreparationTime = CompactBlockProcessor.SYNCHRONIZATION_RESTART_TIMEOUT,
currentTimeMillis = CompactBlockProcessor.SYNCHRONIZATION_RESTART_TIMEOUT,
limitTime = CompactBlockProcessor.SYNCHRONIZATION_RESTART_TIMEOUT
)
}
}
}

View File

@ -0,0 +1,30 @@
package cash.z.ecc.android.sdk.ext
import cash.z.ecc.android.sdk.internal.ext.BLOCK_HEIGHT_DISCONTINUITY
import cash.z.ecc.android.sdk.internal.ext.PREV_HASH_MISMATCH
import cash.z.ecc.android.sdk.internal.ext.TREE_SIZE_MISMATCH
import cash.z.ecc.android.sdk.internal.ext.isScanContinuityError
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ExceptionExtTest {
@Test
fun is_scan_continuity_error() {
assertTrue { RuntimeException(PREV_HASH_MISMATCH).isScanContinuityError() }
assertTrue { RuntimeException(TREE_SIZE_MISMATCH).isScanContinuityError() }
assertTrue { RuntimeException(BLOCK_HEIGHT_DISCONTINUITY).isScanContinuityError() }
assertTrue { RuntimeException(PREV_HASH_MISMATCH.lowercase()).isScanContinuityError() }
assertTrue { RuntimeException(PREV_HASH_MISMATCH.plus("Text")).isScanContinuityError() }
}
@Test
fun is_not_scan_continuity_error() {
assertFalse { RuntimeException("Text").isScanContinuityError() }
assertFalse { RuntimeException("").isScanContinuityError() }
assertFalse { RuntimeException(PREV_HASH_MISMATCH.drop(1)).isScanContinuityError() }
}
}

View File

@ -0,0 +1,13 @@
package cash.z.ecc.android.sdk.fixture
import cash.z.ecc.android.sdk.internal.model.ScanProgress
object ScanProgressFixture {
internal const val DEFAULT_NUMERATOR = 50L
internal const val DEFAULT_DENOMINATOR = 100L
internal fun new(
numerator: Long = DEFAULT_NUMERATOR,
denominator: Long = DEFAULT_DENOMINATOR
) = ScanProgress(numerator, denominator)
}

View File

@ -0,0 +1,17 @@
package cash.z.ecc.android.sdk.fixture
import cash.z.ecc.android.sdk.internal.model.ScanRange
import cash.z.ecc.android.sdk.internal.model.SuggestScanRangePriority
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
object ScanRangeFixture {
internal val DEFAULT_CLOSED_RANGE =
ZcashNetwork.Testnet.saplingActivationHeight..ZcashNetwork.Testnet.saplingActivationHeight + 9
internal val DEFAULT_PRIORITY = SuggestScanRangePriority.Verify.priority
internal fun new(
range: ClosedRange<BlockHeight> = DEFAULT_CLOSED_RANGE,
priority: Long = DEFAULT_PRIORITY
) = ScanRange(range, priority)
}

View File

@ -0,0 +1,24 @@
package cash.z.ecc.android.sdk.internal.model
import cash.z.ecc.android.sdk.fixture.ScanProgressFixture
import kotlin.test.Test
import kotlin.test.assertEquals
class ScanProgressTest {
@Test
fun get_valid_ratio_test() {
val scanProgress = ScanProgressFixture.new()
assertEquals(
scanProgress.getSafeRation(),
ScanProgressFixture.DEFAULT_NUMERATOR.toFloat().div(ScanProgressFixture.DEFAULT_DENOMINATOR)
)
}
@Test
fun get_fallback_ratio_test() {
val scanProgress = ScanProgressFixture.new(
denominator = 0
)
assertEquals(0f, scanProgress.getSafeRation())
}
}

View File

@ -0,0 +1,29 @@
package cash.z.ecc.android.sdk.internal.model
import cash.z.ecc.android.sdk.fixture.ScanRangeFixture
import cash.z.ecc.android.sdk.internal.ext.isNotEmpty
import cash.z.ecc.android.sdk.internal.ext.length
import cash.z.ecc.android.sdk.model.ZcashNetwork
import kotlin.test.Test
import kotlin.test.assertTrue
class ScanRangeTest {
@Test
fun get_suggest_scan_range_priority_test() {
val scanRange = ScanRangeFixture.new(
priority = SuggestScanRangePriority.Verify.priority
)
assertTrue {
scanRange.getSuggestScanRangePriority() == SuggestScanRangePriority.Verify
}
}
@Test
fun scan_range_boundaries_test() {
val scanRange = ScanRangeFixture.new(
range = ZcashNetwork.Testnet.saplingActivationHeight..ZcashNetwork.Testnet.saplingActivationHeight + 9
)
assertTrue { scanRange.range.isNotEmpty() }
assertTrue { scanRange.range.length() == 10L }
}
}

View File

@ -68,4 +68,32 @@ class BlockHeightTest {
ZcashNetwork.Mainnet.saplingActivationHeight + -1L
}
}
@Test
fun subtraction_of_block_height_succeeds() {
val one = BlockHeight.new(
ZcashNetwork.Mainnet,
ZcashNetwork.Mainnet.saplingActivationHeight.value +
ZcashNetwork.Mainnet.saplingActivationHeight.value
)
val two = BlockHeight.new(ZcashNetwork.Mainnet, ZcashNetwork.Mainnet.saplingActivationHeight.value)
assertEquals(ZcashNetwork.Mainnet.saplingActivationHeight.value, (one - two).value)
}
@Test
fun subtraction_of_long_succeeds() {
assertEquals(
ZcashNetwork.Mainnet.saplingActivationHeight.value,
(BlockHeight(419_323L) - 123L).value
)
}
@Test
fun subtraction_of_int_succeeds() {
assertEquals(
ZcashNetwork.Mainnet.saplingActivationHeight.value,
(BlockHeight(419_323) - 123).value
)
}
}

Some files were not shown because too many files have changed in this diff Show More