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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public struct VotingCryptoClient {

// --- Database lifecycle ---
public var openDatabase: @Sendable (_ path: String) async throws -> Void
public var setWalletId: @Sendable (_ walletId: String) async throws -> Void
public var initRound: @Sendable (_ params: VotingRoundParams, _ sessionJson: String?) async throws -> Void
public var getRoundState: @Sendable (_ roundId: String) async throws -> RoundStateInfo
public var getVotes: @Sendable (_ roundId: String) async throws -> [VoteRecord]
Expand All @@ -35,8 +36,7 @@ public struct VotingCryptoClient {
_ walletDbPath: String,
_ snapshotHeight: UInt64,
_ networkId: UInt32,
_ seedFingerprint: [UInt8]?,
_ accountIndex: UInt32?
_ accountUUID: [UInt8]
) async throws -> [NoteInfo]

// --- Bundle management ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ extension VotingCryptoClient: DependencyKey {
openDatabase: { path in
try await dbActor.open(path: path)
},
setWalletId: { walletId in
let backend = try await dbActor.backend()
try backend.setWalletId(walletId)
},
initRound: { params, sessionJson in
let backend = try await dbActor.backend()
let roundIdHex = params.voteRoundId.hexString
Expand Down Expand Up @@ -91,14 +95,13 @@ extension VotingCryptoClient: DependencyKey {
let backend = try await dbActor.backend()
_ = try backend.deleteSkippedBundles(roundId: roundId, keepCount: keepCount)
},
getWalletNotes: { walletDbPath, snapshotHeight, networkId, seedFingerprint, accountIndex in
getWalletNotes: { walletDbPath, snapshotHeight, networkId, accountUUID in
let backend = try await dbActor.backend()
let notes = try backend.getWalletNotes(
walletDbPath: walletDbPath,
snapshotHeight: snapshotHeight,
networkId: networkId,
seedFingerprint: seedFingerprint,
accountIndex: accountIndex.map { Int64($0) } ?? -1
accountUUID: accountUUID
)
return notes.map {
NoteInfo(
Expand Down
2 changes: 2 additions & 0 deletions modules/Sources/Features/Root/RootCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,11 @@ extension Root {
// MARK: - Voting

case .home(.votingBannerTapped):
guard let account = state.selectedWalletAccount else { return .none }
state.homeState.moreRequest = false
state.votingState = .initial
state.votingState.isKeystoneUser = state.homeState.isKeystoneAccountActive
state.votingState.walletId = account.id.id.map { String(format: "%02x", $0) }.joined()
state.path = .voting
return .none

Expand Down
60 changes: 36 additions & 24 deletions modules/Sources/Features/Voting/VotingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
public var votes: [UInt32: VoteChoice] = [:]
public var votingWeight: UInt64
public var isKeystoneUser: Bool
public var walletId: String
public var roundId: String
public var activeSession: VotingSession?

Expand Down Expand Up @@ -426,6 +427,11 @@ public struct Voting { // swiftlint:disable:this type_body_length
return nil
}

/// Compound key scoping recovery files by both round and wallet.
func recoveryKey(for roundId: String) -> String {
"\(roundId)_\(walletId)"
}

public init(
votingRound: VotingRound = VotingRound(
id: "",
Expand All @@ -439,11 +445,13 @@ public struct Voting { // swiftlint:disable:this type_body_length
),
votingWeight: UInt64 = 0,
isKeystoneUser: Bool = false,
walletId: String = "",
roundId: String = ""
) {
self.votingRound = votingRound
self.votingWeight = votingWeight
self.isKeystoneUser = isKeystoneUser
self.walletId = walletId
self.roundId = roundId
}
}
Expand Down Expand Up @@ -691,15 +699,17 @@ public struct Voting { // swiftlint:disable:this type_body_length

case .serviceConfigLoaded(let config):
state.serviceConfig = config
let walletId = state.walletId
return .run { [votingAPI, votingCrypto] send in
// 2. Configure API client URLs
await votingAPI.configureURLs(config)

// 3. Open voting database
// 3. Open voting database and scope to current wallet
let dbPath = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("voting.sqlite3").path
try await votingCrypto.openDatabase(dbPath)
try await votingCrypto.setWalletId(walletId)

// 4. Fetch all rounds and populate the list
let allRounds = try await votingAPI.fetchAllRounds()
Expand All @@ -723,9 +733,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
let networkId: UInt32 = network.networkType == .mainnet ? 0 : 1
let snapshotHeight = session.snapshotHeight
let roundId = session.voteRoundId.hexString
// Capture account identity for filtering notes to the selected account
let accountSeedFingerprint: [UInt8]? = state.selectedWalletAccount?.seedFingerprint
let accountIndex: UInt32? = state.selectedWalletAccount?.zip32AccountIndex.map { UInt32($0.index) }
let accountUUID: [UInt8] = state.selectedWalletAccount?.id.id ?? []
return .run { [votingCrypto, mnemonic, walletStorage, sdkSynchronizer] send in
// Check wallet sync progress before querying notes
let walletScannedHeight = UInt64(sdkSynchronizer.latestState().latestBlockHeight)
Expand All @@ -739,8 +747,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
walletDbPath,
snapshotHeight,
networkId,
accountSeedFingerprint,
accountIndex
accountUUID
)
let totalWeight = notes.reduce(UInt64(0)) { $0 + $1.value }
logger.info("Loaded \(notes.count) notes at height \(snapshotHeight), total weight: \(totalWeight)")
Expand Down Expand Up @@ -1004,6 +1011,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
}
state.witnessTiming = nil
let roundId = activeSession.voteRoundId.hexString
let recoveryRoundKey = state.recoveryKey(for: roundId)
let snapshotHeight = activeSession.snapshotHeight
let notes = state.walletNotes
let network = zcashSDKEnvironment.network
Expand All @@ -1019,7 +1027,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
}

// --- Crash recovery: check if some delegation TXs already landed on-chain ---
let recovery = await votingCrypto.getRecoveryState(roundId)
let recovery = await votingCrypto.getRecoveryState(recoveryRoundKey)
if !recovery.delegationTxHashes.isEmpty {
logger.info("Recovery: found \(recovery.delegationTxHashes.count) stored delegation TX hashes, reconciling...")
var recoveredPositions: [UInt32: UInt32] = [:]
Expand All @@ -1038,7 +1046,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
if bundleCount > 0 && UInt32(recoveredPositions.count) >= bundleCount {
// All bundles already on-chain — mark as complete and resume
logger.info("Recovery: all \(bundleCount) bundles recovered from chain, resuming to proposal list")
await votingCrypto.clearRecoveryState(roundId)
await votingCrypto.clearRecoveryState(recoveryRoundKey)
await send(.roundResumeChecked(alreadyAuthorized: true))
return
} else if !recoveredPositions.isEmpty {
Expand All @@ -1058,7 +1066,7 @@ public struct Voting { // swiftlint:disable:this type_body_length

// Fresh round — clear and initialize
try? await votingCrypto.clearRound(roundId)
await votingCrypto.clearRecoveryState(roundId)
await votingCrypto.clearRecoveryState(recoveryRoundKey)
let params = VotingRoundParams(
voteRoundId: activeSession.voteRoundId,
snapshotHeight: snapshotHeight,
Expand Down Expand Up @@ -1160,9 +1168,9 @@ public struct Voting { // swiftlint:disable:this type_body_length
return .send(.startDelegationProof)
}
// Keystone: check for persisted signatures from a previous session
let roundId = state.roundId
let recoveryRoundKey = state.recoveryKey(for: state.roundId)
return .run { [votingCrypto] send in
let savedSigs = await votingCrypto.loadKeystoneBundleSignatures(roundId)
let savedSigs = await votingCrypto.loadKeystoneBundleSignatures(recoveryRoundKey)
if !savedSigs.isEmpty {
logger.info("Keystone recovery: found \(savedSigs.count) persisted signatures, resuming batch prove")
await send(.keystoneSignaturesRestored(savedSigs))
Expand Down Expand Up @@ -1214,8 +1222,9 @@ public struct Voting { // swiftlint:disable:this type_body_length
case .bundleCountRestored(let count):
state.bundleCount = count
let roundId = state.roundId
let recoveryRoundKey = state.recoveryKey(for: roundId)
return .run { [votingCrypto] send in
let recovery = await votingCrypto.getRecoveryState(roundId)
let recovery = await votingCrypto.getRecoveryState(recoveryRoundKey)
guard !recovery.voteTxHashes.isEmpty else { return }
// Find the first in-flight vote: a TX hash exists but the vote
// isn't marked as submitted in the DB yet.
Expand Down Expand Up @@ -1335,6 +1344,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
state.delegationProofStatus = .generating(progress: 0)
}
let roundId = activeSession.voteRoundId.hexString
let recoveryRoundKey = state.recoveryKey(for: roundId)
let cachedNotes = state.walletNotes
let network = zcashSDKEnvironment.network
let walletDbPath = databaseFiles.dataDbURLFor(network).path
Expand Down Expand Up @@ -1412,7 +1422,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
// are processed here starting from the first incomplete one.
let noteChunks = cachedNotes.smartBundles().bundles
let bundleCount = UInt32(noteChunks.count)
let recoveryState = await votingCrypto.getRecoveryState(roundId)
let recoveryState = await votingCrypto.getRecoveryState(recoveryRoundKey)
let completedBundles = Set(recoveryState.delegationTxHashes.keys)

for bundleIndex: UInt32 in 0..<bundleCount {
Expand Down Expand Up @@ -1470,7 +1480,7 @@ public struct Voting { // swiftlint:disable:this type_body_length

// Persist TX hash immediately so we can recover if the app
// crashes before storeVanPosition completes.
await votingCrypto.storeDelegationTxHash(roundId, bundleIndex, delegTxResult.txHash)
await votingCrypto.storeDelegationTxHash(recoveryRoundKey, bundleIndex, delegTxResult.txHash)

// Poll until the TX lands, then extract the VAN leaf index
// from the delegate_vote event rather than inferring from tree growth.
Expand Down Expand Up @@ -1584,15 +1594,15 @@ public struct Voting { // swiftlint:disable:this type_body_length
state.pendingUnsignedDelegationPczt = nil

// Persist to recovery store so signatures survive app restarts
let roundId = state.roundId
let recoveryRoundKey = state.recoveryKey(for: state.roundId)
let sigInfo = KeystoneBundleSignatureInfo(
bundleIndex: bundleIndex,
sig: signature.sig,
sighash: signature.sighash,
rk: signature.rk
)
let persistEffect: Effect<Action> = .run { [votingCrypto] _ in
await votingCrypto.storeKeystoneBundleSignature(roundId, sigInfo)
await votingCrypto.storeKeystoneBundleSignature(recoveryRoundKey, sigInfo)
}

if bundleIndex + 1 < bundleCount {
Expand All @@ -1618,6 +1628,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
}

let roundId = activeSession.voteRoundId.hexString
let recoveryRoundKey = state.recoveryKey(for: roundId)
let cachedNotes = state.walletNotes
let network = zcashSDKEnvironment.network
let walletDbPath = databaseFiles.dataDbURLFor(network).path
Expand All @@ -1635,7 +1646,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
let hotkeyPhrase = try walletStorage.exportVotingHotkey().seedPhrase.value()
let hotkeySeed = try mnemonic.toSeed(hotkeyPhrase)
let noteChunks = cachedNotes.smartBundles().bundles
let recoveryState = await votingCrypto.getRecoveryState(roundId)
let recoveryState = await votingCrypto.getRecoveryState(recoveryRoundKey)
let completedBundles = Set(recoveryState.delegationTxHashes.keys)

for (bundleIndex, sig) in storedSignatures.enumerated() {
Expand Down Expand Up @@ -1690,7 +1701,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
logger.info("Delegation TX \(bundleIdx) submitted: \(delegTxResult.txHash)")

// Persist TX hash for crash recovery
await votingCrypto.storeDelegationTxHash(roundId, bundleIdx, delegTxResult.txHash)
await votingCrypto.storeDelegationTxHash(recoveryRoundKey, bundleIdx, delegTxResult.txHash)

let delegDeadline = Date().addingTimeInterval(90)
var delegConfirmation: TxConfirmation?
Expand Down Expand Up @@ -1812,9 +1823,9 @@ public struct Voting { // swiftlint:disable:this type_body_length
state.isDelegationProofInFlight = false
state.currentKeystoneBundleIndex = 0
state.keystoneBundleSignatures = []
let roundId = state.roundId
let recoveryRoundKey = state.recoveryKey(for: state.roundId)
return .run { [votingCrypto] _ in
await votingCrypto.clearRecoveryState(roundId)
await votingCrypto.clearRecoveryState(recoveryRoundKey)
}

case .delegationProofFailed(let error):
Expand Down Expand Up @@ -1868,6 +1879,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
let choice = pending.choice
let numOptions = UInt32(state.votingRound.proposals.first { $0.id == proposalId }?.options.count ?? 3)
let roundId = state.roundId
let recoveryRoundKey = state.recoveryKey(for: roundId)
let network = zcashSDKEnvironment.network
let networkId: UInt32 = network.networkType == .mainnet ? 0 : 1
let chainNodeUrl = state.serviceConfig?.voteServers.first?.url ?? "https://46-101-255-48.sslip.io"
Expand Down Expand Up @@ -1895,7 +1907,7 @@ public struct Voting { // swiftlint:disable:this type_body_length

// --- Crash recovery: check if this bundle's vote TX landed on-chain
// but wasn't marked as submitted (Dead State D/E). ---
if let cachedTxHash = await votingCrypto.getVoteTxHash(roundId, bundleIndex, proposalId) {
if let cachedTxHash = await votingCrypto.getVoteTxHash(recoveryRoundKey, bundleIndex, proposalId) {
logger.info("Vote recovery: found cached TX hash for bundle \(bundleIndex), checking chain...")
if let confirmation = try? await votingAPI.fetchTxConfirmation(cachedTxHash),
confirmation.code == 0,
Expand All @@ -1909,7 +1921,7 @@ public struct Voting { // swiftlint:disable:this type_body_length

// Complete share delegation using the persisted bundle.
// Without shares, the tally cannot decrypt the vote.
if let savedBundle = await votingCrypto.getVoteCommitmentBundle(roundId, bundleIndex, proposalId) {
if let savedBundle = await votingCrypto.getVoteCommitmentBundle(recoveryRoundKey, bundleIndex, proposalId) {
await send(.voteSubmissionStepUpdated(.sendingShares))
let payloads = try await votingCrypto.buildSharePayloads(
savedBundle.encShares, savedBundle, choice, numOptions, vcIdx
Expand Down Expand Up @@ -1978,7 +1990,7 @@ public struct Voting { // swiftlint:disable:this type_body_length

// Persist the bundle before submission so encrypted shares survive a crash.
// Share delegation requires the original ciphertexts committed on-chain.
await votingCrypto.storeVoteCommitmentBundle(roundId, bundleIndex, proposalId, builtBundle)
await votingCrypto.storeVoteCommitmentBundle(recoveryRoundKey, bundleIndex, proposalId, builtBundle)

// Sign the cast-vote TX (sighash + spend auth signature)
let castVoteSig = try await votingCrypto.signCastVote(
Expand All @@ -1992,7 +2004,7 @@ public struct Voting { // swiftlint:disable:this type_body_length
await send(.voteCommitmentSubmitted(txHash))

// Persist TX hash immediately for crash recovery
await votingCrypto.storeVoteTxHash(roundId, bundleIndex, proposalId, txHash)
await votingCrypto.storeVoteTxHash(recoveryRoundKey, bundleIndex, proposalId, txHash)

// Poll until our TX lands and extract exact leaf positions
// from the cast_vote event (emits "vanIdx,vcIdx").
Expand Down