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..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 @@ -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 @@ -158,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 { @@ -664,7 +684,7 @@ class VotingRustBackendTest { db.buildTestGovernancePczt(ufvk, mismatchedSamePositionNotesJson) } assertFailsWith { - db.buildTestGovernancePczt(mismatchedUfvk, notes) + db.buildTestGovernancePcztFromSeed(mismatchedUfvk, notes) } } finally { db.close() @@ -733,6 +753,120 @@ class VotingRustBackendTest { } } + @Test + 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 { + 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) + + assertValidGovernancePczt(backend, explicitDb, explicitPczt) + assertValidGovernancePczt(backend, seedDb, seedPczt) + } finally { + explicitDb.close() + seedDb.close() + } + } + + @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( + hotkeyRawAddress = hotkeyAccountZero, + accountIndex = accountIndex + ) + ) + val seedPczt = + seedDb.buildTestGovernancePcztFromSeed( + ufvk = ufvk, + notes = notes, + options = GovernancePcztOptions(accountIndex = accountIndex) + ) + + 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() + } + } + + @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, + options = GovernancePcztOptions(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 { @@ -746,7 +880,7 @@ class VotingRustBackendTest { db.buildTestGovernancePczt( ufvk = ufvk, notes = notes, - networkId = MAINNET_NETWORK_ID + options = GovernancePcztOptions(networkId = MAINNET_NETWORK_ID) ) assertTrue(pczt.pcztBytes.isNotEmpty()) @@ -868,7 +1002,7 @@ class VotingRustBackendTest { pirServerUrl = "http://127.0.0.1:1", networkId = TESTNET_NETWORK_ID, notes = notes, - hotkeySeed = SHORT_FIELD, + hotkeyRawAddress = SHORT_FIELD, proofProgress = null ) } @@ -1415,12 +1549,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, @@ -1442,22 +1577,66 @@ 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 - ) = buildGovernancePczt( - roundId = roundId, + options: GovernancePcztOptions = GovernancePcztOptions() + ): JniGovernancePczt { + val backend = VotingRustBackend.new() + return buildGovernancePczt( + roundId = options.roundId, + bundleIndex = 1, + fvkBytes = backend.extractOrchardFvkFromUfvk(ufvk, options.networkId), + hotkeyRawAddress = + options.hotkeyRawAddress + ?: backend.deriveHotkeyRawAddress(options.hotkeySeed, options.networkId), + networkId = options.networkId, + accountIndex = options.accountIndex, + notes = notes, + seedFingerprint = SEED_FINGERPRINT, + roundName = ROUND_NAME + ) + } + + private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePcztFromSeed( + ufvk: String, + notes: List, + options: GovernancePcztOptions = GovernancePcztOptions() + ) = buildGovernancePcztFromSeed( + roundId = options.roundId, bundleIndex = 1, ufvk = ufvk, - networkId = networkId, - accountIndex = ACCOUNT_INDEX, + networkId = options.networkId, + accountIndex = options.accountIndex, notes = notes, - walletSeed = HOTKEY_SEED, - hotkeySeed = hotkeySeed, + walletSeed = options.walletSeed, + hotkeySeed = options.hotkeySeed, seedFingerprint = SEED_FINGERPRINT, 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, + val networkId: Int = TESTNET_NETWORK_ID, + val roundId: String = PCZT_ROUND_ID, + val hotkeyRawAddress: ByteArray? = null, + val accountIndex: Int = ACCOUNT_INDEX + ) + private fun notes( noteCount: Int, value: Long = NOTE_VALUE, 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..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 @@ -124,6 +124,43 @@ 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, + networkId: Int + ): ByteArray = + withContext(Dispatchers.IO) { + deriveHotkeyRawAddressNative(hotkeySeed, networkId) + ?: 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) { @@ -302,8 +339,49 @@ 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, + 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") + } + + /** + * 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, bundleIndex: Int, ufvk: String, @@ -316,7 +394,7 @@ class VotingRustBackend private constructor() { roundName: String ): JniGovernancePczt = withHandle { handle -> - buildGovernancePcztNative( + buildGovernancePcztFromSeedNative( handle, roundId, bundleIndex, @@ -328,7 +406,7 @@ class VotingRustBackend private constructor() { hotkeySeed, seedFingerprint, roundName - ) ?: error("buildGovernancePczt returned null") + ) ?: error("buildGovernancePcztFromSeed returned null") } @Throws(RuntimeException::class) @@ -373,7 +451,7 @@ class VotingRustBackend private constructor() { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult = withHandle { handle -> @@ -384,7 +462,7 @@ class VotingRustBackend private constructor() { pirServerUrl, networkId, notes.toTypedArray(), - hotkeySeed, + hotkeyRawAddress, proofProgress?.withVotingDbReentryGuard() ) ?: error("buildAndProveDelegation returned null") } @@ -792,6 +870,28 @@ class VotingRustBackend private constructor() { networkId: Int ): ByteArray? + @JvmStatic + @Throws(RuntimeException::class) + private external fun deriveHotkeyRawAddressNative( + hotkeySeed: ByteArray, + 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? @@ -880,6 +980,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 +1069,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..1e08ce54b 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,82 @@ 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)?; + 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, 0)?; + 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 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, @@ -110,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, @@ -221,13 +334,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 +348,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/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 3e22f7913..63b78d189 100644 --- a/backend-lib/src/main/rust/voting/util.rs +++ b/backend-lib/src/main/rust/voting/util.rs @@ -68,6 +68,59 @@ 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, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + hotkey_seed: JByteArray<'local>, + network_id: jint, +) -> jbyteArray { + let res = catch_unwind(&mut env, |env| { + 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)?; + 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()) +} + #[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..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 { @@ -203,7 +284,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 +323,7 @@ class TypesafeVotingBackendImplTest { pirServerUrl = "https://pir.example", networkId = 1, notes = notes, - hotkeySeed = walletSeed + hotkeyRawAddress = hotkeyRawAddress ) { progress -> progressValue = progress } @@ -251,7 +332,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) @@ -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, @@ -802,6 +896,11 @@ class TypesafeVotingBackendImplTest { networkId: Int ): ByteArray = unused() + override suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int + ): ByteArray = unused() + override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = unused() override suspend fun verifyWitness(witness: JniWitnessData): Boolean = unused() @@ -869,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 @@ -880,12 +986,31 @@ 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 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 +1129,29 @@ 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 { + 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, bundleIndex: Int, ufvk: String, @@ -1014,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, @@ -1049,7 +1209,7 @@ class TypesafeVotingBackendImplTest { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult { buildAndProveRoundId = roundId @@ -1057,7 +1217,7 @@ class TypesafeVotingBackendImplTest { buildAndProvePirServerUrl = pirServerUrl buildAndProveNetworkId = networkId buildAndProveNotes = notes - buildAndProveHotkeySeed = hotkeySeed + buildAndProveHotkeyRawAddress = hotkeyRawAddress buildAndProveProgress = proofProgress return proofResult } @@ -1310,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 2418a7baf..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,18 @@ 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 + ): ByteArray + suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray suspend fun verifyWitness(witness: JniWitnessData): Boolean @@ -97,12 +109,39 @@ 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, + fvkBytes: ByteArray, + hotkeyRawAddress: ByteArray, + networkId: Int, + accountIndex: Int, + notes: List, + seedFingerprint: ByteArray, + 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, ufvk: String, @@ -136,7 +175,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..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 @@ -108,6 +108,12 @@ internal class TypesafeVotingBackendImpl( override suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray = rustBackend().extractOrchardFvkFromUfvk(ufvk, networkId) + override suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int + ): ByteArray = + rustBackend().deriveHotkeyRawAddress(hotkeySeed, networkId) + override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = rustBackend().extractNcRoot(treeStateBytes) @@ -173,6 +179,11 @@ internal interface VotingBackendBridge { suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray + suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int + ): ByteArray + suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray suspend fun verifyWitness(witness: JniWitnessData): Boolean @@ -240,6 +251,12 @@ private class RustVotingBackendBridge( override suspend fun extractOrchardFvkFromUfvk(ufvk: String, networkId: Int): ByteArray = rustBackend.extractOrchardFvkFromUfvk(ufvk, networkId) + override suspend fun deriveHotkeyRawAddress( + hotkeySeed: ByteArray, + networkId: Int + ): ByteArray = + rustBackend.deriveHotkeyRawAddress(hotkeySeed, networkId) + override suspend fun extractNcRoot(treeStateBytes: ByteArray): ByteArray = rustBackend.extractNcRoot(treeStateBytes) @@ -303,6 +320,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 +365,7 @@ internal interface VotingDbBackend { pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult @@ -511,6 +540,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 +574,7 @@ private class RustVotingDbBackend( seedFingerprint: ByteArray, roundName: String ): JniGovernancePczt = - votingDb.buildGovernancePczt( + votingDb.buildGovernancePcztFromSeed( roundId, bundleIndex, ufvk, @@ -563,7 +615,7 @@ private class RustVotingDbBackend( pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: VotingProofProgressCallback? ): JniDelegationProofResult = votingDb.buildAndProveDelegation( @@ -572,7 +624,7 @@ private class RustVotingDbBackend( pirServerUrl, networkId, notes, - hotkeySeed, + hotkeyRawAddress, proofProgress ) @@ -810,6 +862,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 +898,7 @@ internal class TypesafeVotingDbImpl( roundName: String ): GovernancePcztResult = votingDb - .buildGovernancePczt( + .buildGovernancePcztFromSeed( roundId, bundleIndex, ufvk, @@ -864,7 +940,7 @@ internal class TypesafeVotingDbImpl( pirServerUrl: String, networkId: Int, notes: List, - hotkeySeed: ByteArray, + hotkeyRawAddress: ByteArray, proofProgress: ((Double) -> Unit)? ): DelegationProofResult = votingDb @@ -874,7 +950,7 @@ internal class TypesafeVotingDbImpl( pirServerUrl, networkId, notes.toJniNoteInfos(), - hotkeySeed, + hotkeyRawAddress, proofProgress?.asVotingProgressCallback() ).toDelegationProofResult()