From cefc71ac59f7d807dd1be21087dd56e0bd8262f0 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 14 May 2026 01:20:07 -0300 Subject: [PATCH 1/5] Split governance PCZT API for Keystone vs software wallets --- CHANGELOG.md | 6 + .../sdk/internal/jni/VotingRustBackendTest.kt | 92 ++++++++++- .../sdk/internal/jni/VotingRustBackend.kt | 71 ++++++++- .../src/main/rust/voting/delegation.rs | 144 ++++++++++++++---- backend-lib/src/main/rust/voting/util.rs | 21 +++ .../internal/TypesafeVotingBackendImplTest.kt | 30 +++- .../sdk/internal/TypesafeVotingBackend.kt | 20 ++- .../sdk/internal/TypesafeVotingBackendImpl.kt | 93 ++++++++++- 8 files changed, 423 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 309c7f836..79bfbf6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added internal `VotingRustBackend` / `TypesafeVotingBackend` plumbing for future shielded voting backend work. - Added internal shielded voting recovery and share-tracking persistence for replaying, retrying, and confirming delegation and vote submission workflows. +- Split the internal governance PCZT API into `buildGovernancePczt` (explicit Orchard + FVK + raw hotkey address, for hardware wallets such as Keystone) and + `buildGovernancePcztFromSeed` (UFVK + wallet seed + hotkey seed, preserving the + UFVK<>walletSeed validation invariant for software wallets). `buildAndProveDelegation` + now takes the raw hotkey address directly, and a new `deriveHotkeyRawAddress` helper + exposes raw-address derivation to callers that do not retain the hotkey seed. - Pinned `orchard` to `=0.13.1` with `unstable-voting-circuits` to match `zcash_voting` / `voting-circuits` requirements. ## [2.5.0] - 2026-05-01 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 26d259023..229ec7602 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 @@ -2,6 +2,7 @@ package cash.z.ecc.android.sdk.internal.jni import android.content.ContentValues import android.database.sqlite.SQLiteDatabase +import cash.z.ecc.android.sdk.internal.model.voting.JniGovernancePczt import cash.z.ecc.android.sdk.internal.model.voting.JniNoteInfo import cash.z.ecc.android.sdk.internal.model.voting.JniRoundPhase import cash.z.ecc.android.sdk.internal.model.voting.JniVanWitness @@ -664,7 +665,7 @@ class VotingRustBackendTest { db.buildTestGovernancePczt(ufvk, mismatchedSamePositionNotesJson) } assertFailsWith { - db.buildTestGovernancePczt(mismatchedUfvk, notes) + db.buildTestGovernancePcztFromSeed(mismatchedUfvk, notes) } } finally { db.close() @@ -733,6 +734,66 @@ class VotingRustBackendTest { } } + @Test + fun build_governance_pczt_explicit_and_seed_paths_match() = + runTest { + val explicitDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + val seedDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + try { + val notes = notes(noteCount = 6, value = PCZT_NOTE_VALUE) + val ufvk = deriveTestUfvk() + explicitDb.initPcztRoundWithBundles(notes) + seedDb.initPcztRoundWithBundles(notes) + + val explicitPczt = explicitDb.buildTestGovernancePczt(ufvk, notes) + val seedPczt = seedDb.buildTestGovernancePcztFromSeed(ufvk, notes) + + assertContentEquals(explicitPczt.pcztBytes, seedPczt.pcztBytes) + assertContentEquals(explicitPczt.rk, seedPczt.rk) + assertContentEquals(explicitPczt.sighash, seedPczt.sighash) + assertEquals(explicitPczt.actionIndex, seedPczt.actionIndex) + assertEquals( + JniRoundPhase.DELEGATION_CONSTRUCTED, + assertNotNull(explicitDb.getRoundState(PCZT_ROUND_ID)).roundPhase + ) + assertEquals( + JniRoundPhase.DELEGATION_CONSTRUCTED, + assertNotNull(seedDb.getRoundState(PCZT_ROUND_ID)).roundPhase + ) + } finally { + explicitDb.close() + seedDb.close() + } + } + + @Test + fun build_governance_pczt_from_seed_rejects_wallet_seed_that_does_not_match_ufvk() = + runTest { + val db = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + try { + val notes = notes(noteCount = 6, value = PCZT_NOTE_VALUE) + val ufvk = deriveTestUfvk() + db.initPcztRoundWithBundles(notes) + + val error = + assertFailsWith { + db.buildTestGovernancePcztFromSeed( + ufvk = ufvk, + notes = notes, + walletSeed = OTHER_HOTKEY_SEED + ) + } + + assertTrue(error.message.orEmpty().contains("ufvk does not match walletSeed")) + assertEquals( + JniRoundPhase.HOTKEY_GENERATED, + assertNotNull(db.getRoundState(PCZT_ROUND_ID)).roundPhase + ) + } finally { + db.close() + } + } + @Test fun build_governance_pczt_accepts_mainnet_network_id() = runTest { @@ -868,7 +929,7 @@ class VotingRustBackendTest { pirServerUrl = "http://127.0.0.1:1", networkId = TESTNET_NETWORK_ID, notes = notes, - hotkeySeed = SHORT_FIELD, + hotkeyRawAddress = SHORT_FIELD, proofProgress = null ) } @@ -1445,15 +1506,36 @@ class VotingRustBackendTest { hotkeySeed: ByteArray = HOTKEY_SEED, networkId: Int = TESTNET_NETWORK_ID, roundId: String = PCZT_ROUND_ID - ) = buildGovernancePczt( + ): JniGovernancePczt { + val backend = VotingRustBackend.new() + return buildGovernancePczt( + roundId = roundId, + bundleIndex = 1, + fvkBytes = backend.extractOrchardFvkFromUfvk(ufvk, networkId), + hotkeyRawAddress = backend.deriveHotkeyRawAddress(hotkeySeed, networkId, ACCOUNT_INDEX), + networkId = networkId, + accountIndex = ACCOUNT_INDEX, + notes = notes, + seedFingerprint = SEED_FINGERPRINT, + roundName = ROUND_NAME + ) + } + + private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePcztFromSeed( + ufvk: String, + notes: List, + walletSeed: ByteArray = HOTKEY_SEED, + networkId: Int = TESTNET_NETWORK_ID, + roundId: String = PCZT_ROUND_ID + ) = buildGovernancePcztFromSeed( roundId = roundId, bundleIndex = 1, ufvk = ufvk, networkId = networkId, accountIndex = ACCOUNT_INDEX, notes = notes, - walletSeed = HOTKEY_SEED, - hotkeySeed = hotkeySeed, + walletSeed = walletSeed, + hotkeySeed = HOTKEY_SEED, seedFingerprint = SEED_FINGERPRINT, roundName = ROUND_NAME ) 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 1356a8d7f..57f02d36c 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 @@ -124,6 +124,17 @@ class VotingRustBackend private constructor() { ?: error("extractOrchardFvkFromUfvk returned null") } + @Throws(RuntimeException::class) + suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray = + withContext(Dispatchers.IO) { + deriveHotkeyRawAddressNative(hotkeySeed, networkId, accountIndex) + ?: error("deriveHotkeyRawAddress returned null") + } + @Throws(RuntimeException::class) suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = withContext(Dispatchers.IO) { @@ -304,6 +315,33 @@ class VotingRustBackend private constructor() { @Throws(RuntimeException::class) suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + roundName: String + ): JniGovernancePczt = + withHandle { handle -> + buildGovernancePcztNative( + handle, + roundId, + bundleIndex, + fvkBytes, + hotkeyRawAddress, + networkId, + accountIndex, + notes.toTypedArray(), + seedFingerprint, + roundName + ) ?: error("buildGovernancePczt returned null") + } + + @Throws(RuntimeException::class) + suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, ufvk: String, @@ -316,7 +354,7 @@ class VotingRustBackend private constructor() { roundName: String ): JniGovernancePczt = withHandle { handle -> - buildGovernancePcztNative( + buildGovernancePcztFromSeedNative( handle, roundId, bundleIndex, @@ -328,7 +366,7 @@ class VotingRustBackend private constructor() { hotkeySeed, seedFingerprint, roundName - ) ?: error("buildGovernancePczt returned null") + ) ?: error("buildGovernancePcztFromSeed returned null") } @Throws(RuntimeException::class) @@ -373,7 +411,7 @@ class VotingRustBackend private constructor() { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult = withHandle { handle -> @@ -384,7 +422,7 @@ class VotingRustBackend private constructor() { pirServerUrl, networkId, notes.toTypedArray(), - hotkeySeed, + hotkeyRawAddress, proofProgress?.withVotingDbReentryGuard() ) ?: error("buildAndProveDelegation returned null") } @@ -792,6 +830,14 @@ class VotingRustBackend private constructor() { networkId: Int ): ByteArray? + @JvmStatic + @Throws(RuntimeException::class) + private external fun deriveHotkeyRawAddressNative( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray? + @JvmStatic @Throws(RuntimeException::class) private external fun extractNcRootNative(treeStateBytes: ByteArray): ByteArray? @@ -880,6 +926,21 @@ class VotingRustBackend private constructor() { @JvmStatic @Throws(RuntimeException::class) private external fun buildGovernancePcztNative( + dbHandle: Long, + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: Array, + seedFingerprint: ByteArray, + roundName: String + ): JniGovernancePczt? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun buildGovernancePcztFromSeedNative( dbHandle: Long, roundId: String, bundleIndex: Int, @@ -954,7 +1015,7 @@ class VotingRustBackend private constructor() { pirServerUrl: String, networkId: Int, notes: Array, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult? diff --git a/backend-lib/src/main/rust/voting/delegation.rs b/backend-lib/src/main/rust/voting/delegation.rs index ab3026bbf..6691d2b0a 100644 --- a/backend-lib/src/main/rust/voting/delegation.rs +++ b/backend-lib/src/main/rust/voting/delegation.rs @@ -8,6 +8,59 @@ use std::collections::HashMap; #[unsafe(no_mangle)] pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_buildGovernancePcztNative< 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + db_handle: jlong, + round_id: JString<'local>, + bundle_index: jint, + fvk_bytes: JByteArray<'local>, + hotkey_raw_address: JByteArray<'local>, + network_id: jint, + account_index: jint, + notes: JObjectArray<'local>, + seed_fingerprint: JByteArray<'local>, + round_name: JString<'local>, +) -> jobject { + let res = catch_unwind(&mut env, |env| { + let db = db_from_handle(db_handle)?; + let _access_lock = db.access_lock()?; + 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 fvk_bytes = java_bytes_exact(env, &fvk_bytes, "fvkBytes", ORCHARD_FVK_BYTES)?; + let hotkey_raw_address = java_bytes_exact( + env, + &hotkey_raw_address, + "hotkeyRawAddress", + ORCHARD_RAW_ADDRESS_BYTES, + )?; + let seed_fingerprint = java_bytes32(env, &seed_fingerprint, "seedFingerprint")?; + + let notes = java_note_info_array(env, ¬es, "notes")?; + let round_id = java_string_to_rust(env, &round_id)?; + let round_name = java_string_to_rust(env, &round_name)?; + let pczt = build_governance_pczt_for_bundle( + &db, + &round_id, + bundle_index, + ¬es, + &fvk_bytes, + &hotkey_raw_address, + network.coin_type(), + account_index, + &seed_fingerprint, + &round_name, + )?; + + make_jni_governance_pczt(env, pczt) + }); + 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_buildGovernancePcztFromSeedNative< + 'local, >( mut env: JNIEnv<'local>, _: JClass<'local>, @@ -31,50 +84,79 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui let account_index = jint_to_u32(account_index, "account_index")?; let ufvk_str = java_string_to_rust(env, &ufvk)?; let fvk_bytes = orchard_fvk_bytes(&ufvk_str, network)?; - - let seed_bytes = + let wallet_seed = java_secret_bytes_at_least(env, &wallet_seed, "walletSeed", PROTOCOL_FIELD_BYTES)?; let hotkey_seed = java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; let derived_fvk_bytes = - orchard_fvk_bytes_from_wallet_seed(seed_bytes.expose_secret(), network, account_index)?; + orchard_fvk_bytes_from_wallet_seed(wallet_seed.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(hotkey_seed.expose_secret(), network, 0)?; + hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, account_index)?; let seed_fingerprint = java_bytes32(env, &seed_fingerprint, "seedFingerprint")?; - let notes = java_note_info_array(env, ¬es, "notes")?; - let bundle_notes = bundled_notes_for_index(¬es, bundle_index)?; - let round_id = java_string_to_rust(env, &round_id)?; - require_round_phase_for_delegation_construction(&db, &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, - HOTKEY_ADDRESS_INDEX, - ) - .map_err(|e| anyhow!("build_governance_pczt: {}", e))?; - update_round_phase_forward(&db, &round_id, RoundPhase::DelegationConstructed)?; + let pczt = build_governance_pczt_for_bundle( + &db, + &round_id, + bundle_index, + ¬es, + &fvk_bytes, + &hotkey_raw_address, + network.coin_type(), + account_index, + &seed_fingerprint, + &round_name, + )?; make_jni_governance_pczt(env, pczt) }); unwrap_exc_or(&mut env, res, JObject::null().into_raw()) } +/// Builds a governance PCZT for one deterministic bundle from the full snapshot note set. +/// +/// Shared by the explicit-FVK Keystone path and the seed-validated software path. Callers must +/// provide already validated signer material; this helper verifies the bundle index, enforces the +/// round phase, persists the constructed delegation state, and advances the phase on success. +fn build_governance_pczt_for_bundle( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + notes: &[NoteInfo], + fvk_bytes: &[u8], + hotkey_raw_address: &[u8], + coin_type: u32, + account_index: u32, + seed_fingerprint: &[u8; PROTOCOL_FIELD_BYTES], + round_name: &str, +) -> anyhow::Result { + let bundle_notes = bundled_notes_for_index(notes, bundle_index)?; + require_round_phase_for_delegation_construction(db, round_id)?; + let pczt = db + .build_governance_pczt( + round_id, + bundle_index, + &bundle_notes, + fvk_bytes, + hotkey_raw_address, + nu6_branch_id(), + coin_type, + seed_fingerprint, + account_index, + round_name, + HOTKEY_ADDRESS_INDEX, + ) + .map_err(|e| anyhow!("build_governance_pczt: {}", e))?; + update_round_phase_forward(db, round_id, RoundPhase::DelegationConstructed)?; + Ok(pczt) +} + #[unsafe(no_mangle)] pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_extractPcztSighashNative< 'local, @@ -221,13 +303,13 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui pir_server_url: JString<'local>, network_id: jint, notes: JObjectArray<'local>, - hotkey_seed: JByteArray<'local>, + hotkey_raw_address: JByteArray<'local>, progress_callback: JObject<'local>, ) -> jobject { let res = catch_unwind(&mut env, |env| { let db = db_from_handle(db_handle)?; let _access_lock = db.access_lock()?; - let network = network_from_id(network_id)?; + network_from_id(network_id)?; let network_id = jint_to_u32(network_id, "network_id")?; let bundle_index = jint_to_u32(bundle_index, "bundle_index")?; let notes = java_note_info_array(env, ¬es, "notes")?; @@ -235,10 +317,12 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui let round_id = java_string_to_rust(env, &round_id)?; require_round_phase_not_after(&db, &round_id, RoundPhase::DelegationProved)?; require_bundle_notes_match(&db, &round_id, bundle_index, &bundle_notes)?; - let hotkey_seed = - java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; - let hotkey_raw_address = - hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, 0)?; + let hotkey_raw_address = java_bytes_exact( + env, + &hotkey_raw_address, + "hotkeyRawAddress", + ORCHARD_RAW_ADDRESS_BYTES, + )?; let pir_url = java_string_to_rust(env, &pir_server_url)?; let pir_client = connect_pir_client(&pir_url)?; let reporter = progress_reporter_from_callback(env, &progress_callback)?; diff --git a/backend-lib/src/main/rust/voting/util.rs b/backend-lib/src/main/rust/voting/util.rs index 3e22f7913..b09570449 100644 --- a/backend-lib/src/main/rust/voting/util.rs +++ b/backend-lib/src/main/rust/voting/util.rs @@ -68,6 +68,27 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_ext 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_deriveHotkeyRawAddressNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + hotkey_seed: JByteArray<'local>, + network_id: jint, + account_index: jint, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + let network = network_from_id(network_id)?; + let account_index = jint_to_u32(account_index, "account_index")?; + let hotkey_seed = + java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; + let bytes = hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, account_index)?; + Ok(env.byte_array_from_slice(&bytes)?.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_extractNcRootNative< 'local, diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt index 644cf4f89..b7ba77c9d 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt @@ -203,7 +203,7 @@ class TypesafeVotingBackendImplTest { generatedWitnesses = generatedWitnesses ) val db = TypesafeVotingDbImpl(backend) - val walletSeed = byteArrayOf(1, 2, 3) + val hotkeyRawAddress = byteArrayOf(1, 2, 3) val senderSeed = byteArrayOf(4, 5, 6) val keystoneSig = byteArrayOf(7, 8) val keystoneSighash = byteArrayOf(9, 10) @@ -242,7 +242,7 @@ class TypesafeVotingBackendImplTest { pirServerUrl = "https://pir.example", networkId = 1, notes = notes, - hotkeySeed = walletSeed + hotkeyRawAddress = hotkeyRawAddress ) { progress -> progressValue = progress } @@ -251,7 +251,7 @@ class TypesafeVotingBackendImplTest { assertEquals("https://pir.example", backend.buildAndProvePirServerUrl) assertEquals(1, backend.buildAndProveNetworkId) assertEquals(jniNotes, backend.buildAndProveNotes) - assertContentEquals(walletSeed, backend.buildAndProveHotkeySeed) + assertContentEquals(hotkeyRawAddress, backend.buildAndProveHotkeyRawAddress) assertNotNull(backend.buildAndProveProgress).onProgress(0.75) assertEquals(0.75, progressValue) assertContentEquals(field(13), proof.nfSigned) @@ -802,6 +802,12 @@ class TypesafeVotingBackendImplTest { networkId: Int ): ByteArray = unused() + override suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray = unused() + override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = unused() override suspend fun verifyWitness(witness: JniWitnessData): Boolean = unused() @@ -885,7 +891,7 @@ class TypesafeVotingBackendImplTest { var buildAndProvePirServerUrl: String? = null var buildAndProveNetworkId: Int? = null var buildAndProveNotes: List? = null - var buildAndProveHotkeySeed: ByteArray = ByteArray(0) + var buildAndProveHotkeyRawAddress: ByteArray = ByteArray(0) var buildAndProveProgress: VotingProofProgressCallback? = null var submissionRoundId: String? = null var submissionBundleIndex: Int? = null @@ -1004,6 +1010,18 @@ class TypesafeVotingBackendImplTest { ): JniVotingHotkey = unused() override suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + roundName: String + ): JniGovernancePczt = unused() + + override suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, ufvk: String, @@ -1049,7 +1067,7 @@ class TypesafeVotingBackendImplTest { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult { buildAndProveRoundId = roundId @@ -1057,7 +1075,7 @@ class TypesafeVotingBackendImplTest { buildAndProvePirServerUrl = pirServerUrl buildAndProveNetworkId = networkId buildAndProveNotes = notes - buildAndProveHotkeySeed = hotkeySeed + buildAndProveHotkeyRawAddress = hotkeyRawAddress buildAndProveProgress = proofProgress return proofResult } 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 2418a7baf..5ac580d07 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 @@ -45,6 +45,12 @@ internal interface TypesafeVotingBackend { suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray + suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray + suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray suspend fun verifyWitness(witness: JniWitnessData): Boolean @@ -103,6 +109,18 @@ internal interface TypesafeVotingDb { ): JniVotingHotkey suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + roundName: String + ): GovernancePcztResult + + suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, ufvk: String, @@ -136,7 +154,7 @@ internal interface TypesafeVotingDb { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: ((Double) -> Unit)? = null ): DelegationProofResult 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 03054d2ae..c44d06957 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 @@ -108,6 +108,13 @@ internal class TypesafeVotingBackendImpl( override suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray = rustBackend().extractOrchardFvkFromUfvk(ufvk, networkId) + override suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray = + rustBackend().deriveHotkeyRawAddress(hotkeySeed, networkId, accountIndex) + override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = rustBackend().extractNcRoot(treeStateBytes) @@ -173,6 +180,12 @@ internal interface VotingBackendBridge { suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray + suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray + suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray suspend fun verifyWitness(witness: JniWitnessData): Boolean @@ -240,6 +253,13 @@ private class RustVotingBackendBridge( override suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray = rustBackend.extractOrchardFvkFromUfvk(ufvk, networkId) + override suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray = + rustBackend.deriveHotkeyRawAddress(hotkeySeed, networkId, accountIndex) + override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = rustBackend.extractNcRoot(treeStateBytes) @@ -303,6 +323,18 @@ internal interface VotingDbBackend { ): JniVotingHotkey suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + roundName: String + ): JniGovernancePczt + + suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, ufvk: String, @@ -336,7 +368,7 @@ internal interface VotingDbBackend { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult @@ -511,6 +543,29 @@ private class RustVotingDbBackend( ): JniVotingHotkey = votingDb.generateHotkey(roundId, seed) override suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + roundName: String + ): JniGovernancePczt = + votingDb.buildGovernancePczt( + roundId, + bundleIndex, + fvkBytes, + hotkeyRawAddress, + networkId, + accountIndex, + notes, + seedFingerprint, + roundName + ) + + override suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, ufvk: String, @@ -522,7 +577,7 @@ private class RustVotingDbBackend( seedFingerprint: ByteArray, roundName: String ): JniGovernancePczt = - votingDb.buildGovernancePczt( + votingDb.buildGovernancePcztFromSeed( roundId, bundleIndex, ufvk, @@ -563,7 +618,7 @@ private class RustVotingDbBackend( pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult = votingDb.buildAndProveDelegation( @@ -572,7 +627,7 @@ private class RustVotingDbBackend( pirServerUrl, networkId, notes, - hotkeySeed, + hotkeyRawAddress, proofProgress ) @@ -810,6 +865,30 @@ internal class TypesafeVotingDbImpl( votingDb.generateHotkey(roundId, seed) override suspend fun buildGovernancePczt( + roundId: String, + bundleIndex: Int, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + roundName: String + ): GovernancePcztResult = + votingDb + .buildGovernancePczt( + roundId, + bundleIndex, + fvkBytes, + hotkeyRawAddress, + networkId, + accountIndex, + notes.toJniNoteInfos(), + seedFingerprint, + roundName + ).toGovernancePcztResult() + + override suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, ufvk: String, @@ -822,7 +901,7 @@ internal class TypesafeVotingDbImpl( roundName: String ): GovernancePcztResult = votingDb - .buildGovernancePczt( + .buildGovernancePcztFromSeed( roundId, bundleIndex, ufvk, @@ -864,7 +943,7 @@ internal class TypesafeVotingDbImpl( pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: ((Double) -> Unit)? ): DelegationProofResult = votingDb @@ -874,7 +953,7 @@ internal class TypesafeVotingDbImpl( pirServerUrl, networkId, notes.toJniNoteInfos(), - hotkeySeed, + hotkeyRawAddress, proofProgress?.asVotingProgressCallback() ).toDelegationProofResult() From 3ca753ce7364daaa3ee30d19cac905a18368bf6a Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 14 May 2026 02:21:51 -0300 Subject: [PATCH 2/5] Align hotkey account derivation with vote path Hotkey material now derives from a named account-0 constant instead of accepting or reusing the caller's wallet account index. This matches zcash_voting's vote construction and signing behavior, where the hotkey spending key is derived through derive_spending_key and therefore fixed to account 0. The seed-based governance PCZT path still uses the caller's account index for wallet seed to UFVK validation and PCZT ZIP-32 metadata, but uses the hotkey account constant for the hotkey raw address. The JNI, typesafe SDK, and tests now expose deriveHotkeyRawAddress without an accountIndex parameter, and a nonzero wallet-account instrumentation test verifies that explicit and seed PCZT paths remain aligned. --- .../sdk/internal/jni/VotingRustBackendTest.kt | 54 ++++++++++++++++--- .../sdk/internal/jni/VotingRustBackend.kt | 8 ++- .../src/main/rust/voting/delegation.rs | 7 ++- backend-lib/src/main/rust/voting/helpers.rs | 5 ++ backend-lib/src/main/rust/voting/util.rs | 18 +++++-- .../internal/TypesafeVotingBackendImplTest.kt | 3 +- .../sdk/internal/TypesafeVotingBackend.kt | 3 +- .../sdk/internal/TypesafeVotingBackendImpl.kt | 13 ++--- 8 files changed, 81 insertions(+), 30 deletions(-) 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 229ec7602..e566c1740 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 @@ -766,6 +766,41 @@ class VotingRustBackendTest { } } + @Test + fun build_governance_pczt_from_seed_uses_wallet_account_but_hotkey_account_zero() = + runTest { + val explicitDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + val seedDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) + try { + val accountIndex = 1 + val notes = notes(noteCount = 6, value = PCZT_NOTE_VALUE) + val ufvk = deriveTestUfvk(accountIndex = accountIndex) + explicitDb.initPcztRoundWithBundles(notes) + seedDb.initPcztRoundWithBundles(notes) + + val explicitPczt = + explicitDb.buildTestGovernancePczt( + ufvk = ufvk, + notes = notes, + accountIndex = accountIndex + ) + val seedPczt = + seedDb.buildTestGovernancePcztFromSeed( + ufvk = ufvk, + notes = notes, + accountIndex = accountIndex + ) + + assertContentEquals(explicitPczt.pcztBytes, seedPczt.pcztBytes) + assertContentEquals(explicitPczt.rk, seedPczt.rk) + assertContentEquals(explicitPczt.sighash, seedPczt.sighash) + assertEquals(explicitPczt.actionIndex, seedPczt.actionIndex) + } finally { + explicitDb.close() + seedDb.close() + } + } + @Test fun build_governance_pczt_from_seed_rejects_wallet_seed_that_does_not_match_ufvk() = runTest { @@ -1476,12 +1511,13 @@ class VotingRustBackendTest { private suspend fun deriveTestUfvk( seed: ByteArray = HOTKEY_SEED, - networkId: Int = TESTNET_NETWORK_ID + networkId: Int = TESTNET_NETWORK_ID, + accountIndex: Int = ACCOUNT_INDEX ): String = RustDerivationTool .new() - .deriveUnifiedFullViewingKeys(seed, networkId, 1) - .first() + .deriveUnifiedFullViewingKeys(seed, networkId, accountIndex + 1) + .last() private suspend fun VotingRustBackend.VotingDb.initPcztRoundWithBundles( notes: List, @@ -1505,16 +1541,17 @@ class VotingRustBackendTest { notes: List, hotkeySeed: ByteArray = HOTKEY_SEED, networkId: Int = TESTNET_NETWORK_ID, - roundId: String = PCZT_ROUND_ID + roundId: String = PCZT_ROUND_ID, + accountIndex: Int = ACCOUNT_INDEX ): JniGovernancePczt { val backend = VotingRustBackend.new() return buildGovernancePczt( roundId = roundId, bundleIndex = 1, fvkBytes = backend.extractOrchardFvkFromUfvk(ufvk, networkId), - hotkeyRawAddress = backend.deriveHotkeyRawAddress(hotkeySeed, networkId, ACCOUNT_INDEX), + hotkeyRawAddress = backend.deriveHotkeyRawAddress(hotkeySeed, networkId), networkId = networkId, - accountIndex = ACCOUNT_INDEX, + accountIndex = accountIndex, notes = notes, seedFingerprint = SEED_FINGERPRINT, roundName = ROUND_NAME @@ -1526,13 +1563,14 @@ class VotingRustBackendTest { notes: List, walletSeed: ByteArray = HOTKEY_SEED, networkId: Int = TESTNET_NETWORK_ID, - roundId: String = PCZT_ROUND_ID + roundId: String = PCZT_ROUND_ID, + accountIndex: Int = ACCOUNT_INDEX ) = buildGovernancePcztFromSeed( roundId = roundId, bundleIndex = 1, ufvk = ufvk, networkId = networkId, - accountIndex = ACCOUNT_INDEX, + accountIndex = accountIndex, notes = notes, walletSeed = walletSeed, hotkeySeed = HOTKEY_SEED, 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 57f02d36c..c19428d2f 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 @@ -127,11 +127,10 @@ class VotingRustBackend private constructor() { @Throws(RuntimeException::class) suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray = withContext(Dispatchers.IO) { - deriveHotkeyRawAddressNative(hotkeySeed, networkId, accountIndex) + deriveHotkeyRawAddressNative(hotkeySeed, networkId) ?: error("deriveHotkeyRawAddress returned null") } @@ -834,8 +833,7 @@ class VotingRustBackend private constructor() { @Throws(RuntimeException::class) private external fun deriveHotkeyRawAddressNative( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray? @JvmStatic diff --git a/backend-lib/src/main/rust/voting/delegation.rs b/backend-lib/src/main/rust/voting/delegation.rs index 6691d2b0a..ef6edbe65 100644 --- a/backend-lib/src/main/rust/voting/delegation.rs +++ b/backend-lib/src/main/rust/voting/delegation.rs @@ -95,8 +95,11 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui "ufvk does not match walletSeed for network_id={network_id} account_index={account_index}" )); } - let hotkey_raw_address = - hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, account_index)?; + let hotkey_raw_address = hotkey_orchard_raw_address( + hotkey_seed.expose_secret(), + network, + HOTKEY_ACCOUNT_INDEX, + )?; let seed_fingerprint = java_bytes32(env, &seed_fingerprint, "seedFingerprint")?; let notes = java_note_info_array(env, ¬es, "notes")?; let round_id = java_string_to_rust(env, &round_id)?; diff --git a/backend-lib/src/main/rust/voting/helpers.rs b/backend-lib/src/main/rust/voting/helpers.rs index 96d8bd764..6012074b5 100644 --- a/backend-lib/src/main/rust/voting/helpers.rs +++ b/backend-lib/src/main/rust/voting/helpers.rs @@ -91,6 +91,11 @@ pub(super) const HOTKEY_SECRET_KEY_BYTES: usize = PROTOCOL_FIELD_BYTES; pub(super) const HOTKEY_PUBLIC_KEY_BYTES: usize = PROTOCOL_FIELD_BYTES; // Hotkeys use one stable Orchard address for voting identity and recovery. pub(super) const HOTKEY_ADDRESS_INDEX: u32 = 0; +// ZIP-32 account for deriving hotkey material from the hotkey seed. This is intentionally +// distinct from HOTKEY_ADDRESS_INDEX: account selects the Orchard account, address index +// selects the stable address within that account. zcash_voting's vote path currently derives +// hotkey signing material only for account 0. +pub(super) const HOTKEY_ACCOUNT_INDEX: u32 = 0; pub(super) const SPEND_AUTH_SIG_BYTES: usize = 64; pub(super) const NOTE_SCOPE_EXTERNAL: u32 = 0; pub(super) const NOTE_SCOPE_INTERNAL: u32 = 1; diff --git a/backend-lib/src/main/rust/voting/util.rs b/backend-lib/src/main/rust/voting/util.rs index b09570449..8a28b7396 100644 --- a/backend-lib/src/main/rust/voting/util.rs +++ b/backend-lib/src/main/rust/voting/util.rs @@ -68,6 +68,16 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_ext unwrap_exc_or(&mut env, res, std::ptr::null_mut()) } +/// Derives the canonical raw Orchard address for the hotkey identity associated with +/// `hotkey_seed` on `network_id`. +/// +/// The ZIP-32 account index is intentionally fixed at 0 and is not exposed as a parameter. +/// `voting::vote_commitment::sign_cast_vote` / `voting::vote_commitment::build_vote_commitment` +/// in `zcash_voting` derive the hotkey spending key via +/// `crate::zkp2::derive_spending_key`, which hardcodes `derive_spending_key_for_account(.., 0)`. +/// Letting callers pass an arbitrary account here would allow delegation to be built against +/// a hotkey the vote-construction path cannot subsequently sign for, so the API surface +/// hides the constraint instead of leaving it as a runtime trap. #[unsafe(no_mangle)] pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_deriveHotkeyRawAddressNative< 'local, @@ -76,14 +86,16 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_der _: JClass<'local>, hotkey_seed: JByteArray<'local>, network_id: jint, - account_index: jint, ) -> jbyteArray { let res = catch_unwind(&mut env, |env| { let network = network_from_id(network_id)?; - let account_index = jint_to_u32(account_index, "account_index")?; let hotkey_seed = java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; - let bytes = hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, account_index)?; + let bytes = hotkey_orchard_raw_address( + hotkey_seed.expose_secret(), + network, + HOTKEY_ACCOUNT_INDEX, + )?; Ok(env.byte_array_from_slice(&bytes)?.into_raw()) }); unwrap_exc_or(&mut env, res, std::ptr::null_mut()) diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt index b7ba77c9d..4df62c07f 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt @@ -804,8 +804,7 @@ class TypesafeVotingBackendImplTest { override suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray = unused() override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = unused() 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 5ac580d07..4416a9f9a 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 @@ -47,8 +47,7 @@ internal interface TypesafeVotingBackend { suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray 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 c44d06957..fb74947f5 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 @@ -110,10 +110,9 @@ internal class TypesafeVotingBackendImpl( override suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray = - rustBackend().deriveHotkeyRawAddress(hotkeySeed, networkId, accountIndex) + rustBackend().deriveHotkeyRawAddress(hotkeySeed, networkId) override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = rustBackend().extractNcRoot(treeStateBytes) @@ -182,8 +181,7 @@ internal interface VotingBackendBridge { suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray @@ -255,10 +253,9 @@ private class RustVotingBackendBridge( override suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, - networkId: Int, - accountIndex: Int + networkId: Int ): ByteArray = - rustBackend.deriveHotkeyRawAddress(hotkeySeed, networkId, accountIndex) + rustBackend.deriveHotkeyRawAddress(hotkeySeed, networkId) override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = rustBackend.extractNcRoot(treeStateBytes) From 9f71bfac47e9a7ad8ed6eee02518f56b6edc78a2 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 14 May 2026 02:31:46 -0300 Subject: [PATCH 3/5] lint --- .../sdk/internal/jni/VotingRustBackendTest.kt | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) 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 e566c1740..0d5186ec6 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 @@ -782,13 +782,13 @@ class VotingRustBackendTest { explicitDb.buildTestGovernancePczt( ufvk = ufvk, notes = notes, - accountIndex = accountIndex + options = GovernancePcztOptions(accountIndex = accountIndex) ) val seedPczt = seedDb.buildTestGovernancePcztFromSeed( ufvk = ufvk, notes = notes, - accountIndex = accountIndex + options = GovernancePcztOptions(accountIndex = accountIndex) ) assertContentEquals(explicitPczt.pcztBytes, seedPczt.pcztBytes) @@ -815,7 +815,7 @@ class VotingRustBackendTest { db.buildTestGovernancePcztFromSeed( ufvk = ufvk, notes = notes, - walletSeed = OTHER_HOTKEY_SEED + options = GovernancePcztOptions(walletSeed = OTHER_HOTKEY_SEED) ) } @@ -842,7 +842,7 @@ class VotingRustBackendTest { db.buildTestGovernancePczt( ufvk = ufvk, notes = notes, - networkId = MAINNET_NETWORK_ID + options = GovernancePcztOptions(networkId = MAINNET_NETWORK_ID) ) assertTrue(pczt.pcztBytes.isNotEmpty()) @@ -1539,19 +1539,16 @@ class VotingRustBackendTest { private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePczt( ufvk: String, notes: List, - hotkeySeed: ByteArray = HOTKEY_SEED, - networkId: Int = TESTNET_NETWORK_ID, - roundId: String = PCZT_ROUND_ID, - accountIndex: Int = ACCOUNT_INDEX + options: GovernancePcztOptions = GovernancePcztOptions() ): JniGovernancePczt { val backend = VotingRustBackend.new() return buildGovernancePczt( - roundId = roundId, + roundId = options.roundId, bundleIndex = 1, - fvkBytes = backend.extractOrchardFvkFromUfvk(ufvk, networkId), - hotkeyRawAddress = backend.deriveHotkeyRawAddress(hotkeySeed, networkId), - networkId = networkId, - accountIndex = accountIndex, + fvkBytes = backend.extractOrchardFvkFromUfvk(ufvk, options.networkId), + hotkeyRawAddress = backend.deriveHotkeyRawAddress(options.hotkeySeed, options.networkId), + networkId = options.networkId, + accountIndex = options.accountIndex, notes = notes, seedFingerprint = SEED_FINGERPRINT, roundName = ROUND_NAME @@ -1561,23 +1558,28 @@ class VotingRustBackendTest { private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePcztFromSeed( ufvk: String, notes: List, - walletSeed: ByteArray = HOTKEY_SEED, - networkId: Int = TESTNET_NETWORK_ID, - roundId: String = PCZT_ROUND_ID, - accountIndex: Int = ACCOUNT_INDEX + options: GovernancePcztOptions = GovernancePcztOptions() ) = buildGovernancePcztFromSeed( - roundId = roundId, + roundId = options.roundId, bundleIndex = 1, ufvk = ufvk, - networkId = networkId, - accountIndex = accountIndex, + networkId = options.networkId, + accountIndex = options.accountIndex, notes = notes, - walletSeed = walletSeed, - hotkeySeed = HOTKEY_SEED, + walletSeed = options.walletSeed, + hotkeySeed = options.hotkeySeed, seedFingerprint = SEED_FINGERPRINT, roundName = ROUND_NAME ) + private class GovernancePcztOptions( + val hotkeySeed: ByteArray = HOTKEY_SEED, + val walletSeed: ByteArray = HOTKEY_SEED, + val networkId: Int = TESTNET_NETWORK_ID, + val roundId: String = PCZT_ROUND_ID, + val accountIndex: Int = ACCOUNT_INDEX + ) + private fun notes( noteCount: Int, value: Long = NOTE_VALUE, From 1bdf0a2b6f028225163b8c9c464dd37fc5dd8332 Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Thu, 14 May 2026 18:18:43 +0200 Subject: [PATCH 4/5] Document Keystone governance PCZT contract and harden tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split between `buildGovernancePczt` (Keystone path, trusts caller-derived fvk + hotkey address) and `buildGovernancePcztFromSeed` (software path, validates fvk against the wallet seed and re-derives the hotkey from a fixed account index) carries real invariants but had no docs. KDoc now records both, including the warning that adding an `accountIndex` parameter to `deriveHotkeyRawAddress` would silently desync delegation construction from vote signing. `build_governance_pczt_explicit_and_seed_paths_match` asserted byte-identical PCZTs across the two paths, which is stronger than what the implementation actually guarantees — the two paths are not required to produce the same PCZT, only valid ones. Replaced with `assertValidGovernancePczt`, which checks the structural invariants that do hold: field-sized rk/sighash, a valid action index, sighash re-extractable from the PCZT bytes, and `DELEGATION_CONSTRUCTED` reached. Also adds coverage for `deriveHotkeyRawAddress` (determinism, seed/network isolation, short-seed rejection) and for the typesafe forwarding of both governance PCZT methods, which were previously stubbed as `unused()` in the recording backend. --- .../sdk/internal/jni/VotingRustBackendTest.kt | 59 +++++-- .../sdk/internal/jni/VotingRustBackend.kt | 20 +++ .../internal/TypesafeVotingBackendImplTest.kt | 162 +++++++++++++++++- .../sdk/internal/TypesafeVotingBackend.kt | 22 +++ 4 files changed, 244 insertions(+), 19 deletions(-) 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 0d5186ec6..91757f62a 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 @@ -159,6 +159,25 @@ class VotingRustBackendTest { } } + @Test + fun derive_hotkey_raw_address_is_deterministic_and_rejects_short_seed() = + runTest { + val backend = VotingRustBackend.new() + + val first = backend.deriveHotkeyRawAddress(HOTKEY_SEED, TESTNET_NETWORK_ID) + val second = backend.deriveHotkeyRawAddress(HOTKEY_SEED, TESTNET_NETWORK_ID) + val otherSeed = backend.deriveHotkeyRawAddress(OTHER_HOTKEY_SEED, TESTNET_NETWORK_ID) + val mainnet = backend.deriveHotkeyRawAddress(HOTKEY_SEED, MAINNET_NETWORK_ID) + + assertEquals(DIVERSIFIER_BYTES + FIELD_BYTES, first.size) + assertContentEquals(first, second) + assertFalse(first.contentEquals(otherSeed)) + assertFalse(first.contentEquals(mainnet)) + assertFailsWith { + backend.deriveHotkeyRawAddress(SHORT_FIELD, TESTNET_NETWORK_ID) + } + } + @Test fun extract_nc_root_decodes_tree_state() = runTest { @@ -735,8 +754,9 @@ class VotingRustBackendTest { } @Test - fun build_governance_pczt_explicit_and_seed_paths_match() = + fun build_governance_pczt_explicit_and_seed_paths_produce_valid_pczts() = runTest { + val backend = VotingRustBackend.new() val explicitDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) val seedDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) try { @@ -748,18 +768,8 @@ class VotingRustBackendTest { val explicitPczt = explicitDb.buildTestGovernancePczt(ufvk, notes) val seedPczt = seedDb.buildTestGovernancePcztFromSeed(ufvk, notes) - assertContentEquals(explicitPczt.pcztBytes, seedPczt.pcztBytes) - assertContentEquals(explicitPczt.rk, seedPczt.rk) - assertContentEquals(explicitPczt.sighash, seedPczt.sighash) - assertEquals(explicitPczt.actionIndex, seedPczt.actionIndex) - assertEquals( - JniRoundPhase.DELEGATION_CONSTRUCTED, - assertNotNull(explicitDb.getRoundState(PCZT_ROUND_ID)).roundPhase - ) - assertEquals( - JniRoundPhase.DELEGATION_CONSTRUCTED, - assertNotNull(seedDb.getRoundState(PCZT_ROUND_ID)).roundPhase - ) + assertValidGovernancePczt(backend, explicitDb, explicitPczt) + assertValidGovernancePczt(backend, seedDb, seedPczt) } finally { explicitDb.close() seedDb.close() @@ -791,10 +801,9 @@ class VotingRustBackendTest { options = GovernancePcztOptions(accountIndex = accountIndex) ) - assertContentEquals(explicitPczt.pcztBytes, seedPczt.pcztBytes) - assertContentEquals(explicitPczt.rk, seedPczt.rk) - assertContentEquals(explicitPczt.sighash, seedPczt.sighash) - assertEquals(explicitPczt.actionIndex, seedPczt.actionIndex) + val backend = VotingRustBackend.new() + assertValidGovernancePczt(backend, explicitDb, explicitPczt) + assertValidGovernancePczt(backend, seedDb, seedPczt) } finally { explicitDb.close() seedDb.close() @@ -1572,6 +1581,22 @@ class VotingRustBackendTest { roundName = ROUND_NAME ) + private suspend fun assertValidGovernancePczt( + backend: VotingRustBackend, + db: VotingRustBackend.VotingDb, + pczt: JniGovernancePczt + ) { + assertTrue(pczt.pcztBytes.isNotEmpty()) + assertEquals(FIELD_BYTES, pczt.rk.size) + assertEquals(FIELD_BYTES, pczt.sighash.size) + assertTrue(pczt.actionIndex >= 0) + assertContentEquals(pczt.sighash, backend.extractPcztSighash(pczt.pcztBytes)) + assertEquals( + JniRoundPhase.DELEGATION_CONSTRUCTED, + assertNotNull(db.getRoundState(PCZT_ROUND_ID)).roundPhase + ) + } + private class GovernancePcztOptions( val hotkeySeed: ByteArray = HOTKEY_SEED, val walletSeed: ByteArray = HOTKEY_SEED, 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 c19428d2f..9aafc710f 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 @@ -124,6 +124,12 @@ class VotingRustBackend private constructor() { ?: error("extractOrchardFvkFromUfvk returned null") } + /** + * Derives the raw Orchard address for the voting hotkey. + * + * The hotkey account index is intentionally fixed by the Rust voting backend to match the + * vote-signing path. Do not add an `accountIndex` parameter unless that path changes with it. + */ @Throws(RuntimeException::class) suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, @@ -312,6 +318,13 @@ class VotingRustBackend private constructor() { ?: error("generateHotkey returned null for roundId=$roundId") } + /** + * Builds a governance PCZT for hardware-wallet flows. + * + * This explicit form trusts [fvkBytes] and [hotkeyRawAddress] as caller-derived Keystone + * input. It does not validate a wallet seed against [fvkBytes]. Software-wallet callers that + * have the wallet seed should use [buildGovernancePcztFromSeed] to retain that invariant. + */ @Throws(RuntimeException::class) suspend fun buildGovernancePczt( roundId: String, @@ -339,6 +352,13 @@ class VotingRustBackend private constructor() { ) ?: error("buildGovernancePczt returned null") } + /** + * Builds a governance PCZT for software-wallet flows. + * + * This path derives the Orchard FVK from [walletSeed] and rejects calls where it does not + * match [ufvk]. It also derives the hotkey raw address from [hotkeySeed] using the fixed + * hotkey account index expected by the vote-signing path. + */ @Throws(RuntimeException::class) suspend fun buildGovernancePcztFromSeed( roundId: String, diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt index 4df62c07f..c5b01e251 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/TypesafeVotingBackendImplTest.kt @@ -164,6 +164,87 @@ class TypesafeVotingBackendImplTest { assertEquals(jniNotes.map { it.toVotingNoteInfo() }, notes) } + @Test + fun governance_pczt_methods_forward_arguments_and_map_results() = + runTest { + val jniResult = + jniGovernancePczt( + pcztBytes = field(GOVERNANCE_PCZT_BYTES_FIXTURE), + rk = field(GOVERNANCE_PCZT_RK_FIXTURE), + sighash = field(GOVERNANCE_PCZT_SIGHASH_FIXTURE), + actionIndex = GOVERNANCE_PCZT_ACTION_INDEX + ) + val backend = + RecordingVotingDbBackend( + proofResult = jniDelegationProofResult(), + submissionResult = jniDelegationSubmissionResult(), + keystoneSubmissionResult = jniDelegationSubmissionResult(), + governancePcztResult = jniResult + ) + val db = TypesafeVotingDbImpl(backend) + val fvkBytes = field(1) + val hotkeyRawAddress = field(GOVERNANCE_PCZT_HOTKEY_ADDRESS_FIXTURE) + val seedFingerprint = field(GOVERNANCE_PCZT_SEED_FINGERPRINT_FIXTURE) + val walletSeed = field(GOVERNANCE_PCZT_WALLET_SEED_FIXTURE) + val hotkeySeed = field(GOVERNANCE_PCZT_HOTKEY_SEED_FIXTURE) + val notes = listOf(votingNoteInfo()) + val jniNotes = notes.map { it.toJniNoteInfo() } + + val explicit = + db.buildGovernancePczt( + roundId = "round-explicit", + bundleIndex = GOVERNANCE_PCZT_EXPLICIT_BUNDLE_INDEX, + fvkBytes = fvkBytes, + hotkeyRawAddress = hotkeyRawAddress, + networkId = 1, + accountIndex = GOVERNANCE_PCZT_EXPLICIT_ACCOUNT_INDEX, + notes = notes, + seedFingerprint = seedFingerprint, + roundName = "Round Explicit" + ) + assertEquals(jniResult.toGovernancePcztResult(), explicit) + assertEquals("round-explicit", backend.governancePcztRoundId) + assertEquals(GOVERNANCE_PCZT_EXPLICIT_BUNDLE_INDEX, backend.governancePcztBundleIndex) + assertContentEquals(fvkBytes, backend.governancePcztFvkBytes) + assertContentEquals(hotkeyRawAddress, backend.governancePcztHotkeyRawAddress) + assertEquals(1, backend.governancePcztNetworkId) + assertEquals(GOVERNANCE_PCZT_EXPLICIT_ACCOUNT_INDEX, backend.governancePcztAccountIndex) + assertEquals(jniNotes, backend.governancePcztNotes) + assertContentEquals(seedFingerprint, backend.governancePcztSeedFingerprint) + assertEquals("Round Explicit", backend.governancePcztRoundName) + + val seed = + db.buildGovernancePcztFromSeed( + roundId = "round-seed", + bundleIndex = GOVERNANCE_PCZT_FROM_SEED_BUNDLE_INDEX, + ufvk = "uview-test", + networkId = 0, + accountIndex = GOVERNANCE_PCZT_FROM_SEED_ACCOUNT_INDEX, + notes = notes, + walletSeed = walletSeed, + hotkeySeed = hotkeySeed, + seedFingerprint = seedFingerprint, + roundName = "Round Seed" + ) + assertEquals(jniResult.toGovernancePcztResult(), seed) + assertEquals("round-seed", backend.governancePcztFromSeedRoundId) + assertEquals( + GOVERNANCE_PCZT_FROM_SEED_BUNDLE_INDEX, + backend.governancePcztFromSeedBundleIndex + ) + assertEquals("uview-test", backend.governancePcztFromSeedUfvk) + assertEquals(0, backend.governancePcztFromSeedNetworkId) + assertEquals( + GOVERNANCE_PCZT_FROM_SEED_ACCOUNT_INDEX, + backend.governancePcztFromSeedAccountIndex + ) + assertEquals(jniNotes, backend.governancePcztFromSeedNotes) + assertContentEquals(walletSeed, backend.governancePcztFromSeedWalletSeed) + assertContentEquals(hotkeySeed, backend.governancePcztFromSeedHotkeySeed) + assertContentEquals(seedFingerprint, backend.governancePcztFromSeedSeedFingerprint) + assertEquals("Round Seed", backend.governancePcztFromSeedRoundName) + } + @Test fun delegation_methods_forward_arguments_and_map_results() = runTest { @@ -639,6 +720,19 @@ class TypesafeVotingBackendImplTest { voteRoundId = voteRoundId ) + private fun jniGovernancePczt( + pcztBytes: ByteArray = + ByteArray(PROOF_BYTES) { DEFAULT_GOVERNANCE_PCZT_BYTES_FIXTURE.toByte() }, + rk: ByteArray = field(DEFAULT_GOVERNANCE_PCZT_RK_FIXTURE), + sighash: ByteArray = field(DEFAULT_GOVERNANCE_PCZT_SIGHASH_FIXTURE), + actionIndex: Int = 1 + ) = JniGovernancePczt( + pcztBytes = pcztBytes, + rk = rk, + sighash = sighash, + actionIndex = actionIndex + ) + private fun jniVanWitness( authPath: List = fieldElements(JNI_VAN_WITNESS_PATH_DEPTH), position: Long = 1, @@ -874,6 +968,13 @@ class TypesafeVotingBackendImplTest { private val commitmentRecord: JniCommitmentBundleRecord? = null, private val shareRecords: Array = emptyArray(), private val unconfirmedShareRecords: Array = emptyArray(), + private val governancePcztResult: JniGovernancePczt = + JniGovernancePczt( + pcztBytes = ByteArray(PROOF_BYTES), + rk = ByteArray(JNI_PROTOCOL_FIELD_BYTES_SIZE), + sighash = ByteArray(JNI_PROTOCOL_FIELD_BYTES_SIZE), + actionIndex = 0 + ), private val recoveryLookupException: RuntimeException? = null ) : VotingDbBackend { var storeWitnessesRoundId: String? = null @@ -885,6 +986,25 @@ class TypesafeVotingBackendImplTest { var precomputePirServerUrl: String? = null var precomputeNetworkId: Int? = null var precomputeNotes: List? = null + var governancePcztRoundId: String? = null + var governancePcztBundleIndex: Int? = null + var governancePcztFvkBytes: ByteArray = ByteArray(0) + var governancePcztHotkeyRawAddress: ByteArray = ByteArray(0) + var governancePcztNetworkId: Int? = null + var governancePcztAccountIndex: Int? = null + var governancePcztNotes: List? = null + var governancePcztSeedFingerprint: ByteArray = ByteArray(0) + var governancePcztRoundName: String? = null + var governancePcztFromSeedRoundId: String? = null + var governancePcztFromSeedBundleIndex: Int? = null + var governancePcztFromSeedUfvk: String? = null + var governancePcztFromSeedNetworkId: Int? = null + var governancePcztFromSeedAccountIndex: Int? = null + var governancePcztFromSeedNotes: List? = null + var governancePcztFromSeedWalletSeed: ByteArray = ByteArray(0) + var governancePcztFromSeedHotkeySeed: ByteArray = ByteArray(0) + var governancePcztFromSeedSeedFingerprint: ByteArray = ByteArray(0) + var governancePcztFromSeedRoundName: String? = null var buildAndProveRoundId: String? = null var buildAndProveBundleIndex: Int? = null var buildAndProvePirServerUrl: String? = null @@ -1018,7 +1138,18 @@ class TypesafeVotingBackendImplTest { notes: List, seedFingerprint: ByteArray, roundName: String - ): JniGovernancePczt = unused() + ): JniGovernancePczt { + governancePcztRoundId = roundId + governancePcztBundleIndex = bundleIndex + governancePcztFvkBytes = fvkBytes + governancePcztHotkeyRawAddress = hotkeyRawAddress + governancePcztNetworkId = networkId + governancePcztAccountIndex = accountIndex + governancePcztNotes = notes + governancePcztSeedFingerprint = seedFingerprint + governancePcztRoundName = roundName + return governancePcztResult + } override suspend fun buildGovernancePcztFromSeed( roundId: String, @@ -1031,7 +1162,19 @@ class TypesafeVotingBackendImplTest { hotkeySeed: ByteArray, seedFingerprint: ByteArray, roundName: String - ): JniGovernancePczt = unused() + ): JniGovernancePczt { + governancePcztFromSeedRoundId = roundId + governancePcztFromSeedBundleIndex = bundleIndex + governancePcztFromSeedUfvk = ufvk + governancePcztFromSeedNetworkId = networkId + governancePcztFromSeedAccountIndex = accountIndex + governancePcztFromSeedNotes = notes + governancePcztFromSeedWalletSeed = walletSeed + governancePcztFromSeedHotkeySeed = hotkeySeed + governancePcztFromSeedSeedFingerprint = seedFingerprint + governancePcztFromSeedRoundName = roundName + return governancePcztResult + } override suspend fun storeWitnesses( roundId: String, @@ -1327,5 +1470,20 @@ class TypesafeVotingBackendImplTest { private companion object { private const val PROOF_BYTES = 3 + private const val GOVERNANCE_PCZT_BYTES_FIXTURE = 41 + private const val GOVERNANCE_PCZT_RK_FIXTURE = 43 + private const val GOVERNANCE_PCZT_SIGHASH_FIXTURE = 44 + private const val GOVERNANCE_PCZT_ACTION_INDEX = 2 + private const val GOVERNANCE_PCZT_HOTKEY_ADDRESS_FIXTURE = 4 + private const val GOVERNANCE_PCZT_SEED_FINGERPRINT_FIXTURE = 7 + private const val GOVERNANCE_PCZT_WALLET_SEED_FIXTURE = 10 + private const val GOVERNANCE_PCZT_HOTKEY_SEED_FIXTURE = 13 + private const val GOVERNANCE_PCZT_EXPLICIT_BUNDLE_INDEX = 2 + private const val GOVERNANCE_PCZT_EXPLICIT_ACCOUNT_INDEX = 3 + private const val GOVERNANCE_PCZT_FROM_SEED_BUNDLE_INDEX = 4 + private const val GOVERNANCE_PCZT_FROM_SEED_ACCOUNT_INDEX = 5 + private const val DEFAULT_GOVERNANCE_PCZT_BYTES_FIXTURE = 20 + private const val DEFAULT_GOVERNANCE_PCZT_RK_FIXTURE = 21 + private const val DEFAULT_GOVERNANCE_PCZT_SIGHASH_FIXTURE = 22 } } 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 4416a9f9a..09d508495 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 @@ -45,6 +45,13 @@ internal interface TypesafeVotingBackend { suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray + /** + * Derives the raw Orchard address for the voting hotkey. + * + * The hotkey account index is intentionally fixed by the Rust voting backend to match the + * vote-signing path. Do not add an `accountIndex` parameter unless that path changes with it; + * otherwise delegation can be built for a hotkey that later vote construction cannot sign for. + */ suspend fun deriveHotkeyRawAddress( hotkeySeed: ByteArray, networkId: Int @@ -102,11 +109,19 @@ internal interface TypesafeVotingDb { notes: List ): JniBundleSetupResult + // nosemgrep: kotlin-typesafe-returns-jni-model -- voting internals consume this JNI carrier. suspend fun generateHotkey( roundId: String, seed: ByteArray ): JniVotingHotkey + /** + * Builds a governance PCZT for hardware-wallet flows. + * + * This explicit form trusts [fvkBytes] and [hotkeyRawAddress] as caller-derived Keystone input. + * It does not validate a wallet seed against [fvkBytes]. Software-wallet callers that have the + * wallet seed should use [buildGovernancePcztFromSeed] to retain that invariant. + */ suspend fun buildGovernancePczt( roundId: String, bundleIndex: Int, @@ -119,6 +134,13 @@ internal interface TypesafeVotingDb { roundName: String ): GovernancePcztResult + /** + * Builds a governance PCZT for software-wallet flows. + * + * This path derives the Orchard FVK from [walletSeed] and rejects calls where it does not match + * [ufvk]. It also derives the hotkey raw address from [hotkeySeed] using the fixed hotkey + * account index expected by the vote-signing path. + */ suspend fun buildGovernancePcztFromSeed( roundId: String, bundleIndex: Int, From 79589efab6a305401f06090fa5abbd834c0ccbe1 Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Thu, 14 May 2026 23:49:55 +0200 Subject: [PATCH 5/5] Assert seed PCZT hotkey account --- .../sdk/internal/jni/VotingRustBackendTest.kt | 38 +++++++++++++++-- .../sdk/internal/jni/VotingRustBackend.kt | 36 ++++++++++++++++ .../src/main/rust/voting/delegation.rs | 42 +++++++++++++++---- backend-lib/src/main/rust/voting/util.rs | 30 ++++++++++--- 4 files changed, 131 insertions(+), 15 deletions(-) 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 91757f62a..ccb69502a 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 @@ -779,20 +779,43 @@ class VotingRustBackendTest { @Test fun build_governance_pczt_from_seed_uses_wallet_account_but_hotkey_account_zero() = runTest { + val backend = VotingRustBackend.new() val explicitDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) val seedDb = VotingRustBackend.new().openVotingDb(newDbPath(), WALLET_ID) try { val accountIndex = 1 val notes = notes(noteCount = 6, value = PCZT_NOTE_VALUE) val ufvk = deriveTestUfvk(accountIndex = accountIndex) + val hotkeyAccountZero = + backend.deriveHotkeyRawAddressForAccountFixture( + HOTKEY_SEED, + TESTNET_NETWORK_ID, + ACCOUNT_INDEX + ) + val hotkeyWalletAccount = + backend.deriveHotkeyRawAddressForAccountFixture( + HOTKEY_SEED, + TESTNET_NETWORK_ID, + accountIndex + ) explicitDb.initPcztRoundWithBundles(notes) seedDb.initPcztRoundWithBundles(notes) + assertContentEquals( + hotkeyAccountZero, + backend.deriveHotkeyRawAddress(HOTKEY_SEED, TESTNET_NETWORK_ID) + ) + assertFalse(hotkeyAccountZero.contentEquals(hotkeyWalletAccount)) + val explicitPczt = explicitDb.buildTestGovernancePczt( ufvk = ufvk, notes = notes, - options = GovernancePcztOptions(accountIndex = accountIndex) + options = + GovernancePcztOptions( + hotkeyRawAddress = hotkeyAccountZero, + accountIndex = accountIndex + ) ) val seedPczt = seedDb.buildTestGovernancePcztFromSeed( @@ -801,9 +824,15 @@ class VotingRustBackendTest { options = GovernancePcztOptions(accountIndex = accountIndex) ) - val backend = VotingRustBackend.new() assertValidGovernancePczt(backend, explicitDb, explicitPczt) assertValidGovernancePczt(backend, seedDb, seedPczt) + val seedPcztRecipient = + backend.extractPcztOutputRecipientFixture( + seedPczt.pcztBytes, + seedPczt.actionIndex + ) + assertContentEquals(hotkeyAccountZero, seedPcztRecipient) + assertFalse(seedPcztRecipient.contentEquals(hotkeyWalletAccount)) } finally { explicitDb.close() seedDb.close() @@ -1555,7 +1584,9 @@ class VotingRustBackendTest { roundId = options.roundId, bundleIndex = 1, fvkBytes = backend.extractOrchardFvkFromUfvk(ufvk, options.networkId), - hotkeyRawAddress = backend.deriveHotkeyRawAddress(options.hotkeySeed, options.networkId), + hotkeyRawAddress = + options.hotkeyRawAddress + ?: backend.deriveHotkeyRawAddress(options.hotkeySeed, options.networkId), networkId = options.networkId, accountIndex = options.accountIndex, notes = notes, @@ -1602,6 +1633,7 @@ class VotingRustBackendTest { val walletSeed: ByteArray = HOTKEY_SEED, val networkId: Int = TESTNET_NETWORK_ID, val roundId: String = PCZT_ROUND_ID, + val hotkeyRawAddress: ByteArray? = null, val accountIndex: Int = ACCOUNT_INDEX ) 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 9aafc710f..aec7ecc24 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 @@ -140,6 +140,27 @@ class VotingRustBackend private constructor() { ?: error("deriveHotkeyRawAddress returned null") } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun deriveHotkeyRawAddressForAccountFixture( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray = + withContext(Dispatchers.IO) { + deriveHotkeyRawAddressForAccountFixtureNative(hotkeySeed, networkId, accountIndex) + ?: error("deriveHotkeyRawAddressForAccountFixture returned null") + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun extractPcztOutputRecipientFixture( + pcztBytes: ByteArray, + actionIndex: Int + ): ByteArray = + withContext(Dispatchers.IO) { + extractPcztOutputRecipientFixtureNative(pcztBytes, actionIndex) + ?: error("extractPcztOutputRecipientFixture returned null") + } + @Throws(RuntimeException::class) suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = withContext(Dispatchers.IO) { @@ -856,6 +877,21 @@ class VotingRustBackend private constructor() { networkId: Int ): ByteArray? + @JvmStatic + @Throws(RuntimeException::class) + private external fun deriveHotkeyRawAddressForAccountFixtureNative( + hotkeySeed: ByteArray, + networkId: Int, + accountIndex: Int + ): ByteArray? + + @JvmStatic + @Throws(RuntimeException::class) + private external fun extractPcztOutputRecipientFixtureNative( + pcztBytes: ByteArray, + actionIndex: Int + ): ByteArray? + @JvmStatic @Throws(RuntimeException::class) private external fun extractNcRootNative(treeStateBytes: ByteArray): ByteArray? diff --git a/backend-lib/src/main/rust/voting/delegation.rs b/backend-lib/src/main/rust/voting/delegation.rs index ef6edbe65..1e08ce54b 100644 --- a/backend-lib/src/main/rust/voting/delegation.rs +++ b/backend-lib/src/main/rust/voting/delegation.rs @@ -88,18 +88,18 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_bui java_secret_bytes_at_least(env, &wallet_seed, "walletSeed", PROTOCOL_FIELD_BYTES)?; let hotkey_seed = java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; - let derived_fvk_bytes = - orchard_fvk_bytes_from_wallet_seed(wallet_seed.expose_secret(), network, account_index)?; + let derived_fvk_bytes = orchard_fvk_bytes_from_wallet_seed( + wallet_seed.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( - hotkey_seed.expose_secret(), - network, - HOTKEY_ACCOUNT_INDEX, - )?; + let hotkey_raw_address = + hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, HOTKEY_ACCOUNT_INDEX)?; let seed_fingerprint = java_bytes32(env, &seed_fingerprint, "seedFingerprint")?; let notes = java_note_info_array(env, ¬es, "notes")?; let round_id = java_string_to_rust(env, &round_id)?; @@ -195,6 +195,34 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_ext unwrap_exc_or(&mut env, res, std::ptr::null_mut()) } +#[cfg(feature = "android-test-fixtures")] +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_extractPcztOutputRecipientFixtureNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + pczt_bytes: JByteArray<'local>, + action_index: jint, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + let bytes = java_bytes(env, &pczt_bytes, "pcztBytes")?; + let action_index = jint_to_usize(action_index, "action_index")?; + let pczt = pczt::Pczt::parse(&bytes).map_err(|e| anyhow!("parse PCZT: {:?}", e))?; + let action = pczt.orchard().actions().get(action_index).ok_or_else(|| { + anyhow!( + "PCZT Orchard action index {action_index} out of range; action_count={}", + pczt.orchard().actions().len() + ) + })?; + let recipient = action.output().recipient().as_ref().ok_or_else(|| { + anyhow!("PCZT Orchard action {action_index} output missing recipient") + })?; + Ok(env.byte_array_from_slice(recipient)?.into_raw()) + }); + unwrap_exc_or(&mut env, res, std::ptr::null_mut()) +} + fn extract_indexed_spend_auth_sig( signed_pczt_bytes: &[u8], action_index: usize, diff --git a/backend-lib/src/main/rust/voting/util.rs b/backend-lib/src/main/rust/voting/util.rs index 8a28b7396..63b78d189 100644 --- a/backend-lib/src/main/rust/voting/util.rs +++ b/backend-lib/src/main/rust/voting/util.rs @@ -91,11 +91,31 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_der let network = network_from_id(network_id)?; let hotkey_seed = java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; - let bytes = hotkey_orchard_raw_address( - hotkey_seed.expose_secret(), - network, - HOTKEY_ACCOUNT_INDEX, - )?; + let bytes = + hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, HOTKEY_ACCOUNT_INDEX)?; + Ok(env.byte_array_from_slice(&bytes)?.into_raw()) + }); + unwrap_exc_or(&mut env, res, std::ptr::null_mut()) +} + +#[cfg(feature = "android-test-fixtures")] +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_VotingRustBackend_deriveHotkeyRawAddressForAccountFixtureNative< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + hotkey_seed: JByteArray<'local>, + network_id: jint, + account_index: jint, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + let network = network_from_id(network_id)?; + let account_index = jint_to_u32(account_index, "account_index")?; + let hotkey_seed = + java_secret_bytes_at_least(env, &hotkey_seed, "hotkeySeed", PROTOCOL_FIELD_BYTES)?; + let bytes = + hotkey_orchard_raw_address(hotkey_seed.expose_secret(), network, account_index)?; Ok(env.byte_array_from_slice(&bytes)?.into_raw()) }); unwrap_exc_or(&mut env, res, std::ptr::null_mut())