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