diff --git a/backend-lib/build.gradle.kts b/backend-lib/build.gradle.kts index aa0027a3b..1b12b7436 100644 --- a/backend-lib/build.gradle.kts +++ b/backend-lib/build.gradle.kts @@ -1,3 +1,6 @@ +import com.google.protobuf.gradle.id +import com.google.protobuf.gradle.proto + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -5,6 +8,7 @@ plugins { id("org.jetbrains.dokka") id("org.mozilla.rust-android-gradle.rust-android") + id("com.google.protobuf") id("wtf.emulator.gradle") id("zcash-sdk.emulator-wtf-conventions") @@ -56,6 +60,10 @@ android { } } + sourceSets.getByName("main") { + proto { srcDir("src/main/proto") } + } + lint { baseline = File("lint-baseline.xml") } @@ -94,10 +102,36 @@ cargo { } } +protobuf { + protoc { + artifact = libs.protoc.compiler.get().asCoordinateString() + } + plugins { + id("java") { + artifact = libs.protoc.gen.java.get().asCoordinateString() + } + } + generateProtoTasks { + all().forEach { + it.plugins { + id("java") { + option("lite") + } + } + it.builtins { + id("kotlin") { + option("lite") + } + } + } + } +} + dependencies { api(projects.lightwalletClientLib) implementation(libs.androidx.annotation) + implementation(libs.bundles.protobuf) // Kotlin implementation(libs.kotlin.stdlib) @@ -119,6 +153,12 @@ dependencies { } tasks { + getByName("preBuild").dependsOn(create("bugfixTask") { + doFirst { + mkdir("build/extracted-include-protos/main") + } + }) + /* * The Mozilla Rust Gradle plugin caches the native build data under the "target" directory, * which does not normally get deleted during a clean. The following task and dependency solves 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 1220009ab..292f9f621 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 @@ -5,6 +5,7 @@ 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 cash.z.ecc.android.sdk.internal.model.JniWalletSummary +import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe /** * Contract defining the exposed capabilities of the Rust backend. @@ -19,18 +20,21 @@ interface Backend { suspend fun initBlockMetaDb(): Int @Suppress("LongParameterList") - suspend fun createToAddress( + suspend fun proposeTransfer( account: Int, - unifiedSpendingKey: ByteArray, to: String, value: Long, memo: ByteArray? = byteArrayOf() - ): ByteArray + ): ProposalUnsafe - suspend fun shieldToAddress( + suspend fun proposeShielding( account: Int, - unifiedSpendingKey: ByteArray, memo: ByteArray? = byteArrayOf() + ): ProposalUnsafe + + suspend fun createProposedTransaction( + proposal: ProposalUnsafe, + unifiedSpendingKey: ByteArray ): ByteArray suspend fun decryptAndStoreTransaction(tx: ByteArray) 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 f11b854b8..c84b8e418 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 @@ -9,6 +9,7 @@ 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 cash.z.ecc.android.sdk.internal.model.JniWalletSummary +import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe import kotlinx.coroutines.withContext import java.io.File @@ -292,44 +293,57 @@ class RustBackend private constructor( ) } - override suspend fun createToAddress( + override suspend fun proposeTransfer( account: Int, - unifiedSpendingKey: ByteArray, to: String, value: Long, memo: ByteArray? - ): ByteArray = + ): ProposalUnsafe = withContext(SdkDispatchers.DATABASE_IO) { - createToAddress( - dataDbFile.absolutePath, - unifiedSpendingKey, - to, - value, - memo ?: ByteArray(0), - spendParamsPath = saplingSpendFile.absolutePath, - outputParamsPath = saplingOutputFile.absolutePath, - networkId = networkId, - useZip317Fees = IS_USE_ZIP_317_FEES + ProposalUnsafe.parse( + proposeTransfer( + dataDbFile.absolutePath, + account, + to, + value, + memo ?: ByteArray(0), + networkId = networkId, + useZip317Fees = IS_USE_ZIP_317_FEES + ) ) } - override suspend fun shieldToAddress( + override suspend fun proposeShielding( account: Int, - unifiedSpendingKey: ByteArray, memo: ByteArray? - ): ByteArray { + ): ProposalUnsafe { return withContext(SdkDispatchers.DATABASE_IO) { - shieldToAddress( + ProposalUnsafe.parse( + proposeShielding( + dataDbFile.absolutePath, + account, + memo ?: ByteArray(0), + networkId = networkId, + useZip317Fees = IS_USE_ZIP_317_FEES + ) + ) + } + } + + override suspend fun createProposedTransaction( + proposal: ProposalUnsafe, + unifiedSpendingKey: ByteArray + ): ByteArray = + withContext(SdkDispatchers.DATABASE_IO) { + createProposedTransaction( dataDbFile.absolutePath, + proposal.toByteArray(), unifiedSpendingKey, - memo ?: ByteArray(0), spendParamsPath = saplingSpendFile.absolutePath, outputParamsPath = saplingOutputFile.absolutePath, - networkId = networkId, - useZip317Fees = IS_USE_ZIP_317_FEES + networkId = networkId ) } - } override suspend fun putUtxo( tAddress: String, @@ -563,30 +577,37 @@ class RustBackend private constructor( @JvmStatic @Suppress("LongParameterList") - private external fun createToAddress( + private external fun proposeTransfer( dbDataPath: String, - usk: ByteArray, + account: Int, to: String, value: Long, memo: ByteArray, - spendParamsPath: String, - outputParamsPath: String, networkId: Int, useZip317Fees: Boolean ): ByteArray @JvmStatic @Suppress("LongParameterList") - private external fun shieldToAddress( + private external fun proposeShielding( dbDataPath: String, - usk: ByteArray, + account: Int, memo: ByteArray, - spendParamsPath: String, - outputParamsPath: String, networkId: Int, useZip317Fees: Boolean ): ByteArray + @JvmStatic + @Suppress("LongParameterList") + private external fun createProposedTransaction( + dbDataPath: String, + proposal: ByteArray, + usk: ByteArray, + spendParamsPath: String, + outputParamsPath: String, + networkId: Int + ): ByteArray + @JvmStatic private external fun branchIdForHeight( height: Long, diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/ProposalUnsafe.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/ProposalUnsafe.kt new file mode 100644 index 000000000..f9af1fa61 --- /dev/null +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/ProposalUnsafe.kt @@ -0,0 +1,46 @@ +package cash.z.ecc.android.sdk.internal.model + +import cash.z.wallet.sdk.internal.ffi.ProposalOuterClass.FeeRule +import cash.z.wallet.sdk.internal.ffi.ProposalOuterClass.Proposal + +/** + * A transaction proposal created by the Rust backend in response to a Kotlin request. + * + * @param inner the parsed Proposal protobuf received across the FFI. + */ +class ProposalUnsafe( + private val inner: Proposal +) { + init { + require(inner.feeRule != FeeRule.FeeRuleNotSpecified) { + "Fee rule must be specified" + } + } + + companion object { + /** + * Parses a Proposal protobuf received across the FFI. + * + * @throws com.google.protobuf.InvalidProtocolBufferException + */ + @Throws(com.google.protobuf.InvalidProtocolBufferException::class) + fun parse(encoded: ByteArray): ProposalUnsafe { + val inner = Proposal.parseFrom(encoded) + return ProposalUnsafe(inner) + } + } + + /** + * Serializes this proposal for passing back across the FFI. + */ + fun toByteArray(): ByteArray { + return inner.toByteArray() + } + + /** + * Returns the fee required by this proposal. + */ + fun feeRequired(): Long { + return inner.balance.feeRequired + } +} diff --git a/backend-lib/src/main/proto/proposal.proto b/backend-lib/src/main/proto/proposal.proto new file mode 100644 index 000000000..84a8aed46 --- /dev/null +++ b/backend-lib/src/main/proto/proposal.proto @@ -0,0 +1,91 @@ +// Copyright (c) 2023 The Zcash developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php . + +syntax = "proto3"; +package cash.z.wallet.sdk.ffi; +option java_package = "cash.z.wallet.sdk.internal.ffi"; + +// A data structure that describes the inputs to be consumed and outputs to +// be produced in a proposed transaction. +message Proposal { + uint32 protoVersion = 1; + // ZIP 321 serialized transaction request + string transactionRequest = 2; + // The anchor height to be used in creating the transaction, if any. + // Setting the anchor height to zero will disallow the use of any shielded + // inputs. + uint32 anchorHeight = 3; + // The inputs to be used in creating the transaction. + repeated ProposedInput inputs = 4; + // The total value, fee value, and change outputs of the proposed + // transaction + TransactionBalance balance = 5; + // The fee rule used in constructing this proposal + FeeRule feeRule = 6; + // The target height for which the proposal was constructed + // + // The chain must contain at least this many blocks in order for the proposal to + // be executed. + uint32 minTargetHeight = 7; + // A flag indicating whether the proposal is for a shielding transaction, + // used for determining which OVK to select for wallet-internal outputs. + bool isShielding = 8; +} + +enum ValuePool { + // Protobuf requires that enums have a zero discriminant as the default + // value. However, we need to require that a known value pool is selected, + // and we do not want to fall back to any default, so sending the + // PoolNotSpecified value will be treated as an error. + PoolNotSpecified = 0; + // The transparent value pool (P2SH is not distinguished from P2PKH) + Transparent = 1; + // The Sapling value pool + Sapling = 2; + // The Orchard value pool + Orchard = 3; +} + +// The unique identifier and value for each proposed input. +message ProposedInput { + bytes txid = 1; + ValuePool valuePool = 2; + uint32 index = 3; + uint64 value = 4; +} + +// The fee rule used in constructing a Proposal +enum FeeRule { + // Protobuf requires that enums have a zero discriminant as the default + // value. However, we need to require that a known fee rule is selected, + // and we do not want to fall back to any default, so sending the + // FeeRuleNotSpecified value will be treated as an error. + FeeRuleNotSpecified = 0; + // 10000 ZAT + PreZip313 = 1; + // 1000 ZAT + Zip313 = 2; + // MAX(10000, 5000 * logical_actions) ZAT + Zip317 = 3; +} + +// The proposed change outputs and fee value. +message TransactionBalance { + repeated ChangeValue proposedChange = 1; + uint64 feeRequired = 2; +} + +// A proposed change output. If the transparent value pool is selected, +// the `memo` field must be null. +message ChangeValue { + uint64 value = 1; + ValuePool valuePool = 2; + MemoBytes memo = 3; +} + +// An object wrapper for memo bytes, to facilitate representing the +// `change_memo == None` case. +message MemoBytes { + bytes value = 1; +} diff --git a/backend-lib/src/main/rust/lib.rs b/backend-lib/src/main/rust/lib.rs index e9b534171..6549bd03f 100644 --- a/backend-lib/src/main/rust/lib.rs +++ b/backend-lib/src/main/rust/lib.rs @@ -1,4 +1,4 @@ -use std::convert::{TryFrom, TryInto}; +use std::convert::{Infallible, TryFrom, TryInto}; use std::num::NonZeroU32; use std::panic; use std::path::Path; @@ -24,8 +24,8 @@ use zcash_client_backend::{ chain::{scan_cached_blocks, CommitmentTreeRoot}, scanning::{ScanPriority, ScanRange}, wallet::{ - decrypt_and_store_transaction, input_selection::GreedyInputSelector, - shield_transparent_funds, spend, + create_proposed_transaction, decrypt_and_store_transaction, + input_selection::GreedyInputSelector, propose_shielding, propose_transfer, }, AccountBalance, AccountBirthday, InputSource, WalletCommitmentTrees, WalletRead, WalletSummary, WalletWrite, @@ -33,7 +33,7 @@ use zcash_client_backend::{ encoding::AddressCodec, fees::{standard::SingleOutputChangeStrategy, DustOutputPolicy}, keys::{DecodingError, Era, UnifiedAddressRequest, UnifiedFullViewingKey, UnifiedSpendingKey}, - proto::service::TreeState, + proto::{proposal::Proposal, service::TreeState}, wallet::{NoteId, OvkPolicy, WalletTransparentOutput}, zip321::{Payment, TransactionRequest}, ShieldedProtocol, @@ -1370,29 +1370,25 @@ fn zip317_helper( } #[no_mangle] -pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_createToAddress<'local>( +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeTransfer<'local>( mut env: JNIEnv<'local>, _: JClass<'local>, db_data: JString<'local>, - usk: JByteArray<'local>, + account: jint, to: JString<'local>, value: jlong, memo: JByteArray<'local>, - spend_params: JString<'local>, - output_params: JString<'local>, network_id: jint, use_zip317_fees: jboolean, ) -> jbyteArray { let res = catch_unwind(&mut env, |env| { let network = parse_network(network_id as u32)?; let mut db_data = wallet_db(env, network, db_data)?; - let usk = decode_usk(&env, usk)?; + let account = account_id_from_jint(account)?; let to = utils::java_string_to_rust(env, &to); let value = NonNegativeAmount::from_nonnegative_i64(value) .map_err(|()| format_err!("Invalid amount, out of range"))?; 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 to = match Address::decode(&network, &to) { Some(to) => to, @@ -1413,8 +1409,6 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_createToA let input_selector = zip317_helper(None, use_zip317_fees); - let prover = LocalTxProver::new(Path::new(&spend_params), Path::new(&output_params)); - let request = TransactionRequest::new(vec![Payment { recipient_address: to, amount: value, @@ -1425,51 +1419,47 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_createToA }]) .map_err(|e| format_err!("Error creating transaction request: {:?}", e))?; - let txid = spend( + let proposal = propose_transfer::<_, _, _, Infallible>( &mut db_data, &network, - &prover, - &prover, + account, &input_selector, - &usk, request, - OvkPolicy::Sender, ANCHOR_OFFSET, ) - .map_err(|e| format_err!("Error while creating transaction: {}", e))?; - - utils::rust_bytes_to_java(&env, txid.as_ref()).map(|arr| arr.into_raw()) + .map_err(|e| format_err!("Error creating transaction proposal: {}", e))?; + + utils::rust_bytes_to_java( + &env, + Proposal::from_standard_proposal(&network, &proposal) + .expect("transaction request should not be empty") + .encode_to_vec() + .as_ref(), + ) + .map(|arr| arr.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_shieldToAddress<'local>( +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeShielding<'local>( mut env: JNIEnv<'local>, _: JClass<'local>, db_data: JString<'local>, - usk: JByteArray<'local>, + account: jint, memo: JByteArray<'local>, - spend_params: JString<'local>, - output_params: JString<'local>, network_id: jint, use_zip317_fees: jboolean, ) -> jbyteArray { let res = catch_unwind(&mut env, |env| { let network = parse_network(network_id as u32)?; let mut db_data = wallet_db(env, network, db_data)?; - let usk = decode_usk(&env, usk)?; + let account = account_id_from_jint(account)?; 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_for_heights = NonZeroU32::new(1).unwrap(); - let account = db_data - .get_account_for_ufvk(&usk.to_unified_full_viewing_key())? - .ok_or_else(|| format_err!("Spending key not recognized."))?; - let from_addrs: Vec = db_data .get_target_and_anchor_heights(min_confirmations_for_heights) .map_err(|e| format_err!("Error while fetching anchor height: {}", e)) @@ -1496,23 +1486,67 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_shieldToA let input_selector = zip317_helper(Some(MemoBytes::from(&memo)), use_zip317_fees); - let prover = LocalTxProver::new(Path::new(&spend_params), Path::new(&output_params)); - let shielding_threshold = NonNegativeAmount::from_u64(100000).unwrap(); - let txid = shield_transparent_funds( + let proposal = propose_shielding::<_, _, _, Infallible>( &mut db_data, &network, - &prover, - &prover, &input_selector, shielding_threshold, - &usk, &from_addrs, min_confirmations, ) .map_err(|e| format_err!("Error while shielding transaction: {}", e))?; + utils::rust_bytes_to_java( + &env, + Proposal::from_standard_proposal(&network, &proposal) + .expect("transaction request should not be empty") + .encode_to_vec() + .as_ref(), + ) + .map(|arr| arr.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_createProposedTransaction< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_data: JString<'local>, + proposal: JByteArray<'local>, + usk: JByteArray<'local>, + spend_params: JString<'local>, + output_params: JString<'local>, + network_id: jint, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + let network = parse_network(network_id as u32)?; + let mut db_data = wallet_db(env, network, db_data)?; + let usk = decode_usk(&env, usk)?; + let spend_params = utils::java_string_to_rust(env, &spend_params); + let output_params = utils::java_string_to_rust(env, &output_params); + + let prover = LocalTxProver::new(Path::new(&spend_params), Path::new(&output_params)); + + let proposal = Proposal::decode(&env.convert_byte_array(proposal)?[..]) + .map_err(|e| format_err!("Invalid proposal: {}", e))? + .try_into_standard_proposal(&network, &db_data)?; + + let txid = create_proposed_transaction::<_, _, Infallible, _, _>( + &mut db_data, + &network, + &prover, + &prover, + &usk, + OvkPolicy::Sender, + &proposal, + ) + .map_err(|e| format_err!("Error while creating transaction: {}", e))?; + utils::rust_bytes_to_java(&env, txid.as_ref()).map(|arr| arr.into_raw()) }); unwrap_exc_or(&mut env, res, ptr::null_mut()) 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 591d917d4..b71b2ad74 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 @@ -6,6 +6,7 @@ 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 cash.z.ecc.android.sdk.internal.model.JniWalletSummary +import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe internal class FakeRustBackend( override val networkId: Int, @@ -80,20 +81,25 @@ internal class FakeRustBackend( "Intentionally not implemented in mocked FakeRustBackend implementation." ) - override suspend fun createToAddress( + override suspend fun proposeTransfer( account: Int, - unifiedSpendingKey: ByteArray, to: String, value: Long, memo: ByteArray? - ): ByteArray { + ): ProposalUnsafe { TODO("Not yet implemented") } - override suspend fun shieldToAddress( + override suspend fun proposeShielding( account: Int, - unifiedSpendingKey: ByteArray, memo: ByteArray? + ): ProposalUnsafe { + TODO("Not yet implemented") + } + + override suspend fun createProposedTransaction( + proposal: ProposalUnsafe, + unifiedSpendingKey: ByteArray ): ByteArray { TODO("Not yet implemented") } 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 2aa3b6227..f14269353 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 @@ -8,6 +8,7 @@ import cash.z.ecc.android.sdk.internal.model.WalletSummary import cash.z.ecc.android.sdk.model.Account import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.FirstClassByteArray +import cash.z.ecc.android.sdk.model.Proposal import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.WalletBalance import cash.z.ecc.android.sdk.model.ZcashNetwork @@ -23,16 +24,21 @@ internal interface TypesafeBackend { ): UnifiedSpendingKey @Suppress("LongParameterList") - suspend fun createToAddress( + suspend fun proposeTransfer( usk: UnifiedSpendingKey, to: String, value: Long, memo: ByteArray? = byteArrayOf() - ): FirstClassByteArray + ): Proposal - suspend fun shieldToAddress( + suspend fun proposeShielding( usk: UnifiedSpendingKey, memo: ByteArray? = byteArrayOf() + ): Proposal + + suspend fun createProposedTransaction( + proposal: Proposal, + usk: UnifiedSpendingKey ): FirstClassByteArray suspend fun getCurrentAddress(account: Account): 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 bc0f6d345..f89b874b9 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 @@ -9,6 +9,7 @@ import cash.z.ecc.android.sdk.internal.model.WalletSummary import cash.z.ecc.android.sdk.model.Account import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.FirstClassByteArray +import cash.z.ecc.android.sdk.model.Proposal import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.WalletBalance import cash.z.ecc.android.sdk.model.Zatoshi @@ -35,34 +36,43 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke } @Suppress("LongParameterList") - override suspend fun createToAddress( + override suspend fun proposeTransfer( usk: UnifiedSpendingKey, to: String, value: Long, memo: ByteArray? - ): FirstClassByteArray = - FirstClassByteArray( - backend.createToAddress( + ): Proposal = + Proposal.fromUnsafe( + backend.proposeTransfer( usk.account.value, - usk.copyBytes(), to, value, memo ) ) - override suspend fun shieldToAddress( + override suspend fun proposeShielding( usk: UnifiedSpendingKey, memo: ByteArray? - ): FirstClassByteArray = - FirstClassByteArray( - backend.shieldToAddress( + ): Proposal = + Proposal.fromUnsafe( + backend.proposeShielding( usk.account.value, - usk.copyBytes(), memo ) ) + override suspend fun createProposedTransaction( + proposal: Proposal, + usk: UnifiedSpendingKey + ): FirstClassByteArray = + FirstClassByteArray( + backend.createProposedTransaction( + proposal.toUnsafe(), + usk.copyBytes() + ) + ) + override suspend fun getCurrentAddress(account: Account): String { return backend.getCurrentAddress(account.value) } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt index 9059382eb..652112ede 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt @@ -137,12 +137,16 @@ internal class TransactionEncoderImpl( return try { saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory) Twig.debug { "params exist! attempting to send..." } - backend.createToAddress( - usk, - toAddress, - amount.value, - memo - ) + // TODO [#1359]: Expose the proposal in a way that enables querying its fee. + // TODO [#1359]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/1359 + val proposal = + backend.proposeTransfer( + usk, + toAddress, + amount.value, + memo + ) + backend.createProposedTransaction(proposal, usk) } catch (t: Throwable) { Twig.debug(t) { "Caught exception while creating transaction." } throw t @@ -159,10 +163,10 @@ internal class TransactionEncoderImpl( return try { saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory) Twig.debug { "params exist! attempting to shield..." } - backend.shieldToAddress( - usk, - memo - ) + // TODO [#1359]: Expose the proposal in a way that enables querying its fee. + // TODO [#1359]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/1359 + val proposal = backend.proposeShielding(usk, memo) + backend.createProposedTransaction(proposal, usk) } catch (t: Throwable) { // TODO [#680]: if this error matches: Insufficient balance (have 0, need 1000 including fee) // then consider custom error that says no UTXOs existed to shield diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/Proposal.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/Proposal.kt new file mode 100644 index 000000000..fc068f9b0 --- /dev/null +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/Proposal.kt @@ -0,0 +1,42 @@ +package cash.z.ecc.android.sdk.model + +import cash.z.ecc.android.sdk.internal.model.ProposalUnsafe + +/** + * A transaction proposal created by the Rust backend in response to a Kotlin request. + * + * @param inner the type-unsafe Proposal protobuf received across the FFI. + */ +class Proposal( + private val inner: ProposalUnsafe +) { + companion object { + /** + * @throws IllegalArgumentException if the proposal is invalid. + */ + @Throws(IllegalArgumentException::class) + fun fromUnsafe(proposal: ProposalUnsafe): Proposal { + val typed = Proposal(proposal) + + // Check for type errors eagerly, to ensure that the caller won't + // encounter these errors later. + typed.feeRequired() + + return typed + } + } + + /** + * Exposes the type-unsafe proposal variant for passing across the FFI. + */ + fun toUnsafe(): ProposalUnsafe { + return inner + } + + /** + * Returns the fee required by this proposal. + */ + fun feeRequired(): Zatoshi { + return Zatoshi(inner.feeRequired()) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 287fefa6c..ec87482cb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -230,6 +230,14 @@ dependencyResolutionManagement { ) ) + bundle( + "protobuf", + listOf( + "grpc-kotlin", + "grpc-protobuf", + ) + ) + bundle( "androidx-compose-core", listOf(