From 0009674f88937f31cf8ff2721b8c7524235e972a Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Sat, 9 May 2026 13:33:13 +0200 Subject: [PATCH 1/5] Add voting bundle setup wrappers --- backend-lib/Cargo.lock | 1 + backend-lib/Cargo.toml | 2 + .../sdk/internal/jni/VotingRustBackendTest.kt | 88 ++++++++++++++++++ .../sdk/internal/jni/VotingRustBackend.kt | 44 +++++++++ .../internal/model/voting/FfiVotingModels.kt | 10 ++ backend-lib/src/main/rust/voting.rs | 5 +- backend-lib/src/main/rust/voting/helpers.rs | 70 +++++++++++++- backend-lib/src/main/rust/voting/json.rs | 92 +++++++++++++++++++ backend-lib/src/main/rust/voting/notes.rs | 59 ++++++++++++ backend-lib/src/main/rust/voting/rounds.rs | 19 ++++ .../sdk/internal/TypesafeVotingBackend.kt | 10 ++ .../sdk/internal/TypesafeVotingBackendImpl.kt | 13 +++ 12 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 backend-lib/src/main/rust/voting/notes.rs diff --git a/backend-lib/Cargo.lock b/backend-lib/Cargo.lock index c3135426c..c68af48c5 100644 --- a/backend-lib/Cargo.lock +++ b/backend-lib/Cargo.lock @@ -6698,6 +6698,7 @@ dependencies = [ "dlopen2", "eip681", "fs-mistrust", + "hex", "http", "http-body-util", "jni", diff --git a/backend-lib/Cargo.toml b/backend-lib/Cargo.toml index e0f15f880..76ecff5e9 100644 --- a/backend-lib/Cargo.toml +++ b/backend-lib/Cargo.toml @@ -56,6 +56,8 @@ uuid = "1" bitflags = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Explicit dependency for FFI-layer voting JSON encoding/decoding of binary fields. +hex = "0.4" # lightwalletd tonic = "0.14" diff --git a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt index 46bd2b60f..0312a3c5d 100644 --- a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt +++ b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt @@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.internal.jni import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundPhase import kotlinx.coroutines.test.runTest import org.json.JSONArray +import org.json.JSONObject import org.junit.Test import kotlin.io.path.createTempDirectory import kotlin.test.assertContentEquals @@ -19,6 +20,7 @@ class VotingRustBackendTest { private const val FIELD_BYTES = 32 private const val SHARE_INDEX = 5 private const val OUT_OF_RANGE_SHARE_INDEX = 16 + private const val DIVERSIFIER_BYTES = 11 private val VOTE_COMMITMENT = ByteArray(FIELD_BYTES) { 1 } private val BLIND = ByteArray(FIELD_BYTES) { 2 } private val SHORT_FIELD = ByteArray(FIELD_BYTES - 1) @@ -31,6 +33,10 @@ class VotingRustBackendTest { private const val SNAPSHOT_HEIGHT = 123_456L private const val JSON_ARRAY_START_INDEX = 0 private const val SESSION_JSON = "{\"round\":\"one\"}" + private const val NOTE_VALUE = 13_000_000L + private const val LARGE_BUNDLE_WEIGHT = 62_500_000L + private const val SMALL_BUNDLE_WEIGHT = 12_500_000L + private const val TWO_BUNDLE_ELIGIBLE_WEIGHT = 75_000_000L private val EA_PK = ByteArray(FIELD_BYTES) { 3 } private val NC_ROOT = ByteArray(FIELD_BYTES) { 4 } private val NULLIFIER_IMT_ROOT = ByteArray(FIELD_BYTES) { 5 } @@ -161,9 +167,91 @@ class VotingRustBackendTest { } } + @Test + fun compute_bundle_setup_returns_exact_weights() = + runTest { + val setup = VotingRustBackend.new().computeBundleSetup(notesJson(noteCount = 6)) + + assertEquals(2, setup.bundleCount) + assertEquals(TWO_BUNDLE_ELIGIBLE_WEIGHT, setup.eligibleWeight) + assertEquals(listOf(LARGE_BUNDLE_WEIGHT, SMALL_BUNDLE_WEIGHT), setup.bundleWeights) + assertEquals(setup.eligibleWeight, setup.bundleWeights.sum()) + } + + @Test + fun compute_bundle_setup_rejects_unknown_note_scope() = + runTest { + val notesJson = + JSONArray() + .put(noteJson(value = NOTE_VALUE, position = 0, byteValue = 1, scope = 2)) + .toString() + + assertFailsWith { + VotingRustBackend.new().computeBundleSetup(notesJson) + } + } + + @Test + fun setup_bundles_round_trips_bundle_count() = + runTest { + val db = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + try { + db.initRound( + roundId = ROUND_ID, + snapshotHeight = SNAPSHOT_HEIGHT, + eaPK = EA_PK, + ncRoot = NC_ROOT, + nullifierIMTRoot = NULLIFIER_IMT_ROOT, + sessionJson = null + ) + + val setup = db.setupBundles(ROUND_ID, notesJson(noteCount = 6)) + + assertEquals(2, setup.bundleCount) + assertEquals(TWO_BUNDLE_ELIGIBLE_WEIGHT, setup.eligibleWeight) + assertEquals(listOf(LARGE_BUNDLE_WEIGHT, SMALL_BUNDLE_WEIGHT), setup.bundleWeights) + assertEquals(setup.eligibleWeight, setup.bundleWeights.sum()) + assertEquals(2, db.getBundleCount(ROUND_ID)) + } finally { + db.close() + } + } + private fun newDbPath() = createTempDirectory("voting-db-").resolve("voting.db").toFile().absolutePath private fun JSONArray.toList(): List = (JSON_ARRAY_START_INDEX until length()).map { index -> get(index) } + + private fun notesJson( + noteCount: Int, + value: Long = NOTE_VALUE + ): String = + JSONArray() + .apply { + repeat(noteCount) { index -> + put(noteJson(value = value, position = index.toLong(), byteValue = index + 1)) + } + }.toString() + + private fun noteJson( + value: Long, + position: Long, + byteValue: Int, + scope: Int = 0 + ) = JSONObject() + .put("commitment", repeatedHex(byteValue)) + .put("nullifier", repeatedHex(byteValue + 1)) + .put("value", value) + .put("position", position) + .put("diversifier", repeatedHex(0, DIVERSIFIER_BYTES)) + .put("rho", repeatedHex(0)) + .put("rseed", repeatedHex(0)) + .put("scope", scope) + .put("ufvk_str", "") + + private fun repeatedHex( + byteValue: Int, + size: Int = FIELD_BYTES + ) = ByteArray(size) { byteValue.toByte() }.toHexString() } diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt index c4cd7b3f9..457329b0f 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt @@ -1,6 +1,7 @@ package cash.z.ecc.android.sdk.internal.jni import androidx.annotation.Keep +import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -17,6 +18,13 @@ class VotingRustBackend private constructor() { blind: ByteArray ): ByteArray = computeShareNullifierNative(voteCommitment, shareIndex, blind) + @Throws(RuntimeException::class) + suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult = + withContext(Dispatchers.IO) { + computeBundleSetupNative(notesJson) + ?: error("computeBundleSetup returned null") + } + suspend fun openVotingDb(dbPath: String, walletId: String): VotingDb = withContext(Dispatchers.IO) { openVotingDbNative(dbPath, walletId).let { dbHandle -> @@ -72,6 +80,16 @@ class VotingRustBackend private constructor() { suspend fun listRoundsJson(): String = withHandle { handle -> listRoundsJsonNative(handle) } + @Throws(RuntimeException::class) + suspend fun getBundleCount(roundId: String): Int = + withHandle { handle -> + getBundleCountNative(handle, roundId).also { count -> + check(count >= 0) { + "getBundleCount failed for roundId=$roundId" + } + } + } + @Throws(RuntimeException::class) suspend fun getVotesJson(roundId: String): String = withHandle { handle -> getVotesJsonNative(handle, roundId) } @@ -93,6 +111,16 @@ class VotingRustBackend private constructor() { } } + @Throws(RuntimeException::class) + suspend fun setupBundles( + roundId: String, + notesJson: String + ): FfiBundleSetupResult = + withHandle { handle -> + setupBundlesNative(handle, roundId, notesJson) + ?: error("setupBundles returned null for roundId=$roundId") + } + private suspend fun withHandle(block: (Long) -> T): T = accessMutex.withLock { val handle = @@ -148,6 +176,10 @@ class VotingRustBackend private constructor() { @Throws(RuntimeException::class) private external fun listRoundsJsonNative(dbHandle: Long): String + @JvmStatic + @Throws(RuntimeException::class) + private external fun getBundleCountNative(dbHandle: Long, roundId: String): Int + @JvmStatic @Throws(RuntimeException::class) private external fun getVotesJsonNative(dbHandle: Long, roundId: String): String @@ -163,5 +195,17 @@ class VotingRustBackend private constructor() { roundId: String, keepCount: Int ): Long + + @JvmStatic + @Throws(RuntimeException::class) + private external fun computeBundleSetupNative(notesJson: String): FfiBundleSetupResult? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun setupBundlesNative( + dbHandle: Long, + roundId: String, + notesJson: String + ): FfiBundleSetupResult? } } diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt index 618aa7e1d..62596890e 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt @@ -8,6 +8,16 @@ internal const val FFI_ROUND_PHASE_DELEGATION_CONSTRUCTED = 2 internal const val FFI_ROUND_PHASE_DELEGATION_PROVED = 3 internal const val FFI_ROUND_PHASE_VOTE_READY = 4 +@Keep +data class FfiBundleSetupResult( + val bundleCount: Int, + val eligibleWeight: Long, + val bundleWeights: List = emptyList() +) { + internal constructor(bundleCount: Int, eligibleWeight: Long, bundleWeights: LongArray) : + this(bundleCount, eligibleWeight, bundleWeights.toList()) +} + @Keep data class FfiRoundState( val roundId: String, diff --git a/backend-lib/src/main/rust/voting.rs b/backend-lib/src/main/rust/voting.rs index 6b23eaf99..9d159ada8 100644 --- a/backend-lib/src/main/rust/voting.rs +++ b/backend-lib/src/main/rust/voting.rs @@ -6,7 +6,7 @@ use jni::{ objects::{JByteArray, JClass, JObject, JString, JValue}, sys::{jboolean, jbyteArray, jint, jlong, jobject, jstring}, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, sync::{ @@ -17,7 +17,7 @@ use std::{ use zcash_voting as voting; use voting::storage::{RoundPhase, RoundState, RoundSummary, VoteRecord, VotingDb}; -use voting::types::VotingError; +use voting::types::{NoteInfo, VotingError}; use crate::utils::{ catch_unwind, exception::unwrap_exc_or, java_nullable_string_to_rust, java_string_to_rust, @@ -26,5 +26,6 @@ use crate::utils::{ mod db; mod helpers; mod json; +mod notes; mod rounds; mod share_tracking; diff --git a/backend-lib/src/main/rust/voting/helpers.rs b/backend-lib/src/main/rust/voting/helpers.rs index 66d665cda..26c250c16 100644 --- a/backend-lib/src/main/rust/voting/helpers.rs +++ b/backend-lib/src/main/rust/voting/helpers.rs @@ -1,4 +1,4 @@ -use super::json::round_phase_to_u32; +use super::json::*; use super::*; pub(super) const PROTOCOL_FIELD_BYTES: usize = 32; @@ -10,6 +10,24 @@ pub(super) fn jint_to_u32(value: jint, field: &str) -> anyhow::Result { u32::try_from(value).map_err(|_| anyhow!("{field} must be non-negative, got {value}")) } +pub(super) fn u32_to_jint(value: u32, field: &str) -> anyhow::Result { + i32::try_from(value) + .map(|v| v as jint) + .map_err(|_| anyhow!("{field} is too large for JNI Int: {value}")) +} + +pub(super) fn usize_to_jint(value: usize, field: &str) -> anyhow::Result { + i32::try_from(value) + .map(|v| v as jint) + .map_err(|_| anyhow!("{field} is too large for JNI Int: {value}")) +} + +pub(super) fn u64_to_jlong(value: u64, field: &str) -> anyhow::Result { + i64::try_from(value) + .map(|v| v as jlong) + .map_err(|_| anyhow!("{field} is too large for JNI Long: {value}")) +} + pub(super) fn jlong_to_u64(value: jlong, field: &str) -> anyhow::Result { u64::try_from(value).map_err(|_| anyhow!("{field} must be non-negative, got {value}")) } @@ -89,3 +107,53 @@ pub(super) fn make_ffi_round_state<'local>( )?; Ok(obj.into_raw()) } + +pub(super) fn make_ffi_bundle_setup_result<'local>( + env: &mut JNIEnv<'local>, + count: u32, + weight: u64, + bundle_weights: &[u64], +) -> anyhow::Result { + let class = + env.find_class("cash/z/ecc/android/sdk/internal/model/voting/FfiBundleSetupResult")?; + let weights = bundle_weights + .iter() + .enumerate() + .map(|(index, weight)| u64_to_jlong(*weight, &format!("bundle_weights[{index}]"))) + .collect::>>()?; + let weights_array = + env.new_long_array(usize_to_jint(weights.len(), "bundle_weights length")?)?; + env.set_long_array_region(&weights_array, 0, &weights)?; + let weights_array_obj = JObject::from(weights_array); + let obj = env.new_object( + &class, + "(IJ[J)V", + &[ + JValue::Int(u32_to_jint(count, "bundle_count")?), + JValue::Long(u64_to_jlong(weight, "eligible_weight")?), + JValue::Object(&weights_array_obj), + ], + )?; + Ok(obj.into_raw()) +} + +pub(super) fn bundle_setup_from_notes(notes: &[NoteInfo]) -> anyhow::Result<(u32, u64, Vec)> { + let chunk_result = voting::types::chunk_notes(notes); + let bundle_weights = chunk_result + .bundles + .iter() + .map(|bundle| { + let total = bundle.iter().try_fold(0u64, |acc, note| { + acc.checked_add(note.value) + .ok_or_else(|| anyhow!("bundle note value overflows u64")) + })?; + Ok((total / voting::BALLOT_DIVISOR) * voting::BALLOT_DIVISOR) + }) + .collect::>>()?; + Ok(( + u32::try_from(chunk_result.bundles.len()) + .map_err(|_| anyhow!("bundle count is too large for u32"))?, + chunk_result.eligible_weight, + bundle_weights, + )) +} diff --git a/backend-lib/src/main/rust/voting/json.rs b/backend-lib/src/main/rust/voting/json.rs index 52a00ba65..2c7e26323 100644 --- a/backend-lib/src/main/rust/voting/json.rs +++ b/backend-lib/src/main/rust/voting/json.rs @@ -1,3 +1,4 @@ +use super::helpers::*; use super::*; const PHASE_INITIALIZED: u32 = 0; @@ -5,6 +6,66 @@ const PHASE_HOTKEY_GENERATED: u32 = 1; const PHASE_DELEGATION_CONSTRUCTED: u32 = 2; const PHASE_DELEGATION_PROVED: u32 = 3; const PHASE_VOTE_READY: u32 = 4; +const NOTE_SCOPE_EXTERNAL: u32 = 0; +const NOTE_SCOPE_INTERNAL: u32 = 1; + +pub(super) fn hex_dec(value: &str, field: &str) -> anyhow::Result> { + hex::decode(value).map_err(|e| anyhow!("field '{field}': invalid hex: {e}")) +} + +#[derive(Serialize, Deserialize)] +pub(super) struct JsonNoteInfo { + pub(super) commitment: String, + pub(super) nullifier: String, + pub(super) value: u64, + pub(super) position: u64, + pub(super) diversifier: String, + pub(super) rho: String, + pub(super) rseed: String, + pub(super) scope: u32, + pub(super) ufvk_str: String, +} + +impl TryFrom for NoteInfo { + type Error = anyhow::Error; + + fn try_from(note: JsonNoteInfo) -> anyhow::Result { + let scope = require_note_scope(note.scope)?; + + Ok(NoteInfo { + commitment: require_len( + hex_dec(¬e.commitment, "commitment")?, + "commitment", + PROTOCOL_FIELD_BYTES, + )?, + nullifier: require_len( + hex_dec(¬e.nullifier, "nullifier")?, + "nullifier", + PROTOCOL_FIELD_BYTES, + )?, + value: note.value, + position: note.position, + diversifier: hex_dec(¬e.diversifier, "diversifier")?, + rho: require_len(hex_dec(¬e.rho, "rho")?, "rho", PROTOCOL_FIELD_BYTES)?, + rseed: require_len( + hex_dec(¬e.rseed, "rseed")?, + "rseed", + PROTOCOL_FIELD_BYTES, + )?, + scope, + ufvk_str: note.ufvk_str, + }) + } +} + +fn require_note_scope(scope: u32) -> anyhow::Result { + match scope { + NOTE_SCOPE_EXTERNAL | NOTE_SCOPE_INTERNAL => Ok(scope), + _ => Err(anyhow!( + "scope must be {NOTE_SCOPE_EXTERNAL} (external) or {NOTE_SCOPE_INTERNAL} (internal), got {scope}" + )), + } +} #[derive(Serialize)] pub(super) struct JsonRoundSummary { @@ -61,3 +122,34 @@ pub(super) fn json_to_jstring( let s = serde_json::to_string(value).map_err(|e| anyhow!("JSON serialization error: {}", e))?; Ok(env.new_string(s)?.into_raw()) } + +pub(super) fn json_from_jstring Deserialize<'de>>( + env: &mut JNIEnv<'_>, + value: &JString<'_>, + field: &str, +) -> anyhow::Result { + let s = java_string_to_rust(env, value)?; + serde_json::from_str(&s).map_err(|e| anyhow!("{field}: JSON parse error: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_note_info_rejects_unknown_scope() { + let note = JsonNoteInfo { + commitment: hex::encode([1u8; PROTOCOL_FIELD_BYTES]), + nullifier: hex::encode([2u8; PROTOCOL_FIELD_BYTES]), + value: 13_000_000, + position: 0, + diversifier: hex::encode([0u8; 11]), + rho: hex::encode([0u8; PROTOCOL_FIELD_BYTES]), + rseed: hex::encode([0u8; PROTOCOL_FIELD_BYTES]), + scope: 2, + ufvk_str: String::new(), + }; + + assert!(NoteInfo::try_from(note).is_err()); + } +} diff --git a/backend-lib/src/main/rust/voting/notes.rs b/backend-lib/src/main/rust/voting/notes.rs new file mode 100644 index 000000000..faa06c4ec --- /dev/null +++ b/backend-lib/src/main/rust/voting/notes.rs @@ -0,0 +1,59 @@ +use super::db::*; +use super::helpers::*; +use super::json::*; +use super::*; + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_computeBundleSetupNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + notes_json: JString<'local>, +) -> jobject { + let res = catch_unwind(&mut env, |env| { + let json_notes: Vec = json_from_jstring(env, ¬es_json, "notesJson")?; + let notes: Vec = json_notes + .into_iter() + .map(NoteInfo::try_from) + .collect::>()?; + let (count, weight, bundle_weights) = bundle_setup_from_notes(¬es)?; + make_ffi_bundle_setup_result(env, count, weight, &bundle_weights) + }); + unwrap_exc_or(&mut env, res, JObject::null().into_raw()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_setupBundlesNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_handle: jlong, + round_id: JString<'local>, + notes_json: JString<'local>, +) -> jobject { + let res = catch_unwind(&mut env, |env| { + let db = db_from_handle(db_handle)?; + let json_notes: Vec = json_from_jstring(env, ¬es_json, "notesJson")?; + let notes: Vec = json_notes + .into_iter() + .map(NoteInfo::try_from) + .collect::>()?; + let (expected_count, expected_weight, bundle_weights) = bundle_setup_from_notes(¬es)?; + let (count, weight) = db + .setup_bundles(&java_string_to_rust(env, &round_id)?, ¬es) + .map_err(|e| anyhow!("setup_bundles: {}", e))?; + if count != expected_count || weight != expected_weight { + return Err(anyhow!( + "setup_bundles result mismatch: db=({}, {}) chunk=({}, {})", + count, + weight, + expected_count, + expected_weight + )); + } + make_ffi_bundle_setup_result(env, count, weight, &bundle_weights) + }); + unwrap_exc_or(&mut env, res, JObject::null().into_raw()) +} diff --git a/backend-lib/src/main/rust/voting/rounds.rs b/backend-lib/src/main/rust/voting/rounds.rs index fdae5aee1..5930ec4f7 100644 --- a/backend-lib/src/main/rust/voting/rounds.rs +++ b/backend-lib/src/main/rust/voting/rounds.rs @@ -80,6 +80,25 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_lis unwrap_exc_or(&mut env, res, std::ptr::null_mut()) } +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_getBundleCountNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_handle: jlong, + round_id: JString<'local>, +) -> jint { + let res = catch_unwind(&mut env, |env| { + let db = db_from_handle(db_handle)?; + let count = db + .get_bundle_count(&java_string_to_rust(env, &round_id)?) + .map_err(|e| anyhow!("get_bundle_count: {}", e))?; + u32_to_jint(count, "bundle_count") + }); + unwrap_exc_or(&mut env, res, -1) +} + #[unsafe(no_mangle)] pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_getVotesJsonNative< 'local, diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt index 91885eeac..ad413aa98 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt @@ -1,11 +1,14 @@ package cash.z.ecc.android.sdk.internal +import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundSummary @Suppress("TooManyFunctions", "LongParameterList") interface TypesafeVotingBackend { suspend fun openVotingDb(dbPath: String, walletId: String): TypesafeVotingDb + + suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult } @Suppress("TooManyFunctions", "LongParameterList") @@ -25,6 +28,8 @@ interface TypesafeVotingDb { suspend fun listRounds(): List + suspend fun getBundleCount(roundId: String): Int + suspend fun getVotes(roundId: String): List suspend fun clearRound(roundId: String) @@ -33,6 +38,11 @@ interface TypesafeVotingDb { roundId: String, keepCount: Int ): Long + + suspend fun setupBundles( + roundId: String, + notesJson: String + ): FfiBundleSetupResult } data class VoteRecord( diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt index 7fd570423..90f93fac1 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt @@ -1,6 +1,7 @@ package cash.z.ecc.android.sdk.internal import cash.z.ecc.android.sdk.internal.jni.VotingRustBackend +import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundSummary import org.json.JSONArray @@ -9,6 +10,9 @@ import org.json.JSONArray class TypesafeVotingBackendImpl : TypesafeVotingBackend { override suspend fun openVotingDb(dbPath: String, walletId: String): TypesafeVotingDb = TypesafeVotingDbImpl(VotingRustBackend.new().openVotingDb(dbPath, walletId)) + + override suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult = + VotingRustBackend.new().computeBundleSetup(notesJson) } @Suppress("TooManyFunctions", "LongParameterList") @@ -46,6 +50,9 @@ private class TypesafeVotingDbImpl( ) } + override suspend fun getBundleCount(roundId: String): Int = + votingDb.getBundleCount(roundId) + override suspend fun getVotes(roundId: String): List = JSONArray(votingDb.getVotesJson(roundId)).toList { obj -> VoteRecord( @@ -63,6 +70,12 @@ private class TypesafeVotingDbImpl( roundId: String, keepCount: Int ): Long = votingDb.deleteSkippedBundles(roundId, keepCount) + + override suspend fun setupBundles( + roundId: String, + notesJson: String + ): FfiBundleSetupResult = + votingDb.setupBundles(roundId, notesJson) } private fun JSONArray.toList(transform: (org.json.JSONObject) -> T): List = From 11156b8b8c97ec58404239000d22a7cd3c7ed39e Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Sat, 9 May 2026 13:35:23 +0200 Subject: [PATCH 2/5] Add voting hotkey and cache wrappers --- .../sdk/internal/jni/VotingRustBackendTest.kt | 29 ++++++++ .../android/sdk/internal/jni/JniConstants.kt | 10 +++ .../sdk/internal/jni/VotingRustBackend.kt | 29 ++++++++ .../internal/model/voting/FfiVotingModels.kt | 68 +++++++++++++++++++ backend-lib/src/main/rust/voting.rs | 1 + backend-lib/src/main/rust/voting/helpers.rs | 44 ++++++++++++ backend-lib/src/main/rust/voting/notes.rs | 23 +++++++ backend-lib/src/main/rust/voting/util.rs | 15 ++++ .../sdk/internal/TypesafeVotingBackend.kt | 8 +++ .../sdk/internal/TypesafeVotingBackendImpl.kt | 10 +++ 10 files changed, 237 insertions(+) create mode 100644 backend-lib/src/main/rust/voting/util.rs diff --git a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt index 0312a3c5d..d653c4d25 100644 --- a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt +++ b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt @@ -12,6 +12,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalStdlibApi::class) @Suppress("MagicNumber") @@ -40,6 +41,8 @@ class VotingRustBackendTest { private val EA_PK = ByteArray(FIELD_BYTES) { 3 } private val NC_ROOT = ByteArray(FIELD_BYTES) { 4 } private val NULLIFIER_IMT_ROOT = ByteArray(FIELD_BYTES) { 5 } + private val HOTKEY_SEED = ByteArray(64) { 0x42 } + private val OTHER_HOTKEY_SEED = ByteArray(64) { 0x43 } } @Test @@ -217,6 +220,32 @@ class VotingRustBackendTest { } } + @Test + fun generate_hotkey_is_deterministic_and_rejects_short_seed() = + runTest { + val db = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + try { + val first = db.generateHotkey(ROUND_ID, HOTKEY_SEED) + val second = db.generateHotkey(ROUND_ID, HOTKEY_SEED) + val other = db.generateHotkey(ROUND_ID, OTHER_HOTKEY_SEED) + + assertContentEquals(first.secretKey.value, second.secretKey.value) + assertContentEquals(first.publicKey.value, second.publicKey.value) + assertEquals(first.address, second.address) + assertFalse(first.secretKey.value.contentEquals(other.secretKey.value)) + assertFalse(first.publicKey.value.contentEquals(other.publicKey.value)) + assertEquals(FIELD_BYTES, first.secretKey.value.size) + assertEquals(FIELD_BYTES, first.publicKey.value.size) + assertTrue(first.address.startsWith("sv1")) + + assertFailsWith { + db.generateHotkey(ROUND_ID, SHORT_FIELD) + } + } finally { + db.close() + } + } + private fun newDbPath() = createTempDirectory("voting-db-").resolve("voting.db").toFile().absolutePath diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt index 1158c5827..b17c381a3 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt @@ -22,3 +22,13 @@ const val JNI_METADATA_KEY_SK_SIZE = 32 * The number of bytes in a chain code. It's used e.g. in [JniMetadataKey.chainCode] */ const val JNI_METADATA_KEY_CHAIN_CODE_SIZE = 32 + +/** + * The number of bytes in a voting hotkey secret key. It's used e.g. in [HotkeySecretKey.value] + */ +const val JNI_HOTKEY_SECRET_KEY_BYTES_SIZE = 32 + +/** + * The number of bytes in a voting hotkey public key. It's used e.g. in [HotkeyPublicKey.value] + */ +const val JNI_HOTKEY_PUBLIC_KEY_BYTES_SIZE = 32 diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt index 457329b0f..ddb517026 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt @@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.internal.jni import androidx.annotation.Keep import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState +import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -25,6 +26,12 @@ class VotingRustBackend private constructor() { ?: error("computeBundleSetup returned null") } + @Throws(RuntimeException::class) + suspend fun warmProvingCaches() = + withContext(Dispatchers.IO) { + warmProvingCachesNative() + } + suspend fun openVotingDb(dbPath: String, walletId: String): VotingDb = withContext(Dispatchers.IO) { openVotingDbNative(dbPath, walletId).let { dbHandle -> @@ -121,6 +128,16 @@ class VotingRustBackend private constructor() { ?: error("setupBundles returned null for roundId=$roundId") } + @Throws(RuntimeException::class) + suspend fun generateHotkey( + roundId: String, + seed: ByteArray + ): FfiVotingHotkey = + withHandle { handle -> + generateHotkeyNative(handle, roundId, seed) + ?: error("generateHotkey returned null for roundId=$roundId") + } + private suspend fun withHandle(block: (Long) -> T): T = accessMutex.withLock { val handle = @@ -148,6 +165,10 @@ class VotingRustBackend private constructor() { blind: ByteArray ): ByteArray + @JvmStatic + @Throws(RuntimeException::class) + private external fun warmProvingCachesNative() + @JvmStatic @Throws(RuntimeException::class) private external fun openVotingDbNative(dbPath: String, walletId: String): Long @@ -207,5 +228,13 @@ class VotingRustBackend private constructor() { roundId: String, notesJson: String ): FfiBundleSetupResult? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun generateHotkeyNative( + dbHandle: Long, + roundId: String, + seed: ByteArray + ): FfiVotingHotkey? } } diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt index 62596890e..3d67cab1d 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt @@ -1,6 +1,74 @@ package cash.z.ecc.android.sdk.internal.model.voting import androidx.annotation.Keep +import cash.z.ecc.android.sdk.internal.jni.JNI_HOTKEY_PUBLIC_KEY_BYTES_SIZE +import cash.z.ecc.android.sdk.internal.jni.JNI_HOTKEY_SECRET_KEY_BYTES_SIZE + +@Keep +@ConsistentCopyVisibility +data class HotkeySecretKey internal constructor( + val value: ByteArray +) { + init { + require(value.size == JNI_HOTKEY_SECRET_KEY_BYTES_SIZE) { + "HotkeySecretKey must be $JNI_HOTKEY_SECRET_KEY_BYTES_SIZE bytes, got ${value.size}" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HotkeySecretKey) return false + return value.contentEquals(other.value) + } + + override fun hashCode(): Int = value.contentHashCode() + + // Do not include secret key bytes in logs. + override fun toString(): String = "HotkeySecretKey(size=${value.size})" + + companion object { + internal fun new(bytes: ByteArray) = HotkeySecretKey(bytes) + } +} + +@Keep +@ConsistentCopyVisibility +data class HotkeyPublicKey internal constructor( + val value: ByteArray +) { + init { + require(value.size == JNI_HOTKEY_PUBLIC_KEY_BYTES_SIZE) { + "HotkeyPublicKey must be $JNI_HOTKEY_PUBLIC_KEY_BYTES_SIZE bytes, got ${value.size}" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HotkeyPublicKey) return false + return value.contentEquals(other.value) + } + + override fun hashCode(): Int = value.contentHashCode() + + override fun toString(): String = "HotkeyPublicKey(${value.toHexString()})" + + companion object { + internal fun new(bytes: ByteArray) = HotkeyPublicKey(bytes) + } +} + +private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } + +@Keep +@ConsistentCopyVisibility +data class FfiVotingHotkey internal constructor( + val secretKey: HotkeySecretKey, + val publicKey: HotkeyPublicKey, + val address: String +) { + internal constructor(sk: ByteArray, pk: ByteArray, addr: String) : + this(HotkeySecretKey.new(sk), HotkeyPublicKey.new(pk), addr) +} internal const val FFI_ROUND_PHASE_INITIALIZED = 0 internal const val FFI_ROUND_PHASE_HOTKEY_GENERATED = 1 diff --git a/backend-lib/src/main/rust/voting.rs b/backend-lib/src/main/rust/voting.rs index 9d159ada8..0d8976d09 100644 --- a/backend-lib/src/main/rust/voting.rs +++ b/backend-lib/src/main/rust/voting.rs @@ -29,3 +29,4 @@ mod json; mod notes; mod rounds; mod share_tracking; +mod util; diff --git a/backend-lib/src/main/rust/voting/helpers.rs b/backend-lib/src/main/rust/voting/helpers.rs index 26c250c16..0f2787a30 100644 --- a/backend-lib/src/main/rust/voting/helpers.rs +++ b/backend-lib/src/main/rust/voting/helpers.rs @@ -43,6 +43,21 @@ pub(super) fn require_len(bytes: Vec, field: &str, expected: usize) -> anyho } } +pub(super) fn require_min_len( + bytes: Vec, + field: &str, + minimum: usize, +) -> anyhow::Result> { + if bytes.len() >= minimum { + Ok(bytes) + } else { + Err(anyhow!( + "{field} must be at least {minimum} bytes, got {}", + bytes.len() + )) + } +} + pub(super) fn java_bytes( env: &mut JNIEnv<'_>, array: &JByteArray<'_>, @@ -77,6 +92,15 @@ pub(super) fn fixed_bytes(bytes: Vec, field: &str) -> anyhow .map_err(|_| anyhow!("{field} must be exactly {N} bytes, got {len}")) } +pub(super) fn java_bytes_at_least( + env: &mut JNIEnv<'_>, + array: &JByteArray<'_>, + field: &str, + minimum: usize, +) -> anyhow::Result> { + require_min_len(java_bytes(env, array, field)?, field, minimum) +} + pub(super) fn make_ffi_round_state<'local>( env: &mut JNIEnv<'local>, state: RoundState, @@ -108,6 +132,26 @@ pub(super) fn make_ffi_round_state<'local>( Ok(obj.into_raw()) } +pub(super) fn make_ffi_voting_hotkey<'local>( + env: &mut JNIEnv<'local>, + hotkey: voting::types::VotingHotkey, +) -> anyhow::Result { + let class = env.find_class("cash/z/ecc/android/sdk/internal/model/voting/FfiVotingHotkey")?; + let sk_obj: JObject<'local> = env.byte_array_from_slice(&hotkey.secret_key)?.into(); + let pk_obj: JObject<'local> = env.byte_array_from_slice(&hotkey.public_key)?.into(); + let addr_obj: JObject<'local> = env.new_string(&hotkey.address)?.into(); + let obj = env.new_object( + &class, + "([B[BLjava/lang/String;)V", + &[ + JValue::Object(&sk_obj), + JValue::Object(&pk_obj), + JValue::Object(&addr_obj), + ], + )?; + Ok(obj.into_raw()) +} + pub(super) fn make_ffi_bundle_setup_result<'local>( env: &mut JNIEnv<'local>, count: u32, diff --git a/backend-lib/src/main/rust/voting/notes.rs b/backend-lib/src/main/rust/voting/notes.rs index faa06c4ec..a22ba5b1f 100644 --- a/backend-lib/src/main/rust/voting/notes.rs +++ b/backend-lib/src/main/rust/voting/notes.rs @@ -57,3 +57,26 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_set }); unwrap_exc_or(&mut env, res, JObject::null().into_raw()) } + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_generateHotkeyNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_handle: jlong, + round_id: JString<'local>, + seed: JByteArray<'local>, +) -> jobject { + let res = catch_unwind(&mut env, |env| { + let db = db_from_handle(db_handle)?; + let hotkey = db + .generate_hotkey( + &java_string_to_rust(env, &round_id)?, + &java_bytes_at_least(env, &seed, "seed", PROTOCOL_FIELD_BYTES)?, + ) + .map_err(|e| anyhow!("generate_hotkey: {}", e))?; + make_ffi_voting_hotkey(env, hotkey) + }); + unwrap_exc_or(&mut env, res, JObject::null().into_raw()) +} diff --git a/backend-lib/src/main/rust/voting/util.rs b/backend-lib/src/main/rust/voting/util.rs new file mode 100644 index 000000000..826ac08e4 --- /dev/null +++ b/backend-lib/src/main/rust/voting/util.rs @@ -0,0 +1,15 @@ +use super::*; + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_warmProvingCachesNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, +) { + let res = catch_unwind(&mut env, |_env| { + voting::warm_proving_caches(); + Ok(()) + }); + unwrap_exc_or(&mut env, res, ()) +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt index ad413aa98..f026ee634 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt @@ -3,12 +3,15 @@ package cash.z.ecc.android.sdk.internal import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundSummary +import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey @Suppress("TooManyFunctions", "LongParameterList") interface TypesafeVotingBackend { suspend fun openVotingDb(dbPath: String, walletId: String): TypesafeVotingDb suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult + + suspend fun warmProvingCaches() } @Suppress("TooManyFunctions", "LongParameterList") @@ -43,6 +46,11 @@ interface TypesafeVotingDb { roundId: String, notesJson: String ): FfiBundleSetupResult + + suspend fun generateHotkey( + roundId: String, + seed: ByteArray + ): FfiVotingHotkey } data class VoteRecord( diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt index 90f93fac1..ee7a648b6 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt @@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.internal.jni.VotingRustBackend import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundSummary +import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey import org.json.JSONArray @Suppress("TooManyFunctions", "LongParameterList") @@ -13,6 +14,9 @@ class TypesafeVotingBackendImpl : TypesafeVotingBackend { override suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult = VotingRustBackend.new().computeBundleSetup(notesJson) + + override suspend fun warmProvingCaches() = + VotingRustBackend.new().warmProvingCaches() } @Suppress("TooManyFunctions", "LongParameterList") @@ -76,6 +80,12 @@ private class TypesafeVotingDbImpl( notesJson: String ): FfiBundleSetupResult = votingDb.setupBundles(roundId, notesJson) + + override suspend fun generateHotkey( + roundId: String, + seed: ByteArray + ): FfiVotingHotkey = + votingDb.generateHotkey(roundId, seed) } private fun JSONArray.toList(transform: (org.json.JSONObject) -> T): List = From dfc415df5210af19432786092625eaaac5e4afae Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Sat, 9 May 2026 13:37:41 +0200 Subject: [PATCH 3/5] Add governance PCZT signing wrappers --- .../sdk/internal/jni/VotingRustBackendTest.kt | 74 +++++++++- .../sdk/internal/jni/VotingRustBackend.kt | 80 ++++++++++- backend-lib/src/main/rust/voting.rs | 7 +- backend-lib/src/main/rust/voting/db.rs | 4 +- .../src/main/rust/voting/delegation.rs | 109 ++++++++++++++ backend-lib/src/main/rust/voting/helpers.rs | 133 ++++++++++++++++-- backend-lib/src/main/rust/voting/json.rs | 26 ++++ backend-lib/src/main/rust/voting/notes.rs | 6 +- backend-lib/src/main/rust/voting/rounds.rs | 2 +- .../cash/z/ecc/android/sdk/ext/BlockExt.kt | 3 +- .../sdk/internal/TypesafeVotingBackend.kt | 56 +++++++- .../sdk/internal/TypesafeVotingBackendImpl.kt | 70 ++++++++- 12 files changed, 538 insertions(+), 32 deletions(-) create mode 100644 backend-lib/src/main/rust/voting/delegation.rs diff --git a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt index d653c4d25..8b59bf8c9 100644 --- a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt +++ b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt @@ -32,9 +32,15 @@ class VotingRustBackendTest { private const val OTHER_WALLET_ID = "wallet-2" private const val ROUND_ID = "round-1" private const val SNAPSHOT_HEIGHT = 123_456L - private const val JSON_ARRAY_START_INDEX = 0 private const val SESSION_JSON = "{\"round\":\"one\"}" + private const val TESTNET_NETWORK_ID = 0 + private const val ACCOUNT_INDEX = 0 + private const val ADDRESS_INDEX = 1 + private const val PCZT_ROUND_ID = + "0101010101010101010101010101010101010101010101010101010101010101" + private const val ROUND_NAME = "Test Round" private const val NOTE_VALUE = 13_000_000L + private const val PCZT_NOTE_VALUE = 15_000_000L private const val LARGE_BUNDLE_WEIGHT = 62_500_000L private const val SMALL_BUNDLE_WEIGHT = 12_500_000L private const val TWO_BUNDLE_ELIGIBLE_WEIGHT = 75_000_000L @@ -43,6 +49,7 @@ class VotingRustBackendTest { private val NULLIFIER_IMT_ROOT = ByteArray(FIELD_BYTES) { 5 } private val HOTKEY_SEED = ByteArray(64) { 0x42 } private val OTHER_HOTKEY_SEED = ByteArray(64) { 0x43 } + private val SEED_FINGERPRINT = ByteArray(FIELD_BYTES) { 6 } } @Test @@ -246,11 +253,74 @@ class VotingRustBackendTest { } } + @Test + fun build_governance_pczt_returns_parseable_pczt_and_extractable_sighash() = + runTest { + val backend = VotingRustBackend.new() + val db = backend.openVotingDb(newDbPath(), WALLET_ID) + try { + val notesJson = notesJson(noteCount = 6, value = PCZT_NOTE_VALUE) + val mismatchedNotesJson = notesJson(noteCount = 1, value = PCZT_NOTE_VALUE) + val ufvk = + RustDerivationTool + .new() + .deriveUnifiedFullViewingKeys(HOTKEY_SEED, TESTNET_NETWORK_ID, 1) + .first() + + db.initRound( + roundId = PCZT_ROUND_ID, + snapshotHeight = SNAPSHOT_HEIGHT, + eaPK = EA_PK, + ncRoot = NC_ROOT, + nullifierIMTRoot = NULLIFIER_IMT_ROOT, + sessionJson = null + ) + db.setupBundles(PCZT_ROUND_ID, notesJson) + + assertFailsWith { + db.buildTestGovernancePcztJson(ufvk, mismatchedNotesJson) + } + + val pcztJson = + JSONObject(db.buildTestGovernancePcztJson(ufvk, notesJson)) + val pcztBytes = pcztJson.getString("pczt_bytes").hexToByteArray() + val sighash = pcztJson.getString("pczt_sighash").hexToByteArray() + val extractedSighash = backend.extractPcztSighash(pcztBytes) + + assertTrue(pcztBytes.isNotEmpty()) + assertEquals(FIELD_BYTES, pcztJson.getString("rk").hexToByteArray().size) + assertEquals(FIELD_BYTES, sighash.size) + assertTrue(pcztJson.getInt("action_index") >= 0) + assertContentEquals(sighash, extractedSighash) + assertFailsWith { + backend.extractSpendAuthSig(pcztBytes, pcztJson.getInt("action_index")) + } + } finally { + db.close() + } + } + private fun newDbPath() = createTempDirectory("voting-db-").resolve("voting.db").toFile().absolutePath + private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePcztJson( + ufvk: String, + notesJson: String + ) = buildGovernancePcztJson( + roundId = PCZT_ROUND_ID, + bundleIndex = 1, + ufvk = ufvk, + networkId = TESTNET_NETWORK_ID, + accountIndex = ACCOUNT_INDEX, + notesJson = notesJson, + walletSeed = HOTKEY_SEED, + seedFingerprint = SEED_FINGERPRINT, + roundName = ROUND_NAME, + addressIndex = ADDRESS_INDEX + ) + private fun JSONArray.toList(): List = - (JSON_ARRAY_START_INDEX until length()).map { index -> get(index) } + (0 until length()).map { index -> get(index) } private fun notesJson( noteCount: Int, diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt index ddb517026..6fda102cc 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt @@ -13,11 +13,14 @@ import kotlinx.coroutines.withContext @Suppress("TooManyFunctions", "LongParameterList") class VotingRustBackend private constructor() { @Throws(RuntimeException::class) - fun computeShareNullifier( + suspend fun computeShareNullifier( voteCommitment: ByteArray, shareIndex: Int, blind: ByteArray - ): ByteArray = computeShareNullifierNative(voteCommitment, shareIndex, blind) + ): ByteArray = + withContext(Dispatchers.IO) { + computeShareNullifierNative(voteCommitment, shareIndex, blind) + } @Throws(RuntimeException::class) suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult = @@ -32,6 +35,23 @@ class VotingRustBackend private constructor() { warmProvingCachesNative() } + @Throws(RuntimeException::class) + suspend fun extractPcztSighash(pcztBytes: ByteArray): ByteArray = + withContext(Dispatchers.IO) { + extractPcztSighashNative(pcztBytes) + ?: error("extractPcztSighash returned null") + } + + @Throws(RuntimeException::class) + suspend fun extractSpendAuthSig( + signedPcztBytes: ByteArray, + actionIndex: Int + ): ByteArray = + withContext(Dispatchers.IO) { + extractSpendAuthSigNative(signedPcztBytes, actionIndex) + ?: error("extractSpendAuthSig returned null") + } + suspend fun openVotingDb(dbPath: String, walletId: String): VotingDb = withContext(Dispatchers.IO) { openVotingDbNative(dbPath, walletId).let { dbHandle -> @@ -138,6 +158,35 @@ class VotingRustBackend private constructor() { ?: error("generateHotkey returned null for roundId=$roundId") } + @Throws(RuntimeException::class) + suspend fun buildGovernancePcztJson( + roundId: String, + bundleIndex: Int, + ufvk: String, + networkId: Int, + accountIndex: Int, + notesJson: String, + walletSeed: ByteArray, + seedFingerprint: ByteArray, + roundName: String, + addressIndex: Int + ): String = + withHandle { handle -> + buildGovernancePcztJsonNative( + handle, + roundId, + bundleIndex, + ufvk, + networkId, + accountIndex, + notesJson, + walletSeed, + seedFingerprint, + roundName, + addressIndex + ) ?: error("buildGovernancePczt returned null") + } + private suspend fun withHandle(block: (Long) -> T): T = accessMutex.withLock { val handle = @@ -236,5 +285,32 @@ class VotingRustBackend private constructor() { roundId: String, seed: ByteArray ): FfiVotingHotkey? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun buildGovernancePcztJsonNative( + dbHandle: Long, + roundId: String, + bundleIndex: Int, + ufvk: String, + networkId: Int, + accountIndex: Int, + notesJson: String, + walletSeed: ByteArray, + seedFingerprint: ByteArray, + roundName: String, + addressIndex: Int + ): String? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun extractPcztSighashNative(pcztBytes: ByteArray): ByteArray? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun extractSpendAuthSigNative( + signedPcztBytes: ByteArray, + actionIndex: Int + ): ByteArray? } } diff --git a/backend-lib/src/main/rust/voting.rs b/backend-lib/src/main/rust/voting.rs index 0d8976d09..3ca7485ff 100644 --- a/backend-lib/src/main/rust/voting.rs +++ b/backend-lib/src/main/rust/voting.rs @@ -6,6 +6,8 @@ use jni::{ objects::{JByteArray, JClass, JObject, JString, JValue}, sys::{jboolean, jbyteArray, jint, jlong, jobject, jstring}, }; +use orchard::keys::Scope; +use secrecy::{ExposeSecret, SecretVec}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, @@ -14,16 +16,19 @@ use std::{ atomic::{AtomicI64, Ordering}, }, }; +use zcash_client_backend::keys::{UnifiedFullViewingKey, UnifiedSpendingKey}; +use zcash_protocol::consensus::{BranchId, Network, NetworkConstants}; use zcash_voting as voting; use voting::storage::{RoundPhase, RoundState, RoundSummary, VoteRecord, VotingDb}; -use voting::types::{NoteInfo, VotingError}; +use voting::types::{GovernancePczt, NoteInfo, VotingError}; use crate::utils::{ catch_unwind, exception::unwrap_exc_or, java_nullable_string_to_rust, java_string_to_rust, }; mod db; +mod delegation; mod helpers; mod json; mod notes; diff --git a/backend-lib/src/main/rust/voting/db.rs b/backend-lib/src/main/rust/voting/db.rs index 670db4747..86349cd75 100644 --- a/backend-lib/src/main/rust/voting/db.rs +++ b/backend-lib/src/main/rust/voting/db.rs @@ -9,9 +9,7 @@ fn registry() -> &'static Mutex>> { fn next_handle() -> anyhow::Result { NEXT_DB_HANDLE - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| { - id.checked_add(1).filter(|next| *next > 0) - }) + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)) .map_err(|_| anyhow!("voting DB handle space exhausted")) } diff --git a/backend-lib/src/main/rust/voting/delegation.rs b/backend-lib/src/main/rust/voting/delegation.rs new file mode 100644 index 000000000..6096a0510 --- /dev/null +++ b/backend-lib/src/main/rust/voting/delegation.rs @@ -0,0 +1,109 @@ +use super::db::*; +use super::helpers::*; +use super::json::*; +use super::*; + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_buildGovernancePcztJsonNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_handle: jlong, + round_id: JString<'local>, + bundle_index: jint, + ufvk: JString<'local>, + network_id: jint, + account_index: jint, + notes_json: JString<'local>, + wallet_seed: JByteArray<'local>, + seed_fingerprint: JByteArray<'local>, + round_name: JString<'local>, + address_index: jint, +) -> jstring { + let res = catch_unwind(&mut env, |env| { + let db = db_from_handle(db_handle)?; + let network = network_from_id(network_id)?; + let bundle_index = jint_to_u32(bundle_index, "bundle_index")?; + let account_index = jint_to_u32(account_index, "account_index")?; + let address_index = jint_to_u32(address_index, "address_index")?; + let ufvk_str = java_string_to_rust(env, &ufvk)?; + let fvk_bytes = orchard_fvk_bytes(&ufvk_str, network)?; + + let seed_bytes = + java_secret_bytes_at_least(env, &wallet_seed, "walletSeed", PROTOCOL_FIELD_BYTES)?; + let hotkey_raw_address = hotkey_orchard_raw_address_from_wallet_seed( + seed_bytes.expose_secret(), + network, + account_index, + address_index, + )?; + let seed_fingerprint = java_bytes32(env, &seed_fingerprint, "seedFingerprint")?; + + let json_notes: Vec = json_from_jstring(env, ¬es_json, "notesJson")?; + let notes: Vec = json_notes + .into_iter() + .map(NoteInfo::try_from) + .collect::>()?; + let bundle_notes = bundled_notes_for_index(¬es, bundle_index)?; + + let round_id = java_string_to_rust(env, &round_id)?; + let round_name = java_string_to_rust(env, &round_name)?; + let pczt = db + .build_governance_pczt( + &round_id, + bundle_index, + &bundle_notes, + &fvk_bytes, + &hotkey_raw_address, + nu6_branch_id(), + network.coin_type(), + &seed_fingerprint, + account_index, + &round_name, + address_index, + ) + .map_err(|e| anyhow!("build_governance_pczt: {}", e))?; + + json_to_jstring(env, &JsonGovernancePczt::try_from(pczt)?) + }); + unwrap_exc_or(&mut env, res, std::ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_extractPcztSighashNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + pczt_bytes: JByteArray<'local>, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + let bytes = java_bytes(env, &pczt_bytes, "pcztBytes")?; + let sighash = voting::action::extract_pczt_sighash(&bytes) + .map_err(|e| anyhow!("extract_pczt_sighash: {}", e))?; + Ok(env.byte_array_from_slice(&sighash)?.into_raw()) + }); + unwrap_exc_or(&mut env, res, std::ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_extractSpendAuthSigNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + signed_pczt_bytes: JByteArray<'local>, + action_index: jint, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + let bytes = java_bytes(env, &signed_pczt_bytes, "signedPcztBytes")?; + let sig = voting::action::extract_spend_auth_sig( + &bytes, + jint_to_usize(action_index, "action_index")?, + ) + .map_err(|e| anyhow!("extract_spend_auth_sig: {}", e))?; + Ok(env.byte_array_from_slice(&sig)?.into_raw()) + }); + unwrap_exc_or(&mut env, res, std::ptr::null_mut()) +} diff --git a/backend-lib/src/main/rust/voting/helpers.rs b/backend-lib/src/main/rust/voting/helpers.rs index 0f2787a30..95af71b15 100644 --- a/backend-lib/src/main/rust/voting/helpers.rs +++ b/backend-lib/src/main/rust/voting/helpers.rs @@ -1,15 +1,23 @@ use super::json::*; use super::*; +pub(super) const ORCHARD_RAW_ADDRESS_BYTES: usize = 43; +pub(super) const ORCHARD_FVK_BYTES: usize = 96; pub(super) const PROTOCOL_FIELD_BYTES: usize = 32; pub(super) const VOTE_COMMITMENT_BYTES: usize = PROTOCOL_FIELD_BYTES; pub(super) const BLIND_BYTES: usize = PROTOCOL_FIELD_BYTES; -pub(super) const SHARE_NULLIFIER_BYTES: usize = 32; +pub(super) const SHARE_NULLIFIER_BYTES: usize = PROTOCOL_FIELD_BYTES; +pub(super) const NETWORK_ID_TESTNET: jint = 0; +pub(super) const NETWORK_ID_MAINNET: jint = 1; pub(super) fn jint_to_u32(value: jint, field: &str) -> anyhow::Result { u32::try_from(value).map_err(|_| anyhow!("{field} must be non-negative, got {value}")) } +pub(super) fn jint_to_usize(value: jint, field: &str) -> anyhow::Result { + usize::try_from(value).map_err(|_| anyhow!("{field} must be non-negative, got {value}")) +} + pub(super) fn u32_to_jint(value: u32, field: &str) -> anyhow::Result { i32::try_from(value) .map(|v| v as jint) @@ -58,6 +66,16 @@ pub(super) fn require_min_len( } } +pub(super) fn require_32( + bytes: Vec, + field: &str, +) -> anyhow::Result<[u8; PROTOCOL_FIELD_BYTES]> { + let bytes = require_len(bytes, field, PROTOCOL_FIELD_BYTES)?; + bytes + .try_into() + .map_err(|_| anyhow!("{field} must be exactly {PROTOCOL_FIELD_BYTES} bytes")) +} + pub(super) fn java_bytes( env: &mut JNIEnv<'_>, array: &JByteArray<'_>, @@ -92,20 +110,73 @@ pub(super) fn fixed_bytes(bytes: Vec, field: &str) -> anyhow .map_err(|_| anyhow!("{field} must be exactly {N} bytes, got {len}")) } -pub(super) fn java_bytes_at_least( +pub(super) fn java_secret_bytes_at_least( env: &mut JNIEnv<'_>, array: &JByteArray<'_>, field: &str, minimum: usize, +) -> anyhow::Result> { + require_min_len(java_bytes(env, array, field)?, field, minimum).map(SecretVec::new) +} + +pub(super) fn java_bytes32( + env: &mut JNIEnv<'_>, + array: &JByteArray<'_>, + field: &str, +) -> anyhow::Result<[u8; PROTOCOL_FIELD_BYTES]> { + require_32(java_bytes(env, array, field)?, field) +} + +pub(super) fn network_from_id(id: jint) -> anyhow::Result { + match id { + NETWORK_ID_TESTNET => Ok(Network::TestNetwork), + NETWORK_ID_MAINNET => Ok(Network::MainNetwork), + _ => Err(anyhow!("invalid network_id {}", id)), + } +} + +pub(super) fn hotkey_orchard_raw_address_from_wallet_seed( + wallet_seed: &[u8], + network: Network, + account_index: u32, + address_index: u32, ) -> anyhow::Result> { - require_min_len(java_bytes(env, array, field)?, field, minimum) + let account_id = zip32::AccountId::try_from(account_index) + .map_err(|_| anyhow!("invalid account_index {}", account_index))?; + let usk = UnifiedSpendingKey::from_seed(&network, wallet_seed, account_id) + .map_err(|e| anyhow!("failed to derive hotkey USK from wallet seed: {}", e))?; + let fvk = usk.to_unified_full_viewing_key(); + let orchard_fvk = fvk + .orchard() + .ok_or_else(|| anyhow!("hotkey UFVK has no Orchard component"))?; + let addr = orchard_fvk.address_at(address_index, Scope::External); + require_len( + addr.to_raw_address_bytes().to_vec(), + "hotkey_raw_address", + ORCHARD_RAW_ADDRESS_BYTES, + ) +} + +pub(super) fn orchard_fvk_bytes(ufvk_str: &str, network: Network) -> anyhow::Result> { + let ufvk = UnifiedFullViewingKey::decode(&network, ufvk_str) + .map_err(|e| anyhow!("failed to decode UFVK: {}", e))?; + let fvk = ufvk + .orchard() + .ok_or_else(|| anyhow!("UFVK has no Orchard component"))?; + require_len(fvk.to_bytes().to_vec(), "orchard_fvk", ORCHARD_FVK_BYTES) +} + +// NU6 branch ID used by the governance PCZT signer path. Revisit this when +// the voting transaction format moves to a later consensus branch. +pub(super) fn nu6_branch_id() -> u32 { + BranchId::Nu6.into() } pub(super) fn make_ffi_round_state<'local>( env: &mut JNIEnv<'local>, state: RoundState, ) -> anyhow::Result { - let phase = round_phase_to_u32(state.phase) as i32; + let phase = round_phase_to_u32(state.phase); let class = env.find_class("cash/z/ecc/android/sdk/internal/model/voting/FfiRoundState")?; let round_id_obj: JObject<'local> = env.new_string(&state.round_id)?.into(); let hotkey_obj: JObject<'local> = match &state.hotkey_address { @@ -114,7 +185,11 @@ pub(super) fn make_ffi_round_state<'local>( }; let long_class = env.find_class("java/lang/Long")?; let weight_obj: JObject<'local> = match state.delegated_weight { - Some(w) => env.new_object(&long_class, "(J)V", &[JValue::Long(w as i64)])?, + Some(w) => env.new_object( + &long_class, + "(J)V", + &[JValue::Long(u64_to_jlong(w, "delegated_weight")?)], + )?, None => JObject::null(), }; let obj = env.new_object( @@ -122,8 +197,8 @@ pub(super) fn make_ffi_round_state<'local>( "(Ljava/lang/String;IJLjava/lang/String;Ljava/lang/Long;Z)V", &[ JValue::Object(&round_id_obj), - JValue::Int(phase), - JValue::Long(state.snapshot_height as i64), + JValue::Int(u32_to_jint(phase, "round_phase")?), + JValue::Long(u64_to_jlong(state.snapshot_height, "snapshot_height")?), JValue::Object(&hotkey_obj), JValue::Object(&weight_obj), JValue::Bool(state.proof_generated as jboolean), @@ -137,7 +212,10 @@ pub(super) fn make_ffi_voting_hotkey<'local>( hotkey: voting::types::VotingHotkey, ) -> anyhow::Result { let class = env.find_class("cash/z/ecc/android/sdk/internal/model/voting/FfiVotingHotkey")?; - let sk_obj: JObject<'local> = env.byte_array_from_slice(&hotkey.secret_key)?.into(); + let secret_key = SecretVec::new(hotkey.secret_key); + let sk_obj: JObject<'local> = env + .byte_array_from_slice(secret_key.expose_secret())? + .into(); let pk_obj: JObject<'local> = env.byte_array_from_slice(&hotkey.public_key)?.into(); let addr_obj: JObject<'local> = env.new_string(&hotkey.address)?.into(); let obj = env.new_object( @@ -201,3 +279,42 @@ pub(super) fn bundle_setup_from_notes(notes: &[NoteInfo]) -> anyhow::Result<(u32 bundle_weights, )) } + +pub(super) fn bundled_notes_for_index( + notes: &[NoteInfo], + bundle_index: u32, +) -> anyhow::Result> { + let chunk_result = voting::types::chunk_notes(notes); + let bundle_index = usize::try_from(bundle_index) + .map_err(|_| anyhow!("bundle_index is too large for this platform: {bundle_index}"))?; + + chunk_result + .bundles + .get(bundle_index) + .cloned() + .ok_or_else(|| anyhow!("bundle_index {bundle_index} is not present in note bundle set")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hotkey_orchard_raw_address_uses_address_index() { + let seed = [0x42_u8; 64]; + + let index_zero = + hotkey_orchard_raw_address_from_wallet_seed(&seed, Network::TestNetwork, 0, 0).unwrap(); + let index_one = + hotkey_orchard_raw_address_from_wallet_seed(&seed, Network::TestNetwork, 0, 1).unwrap(); + + assert_eq!(ORCHARD_RAW_ADDRESS_BYTES, index_zero.len()); + assert_eq!(ORCHARD_RAW_ADDRESS_BYTES, index_one.len()); + assert_ne!(index_zero, index_one); + } + + #[test] + fn nu6_branch_id_comes_from_protocol_crate() { + assert_eq!(nu6_branch_id(), u32::from(BranchId::Nu6)); + } +} diff --git a/backend-lib/src/main/rust/voting/json.rs b/backend-lib/src/main/rust/voting/json.rs index 2c7e26323..daf8abf07 100644 --- a/backend-lib/src/main/rust/voting/json.rs +++ b/backend-lib/src/main/rust/voting/json.rs @@ -9,6 +9,10 @@ const PHASE_VOTE_READY: u32 = 4; const NOTE_SCOPE_EXTERNAL: u32 = 0; const NOTE_SCOPE_INTERNAL: u32 = 1; +pub(super) fn hex_enc(bytes: &[u8]) -> String { + hex::encode(bytes) +} + pub(super) fn hex_dec(value: &str, field: &str) -> anyhow::Result> { hex::decode(value).map_err(|e| anyhow!("field '{field}': invalid hex: {e}")) } @@ -115,6 +119,28 @@ impl From for JsonVoteRecord { } } +#[derive(Serialize)] +pub(super) struct JsonGovernancePczt { + pub(super) pczt_bytes: String, + pub(super) rk: String, + pub(super) action_index: u32, + pub(super) pczt_sighash: String, +} + +impl TryFrom for JsonGovernancePczt { + type Error = anyhow::Error; + + fn try_from(pczt: GovernancePczt) -> anyhow::Result { + Ok(JsonGovernancePczt { + pczt_bytes: hex_enc(&pczt.pczt_bytes), + rk: hex_enc(&pczt.rk), + action_index: u32::try_from(pczt.action_index) + .map_err(|_| anyhow!("action_index is too large for u32: {}", pczt.action_index))?, + pczt_sighash: hex_enc(&pczt.pczt_sighash), + }) + } +} + pub(super) fn json_to_jstring( env: &mut JNIEnv<'_>, value: &T, diff --git a/backend-lib/src/main/rust/voting/notes.rs b/backend-lib/src/main/rust/voting/notes.rs index a22ba5b1f..1532d8f57 100644 --- a/backend-lib/src/main/rust/voting/notes.rs +++ b/backend-lib/src/main/rust/voting/notes.rs @@ -70,11 +70,9 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_gen ) -> jobject { let res = catch_unwind(&mut env, |env| { let db = db_from_handle(db_handle)?; + let seed = java_secret_bytes_at_least(env, &seed, "seed", PROTOCOL_FIELD_BYTES)?; let hotkey = db - .generate_hotkey( - &java_string_to_rust(env, &round_id)?, - &java_bytes_at_least(env, &seed, "seed", PROTOCOL_FIELD_BYTES)?, - ) + .generate_hotkey(&java_string_to_rust(env, &round_id)?, seed.expose_secret()) .map_err(|e| anyhow!("generate_hotkey: {}", e))?; make_ffi_voting_hotkey(env, hotkey) }); diff --git a/backend-lib/src/main/rust/voting/rounds.rs b/backend-lib/src/main/rust/voting/rounds.rs index 5930ec4f7..6c7097da5 100644 --- a/backend-lib/src/main/rust/voting/rounds.rs +++ b/backend-lib/src/main/rust/voting/rounds.rs @@ -157,7 +157,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_del jint_to_u32(keep_count, "keep_count")?, ) .map_err(|e| anyhow!("delete_skipped_bundles: {}", e))?; - Ok(deleted_rows as jlong) + u64_to_jlong(deleted_rows, "deleted_rows") }); unwrap_exc_or(&mut env, res, -1) } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt index fd093d701..0201a49ce 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt @@ -10,8 +10,7 @@ fun ByteArray.toHex(): String { return sb.toString() } -// Not used within the SDK, but is used by the Wallet app -@Suppress("unused", "MagicNumber") +@Suppress("MagicNumber") fun String.fromHex(): ByteArray { val len = length val data = ByteArray(len / 2) diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt index f026ee634..10354b64f 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackend.kt @@ -6,16 +6,29 @@ import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundSummary import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey @Suppress("TooManyFunctions", "LongParameterList") -interface TypesafeVotingBackend { +internal interface TypesafeVotingBackend { suspend fun openVotingDb(dbPath: String, walletId: String): TypesafeVotingDb + suspend fun computeShareNullifier( + voteCommitment: ByteArray, + shareIndex: Int, + blind: ByteArray + ): ByteArray + suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult suspend fun warmProvingCaches() + + suspend fun extractPcztSighash(pcztBytes: ByteArray): ByteArray + + suspend fun extractSpendAuthSig( + signedPcztBytes: ByteArray, + actionIndex: Int + ): ByteArray } @Suppress("TooManyFunctions", "LongParameterList") -interface TypesafeVotingDb { +internal interface TypesafeVotingDb { suspend fun close() suspend fun initRound( @@ -51,9 +64,46 @@ interface TypesafeVotingDb { roundId: String, seed: ByteArray ): FfiVotingHotkey + + suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + ufvk: String, + networkId: Int, + accountIndex: Int, + notesJson: String, + walletSeed: ByteArray, + seedFingerprint: ByteArray, + roundName: String, + addressIndex: Int + ): GovernancePcztResult +} + +internal data class GovernancePcztResult( + val pcztBytes: ByteArray, + val rk: ByteArray, + val sighash: ByteArray, + val actionIndex: Int +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GovernancePcztResult) return false + return pcztBytes.contentEquals(other.pcztBytes) && + rk.contentEquals(other.rk) && + sighash.contentEquals(other.sighash) && + actionIndex == other.actionIndex + } + + override fun hashCode(): Int { + var result = pcztBytes.contentHashCode() + result = 31 * result + rk.contentHashCode() + result = 31 * result + sighash.contentHashCode() + result = 31 * result + actionIndex + return result + } } -data class VoteRecord( +internal data class VoteRecord( val proposalId: Int, val bundleIndex: Int, val choice: Int, diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt index ee7a648b6..65beebab4 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt @@ -1,22 +1,47 @@ package cash.z.ecc.android.sdk.internal +import cash.z.ecc.android.sdk.ext.fromHex import cash.z.ecc.android.sdk.internal.jni.VotingRustBackend import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundSummary import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey import org.json.JSONArray +import org.json.JSONObject @Suppress("TooManyFunctions", "LongParameterList") -class TypesafeVotingBackendImpl : TypesafeVotingBackend { +internal class TypesafeVotingBackendImpl : TypesafeVotingBackend { + private val rustBackendLazy = + SuspendingLazy { + VotingRustBackend.new() + } + + override suspend fun computeShareNullifier( + voteCommitment: ByteArray, + shareIndex: Int, + blind: ByteArray + ): ByteArray = + rustBackend().computeShareNullifier(voteCommitment, shareIndex, blind) + override suspend fun openVotingDb(dbPath: String, walletId: String): TypesafeVotingDb = - TypesafeVotingDbImpl(VotingRustBackend.new().openVotingDb(dbPath, walletId)) + TypesafeVotingDbImpl(rustBackend().openVotingDb(dbPath, walletId)) override suspend fun computeBundleSetup(notesJson: String): FfiBundleSetupResult = - VotingRustBackend.new().computeBundleSetup(notesJson) + rustBackend().computeBundleSetup(notesJson) override suspend fun warmProvingCaches() = - VotingRustBackend.new().warmProvingCaches() + rustBackend().warmProvingCaches() + + override suspend fun extractPcztSighash(pcztBytes: ByteArray): ByteArray = + rustBackend().extractPcztSighash(pcztBytes) + + override suspend fun extractSpendAuthSig( + signedPcztBytes: ByteArray, + actionIndex: Int + ): ByteArray = + rustBackend().extractSpendAuthSig(signedPcztBytes, actionIndex) + + private suspend fun rustBackend() = rustBackendLazy.getInstance(Unit) } @Suppress("TooManyFunctions", "LongParameterList") @@ -86,14 +111,47 @@ private class TypesafeVotingDbImpl( seed: ByteArray ): FfiVotingHotkey = votingDb.generateHotkey(roundId, seed) + + override suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + ufvk: String, + networkId: Int, + accountIndex: Int, + notesJson: String, + walletSeed: ByteArray, + seedFingerprint: ByteArray, + roundName: String, + addressIndex: Int + ): GovernancePcztResult = + JSONObject( + votingDb.buildGovernancePcztJson( + roundId, + bundleIndex, + ufvk, + networkId, + accountIndex, + notesJson, + walletSeed, + seedFingerprint, + roundName, + addressIndex + ) + ).toGovernancePcztResult() } private fun JSONArray.toList(transform: (org.json.JSONObject) -> T): List = - (JSON_ARRAY_START_INDEX until length()).map { index -> + (0 until length()).map { index -> transform(getJSONObject(index)) } private fun org.json.JSONObject.getCheckedInt(name: String): Int = Math.toIntExact(getLong(name)) -private const val JSON_ARRAY_START_INDEX = 0 +private fun JSONObject.toGovernancePcztResult() = + GovernancePcztResult( + pcztBytes = getString("pczt_bytes").fromHex(), + rk = getString("rk").fromHex(), + sighash = getString("pczt_sighash").fromHex(), + actionIndex = getCheckedInt("action_index") + ) From 93cb7efac819bd2988588cc0a3efd8bb2c404ac1 Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Mon, 11 May 2026 00:50:44 +0200 Subject: [PATCH 4/5] Document voting dependency pin --- CHANGELOG.md | 3 ++- backend-lib/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9dca8e37..8790cf7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for snapshot-height consumers. ### Internal -- Added the Rust `zcash_voting` dependency foundation for future shielded voting backend work. +- Added internal `VotingRustBackend` / `TypesafeVotingBackend` plumbing for future shielded voting backend work. +- Pinned `orchard` to `=0.13.1` with `unstable-voting-circuits` to match `zcash_voting` / `voting-circuits` requirements. ### Changed - `Synchronizer.importAccountByUfvk` now calls `TypesafeBackend.rewindToChainState` after importing diff --git a/backend-lib/Cargo.toml b/backend-lib/Cargo.toml index 76ecff5e9..1846b6d41 100644 --- a/backend-lib/Cargo.toml +++ b/backend-lib/Cargo.toml @@ -85,8 +85,8 @@ rust-analyzer = "0.0.1" # # `zcash_voting` provides client-side primitives for shielded voting. It depends # on Orchard's unstable voting-circuits APIs, so the direct Orchard dependency is -# pinned to the same version and enables the same feature. Remove the exact pin -# once these circuit APIs are available through a stable Orchard feature. +# pinned to the same version and enables the same feature. Revisit the exact pin +# if the upstream voting crates relax their Orchard requirement. # The Android JNI boundary for voting code is `src/main/rust/voting.rs`. # Its share-nullifier entry point forwards to # `zcash_voting::share_tracking::compute_share_nullifier`: From 4c722653946008ea703196925e44118fadf3b2b2 Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Mon, 11 May 2026 00:50:58 +0200 Subject: [PATCH 5/5] Tighten voting JNI review fixes --- backend-lib/Cargo.lock | 1 + backend-lib/Cargo.toml | 1 + .../sdk/internal/jni/VotingRustBackendTest.kt | 219 ++++++++++++++-- .../android/sdk/internal/jni/JniConstants.kt | 10 + .../sdk/internal/jni/VotingRustBackend.kt | 7 +- .../internal/model/voting/FfiVotingModels.kt | 21 +- backend-lib/src/main/rust/voting.rs | 2 +- backend-lib/src/main/rust/voting/db.rs | 2 + .../src/main/rust/voting/delegation.rs | 85 +++++- backend-lib/src/main/rust/voting/helpers.rs | 247 +++++++++++++++++- backend-lib/src/main/rust/voting/json.rs | 17 +- backend-lib/src/main/rust/voting/notes.rs | 8 +- backend-lib/src/main/rust/voting/rounds.rs | 12 +- .../cash/z/ecc/android/sdk/ext/BlockExt.kt | 20 +- .../sdk/internal/TypesafeVotingBackendImpl.kt | 21 +- 15 files changed, 618 insertions(+), 55 deletions(-) diff --git a/backend-lib/Cargo.lock b/backend-lib/Cargo.lock index c68af48c5..6eee521bb 100644 --- a/backend-lib/Cargo.lock +++ b/backend-lib/Cargo.lock @@ -6693,6 +6693,7 @@ version = "2.4.4" dependencies = [ "anyhow", "bitflags 2.11.0", + "blake2b_simd", "bytes", "component", "dlopen2", diff --git a/backend-lib/Cargo.toml b/backend-lib/Cargo.toml index 1846b6d41..eb29ef323 100644 --- a/backend-lib/Cargo.toml +++ b/backend-lib/Cargo.toml @@ -54,6 +54,7 @@ anyhow = "1" jni = { version = "0.21", default-features = false } uuid = "1" bitflags = "2" +blake2b_simd = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" # Explicit dependency for FFI-layer voting JSON encoding/decoding of binary fields. diff --git a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt index 8b59bf8c9..407d315b0 100644 --- a/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt +++ b/backend-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackendTest.kt @@ -33,9 +33,11 @@ class VotingRustBackendTest { private const val ROUND_ID = "round-1" private const val SNAPSHOT_HEIGHT = 123_456L private const val SESSION_JSON = "{\"round\":\"one\"}" - private const val TESTNET_NETWORK_ID = 0 + private const val TESTNET_NETWORK_ID = JNI_VOTING_NETWORK_ID_TESTNET private const val ACCOUNT_INDEX = 0 private const val ADDRESS_INDEX = 1 + private const val MAINNET_NETWORK_ID = JNI_VOTING_NETWORK_ID_MAINNET + private const val SECOND_ROUND_ID = "round-2" private const val PCZT_ROUND_ID = "0101010101010101010101010101010101010101010101010101010101010101" private const val ROUND_NAME = "Test Round" @@ -79,6 +81,12 @@ class VotingRustBackendTest { } } + @Test + fun warm_proving_caches_smoke() = + runTest { + VotingRustBackend.new().warmProvingCaches() + } + @Test fun voting_db_round_state_round_trips() = runTest { @@ -111,6 +119,7 @@ class VotingRustBackendTest { assertEquals(FfiRoundPhase.INITIALIZED.value, round.getInt("phase")) assertEquals(SNAPSHOT_HEIGHT, round.getLong("snapshot_height")) + // Non-empty getVotesJson coverage belongs with the vote-insertion JNI wrapper. assertEquals(emptyList(), JSONArray(db.getVotesJson(ROUND_ID)).toList()) db.clearRound(ROUND_ID) @@ -144,6 +153,45 @@ class VotingRustBackendTest { } } + @Test + fun list_rounds_returns_all_rounds_for_current_wallet_only() = + runTest { + val dbPath = newDbPath() + val firstWallet = VotingRustBackend.new().openVotingDb(dbPath, WALLET_ID) + val secondWallet = VotingRustBackend.new().openVotingDb(dbPath, OTHER_WALLET_ID) + try { + firstWallet.initRound( + roundId = ROUND_ID, + snapshotHeight = SNAPSHOT_HEIGHT, + eaPK = EA_PK, + ncRoot = NC_ROOT, + nullifierIMTRoot = NULLIFIER_IMT_ROOT, + sessionJson = null + ) + firstWallet.initRound( + roundId = SECOND_ROUND_ID, + snapshotHeight = SNAPSHOT_HEIGHT, + eaPK = EA_PK, + ncRoot = NC_ROOT, + nullifierIMTRoot = NULLIFIER_IMT_ROOT, + sessionJson = null + ) + + val firstWalletRounds = + JSONArray(firstWallet.listRoundsJson()) + .toList() + .map { (it as JSONObject).getString("round_id") } + .toSet() + val secondWalletRounds = JSONArray(secondWallet.listRoundsJson()) + + assertEquals(setOf(ROUND_ID, SECOND_ROUND_ID), firstWalletRounds) + assertEquals(0, secondWalletRounds.length()) + } finally { + firstWallet.close() + secondWallet.close() + } + } + @Test fun voting_db_rejects_malformed_inputs_and_closed_handle() = runTest { @@ -201,6 +249,21 @@ class VotingRustBackendTest { } } + @Test + fun compute_bundle_setup_rejects_malformed_diversifier() = + runTest { + val notesJson = + JSONArray() + .put( + noteJson(value = NOTE_VALUE, position = 0, byteValue = 1) + .put("diversifier", repeatedHex(0, DIVERSIFIER_BYTES - 1)) + ).toString() + + assertFailsWith { + VotingRustBackend.new().computeBundleSetup(notesJson) + } + } + @Test fun setup_bundles_round_trips_bundle_count() = runTest { @@ -222,6 +285,10 @@ class VotingRustBackendTest { assertEquals(listOf(LARGE_BUNDLE_WEIGHT, SMALL_BUNDLE_WEIGHT), setup.bundleWeights) assertEquals(setup.eligibleWeight, setup.bundleWeights.sum()) assertEquals(2, db.getBundleCount(ROUND_ID)) + + val deletedRows = db.deleteSkippedBundles(ROUND_ID, keepCount = 1) + assertEquals(1L, deletedRows) + assertEquals(1, db.getBundleCount(ROUND_ID)) } finally { db.close() } @@ -232,6 +299,15 @@ class VotingRustBackendTest { runTest { val db = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) try { + db.initRound( + roundId = ROUND_ID, + snapshotHeight = SNAPSHOT_HEIGHT, + eaPK = EA_PK, + ncRoot = NC_ROOT, + nullifierIMTRoot = NULLIFIER_IMT_ROOT, + sessionJson = null + ) + val first = db.generateHotkey(ROUND_ID, HOTKEY_SEED) val second = db.generateHotkey(ROUND_ID, HOTKEY_SEED) val other = db.generateHotkey(ROUND_ID, OTHER_HOTKEY_SEED) @@ -244,6 +320,13 @@ class VotingRustBackendTest { assertEquals(FIELD_BYTES, first.secretKey.value.size) assertEquals(FIELD_BYTES, first.publicKey.value.size) assertTrue(first.address.startsWith("sv1")) + assertEquals( + FfiRoundPhase.HOTKEY_GENERATED, + assertNotNull(db.getRoundState(ROUND_ID)).roundPhase + ) + + first.secretKey.clear() + assertTrue(first.secretKey.value.all { it == 0.toByte() }) assertFailsWith { db.generateHotkey(ROUND_ID, SHORT_FIELD) @@ -254,32 +337,46 @@ class VotingRustBackendTest { } @Test - fun build_governance_pczt_returns_parseable_pczt_and_extractable_sighash() = + fun build_governance_pczt_rejects_mismatched_bundle_inputs_and_seed() = runTest { - val backend = VotingRustBackend.new() - val db = backend.openVotingDb(newDbPath(), WALLET_ID) + val db = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) try { val notesJson = notesJson(noteCount = 6, value = PCZT_NOTE_VALUE) val mismatchedNotesJson = notesJson(noteCount = 1, value = PCZT_NOTE_VALUE) - val ufvk = - RustDerivationTool - .new() - .deriveUnifiedFullViewingKeys(HOTKEY_SEED, TESTNET_NETWORK_ID, 1) - .first() - - db.initRound( - roundId = PCZT_ROUND_ID, - snapshotHeight = SNAPSHOT_HEIGHT, - eaPK = EA_PK, - ncRoot = NC_ROOT, - nullifierIMTRoot = NULLIFIER_IMT_ROOT, - sessionJson = null - ) - db.setupBundles(PCZT_ROUND_ID, notesJson) + val mismatchedSameIndexNotesJson = + notesJson(noteCount = 6, value = PCZT_NOTE_VALUE, positionOffset = 10) + val mismatchedSamePositionNotesJson = + notesJson(noteCount = 6, value = PCZT_NOTE_VALUE, ufvkString = "different") + val ufvk = deriveTestUfvk() + val mismatchedUfvk = deriveTestUfvk(seed = OTHER_HOTKEY_SEED) + db.initPcztRoundWithBundles(notesJson) assertFailsWith { db.buildTestGovernancePcztJson(ufvk, mismatchedNotesJson) } + assertFailsWith { + db.buildTestGovernancePcztJson(ufvk, mismatchedSameIndexNotesJson) + } + assertFailsWith { + db.buildTestGovernancePcztJson(ufvk, mismatchedSamePositionNotesJson) + } + assertFailsWith { + db.buildTestGovernancePcztJson(mismatchedUfvk, notesJson) + } + } finally { + db.close() + } + } + + @Test + fun build_governance_pczt_returns_parseable_pczt_and_extractable_sighash() = + runTest { + val backend = VotingRustBackend.new() + val db = backend.openVotingDb(newDbPath(), WALLET_ID) + try { + val notesJson = notesJson(noteCount = 6, value = PCZT_NOTE_VALUE) + val ufvk = deriveTestUfvk() + db.initPcztRoundWithBundles(notesJson) val pcztJson = JSONObject(db.buildTestGovernancePcztJson(ufvk, notesJson)) @@ -292,6 +389,13 @@ class VotingRustBackendTest { assertEquals(FIELD_BYTES, sighash.size) assertTrue(pcztJson.getInt("action_index") >= 0) assertContentEquals(sighash, extractedSighash) + assertEquals( + FfiRoundPhase.DELEGATION_CONSTRUCTED, + assertNotNull(db.getRoundState(PCZT_ROUND_ID)).roundPhase + ) + assertFailsWith { + db.generateHotkey(PCZT_ROUND_ID, HOTKEY_SEED) + } assertFailsWith { backend.extractSpendAuthSig(pcztBytes, pcztJson.getInt("action_index")) } @@ -300,20 +404,71 @@ class VotingRustBackendTest { } } + @Test + fun build_governance_pczt_accepts_mainnet_network_id() = + runTest { + val db = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + try { + val notesJson = notesJson(noteCount = 6, value = PCZT_NOTE_VALUE) + val ufvk = deriveTestUfvk(networkId = MAINNET_NETWORK_ID) + db.initPcztRoundWithBundles(notesJson) + + val pcztJson = + JSONObject( + db.buildTestGovernancePcztJson( + ufvk = ufvk, + notesJson = notesJson, + networkId = MAINNET_NETWORK_ID + ) + ) + + assertTrue(pcztJson.getString("pczt_bytes").hexToByteArray().isNotEmpty()) + } finally { + db.close() + } + } + private fun newDbPath() = createTempDirectory("voting-db-").resolve("voting.db").toFile().absolutePath + private suspend fun deriveTestUfvk( + seed: ByteArray = HOTKEY_SEED, + networkId: Int = TESTNET_NETWORK_ID + ): String = + RustDerivationTool + .new() + .deriveUnifiedFullViewingKeys(seed, networkId, 1) + .first() + + private suspend fun VotingRustBackend.VotingDb.initPcztRoundWithBundles( + notesJson: String, + roundId: String = PCZT_ROUND_ID + ) { + initRound( + roundId = roundId, + snapshotHeight = SNAPSHOT_HEIGHT, + eaPK = EA_PK, + ncRoot = NC_ROOT, + nullifierIMTRoot = NULLIFIER_IMT_ROOT, + sessionJson = null + ) + setupBundles(roundId, notesJson) + } + private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePcztJson( ufvk: String, - notesJson: String + notesJson: String, + walletSeed: ByteArray = HOTKEY_SEED, + networkId: Int = TESTNET_NETWORK_ID, + roundId: String = PCZT_ROUND_ID ) = buildGovernancePcztJson( - roundId = PCZT_ROUND_ID, + roundId = roundId, bundleIndex = 1, ufvk = ufvk, - networkId = TESTNET_NETWORK_ID, + networkId = networkId, accountIndex = ACCOUNT_INDEX, notesJson = notesJson, - walletSeed = HOTKEY_SEED, + walletSeed = walletSeed, seedFingerprint = SEED_FINGERPRINT, roundName = ROUND_NAME, addressIndex = ADDRESS_INDEX @@ -324,12 +479,21 @@ class VotingRustBackendTest { private fun notesJson( noteCount: Int, - value: Long = NOTE_VALUE + value: Long = NOTE_VALUE, + positionOffset: Long = 0, + ufvkString: String = "" ): String = JSONArray() .apply { repeat(noteCount) { index -> - put(noteJson(value = value, position = index.toLong(), byteValue = index + 1)) + put( + noteJson( + value = value, + position = positionOffset + index.toLong(), + byteValue = index + 1, + ufvkString = ufvkString + ) + ) } }.toString() @@ -337,7 +501,8 @@ class VotingRustBackendTest { value: Long, position: Long, byteValue: Int, - scope: Int = 0 + scope: Int = 0, + ufvkString: String = "" ) = JSONObject() .put("commitment", repeatedHex(byteValue)) .put("nullifier", repeatedHex(byteValue + 1)) @@ -347,7 +512,7 @@ class VotingRustBackendTest { .put("rho", repeatedHex(0)) .put("rseed", repeatedHex(0)) .put("scope", scope) - .put("ufvk_str", "") + .put("ufvk_str", ufvkString) private fun repeatedHex( byteValue: Int, diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt index b17c381a3..400f1cf04 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt @@ -32,3 +32,13 @@ const val JNI_HOTKEY_SECRET_KEY_BYTES_SIZE = 32 * The number of bytes in a voting hotkey public key. It's used e.g. in [HotkeyPublicKey.value] */ const val JNI_HOTKEY_PUBLIC_KEY_BYTES_SIZE = 32 + +/** + * Voting JNI network id for testnet. Matches [cash.z.ecc.android.sdk.model.ZcashNetwork.ID_TESTNET]. + */ +const val JNI_VOTING_NETWORK_ID_TESTNET = 0 + +/** + * Voting JNI network id for mainnet. Matches [cash.z.ecc.android.sdk.model.ZcashNetwork.ID_MAINNET]. + */ +const val JNI_VOTING_NETWORK_ID_MAINNET = 1 diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt index 6fda102cc..9b643de59 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/VotingRustBackend.kt @@ -1,6 +1,7 @@ package cash.z.ecc.android.sdk.internal.jni import androidx.annotation.Keep +import cash.z.ecc.android.sdk.internal.SdkDispatchers import cash.z.ecc.android.sdk.internal.model.voting.FfiBundleSetupResult import cash.z.ecc.android.sdk.internal.model.voting.FfiRoundState import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey @@ -53,7 +54,7 @@ class VotingRustBackend private constructor() { } suspend fun openVotingDb(dbPath: String, walletId: String): VotingDb = - withContext(Dispatchers.IO) { + withContext(SdkDispatchers.DATABASE_IO) { openVotingDbNative(dbPath, walletId).let { dbHandle -> check(dbHandle != 0L) { "openVotingDb failed for dbPath=$dbPath" @@ -71,7 +72,7 @@ class VotingRustBackend private constructor() { suspend fun close() { accessMutex.withLock { dbHandle?.let { handle -> - withContext(Dispatchers.IO) { + withContext(SdkDispatchers.DATABASE_IO) { closeVotingDbNative(handle) } dbHandle = null @@ -193,7 +194,7 @@ class VotingRustBackend private constructor() { checkNotNull(dbHandle) { "Voting DB handle is closed" } - withContext(Dispatchers.IO) { + withContext(SdkDispatchers.DATABASE_IO) { block(handle) } } diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt index 3d67cab1d..2de4d7c4f 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/voting/FfiVotingModels.kt @@ -4,17 +4,36 @@ import androidx.annotation.Keep import cash.z.ecc.android.sdk.internal.jni.JNI_HOTKEY_PUBLIC_KEY_BYTES_SIZE import cash.z.ecc.android.sdk.internal.jni.JNI_HOTKEY_SECRET_KEY_BYTES_SIZE +/** + * JVM-owned voting hotkey secret bytes. + * + * Call [useSecret], [clear], or [close] as soon as the secret is no longer needed. The JVM + * byte array cannot be zeroized automatically by Rust after it crosses JNI. + */ @Keep @ConsistentCopyVisibility data class HotkeySecretKey internal constructor( val value: ByteArray -) { +) : AutoCloseable { init { require(value.size == JNI_HOTKEY_SECRET_KEY_BYTES_SIZE) { "HotkeySecretKey must be $JNI_HOTKEY_SECRET_KEY_BYTES_SIZE bytes, got ${value.size}" } } + fun clear() { + value.fill(0) + } + + fun useSecret(block: (ByteArray) -> T): T = + try { + block(value) + } finally { + clear() + } + + override fun close() = clear() + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is HotkeySecretKey) return false diff --git a/backend-lib/src/main/rust/voting.rs b/backend-lib/src/main/rust/voting.rs index 3ca7485ff..2d388573b 100644 --- a/backend-lib/src/main/rust/voting.rs +++ b/backend-lib/src/main/rust/voting.rs @@ -21,7 +21,7 @@ use zcash_protocol::consensus::{BranchId, Network, NetworkConstants}; use zcash_voting as voting; use voting::storage::{RoundPhase, RoundState, RoundSummary, VoteRecord, VotingDb}; -use voting::types::{GovernancePczt, NoteInfo, VotingError}; +use voting::types::{GovernancePczt, NoteInfo}; use crate::utils::{ catch_unwind, exception::unwrap_exc_or, java_nullable_string_to_rust, java_string_to_rust, diff --git a/backend-lib/src/main/rust/voting/db.rs b/backend-lib/src/main/rust/voting/db.rs index 86349cd75..1961d9f78 100644 --- a/backend-lib/src/main/rust/voting/db.rs +++ b/backend-lib/src/main/rust/voting/db.rs @@ -1,3 +1,4 @@ +use super::helpers::*; use super::*; static NEXT_DB_HANDLE: AtomicI64 = AtomicI64::new(1); @@ -44,6 +45,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_ope let db = VotingDb::open(&path).map_err(|e| anyhow!("VotingDb::open failed: {}", e))?; db.set_wallet_id(&wallet_id); + init_voting_android_tables(&db)?; let handle = next_handle()?; registry() .lock() diff --git a/backend-lib/src/main/rust/voting/delegation.rs b/backend-lib/src/main/rust/voting/delegation.rs index 6096a0510..7c1a393fc 100644 --- a/backend-lib/src/main/rust/voting/delegation.rs +++ b/backend-lib/src/main/rust/voting/delegation.rs @@ -32,6 +32,13 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui let seed_bytes = java_secret_bytes_at_least(env, &wallet_seed, "walletSeed", PROTOCOL_FIELD_BYTES)?; + let derived_fvk_bytes = + orchard_fvk_bytes_from_wallet_seed(seed_bytes.expose_secret(), network, account_index)?; + if derived_fvk_bytes != fvk_bytes { + return Err(anyhow!( + "ufvk does not match walletSeed for network_id={network_id} account_index={account_index}" + )); + } let hotkey_raw_address = hotkey_orchard_raw_address_from_wallet_seed( seed_bytes.expose_secret(), network, @@ -48,6 +55,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui let bundle_notes = bundled_notes_for_index(¬es, bundle_index)?; let round_id = java_string_to_rust(env, &round_id)?; + require_persisted_bundle_notes(&db, &round_id, bundle_index, &bundle_notes)?; let round_name = java_string_to_rust(env, &round_name)?; let pczt = db .build_governance_pczt( @@ -64,6 +72,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui address_index, ) .map_err(|e| anyhow!("build_governance_pczt: {}", e))?; + update_round_phase_forward(&db, &round_id, RoundPhase::DelegationConstructed)?; json_to_jstring(env, &JsonGovernancePczt::try_from(pczt)?) }); @@ -98,12 +107,78 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_ext ) -> jbyteArray { let res = catch_unwind(&mut env, |env| { let bytes = java_bytes(env, &signed_pczt_bytes, "signedPcztBytes")?; - let sig = voting::action::extract_spend_auth_sig( - &bytes, - jint_to_usize(action_index, "action_index")?, - ) - .map_err(|e| anyhow!("extract_spend_auth_sig: {}", e))?; + let action_index = jint_to_usize(action_index, "action_index")?; + let sig = voting::action::extract_spend_auth_sig(&bytes, action_index) + .map_err(|e| anyhow!("extract_spend_auth_sig: {}", e))?; Ok(env.byte_array_from_slice(&sig)?.into_raw()) }); unwrap_exc_or(&mut env, res, std::ptr::null_mut()) } + +#[cfg(test)] +mod tests { + use super::*; + use orchard::keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}; + use voting::types::VotingRoundParams; + + #[test] + fn extract_spend_auth_sig_accepts_signed_governance_pczt() { + let spending_key = SpendingKey::from_bytes([0x42; 32]).expect("valid spending key"); + let fvk = FullViewingKey::from(&spending_key); + let hotkey_spending_key = SpendingKey::from_bytes([0x43; 32]).expect("valid hotkey"); + let hotkey_fvk = FullViewingKey::from(&hotkey_spending_key); + let hotkey_address = hotkey_fvk + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + .to_vec(); + let result = voting::action::build_governance_pczt( + &[note_info()], + &round_params(), + &fvk.to_bytes().to_vec(), + &hotkey_address, + nu6_branch_id(), + Network::TestNetwork.coin_type(), + &[0xAA; 32], + 0, + "Test Round", + ) + .expect("governance PCZT"); + + let pczt = pczt::Pczt::parse(&result.pczt_bytes).expect("parse PCZT"); + let mut signer = pczt::roles::signer::Signer::new(pczt).expect("signer"); + let spend_authorizing_key = SpendAuthorizingKey::from(&spending_key); + signer + .sign_orchard(result.action_index, &spend_authorizing_key) + .expect("sign orchard action"); + let signed_pczt = signer.finish().serialize(); + let sig = + voting::action::extract_spend_auth_sig(&signed_pczt, result.action_index).unwrap(); + + assert_ne!(sig, [0u8; 64]); + } + + fn note_info() -> NoteInfo { + NoteInfo { + commitment: vec![1; PROTOCOL_FIELD_BYTES], + nullifier: vec![2; PROTOCOL_FIELD_BYTES], + value: 15_000_000, + position: 0, + diversifier: vec![0; 11], + rho: vec![0; PROTOCOL_FIELD_BYTES], + rseed: vec![0; PROTOCOL_FIELD_BYTES], + scope: 0, + ufvk_str: String::new(), + } + } + + fn round_params() -> VotingRoundParams { + VotingRoundParams { + vote_round_id: "0101010101010101010101010101010101010101010101010101010101010101" + .to_string(), + snapshot_height: 100_000, + ea_pk: vec![0xEA; PROTOCOL_FIELD_BYTES], + nc_root: vec![0x01; PROTOCOL_FIELD_BYTES], + nullifier_imt_root: vec![0x02; PROTOCOL_FIELD_BYTES], + } + } +} diff --git a/backend-lib/src/main/rust/voting/helpers.rs b/backend-lib/src/main/rust/voting/helpers.rs index 95af71b15..c60c2e077 100644 --- a/backend-lib/src/main/rust/voting/helpers.rs +++ b/backend-lib/src/main/rust/voting/helpers.rs @@ -7,8 +7,12 @@ pub(super) const PROTOCOL_FIELD_BYTES: usize = 32; pub(super) const VOTE_COMMITMENT_BYTES: usize = PROTOCOL_FIELD_BYTES; pub(super) const BLIND_BYTES: usize = PROTOCOL_FIELD_BYTES; pub(super) const SHARE_NULLIFIER_BYTES: usize = PROTOCOL_FIELD_BYTES; +pub(super) const HOTKEY_SECRET_KEY_BYTES: usize = PROTOCOL_FIELD_BYTES; +pub(super) const HOTKEY_PUBLIC_KEY_BYTES: usize = PROTOCOL_FIELD_BYTES; pub(super) const NETWORK_ID_TESTNET: jint = 0; pub(super) const NETWORK_ID_MAINNET: jint = 1; +const NOTE_IDENTITY_HASH_BYTES: usize = PROTOCOL_FIELD_BYTES; +const NOTE_IDENTITY_DOMAIN: &[u8] = b"zcash-android-voting-note-v1"; pub(super) fn jint_to_u32(value: jint, field: &str) -> anyhow::Result { u32::try_from(value).map_err(|_| anyhow!("{field} must be non-negative, got {value}")) @@ -157,6 +161,26 @@ pub(super) fn hotkey_orchard_raw_address_from_wallet_seed( ) } +pub(super) fn orchard_fvk_bytes_from_wallet_seed( + wallet_seed: &[u8], + network: Network, + account_index: u32, +) -> anyhow::Result> { + let account_id = zip32::AccountId::try_from(account_index) + .map_err(|_| anyhow!("invalid account_index {}", account_index))?; + let usk = UnifiedSpendingKey::from_seed(&network, wallet_seed, account_id) + .map_err(|e| anyhow!("failed to derive USK from wallet seed: {}", e))?; + let ufvk = usk.to_unified_full_viewing_key(); + let orchard_fvk = ufvk + .orchard() + .ok_or_else(|| anyhow!("derived UFVK has no Orchard component"))?; + require_len( + orchard_fvk.to_bytes().to_vec(), + "derived_orchard_fvk", + ORCHARD_FVK_BYTES, + ) +} + pub(super) fn orchard_fvk_bytes(ufvk_str: &str, network: Network) -> anyhow::Result> { let ufvk = UnifiedFullViewingKey::decode(&network, ufvk_str) .map_err(|e| anyhow!("failed to decode UFVK: {}", e))?; @@ -212,11 +236,20 @@ pub(super) fn make_ffi_voting_hotkey<'local>( hotkey: voting::types::VotingHotkey, ) -> anyhow::Result { let class = env.find_class("cash/z/ecc/android/sdk/internal/model/voting/FfiVotingHotkey")?; - let secret_key = SecretVec::new(hotkey.secret_key); + let secret_key = SecretVec::new(require_len( + hotkey.secret_key, + "hotkey_secret_key", + HOTKEY_SECRET_KEY_BYTES, + )?); + let public_key = require_len( + hotkey.public_key, + "hotkey_public_key", + HOTKEY_PUBLIC_KEY_BYTES, + )?; let sk_obj: JObject<'local> = env .byte_array_from_slice(secret_key.expose_secret())? .into(); - let pk_obj: JObject<'local> = env.byte_array_from_slice(&hotkey.public_key)?.into(); + let pk_obj: JObject<'local> = env.byte_array_from_slice(&public_key)?.into(); let addr_obj: JObject<'local> = env.new_string(&hotkey.address)?.into(); let obj = env.new_object( &class, @@ -280,6 +313,90 @@ pub(super) fn bundle_setup_from_notes(notes: &[NoteInfo]) -> anyhow::Result<(u32 )) } +fn update_hash_with_len_prefixed_bytes(state: &mut blake2b_simd::State, value: &[u8]) { + state.update(&(value.len() as u64).to_le_bytes()); + state.update(value); +} + +fn note_identity_hash(note: &NoteInfo) -> [u8; NOTE_IDENTITY_HASH_BYTES] { + let mut state = blake2b_simd::Params::new() + .hash_length(NOTE_IDENTITY_HASH_BYTES) + .to_state(); + state.update(NOTE_IDENTITY_DOMAIN); + state.update(¬e.position.to_le_bytes()); + state.update(¬e.value.to_le_bytes()); + state.update(¬e.scope.to_le_bytes()); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.commitment); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.nullifier); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.diversifier); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.rho); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.rseed); + update_hash_with_len_prefixed_bytes(&mut state, note.ufvk_str.as_bytes()); + + let hash = state.finalize(); + let mut out = [0u8; NOTE_IDENTITY_HASH_BYTES]; + out.copy_from_slice(hash.as_bytes()); + out +} + +pub(super) fn init_voting_android_tables(db: &VotingDb) -> anyhow::Result<()> { + let conn = db.conn(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS android_voting_bundle_note_identity ( + round_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + note_order INTEGER NOT NULL, + position INTEGER NOT NULL, + identity_hash BLOB NOT NULL, + PRIMARY KEY (round_id, wallet_id, bundle_index, note_order), + FOREIGN KEY (round_id, wallet_id, bundle_index) + REFERENCES bundles(round_id, wallet_id, bundle_index) ON DELETE CASCADE + );", + ) + .map_err(|e| anyhow!("failed to initialize Android voting tables: {e}")) +} + +pub(super) fn store_bundle_note_identities( + db: &VotingDb, + round_id: &str, + notes: &[NoteInfo], +) -> anyhow::Result<()> { + let conn = db.conn(); + let wallet_id = db.wallet_id(); + conn.execute( + "DELETE FROM android_voting_bundle_note_identity WHERE round_id = ?1 AND wallet_id = ?2", + rusqlite::params![round_id, wallet_id], + ) + .map_err(|e| anyhow!("failed to clear bundle note identities: {e}"))?; + + let chunk_result = voting::types::chunk_notes(notes); + for (bundle_index, bundle) in chunk_result.bundles.iter().enumerate() { + let bundle_index = i64::try_from(bundle_index) + .map_err(|_| anyhow!("bundle_index is too large for SQLite"))?; + for (note_order, note) in bundle.iter().enumerate() { + let note_order = i64::try_from(note_order) + .map_err(|_| anyhow!("note_order is too large for SQLite"))?; + conn.execute( + "INSERT INTO android_voting_bundle_note_identity + (round_id, wallet_id, bundle_index, note_order, position, identity_hash) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + round_id, + wallet_id, + bundle_index, + note_order, + u64_to_jlong(note.position, "note.position")?, + note_identity_hash(note).to_vec(), + ], + ) + .map_err(|e| anyhow!("failed to store bundle note identity: {e}"))?; + } + } + + Ok(()) +} + pub(super) fn bundled_notes_for_index( notes: &[NoteInfo], bundle_index: u32, @@ -295,6 +412,132 @@ pub(super) fn bundled_notes_for_index( .ok_or_else(|| anyhow!("bundle_index {bundle_index} is not present in note bundle set")) } +pub(super) fn require_persisted_bundle_notes( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + bundle_notes: &[NoteInfo], +) -> anyhow::Result<()> { + let stored_positions = { + let conn = db.conn(); + let wallet_id = db.wallet_id(); + voting::storage::queries::load_bundle_note_positions( + &conn, + round_id, + &wallet_id, + bundle_index, + ) + .map_err(|e| anyhow!("load_bundle_note_positions: {}", e))? + }; + let requested_positions = bundle_notes + .iter() + .map(|note| note.position) + .collect::>(); + + if stored_positions == requested_positions { + require_persisted_bundle_note_identities(db, round_id, bundle_index, bundle_notes) + } else { + Err(anyhow!( + "bundle_index {bundle_index} notes do not match persisted setup: stored positions {:?}, requested positions {:?}", + stored_positions, + requested_positions + )) + } +} + +fn require_persisted_bundle_note_identities( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + bundle_notes: &[NoteInfo], +) -> anyhow::Result<()> { + let stored = { + let conn = db.conn(); + let wallet_id = db.wallet_id(); + let mut stmt = conn + .prepare( + "SELECT position, identity_hash + FROM android_voting_bundle_note_identity + WHERE round_id = ?1 AND wallet_id = ?2 AND bundle_index = ?3 + ORDER BY note_order ASC", + ) + .map_err(|e| anyhow!("failed to prepare bundle note identity query: {e}"))?; + let rows = stmt + .query_map( + rusqlite::params![round_id, wallet_id, i64::from(bundle_index)], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)), + ) + .map_err(|e| anyhow!("failed to query bundle note identities: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| anyhow!("failed to read bundle note identities: {e}"))? + }; + + if stored.len() != bundle_notes.len() { + return Err(anyhow!( + "bundle_index {bundle_index} note identity count mismatch: stored {}, requested {}", + stored.len(), + bundle_notes.len() + )); + } + + for (index, ((stored_position, stored_hash), note)) in + stored.iter().zip(bundle_notes.iter()).enumerate() + { + let stored_position = u64::try_from(*stored_position) + .map_err(|_| anyhow!("stored note position is negative at index {index}"))?; + let requested_hash = note_identity_hash(note); + if stored_position != note.position || stored_hash.as_slice() != requested_hash { + return Err(anyhow!( + "bundle_index {bundle_index} note identity mismatch at index {index}" + )); + } + } + + Ok(()) +} + +pub(super) fn round_exists(db: &VotingDb, round_id: &str) -> anyhow::Result { + let conn = db.conn(); + let wallet_id = db.wallet_id(); + match conn.query_row( + "SELECT 1 FROM rounds WHERE round_id = ?1 AND wallet_id = ?2 LIMIT 1", + rusqlite::params![round_id, wallet_id], + |_| Ok(()), + ) { + Ok(()) => Ok(true), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(e) => Err(anyhow!("round_exists query failed: {}", e)), + } +} + +pub(super) fn update_round_phase_forward( + db: &VotingDb, + round_id: &str, + phase: RoundPhase, +) -> anyhow::Result<()> { + let conn = db.conn(); + let wallet_id = db.wallet_id(); + let current = voting::storage::queries::get_round_state(&conn, round_id, &wallet_id) + .map_err(|e| anyhow!("get_round_state before phase update: {}", e))? + .phase; + let current_rank = round_phase_to_u32(current); + let requested_rank = round_phase_to_u32(phase); + + if current_rank > requested_rank { + return Err(anyhow!( + "refusing to regress round phase for {round_id}: current={current_rank}, requested={requested_rank}" + )); + } + + if current_rank == requested_rank { + return Ok(()); + } + + voting::storage::queries::update_round_phase(&conn, round_id, &wallet_id, phase) + .map_err(|e| anyhow!("update_round_phase: {}", e)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend-lib/src/main/rust/voting/json.rs b/backend-lib/src/main/rust/voting/json.rs index daf8abf07..95896c6a2 100644 --- a/backend-lib/src/main/rust/voting/json.rs +++ b/backend-lib/src/main/rust/voting/json.rs @@ -8,6 +8,7 @@ const PHASE_DELEGATION_PROVED: u32 = 3; const PHASE_VOTE_READY: u32 = 4; const NOTE_SCOPE_EXTERNAL: u32 = 0; const NOTE_SCOPE_INTERNAL: u32 = 1; +const ORCHARD_DIVERSIFIER_BYTES: usize = 11; pub(super) fn hex_enc(bytes: &[u8]) -> String { hex::encode(bytes) @@ -49,7 +50,11 @@ impl TryFrom for NoteInfo { )?, value: note.value, position: note.position, - diversifier: hex_dec(¬e.diversifier, "diversifier")?, + diversifier: require_len( + hex_dec(¬e.diversifier, "diversifier")?, + "diversifier", + ORCHARD_DIVERSIFIER_BYTES, + )?, rho: require_len(hex_dec(¬e.rho, "rho")?, "rho", PROTOCOL_FIELD_BYTES)?, rseed: require_len( hex_dec(¬e.rseed, "rseed")?, @@ -155,7 +160,13 @@ pub(super) fn json_from_jstring Deserialize<'de>>( field: &str, ) -> anyhow::Result { let s = java_string_to_rust(env, value)?; - serde_json::from_str(&s).map_err(|e| anyhow!("{field}: JSON parse error: {e}")) + serde_json::from_str(&s).map_err(|e| { + anyhow!( + "{field}: JSON parse error at line {}, column {}", + e.line(), + e.column() + ) + }) } #[cfg(test)] @@ -169,7 +180,7 @@ mod tests { nullifier: hex::encode([2u8; PROTOCOL_FIELD_BYTES]), value: 13_000_000, position: 0, - diversifier: hex::encode([0u8; 11]), + diversifier: hex::encode([0u8; ORCHARD_DIVERSIFIER_BYTES]), rho: hex::encode([0u8; PROTOCOL_FIELD_BYTES]), rseed: hex::encode([0u8; PROTOCOL_FIELD_BYTES]), scope: 2, diff --git a/backend-lib/src/main/rust/voting/notes.rs b/backend-lib/src/main/rust/voting/notes.rs index 1532d8f57..a8f73ae21 100644 --- a/backend-lib/src/main/rust/voting/notes.rs +++ b/backend-lib/src/main/rust/voting/notes.rs @@ -41,8 +41,9 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_set .map(NoteInfo::try_from) .collect::>()?; let (expected_count, expected_weight, bundle_weights) = bundle_setup_from_notes(¬es)?; + let round_id = java_string_to_rust(env, &round_id)?; let (count, weight) = db - .setup_bundles(&java_string_to_rust(env, &round_id)?, ¬es) + .setup_bundles(&round_id, ¬es) .map_err(|e| anyhow!("setup_bundles: {}", e))?; if count != expected_count || weight != expected_weight { return Err(anyhow!( @@ -53,6 +54,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_set expected_weight )); } + store_bundle_note_identities(&db, &round_id, ¬es)?; make_ffi_bundle_setup_result(env, count, weight, &bundle_weights) }); unwrap_exc_or(&mut env, res, JObject::null().into_raw()) @@ -71,9 +73,11 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_gen let res = catch_unwind(&mut env, |env| { let db = db_from_handle(db_handle)?; let seed = java_secret_bytes_at_least(env, &seed, "seed", PROTOCOL_FIELD_BYTES)?; + let round_id = java_string_to_rust(env, &round_id)?; let hotkey = db - .generate_hotkey(&java_string_to_rust(env, &round_id)?, seed.expose_secret()) + .generate_hotkey(&round_id, seed.expose_secret()) .map_err(|e| anyhow!("generate_hotkey: {}", e))?; + update_round_phase_forward(&db, &round_id, RoundPhase::HotkeyGenerated)?; make_ffi_voting_hotkey(env, hotkey) }); unwrap_exc_or(&mut env, res, JObject::null().into_raw()) diff --git a/backend-lib/src/main/rust/voting/rounds.rs b/backend-lib/src/main/rust/voting/rounds.rs index 6c7097da5..ddd181fcc 100644 --- a/backend-lib/src/main/rust/voting/rounds.rs +++ b/backend-lib/src/main/rust/voting/rounds.rs @@ -50,10 +50,14 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_get ) -> jobject { let res = catch_unwind(&mut env, |env| { let db = db_from_handle(db_handle)?; - match db.get_round_state(&java_string_to_rust(env, &round_id)?) { - Ok(state) => make_ffi_round_state(env, state), - Err(VotingError::InvalidInput { .. }) => Ok(JObject::null().into_raw()), - Err(e) => Err(anyhow!("get_round_state: {}", e)), + let round_id = java_string_to_rust(env, &round_id)?; + if !round_exists(&db, &round_id)? { + Ok(JObject::null().into_raw()) + } else { + let state = db + .get_round_state(&round_id) + .map_err(|e| anyhow!("get_round_state: {}", e))?; + make_ffi_round_state(env, state) } }); unwrap_exc_or(&mut env, res, JObject::null().into_raw()) diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt index 0201a49ce..5882bb740 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/BlockExt.kt @@ -2,8 +2,11 @@ package cash.z.ecc.android.sdk.ext import java.util.Locale +private const val HEX_CHARS_PER_BYTE = 2 +private const val HEX_RADIX = 16 + fun ByteArray.toHex(): String { - val sb = StringBuilder(size * 2) + val sb = StringBuilder(size * HEX_CHARS_PER_BYTE) for (b in this) { sb.append(String.format(Locale.ROOT, "%02x", b)) } @@ -12,13 +15,22 @@ fun ByteArray.toHex(): String { @Suppress("MagicNumber") fun String.fromHex(): ByteArray { + require(length % HEX_CHARS_PER_BYTE == 0) { + "Hex string must have an even length, got $length" + } + val len = length - val data = ByteArray(len / 2) + val data = ByteArray(len / HEX_CHARS_PER_BYTE) var i = 0 while (i < len) { + val high = Character.digit(this[i], HEX_RADIX) + val low = Character.digit(this[i + 1], HEX_RADIX) + require(high >= 0 && low >= 0) { + "Invalid hex character at index $i" + } data[i / 2] = - ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte() - i += 2 + ((high shl 4) + low).toByte() + i += HEX_CHARS_PER_BYTE } return data } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt index 65beebab4..9943ff8f7 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImpl.kt @@ -9,6 +9,8 @@ import cash.z.ecc.android.sdk.internal.model.voting.FfiVotingHotkey import org.json.JSONArray import org.json.JSONObject +private const val PCZT_HASH_BYTES = 32 + @Suppress("TooManyFunctions", "LongParameterList") internal class TypesafeVotingBackendImpl : TypesafeVotingBackend { private val rustBackendLazy = @@ -150,8 +152,21 @@ private fun org.json.JSONObject.getCheckedInt(name: String): Int = private fun JSONObject.toGovernancePcztResult() = GovernancePcztResult( - pcztBytes = getString("pczt_bytes").fromHex(), - rk = getString("rk").fromHex(), - sighash = getString("pczt_sighash").fromHex(), + pcztBytes = getHexBytes("pczt_bytes"), + rk = getHexBytes("rk", PCZT_HASH_BYTES), + sighash = getHexBytes("pczt_sighash", PCZT_HASH_BYTES), actionIndex = getCheckedInt("action_index") ) + +private fun JSONObject.getHexBytes( + name: String, + expectedSize: Int? = null +): ByteArray { + val bytes = getString(name).fromHex() + + require(expectedSize == null || bytes.size == expectedSize) { + "$name must be $expectedSize bytes, got ${bytes.size}" + } + + return bytes +}