614 lines
24 KiB
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()
|
|
}
|
|
}
|