Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend-lib/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.proto

plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("zcash-sdk.android-conventions")

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")
Expand Down Expand Up @@ -56,6 +60,10 @@ android {
}
}

sourceSets.getByName("main") {
proto { srcDir("src/main/proto") }
}

lint {
baseline = File("lint-baseline.xml")
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be documented, or is it not exposed to the SDK user?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't intend for it to be exposed to the SDK user, but IDK if I succeeded within Kotlin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not ideal, as the class is still reachable from clients, but this is how we used to with, e.g., jni models too. We're helping ourselves with the internal in the package.

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)
Comment thread
str4d marked this conversation as resolved.
return ProposalUnsafe(inner)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Despite this being called ProposalUnsafe, I expect we'll do most validation in this class because we have access to the parsed protobuf (which can't be used outside backend-lib without requiring sdk-lib to depend on the protobuf library, and I figure it's better to keep it being a backend-internal detail). The wrapping Proposal in sdk-lib is wrapping the exposed types here in safe API types like Zatoshi.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is the way we do it with the other models as well.

}
}

/**
* 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
}
}
91 changes: 91 additions & 0 deletions backend-lib/src/main/proto/proposal.proto
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
str4d marked this conversation as resolved.
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;
}
Loading