Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ directly impact users rather than highlighting other crucial architectural updat

## [Unreleased]

### Added
- Instant Spendability: PIR-based spend detection and witness fetching notify users of pending spends before sync completes.
- PIR setup screen in Advanced Settings with user toggle to enable/disable the feature.
- Pending-spend spinner in transaction rows for outgoing transactions still confirming.

## 3.2.0 build 5 (2026-03-09)

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public struct SDKSynchronizerClient {
public var exchangeRateEnabled: (Bool) async throws -> Void
public var isTorSuccessfullyInitialized: () async -> Bool?
public var httpRequestOverTor: (URLRequest) async throws -> (Data, HTTPURLResponse)

public var debugDatabaseSql: (String) -> String = { _ in "" }

public var getSingleUseTransparentAddress: (AccountUUID) async throws -> SingleUseTransparentAddress = { _ in
Expand All @@ -100,5 +100,9 @@ public struct SDKSynchronizerClient {
public var updateTransparentAddressTransactions: (String) async throws -> TransparentAddressCheckResult = { _ in .notFound }
public var fetchUTXOsByAddress: (String, AccountUUID) async throws -> TransparentAddressCheckResult = { _, _ in .notFound }
public var enhanceTransactionBy: (String) async throws -> Void

public var checkWalletSpendability: @Sendable (String, SpendabilityProgressHandler?) async throws -> SpendabilityResult

public var fetchNoteWitnesses: @Sendable (String, SpendabilityProgressHandler?) async throws -> WitnessResult
}

Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,18 @@ extension SDKSynchronizerClient: DependencyKey {
},
enhanceTransactionBy: { txId in
try await synchronizer.enhanceTransactionBy(txId: TxId(txId))
},
checkWalletSpendability: { pirServerUrl, progress in
try await synchronizer.checkWalletSpendability(
pirServerUrl: pirServerUrl,
progress: progress
)
},
fetchNoteWitnesses: { pirServerUrl, progress in
try await synchronizer.fetchNoteWitnesses(
pirServerUrl: pirServerUrl,
progress: progress
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ extension SDKSynchronizerClient: TestDependencyKey {
checkSingleUseTransparentAddresses: unimplemented("\(Self.self).checkSingleUseTransparentAddresses", placeholder: .notFound),
updateTransparentAddressTransactions: unimplemented("\(Self.self).updateTransparentAddressTransactions", placeholder: .notFound),
fetchUTXOsByAddress: unimplemented("\(Self.self).fetchUTXOsByAddress", placeholder: .notFound),
enhanceTransactionBy: unimplemented("\(Self.self).enhanceTransactionBy")
enhanceTransactionBy: unimplemented("\(Self.self).enhanceTransactionBy"),
checkWalletSpendability: unimplemented("\(Self.self).checkWalletSpendability", placeholder: SpendabilityResult(earliestHeight: 0, latestHeight: 0, spentNoteIds: [], totalSpentValue: 0)),
fetchNoteWitnesses: unimplemented("\(Self.self).fetchNoteWitnesses", placeholder: WitnessResult(witnessedNoteIds: [], totalWitnessedValue: 0))
)
}

Expand Down Expand Up @@ -128,7 +130,9 @@ extension SDKSynchronizerClient {
checkSingleUseTransparentAddresses: { _ in .notFound },
updateTransparentAddressTransactions: { _ in .notFound },
fetchUTXOsByAddress: { _, _ in .notFound },
enhanceTransactionBy: { _ in }
enhanceTransactionBy: { _ in },
checkWalletSpendability: { _, _ in SpendabilityResult(earliestHeight: 0, latestHeight: 0, spentNoteIds: [], totalSpentValue: 0) },
fetchNoteWitnesses: { _, _ in WitnessResult(witnessedNoteIds: [], totalWitnessedValue: 0) }
)

public static let mock = Self.mocked()
Expand Down Expand Up @@ -252,7 +256,9 @@ extension SDKSynchronizerClient {
checkSingleUseTransparentAddresses: @escaping (AccountUUID) async throws -> TransparentAddressCheckResult = { _ in .notFound },
updateTransparentAddressTransactions: @escaping (String) async throws -> TransparentAddressCheckResult = { _ in .notFound },
fetchUTXOsByAddress: @escaping (String, AccountUUID) async throws -> TransparentAddressCheckResult = { _, _ in .notFound },
enhanceTransactionBy: @escaping (String) async throws -> Void = { _ in }
enhanceTransactionBy: @escaping (String) async throws -> Void = { _ in },
checkWalletSpendability: @escaping (String, SpendabilityProgressHandler?) async throws -> SpendabilityResult = { _, _ in SpendabilityResult(earliestHeight: 0, latestHeight: 0, spentNoteIds: [], totalSpentValue: 0) },
fetchNoteWitnesses: @escaping (String, SpendabilityProgressHandler?) async throws -> WitnessResult = { _, _ in WitnessResult(witnessedNoteIds: [], totalWitnessedValue: 0) }
) -> SDKSynchronizerClient {
SDKSynchronizerClient(
stateStream: stateStream,
Expand Down Expand Up @@ -300,7 +306,9 @@ extension SDKSynchronizerClient {
checkSingleUseTransparentAddresses: checkSingleUseTransparentAddresses,
updateTransparentAddressTransactions: updateTransparentAddressTransactions,
fetchUTXOsByAddress: fetchUTXOsByAddress,
enhanceTransactionBy: enhanceTransactionBy
enhanceTransactionBy: enhanceTransactionBy,
checkWalletSpendability: checkWalletSpendability,
fetchNoteWitnesses: fetchNoteWitnesses
)
}
}
32 changes: 32 additions & 0 deletions modules/Sources/Dependencies/WalletStorage/WalletStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public struct WalletStorage {
public static let zcashStoredWalletBackupAcknowledged = "zcashStoredWalletBackupAcknowledged"
public static let zcashStoredShieldingAcknowledged = "zcashStoredShieldingAcknowledged"
public static let zcashStoredTorSetupFlag = "zcashStoredTorSetupFlag"
public static let zcashStoredPIRFlag = "zcashStoredPIRFlag"
public static let zcashStoredZodlAnnouncementFlag = "zcashStoredZodlAnnouncementFlag"

/// Versioning of the stored data
Expand Down Expand Up @@ -183,6 +184,7 @@ public struct WalletStorage {
try? deleteData(forKey: Constants.zcashStoredWalletBackupAcknowledged)
try? deleteData(forKey: Constants.zcashStoredShieldingAcknowledged)
try? deleteData(forKey: Constants.zcashStoredTorSetupFlag)
try? deleteData(forKey: Constants.zcashStoredPIRFlag)
try? deleteData(forKey: Constants.zcashStoredZodlAnnouncementFlag)
}

Expand Down Expand Up @@ -421,6 +423,36 @@ public struct WalletStorage {
return try? decode(json: reqData, as: Bool.self)
}

public func importPIRFlag(_ enabled: Bool) throws {
guard let data = try? encode(object: enabled) else {
throw KeychainError.encoding
}

do {
try setData(data, forKey: Constants.zcashStoredPIRFlag)
} catch KeychainError.duplicate {
try updateData(data, forKey: Constants.zcashStoredPIRFlag)
} catch {
throw WalletStorageError.storageError(error)
}
}

public func exportPIRFlag() -> Bool? {
let reqData: Data?

do {
reqData = try data(forKey: Constants.zcashStoredPIRFlag)
} catch {
return nil
}

guard let reqData else {
return nil
}

return try? decode(json: reqData, as: Bool.self)
}

// MARK: - Wallet Storage Codable & Query helpers

public func decode<T: Decodable>(json: Data, as clazz: T.Type) throws -> T? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,8 @@ public struct WalletStorageClient {
/// Tor setup flag
public var importTorSetupFlag: (Bool) throws -> Void
public var exportTorSetupFlag: () -> Bool? = { nil }

/// PIR (Private Information Retrieval) flag — user toggle in Advanced Settings
public var importPIRFlag: (Bool) throws -> Void
public var exportPIRFlag: () -> Bool? = { nil }
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ extension WalletStorageClient: DependencyKey {
},
exportTorSetupFlag: {
walletStorage.exportTorSetupFlag()
},
importPIRFlag: { enabled in
try walletStorage.importPIRFlag(enabled)
},
exportPIRFlag: {
walletStorage.exportPIRFlag()
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ extension WalletStorageClient: TestDependencyKey {
importShieldingAcknowledged: unimplemented("\(Self.self).importShieldingAcknowledged"),
exportShieldingAcknowledged: unimplemented("\(Self.self).exportShieldingAcknowledged", placeholder: false),
importTorSetupFlag: unimplemented("\(Self.self).importTorSetupFlag"),
exportTorSetupFlag: unimplemented("\(Self.self).exportTorSetupFlag", placeholder: nil)
exportTorSetupFlag: unimplemented("\(Self.self).exportTorSetupFlag", placeholder: nil),
importPIRFlag: unimplemented("\(Self.self).importPIRFlag"),
exportPIRFlag: unimplemented("\(Self.self).exportPIRFlag", placeholder: nil)
)
}

Expand All @@ -58,6 +60,8 @@ extension WalletStorageClient {
importShieldingAcknowledged: { _ in },
exportShieldingAcknowledged: { false },
importTorSetupFlag: { _ in },
exportTorSetupFlag: { false }
exportTorSetupFlag: { false },
importPIRFlag: { _ in },
exportPIRFlag: { true }
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
import ComposableArchitecture
import Generated
import Models
import ZcashLightClientKit

// Path
import AddressBook
Expand Down Expand Up @@ -322,7 +323,9 @@ extension SwapAndPayCoordFlow {
let network = zcashSDKEnvironment.network.networkType
let spendingKey = try derivationTool.deriveSpendingKey(seedBytes, zip32AccountIndex, network)

let result = try await sdkSynchronizer.createProposedTransactions(proposal, spendingKey)
var pirProposal = proposal
pirProposal.pirWitnessConfig = Proposal.PIRWitnessConfig(serverURL: SpendabilityPIRConfig.default.witnessServerUrl)
let result = try await sdkSynchronizer.createProposedTransactions(pirProposal, spendingKey)

switch result {
case .grpcFailure(let txIds):
Expand Down
109 changes: 109 additions & 0 deletions modules/Sources/Features/PIRSetup/PIRSetupStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// PIRSetupStore.swift
// Zashi
//

import ComposableArchitecture

import Generated
import WalletStorage
import Models

@Reducer
public struct PIRSetup {
@ObservableState
public struct State: Equatable {
public enum SettingsOptions: CaseIterable {
case optIn
case optOut

public func title() -> String {
switch self {
case .optIn: return String(localizable: .currencyConversionEnable)
case .optOut: return String(localizable: .currencyConversionLearnMoreOptionDisable)
}
}

public func subtitle() -> String {
switch self {
case .optIn: return "Speed up transactions by checking spendability in the background using private information retrieval."
case .optOut: return "Disable background spendability checks. Transactions may take longer to confirm as spendable."
}
}

public func icon() -> ImageAsset {
switch self {
case .optIn: return Asset.Assets.check
case .optOut: return Asset.Assets.buttonCloseX
}
}
}

public var activeSettingsOption: SettingsOptions?
public var currentSettingsOption = SettingsOptions.optOut
public var isSettingsView: Bool = false
@Shared(.inMemory(.pirUserEnabled)) public var pirUserEnabled: Bool = true

public var isSaveButtonDisabled: Bool {
currentSettingsOption == activeSettingsOption
}

public init(
activeSettingsOption: SettingsOptions? = nil,
currentSettingsOption: SettingsOptions = .optOut,
isSettingsView: Bool = false
) {
self.activeSettingsOption = activeSettingsOption
self.currentSettingsOption = currentSettingsOption
self.isSettingsView = isSettingsView
}
}

public enum Action: BindableAction, Equatable {
case binding(BindingAction<PIRSetup.State>)
case backToHomeTapped
case onAppear
case saveChangesTapped
case settingsOptionTapped(State.SettingsOptions)
}

@Dependency(\.walletStorage) var walletStorage

public init() { }

public var body: some Reducer<State, Action> {
BindingReducer()

Reduce { state, action in
switch action {
case .onAppear:
if let pirEnabled = walletStorage.exportPIRFlag() {
let option: State.SettingsOptions = pirEnabled ? .optIn : .optOut
state.activeSettingsOption = option
state.currentSettingsOption = option
} else {
state.activeSettingsOption = .optIn
state.currentSettingsOption = .optIn
}
return .none

case .backToHomeTapped:
return .none

case .binding:
return .none

case .settingsOptionTapped(let newOption):
state.currentSettingsOption = newOption
return .none

case .saveChangesTapped:
let newFlag = state.currentSettingsOption == .optIn
try? walletStorage.importPIRFlag(newFlag)
state.$pirUserEnabled.withLock { $0 = newFlag }
state.activeSettingsOption = state.currentSettingsOption
return .send(.backToHomeTapped)
}
}
}
}
Loading