secant-ios-wallet/modules/Sources/Features/SendConfirmation/SendConfirmationStore.swift

614 lines
24 KiB
Swift

//
// SendConfirmationStore.swift
//
//
// Created by Lukáš Korba on 13.05.2024.
//
import SwiftUI
import ComposableArchitecture
import ZcashLightClientKit
import AudioServices
import Utils
import MnemonicClient
import SDKSynchronizer
import WalletStorage
import ZcashSDKEnvironment
import UIComponents
import Models
import Generated
import BalanceFormatter
import WalletBalances
import LocalAuthenticationHandler
import AddressBookClient
import MessageUI
import SupportDataGenerator
import KeystoneHandler
import TransactionDetails
import AddressBook
@Reducer
public struct SendConfirmation {
enum Constants {
static let delay = 1.0
}
@ObservableState
public struct State: Equatable {
public enum Result: Equatable {
case failure
case partial
case resubmission
case success
}
public var address: String
@Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty
public var alias: String?
@Presents public var alert: AlertState<Action>?
public var amount: Zatoshi
public var canSendMail = false
public var currencyAmount: RedactableString
public var failedCode: Int?
public var failedDescription: String?
public var failedPcztMsg: String?
@Shared(.inMemory(.featureFlags)) public var featureFlags: FeatureFlags = .initial
public var feeRequired: Zatoshi
public var isAddressExpanded = false
public var isKeystoneCodeFound = false
public var isSending = false
public var isShielding = false
public var isTransparentAddress = false
public var message: String
public var messageToBeShared: String?
public var partialFailureTxIds: [String] = []
public var partialFailureStatuses: [String] = []
public var pczt: Pczt?
public var pcztForUI: Pczt?
public var pcztWithProofs: Pczt?
public var pcztWithSigs: Pczt?
public var proposal: Proposal?
public var randomSuccessIconIndex = 0
public var randomFailureIconIndex = 0
public var randomResubmissionIconIndex = 0
public var redactedPcztForSigner: Pczt?
public var rejectSendRequest = false
public var result: Result?
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
public var scanFailedDuringScanBinding = false
public var scanFailedPreScanBinding = false
public var sendingScreenOnAppearTimestamp: TimeInterval = 0
public var supportData: SupportData?
public var txIdToExpand: String?
@Shared(.inMemory(.walletAccounts)) public var walletAccounts: [WalletAccount] = []
@Shared(.inMemory(.zashiWalletAccount)) public var zashiWalletAccount: WalletAccount? = nil
public var pcztToShare: Pczt?
public var addressToShow: String {
isTransparentAddress
? address
: isAddressExpanded
? address
: address.zip316
}
public var successIlustration: Image {
switch randomSuccessIconIndex {
case 1: return Asset.Assets.Illustrations.success1.image
default: return Asset.Assets.Illustrations.success2.image
}
}
public var failureIlustration: Image {
switch randomFailureIconIndex {
case 1: return Asset.Assets.Illustrations.failure1.image
case 2: return Asset.Assets.Illustrations.failure2.image
default: return Asset.Assets.Illustrations.failure3.image
}
}
public var resubmissionIlustration: Image {
switch randomResubmissionIconIndex {
case 1: return Asset.Assets.Illustrations.resubmission1.image
default: return Asset.Assets.Illustrations.resubmission2.image
}
}
public init(
address: String,
amount: Zatoshi,
currencyAmount: RedactableString = .empty,
feeRequired: Zatoshi,
isSending: Bool = false,
message: String,
proposal: Proposal?
) {
self.address = address
self.amount = amount
self.currencyAmount = currencyAmount
self.feeRequired = feeRequired
self.isSending = isSending
self.message = message
self.proposal = proposal
}
}
public enum Action: BindableAction, Equatable {
case alert(PresentationAction<Action>)
case backFromFailureTapped
case binding(BindingAction<SendConfirmation.State>)
case cancelTapped
case closeTapped
case confirmWithKeystoneTapped
case getSignatureTapped
case goBackTappedFromRequestZec
case onAppear
case rejectRequestCanceled
case rejectRequested
case rejectTapped
case reportTapped
case saveAddressTapped(RedactableString)
case sendDone
case sendFailed(ZcashError?, Bool)
case sendingScreenOnAppear
case sendPartial([String], [String])
case sendRequested
case sendSupportMailFinished
case sendTapped
case sendTriggered
case shareFinished
case showHideButtonTapped
case stopSending
case updateFailedData(Int, String, String)
case updateResult(State.Result?)
case updateTxIdToExpand(String?)
case viewTransactionTapped
// PCZT
case addProofsToPczt
case backFromPCZTFailureTapped
case createTransactionFromPCZT
case foundPCZT(Pczt)
case pcztResolved(Pczt)
case pcztSendFailed(ZcashError?)
case pcztWithProofsResolved(Pczt)
case redactedPCZTForSigner(Pczt)
case redactPCZTForSigner
case resetPCZTs
case resolvePCZT
case sharePCZT
}
@Dependency(\.addressBook) var addressBook
@Dependency(\.audioServices) var audioServices
@Dependency(\.localAuthentication) var localAuthentication
@Dependency(\.derivationTool) var derivationTool
@Dependency(\.keystoneHandler) var keystoneHandler
@Dependency(\.mainQueue) var mainQueue
@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> {
BindingReducer()
Reduce { state, action in
switch action {
case .onAppear:
state.pcztForUI = nil
state.rejectSendRequest = false
state.txIdToExpand = nil
state.randomSuccessIconIndex = Int.random(in: 1...2)
state.randomFailureIconIndex = Int.random(in: 1...3)
state.randomResubmissionIconIndex = Int.random(in: 1...2)
state.isTransparentAddress = derivationTool.isTransparentAddress(state.address, zcashSDKEnvironment.network.networkType)
state.canSendMail = MFMailComposeViewController.canSendMail()
state.alias = nil
for contact in state.addressBookContacts.contacts {
if contact.id == state.address {
state.alias = contact.name
break
}
}
return .none
case .alert(.presented(let action)):
return .send(action)
case .alert(.dismiss):
state.alert = nil
return .none
case .binding:
return .none
case .saveAddressTapped:
return .none
case .showHideButtonTapped:
state.isAddressExpanded.toggle()
return .none
case .goBackTappedFromRequestZec:
return .none
case .cancelTapped:
return .none
case .viewTransactionTapped:
return .none
case .closeTapped, .backFromFailureTapped:
return .none
case .stopSending:
state.isSending = false
return .none
case .sendTapped:
state.isSending = true
return .run { send in
guard await localAuthentication.authenticate() else {
await send(.stopSending)
return
}
await send(.sendRequested)
}
case .sendRequested:
if state.featureFlags.sendingScreen {
return .run { send in
// delay here is necessary because we've just pushed the sending screen
// and we need it to finish the presentation on screen before the send logic is triggered.
// If the logic fails immediately, failed screen would try to be presented while
// sending screen is still presenting, resulting in a navigational bug.
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.sendTriggered)
}
} else {
return .send(.sendTriggered)
}
case .sendTriggered:
guard let proposal = state.proposal else {
return .send(.sendFailed("missing proposal".toZcashError(), true))
}
guard let zip32AccountIndex = state.selectedWalletAccount?.zip32AccountIndex else {
return .none
}
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, zip32AccountIndex, network)
let result = try await sdkSynchronizer.createProposedTransactions(proposal, spendingKey)
switch result {
case .grpcFailure(let txIds):
await send(.updateTxIdToExpand(txIds.last))
await send(.sendFailed("sdkSynchronizer.createProposedTransactions-grpcFailure".toZcashError(), false))
case let .failure(txIds, code, description):
await send(.updateFailedData(code, description, ""))
await send(.updateTxIdToExpand(txIds.last))
await send(.sendFailed("sdkSynchronizer.createProposedTransactions-failure \(code) \(description)".toZcashError(), true))
case let .partial(txIds: txIds, statuses: statuses):
await send(.updateTxIdToExpand(txIds.last))
await send(.sendPartial(txIds, statuses))
case .success(let txIds):
await send(.updateTxIdToExpand(txIds.last))
await send(.sendDone)
}
} catch {
await send(.sendFailed(error.toZcashError(), true))
}
}
case .sendDone:
state.isSending = false
if state.featureFlags.sendingScreen {
let diffTime = Date().timeIntervalSince1970 - state.sendingScreenOnAppearTimestamp
let waitTimeToPresentScreen = diffTime > 2.0 ? 0.01 : 2.0 - diffTime
return .run { send in
try? await mainQueue.sleep(for: .seconds(waitTimeToPresentScreen))
await send(.updateResult(.success))
}
} else {
return .none
}
case let .sendFailed(error, isFatal):
state.failedDescription = error?.localizedDescription ?? ""
state.isSending = false
if state.featureFlags.sendingScreen {
let diffTime = Date().timeIntervalSince1970 - state.sendingScreenOnAppearTimestamp
let waitTimeToPresentScreen = diffTime > 2.0 ? 0.01 : 2.0 - diffTime
return .run { send in
try? await mainQueue.sleep(for: .seconds(waitTimeToPresentScreen))
await send(.updateResult(isFatal ? .failure : .resubmission))
}
} else {
if let error {
state.alert = AlertState.sendFailure(error)
}
return .none
}
case let .sendPartial(txIds, statuses):
state.isSending = false
state.partialFailureTxIds = txIds
state.partialFailureStatuses = statuses
return .send(.updateResult(.partial))
case .updateTxIdToExpand(let txId):
state.txIdToExpand = txId
return .none
case .updateResult(let result):
state.result = result
if let result {
if result == .success {
audioServices.systemSoundVibrate()
return .none
} else {
return .run { _ in
audioServices.systemSoundVibrate()
try? await mainQueue.sleep(for: .seconds(Constants.delay))
audioServices.systemSoundVibrate()
}
}
} else {
return .none
}
case let .updateFailedData(code, desc, pcztMsg):
state.failedCode = code
state.failedDescription = desc
#if DEBUG
state.failedPcztMsg = pcztMsg
#endif
return .none
case .reportTapped:
var supportData = SupportDataGenerator.generate()
supportData.message =
"""
\(state.failedCode ?? -1000) \(state.failedDescription ?? "")
\(supportData.message)
\(state.failedPcztMsg ?? "")
"""
if state.canSendMail {
state.supportData = supportData
} else {
state.messageToBeShared = supportData.message
}
return .none
case .sendSupportMailFinished:
state.supportData = nil
return .none
case .shareFinished:
state.messageToBeShared = nil
state.pcztToShare = nil
return .none
case .sendingScreenOnAppear:
state.sendingScreenOnAppearTimestamp = Date().timeIntervalSince1970
return .none
// MARK: - Keystone
case .getSignatureTapped:
state.isKeystoneCodeFound = false
keystoneHandler.resetQRDecoder()
return .none
case .rejectRequestCanceled:
state.rejectSendRequest = false
return .none
case .rejectRequested:
state.rejectSendRequest = true
return .none
case .rejectTapped:
state.rejectSendRequest = false
return .send(.resetPCZTs)
case .confirmWithKeystoneTapped:
return .none
case .foundPCZT(let pcztWithSigs):
guard !state.scanFailedPreScanBinding && !state.scanFailedDuringScanBinding else {
return .none
}
if !state.isKeystoneCodeFound {
state.isKeystoneCodeFound = true
state.pcztWithSigs = pcztWithSigs
return .run { send in
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.createTransactionFromPCZT)
}
}
return .none
case .resolvePCZT:
guard let proposal = state.proposal, let account = state.selectedWalletAccount else {
return .run { send in
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.updateFailedData(-899, "resolvePCZT failed to start the process", ""))
await send(.pcztSendFailed("resolvePCZT failed to start the process".toZcashError()))
}
}
return .run { send in
do {
let pczt = try await sdkSynchronizer.createPCZTFromProposal(account.id, proposal)
await send(.pcztResolved(pczt))
} catch {
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.updateFailedData(-898, error.toZcashError().detailedMessage, ""))
await send(.pcztSendFailed("resolvePCZT createPCZTFromProposal failed".toZcashError()))
}
}
case .pcztResolved(let pczt):
state.pczt = pczt
return .merge(
.send(.redactPCZTForSigner),
.send(.addProofsToPczt)
)
case .redactPCZTForSigner:
guard let pczt = state.pczt else {
return .run { send in
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.updateFailedData(-797, "redactPCZTForSigner failed to start the process", ""))
await send(.pcztSendFailed("redactPCZTForSigner failed to start the process".toZcashError()))
}
}
return .run { send in
do {
let redactedPczt = try await sdkSynchronizer.redactPCZTForSigner(Pczt(pczt))
await send(.redactedPCZTForSigner(redactedPczt))
} catch {
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.updateFailedData(-796, error.toZcashError().detailedMessage, ""))
await send(.pcztSendFailed("redactPCZTForSigner failed".toZcashError()))
}
}
case .redactedPCZTForSigner(let redactedPczt):
state.redactedPcztForSigner = redactedPczt
state.pcztForUI = redactedPczt
return .none
case .addProofsToPczt:
guard let pczt = state.pczt else {
return .run { send in
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.updateFailedData(-799, "addProofsToPczt failed to start the process", ""))
await send(.pcztSendFailed("addProofsToPczt failed to start the process".toZcashError()))
}
}
return .run { send in
do {
let pcztWithProofs = try await sdkSynchronizer.addProofsToPCZT(Pczt(pczt))
await send(.pcztWithProofsResolved(pcztWithProofs))
} catch {
try? await mainQueue.sleep(for: .seconds(Constants.delay))
await send(.updateFailedData(-798, error.toZcashError().detailedMessage, ""))
await send(.pcztSendFailed("addProofsToPczt failed".toZcashError()))
}
}
case .pcztWithProofsResolved(let pcztWithProofs):
state.pcztWithProofs = pcztWithProofs
return .send(.createTransactionFromPCZT)
case .sharePCZT:
state.pcztToShare = state.pczt
return .none
case .createTransactionFromPCZT:
guard let pcztWithProofs = state.pcztWithProofs, let pcztWithSigs = state.pcztWithSigs else {
return .none
}
#if DEBUG
let pcztMessage =
"""
original pczt:
\(state.pczt?.hexEncodedString() ?? "failed to unwrap")
redactedPcztForSigner:
\(state.redactedPcztForSigner?.hexEncodedString() ?? "failed to unwrap")
pcztWithProofs:
\(pcztWithProofs.hexEncodedString())
pcztWithSigs:
\(pcztWithSigs.hexEncodedString())
"""
#else
let pcztMessage = ""
#endif
return .run { send in
do {
let result = try await sdkSynchronizer.createTransactionFromPCZT(pcztWithProofs, pcztWithSigs)
await send(.resetPCZTs)
switch result {
case .grpcFailure(let txIds):
await send(.updateFailedData(-999, "grpcFailure", pcztMessage))
await send(.updateTxIdToExpand(txIds.last))
await send(.sendFailed("sdkSynchronizer.createProposedTransactions".toZcashError(), false))
case let .failure(txIds, code, description):
if description.isEmpty {
await send(.updateFailedData(-997, "result.failure \(txIds)", pcztMessage))
} else {
await send(.updateFailedData(code, description, pcztMessage))
}
await send(.updateTxIdToExpand(txIds.last))
await send(.sendFailed("sdkSynchronizer.createProposedTransactions".toZcashError(), true))
case let .partial(txIds: txIds, statuses: statuses):
await send(.updateTxIdToExpand(txIds.last))
await send(.sendPartial(txIds, statuses))
case .success(let txIds):
await send(.updateTxIdToExpand(txIds.last))
await send(.sendDone)
}
} catch {
await send(.resetPCZTs)
await send(.updateFailedData(-998, error.toZcashError().detailedMessage, pcztMessage))
await send(.sendFailed(error.toZcashError(), true))
}
}
case .pcztSendFailed:
state.isSending = false
return .none
case .backFromPCZTFailureTapped:
return .none
case .resetPCZTs:
state.pczt = nil
state.pcztWithProofs = nil
state.pcztWithSigs = nil
state.pcztToShare = nil
state.proposal = nil
state.redactedPcztForSigner = nil
return .none
}
}
}
}
// MARK: Alerts
extension AlertState where Action == SendConfirmation.Action {
public static func sendFailure(_ error: ZcashError) -> AlertState {
AlertState {
TextState(L10n.Send.Alert.Failure.title)
} message: {
TextState(L10n.Send.Alert.Failure.message(error.detailedMessage))
}
}
}
extension Pczt {
func hexEncodedString() -> String {
return map { String(format: "%02hhx", $0) }.joined()
}
}