diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6ff53a4..180dbf719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift index 4550c652c..eeab8a77c 100644 --- a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift +++ b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift @@ -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 @@ -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 } diff --git a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift index e78561032..799845ebe 100644 --- a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift +++ b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift @@ -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() } ) } diff --git a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift index da8400307..2bcbe9744 100644 --- a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift +++ b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift @@ -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)) ) } @@ -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() @@ -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, @@ -300,7 +306,9 @@ extension SDKSynchronizerClient { checkSingleUseTransparentAddresses: checkSingleUseTransparentAddresses, updateTransparentAddressTransactions: updateTransparentAddressTransactions, fetchUTXOsByAddress: fetchUTXOsByAddress, - enhanceTransactionBy: enhanceTransactionBy + enhanceTransactionBy: enhanceTransactionBy, + checkWalletSpendability: checkWalletSpendability, + getPIRPendingSpends: getPIRPendingSpends ) } } diff --git a/modules/Sources/Features/CoordFlows/TransactionsCoordFlowCoordinator.swift b/modules/Sources/Features/CoordFlows/TransactionsCoordFlowCoordinator.swift index bd05cfa67..d8df5667e 100644 --- a/modules/Sources/Features/CoordFlows/TransactionsCoordFlowCoordinator.swift +++ b/modules/Sources/Features/CoordFlows/TransactionsCoordFlowCoordinator.swift @@ -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)) diff --git a/modules/Sources/Features/Root/RootCoordinator.swift b/modules/Sources/Features/Root/RootCoordinator.swift index c7d254008..f20e47dc2 100644 --- a/modules/Sources/Features/Root/RootCoordinator.swift +++ b/modules/Sources/Features/Root/RootCoordinator.swift @@ -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) { diff --git a/modules/Sources/Features/Root/RootInitialization.swift b/modules/Sources/Features/Root/RootInitialization.swift index 60dd75a62..3b170c0a5 100644 --- a/modules/Sources/Features/Root/RootInitialization.swift +++ b/modules/Sources/Features/Root/RootInitialization.swift @@ -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) @@ -205,6 +207,12 @@ 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, @@ -212,6 +220,29 @@ extension Root { ) } + 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() diff --git a/modules/Sources/Features/Root/RootStore.swift b/modules/Sources/Features/Root/RootStore.swift index 2690d012c..1a3bd3cce 100644 --- a/modules/Sources/Features/Root/RootStore.swift +++ b/modules/Sources/Features/Root/RootStore.swift @@ -57,6 +57,7 @@ public struct Root { let WalletConfigCancelId = UUID() let DidFinishLaunchingId = UUID() let CancelFlexaId = UUID() + let PIRCheckCancelId = UUID() @ObservableState public struct State { @@ -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 @@ -238,8 +240,9 @@ public struct Root { case foundTransactions([ZcashTransaction.Overview]) case minedTransaction(ZcashTransaction.Overview) case fetchTransactionsForTheSelectedAccount - case fetchedTransactions(IdentifiedArrayOf) + case fetchedTransactions(IdentifiedArrayOf, PIRPendingSpends?) case noChangeInTransactions + case syncReachedUpToDate // Address Book case loadContacts diff --git a/modules/Sources/Features/Root/RootTransactions.swift b/modules/Sources/Features/Root/RootTransactions.swift index 6dc155cd7..7d6825cbe 100644 --- a/modules/Sources/Features/Root/RootTransactions.swift +++ b/modules/Sources/Features/Root/RootTransactions.swift @@ -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 } @@ -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: @@ -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 @@ -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 diff --git a/modules/Sources/Features/TransactionList/TransactionListView.swift b/modules/Sources/Features/TransactionList/TransactionListView.swift index 8e5d55123..286bfee2e 100644 --- a/modules/Sources/Features/TransactionList/TransactionListView.swift +++ b/modules/Sources/Features/TransactionList/TransactionListView.swift @@ -68,6 +68,7 @@ public struct TransactionListView: View { } } } + .disabled(transaction.isPIRDetectedSpend) .listRowInsets(EdgeInsets()) } } diff --git a/modules/Sources/Features/TransactionsManager/TransactionsManagerView.swift b/modules/Sources/Features/TransactionsManager/TransactionsManagerView.swift index f9011bd8b..c3c46353f 100644 --- a/modules/Sources/Features/TransactionsManager/TransactionsManagerView.swift +++ b/modules/Sources/Features/TransactionsManager/TransactionsManagerView.swift @@ -124,6 +124,7 @@ public struct TransactionsManagerView: View { } } } + .disabled(transaction.isPIRDetectedSpend) .listRowInsets(EdgeInsets()) } } diff --git a/modules/Sources/Generated/L10n.swift b/modules/Sources/Generated/L10n.swift index 0f6165c28..8ecf19558 100644 --- a/modules/Sources/Generated/L10n.swift +++ b/modules/Sources/Generated/L10n.swift @@ -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 diff --git a/modules/Sources/Generated/Resources/en.lproj/Localizable.strings b/modules/Sources/Generated/Resources/en.lproj/Localizable.strings index d26ab33a2..016360209 100644 --- a/modules/Sources/Generated/Resources/en.lproj/Localizable.strings +++ b/modules/Sources/Generated/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/modules/Sources/Generated/Resources/es.lproj/Localizable.strings b/modules/Sources/Generated/Resources/es.lproj/Localizable.strings index 2d4c32ef8..085d036b9 100644 --- a/modules/Sources/Generated/Resources/es.lproj/Localizable.strings +++ b/modules/Sources/Generated/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/modules/Sources/Generated/SharedStateKeys.swift b/modules/Sources/Generated/SharedStateKeys.swift index 5a375bd67..b5d6519cb 100644 --- a/modules/Sources/Generated/SharedStateKeys.swift +++ b/modules/Sources/Generated/SharedStateKeys.swift @@ -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" } diff --git a/modules/Sources/Models/SpendabilityPIRConfig.swift b/modules/Sources/Models/SpendabilityPIRConfig.swift new file mode 100644 index 000000000..240e80297 --- /dev/null +++ b/modules/Sources/Models/SpendabilityPIRConfig.swift @@ -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 +} diff --git a/modules/Sources/Models/TransactionState.swift b/modules/Sources/Models/TransactionState.swift index 267c95370..aba547a37 100644 --- a/modules/Sources/Models/TransactionState.swift +++ b/modules/Sources/Models/TransactionState.swift @@ -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 @@ -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: @@ -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) @@ -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 diff --git a/modules/Sources/Models/WalletConfig.swift b/modules/Sources/Models/WalletConfig.swift index b097bc323..de34336f8 100644 --- a/modules/Sources/Models/WalletConfig.swift +++ b/modules/Sources/Models/WalletConfig.swift @@ -12,6 +12,8 @@ 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 { @@ -19,6 +21,7 @@ public enum FeatureFlag: String, CaseIterable, Codable { case .onboardingFlow: return false case .testBackupPhraseFlow: return false case .showFiatConversion: return false + case .pirSpendability: return false } } } diff --git a/modules/Sources/UIComponents/Transactions/TransactionRowView.swift b/modules/Sources/UIComponents/Transactions/TransactionRowView.swift index c0b5dd9a8..eaef6e3a0 100644 --- a/modules/Sources/UIComponents/Transactions/TransactionRowView.swift +++ b/modules/Sources/UIComponents/Transactions/TransactionRowView.swift @@ -89,6 +89,12 @@ public struct TransactionRowView: View { Text(transaction.daysAgo) .font(.custom(FontFamily.Inter.regular.name, size: 13)) .foregroundColor(Asset.Colors.shade47.color) + + if transaction.isPIRDetectedSpend { + Text(L10n.Transaction.pirDetectedSubtitle) + .font(.custom(FontFamily.Inter.regular.name, size: 11)) + .foregroundColor(Asset.Colors.shade47.color) + } } Spacer() @@ -106,7 +112,7 @@ public struct TransactionRowView: View { } @ViewBuilder private func balanceView() -> some View { - Group { + HStack(spacing: 0) { if isSensitiveContentHidden { Text(L10n.General.hideBalancesMost) .foregroundColor(Design.Text.primary.color(colorScheme)) @@ -116,8 +122,19 @@ public struct TransactionRowView: View { + Text(" \(tokenName)") } } else { - Text(transaction.isSpending ? "- " : "") - + Text(transaction.netValue) + if transaction.isSpending { + if transaction.isPending || transaction.isPIRDetectedSpend { + ProgressView() + .scaleEffect(0.7) + .frame(width: 14, height: 14) + .padding(.trailing, 6) + } else { + Text("- ") + } + } + Text(transaction.isSpending + ? transaction.netValue.replacingOccurrences(of: "-", with: "") + : transaction.netValue) + Text(" \(tokenName)") } } diff --git a/secant/Resources/en.lproj/Localizable.strings b/secant/Resources/en.lproj/Localizable.strings index d26ab33a2..016360209 100644 --- a/secant/Resources/en.lproj/Localizable.strings +++ b/secant/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/secant/Resources/es.lproj/Localizable.strings b/secant/Resources/es.lproj/Localizable.strings index 2d4c32ef8..085d036b9 100644 --- a/secant/Resources/es.lproj/Localizable.strings +++ b/secant/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/secantTests/HomeTests/HomeFeatureFlagTests.swift b/secantTests/HomeTests/HomeFeatureFlagTests.swift index 1b27eb4d6..6db8d8855 100644 --- a/secantTests/HomeTests/HomeFeatureFlagTests.swift +++ b/secantTests/HomeTests/HomeFeatureFlagTests.swift @@ -20,4 +20,8 @@ class HomeFeatureFlagTests: XCTestCase { func testShowFiatConversionOffByDefault() throws { XCTAssertFalse(WalletConfig.initial.isEnabled(.showFiatConversion)) } + + func testPirSpendabilityOffByDefault() throws { + XCTAssertFalse(WalletConfig.initial.isEnabled(.pirSpendability)) + } } diff --git a/secantTests/RootTests/PIRSpendabilityTests.swift b/secantTests/RootTests/PIRSpendabilityTests.swift new file mode 100644 index 000000000..1f52d79cf --- /dev/null +++ b/secantTests/RootTests/PIRSpendabilityTests.swift @@ -0,0 +1,215 @@ +// +// PIRSpendabilityTests.swift +// secantTests +// +// Created by Roman on 2026-04-03. +// + +import XCTest +import Combine +import ComposableArchitecture +import Root +import Models +@testable import secant_testnet +@testable import ZcashLightClientKit + +@MainActor +class PIRSpendabilityTests: XCTestCase { + + private func stateWithPirEnabled() -> Root.State { + var state = Root.State.initial + var flags = state.walletConfig.flags + flags[.pirSpendability] = true + state.walletConfig = WalletConfig(flags: flags) + return state + } + + // MARK: - checkSpendabilityPIR + + func testCheckSpendabilityPIR_Success() async throws { + let expectedResult = SpendabilityResult( + earliestHeight: 100, + latestHeight: 200, + spentNoteIds: [1, 2], + totalSpentValue: 50_000 + ) + + let store = TestStore( + initialState: stateWithPirEnabled() + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + store.dependencies.sdkSynchronizer.checkWalletSpendability = { _, _ in expectedResult } + + await store.send(.initialization(.checkSpendabilityPIR)) + + await store.receive(.initialization(.checkSpendabilityPIRResult(expectedResult))) { state in + state.pirSpendabilityResult = expectedResult + } + } + + func testCheckSpendabilityPIR_Failure() async throws { + struct PIRError: Error {} + + let store = TestStore( + initialState: stateWithPirEnabled() + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + store.dependencies.sdkSynchronizer.checkWalletSpendability = { _, _ in throw PIRError() } + + await store.send(.initialization(.checkSpendabilityPIR)) + + await store.receive(.initialization(.checkSpendabilityPIRResult(nil))) + } + + func testCheckSpendabilityPIR_NoOpWhenFlagOff() async throws { + let store = TestStore( + initialState: .initial + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.initialization(.checkSpendabilityPIR)) + } + + func testCheckSpendabilityPIRResult_TriggersTransactionRefresh() async throws { + let result = SpendabilityResult( + earliestHeight: 100, + latestHeight: 200, + spentNoteIds: [1], + totalSpentValue: 10_000 + ) + + let store = TestStore( + initialState: .initial + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.initialization(.checkSpendabilityPIRResult(result))) { state in + state.pirSpendabilityResult = result + } + + await store.receive(.fetchTransactionsForTheSelectedAccount) + await store.receive(.home(.walletBalances(.updateBalances))) + } + + // MARK: - foundTransactions / syncReachedUpToDate trigger PIR + + func testFoundTransactions_TriggersPIRCheck() async throws { + let store = TestStore( + initialState: stateWithPirEnabled() + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.foundTransactions([])) + + await store.receive(.fetchTransactionsForTheSelectedAccount) + await store.receive(.initialization(.checkSpendabilityPIR)) + } + + func testSyncReachedUpToDate_TriggersPIRCheck() async throws { + let store = TestStore( + initialState: stateWithPirEnabled() + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.syncReachedUpToDate) + + await store.receive(.fetchTransactionsForTheSelectedAccount) + await store.receive(.initialization(.checkSpendabilityPIR)) + } + + func testFoundTransactions_DoesNotTriggerPIRCheckWhenFlagOff() async throws { + let store = TestStore( + initialState: .initial + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.foundTransactions([])) + + await store.receive(.fetchTransactionsForTheSelectedAccount) + } + + // MARK: - fetchedTransactions with PIR placeholder + + func testFetchedTransactions_WithPIRPendingSpends_IncludesPlaceholder() async throws { + let pirPending = PIRPendingSpends( + notes: [PIRPendingNote(noteId: 42, value: 25_000)], + totalValue: 25_000 + ) + + let store = TestStore( + initialState: stateWithPirEnabled() + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.fetchedTransactions([], pirPending)) { state in + XCTAssertTrue(state.transactions.contains(where: { $0.isPIRDetectedSpend })) + + let pirTx = state.transactions.first(where: { $0.isPIRDetectedSpend }) + XCTAssertEqual(pirTx?.zecAmount, Zatoshi(-25_000)) + } + } + + func testFetchedTransactions_WithoutPIRPendingSpends_NoPlaceholder() async throws { + let store = TestStore( + initialState: .initial + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.fetchedTransactions([], nil)) { state in + XCTAssertFalse(state.transactions.contains(where: { $0.isPIRDetectedSpend })) + } + } + + func testFetchedTransactions_WithEmptyPIRNotes_NoPlaceholder() async throws { + let pirPending = PIRPendingSpends(notes: [], totalValue: 0) + + let store = TestStore( + initialState: .initial + ) { + Root() + } + store.exhaustivity = .off + + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.fetchedTransactions([], pirPending)) { state in + XCTAssertFalse(state.transactions.contains(where: { $0.isPIRDetectedSpend })) + } + } +}