diff --git a/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientInterface.swift b/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientInterface.swift index 91859e082..52103f556 100644 --- a/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientInterface.swift +++ b/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientInterface.swift @@ -155,61 +155,58 @@ public struct VotingCryptoClient { /// Extract the Orchard nc_root from a protobuf-encoded TreeState. public var extractNcRoot: @Sendable (_ treeStateBytes: Data) throws -> Data - // --- Crash recovery (Swift-side JSON file alongside SQLite DB) --- + // --- Recovery state (stored in the voting SQLite DB) --- - /// Persist a delegation TX hash for a bundle immediately after submission. + /// Store the TX hash of a delegation bundle that has been submitted to the chain. public var storeDelegationTxHash: @Sendable ( _ roundId: String, _ bundleIndex: UInt32, _ txHash: String - ) async -> Void = { _, _, _ in } + ) async throws -> Void /// Load a previously stored delegation TX hash for a bundle (nil if never stored). public var getDelegationTxHash: @Sendable ( _ roundId: String, _ bundleIndex: UInt32 - ) async -> String? = { _, _ in nil } + ) async throws -> String? /// Persist a vote TX hash for a bundle + proposal immediately after submission. public var storeVoteTxHash: @Sendable ( _ roundId: String, _ bundleIndex: UInt32, _ proposalId: UInt32, _ txHash: String - ) async -> Void = { _, _, _, _ in } + ) async throws -> Void /// Load a previously stored vote TX hash (nil if never stored). public var getVoteTxHash: @Sendable ( _ roundId: String, _ bundleIndex: UInt32, _ proposalId: UInt32 - ) async -> String? = { _, _, _ in nil } + ) async throws -> String? /// Persist a Keystone bundle signature so it survives app restarts. public var storeKeystoneBundleSignature: @Sendable ( _ roundId: String, _ info: KeystoneBundleSignatureInfo - ) async -> Void = { _, _ in } + ) async throws -> Void /// Load all persisted Keystone bundle signatures for a round. public var loadKeystoneBundleSignatures: @Sendable ( _ roundId: String - ) async -> [KeystoneBundleSignatureInfo] = { _ in [] } - /// Persist the vote commitment bundle (with encrypted shares) before TX submission. + ) async throws -> [KeystoneBundleSignatureInfo] + /// Persist the vote commitment bundle + VC tree position before TX submission. /// Required for share delegation if the app crashes between TX confirm and share send. public var storeVoteCommitmentBundle: @Sendable ( _ roundId: String, _ bundleIndex: UInt32, _ proposalId: UInt32, - _ bundle: VoteCommitmentBundle - ) async -> Void = { _, _, _, _ in } + _ bundle: VoteCommitmentBundle, + _ vcTreePosition: UInt64 + ) async throws -> Void /// Load a persisted vote commitment bundle (nil if never stored). public var getVoteCommitmentBundle: @Sendable ( _ roundId: String, _ bundleIndex: UInt32, _ proposalId: UInt32 - ) async -> VoteCommitmentBundle? = { _, _, _ in nil } - /// Load the full recovery state for a round. - public var getRecoveryState: @Sendable ( - _ roundId: String - ) async -> RoundRecoveryState = { _ in RoundRecoveryState() } - /// Clear recovery state for a round (called after successful completion). + ) async throws -> VoteCommitmentBundle? + /// Clear recovery state for a round (keystone sigs, TX hashes). public var clearRecoveryState: @Sendable ( _ roundId: String - ) async -> Void = { _ in } + ) async throws -> Void } diff --git a/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientLiveKey.swift b/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientLiveKey.swift index 4038895dd..82e9c7307 100644 --- a/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientLiveKey.swift +++ b/modules/Sources/Dependencies/VotingCryptoClient/VotingCryptoClientLiveKey.swift @@ -489,48 +489,49 @@ extension VotingCryptoClient: DependencyKey { Data(try VotingRustBackend.extractNcRoot(treeStateBytes: [UInt8](treeStateBytes))) }, storeDelegationTxHash: { roundId, bundleIndex, txHash in - await RecoveryFileStore.shared.update(roundId: roundId) { state in - state.delegationTxHashes[bundleIndex] = txHash - } + let backend = try await dbActor.backend() + try backend.storeDelegationTxHash(roundId: roundId, bundleIndex: bundleIndex, txHash: txHash) }, getDelegationTxHash: { roundId, bundleIndex in - await RecoveryFileStore.shared.load(roundId: roundId).delegationTxHashes[bundleIndex] + let backend = try await dbActor.backend() + return try backend.getDelegationTxHash(roundId: roundId, bundleIndex: bundleIndex) }, storeVoteTxHash: { roundId, bundleIndex, proposalId, txHash in - let key = RoundRecoveryState.voteTxKey(bundleIndex: bundleIndex, proposalId: proposalId) - await RecoveryFileStore.shared.update(roundId: roundId) { state in - state.voteTxHashes[key] = txHash - } + let backend = try await dbActor.backend() + try backend.storeVoteTxHash(roundId: roundId, bundleIndex: bundleIndex, proposalId: proposalId, txHash: txHash) }, getVoteTxHash: { roundId, bundleIndex, proposalId in - let key = RoundRecoveryState.voteTxKey(bundleIndex: bundleIndex, proposalId: proposalId) - return await RecoveryFileStore.shared.load(roundId: roundId).voteTxHashes[key] + let backend = try await dbActor.backend() + return try backend.getVoteTxHash(roundId: roundId, bundleIndex: bundleIndex, proposalId: proposalId) }, storeKeystoneBundleSignature: { roundId, info in - await RecoveryFileStore.shared.update(roundId: roundId) { state in - state.keystoneSignatures.removeAll { $0.bundleIndex == info.bundleIndex } - state.keystoneSignatures.append(info) - state.keystoneSignatures.sort { $0.bundleIndex < $1.bundleIndex } - } + let backend = try await dbActor.backend() + try backend.storeKeystoneSignature(roundId: roundId, bundleIndex: info.bundleIndex, sig: info.sig, sighash: info.sighash, rk: info.rk) }, loadKeystoneBundleSignatures: { roundId in - await RecoveryFileStore.shared.load(roundId: roundId).keystoneSignatures - }, - storeVoteCommitmentBundle: { roundId, bundleIndex, proposalId, bundle in - let key = RoundRecoveryState.voteTxKey(bundleIndex: bundleIndex, proposalId: proposalId) - await RecoveryFileStore.shared.update(roundId: roundId) { state in - state.voteCommitmentBundles[key] = bundle + let backend = try await dbActor.backend() + return try backend.getKeystoneSignatures(roundId: roundId).map { + KeystoneBundleSignatureInfo( + bundleIndex: $0.bundleIndex, + sig: Data($0.sig), + sighash: Data($0.sighash), + rk: Data($0.rk) + ) } }, - getVoteCommitmentBundle: { roundId, bundleIndex, proposalId in - let key = RoundRecoveryState.voteTxKey(bundleIndex: bundleIndex, proposalId: proposalId) - return await RecoveryFileStore.shared.load(roundId: roundId).voteCommitmentBundles[key] + storeVoteCommitmentBundle: { roundId, bundleIndex, proposalId, bundle, vcTreePosition in + let backend = try await dbActor.backend() + let json = String(data: try JSONEncoder().encode(bundle), encoding: .utf8) ?? "{}" + try backend.storeCommitmentBundle(roundId: roundId, bundleIndex: bundleIndex, proposalId: proposalId, bundleJson: json, vcTreePosition: vcTreePosition) }, - getRecoveryState: { roundId in - await RecoveryFileStore.shared.load(roundId: roundId) + getVoteCommitmentBundle: { roundId, bundleIndex, proposalId in + let backend = try await dbActor.backend() + guard let result = try backend.getCommitmentBundle(roundId: roundId, bundleIndex: bundleIndex, proposalId: proposalId) else { return nil } + return try JSONDecoder().decode(VoteCommitmentBundle.self, from: Data(result.json.utf8)) }, clearRecoveryState: { roundId in - await RecoveryFileStore.shared.clear(roundId: roundId) + let backend = try await dbActor.backend() + try backend.clearRecoveryState(roundId: roundId) } ) } @@ -562,48 +563,6 @@ private actor DatabaseActor { } } -// MARK: - RecoveryFileStore - -/// Persists per-round recovery state (TX hashes, Keystone signatures) to a JSON file -/// in the Documents directory. Sits alongside the voting SQLite DB and survives app -/// restarts without requiring Rust backend changes. -private actor RecoveryFileStore { - static let shared = RecoveryFileStore() - private var cache: [String: RoundRecoveryState] = [:] - - private func fileURL(roundId: String) -> URL { - FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("voting-recovery-\(roundId).json") - } - - func load(roundId: String) -> RoundRecoveryState { - if let cached = cache[roundId] { return cached } - let url = fileURL(roundId: roundId) - guard let data = try? Data(contentsOf: url), - let state = try? JSONDecoder().decode(RoundRecoveryState.self, from: data) - else { return RoundRecoveryState() } - cache[roundId] = state - return state - } - - func update(roundId: String, _ mutation: (inout RoundRecoveryState) -> Void) { - var state = load(roundId: roundId) - mutation(&state) - cache[roundId] = state - let url = fileURL(roundId: roundId) - if let data = try? JSONEncoder().encode(state) { - try? data.write(to: url, options: .atomic) - } - } - - func clear(roundId: String) { - cache.removeValue(forKey: roundId) - let url = fileURL(roundId: roundId) - try? FileManager.default.removeItem(at: url) - } -} - // MARK: - Helpers enum VotingCryptoError: LocalizedError { diff --git a/modules/Sources/Dependencies/VotingModels/VotingModels.swift b/modules/Sources/Dependencies/VotingModels/VotingModels.swift index b5ef90639..db3da8183 100644 --- a/modules/Sources/Dependencies/VotingModels/VotingModels.swift +++ b/modules/Sources/Dependencies/VotingModels/VotingModels.swift @@ -730,36 +730,6 @@ public struct KeystoneBundleSignatureInfo: Equatable, Sendable, Codable { } } -/// Lightweight state persisted to a JSON file alongside the SQLite DB to track -/// in-flight TX hashes and Keystone signatures that would otherwise be lost on crash. -public struct RoundRecoveryState: Equatable, Sendable, Codable { - /// Delegation TX hashes by bundle index, stored immediately after submitDelegation returns. - public var delegationTxHashes: [UInt32: String] - /// Vote TX hashes by bundle index + proposal ID: "bundleIndex-proposalId" -> txHash. - public var voteTxHashes: [String: String] - /// Vote commitment bundles (containing encrypted shares) persisted before TX submission. - /// Keyed the same as voteTxHashes. Required for share delegation after crash recovery. - public var voteCommitmentBundles: [String: VoteCommitmentBundle] - /// Keystone signatures collected during the multi-bundle signing loop. - public var keystoneSignatures: [KeystoneBundleSignatureInfo] - - public init( - delegationTxHashes: [UInt32: String] = [:], - voteTxHashes: [String: String] = [:], - voteCommitmentBundles: [String: VoteCommitmentBundle] = [:], - keystoneSignatures: [KeystoneBundleSignatureInfo] = [] - ) { - self.delegationTxHashes = delegationTxHashes - self.voteTxHashes = voteTxHashes - self.voteCommitmentBundles = voteCommitmentBundles - self.keystoneSignatures = keystoneSignatures - } - - public static func voteTxKey(bundleIndex: UInt32, proposalId: UInt32) -> String { - "\(bundleIndex)-\(proposalId)" - } -} - // MARK: - Proof Events public enum ProofEvent: Equatable, Sendable { diff --git a/modules/Sources/Features/Voting/VotingStore.swift b/modules/Sources/Features/Voting/VotingStore.swift index 1537d354b..c719029fd 100644 --- a/modules/Sources/Features/Voting/VotingStore.swift +++ b/modules/Sources/Features/Voting/VotingStore.swift @@ -430,11 +430,6 @@ 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: "", @@ -1014,7 +1009,6 @@ 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 @@ -1030,46 +1024,43 @@ 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(recoveryRoundKey) - if !recovery.delegationTxHashes.isEmpty { - logger.info("Recovery: found \(recovery.delegationTxHashes.count) stored delegation TX hashes, reconciling...") + let existingBundleCount = (try? await votingCrypto.getBundleCount(roundId)) ?? 0 + + var recoveredDelegationHashes: [(UInt32, String)] = [] + for bundleIndex: UInt32 in 0.. 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(recoveryRoundKey) + if existingBundleCount > 0 && UInt32(recoveredPositions.count) >= existingBundleCount { + try await votingCrypto.clearRecoveryState(roundId) await send(.roundResumeChecked(alreadyAuthorized: true)) return } else if !recoveredPositions.isEmpty { - // Partial — some bundles on-chain, some not. Resume delegation from next unsubmitted bundle. - logger.info("Recovery: \(recoveredPositions.count)/\(bundleCount) bundles recovered, resuming delegation") await send(.witnessPreparationStarted) let count = try await votingCrypto.getBundleCount(roundId) await send(.witnessVerificationCompleted([], [], .init(treeStateFetchMs: 0, witnessGenerationMs: 0, verificationMs: 0), count)) return } - // No TXs confirmed — fall through to fresh start - logger.info("Recovery: no delegation TXs confirmed, proceeding with fresh start") } - // Fresh round — show witness preparation status await send(.witnessPreparationStarted) - // Fresh round — clear and initialize try? await votingCrypto.clearRound(roundId) - await votingCrypto.clearRecoveryState(recoveryRoundKey) + try await votingCrypto.clearRecoveryState(roundId) let params = VotingRoundParams( voteRoundId: activeSession.voteRoundId, snapshotHeight: snapshotHeight, @@ -1181,9 +1172,9 @@ public struct Voting { // swiftlint:disable:this type_body_length return .send(.startDelegationProof) } // Keystone: check for persisted signatures from a previous session - let recoveryRoundKey = state.recoveryKey(for: state.roundId) + let roundId = state.roundId return .run { [votingCrypto] send in - let savedSigs = await votingCrypto.loadKeystoneBundleSignatures(recoveryRoundKey) + let savedSigs = (try? await votingCrypto.loadKeystoneBundleSignatures(roundId)) ?? [] if !savedSigs.isEmpty { logger.info("Keystone recovery: found \(savedSigs.count) persisted signatures, resuming batch prove") await send(.keystoneSignaturesRestored(savedSigs)) @@ -1245,30 +1236,17 @@ public struct Voting { // swiftlint:disable:this type_body_length } } let roundId = state.roundId - let recoveryRoundKey = state.recoveryKey(for: roundId) let bundleCount = count return .run { [votingCrypto] send in - let recovery = await votingCrypto.getRecoveryState(recoveryRoundKey) let votes = (try? await votingCrypto.getVotes(roundId)) ?? [] // Check 1: a TX hash exists but the vote isn't marked as submitted // in the DB yet (crash during step 2 or 3 of a bundle). - if !recovery.voteTxHashes.isEmpty { - let unsubmittedByProposal: [UInt32: VoteChoice] = { - var result: [UInt32: VoteChoice] = [:] - for vote in votes where !vote.submitted { - result[vote.proposalId] = vote.choice - } - return result - }() - for (key, _) in recovery.voteTxHashes { - let parts = key.split(separator: "-") - guard parts.count == 2, - let proposalId = UInt32(parts[1]), - let choice = unsubmittedByProposal[proposalId] - else { continue } - logger.info("Vote resume: found in-flight vote for proposal \(proposalId), auto-resuming") - await send(.resumePendingVote(proposalId: proposalId, choice: choice)) + let unsubmitted = votes.filter { !$0.submitted } + for vote in unsubmitted { + if let _ = try? await votingCrypto.getVoteTxHash(roundId, vote.bundleIndex, vote.proposalId) { + logger.info("Vote resume: found in-flight vote for proposal \(vote.proposalId), auto-resuming") + await send(.resumePendingVote(proposalId: vote.proposalId, choice: vote.choice)) return } } @@ -1390,7 +1368,6 @@ 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 @@ -1468,8 +1445,12 @@ 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(recoveryRoundKey) - let completedBundles = Set(recoveryState.delegationTxHashes.keys) + var completedBundles = Set() + for idx: UInt32 in 0.. = .run { [votingCrypto] _ in - await votingCrypto.storeKeystoneBundleSignature(recoveryRoundKey, sigInfo) + try await votingCrypto.storeKeystoneBundleSignature(roundId, sigInfo) } if bundleIndex + 1 < bundleCount { @@ -1673,7 +1654,6 @@ 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 @@ -1691,8 +1671,12 @@ 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(recoveryRoundKey) - let completedBundles = Set(recoveryState.delegationTxHashes.keys) + var completedBundles = Set() + for idx: UInt32 in 0..