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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RuntimeException> {
backend.deriveHotkeyRawAddress(SHORT_FIELD, TESTNET_NETWORK_ID)
}
}

@Test
fun extract_nc_root_decodes_tree_state() =
runTest {
Expand Down Expand Up @@ -664,7 +684,7 @@ class VotingRustBackendTest {
db.buildTestGovernancePczt(ufvk, mismatchedSamePositionNotesJson)
}
assertFailsWith<RuntimeException> {
db.buildTestGovernancePczt(mismatchedUfvk, notes)
db.buildTestGovernancePcztFromSeed(mismatchedUfvk, notes)
}
} finally {
db.close()
Expand Down Expand Up @@ -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() =
Comment thread
greg0x marked this conversation as resolved.
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<RuntimeException> {
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 {
Expand All @@ -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())
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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<JniNoteInfo>,
Expand All @@ -1442,22 +1577,66 @@ class VotingRustBackendTest {
private suspend fun VotingRustBackend.VotingDb.buildTestGovernancePczt(
ufvk: String,
notes: List<JniNoteInfo>,
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<JniNoteInfo>,
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,
Expand Down
Loading