diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e697270..9f1385b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ directly impact users rather than highlighting other crucial architectural updat ## [Unreleased] ### Added +- Proposal API integrated with error handling for multi-transaction Proposals. - Privacy info manifest. ### Fixed diff --git a/modules/Package.swift b/modules/Package.swift index 919c7c40a..3ca7b3ec5 100644 --- a/modules/Package.swift +++ b/modules/Package.swift @@ -33,6 +33,7 @@ let package = Package( .library(name: "Models", targets: ["Models"]), .library(name: "NumberFormatter", targets: ["NumberFormatter"]), .library(name: "OnboardingFlow", targets: ["OnboardingFlow"]), + .library(name: "PartialProposalError", targets: ["PartialProposalError"]), .library(name: "Pasteboard", targets: ["Pasteboard"]), .library(name: "PrivateDataConsent", targets: ["PrivateDataConsent"]), .library(name: "RecoveryPhraseDisplay", targets: ["RecoveryPhraseDisplay"]), @@ -67,7 +68,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-url-routing", from: "0.6.0"), .package(url: "https://github.com/zcash-hackworks/MnemonicSwift", from: "2.2.4"), - .package(url: "https://github.com/zcash/ZcashLightClientKit", from: "2.0.10"), + .package(url: "https://github.com/zcash/ZcashLightClientKit", from: "2.0.11"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.17.0") ], targets: [ @@ -106,6 +107,7 @@ let package = Package( "MnemonicClient", "Models", "NumberFormatter", + "PartialProposalError", "RestoreWalletStorage", "SDKSynchronizer", "SyncProgress", @@ -305,6 +307,17 @@ let package = Package( ], path: "Sources/Features/OnboardingFlow" ), + .target( + name: "PartialProposalError", + dependencies: [ + "Generated", + "SupportDataGenerator", + "UIComponents", + "Utils", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") + ], + path: "Sources/Features/PartialProposalError" + ), .target( name: "Pasteboard", dependencies: [ @@ -462,6 +475,7 @@ let package = Package( "DerivationTool", "MnemonicClient", "Models", + "PartialProposalError", "Scan", "SDKSynchronizer", "UIComponents", diff --git a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift index bb18b54c7..1ddf9bd92 100644 --- a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift +++ b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerInterface.swift @@ -19,6 +19,12 @@ extension DependencyValues { } public struct SDKSynchronizerClient { + public enum CreateProposedTransactionsResult: Equatable { + case failure + case partial(txIds: [String]) + case success + } + public let stateStream: () -> AnyPublisher public let eventStream: () -> AnyPublisher public let latestState: () -> SynchronizerState @@ -43,4 +49,9 @@ public struct SDKSynchronizerClient { public var wipe: () -> AnyPublisher? public var switchToEndpoint: (LightWalletEndpoint) async throws -> Void + + // Proposals + public var proposeTransfer: (Int, Recipient, Zatoshi, Memo?) async throws -> Proposal + public var createProposedTransactions: (Proposal, UnifiedSpendingKey) async throws -> CreateProposedTransactionsResult + public var proposeShielding: (Int, Zatoshi, Memo, TransparentAddress?) async throws -> Proposal? } diff --git a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift index e68987e22..8fb5ac911 100644 --- a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift +++ b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerLive.swift @@ -116,6 +116,58 @@ extension SDKSynchronizerClient: DependencyKey { wipe: { synchronizer.wipe() }, switchToEndpoint: { endpoint in try await synchronizer.switchTo(endpoint: endpoint) + }, + proposeTransfer: { accountIndex, recipient, amount, memo in + try await synchronizer.proposeTransfer( + accountIndex: accountIndex, + recipient: recipient, + amount: amount, + memo: memo + ) + }, + createProposedTransactions: { proposal, spendingKey in + let stream = try await synchronizer.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey + ) + + let transactionCount = proposal.transactionCount() + var successCount = 0 + var iterator = stream.makeAsyncIterator() + + var txIds: [String] = [] + + for _ in 1...transactionCount { + if let transactionSubmitResult = try await iterator.next() { + switch transactionSubmitResult { + case .success(txId: let id): + successCount += 1 + txIds.append(id.toHexStringTxId()) + case .grpcFailure(txId: let id, error: _): + txIds.append(id.toHexStringTxId()) + case .submitFailure(txId: let id, code: _, description: _): + txIds.append(id.toHexStringTxId()) + case .notAttempted(txId: let id): + txIds.append(id.toHexStringTxId()) + } + } + } + + if successCount == 0 { + return .failure + } else if successCount == transactionCount { + return .success + } else { + return .partial(txIds: txIds) + } + }, + proposeShielding: { accountIndex, shieldingThreshold, memo, transparentReceiver in + try await synchronizer.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memo: memo, + transparentReceiver: transparentReceiver + ) } ) } diff --git a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift index 88f467bb4..f7b53f757 100644 --- a/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift +++ b/modules/Sources/Dependencies/SDKSynchronizer/SDKSynchronizerTest.swift @@ -30,7 +30,10 @@ extension SDKSynchronizerClient: TestDependencyKey { sendTransaction: XCTUnimplemented("\(Self.self).sendTransaction", placeholder: .placeholder()), shieldFunds: XCTUnimplemented("\(Self.self).shieldFunds", placeholder: .placeholder()), wipe: XCTUnimplemented("\(Self.self).wipe"), - switchToEndpoint: XCTUnimplemented("\(Self.self).switchToEndpoint") + switchToEndpoint: XCTUnimplemented("\(Self.self).switchToEndpoint"), + proposeTransfer: XCTUnimplemented("\(Self.self).proposeTransfer", placeholder: .testOnlyFakeProposal(totalFee: 0)), + createProposedTransactions: XCTUnimplemented("\(Self.self).createProposedTransactions", placeholder: .success), + proposeShielding: XCTUnimplemented("\(Self.self).proposeShielding", placeholder: nil) ) } @@ -52,7 +55,10 @@ extension SDKSynchronizerClient { sendTransaction: { _, _, _, _ in return .placeholder() }, shieldFunds: { _, _, _ in return .placeholder() }, wipe: { Empty().eraseToAnyPublisher() }, - switchToEndpoint: { _ in } + switchToEndpoint: { _ in }, + proposeTransfer: { _, _, _, _ in .testOnlyFakeProposal(totalFee: 0) }, + createProposedTransactions: { _, _ in .success }, + proposeShielding: { _, _, _, _ in nil } ) public static let mock = Self.mocked() @@ -172,7 +178,13 @@ extension SDKSynchronizerClient { ) }, wipe: @escaping () -> AnyPublisher? = { Fail(error: "Error").eraseToAnyPublisher() }, - switchToEndpoint: @escaping (LightWalletEndpoint) async throws -> Void = { _ in } + switchToEndpoint: @escaping (LightWalletEndpoint) async throws -> Void = { _ in }, + proposeTransfer: + @escaping (Int, Recipient, Zatoshi, Memo?) async throws -> Proposal = { _, _, _, _ in .testOnlyFakeProposal(totalFee: 0) }, + createProposedTransactions: + @escaping (Proposal, UnifiedSpendingKey) async throws -> CreateProposedTransactionsResult = { _, _ in .success }, + proposeShielding: + @escaping (Int, Zatoshi, Memo, TransparentAddress?) async throws -> Proposal? = { _, _, _, _ in nil } ) -> SDKSynchronizerClient { SDKSynchronizerClient( stateStream: stateStream, @@ -191,7 +203,10 @@ extension SDKSynchronizerClient { sendTransaction: sendTransaction, shieldFunds: shieldFunds, wipe: wipe, - switchToEndpoint: switchToEndpoint + switchToEndpoint: switchToEndpoint, + proposeTransfer: proposeTransfer, + createProposedTransactions: createProposedTransactions, + proposeShielding: proposeShielding ) } } diff --git a/modules/Sources/Dependencies/SupportDataGenerator/SupportDataGenerator.swift b/modules/Sources/Dependencies/SupportDataGenerator/SupportDataGenerator.swift index f0ec87db8..5da8530df 100644 --- a/modules/Sources/Dependencies/SupportDataGenerator/SupportDataGenerator.swift +++ b/modules/Sources/Dependencies/SupportDataGenerator/SupportDataGenerator.swift @@ -22,6 +22,7 @@ public enum SupportDataGenerator { public enum Constants { public static let email = "support@electriccoin.co" public static let subject = "Zashi" + public static let subjectPPE = "Zashi - transaction error" } public static func generate() -> SupportData { @@ -43,6 +44,31 @@ public enum SupportDataGenerator { return SupportData(toAddress: Constants.email, subject: Constants.subject, message: message) } + + public static func generatePartialProposalError(txIds: [String]) -> SupportData { + let data = SupportDataGenerator.generate() + + let idsString = txIds.map { id in + "- ID: \(id)" + } + .joined(separator: "\n") + + let message = + """ + Hi Zashi Team, + + While sending a transaction, I encountered an error state. + + This is the list of the transaction IDs: + \(idsString.description) + + Thanks for your support. + + \(data.message) + """ + + return SupportData(toAddress: Constants.email, subject: Constants.subjectPPE, message: message) + } } private protocol SupportDataGeneratorItem { diff --git a/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentInterface.swift b/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentInterface.swift index f91c64aad..2005fcf5e 100644 --- a/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentInterface.swift +++ b/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentInterface.swift @@ -126,5 +126,6 @@ public struct ZcashSDKEnvironment { public let network: ZcashNetwork public let requiredTransactionConfirmations: Int public let sdkVersion: String + public let shieldingThreshold: Zatoshi public let tokenName: String } diff --git a/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentLiveKey.swift b/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentLiveKey.swift index 809a3206f..9d1b68473 100644 --- a/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentLiveKey.swift +++ b/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentLiveKey.swift @@ -45,6 +45,7 @@ extension ZcashSDKEnvironment { network: network, requiredTransactionConfirmations: ZcashSDKConstants.requiredTransactionConfirmations, sdkVersion: "0.18.1-beta", + shieldingThreshold: Zatoshi(100_000), tokenName: network.networkType == .testnet ? "TAZ" : "ZEC" ) } diff --git a/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentTestKey.swift b/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentTestKey.swift index a180fbba1..d00339a52 100644 --- a/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentTestKey.swift +++ b/modules/Sources/Dependencies/ZcashSDKEnvironment/ZcashSDKEnvironmentTestKey.swift @@ -27,6 +27,7 @@ extension ZcashSDKEnvironment: TestDependencyKey { network: ZcashNetworkBuilder.network(for: .testnet), requiredTransactionConfirmations: ZcashSDKConstants.requiredTransactionConfirmations, sdkVersion: "0.18.1-beta", + shieldingThreshold: Zatoshi(100_000), tokenName: "TAZ" ) } diff --git a/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownStore.swift b/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownStore.swift index 2a24f2012..05ae60168 100644 --- a/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownStore.swift +++ b/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownStore.swift @@ -5,12 +5,13 @@ // Created by Lukáš Korba on 04.08.2022. // -import Foundation +import SwiftUI import ComposableArchitecture import ZcashLightClientKit import DerivationTool import MnemonicClient import NumberFormatter +import PartialProposalError import Utils import Generated import WalletStorage @@ -27,12 +28,18 @@ public struct BalanceBreakdownReducer: Reducer { private let CancelId = UUID() public struct State: Equatable { + public enum Destination: Equatable { + case partialProposalError + } + @PresentationState public var alert: AlertState? public var autoShieldingThreshold: Zatoshi public var changePending: Zatoshi + public var destination: Destination? public var isRestoringWallet = false public var isShieldingFunds: Bool public var isHintBoxVisible = false + public var partialProposalErrorState: PartialProposalError.State public var pendingTransactions: Zatoshi public var shieldedBalance: Zatoshi public var totalBalance: Zatoshi @@ -54,9 +61,11 @@ public struct BalanceBreakdownReducer: Reducer { public init( autoShieldingThreshold: Zatoshi, changePending: Zatoshi, + destination: Destination? = nil, isRestoringWallet: Bool = false, isShieldingFunds: Bool, isHintBoxVisible: Bool = false, + partialProposalErrorState: PartialProposalError.State, pendingTransactions: Zatoshi, shieldedBalance: Zatoshi, syncProgressState: SyncProgressReducer.State, @@ -65,9 +74,11 @@ public struct BalanceBreakdownReducer: Reducer { ) { self.autoShieldingThreshold = autoShieldingThreshold self.changePending = changePending + self.destination = destination self.isRestoringWallet = isRestoringWallet self.isShieldingFunds = isShieldingFunds self.isHintBoxVisible = isHintBoxVisible + self.partialProposalErrorState = partialProposalErrorState self.pendingTransactions = pendingTransactions self.shieldedBalance = shieldedBalance self.totalBalance = totalBalance @@ -80,13 +91,16 @@ public struct BalanceBreakdownReducer: Reducer { case alert(PresentationAction) case onAppear case onDisappear + case partialProposalError(PartialProposalError.Action) case restoreWalletTask case restoreWalletValue(Bool) case shieldFunds - case shieldFundsSuccess(TransactionState) case shieldFundsFailure(ZcashError) + case shieldFundsPartial([String]) + case shieldFundsSuccess case synchronizerStateChanged(RedactableSynchronizerState) case syncProgress(SyncProgressReducer.Action) + case updateDestination(BalanceBreakdownReducer.State.Destination?) case updateHintBoxVisibility(Bool) } @@ -106,6 +120,10 @@ public struct BalanceBreakdownReducer: Reducer { SyncProgressReducer() } + Scope(state: \.partialProposalErrorState, action: /Action.partialProposalError) { + PartialProposalError() + } + Reduce { state, action in switch action { case .alert(.presented(let action)): @@ -119,6 +137,7 @@ public struct BalanceBreakdownReducer: Reducer { return .none case .onAppear: + state.autoShieldingThreshold = zcashSDKEnvironment.shieldingThreshold return .publisher { sdkSynchronizer.stateStream() .throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true) @@ -129,6 +148,9 @@ public struct BalanceBreakdownReducer: Reducer { case .onDisappear: return .cancel(id: CancelId) + + case .partialProposalError: + return .none case .restoreWalletTask: return .run { send in @@ -143,30 +165,48 @@ public struct BalanceBreakdownReducer: Reducer { case .shieldFunds: state.isShieldingFunds = true - return .run { [state] send in + return .run { send in do { let storedWallet = try walletStorage.exportWallet() let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value()) let spendingKey = try derivationTool.deriveSpendingKey(seedBytes, 0, zcashSDKEnvironment.network.networkType) + + guard let uAddress = try await sdkSynchronizer.getUnifiedAddress(0) else { throw "sdkSynchronizer.getUnifiedAddress" } - let transaction = try await sdkSynchronizer.shieldFunds(spendingKey, Memo(string: ""), state.autoShieldingThreshold) - - await send(.shieldFundsSuccess(transaction)) + let address = try uAddress.transparentReceiver() + let proposal = try await sdkSynchronizer.proposeShielding(0, zcashSDKEnvironment.shieldingThreshold, .empty, address) + + guard let proposal else { throw "sdkSynchronizer.proposeShielding" } + + let result = try await sdkSynchronizer.createProposedTransactions(proposal, spendingKey) + + switch result { + case .failure: + await send(.shieldFundsFailure("sdkSynchronizer.createProposedTransactions".toZcashError())) + case .partial(txIds: let txIds): + await send(.shieldFundsPartial(txIds)) + case .success: + await send(.shieldFundsSuccess) + } } catch { await send(.shieldFundsFailure(error.toZcashError())) } } - case .shieldFundsSuccess: + case .shieldFundsFailure(let error): state.isShieldingFunds = false - state.transparentBalance = .zero + state.alert = AlertState.shieldFundsFailure(error) return .none - case .shieldFundsFailure(let error): + case .shieldFundsSuccess: state.isShieldingFunds = false - state.alert = AlertState.shieldFundsFailure(error) + state.transparentBalance = .zero return .none + case .shieldFundsPartial(let txIds): + state.partialProposalErrorState.txIds = txIds + return .send(.updateDestination(.partialProposalError)) + case .synchronizerStateChanged(let latestState): let accountBalance = latestState.data.accountBalance?.data state.shieldedBalance = accountBalance?.saplingBalance.spendableValue ?? .zero @@ -178,7 +218,11 @@ public struct BalanceBreakdownReducer: Reducer { case .syncProgress: return .none - + + case let .updateDestination(destination): + state.destination = destination + return .none + case .updateHintBoxVisibility(let visibility): state.isHintBoxVisible = visibility return .none @@ -199,13 +243,43 @@ extension AlertState where Action == BalanceBreakdownReducer.Action { } } +// MARK: - Store + +extension BalanceBreakdownStore { + func partialProposalErrorStore() -> StoreOf { + self.scope( + state: \.partialProposalErrorState, + action: BalanceBreakdownReducer.Action.partialProposalError + ) + } +} + +// MARK: - ViewStore + +extension BalanceBreakdownViewStore { + var destinationBinding: Binding { + self.binding( + get: \.destination, + send: BalanceBreakdownReducer.Action.updateDestination + ) + } + + var bindingForPartialProposalError: Binding { + self.destinationBinding.map( + extract: { $0 == .partialProposalError }, + embed: { $0 ? BalanceBreakdownReducer.State.Destination.partialProposalError : nil } + ) + } +} + // MARK: - Placeholders extension BalanceBreakdownReducer.State { public static let placeholder = BalanceBreakdownReducer.State( - autoShieldingThreshold: Zatoshi(1_000_000), + autoShieldingThreshold: .zero, changePending: .zero, isShieldingFunds: false, + partialProposalErrorState: .initial, pendingTransactions: .zero, shieldedBalance: .zero, syncProgressState: .initial, @@ -214,9 +288,10 @@ extension BalanceBreakdownReducer.State { ) public static let initial = BalanceBreakdownReducer.State( - autoShieldingThreshold: Zatoshi(1_000_000), + autoShieldingThreshold: .zero, changePending: .zero, isShieldingFunds: false, + partialProposalErrorState: .initial, pendingTransactions: .zero, shieldedBalance: .zero, syncProgressState: .initial, diff --git a/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownView.swift b/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownView.swift index 9565f18fb..050b9d997 100644 --- a/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownView.swift +++ b/modules/Sources/Features/BalanceBreakdown/BalanceBreakdownView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import ZcashLightClientKit import Generated +import PartialProposalError import UIComponents import Utils import Models @@ -69,6 +70,12 @@ public struct BalanceBreakdownView: View { ) ) .padding(.top, viewStore.isRestoringWallet ? 0 : 40) + .navigationLinkEmpty( + isActive: viewStore.bindingForPartialProposalError, + destination: { + PartialProposalErrorView(store: store.partialProposalErrorStore()) + } + ) } } .padding(.vertical, 1) @@ -223,7 +230,7 @@ extension BalanceBreakdownView { .padding(.bottom, 15) .disabled(!viewStore.isShieldableBalanceAvailable || viewStore.isShieldingFunds) - Text(L10n.Balances.fee(ZatoshiStringRepresentation.feeFormat)) + Text(ZatoshiStringRepresentation.feeFormat) .font(.custom(FontFamily.Inter.semiBold.name, size: 11)) } } @@ -268,6 +275,7 @@ extension BalanceBreakdownView { changePending: Zatoshi(25_234_000), isShieldingFunds: true, isHintBoxVisible: true, + partialProposalErrorState: .initial, pendingTransactions: Zatoshi(25_234_000), shieldedBalance: Zatoshi(25_234_778), syncProgressState: .init( diff --git a/modules/Sources/Features/PartialProposalError/PartialProposalErrorStore.swift b/modules/Sources/Features/PartialProposalError/PartialProposalErrorStore.swift new file mode 100644 index 000000000..76c7b5ad4 --- /dev/null +++ b/modules/Sources/Features/PartialProposalError/PartialProposalErrorStore.swift @@ -0,0 +1,72 @@ +// +// PartialProposalErrorStore.swift +// +// +// Created by Lukáš Korba on 11.03.2024. +// + +import ComposableArchitecture +import MessageUI + +import SupportDataGenerator + +@Reducer +public struct PartialProposalError { + @ObservableState + public struct State: Equatable { + public var isBackButtonHidden: Bool + public var isExportingData: Bool + public var message: String + public var supportData: SupportData? + public var txIds: [String] + + public init( + isBackButtonHidden: Bool = true, + isExportingData: Bool = false, + message: String, + supportData: SupportData? = nil, + txIds: [String] + ) { + self.isBackButtonHidden = isBackButtonHidden + self.isExportingData = isExportingData + self.message = message + self.supportData = supportData + self.txIds = txIds + } + } + + public enum Action: Equatable { + case sendSupportMail + case sendSupportMailFinished + case shareFinished + } + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .sendSupportMail: + let supportData = SupportDataGenerator.generatePartialProposalError(txIds: state.txIds) + if MFMailComposeViewController.canSendMail() { + state.supportData = supportData + } else { + state.message = supportData.message + state.isExportingData = true + } + return .none + + case .sendSupportMailFinished: + state.isBackButtonHidden = false + state.supportData = nil + return .none + + case .shareFinished: + state.isBackButtonHidden = false + state.isExportingData = false + return .none + } + } + } +} + diff --git a/modules/Sources/Features/PartialProposalError/PartialProposalErrorView.swift b/modules/Sources/Features/PartialProposalError/PartialProposalErrorView.swift new file mode 100644 index 000000000..066febd68 --- /dev/null +++ b/modules/Sources/Features/PartialProposalError/PartialProposalErrorView.swift @@ -0,0 +1,148 @@ +// +// PartialProposalErrorView.swift +// +// +// Created by Lukáš Korba on 11.03.2024. +// + +import SwiftUI +import ComposableArchitecture + +import Generated +import UIComponents + +public struct PartialProposalErrorView: View { + @Perception.Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + WithPerceptionTracking { + VStack(alignment: .center) { + ZashiIcon() + .padding(.top, 20) + .scaleEffect(2) + .padding(.vertical, 30) + .overlay { + Asset.Assets.alertIcon.image + .resizable() + .frame(width: 24, height: 24) + .offset(x: 25, y: 15) + } + + Group { + Text(L10n.ProposalPartial.message1) + Text(L10n.ProposalPartial.message2) + Text(L10n.ProposalPartial.message3) + } + .font(.custom(FontFamily.Inter.medium.name, size: 14)) + .multilineTextAlignment(.center) + .padding(.bottom, 10) + + Text("Transaction Ids".uppercased()) + .font(.custom(FontFamily.Inter.bold.name, size: 14)) + .padding(.top, 30) + .padding(.bottom, 3) + + ForEach(store.txIds, id: \.self) { txId in + Text("ID: \(txId)") + .font(.custom(FontFamily.Inter.regular.name, size: 13)) + .lineLimit(1) + .truncationMode(.middle) + } + + Button(L10n.ProposalPartial.contactSupport.uppercased()) { + store.send(.sendSupportMail) + } + .zcashStyle() + .padding(.vertical, 25) + .padding(.top, 40) + .onChange(of: store.supportData) { supportData in + if supportData == nil { + store.send(.shareFinished) + } + } + + if let supportData = store.supportData { + UIMailDialogView( + supportData: supportData, + completion: { + store.send(.sendSupportMailFinished) + } + ) + // UIMailDialogView only wraps MFMailComposeViewController presentation + // so frame is set to 0 to not break SwiftUIs layout + .frame(width: 0, height: 0) + } + + shareMessageView() + } + .padding(.horizontal, 60) + .zashiBack(hidden: store.isBackButtonHidden) + } + } + .navigationBarBackButtonHidden() + .navigationBarTitleDisplayMode(.inline) + .padding(.vertical, 1) + .applyScreenBackground(withPattern: true) + .zashiTitle { + Text(L10n.ProposalPartial.title.uppercased()) + .font(.custom(FontFamily.Archivo.bold.name, size: 14)) + } + } +} + +private extension PartialProposalErrorView { + @ViewBuilder func shareMessageView() -> some View { + if store.isExportingData { + UIShareDialogView(activityItems: [store.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() + } + } +} + +#Preview { + NavigationView { + PartialProposalErrorView( + store: + StoreOf( + initialState: PartialProposalError.State( + message: "message", + txIds: [ + "ba5113d9e78c885bdb03d44f784dc35aa0822c0f212f364b5ffc994a3e219d1f", + "ba5113d9e78c885bdb03d44f784dc35aa0822c0f212f364b5ffc994a3e219d1f", + "ba5113d9e78c885bdb03d44f784dc35aa0822c0f212f364b5ffc994a3e219d1f" + ] + ) + ) { + PartialProposalError() + } + ) + } +} + +// MARK: Placeholders + +extension PartialProposalError.State { + public static let initial = PartialProposalError.State( + message: "message", + txIds: [] + ) +} + +extension PartialProposalError { + public static let placeholder = StoreOf( + initialState: .initial + ) { + PartialProposalError() + } +} diff --git a/modules/Sources/Features/SendFlow/SendFlowConfirmationView.swift b/modules/Sources/Features/SendFlow/SendFlowConfirmationView.swift index 530cd85a4..5b6d657d2 100644 --- a/modules/Sources/Features/SendFlow/SendFlowConfirmationView.swift +++ b/modules/Sources/Features/SendFlow/SendFlowConfirmationView.swift @@ -56,7 +56,7 @@ public struct SendFlowConfirmationView: View { Text(L10n.Send.feeSummary) .font(.custom(FontFamily.Inter.regular.name, size: 14)) ZatoshiRepresentationView( - balance: Zatoshi(10_000), + balance: viewStore.feeRequired, fontName: FontFamily.Archivo.semiBold.name, mostSignificantFontSize: 16, leastSignificantFontSize: 8, @@ -151,6 +151,7 @@ public struct SendFlowConfirmationView: View { charLimit: 512, text: "This is some message I want to see in the preview and long enough to have at least two lines".redacted ), + partialProposalErrorState: .initial, scanState: .initial, spendableBalance: Zatoshi(4412323012_345), transactionAddressInputState: diff --git a/modules/Sources/Features/SendFlow/SendFlowStore.swift b/modules/Sources/Features/SendFlow/SendFlowStore.swift index 90a54d77b..511ade5b5 100644 --- a/modules/Sources/Features/SendFlow/SendFlowStore.swift +++ b/modules/Sources/Features/SendFlow/SendFlowStore.swift @@ -11,6 +11,7 @@ import ZcashLightClientKit import AudioServices import Utils import Scan +import PartialProposalError import MnemonicClient import SDKSynchronizer import WalletStorage @@ -28,6 +29,7 @@ public struct SendFlowReducer: Reducer { public struct State: Equatable { public enum Destination: Equatable { + case partialProposalError case sendConfirmation case scanQR } @@ -37,6 +39,8 @@ public struct SendFlowReducer: Reducer { public var destination: Destination? public var isSending = false public var memoState: MessageEditorReducer.State + public var partialProposalErrorState: PartialProposalError.State + public var proposal: Proposal? public var scanState: Scan.State public var spendableBalance = Zatoshi.zero public var totalBalance = Zatoshi.zero @@ -59,9 +63,13 @@ public struct SendFlowReducer: Reducer { } public var feeFormat: String { - L10n.Send.fee(ZatoshiStringRepresentation.feeFormat) + ZatoshiStringRepresentation.feeFormat } - + + public var feeRequired: Zatoshi { + proposal?.totalFeeRequired() ?? Zatoshi(0) + } + public var message: String { memoState.text.data } @@ -85,10 +93,8 @@ public struct SendFlowReducer: Reducer { public var isInsufficientFunds: Bool { guard transactionAmountInputState.isValidInput else { return false } - - @Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment - return transactionAmountInputState.amount.data > spendableBalance.amount - zcashSDKEnvironment.network.constants.defaultFee().amount + return transactionAmountInputState.amount.data > spendableBalance.amount } public var isMemoInputEnabled: Bool { @@ -110,7 +116,9 @@ public struct SendFlowReducer: Reducer { public init( addMemoState: Bool, destination: Destination? = nil, + isSending: Bool = false, memoState: MessageEditorReducer.State, + partialProposalErrorState: PartialProposalError.State, scanState: Scan.State, spendableBalance: Zatoshi = .zero, totalBalance: Zatoshi = .zero, @@ -119,7 +127,9 @@ public struct SendFlowReducer: Reducer { ) { self.addMemoState = addMemoState self.destination = destination + self.isSending = isSending self.memoState = memoState + self.partialProposalErrorState = partialProposalErrorState self.scanState = scanState self.spendableBalance = spendableBalance self.totalBalance = totalBalance @@ -134,11 +144,14 @@ public struct SendFlowReducer: Reducer { case memo(MessageEditorReducer.Action) case onAppear case onDisappear + case partialProposalError(PartialProposalError.Action) + case proposal(Proposal) case reviewPressed case scan(Scan.Action) case sendPressed - case sendDone(TransactionState) + case sendDone case sendFailed(ZcashError) + case sendPartial([String]) case synchronizerStateChanged(RedactableSynchronizerState) case transactionAddressInput(TransactionAddressTextFieldReducer.Action) case transactionAmountInput(TransactionAmountTextFieldReducer.Action) @@ -172,6 +185,10 @@ public struct SendFlowReducer: Reducer { Scan() } + Scope(state: \.partialProposalErrorState, action: /Action.partialProposalError) { + PartialProposalError() + } + Reduce { state, action in switch action { case .alert(.presented(let action)): @@ -201,56 +218,80 @@ public struct SendFlowReducer: Reducer { state.destination = nil state.isSending = false return .none + + case .partialProposalError: + return .none + case let .proposal(proposal): + state.proposal = proposal + return .none + case let .updateDestination(destination): state.destination = destination return .none case .reviewPressed: - state.destination = .sendConfirmation - return .none + return .run { [state] send in + do { + let recipient = try Recipient(state.address, network: zcashSDKEnvironment.network.networkType) + + let memo: Memo? + if state.transactionAddressInputState.isValidTransparentAddress { + memo = nil + } else if let memoText = state.addMemoState ? state.memoState.text : nil { + memo = memoText.data.isEmpty ? nil : try Memo(string: memoText.data) + } else { + memo = nil + } + + let proposal = try await sdkSynchronizer.proposeTransfer(0, recipient, state.amount, memo) + + await send(.proposal(proposal)) + await send(.updateDestination(.sendConfirmation)) + } catch { + await send(.sendFailed(error.toZcashError())) + } + } case .sendPressed: + state.isSending = true + + guard let proposal = state.proposal else { + return .send(.sendFailed("missing proposal".toZcashError())) + } + state.amount = Zatoshi(state.transactionAmountInputState.amount.data) state.address = state.transactionAddressInputState.textFieldState.text.data - do { - let storedWallet = try walletStorage.exportWallet() - let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value()) - let network = zcashSDKEnvironment.network.networkType - let spendingKey = try derivationTool.deriveSpendingKey(seedBytes, 0, network) - - let memo: Memo? - if state.transactionAddressInputState.isValidTransparentAddress { - memo = nil - } else if let memoText = state.addMemoState ? state.memoState.text : nil { - memo = memoText.data.isEmpty ? nil : try Memo(string: memoText.data) - } else { - memo = nil - } - - let recipient = try Recipient(state.address, network: network) - state.isSending = true - - return .run { [state] send in - do { - let transaction = try await sdkSynchronizer.sendTransaction(spendingKey, state.amount, recipient, memo) - await send(.sendDone(transaction)) - } catch { - await send(.sendFailed(error.toZcashError())) + return .run { send in + do { + let storedWallet = try walletStorage.exportWallet() + let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value()) + let network = zcashSDKEnvironment.network.networkType + let spendingKey = try derivationTool.deriveSpendingKey(seedBytes, 0, network) + + let result = try await sdkSynchronizer.createProposedTransactions(proposal, spendingKey) + + switch result { + case .failure: + await send(.sendFailed("sdkSynchronizer.createProposedTransactions".toZcashError())) + case .partial(txIds: let txIds): + await send(.sendPartial(txIds)) + case .success: + await send(.sendDone) } + } catch { + await send(.sendFailed(error.toZcashError())) } - } catch { - return .send(.sendFailed(error.toZcashError())) } - + case .sendDone: + state.isSending = false state.destination = nil state.memoState.text = "".redacted state.transactionAmountInputState.textFieldState.text = "".redacted state.transactionAmountInputState.amount = Int64(0).redacted state.transactionAddressInputState.textFieldState.text = "".redacted - state.isSending = false return .none case .sendFailed(let error): @@ -258,6 +299,10 @@ public struct SendFlowReducer: Reducer { state.alert = AlertState.sendFailure(error) return .none + case .sendPartial(let txIds): + state.partialProposalErrorState.txIds = txIds + return .send(.updateDestination(.partialProposalError)) + case .transactionAmountInput: return .none @@ -272,7 +317,7 @@ public struct SendFlowReducer: Reducer { case .transactionAddressInput: return .none - + case .synchronizerStateChanged(let latestState): state.spendableBalance = latestState.data.accountBalance?.data?.saplingBalance.spendableValue ?? .zero state.totalBalance = latestState.data.accountBalance?.data?.saplingBalance.total() ?? .zero @@ -333,6 +378,13 @@ extension SendFlowStore { action: SendFlowReducer.Action.scan ) } + + func partialProposalErrorStore() -> StoreOf { + self.scope( + state: \.partialProposalErrorState, + action: SendFlowReducer.Action.partialProposalError + ) + } } // MARK: - ViewStore @@ -347,19 +399,26 @@ extension SendFlowViewStore { var bindingForScanQR: Binding { self.destinationBinding.map( - extract: { - $0 == .scanQR - }, + extract: { $0 == .scanQR }, embed: { $0 ? SendFlowReducer.State.Destination.scanQR : nil } ) } var bindingForSendConfirmation: Binding { self.destinationBinding.map( - extract: { - $0 == .sendConfirmation - }, - embed: { $0 ? SendFlowReducer.State.Destination.sendConfirmation : nil } + extract: { $0 == .sendConfirmation }, + embed: { + $0 ? SendFlowReducer.State.Destination.sendConfirmation : + self.destination == .partialProposalError ? SendFlowReducer.State.Destination.partialProposalError : + nil + } + ) + } + + var bindingForPartialProposalError: Binding { + self.destinationBinding.map( + extract: { $0 == .partialProposalError }, + embed: { $0 ? SendFlowReducer.State.Destination.partialProposalError : nil } ) } } @@ -372,6 +431,7 @@ extension SendFlowReducer.State { addMemoState: true, destination: nil, memoState: .initial, + partialProposalErrorState: .initial, scanState: .initial, transactionAddressInputState: .initial, transactionAmountInputState: .initial diff --git a/modules/Sources/Features/SendFlow/SendFlowView.swift b/modules/Sources/Features/SendFlow/SendFlowView.swift index 5c3ef1a2b..ae28605ae 100644 --- a/modules/Sources/Features/SendFlow/SendFlowView.swift +++ b/modules/Sources/Features/SendFlow/SendFlowView.swift @@ -11,6 +11,7 @@ import Generated import Scan import UIComponents import BalanceFormatter +import PartialProposalError public struct SendFlowView: View { private enum InputID: Hashable { @@ -150,6 +151,12 @@ public struct SendFlowView: View { ScanView(store: store.scanStore()) } ) + .navigationLinkEmpty( + isActive: viewStore.bindingForPartialProposalError, + destination: { + PartialProposalErrorView(store: store.partialProposalErrorStore()) + } + ) .navigationLinkEmpty( isActive: viewStore.bindingForSendConfirmation, destination: { @@ -178,6 +185,7 @@ public struct SendFlowView: View { addMemoState: true, destination: nil, memoState: .initial, + partialProposalErrorState: .initial, scanState: .initial, transactionAddressInputState: .initial, transactionAmountInputState: .initial diff --git a/modules/Sources/Features/Settings/SettingsView.swift b/modules/Sources/Features/Settings/SettingsView.swift index ff3a4d366..174b509ef 100644 --- a/modules/Sources/Features/Settings/SettingsView.swift +++ b/modules/Sources/Features/Settings/SettingsView.swift @@ -42,18 +42,6 @@ public struct SettingsView: View { } .onChange(of: viewStore.isRestoringWallet) { isRestoringWalletBadgeOn = $0 } - if let supportData = viewStore.supportData { - UIMailDialogView( - supportData: supportData, - completion: { - viewStore.send(.sendSupportMailFinished) - } - ) - // UIMailDialogView only wraps MFMailComposeViewController presentation - // so frame is set to 0 to not break SwiftUIs layout - .frame(width: 0, height: 0) - } - Button(L10n.Settings.advanced.uppercased()) { viewStore.send(.updateDestination(.advanced)) } @@ -67,6 +55,18 @@ public struct SettingsView: View { } .zcashStyle() .padding(.bottom, 40) + + if let supportData = viewStore.supportData { + UIMailDialogView( + supportData: supportData, + completion: { + viewStore.send(.sendSupportMailFinished) + } + ) + // UIMailDialogView only wraps MFMailComposeViewController presentation + // so frame is set to 0 to not break SwiftUIs layout + .frame(width: 0, height: 0) + } } .padding(.horizontal, 70) } diff --git a/modules/Sources/Features/Tabs/TabsStore.swift b/modules/Sources/Features/Tabs/TabsStore.swift index d06638859..bb5c7f331 100644 --- a/modules/Sources/Features/Tabs/TabsStore.swift +++ b/modules/Sources/Features/Tabs/TabsStore.swift @@ -119,8 +119,7 @@ public struct TabsReducer: Reducer { case .addressDetails: return .none - case .balanceBreakdown(.shieldFundsSuccess(let transaction)): - state.homeState.transactionListState.transactionList.insert(transaction, at: 0) + case .balanceBreakdown(.shieldFundsSuccess): return .none case .balanceBreakdown: @@ -144,8 +143,7 @@ public struct TabsReducer: Reducer { state.isRestoringWallet = value return .none - case .send(.sendDone(let transaction)): - state.homeState.transactionListState.transactionList.insert(transaction, at: 0) + case .send(.sendDone): state.selectedTab = .account return .none diff --git a/modules/Sources/Generated/L10n.swift b/modules/Sources/Generated/L10n.swift index 754c6ac9d..5b10d8c3f 100644 --- a/modules/Sources/Generated/L10n.swift +++ b/modules/Sources/Generated/L10n.swift @@ -47,10 +47,6 @@ public enum L10n { public enum Balances { /// Change pending public static let changePending = L10n.tr("Localizable", "balances.changePending", fallback: "Change pending") - /// (Fee %@) - public static func fee(_ p1: Any) -> String { - return L10n.tr("Localizable", "balances.fee", String(describing: p1), fallback: "(Fee %@)") - } /// Pending transactions public static let pendingTransactions = L10n.tr("Localizable", "balances.pendingTransactions", fallback: "Pending transactions") /// The restore process can take several hours on lower-powered devices, and even on powerful devices is likely to take more than an hour. @@ -137,9 +133,9 @@ public enum L10n { public static let dateNotAvailable = L10n.tr("Localizable", "general.dateNotAvailable", fallback: "date not available") /// Done public static let done = L10n.tr("Localizable", "general.done", fallback: "Done") - /// < %@ + /// (Typical Fee < %@) public static func fee(_ p1: Any) -> String { - return L10n.tr("Localizable", "general.fee", String(describing: p1), fallback: "< %@") + return L10n.tr("Localizable", "general.fee", String(describing: p1), fallback: "(Typical Fee < %@)") } /// Max public static let max = L10n.tr("Localizable", "general.max", fallback: "Max") @@ -289,6 +285,18 @@ public enum L10n { /// Consent for Exporting Private Data public static let title = L10n.tr("Localizable", "privateDataConsent.title", fallback: "Consent for Exporting Private Data") } + public enum ProposalPartial { + /// Contact Support + public static let contactSupport = L10n.tr("Localizable", "proposalPartial.contactSupport", fallback: "Contact Support") + /// Sending to this recipient required multiple transactions but only some of them succeeded. + public static let message1 = L10n.tr("Localizable", "proposalPartial.message1", fallback: "Sending to this recipient required multiple transactions but only some of them succeeded.") + /// Your funds are safe but need to be recovered with assistance from our side. + public static let message2 = L10n.tr("Localizable", "proposalPartial.message2", fallback: "Your funds are safe but need to be recovered with assistance from our side.") + /// Please use the button below to contact us. It automatically prepares all the data we need to help you recover your funds. + public static let message3 = L10n.tr("Localizable", "proposalPartial.message3", fallback: "Please use the button below to contact us. It automatically prepares all the data we need to help you recover your funds.") + /// Transaction Error + public static let title = L10n.tr("Localizable", "proposalPartial.title", fallback: "Transaction Error") + } public enum ReceiveZec { /// Your Address public static let yourAddress = L10n.tr("Localizable", "receiveZec.yourAddress", fallback: "Your Address") @@ -521,10 +529,6 @@ public enum L10n { public static let editMemo = L10n.tr("Localizable", "send.editMemo", fallback: "Memo included. Tap to edit.") /// Sending transaction failed public static let failed = L10n.tr("Localizable", "send.failed", fallback: "Sending transaction failed") - /// (Fee %@) - public static func fee(_ p1: Any) -> String { - return L10n.tr("Localizable", "send.fee", String(describing: p1), fallback: "(Fee %@)") - } /// Fee: public static let feeSummary = L10n.tr("Localizable", "send.feeSummary", fallback: "Fee:") /// Aditional funds may be in transit diff --git a/modules/Sources/Generated/Resources/Assets.xcassets/alertIcon.imageset/Contents.json b/modules/Sources/Generated/Resources/Assets.xcassets/alertIcon.imageset/Contents.json new file mode 100644 index 000000000..7402d5539 --- /dev/null +++ b/modules/Sources/Generated/Resources/Assets.xcassets/alertIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "alertIcon.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/modules/Sources/Generated/Resources/Assets.xcassets/alertIcon.imageset/alertIcon.png b/modules/Sources/Generated/Resources/Assets.xcassets/alertIcon.imageset/alertIcon.png new file mode 100644 index 000000000..31a97cfb4 Binary files /dev/null and b/modules/Sources/Generated/Resources/Assets.xcassets/alertIcon.imageset/alertIcon.png differ diff --git a/modules/Sources/Generated/Resources/Localizable.strings b/modules/Sources/Generated/Resources/Localizable.strings index e14c24489..15d8909da 100644 --- a/modules/Sources/Generated/Resources/Localizable.strings +++ b/modules/Sources/Generated/Resources/Localizable.strings @@ -66,6 +66,13 @@ "recoveryPhraseTestPreamble.paragraph3" = "So how do you recover funds that you've hidden on a completely decentralized and private block-chain?"; "recoveryPhraseTestPreamble.button.goNext" = "By understanding and preparing"; +// MARK: - Proposal Partial Error +"proposalPartial.title" = "Transaction Error"; +"proposalPartial.message1" = "Sending to this recipient required multiple transactions but only some of them succeeded."; +"proposalPartial.message2" = "Your funds are safe but need to be recovered with assistance from our side."; +"proposalPartial.message3" = "Please use the button below to contact us. It automatically prepares all the data we need to help you recover your funds."; +"proposalPartial.contactSupport" = "Contact Support"; + // MARK: - Import Wallet Screen "importWallet.title" = "Wallet Import"; "importWallet.description" = "Enter secret\nrecovery phrase"; @@ -119,7 +126,6 @@ "balances.transparentBalance" = "Transparent balance"; "balances.shieldButtonTitle" = "Shield and consolidate funds"; "balances.shieldingInProgress" = "Shielding funds"; -"balances.fee" = "(Fee %@)"; "balances.synced" = "Synced"; "balances.syncing" = "Syncing"; "balances.syncingError" = "Zashi encountered an error while syncing, attempting to resolve..."; @@ -150,7 +156,6 @@ "send.error.insufficientFunds" = "Insufficient funds"; "send.error.invalidAmount" = "Invalid amount"; "send.error.invalidAddress" = "Invalid address"; -"send.fee" = "(Fee %@)"; "send.amountSummary" = "Amount:"; "send.toSummary" = "To:"; "send.feeSummary" = "Fee:"; @@ -222,7 +227,7 @@ Sharing this private data is irrevocable — once you have shared this private d "qrCodeFor" = "QR Code for %@"; "general.dateNotAvailable" = "date not available"; "general.tapToCopy" = "Tap to copy"; -"general.fee" = "< %@"; +"general.fee" = "(Typical Fee < %@)"; // MARK: - Transaction List "transactionList.collapse" = "Collapse transaction"; diff --git a/modules/Sources/Generated/XCAssets+Generated.swift b/modules/Sources/Generated/XCAssets+Generated.swift index fc24d6f83..42f4617dd 100644 --- a/modules/Sources/Generated/XCAssets+Generated.swift +++ b/modules/Sources/Generated/XCAssets+Generated.swift @@ -28,6 +28,7 @@ public enum Asset { public static let splashHi = ImageAsset(name: "SplashHi") public static let welcomeScreenLogo = ImageAsset(name: "WelcomeScreenLogo") public static let zashiLogo = ImageAsset(name: "ZashiLogo") + public static let alertIcon = ImageAsset(name: "alertIcon") public static let copy = ImageAsset(name: "copy") public static let flyReceivedFilled = ImageAsset(name: "flyReceivedFilled") public static let gridTile = ImageAsset(name: "gridTile") diff --git a/modules/Sources/Features/Settings/UIKitBridge/UIMailDialog.swift b/modules/Sources/UIComponents/UIKitBridge/UIMailDialog.swift similarity index 89% rename from modules/Sources/Features/Settings/UIKitBridge/UIMailDialog.swift rename to modules/Sources/UIComponents/UIKitBridge/UIMailDialog.swift index 4bdac785c..444e19b32 100644 --- a/modules/Sources/Features/Settings/UIKitBridge/UIMailDialog.swift +++ b/modules/Sources/UIComponents/UIKitBridge/UIMailDialog.swift @@ -14,11 +14,11 @@ import SupportDataGenerator public class UIMailDialog: UIView { public var completion: (() -> Void)? - required init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) } } @@ -59,6 +59,11 @@ public struct UIMailDialogView: UIViewRepresentable { public let supportData: SupportData public let completion: () -> Void + public init(supportData: SupportData, completion: @escaping () -> Void) { + self.supportData = supportData + self.completion = completion + } + public func makeUIView(context: UIViewRepresentableContext) -> UIMailDialog { let view = UIMailDialog() view.doInitialSetup(supportData: supportData, completion: completion) diff --git a/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 783a74520..34cbb4bdd 100644 --- a/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stephencelis/SQLite.swift.git", "state" : { - "revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", - "version" : "0.14.1" + "revision" : "e78ae0220e17525a15ac68c697a155eb7a672a8e", + "version" : "0.15.0" } }, { @@ -266,8 +266,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "363da63c1966405764f380c627409b2f9d9e710b", - "version" : "1.21.0" + "revision" : "a3b640d7dc567225db7c94386a6e71aded1bfa63", + "version" : "1.22.0" } }, { @@ -374,8 +374,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "c90afd6cc092468e71810bc715ddb49be8210b75", - "version" : "0.5.1" + "revision" : "7c801be1f445402a433b32835a50d832e8a50437", + "version" : "0.6.0" } }, { @@ -383,8 +383,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash/ZcashLightClientKit", "state" : { - "revision" : "2ef0e00385a8495b9d7115f7d0f9f1f19f91afa8", - "version" : "2.0.10" + "revision" : "6c9b7a91d6b9ec4f3b8cb699a558eac1178ba837", + "version" : "2.0.11" } } ], diff --git a/secantTests/BalanceBreakdownTests/BalanceBreakdownTests.swift b/secantTests/BalanceBreakdownTests/BalanceBreakdownTests.swift index 9cb595404..d7c1894e2 100644 --- a/secantTests/BalanceBreakdownTests/BalanceBreakdownTests.swift +++ b/secantTests/BalanceBreakdownTests/BalanceBreakdownTests.swift @@ -56,9 +56,12 @@ class BalanceBreakdownTests: XCTestCase { await store.send(.shieldFunds) { state in state.isShieldingFunds = true } - await store.receive(.shieldFundsFailure(ZcashError.synchronizerNotPrepared)) { state in + + let zcashError = ZcashError.unknown("sdkSynchronizer.proposeShielding") + + await store.receive(.shieldFundsFailure(zcashError)) { state in state.isShieldingFunds = false - state.alert = AlertState.shieldFundsFailure(ZcashError.synchronizerNotPrepared) + state.alert = AlertState.shieldFundsFailure(zcashError) } // long-living (cancelable) effects need to be properly canceled. @@ -74,6 +77,13 @@ class BalanceBreakdownTests: XCTestCase { ) { BalanceBreakdownReducer() } + + store.dependencies.mainQueue = .immediate + store.dependencies.sdkSynchronizer = .noOp + + await store.send(.onAppear) { state in + state.autoShieldingThreshold = Zatoshi(100_000) + } XCTAssertFalse(store.state.isShieldingFunds) XCTAssertFalse(store.state.isShieldableBalanceAvailable) diff --git a/secantTests/SendTests/SendTests.swift b/secantTests/SendTests/SendTests.swift index 7eebc754f..fc7f3004e 100644 --- a/secantTests/SendTests/SendTests.swift +++ b/secantTests/SendTests/SendTests.swift @@ -46,6 +46,7 @@ class SendTests: XCTestCase { text: "ztestsapling1psqa06alcfj9t6s246hht3n7kcw5h900r6z40qnuu7l58qs55kzeqa98879z9hzy596dca4hmsr".redacted ) ) + initialState.proposal = Proposal.testOnlyFakeProposal(totalFee: 10_000) let store = TestStore( initialState: initialState @@ -57,6 +58,13 @@ class SendTests: XCTestCase { store.dependencies.mainQueue = .immediate store.dependencies.mnemonic = .liveValue store.dependencies.sdkSynchronizer = .mocked() + let transactionSubmitResult = TransactionSubmitResult.success(txId: Data()) + store.dependencies.sdkSynchronizer.createProposedTransactions = { _, _ in + AsyncThrowingStream { continuation in + continuation.yield(transactionSubmitResult) + continuation.finish() + } + } store.dependencies.walletStorage = .noOp // simulate the sending confirmation button to be pressed @@ -66,21 +74,11 @@ class SendTests: XCTestCase { state.isSending = true } - let transaction = TransactionState( - expiryHeight: 40, - memos: [], - minedHeight: 50, - shielded: true, - zAddress: "tteafadlamnelkqe", - fee: Zatoshi(10), - id: "id", - status: .paid, - timestamp: 1234567, - zecAmount: Zatoshi(10) - ) - - await store.receive(.sendDone(transaction)) { state in + await store.receive(.transactionSubmitResult(transactionSubmitResult)) { state in state.isSending = false + } + + await store.receive(.sendDone) { state in state.transactionAddressInputState.textFieldState.text = "".redacted state.transactionAmountInputState.textFieldState.text = "".redacted } @@ -159,7 +157,9 @@ class SendTests: XCTestCase { store.dependencies.walletStorage.exportWallet = { throw walletStorageError } // simulate the sending confirmation button to be pressed - await store.send(.sendPressed) + await store.send(.sendPressed) { state in + state.isSending = true + } await store.receive(.sendFailed(walletStorageError)) { state in state.isSending = false @@ -684,23 +684,36 @@ class SendTests: XCTestCase { } func testReviewPressed() async throws { - let sendState = SendFlowReducer.State( + var sendState = SendFlowReducer.State( addMemoState: true, memoState: .initial, scanState: .initial, transactionAddressInputState: .initial, transactionAmountInputState: .initial ) + sendState.address = "tmViyFacKkncJ6zhEqs8rjqNPkGqXsMV4uq" let store = TestStore( initialState: sendState ) { SendFlowReducer() } + + store.dependencies.sdkSynchronizer = .noOp + let proposal = Proposal.testOnlyFakeProposal(totalFee: 10_000) + store.dependencies.sdkSynchronizer.proposeTransfer = { _, _, _, _ in proposal } - await store.send(.reviewPressed) { state in + await store.send(.reviewPressed) + + await store.receive(.proposal(proposal)) { state in + state.proposal = proposal + } + + await store.receive(.updateDestination(.sendConfirmation)) { state in state.destination = .sendConfirmation } + + await store.finish() } func testMemoToMessage() throws { diff --git a/secantTests/TabsTests/TabsTests.swift b/secantTests/TabsTests/TabsTests.swift index d6c0834c9..2ed3fd2cc 100644 --- a/secantTests/TabsTests/TabsTests.swift +++ b/secantTests/TabsTests/TabsTests.swift @@ -156,13 +156,10 @@ class TabsTests: XCTestCase { TabsReducer() } - let transaction = TransactionState.placeholder(uuid: "3") - - await store.send(.send(.sendDone(transaction))) { state in + await store.send(.send(.sendDone)) { state in state.selectedTab = .account state.homeState.transactionListState.transactionList = IdentifiedArrayOf( uniqueElements: [ - transaction, TransactionState.placeholder(uuid: "1"), TransactionState.placeholder(uuid: "2") ] @@ -170,7 +167,7 @@ class TabsTests: XCTestCase { } } - func testShieldFundsSucceedAndTransactionListUpdated() async throws { + func testShieldFundsSucceed() async throws { var placeholderState = TabsReducer.State.initial placeholderState.selectedTab = .send placeholderState.balanceBreakdownState.transparentBalance = Zatoshi(10_000) @@ -182,6 +179,15 @@ class TabsTests: XCTestCase { } store.dependencies.sdkSynchronizer = .mock + let proposal = Proposal.testOnlyFakeProposal(totalFee: 10_000) + store.dependencies.sdkSynchronizer.proposeShielding = { _, _, _, _ in proposal } + let transactionSubmitResult = TransactionSubmitResult.success(txId: Data()) + store.dependencies.sdkSynchronizer.createProposedTransactions = { _, _ in + AsyncThrowingStream { continuation in + continuation.yield(transactionSubmitResult) + continuation.finish() + } + } store.dependencies.derivationTool = .liveValue store.dependencies.mnemonic = .mock store.dependencies.walletStorage.exportWallet = { .placeholder } @@ -191,23 +197,12 @@ class TabsTests: XCTestCase { state.balanceBreakdownState.isShieldingFunds = true } - let shieldedTransaction = TransactionState( - expiryHeight: 40, - memos: [try Memo(string: "")], - minedHeight: 50, - shielded: true, - zAddress: "tteafadlamnelkqe", - fee: Zatoshi(10), - id: "id", - status: .paid, - timestamp: 1234567, - zecAmount: Zatoshi(10) - ) - - await store.receive(.balanceBreakdown(.shieldFundsSuccess(shieldedTransaction))) { state in + await store.receive(.balanceBreakdown(.transactionSubmitResult(transactionSubmitResult))) { state in state.balanceBreakdownState.isShieldingFunds = false + } + + await store.receive(.balanceBreakdown(.shieldFundsSuccess)) { state in state.balanceBreakdownState.transparentBalance = .zero - state.homeState.transactionListState.transactionList = IdentifiedArrayOf(uniqueElements: [shieldedTransaction]) } await store.finish()