Shielding processor and Adoption of scan & recovery progresses

This commit is contained in:
Lukas Korba 2025-04-09 12:54:07 +02:00
parent b2adf66ef2
commit 76fb5c49fb
16 changed files with 281 additions and 42 deletions

View File

@ -69,6 +69,7 @@ let package = Package(
.library(name: "SendForm", targets: ["SendForm"]),
.library(name: "ServerSetup", targets: ["ServerSetup"]),
.library(name: "Settings", targets: ["Settings"]),
.library(name: "ShieldingProcessor", targets: ["ShieldingProcessor"]),
.library(name: "SmartBanner", targets: ["SmartBanner"]),
.library(name: "SupportDataGenerator", targets: ["SupportDataGenerator"]),
.library(name: "SyncProgress", targets: ["SyncProgress"]),
@ -415,8 +416,9 @@ let package = Package(
"PartnerKeys",
"ReviewRequest",
"Scan",
"Settings",
"SDKSynchronizer",
"Settings",
"ShieldingProcessor",
"SmartBanner",
"SyncProgress",
"TransactionList",
@ -859,6 +861,23 @@ let package = Package(
],
path: "Sources/Features/Settings"
),
.target(
name: "ShieldingProcessor",
dependencies: [
"Generated",
"DerivationTool",
"MnemonicClient",
"Models",
"SDKSynchronizer",
"UIComponents",
"Utils",
"WalletStorage",
"ZcashSDKEnvironment",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "ZcashLightClientKit", package: "ZcashLightClientKit")
],
path: "Sources/Dependencies/ShieldingProcessor"
),
.target(
name: "SmartBanner",
dependencies: [
@ -867,6 +886,7 @@ let package = Package(
"Models",
"NetworkMonitor",
"SDKSynchronizer",
"SupportDataGenerator",
"UIComponents",
"UserPreferencesStorage",
"Utils",

View File

@ -40,7 +40,7 @@ extension LocalNotificationClient: DependencyKey {
}
},
scheduleShielding: {
guard let futureDate = Calendar.current.date(byAdding: .hour, value: 48, to: Date()) else {
guard let futureDate = Calendar.current.date(byAdding: .minute, value: 1, to: Date()) else {
return
}
@ -52,7 +52,7 @@ extension LocalNotificationClient: DependencyKey {
)
},
scheduleWalletBackup: {
guard let futureDate = Calendar.current.date(byAdding: .hour, value: 48, to: Date()) else {
guard let futureDate = Calendar.current.date(byAdding: .minute, value: 1, to: Date()) else {
return
}

View File

@ -20,10 +20,7 @@ extension NetworkMonitorClient: DependencyKey {
return NetworkMonitorClient(
networkMonitorStream: {
monitor.pathUpdateHandler = {
print("__LD status \($0)")
subject.send($0.status == .satisfied)
}
monitor.pathUpdateHandler = { subject.send($0.status == .satisfied) }
monitor.start(queue: queue)
return subject.eraseToAnyPublisher()

View File

@ -0,0 +1,126 @@
//
// ShieldingProcessorStore.swift
// modules
//
// Created by Lukáš Korba on 08.04.2025.
//
import SwiftUI
import ComposableArchitecture
import ZcashLightClientKit
import DerivationTool
import MnemonicClient
import Utils
import Generated
import WalletStorage
import SDKSynchronizer
import Models
import ZcashSDKEnvironment
@Reducer
public struct ShieldingProcessor {
@ObservableState
public struct State: Equatable {
public var isShieldingFunds = false
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
public init() { }
}
@CasePathable
public enum Action: Equatable {
case proposalReadyForShieldingWithKeystone(Proposal)
case shieldFunds
case shieldFundsFailure(ZcashError)
case shieldFundsSuccess
case shieldFundsWithKeystone
}
@Dependency(\.derivationTool) var derivationTool
@Dependency(\.mnemonic) var mnemonic
@Dependency(\.sdkSynchronizer) var sdkSynchronizer
@Dependency(\.walletStorage) var walletStorage
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
public init() { }
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .shieldFunds:
guard let account = state.selectedWalletAccount, let zip32AccountIndex = account.zip32AccountIndex else {
return .none
}
if account.vendor == .keystone {
return .send(.shieldFundsWithKeystone)
}
// Regular path only for Zashi account
state.isShieldingFunds = true
return .run { send in
do {
let storedWallet = try walletStorage.exportWallet()
let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value())
let spendingKey = try derivationTool.deriveSpendingKey(seedBytes, zip32AccountIndex, zcashSDKEnvironment.network.networkType)
let proposal = try await sdkSynchronizer.proposeShielding(account.id, zcashSDKEnvironment.shieldingThreshold, .empty, nil)
guard let proposal else { throw "sdkSynchronizer.proposeShielding" }
let result = try await sdkSynchronizer.createProposedTransactions(proposal, spendingKey)
switch result {
case .grpcFailure:
await send(.shieldFundsFailure("sdkSynchronizer.createProposedTransactions".toZcashError()))
case .failure:
await send(.shieldFundsFailure("sdkSynchronizer.createProposedTransactions".toZcashError()))
case .partial:
break
case .success:
await send(.shieldFundsSuccess)
}
} catch {
await send(.shieldFundsFailure(error.toZcashError()))
}
}
case .shieldFundsWithKeystone:
guard let account = state.selectedWalletAccount else {
return .none
}
return .run { send in
do {
let proposal = try await sdkSynchronizer.proposeShielding(account.id, zcashSDKEnvironment.shieldingThreshold, .empty, nil)
guard let proposal else { throw "sdkSynchronizer.proposeShielding" }
await send(.proposalReadyForShieldingWithKeystone(proposal))
} catch {
await send(.shieldFundsFailure(error.toZcashError()))
}
}
case .proposalReadyForShieldingWithKeystone:
return .none
case .shieldFundsFailure:
state.isShieldingFunds = false
return .none
case .shieldFundsSuccess:
state.isShieldingFunds = false
return .none
}
}
}
}
// MARK: Alerts
extension AlertState where Action == ShieldingProcessor.Action {
public static func shieldFundsFailure(_ error: ZcashError) -> AlertState {
AlertState {
TextState(L10n.Balances.Alert.ShieldFunds.Failure.title)
} message: {
TextState(L10n.Balances.Alert.ShieldFunds.Failure.message(error.detailedMessage))
}
}
}

View File

@ -281,7 +281,7 @@ extension BalancesView {
pendingTransactions: Zatoshi(25_234_000),
syncProgressState: .init(
lastKnownSyncPercentage: 0.43,
synchronizerStatusSnapshot: SyncStatusSnapshot(.syncing(0.41)),
synchronizerStatusSnapshot: SyncStatusSnapshot(.syncing(0.41, 0)),
syncStatusMessage: "Syncing"
),
walletBalancesState: .initial

View File

@ -16,6 +16,7 @@ import UserPreferencesStorage
import Utils
import BalanceBreakdown
import SmartBanner
import ShieldingProcessor
@Reducer
public struct Home {
@ -37,6 +38,7 @@ public struct Home {
public var isRateTooltipEnabled = false
public var migratingDatabase = true
public var moreRequest = false
public var shieldingProcessorState = ShieldingProcessor.State()
public var smartBannerState = SmartBanner.State.initial
public var syncProgressState: SyncProgress.State
public var walletConfig: WalletConfig
@ -115,6 +117,7 @@ public struct Home {
case seeAllTransactionsTapped
case sendTapped
case settingsTapped
case shieldingProcessor(ShieldingProcessor.Action)
case showSynchronizerErrorAlert(ZcashError)
case smartBanner(SmartBanner.Action)
case synchronizerStateChanged(RedactableSynchronizerState)
@ -140,11 +143,15 @@ public struct Home {
public var body: some Reducer<State, Action> {
BindingReducer()
Scope(state: \.transactionListState, action: \.transactionList) {
TransactionList()
}
Scope(state: \.shieldingProcessorState, action: \.shieldingProcessor) {
ShieldingProcessor()
}
// Scope(state: \.scanState, action: \.scan) {
// Scan()
// }
@ -333,9 +340,14 @@ public struct Home {
state.isInAppBrowserKeystoneOn = true
return .none
case .walletAccountTapped(let walletAccount):
state.$selectedWalletAccount.withLock { $0 = walletAccount }
case .walletAccountTapped:
state.accountSwitchRequest = false
return .none
// guard state.selectedWalletAccount != walletAccount else {
// return .none
// }
// state.$selectedWalletAccount.withLock { $0 = walletAccount }
// return .send(.smartBanner(.walletAccountChanged))
//state.homeState.transactionListState.isInvalidated = true
// state.receiveState.currentFocus = .uaAddress
// return .concatenate(
@ -344,11 +356,22 @@ public struct Home {
// .send(.balanceBreakdown(.walletBalances(.updateBalances))),
// .send(.transactionsManager(.resetFiltersTapped))
// )
return .none
// return .none
// Smart Banner
case .smartBanner(.currencyConversionScreenRequested):
return .send(.currencyConversionSetupTapped)
case .smartBanner(.shieldTapped):
return .send(.shieldingProcessor(.shieldFunds))
// Shielding processor
case .shieldingProcessor(.shieldFundsFailure(let error)):
state.alert = AlertState.shieldFundsFailure(error)
return .none
// More actions
case .coinbaseTapped:
state.moreRequest = false
@ -374,7 +397,22 @@ public struct Home {
case .smartBanner:
return .none
case .shieldingProcessor:
return .none
}
}
}
}
// MARK: Alerts
extension AlertState where Action == Home.Action {
public static func shieldFundsFailure(_ error: ZcashError) -> AlertState {
AlertState {
TextState(L10n.Balances.Alert.ShieldFunds.Failure.title)
} message: {
TextState(L10n.Balances.Alert.ShieldFunds.Failure.message(error.detailedMessage))
}
}
}

View File

@ -452,7 +452,7 @@ struct HomeView_Previews: PreviewProvider {
.init(
syncProgressState: .init(
lastKnownSyncPercentage: Float(0.43),
synchronizerStatusSnapshot: SyncStatusSnapshot(.syncing(0.41)),
synchronizerStatusSnapshot: SyncStatusSnapshot(.syncing(0.41, 0)),
syncStatusMessage: "Syncing"
),
transactionListState: .initial,

View File

@ -15,9 +15,13 @@ extension Root {
// MARK: - Accounts
case .home(.walletAccountTapped(let walletAccount)):
guard state.selectedWalletAccount != walletAccount else {
return .none
}
state.$selectedWalletAccount.withLock { $0 = walletAccount }
state.homeState.transactionListState.isInvalidated = true
return .merge(
.send(.home(.smartBanner(.walletAccountChanged))),
.send(.home(.walletBalances(.updateBalances))),
.send(.loadContacts),
.send(.resolveMetadataEncryptionKeys),
@ -144,9 +148,11 @@ extension Root {
state.path = nil
return .none
case .home(.balances(.proposalReadyForShieldingWithKeystone(let proposal))):
case .home(.balances(.proposalReadyForShieldingWithKeystone(let proposal))),
.home(.shieldingProcessor(.proposalReadyForShieldingWithKeystone(let proposal))):
state.signWithKeystoneCoordFlowState = .initial
state.signWithKeystoneCoordFlowState.sendConfirmationState.proposal = proposal
state.signWithKeystoneCoordFlowState.sendConfirmationState.isShielding = true
state.homeState.balancesBinding = false
return .run { send in
try? await mainQueue.sleep(for: .seconds(0.8))

View File

@ -72,7 +72,7 @@ extension SmartBannerView {
"Report",
type: .ghost
) {
store.send(.reportTapped)
}
.padding(.bottom, 12)

View File

@ -19,6 +19,8 @@ import UIComponents
import NetworkMonitor
import ZcashSDKEnvironment
import LocalNotification
import SupportDataGenerator
import MessageUI
@Reducer
public struct SmartBanner {
@ -49,17 +51,20 @@ public struct SmartBanner {
public var delay = 1.5
public var isOpen = false
public var isShielding = false
public var isSmartBannerSheetPresented = false
public var lastKnownErrorMessage = ""
public var lastKnownSyncPercentage = 0.0
public var messageToBeShared: String?
public var priorityContent: PriorityContent? = nil
public var priorityContentRequested: PriorityContent? = nil
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
public var supportData: SupportData?
public var synchronizerStatusSnapshot: SyncStatusSnapshot = .snapshotFor(state: .unprepared)
public var tokenName = "ZEC"
public var transparentBalance = Zatoshi(0)
@Shared(.inMemory(.walletStatus)) public var walletStatus: WalletStatus = .none
public init() { }
}
@ -86,10 +91,13 @@ public struct SmartBanner {
case openBanner
case openBannerRequest
case remindMeLaterTapped(State.PriorityContent)
case reportTapped
case shareFinished
case smartBannerContentTapped
case synchronizerStateChanged(RedactableSynchronizerState)
case transparentBalanceUpdated(Zatoshi)
case triggerPriority(State.PriorityContent)
case walletAccountChanged
// Action buttons
case autoShieldingTapped
@ -152,6 +160,33 @@ public struct SmartBanner {
case .binding:
return .none
case .walletAccountChanged:
return .run { send in
await send(.closeBanner(true), animation: .easeInOut(duration: Constants.easeInOutDuration))
try? await mainQueue.sleep(for: .seconds(1))
await send(.evaluatePriority1)
}
case .reportTapped:
var supportData = SupportDataGenerator.generate()
supportData.message =
"""
code: -2000
\(state.lastKnownErrorMessage)
\(supportData.message)
"""
if MFMailComposeViewController.canSendMail() {
state.supportData = supportData
} else {
state.messageToBeShared = supportData.message
}
return .none
case .shareFinished:
state.messageToBeShared = nil
return .none
case .networkMonitorChanged(let isConnected):
if state.priorityContent == .priority1 && isConnected {
return .run { send in
@ -193,8 +228,8 @@ public struct SmartBanner {
if snapshot.syncStatus != state.synchronizerStatusSnapshot.syncStatus {
state.synchronizerStatusSnapshot = snapshot
if case .syncing(let progress) = snapshot.syncStatus {
state.lastKnownSyncPercentage = Double(progress)
if case let .syncing(syncProgress, recoveryProgress) = snapshot.syncStatus {
state.lastKnownSyncPercentage = Double(syncProgress)
if state.priorityContent == .priority2 {
return .send(.closeAndCleanupBanner)
@ -219,12 +254,14 @@ public struct SmartBanner {
let accountsBalances = latestState.data.accountsBalances
if let account = state.selectedWalletAccount, let accountBalance = accountsBalances[account.id] {
state.transparentBalance = accountBalance.unshielded
if accountBalance.orchardBalance.spendableValue.amount == 0 && state.priorityContent != .priority5 {
return .send(.triggerPriority(.priority5))
} else if accountBalance.orchardBalance.spendableValue.amount > 0 && state.priorityContent == .priority5 {
return .send(.closeAndCleanupBanner)
}
// let total = accountBalance.orchardBalance.total().amount + accountBalance.saplingBalance.total().amount
// let spendable = accountBalance.orchardBalance.spendableValue.amount + accountBalance.saplingBalance.spendableValue.amount
//
// if spendable == 0 && total > 0 && state.priorityContent != .priority5 {
// return .send(.triggerPriority(.priority5))
// } else if spendable > 0 && state.priorityContent == .priority5 {
// return .send(.closeAndCleanupBanner)
// }
}
}
@ -259,7 +296,6 @@ public struct SmartBanner {
// updating balance
case .evaluatePriority5:
return .send(.evaluatePriority6)
// wallet backup
@ -338,8 +374,6 @@ public struct SmartBanner {
return .send(.openBannerRequest)
case .closeAndCleanupBanner:
// state.priorityContentRequested = nil
// state.priorityContent = nil
return .run { send in
await send(.closeBanner(true), animation: .easeInOut(duration: Constants.easeInOutDuration))
}
@ -361,7 +395,7 @@ public struct SmartBanner {
return .send(.smartBannerContentTapped)
case .shieldTapped:
return .none
return .send(.closeAndCleanupBanner)
case .walletBackupTapped:
return .none

View File

@ -57,6 +57,8 @@ public struct SmartBannerView: View {
.shadow(color: Design.Text.primary.color(colorScheme).opacity(0.1), radius: store.isOpen ? 1 : 0, y: -1)
}
.frame(minHeight: SBConstants.fixedHeight + SBConstants.shadowHeight)
shareMessageView()
}
.zashiSheet(isPresented: $store.isSmartBannerSheetPresented) {
helpSheetContent()
@ -79,6 +81,21 @@ public struct SmartBannerView: View {
}
}
extension SmartBannerView {
@ViewBuilder func shareMessageView() -> some View {
if let message = store.messageToBeShared {
UIShareDialogView(activityItems: [message]) {
store.send(.shareFinished)
}
// UIShareDialogView only wraps UIActivityViewController presentation
// so frame is set to 0 to not break SwiftUIs layout
.frame(width: 0, height: 0)
} else {
EmptyView()
}
}
}
// MARK: - Store
extension SmartBanner {

View File

@ -31,9 +31,9 @@ public struct SyncProgress {
}
public var syncingPercentage: Float {
if case .syncing(let progress) = synchronizerStatusSnapshot.syncStatus {
if case let .syncing(syncProgress, recoveryProgress) = synchronizerStatusSnapshot.syncStatus {
// Report at most 99.9% until the wallet is fully ready.
return progress * 0.999
return syncProgress * 0.999
}
return lastKnownSyncPercentage
@ -98,8 +98,9 @@ public struct SyncProgress {
if snapshot.syncStatus != state.synchronizerStatusSnapshot.syncStatus {
state.synchronizerStatusSnapshot = snapshot
if case .syncing(let progress) = snapshot.syncStatus {
state.lastKnownSyncPercentage = progress
if case let .syncing(syncProgress, recoveryProgress) = snapshot.syncStatus {
print("__LD \(syncProgress) \(recoveryProgress)")
state.lastKnownSyncPercentage = syncProgress
}
state.lastKnownErrorMessage = nil

View File

@ -74,7 +74,7 @@ public struct SyncProgressView: View {
StoreOf<SyncProgress>(
initialState: .init(
lastKnownSyncPercentage: Float(0.43),
synchronizerStatusSnapshot: SyncStatusSnapshot(.syncing(0.41)),
synchronizerStatusSnapshot: SyncStatusSnapshot(.syncing(0.41, 0)),
syncStatusMessage: "Syncing"
)
) {

View File

@ -33,8 +33,8 @@ public struct SyncStatusSnapshot: Equatable {
case .stopped:
return SyncStatusSnapshot(state, L10n.Sync.Message.stopped)
case .syncing(let progress):
return SyncStatusSnapshot(state, L10n.Sync.Message.sync(String(format: "%0.1f", progress * 100)))
case let .syncing(syncProgress, recoveryProgress):
return SyncStatusSnapshot(state, L10n.Sync.Message.sync(String(format: "%0.1f", syncProgress * 100)))
}
}
}

View File

@ -446,8 +446,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Electric-Coin-Company/zcash-light-client-ffi",
"state" : {
"revision" : "78cc7388a2ba5530888a99e584823a7399631d48",
"version" : "0.14.2"
"branch" : "preview/release/0.15.0",
"revision" : "c336dfc88b81aa3c857bc35e2d7d5452ae816d6e"
}
},
{
@ -465,7 +465,7 @@
"location" : "https://github.com/LukasKorba/ZcashLightClientKit",
"state" : {
"branch" : "1537-Birthday-estimate-based-on-a-date",
"revision" : "04ca05428f469767725ba421383fca31cf225855"
"revision" : "34b5422d784918a3ce9af47a648de079dd349a40"
}
}
],

View File

@ -19,7 +19,7 @@ final class SyncProgressTests: XCTestCase {
let store = TestStore(
initialState: SyncProgress.State(
lastKnownSyncPercentage: 0.0,
synchronizerStatusSnapshot: .snapshotFor(state: .syncing(0.513))
synchronizerStatusSnapshot: .snapshotFor(state: .syncing(0.513, 0))
)
) {
SyncProgress()
@ -61,7 +61,7 @@ final class SyncProgressTests: XCTestCase {
let store = TestStore(
initialState: SyncProgress.State(
lastKnownSyncPercentage: 0.15,
synchronizerStatusSnapshot: .snapshotFor(state: .syncing(0.513))
synchronizerStatusSnapshot: .snapshotFor(state: .syncing(0.513, 0))
)
) {
SyncProgress()
@ -82,14 +82,14 @@ final class SyncProgressTests: XCTestCase {
let store = TestStore(
initialState: SyncProgress.State(
lastKnownSyncPercentage: 0.15,
synchronizerStatusSnapshot: .snapshotFor(state: .syncing(0.513))
synchronizerStatusSnapshot: .snapshotFor(state: .syncing(0.513, 0))
)
) {
SyncProgress()
}
var syncState: SynchronizerState = .zero
syncState.syncStatus = .syncing(0.545)
syncState.syncStatus = .syncing(0.545, 0)
let snapshot = SyncStatusSnapshot.snapshotFor(state: syncState.syncStatus)
await store.send(.synchronizerStateChanged(syncState.redacted)) { state in