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 @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)
}
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 0 additions & 30 deletions modules/Sources/Dependencies/VotingModels/VotingModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading