diff --git a/CHANGELOG.md b/CHANGELOG.md index d860e4df7..de79ec9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Migrated to Rust 1.80.0. - `Synchronizer.proposeTransfer` now supports TEX addresses (ZIP 320). +- Internal transactions-enhancing logic has changed to support the history of transactions made to TEX addresses ### Added - `Synchronizer.isValidTexAddr` which checks whether the given address is a valid ZIP 320 TEX address diff --git a/backend-lib/Cargo.lock b/backend-lib/Cargo.lock index 69c2e31b0..dbf2d31a5 100644 --- a/backend-lib/Cargo.lock +++ b/backend-lib/Cargo.lock @@ -1111,7 +1111,7 @@ dependencies = [ [[package]] name = "equihash" version = "0.2.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "blake2b_simd", "byteorder", @@ -1147,7 +1147,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "blake2b_simd", ] @@ -1663,8 +1663,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "incrementalmerkletree" -version = "0.5.1" -source = "git+https://github.com/zcash/incrementalmerkletree?rev=337f59179eda51261e9ddfc6b18e8fb84ea277c9#337f59179eda51261e9ddfc6b18e8fb84ea277c9" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75346da3bd8e3d8891d02508245ed2df34447ca6637e343829f8d08986e9cde2" dependencies = [ "either", ] @@ -2141,9 +2142,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchard" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0462569fc8b0d1b158e4d640571867a4e4319225ebee2ab6647e60c70af19ae3" +checksum = "4dc7bde644aeb980be296cd908c6650894dc8541deb56f9f5294c52ed7ca568f" dependencies = [ "aes", "bitvec", @@ -2164,6 +2165,7 @@ dependencies = [ "serde", "subtle", "tracing", + "visibility", "zcash_note_encryption", "zcash_spec", "zip32", @@ -2995,9 +2997,9 @@ dependencies = [ [[package]] name = "sapling-crypto" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f4270033afcb0c74c5c7d59c73cfd1040367f67f224fe7ed9a919ae618f1b7" +checksum = "15e379398fffad84e49f9a45a05635fc004f66086e65942dbf4eb95332c26d2a" dependencies = [ "aes", "bellman", @@ -3257,8 +3259,9 @@ dependencies = [ [[package]] name = "shardtree" -version = "0.3.1" -source = "git+https://github.com/zcash/incrementalmerkletree?rev=337f59179eda51261e9ddfc6b18e8fb84ea277c9#337f59179eda51261e9ddfc6b18e8fb84ea277c9" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78222845cd8bbe5eb95687407648ff17693a35de5e8abaa39a4681fb21e033f9" dependencies = [ "bitflags", "either", @@ -4526,6 +4529,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "void" version = "1.0.2" @@ -4990,7 +5004,7 @@ dependencies = [ [[package]] name = "zcash_address" version = "0.3.2" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "bech32", "bs58", @@ -5002,7 +5016,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.12.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "arti-client", "async-trait", @@ -5058,7 +5072,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.10.3" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "bip32", "bs58", @@ -5094,7 +5108,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.2.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "byteorder", "nonempty", @@ -5103,7 +5117,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.2.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "bech32", "bip32", @@ -5144,7 +5158,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.15.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "aes", "bip32", @@ -5182,7 +5196,7 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.15.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "bellman", "blake2b_simd", @@ -5204,7 +5218,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.1.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "document-features", "memuse", @@ -5294,7 +5308,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.0.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=7f7b685b99132505a0fe4ba588f329e3516e9669#7f7b685b99132505a0fe4ba588f329e3516e9669" +source = "git+https://github.com/zcash/librustzcash.git?rev=5a32d3b9bdcb028b4b7522717bceb5afff5112d5#5a32d3b9bdcb028b4b7522717bceb5afff5112d5" dependencies = [ "base64 0.21.7", "nom", diff --git a/backend-lib/Cargo.toml b/backend-lib/Cargo.toml index df7ae4715..d6901dcb2 100644 --- a/backend-lib/Cargo.toml +++ b/backend-lib/Cargo.toml @@ -11,10 +11,10 @@ edition = "2018" rust-version = "1.80" [dependencies] -orchard = "0.8" +orchard = "0.9" prost = "0.13" rusqlite = "0.29" -sapling = { package = "sapling-crypto", version = "0.1", default-features = false } +sapling = { package = "sapling-crypto", version = "0.2", default-features = false } secrecy = "0.8" zcash_address = "0.3" zcash_client_backend = { version = "0.12.1", features = ["orchard", "tor", "transparent-inputs", "unstable"] } @@ -67,12 +67,10 @@ path = "src/main/rust/lib.rs" crate-type = ["staticlib", "cdylib"] [patch.crates-io] -incrementalmerkletree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "337f59179eda51261e9ddfc6b18e8fb84ea277c9" } -shardtree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "337f59179eda51261e9ddfc6b18e8fb84ea277c9" } -zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } -zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } -zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } -zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } -zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } -zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } -zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "7f7b685b99132505a0fe4ba588f329e3516e9669" } +zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } +zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } +zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } +zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } +zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } +zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } +zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "5a32d3b9bdcb028b4b7522717bceb5afff5112d5" } diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Backend.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Backend.kt index 885587c5e..1e83d39a3 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Backend.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Backend.kt @@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.internal.model.JniBlockMeta import cash.z.ecc.android.sdk.internal.model.JniScanRange import cash.z.ecc.android.sdk.internal.model.JniScanSummary import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot +import cash.z.ecc.android.sdk.internal.model.JniTransactionDataRequest import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey import cash.z.ecc.android.sdk.internal.model.JniWalletSummary import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe @@ -39,7 +40,14 @@ interface Backend { unifiedSpendingKey: ByteArray ): List - suspend fun decryptAndStoreTransaction(tx: ByteArray) + /** + * @throws RuntimeException as a common indicator of the operation failure + */ + @Throws(RuntimeException::class) + suspend fun decryptAndStoreTransaction( + tx: ByteArray, + minedHeight: Long? + ) /** * Sets up the internal structure of the data database. @@ -176,6 +184,12 @@ interface Backend { limit: Long ): JniScanSummary + /** + * @throws RuntimeException as a common indicator of the operation failure + */ + @Throws(RuntimeException::class) + suspend fun transactionDataRequests(): List + /** * @throws RuntimeException as a common indicator of the operation failure */ @@ -206,4 +220,13 @@ interface Backend { value: Long, height: Long ) + + /** + * @throws RuntimeException as a common indicator of the operation failure + */ + @Throws(RuntimeException::class) + suspend fun setTransactionStatus( + txId: ByteArray, + status: Long, + ) } diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustBackend.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustBackend.kt index 943d341aa..ea6701c33 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustBackend.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustBackend.kt @@ -8,6 +8,7 @@ import cash.z.ecc.android.sdk.internal.model.JniBlockMeta import cash.z.ecc.android.sdk.internal.model.JniScanRange import cash.z.ecc.android.sdk.internal.model.JniScanSummary import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot +import cash.z.ecc.android.sdk.internal.model.JniTransactionDataRequest import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey import cash.z.ecc.android.sdk.internal.model.JniWalletSummary import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe @@ -295,14 +296,26 @@ class RustBackend private constructor( } } - override suspend fun decryptAndStoreTransaction(tx: ByteArray) = - withContext(SdkDispatchers.DATABASE_IO) { - decryptAndStoreTransaction( - dataDbFile.absolutePath, - tx, + override suspend fun transactionDataRequests(): List { + return withContext(SdkDispatchers.DATABASE_IO) { + transactionDataRequests( + dbDataPath = dataDbFile.absolutePath, networkId = networkId - ) + ).asList() } + } + + override suspend fun decryptAndStoreTransaction( + tx: ByteArray, + minedHeight: Long? + ) = withContext(SdkDispatchers.DATABASE_IO) { + decryptAndStoreTransaction( + dataDbFile.absolutePath, + tx, + minedHeight = minedHeight ?: -1, + networkId = networkId + ) + } override suspend fun proposeTransfer( account: Int, @@ -382,6 +395,18 @@ class RustBackend private constructor( ) } + override suspend fun setTransactionStatus( + txId: ByteArray, + status: Long + ) = withContext(SdkDispatchers.DATABASE_IO) { + Companion.setTransactionStatus( + dataDbFile.absolutePath, + txId, + status, + networkId = networkId + ) + } + override fun isValidSaplingAddr(addr: String) = isValidSaplingAddress(addr, networkId = networkId) override fun isValidTransparentAddr(addr: String) = isValidTransparentAddress(addr, networkId = networkId) @@ -612,10 +637,25 @@ class RustBackend private constructor( networkId: Int ): JniScanSummary + @JvmStatic + private external fun transactionDataRequests( + dbDataPath: String, + networkId: Int + ): Array + @JvmStatic private external fun decryptAndStoreTransaction( dbDataPath: String, tx: ByteArray, + minedHeight: Long, + networkId: Int + ) + + @JvmStatic + private external fun setTransactionStatus( + dbDataPath: String, + txId: ByteArray, + status: Long, networkId: Int ) diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest.kt new file mode 100644 index 000000000..436d41d2f --- /dev/null +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest.kt @@ -0,0 +1,40 @@ +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. + */ +@Keep +sealed class JniTransactionDataRequest { + @Keep + class GetStatus(val txid: ByteArray) : JniTransactionDataRequest() + + @Keep + class Enhancement(val txid: ByteArray) : JniTransactionDataRequest() + + @Keep + data class SpendsFromAddress( + val address: String, + val startHeight: Long, + val endHeight: Long, + ) : JniTransactionDataRequest() { + 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" + } + // We use -1L to represent None across JNI. + if (endHeight != -1L) { + require(endHeight.isInUIntRange()) { + "Height $endHeight is outside of allowed UInt range" + } + require(endHeight >= startHeight) { + "End height $endHeight must be greater than start height $startHeight." + } + } + } + } +} diff --git a/backend-lib/src/main/rust/lib.rs b/backend-lib/src/main/rust/lib.rs index 7d750593e..97379a286 100644 --- a/backend-lib/src/main/rust/lib.rs +++ b/backend-lib/src/main/rust/lib.rs @@ -19,6 +19,7 @@ use tracing::{debug, error}; use tracing_subscriber::prelude::*; use tracing_subscriber::reload; use zcash_address::{ToAddress, ZcashAddress}; +use zcash_client_backend::data_api::{TransactionDataRequest, TransactionStatus}; use zcash_client_backend::{ address::{Address, UnifiedAddress}, data_api::{ @@ -50,7 +51,7 @@ use zcash_primitives::{ consensus::{ BlockHeight, BranchId, Network, Network::{MainNetwork, TestNetwork}, - Parameters, + NetworkType, Parameters, }, legacy::{Script, TransparentAddress}, memo::{Memo, MemoBytes}, @@ -369,7 +370,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_createAcc let account = db_data.get_account(account_id)?.expect("just created"); let account_index = match account.source() { AccountSource::Derived { account_index, .. } => account_index, - AccountSource::Imported => unreachable!("just created"), + AccountSource::Imported { .. } => unreachable!("just created"), }; Ok(encode_usk(env, account_index, usk)?.into_raw()) @@ -1327,7 +1328,9 @@ fn encode_wallet_summary<'a, P: Parameters>( .source() { AccountSource::Derived { account_index, .. } => account_index, - AccountSource::Imported => unreachable!("Imported accounts are unimplemented"), + AccountSource::Imported { .. } => { + unreachable!("Imported accounts are unimplemented") + } }; Ok::<_, anyhow::Error>((account_index, balance)) }) @@ -1500,6 +1503,87 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_scanBlock unwrap_exc_or(&mut env, res, ptr::null_mut()) } +fn encode_transaction_data_request<'a>( + env: &mut JNIEnv<'a>, + net: NetworkType, + transaction_data_request: TransactionDataRequest, +) -> jni::errors::Result> { + match transaction_data_request { + TransactionDataRequest::GetStatus(txid) => env.new_object( + "cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest$GetStatus", + "([B)V", + &[(&env.byte_array_from_slice(txid.as_ref())?).into()], + ), + TransactionDataRequest::Enhancement(txid) => env.new_object( + "cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest$Enhancement", + "([B)V", + &[(&env.byte_array_from_slice(txid.as_ref())?).into()], + ), + TransactionDataRequest::SpendsFromAddress { + address, + block_range_start, + block_range_end, + } => { + let taddr = match address { + TransparentAddress::PublicKeyHash(data) => { + ZcashAddress::from_transparent_p2pkh(net, data) + } + TransparentAddress::ScriptHash(data) => { + ZcashAddress::from_transparent_p2sh(net, data) + } + }; + + env.new_object( + "cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest$SpendsFromAddress", + "(Ljava/lang/String;JJ)V", + &[ + (&env.new_string(taddr.encode())?).into(), + JValue::Long(i64::from(u32::from(block_range_start))), + JValue::Long(block_range_end.map(u32::from).map(i64::from).unwrap_or(-1)), + ], + ) + } + } +} + +#[no_mangle] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_transactionDataRequests< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_data: JString<'local>, + network_id: jint, +) -> jobjectArray { + let res = catch_unwind(&mut env, |env| { + let _span = tracing::info_span!("RustBackend.transactionDataRequests").entered(); + let network = parse_network(network_id as u32)?; + let db_data = wallet_db(env, network, db_data)?; + + let ranges = db_data + .transaction_data_requests() + .map_err(|e| anyhow!("Error while fetching transaction data requests: {}", e))?; + + let net = network.network_type(); + + Ok(utils::rust_vec_to_java( + env, + ranges, + "cash/z/ecc/android/sdk/internal/model/JniTransactionDataRequest", + |env, request| encode_transaction_data_request(env, net, request), + |env| { + encode_transaction_data_request( + env, + net, + TransactionDataRequest::GetStatus(TxId::from_bytes([0; 32])), + ) + }, + )? + .into_raw()) + }); + unwrap_exc_or(&mut env, res, ptr::null_mut()) +} + #[no_mangle] pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_putUtxo<'local>( mut env: JNIEnv<'local>, @@ -1556,6 +1640,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_decryptAn _: JClass<'local>, db_data: JString<'local>, tx: JByteArray<'local>, + mined_height: jlong, network_id: jint, ) -> jboolean { let res = catch_unwind(&mut env, |env| { @@ -1570,8 +1655,9 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_decryptAn // - v5 and above transactions ignore the argument, and parse the correct value // from their encoding. let tx = Transaction::read(&tx_bytes[..], BranchId::Sapling)?; + let mined_height = BlockHeight::try_from(mined_height).ok(); - match decrypt_and_store_transaction(&network, &mut db_data, &tx) { + match decrypt_and_store_transaction(&network, &mut db_data, &tx, mined_height) { Ok(()) => Ok(JNI_TRUE), Err(e) => Err(anyhow!("Error while decrypting transaction: {}", e)), } @@ -1580,6 +1666,38 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_decryptAn unwrap_exc_or(&mut env, res, JNI_FALSE) } +#[no_mangle] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_setTransactionStatus< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_data: JString<'local>, + txid_bytes: JByteArray<'local>, + status: jlong, + network_id: jint, +) -> jboolean { + let res = catch_unwind(&mut env, |env| { + let _span = tracing::info_span!("RustBackend.setTransactionStatus").entered(); + let network = parse_network(network_id as u32)?; + let mut db_data = wallet_db(env, network, db_data)?; + let txid_bytes = env.convert_byte_array(txid_bytes)?; + let txid = TxId::read(&txid_bytes[..])?; + let status = match status { + -2 => TransactionStatus::TxidNotRecognized, + -1 => TransactionStatus::NotInMainChain, + height => TransactionStatus::Mined(BlockHeight::try_from(height)?), + }; + + match db_data.set_transaction_status(txid, status) { + Ok(()) => Ok(JNI_TRUE), + Err(e) => Err(anyhow!("Error while setting transaction status: {}", e)), + } + }); + + unwrap_exc_or(&mut env, res, JNI_FALSE) +} + fn zip317_helper( change_memo: Option, use_zip317_fees: jboolean, diff --git a/lightwallet-client-lib/src/main/java/co/electriccoin/lightwallet/client/model/RawTransactionUnsafe.kt b/lightwallet-client-lib/src/main/java/co/electriccoin/lightwallet/client/model/RawTransactionUnsafe.kt index b3b343d8e..0c1ac4dfb 100644 --- a/lightwallet-client-lib/src/main/java/co/electriccoin/lightwallet/client/model/RawTransactionUnsafe.kt +++ b/lightwallet-client-lib/src/main/java/co/electriccoin/lightwallet/client/model/RawTransactionUnsafe.kt @@ -7,12 +7,41 @@ import cash.z.wallet.sdk.internal.rpc.Service.RawTransaction * * It is marked as "unsafe" because it is not guaranteed to be valid. */ -class RawTransactionUnsafe(val height: BlockHeightUnsafe, val data: ByteArray) { +sealed class RawTransactionUnsafe(open val data: ByteArray) { + /** + * The transaction was found in a block mined in the current main chain. + */ + class MainChain(override val data: ByteArray, val height: BlockHeightUnsafe) : RawTransactionUnsafe(data) + + /** + * The transaction was found in the mempool, and can potentially be mined in the + * current main chain. + */ + @Suppress("SpellCheckingInspection") + class Mempool(override val data: ByteArray) : RawTransactionUnsafe(data) + + /** + * The transaction was found in an orphaned block. + * + * In particular, it was not found in the current main chain or the mempool, which + * means that the transaction is likely conflicted with the main chain (e.g. it may + * double-spend funds spent in the main chain, or it may have expired). + */ + class OrphanedBlock(override val data: ByteArray) : RawTransactionUnsafe(data) + companion object { - fun new(rawTransaction: RawTransaction) = - RawTransactionUnsafe( - BlockHeightUnsafe(rawTransaction.height), - rawTransaction.data.toByteArray() - ) + fun new(rawTransaction: RawTransaction): RawTransactionUnsafe { + val data = rawTransaction.data.toByteArray() + return when (rawTransaction.height) { + -1L -> OrphanedBlock(data) + 0L -> Mempool(data) + else -> MainChain(data, BlockHeightUnsafe(rawTransaction.height)) + } + } } + + /** + * This is a safe [toString] function that prints only non-sensitive parts + */ + override fun toString() = "RawTransactionUnsafe: type: ${this::class.simpleName}" } diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/fixture/FakeRustBackend.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/fixture/FakeRustBackend.kt index a3627b68a..7eb4d1861 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/fixture/FakeRustBackend.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/fixture/FakeRustBackend.kt @@ -4,6 +4,7 @@ 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.JniScanRange import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot +import cash.z.ecc.android.sdk.internal.model.JniTransactionDataRequest import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey import cash.z.ecc.android.sdk.internal.model.JniWalletSummary import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe @@ -26,33 +27,33 @@ internal class FakeRustBackend( orchardStartIndex: Long, orchardRoots: List, ) { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun updateChainTip(height: Long) { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun getFullyScannedHeight(): Long? { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun getMaxScannedHeight(): Long? { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun getWalletSummary(): JniWalletSummary { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun suggestScanRanges(): List { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun getLatestCacheHeight(): Long = metadata.maxOf { it.height } override suspend fun getTotalTransparentBalance(address: String): Long { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun putUtxo( @@ -63,7 +64,14 @@ internal class FakeRustBackend( value: Long, height: Long ) { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") + } + + override suspend fun setTransactionStatus( + txId: ByteArray, + status: Long + ) { + error("Intentionally not implemented yet.") } override suspend fun findBlockMetadata(height: Long): JniBlockMeta? { @@ -74,10 +82,9 @@ internal class FakeRustBackend( metadata.removeAll { it.height > height } } - override suspend fun initBlockMetaDb(): Int = - error( - "Intentionally not implemented in mocked FakeRustBackend implementation." - ) + override suspend fun initBlockMetaDb(): Int { + error("Intentionally not implemented yet.") + } override suspend fun proposeTransfer( account: Int, @@ -85,7 +92,7 @@ internal class FakeRustBackend( value: Long, memo: ByteArray? ): ProposalUnsafe { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun proposeShielding( @@ -94,27 +101,34 @@ internal class FakeRustBackend( memo: ByteArray?, transparentReceiver: String? ): ProposalUnsafe? { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun createProposedTransactions( proposal: ProposalUnsafe, unifiedSpendingKey: ByteArray ): List { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } - override suspend fun decryptAndStoreTransaction(tx: ByteArray) = - error("Intentionally not implemented in mocked FakeRustBackend implementation.") + override suspend fun decryptAndStoreTransaction( + tx: ByteArray, + minedHeight: Long? + ) { + error("Intentionally not implemented yet.") + } - override suspend fun initDataDb(seed: ByteArray?): Int = - error("Intentionally not implemented in mocked FakeRustBackend implementation.") + override suspend fun initDataDb(seed: ByteArray?): Int { + error("Intentionally not implemented yet.") + } override suspend fun createAccount( seed: ByteArray, treeState: ByteArray, recoverUntil: Long? - ): JniUnifiedSpendingKey = error("Intentionally not implemented in mocked FakeRustBackend implementation.") + ): JniUnifiedSpendingKey { + error("Intentionally not implemented yet.") + } override suspend fun isSeedRelevantToAnyDerivedAccounts(seed: ByteArray): Boolean = error("Intentionally not implemented in mocked FakeRustBackend implementation.") @@ -133,33 +147,35 @@ internal class FakeRustBackend( } override suspend fun getCurrentAddress(account: Int): String { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } - override fun getTransparentReceiver(ua: String): String? = - error("Intentionally not implemented in mocked FakeRustBackend implementation.") + override fun getTransparentReceiver(ua: String): String? { + error("Intentionally not implemented yet.") + } - override fun getSaplingReceiver(ua: String): String? = - error( - "Intentionally not implemented in mocked FakeRustBackend implementation." - ) + override fun getSaplingReceiver(ua: String): String? { + error("Intentionally not implemented yet.") + } override suspend fun listTransparentReceivers(account: Int): List { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override fun getBranchIdForHeight(height: Long): Long { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun getMemoAsUtf8( txId: ByteArray, protocol: Int, outputIndex: Int - ): String? = error("Intentionally not implemented in mocked FakeRustBackend implementation.") + ): String? { + error("Intentionally not implemented yet.") + } override suspend fun getNearestRewindHeight(height: Long): Long { - TODO("Not yet implemented") + error("Intentionally not implemented yet.") } override suspend fun scanBlocks( @@ -167,4 +183,8 @@ internal class FakeRustBackend( fromState: ByteArray, limit: Long ) = error("Intentionally not implemented in mocked FakeRustBackend implementation.") + + override suspend fun transactionDataRequests(): List { + error("Intentionally not implemented yet.") + } } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt index 05dd15b6b..6fd952450 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt @@ -15,8 +15,10 @@ import cash.z.ecc.android.sdk.block.processor.model.SyncingResult import cash.z.ecc.android.sdk.block.processor.model.UpdateChainTipResult import cash.z.ecc.android.sdk.block.processor.model.VerifySuggestedScanRange import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException +import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDataRequestsError import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDecryptError import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDownloadError +import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxSetStatusError import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedConsensusBranch import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedNetwork import cash.z.ecc.android.sdk.exception.InitializeException @@ -38,24 +40,28 @@ import cash.z.ecc.android.sdk.internal.ext.retryWithBackoff import cash.z.ecc.android.sdk.internal.ext.toHexReversed import cash.z.ecc.android.sdk.internal.metrics.TraceScope import cash.z.ecc.android.sdk.internal.model.BlockBatch -import cash.z.ecc.android.sdk.internal.model.DbTransactionOverview import cash.z.ecc.android.sdk.internal.model.JniBlockMeta 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.SuggestScanRangePriority +import cash.z.ecc.android.sdk.internal.model.TransactionDataRequest +import cash.z.ecc.android.sdk.internal.model.TransactionStatus import cash.z.ecc.android.sdk.internal.model.TreeState import cash.z.ecc.android.sdk.internal.model.WalletSummary import cash.z.ecc.android.sdk.internal.model.ext.from import cash.z.ecc.android.sdk.internal.model.ext.toBlockHeight +import cash.z.ecc.android.sdk.internal.model.ext.toTransactionStatus import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository import cash.z.ecc.android.sdk.model.Account import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.PercentDecimal +import cash.z.ecc.android.sdk.model.RawTransaction 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 co.electriccoin.lightwallet.client.model.BlockHeightUnsafe import co.electriccoin.lightwallet.client.model.GetAddressUtxosReplyUnsafe +import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe import co.electriccoin.lightwallet.client.model.Response import co.electriccoin.lightwallet.client.model.ShieldedProtocolEnum import co.electriccoin.lightwallet.client.model.SubtreeRootUnsafe @@ -1574,7 +1580,8 @@ class CompactBlockProcessor internal constructor( range = currentEnhancingRange, repository = repository, backend = backend, - downloader = downloader + downloader = downloader, + network = network ).collect { enhancingResult -> Twig.info { "Enhancing result: $enhancingResult" } resultState = @@ -1838,29 +1845,67 @@ class CompactBlockProcessor internal constructor( range: ClosedRange, repository: DerivedDataRepository, backend: TypesafeBackend, - downloader: CompactBlockDownloader + downloader: CompactBlockDownloader, + network: ZcashNetwork ): Flow = flow { Twig.debug { "Enhancing transaction details for blocks $range" } - val newTxs = repository.findNewTransactions(range) - if (newTxs.isEmpty()) { + val newTxDataRequests = + runCatching { + transactionDataRequests(backend) + }.onFailure { + Twig.error(it) { "Failed to get transaction data requests" } + }.getOrElse { + emit( + SyncingResult.EnhanceFailed( + range.start, + it as CompactBlockProcessorException.EnhanceTransactionError + ) + ) + return@flow + } + + if (newTxDataRequests.isEmpty()) { Twig.debug { "No new transactions found in $range" } } else { - Twig.debug { "Enhancing ${newTxs.size} transaction(s)!" } + Twig.debug { "Enhancing ${newTxDataRequests.size} transaction(s)!" } // If the first transaction has been added - if (newTxs.size.toLong() == repository.getTransactionCount()) { + // Ideally, we could remove this last reference to the transaction view from the enhancing logic + if (newTxDataRequests.size.toLong() == repository.getTransactionCount()) { Twig.debug { "Encountered the first transaction. This changes the birthday height!" } emit(SyncingResult.UpdateBirthday) } - newTxs.filter { it.minedHeight != null }.onEach { newTransaction -> - val trEnhanceResult = enhanceTransaction(newTransaction, backend, downloader) - if (trEnhanceResult is SyncingResult.EnhanceFailed) { - Twig.error { "Encountered transaction enhancing error: ${trEnhanceResult.exception}" } - emit(trEnhanceResult) - // We intentionally do not terminate the batch enhancing here, just reporting it + newTxDataRequests.forEach { + Twig.debug { "Transaction data request: $it" } + + when (it) { + is TransactionDataRequest.EnhancementRequired -> { + val trxEnhanceResult = enhanceTransaction(it, backend, downloader, network) + if (trxEnhanceResult is SyncingResult.EnhanceFailed) { + Twig.error(trxEnhanceResult.exception) { "Encountered transaction enhancing error" } + emit(trxEnhanceResult) + // We intentionally do not terminate the batch enhancing here, just reporting it + } + } + is TransactionDataRequest.SpendsFromAddress -> { + val processTaddrTxidsResult = + processTransparentAddressTxids( + transactionRequest = it, + backend = backend, + downloader = downloader, + network = network + ) + if (processTaddrTxidsResult is SyncingResult.EnhanceFailed) { + Twig.error(processTaddrTxidsResult.exception) { + "Encountered SpendsFromAddress transactions error" + } + emit(processTaddrTxidsResult) + // We intentionally do not terminate the batch enhancing here, just reporting it + } + } } } } @@ -1869,107 +1914,251 @@ class CompactBlockProcessor internal constructor( emit(SyncingResult.EnhanceSuccess) } - private suspend fun enhanceTransaction( - transaction: DbTransactionOverview, + private suspend fun processTransparentAddressTxids( + transactionRequest: TransactionDataRequest.SpendsFromAddress, backend: TypesafeBackend, - downloader: CompactBlockDownloader + downloader: CompactBlockDownloader, + network: ZcashNetwork ): SyncingResult { - Twig.debug { - "Starting enhancing transaction (txid:${transaction.txIdString()} block:${transaction - .minedHeight})" + Twig.debug { "Starting to get transparent address transactions ids" } + + // This case should not happen, at least for now, but we must handle it + if (transactionRequest.endHeight == null) { + Twig.error { "Unexpected Null " } + return SyncingResult.EnhanceFailed( + failedAtHeight = transactionRequest.startHeight, + exception = + CompactBlockProcessorException.EnhanceTransactionError( + message = "Unexpected NULL TransactionDataRequest.SpendsFromAddress.endHeight", + height = transactionRequest.startHeight, + cause = IllegalStateException("Unexpected SpendsFromAddress state") + ) + ) } - if (transaction.minedHeight == null) { - return SyncingResult.EnhanceSuccess + + val traceScope = TraceScope("CompactBlockProcessor.processTransparentAddressTxids") + val result = + try { + // Fetching transactions is done with retries to eliminate a bad network condition + getTransparentAddressTransactions( + transactionRequest = transactionRequest, + downloader = downloader + ).onEach { rawTransactionUnsafe -> + // Decrypting and storing transaction is run just once, since we consider it more stable + Twig.verbose { "Decrypting and storing rawTransactionUnsafe" } + decryptTransaction( + rawTransaction = + RawTransaction.new( + rawTransactionUnsafe = rawTransactionUnsafe, + network = network + ), + backend = backend + ) + }.onCompletion { + Twig.verbose { "Done Decrypting and storing of all transaction" } + }.collect() + + SyncingResult.EnhanceSuccess + } catch (exception: CompactBlockProcessorException.EnhanceTransactionError) { + SyncingResult.EnhanceFailed(transactionRequest.startHeight, exception) + } + traceScope.end() + return result + } + + @Throws(EnhanceTxDownloadError::class) + private suspend fun getTransparentAddressTransactions( + transactionRequest: TransactionDataRequest.SpendsFromAddress, + downloader: CompactBlockDownloader, + ): Flow { + val traceScope = TraceScope("CompactBlockProcessor.getTransparentAddressTransactions") + var resultFlow: Flow? = null + + retryUpToAndThrow( + retries = TRANSACTION_FETCH_RETRIES, + exceptionWrapper = { EnhanceTxDownloadError(it) } + ) { failedAttempts -> + if (failedAttempts == 0) { + Twig.debug { "Starting to get transactions for tAddr: ${transactionRequest.address}" } + } else { + Twig.warn { + "Retrying to fetch transactions for tAddr: ${transactionRequest.address}" + + " after $failedAttempts failure(s)..." + } + } + + // We can safely assert non-nullability here as we check in the function caller + // - 1 for the end height because the GRPC request is end-inclusive whereas we use end-exclusive + // ranges everywhere in the Rust code + val requestedRange = transactionRequest.startHeight..(transactionRequest.endHeight!! - 1) + resultFlow = + downloader.getTAddressTransactions( + transparentAddress = transactionRequest.address, + blockHeightRange = requestedRange + ) } + traceScope.end() + // The flow is initialized or the EnhanceTxDownloadError is thrown after all the attempts failed + return resultFlow!! + } + + @Suppress("LongMethod") + private suspend fun enhanceTransaction( + transactionRequest: TransactionDataRequest.EnhancementRequired, + backend: TypesafeBackend, + downloader: CompactBlockDownloader, + network: ZcashNetwork + ): SyncingResult { + Twig.debug { "Starting enhancing transaction: txid: ${transactionRequest.txIdString()}" } val traceScope = TraceScope("CompactBlockProcessor.enhanceTransaction") val result = try { // Fetching transaction is done with retries to eliminate a bad network condition - Twig.verbose { - "Fetching transaction (txid:${transaction.txIdString()} block:${transaction - .minedHeight})" - } - val transactionData = + val rawTransactionUnsafe = fetchTransaction( - transactionId = transaction.txIdString(), - rawTransactionId = transaction.rawId.byteArray, - minedHeight = transaction.minedHeight, - downloader = downloader + transactionRequest = transactionRequest, + downloader = downloader, ) - // Decrypting and storing transaction is run just once, since we consider it more stable - Twig.verbose { - "Decrypting and storing transaction " + - "(txid:${transaction.txIdString()} block:${transaction.minedHeight})" - } - decryptTransaction( - transactionData = transactionData, - minedHeight = transaction.minedHeight, - backend = backend - ) + Twig.debug { "Transaction fetched: $rawTransactionUnsafe" } - Twig.debug { - "Done enhancing transaction (txid:${transaction.txIdString()} block:${transaction - .minedHeight})" + // We need to distinct between the two possible states of [transactionRequest] + when (transactionRequest) { + is TransactionDataRequest.GetStatus -> { + Twig.debug { + "Resolving TransactionDataRequest.GetStatus by setting status of " + + "transaction: txid: ${transactionRequest.txIdString() }" + } + val status = + rawTransactionUnsafe?.toTransactionStatus(network) + ?: TransactionStatus.TxidNotRecognized + setTransactionStatus( + transactionRawId = transactionRequest.txid, + status = status, + backend = backend + ) + } + is TransactionDataRequest.Enhancement -> { + if (rawTransactionUnsafe == null) { + Twig.debug { + "Resolving TransactionDataRequest.Enhancement by setting status of " + + "transaction. Txid not recognized: ${transactionRequest.txIdString()}" + } + setTransactionStatus( + transactionRawId = transactionRequest.txid, + status = TransactionStatus.TxidNotRecognized, + backend = backend + ) + } else { + Twig.debug { + "Resolving TransactionDataRequest.Enhancement by decrypting and storing " + + "transaction: txid: ${transactionRequest.txIdString()}" + } + decryptTransaction( + rawTransaction = + RawTransaction.new( + rawTransactionUnsafe = rawTransactionUnsafe, + network = network + ), + backend = backend + ) + } + } } + + Twig.debug { "Done enhancing transaction: txid: ${transactionRequest.txIdString()}" } SyncingResult.EnhanceSuccess } catch (exception: CompactBlockProcessorException.EnhanceTransactionError) { - SyncingResult.EnhanceFailed( - transaction.minedHeight, - exception - ) + SyncingResult.EnhanceFailed(null, exception) } traceScope.end() return result } - // TODO [#1254]: CompactblockProcessor.fetchTransaction pass txId twice - // TODO [#1254]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1254 + /** + * Fetch the transaction complete data by [TransactionDataRequest.EnhancementRequired.txid] from Light Wallet + * server. This function handles [Response.Failure.Server.NotFound] by returning null. + * + * @return [RawTransactionUnsafe] if the transaction has been found, null otherwise. + * + * @throws CompactBlockProcessorException.EnhanceTransactionError in case of any other error + */ @Throws(EnhanceTxDownloadError::class) private suspend fun fetchTransaction( - transactionId: String, - rawTransactionId: ByteArray, - minedHeight: BlockHeight, - downloader: CompactBlockDownloader - ): ByteArray { + transactionRequest: TransactionDataRequest.EnhancementRequired, + downloader: CompactBlockDownloader, + ): RawTransactionUnsafe? { + var transactionResult: RawTransactionUnsafe? = null val traceScope = TraceScope("CompactBlockProcessor.fetchTransaction") - var transactionDataResult: ByteArray? = null + retryUpToAndThrow(TRANSACTION_FETCH_RETRIES) { failedAttempts -> if (failedAttempts == 0) { - Twig.debug { "Starting to fetch transaction (txid:$transactionId, block:$minedHeight)" } + Twig.debug { "Starting to fetch transaction: txid: ${transactionRequest.txIdString()}" } } else { Twig.warn { - "Retrying to fetch transaction (txid:$transactionId, block:$minedHeight) after" + - " $failedAttempts failure(s)..." + "Retrying to fetch transaction: txid: ${transactionRequest.txIdString()}" + + " after $failedAttempts failure(s)..." } } - when (val response = downloader.fetchTransaction(rawTransactionId)) { - is Response.Success -> { - transactionDataResult = response.result.data - } - is Response.Failure -> { - throw EnhanceTxDownloadError(minedHeight, response.toThrowable()) + + transactionResult = + when (val response = downloader.fetchTransaction(transactionRequest.txid)) { + is Response.Success -> response.result + is Response.Failure -> { + if (response is Response.Failure.Server.NotFound) { + null + } else { + throw EnhanceTxDownloadError(response.toThrowable()) + } + } } - } } traceScope.end() - // Result is fetched or EnhanceTxDownloadError is thrown after all attempts failed at this point - return transactionDataResult!! + return transactionResult } @Throws(EnhanceTxDecryptError::class) private suspend fun decryptTransaction( - transactionData: ByteArray, - minedHeight: BlockHeight, + rawTransaction: RawTransaction, backend: TypesafeBackend, ) { val traceScope = TraceScope("CompactBlockProcessor.decryptTransaction") runCatching { - backend.decryptAndStoreTransaction(transactionData) + backend.decryptAndStoreTransaction(rawTransaction.data, rawTransaction.height) + }.onFailure { + traceScope.end() + throw EnhanceTxDecryptError(rawTransaction.height, it) + } + traceScope.end() + } + + @Throws(EnhanceTxDataRequestsError::class) + private suspend fun transactionDataRequests(backend: TypesafeBackend): List { + val traceScope = TraceScope("CompactBlockProcessor.transactionDataRequests") + val result = + runCatching { + backend.transactionDataRequests() + }.getOrElse { + traceScope.end() + throw EnhanceTxDataRequestsError(it) + } + traceScope.end() + return result + } + + @Throws(EnhanceTxSetStatusError::class) + private suspend fun setTransactionStatus( + transactionRawId: ByteArray, + status: TransactionStatus, + backend: TypesafeBackend, + ) { + val traceScope = TraceScope("CompactBlockProcessor.setTransactionStatus") + runCatching { + backend.setTransactionStatus(transactionRawId, status) }.onFailure { traceScope.end() - throw EnhanceTxDecryptError(minedHeight, it) + throw EnhanceTxSetStatusError(it) } traceScope.end() } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/model/SyncingResult.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/model/SyncingResult.kt index 3824d236c..b11b88cc1 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/model/SyncingResult.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/model/SyncingResult.kt @@ -61,7 +61,7 @@ internal sealed class SyncingResult { data object FetchUtxos : SyncingResult() data class EnhanceFailed( - override val failedAtHeight: BlockHeight, + override val failedAtHeight: BlockHeight? = null, override val exception: CompactBlockProcessorException ) : Failure, SyncingResult() diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt index bbb7000a9..3d3115df1 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt @@ -98,26 +98,35 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? = open class EnhanceTransactionError( message: String, - val height: BlockHeight, + val height: BlockHeight?, cause: Throwable ) : CompactBlockProcessorException(message, cause) { - class EnhanceTxDownloadError( - height: BlockHeight, - cause: Throwable - ) : EnhanceTransactionError( - "Error while attempting to download a transaction to enhance", - height, - cause - ) + class EnhanceTxDownloadError(cause: Throwable) : EnhanceTransactionError( + "Error while attempting to download a transaction to enhance", + null, + cause + ) class EnhanceTxDecryptError( - height: BlockHeight, + height: BlockHeight?, cause: Throwable ) : EnhanceTransactionError( "Error while attempting to decrypt and store a transaction to enhance", height, cause ) + + class EnhanceTxSetStatusError(cause: Throwable) : EnhanceTransactionError( + "Error while attempting to set status of a transaction to the Rust backend", + null, + cause + ) + + class EnhanceTxDataRequestsError(cause: Throwable) : EnhanceTransactionError( + "Error while attempting to request transactions data from the Rust backend", + null, + cause + ) } class MismatchedNetwork(clientNetwork: String?, serverNetwork: String?) : CompactBlockProcessorException( @@ -281,6 +290,12 @@ sealed class LightWalletException(message: String, cause: Throwable? = null) : S "Failed to fetch latest block height with code: $code due to: ${description ?: "-"}", cause ) + + class GetTAddressTransactionsException(code: Int, description: String?, cause: Throwable) : SdkException( + "Failed to get transactions belonging to the given transparent address with code: $code due" + + " to: ${description ?: "-"}", + cause + ) } /** diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackend.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackend.kt index 4981774d1..7943abee2 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackend.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackend.kt @@ -6,6 +6,8 @@ import cash.z.ecc.android.sdk.internal.model.JniBlockMeta import cash.z.ecc.android.sdk.internal.model.ScanRange import cash.z.ecc.android.sdk.internal.model.ScanSummary import cash.z.ecc.android.sdk.internal.model.SubtreeRoot +import cash.z.ecc.android.sdk.internal.model.TransactionDataRequest +import cash.z.ecc.android.sdk.internal.model.TransactionStatus import cash.z.ecc.android.sdk.internal.model.TreeState import cash.z.ecc.android.sdk.internal.model.WalletSummary import cash.z.ecc.android.sdk.internal.model.ZcashProtocol @@ -137,6 +139,12 @@ internal interface TypesafeBackend { limit: Long ): ScanSummary + /** + * @throws RuntimeException as a common indicator of the operation failure + */ + @Throws(RuntimeException::class) + suspend fun transactionDataRequests(): List + /** * @throws RuntimeException as a common indicator of the operation failure */ @@ -149,7 +157,15 @@ internal interface TypesafeBackend { @Throws(RuntimeException::class) suspend fun suggestScanRanges(): List - suspend fun decryptAndStoreTransaction(tx: ByteArray) + suspend fun decryptAndStoreTransaction( + tx: ByteArray, + minedHeight: BlockHeight? + ) + + suspend fun setTransactionStatus( + txId: ByteArray, + status: TransactionStatus, + ) fun getSaplingReceiver(ua: String): String? diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackendImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackendImpl.kt index b4a59a076..34c732dba 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackendImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeBackendImpl.kt @@ -7,6 +7,8 @@ import cash.z.ecc.android.sdk.internal.model.JniSubtreeRoot import cash.z.ecc.android.sdk.internal.model.ScanRange import cash.z.ecc.android.sdk.internal.model.ScanSummary import cash.z.ecc.android.sdk.internal.model.SubtreeRoot +import cash.z.ecc.android.sdk.internal.model.TransactionDataRequest +import cash.z.ecc.android.sdk.internal.model.TransactionStatus import cash.z.ecc.android.sdk.internal.model.TreeState import cash.z.ecc.android.sdk.internal.model.WalletSummary import cash.z.ecc.android.sdk.internal.model.ZcashProtocol @@ -231,6 +233,14 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke limit: Long ): ScanSummary = ScanSummary.new(backend.scanBlocks(fromHeight.value, fromState.encoded, limit), network) + override suspend fun transactionDataRequests(): List = + backend.transactionDataRequests().map { jniRequest -> + TransactionDataRequest.new( + jniRequest, + network + ) + } + override suspend fun getWalletSummary(): WalletSummary? = backend.getWalletSummary()?.let { jniWalletSummary -> WalletSummary.new(jniWalletSummary) @@ -244,7 +254,19 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke ) } - override suspend fun decryptAndStoreTransaction(tx: ByteArray) = backend.decryptAndStoreTransaction(tx) + override suspend fun decryptAndStoreTransaction( + tx: ByteArray, + minedHeight: BlockHeight? + ) = backend + .decryptAndStoreTransaction(tx, minedHeight?.value) + + override suspend fun setTransactionStatus( + txId: ByteArray, + status: TransactionStatus + ) = backend.setTransactionStatus( + txId = txId, + status = status.toPrimitiveValue() + ) override fun getSaplingReceiver(ua: String): String? = backend.getSaplingReceiver(ua) diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/block/CompactBlockDownloader.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/block/CompactBlockDownloader.kt index 950aec877..789c54764 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/block/CompactBlockDownloader.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/block/CompactBlockDownloader.kt @@ -150,6 +150,42 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository */ suspend fun fetchTransaction(txId: ByteArray) = lightWalletClient.fetchTransaction(txId) + /** + * Get transactions belonging to the given transparent address + * + * @throws LightWalletException.GetTAddressTransactionsException if any error while getting the transactions occurs + * @return List of all the transaction belonging to the given transparent address on the given block range + */ + fun getTAddressTransactions( + transparentAddress: String, + blockHeightRange: ClosedRange + ) = lightWalletClient.getTAddressTransactions( + tAddress = transparentAddress, + blockHeightRange = + BlockHeightUnsafe.from(blockHeightRange.start)..BlockHeightUnsafe.from(blockHeightRange.endInclusive) + ).map { response -> + when (response) { + is Response.Success -> { + Twig.verbose { "Get a new rawTransactionUnsafe successfully" } + response.result + } + is Response.Failure -> { + Twig.error(response.toThrowable()) { "Getting a new rawTransactionUnsafe failed" } + throw LightWalletException.GetTAddressTransactionsException( + response.code, + response.description, + response.toThrowable() + ) + } + } + }.onCompletion { error -> + if (error != null) { + Twig.error(error) { "Getting list of rawTransactionUnsafe failed" } + } else { + Twig.debug { "All rawTransactionUnsafe got successfully" } + } + } + /** * Fetch all UTXOs for the given addresses and from the given height. * @@ -168,7 +204,7 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository * * @return a flow of information about roots of subtrees of the Sapling and Orchard note commitment trees. */ - suspend fun getSubtreeRoots( + fun getSubtreeRoots( startIndex: UInt, shieldedProtocol: ShieldedProtocolEnum, maxEntries: UInt diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt index cfffb7038..f3ba35cbd 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt @@ -57,15 +57,6 @@ internal class AllTransactionView( AllTransactionViewDefinition.COLUMN_INTEGER_MINED_HEIGHT ) - private val SELECTION_BLOCK_RANGE = - String.format( - Locale.ROOT, - // $NON-NLS - "%s >= ? AND %s <= ?", - AllTransactionViewDefinition.COLUMN_INTEGER_MINED_HEIGHT, - AllTransactionViewDefinition.COLUMN_INTEGER_MINED_HEIGHT - ) - private val SELECTION_RAW_IS_NULL = String.format( Locale.ROOT, @@ -148,16 +139,6 @@ internal class AllTransactionView( cursorParser = cursorParser ) - fun getTransactionRange(blockHeightRange: ClosedRange) = - sqliteDatabase.queryAndMap( - table = AllTransactionViewDefinition.VIEW_NAME, - columns = COLUMNS, - orderBy = ORDER_BY, - selection = SELECTION_BLOCK_RANGE, - selectionArgs = arrayOf(blockHeightRange.start.value, blockHeightRange.endInclusive.value), - cursorParser = cursorParser - ) - suspend fun getOldestTransaction() = sqliteDatabase.queryAndMap( table = AllTransactionViewDefinition.VIEW_NAME, diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt index 8542872ec..de8152665 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt @@ -26,9 +26,6 @@ internal class DbDerivedDataRepository( return derivedDataDb.transactionTable.findEncodedTransactionByTxId(txId) } - override suspend fun findNewTransactions(blockHeightRange: ClosedRange): List = - derivedDataDb.allTransactionView.getTransactionRange(blockHeightRange).toList() - override suspend fun getOldestTransaction() = derivedDataDb.allTransactionView.getOldestTransaction() override suspend fun findMinedHeight(rawTransactionId: ByteArray) = diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/TransactionDataRequest.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/TransactionDataRequest.kt new file mode 100644 index 000000000..2ab5bfb13 --- /dev/null +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/TransactionDataRequest.kt @@ -0,0 +1,61 @@ +package cash.z.ecc.android.sdk.internal.model + +import cash.z.ecc.android.sdk.exception.SdkException +import cash.z.ecc.android.sdk.internal.ext.toHexReversed +import cash.z.ecc.android.sdk.model.BlockHeight +import cash.z.ecc.android.sdk.model.ZcashNetwork + +/** + * Serves as cross layer (Kotlin, Rust) communication class. + */ +sealed interface TransactionDataRequest { + sealed class EnhancementRequired(open val txid: ByteArray) : TransactionDataRequest { + abstract fun txIdString(): String + } + + data class GetStatus(override val txid: ByteArray) : EnhancementRequired(txid) { + override fun txIdString() = txid.toHexReversed() + } + + data class Enhancement(override val txid: ByteArray) : EnhancementRequired(txid) { + override fun txIdString() = txid.toHexReversed() + } + + data class SpendsFromAddress( + val address: String, + val startHeight: BlockHeight, + val endHeight: BlockHeight?, + ) : TransactionDataRequest { + init { + if (endHeight != null) { + require(endHeight >= startHeight) { + "End height $endHeight must not be less than start height $startHeight." + } + } + } + } + + companion object { + fun new( + jni: JniTransactionDataRequest, + zcashNetwork: ZcashNetwork + ): TransactionDataRequest { + return when (jni) { + is JniTransactionDataRequest.GetStatus -> GetStatus(jni.txid) + is JniTransactionDataRequest.Enhancement -> Enhancement(jni.txid) + is JniTransactionDataRequest.SpendsFromAddress -> + SpendsFromAddress( + jni.address, + BlockHeight.new(zcashNetwork, jni.startHeight), + if (jni.endHeight == -1L) { + null + } else { + BlockHeight.new(zcashNetwork, jni.endHeight) + } + ) + + else -> throw SdkException("Unknown JniTransactionDataRequest variant", cause = null) + } + } + } +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/TransactionStatus.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/TransactionStatus.kt new file mode 100644 index 000000000..f9c7c9e0e --- /dev/null +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/TransactionStatus.kt @@ -0,0 +1,23 @@ +package cash.z.ecc.android.sdk.internal.model + +import cash.z.ecc.android.sdk.model.BlockHeight + +sealed class TransactionStatus { + abstract fun toPrimitiveValue(): Long + + data class Mined(val height: BlockHeight) : TransactionStatus() { + override fun toPrimitiveValue() = height.value + } + + data object NotInMainChain : TransactionStatus() { + private const val NOT_IN_MAIN_CHAIN = -1L + + override fun toPrimitiveValue() = NOT_IN_MAIN_CHAIN + } + + data object TxidNotRecognized : TransactionStatus() { + private const val TXID_NOT_RECOGNIZED = -2L + + override fun toPrimitiveValue() = TXID_NOT_RECOGNIZED + } +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/ext/RawTransactionUnsafeExt.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/ext/RawTransactionUnsafeExt.kt new file mode 100644 index 000000000..becb19e1d --- /dev/null +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/ext/RawTransactionUnsafeExt.kt @@ -0,0 +1,16 @@ +package cash.z.ecc.android.sdk.internal.model.ext + +import cash.z.ecc.android.sdk.internal.model.TransactionStatus +import cash.z.ecc.android.sdk.model.ZcashNetwork +import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe +import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe.MainChain +import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe.Mempool +import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe.OrphanedBlock + +internal fun RawTransactionUnsafe.toTransactionStatus(network: ZcashNetwork): TransactionStatus { + return when (this) { + is MainChain -> TransactionStatus.Mined(height.toBlockHeight(network)) + is Mempool -> TransactionStatus.NotInMainChain + is OrphanedBlock -> TransactionStatus.TxidNotRecognized + } +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt index 286a20364..ec0496446 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt @@ -30,19 +30,6 @@ internal interface DerivedDataRepository { */ suspend fun findEncodedTransactionByTxId(txId: FirstClassByteArray): EncodedTransaction? - /** - * Find all the newly scanned transactions in the given range, including transactions (like - * change or those only identified by nullifiers) which should not appear in the UI. This method - * is intended for use after a scan, in order to collect all the transactions that were - * discovered and then enhance them with additional details. It returns a list to signal that - * the intention is not to add them to a recyclerview or otherwise show in the UI. - * - * @param blockHeightRange the range of blocks to check for transactions. - * - * @return a list of transactions that were mined in the given range, inclusive. - */ - suspend fun findNewTransactions(blockHeightRange: ClosedRange): List - suspend fun getOldestTransaction(): DbTransactionOverview? /** diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/RawTransaction.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/RawTransaction.kt new file mode 100644 index 000000000..6e300632d --- /dev/null +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/RawTransaction.kt @@ -0,0 +1,56 @@ +package cash.z.ecc.android.sdk.model + +import cash.z.ecc.android.sdk.internal.model.ext.toBlockHeight +import cash.z.ecc.android.sdk.model.RawTransaction.Companion.new +import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe + +/** + * Represents a safe type transaction object obtained from Light wallet server. It contains complete transaction data. + * + * New instances are constructed using the [new] factory method. + * + * @param data The complete data of the transaction. + * @param height The transaction mined height. + */ +data class RawTransaction internal constructor( + val data: ByteArray, + val height: BlockHeight? +) { + init { + require(data.isNotEmpty()) { "Empty RawTransaction data" } + } + + companion object { + fun new( + rawTransactionUnsafe: RawTransactionUnsafe, + network: ZcashNetwork + ): RawTransaction { + return RawTransaction( + data = rawTransactionUnsafe.data, + height = + when (rawTransactionUnsafe) { + is RawTransactionUnsafe.MainChain -> rawTransactionUnsafe.height.toBlockHeight(network) + else -> null + } + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RawTransaction + + if (!data.contentEquals(other.data)) return false + if (height != other.height) return false + + return true + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + height.hashCode() + return result + } +}