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

## [Unreleased]

### Added
- Detect spent orchard notes early via PIR (Private Information Retrieval) before the scanner catches up. A "Detected spend" placeholder appears in the activity feed and the balance updates immediately.

## 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,8 @@ 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 getPIRPendingSpends: @Sendable () async throws -> PIRPendingSpends
}

Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,15 @@ 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
)
},
getPIRPendingSpends: {
try await synchronizer.getPIRPendingSpends()
}
)
}
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)),
getPIRPendingSpends: unimplemented("\(Self.self).getPIRPendingSpends", placeholder: PIRPendingSpends(notes: [], totalValue: 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) },
getPIRPendingSpends: { PIRPendingSpends(notes: [], totalValue: 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) },
getPIRPendingSpends: @escaping () async throws -> PIRPendingSpends = { PIRPendingSpends(notes: [], totalValue: 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,
getPIRPendingSpends: getPIRPendingSpends
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ extension TransactionsCoordFlow {

case .transactionsManager(.transactionTapped(let txId)):
if let index = state.transactions.index(id: txId) {
if state.transactions[index].isPIRDetectedSpend {
return .none
}
var transactionDetailsState = TransactionDetails.State.initial
transactionDetailsState.transaction = state.transactions[index]
state.path.append(.transactionDetails(transactionDetailsState))
Expand Down
4 changes: 4 additions & 0 deletions modules/Sources/Features/Root/RootCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ extension Root {
return .none

case .home(.transactionList(.transactionTapped(let txId))):
if let index = state.transactions.index(id: txId),
state.transactions[index].isPIRDetectedSpend {
return .none
}
state.transactionsCoordFlowState = .initial
state.transactionsCoordFlowState.transactionToOpen = txId
if let index = state.transactions.index(id: txId) {
Expand Down
31 changes: 31 additions & 0 deletions modules/Sources/Features/Root/RootInitialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ extension Root {
case checkRestoreWalletFlag(SyncStatus)
case checkWalletInitialization
case checkWalletConfig
case checkSpendabilityPIR
case checkSpendabilityPIRResult(SpendabilityResult?)
case initializeSDK(WalletInitMode)
case initialSetups
case initializationFailed(ZcashError)
Expand Down Expand Up @@ -205,13 +207,42 @@ extension Root {
.cancellable(id: CancelStateId, cancelInFlight: true)
if state.bgTask != nil {
return stateStreamEffect
} else if state.walletConfig.isEnabled(.pirSpendability) {
return .merge(
stateStreamEffect,
.send(.home(.smartBanner(.evaluatePriority1))),
.send(.initialization(.checkSpendabilityPIR))
)
} else {
return .merge(
stateStreamEffect,
.send(.home(.smartBanner(.evaluatePriority1)))
)
}

case .initialization(.checkSpendabilityPIR):
guard state.walletConfig.isEnabled(.pirSpendability) else {
return .none
}
let pirUrl = SpendabilityPIRConfig.default.serverUrl
return .run { send in
do {
let result = try await sdkSynchronizer.checkWalletSpendability(pirUrl, nil)
await send(.initialization(.checkSpendabilityPIRResult(result)))
} catch {
LoggerProxy.event("PIR spendability check failed: \(error)")
await send(.initialization(.checkSpendabilityPIRResult(nil)))
}
}
.cancellable(id: PIRCheckCancelId, cancelInFlight: true)

case .initialization(.checkSpendabilityPIRResult(let result)):
state.$pirSpendabilityResult.withLock { $0 = result }
return .merge(
.send(.fetchTransactionsForTheSelectedAccount),
.send(.home(.walletBalances(.updateBalances)))
)

case .initialization(.checkWalletConfig):
return .publisher {
walletConfigProvider.load()
Expand Down
5 changes: 4 additions & 1 deletion modules/Sources/Features/Root/RootStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public struct Root {
let WalletConfigCancelId = UUID()
let DidFinishLaunchingId = UUID()
let CancelFlexaId = UUID()
let PIRCheckCancelId = UUID()

@ObservableState
public struct State {
Expand Down Expand Up @@ -118,6 +119,7 @@ public struct Root {
@Shared(.inMemory(.walletAccounts)) public var walletAccounts: [WalletAccount] = []
public var walletConfig: WalletConfig
@Shared(.inMemory(.walletStatus)) public var walletStatus: WalletStatus = .none
@Shared(.inMemory(.pirSpendabilityResult)) public var pirSpendabilityResult: SpendabilityResult? = nil
public var wasRestoringWhenDisconnected = false
public var welcomeState: Welcome.State
@Shared(.inMemory(.zashiWalletAccount)) public var zashiWalletAccount: WalletAccount? = nil
Expand Down Expand Up @@ -238,8 +240,9 @@ public struct Root {
case foundTransactions([ZcashTransaction.Overview])
case minedTransaction(ZcashTransaction.Overview)
case fetchTransactionsForTheSelectedAccount
case fetchedTransactions(IdentifiedArrayOf<TransactionState>)
case fetchedTransactions(IdentifiedArrayOf<TransactionState>, PIRPendingSpends?)
case noChangeInTransactions
case syncReachedUpToDate

// Address Book
case loadContacts
Expand Down
35 changes: 30 additions & 5 deletions modules/Sources/Features/Root/RootTransactions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ extension Root {
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
.map {
if $0.syncStatus == .upToDate {
return Root.Action.fetchTransactionsForTheSelectedAccount
return Root.Action.syncReachedUpToDate
}
return Root.Action.noChangeInTransactions
}
Expand All @@ -49,7 +49,13 @@ extension Root {
case .noChangeInTransactions:
return .none

case .foundTransactions:
case .foundTransactions, .syncReachedUpToDate:
if state.walletConfig.isEnabled(.pirSpendability) {
return .merge(
.send(.fetchTransactionsForTheSelectedAccount),
.send(.initialization(.checkSpendabilityPIR))
)
}
return .send(.fetchTransactionsForTheSelectedAccount)

case .minedTransaction:
Expand All @@ -59,13 +65,22 @@ extension Root {
guard let accountUUID = state.selectedWalletAccount?.id else {
return .none
}
let pirEnabled = state.walletConfig.isEnabled(.pirSpendability)
return .run { send in
if let transactions = try? await sdkSynchronizer.getAllTransactions(accountUUID) {
await send(.fetchedTransactions(transactions))
async let txTask = sdkSynchronizer.getAllTransactions(accountUUID)
let pirPending: PIRPendingSpends?
if pirEnabled {
pirPending = try? await sdkSynchronizer.getPIRPendingSpends()
} else {
pirPending = nil
}

if let transactions = try? await txTask {
await send(.fetchedTransactions(transactions, pirPending))
}
}

case .fetchedTransactions(var transactions):
case .fetchedTransactions(var transactions, let pirPendingSpends):
let mempoolHeight = sdkSynchronizer.latestState().latestBlockHeight + 1

// Resolve Swaps
Expand Down Expand Up @@ -101,6 +116,16 @@ extension Root {
)
}

// PIR placeholder -- DB-backed, auto-reconciles with scanning
if state.walletConfig.isEnabled(.pirSpendability),
let pirPending = pirPendingSpends, !pirPending.notes.isEmpty {
mixedTransactions.append(
TransactionState(
pirDetectedSpentValue: Int64(pirPending.totalValue)
)
)
}

// Sort all transactions
let sortedTransactions = mixedTransactions
.sorted { lhs, rhs in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public struct TransactionListView: View {
}
}
}
.disabled(transaction.isPIRDetectedSpend)
.listRowInsets(EdgeInsets())
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ public struct TransactionsManagerView: View {
}
}
}
.disabled(transaction.isPIRDetectedSpend)
.listRowInsets(EdgeInsets())
}
}
Expand Down
4 changes: 4 additions & 0 deletions modules/Sources/Generated/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2132,6 +2132,10 @@ public enum L10n {
public static let failedSend = L10n.tr("Localizable", "transaction.failedSend", fallback: "Send failed")
/// Shielding Failed
public static let failedShieldedFunds = L10n.tr("Localizable", "transaction.failedShieldedFunds", fallback: "Shielding Failed")
/// Spend detected
public static let pirDetected = L10n.tr("Localizable", "transaction.pirDetected", fallback: "Spend detected")
/// Syncing for details…
public static let pirDetectedSubtitle = L10n.tr("Localizable", "transaction.pirDetectedSubtitle", fallback: "Syncing for details…")
/// Received
public static let received = L10n.tr("Localizable", "transaction.received", fallback: "Received")
/// Receiving
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@
"transaction.failedReceive" = "Receive failed";
"transaction.shieldingFunds" = "Shielding";
"transaction.failedShieldedFunds" = "Shielding Failed";
"transaction.pirDetected" = "Spend detected";
"transaction.pirDetectedSubtitle" = "Syncing for details…";
"transaction.saveAddress" = "Save address";
"transaction.selectText" = "Select text";
"transaction.shieldedFunds" = "Shielded";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@
"transaction.failedReceive" = "Recepción fallida";
"transaction.shieldingFunds" = "Protegiendo";
"transaction.failedShieldedFunds" = "Fondos Protegidos Fallidos";
"transaction.pirDetected" = "Gasto detectado";
"transaction.pirDetectedSubtitle" = "Sincronizando detalles…";
"transaction.saveAddress" = "Guardar dirección";
"transaction.selectText" = "Seleccionar texto";
"transaction.shieldedFunds" = "Protegidos";
Expand Down
1 change: 1 addition & 0 deletions modules/Sources/Generated/SharedStateKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ public extension String {
static let transactionMemos = "sharedStateKey_transactionMemos"
static let swapAssets = "sharedStateKey_swapAssets"
static let swapAPIAccess = "sharedStateKey_swapAPIAccess"
static let pirSpendabilityResult = "sharedStateKey_pirSpendabilityResult"
}
18 changes: 18 additions & 0 deletions modules/Sources/Models/SpendabilityPIRConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

public struct SpendabilityPIRConfig: Sendable {
/// Base URL of the spendability PIR server.
public let serverUrl: String

public init(serverUrl: String) {
self.serverUrl = serverUrl
}

/// Default config. Debug builds connect to a local spend-server;
/// distribution builds use the production endpoint.
#if SECANT_DISTRIB
public static let `default` = SpendabilityPIRConfig(serverUrl: "https://pir.zashi.app")
#else
public static let `default` = SpendabilityPIRConfig(serverUrl: "http://localhost:8080")
#endif
}
22 changes: 20 additions & 2 deletions modules/Sources/Models/TransactionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public struct TransactionState: Equatable, Identifiable {
public var totalReceived: Zatoshi?

public var rawID: Data? = nil

public var isPIRDetectedSpend = false

// Swaps
public var swapToZecAmount: String? = nil
public var swapStatus = UMSwapId.SwapStatus.pending
Expand Down Expand Up @@ -116,6 +117,9 @@ public struct TransactionState: Equatable, Identifiable {
}

public func title(_ detailScreen: Bool = false) -> String {
if isPIRDetectedSpend {
return L10n.Transaction.pirDetected
}
if type == .zcash {
switch status {
case .failed:
Expand Down Expand Up @@ -203,7 +207,7 @@ public struct TransactionState: Equatable, Identifiable {
}

public var daysAgo: String {
guard let timestamp else { return "" }
guard let timestamp, !isPIRDetectedSpend else { return "" }

let transactionDate = Date(timeIntervalSince1970: timestamp)

Expand Down Expand Up @@ -346,6 +350,20 @@ public struct TransactionState: Equatable, Identifiable {
self.isTransparentRecipient = false
}

/// Synthetic placeholder for PIR-detected spends, visible until scanning catches up.
public init(pirDetectedSpentValue: Int64) {
self.id = "pir-detected-spend"
self.status = .paid
self.zecAmount = Zatoshi(-pirDetectedSpentValue)
self.isPIRDetectedSpend = true
self.isSentTransaction = true
self.fee = nil
self.memoCount = 0
self.isShieldingTransaction = false
self.isTransparentRecipient = false
self.timestamp = Date().timeIntervalSince1970
}

public func confirmationsWith(_ latestMinedHeight: BlockHeight?) -> BlockHeight {
guard let minedHeight, let latestMinedHeight, minedHeight > 0, latestMinedHeight > 0 else {
return 0
Expand Down
3 changes: 3 additions & 0 deletions modules/Sources/Models/WalletConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ public enum FeatureFlag: String, CaseIterable, Codable {
case onboardingFlow
case testBackupPhraseFlow
case showFiatConversion
/// Spendability PIR sync, pending-spend placeholder row, and PIR Debug.
case pirSpendability

public var enabledByDefault: Bool {
switch self {
case .testFlag1, .testFlag2: return false
case .onboardingFlow: return false
case .testBackupPhraseFlow: return false
case .showFiatConversion: return false
case .pirSpendability: return false
}
}
}
Expand Down
Loading