secant-ios-wallet/modules/Sources/Features/CoordFlows/ScanCoordFlowCoordinator.swift

726 lines
34 KiB
Swift

//
// ScanCoordFlowCoordinator.swift
// Zashi
//
// Created by Lukáš Korba on 2025-03-19.
//
import Foundation
import ComposableArchitecture
import ZcashLightClientKit
import ZcashPaymentURI
import Generated
import AudioServices
// Path
import AddressBook
import PartialProposalError
import Scan
import SendConfirmation
import SendForm
import TransactionDetails
extension ScanCoordFlow {
public func coordinatorReduce() -> Reduce<ScanCoordFlow.State, ScanCoordFlow.Action> {
Reduce { state, action in
switch action {
// MARK: - Address Book
case .path(.element(id: _, action: .addressBook(.editId(let address)))):
let _ = state.path.removeLast()
audioServices.systemSoundVibrate()
if let first = state.path.ids.first {
return .send(.path(.element(id: first, action: .sendForm(.addressUpdated(address.redacted)))))
}
return .none
case .path(.element(id: _, action: .addressBook(.walletAccountTapped(let contact)))):
if let address = contact.unifiedAddress {
let _ = state.path.removeLast()
audioServices.systemSoundVibrate()
if let first = state.path.ids.first {
return .send(.path(.element(id: first, action: .sendForm(.addressUpdated(address.redacted)))))
}
}
return .none
case .path(.element(id: _, action: .addressBook(.addManualButtonTapped))):
var addressBookState = AddressBook.State.initial
addressBookState.isAddressFocused = true
state.path.append(.addressBookContact(addressBookState))
return .none
case .path(.element(id: _, action: .addressBook(.scanButtonTapped))):
var scanState = Scan.State.initial
scanState.checkers = [.zcashAddressScanChecker]
state.path.append(.scan(scanState))
return .none
// MARK: - Address Book Contact
case .path(.element(id: _, action: .addressBookContact(.dismissAddContactRequired))):
let _ = state.path.popLast()
// handling the path in the transaction details
for element in state.path {
if element.is(\.transactionDetails) {
return .none
}
}
// handling the path in send confirmation
for element in state.path {
if element.is(\.sendConfirmation) {
return .none
}
}
// handling the path in send form
for element in state.path {
if element.is(\.scan) {
let _ = state.path.popLast()
return .none
}
}
return .none
// MARK: - Keystone
case .path(.element(id: _, action: .sendConfirmation(.confirmWithKeystoneTapped))):
for element in state.path {
if case .sendConfirmation(let sendConfirmationState) = element {
state.path.append(.confirmWithKeystone(sendConfirmationState))
if let last = state.path.ids.last {
return .send(.path(.element(id: last, action: .confirmWithKeystone(.resolvePCZT))))
}
}
}
return .none
case .path(.element(id: _, action: .requestZecConfirmation(.confirmWithKeystoneTapped))):
for element in state.path {
if case .requestZecConfirmation(let sendConfirmationState) = element {
state.path.append(.confirmWithKeystone(sendConfirmationState))
if let last = state.path.ids.last {
return .send(.path(.element(id: last, action: .confirmWithKeystone(.resolvePCZT))))
}
}
}
return .none
case .path(.element(id: _, action: .confirmWithKeystone(.getSignatureTapped))):
var scanState = Scan.State.initial
scanState.checkers = [.keystonePCZTScanChecker]
state.path.append(.scan(scanState))
return .none
case .path(.element(id: _, action: .scan(.foundPCZT(let pcztWithSigs)))):
for (id, element) in zip(state.path.ids, state.path) {
if case .confirmWithKeystone(let sendConfirmationState) = element {
state.path.append(.sending(sendConfirmationState))
return .send(.path(.element(id: id, action: .confirmWithKeystone(.foundPCZT(pcztWithSigs)))))
}
}
return .none
case .path(.element(id: _, action: .confirmWithKeystone(.updateResult(let result)))):
for element in state.path {
if case .confirmWithKeystone(let sendConfirmationState) = element {
return .send(.resolveSendResult(result, sendConfirmationState))
}
}
return .none
case .path(.element(id: _, action: .confirmWithKeystone(.pcztSendFailed(let error)))):
for element in state.path.reversed() {
if element.is(\.sending) {
for (id, element2) in zip(state.path.ids, state.path) {
if element2.is(\.confirmWithKeystone) {
return .send(.path(.element(id: id, action: .confirmWithKeystone(.sendFailed(error?.toZcashError(), true)))))
}
}
break
} else if element.is(\.scan) || element.is(\.confirmWithKeystone) {
for element2 in state.path {
if case .confirmWithKeystone(let sendConfirmationState) = element2 {
state.path.append(.preSendingFailure(sendConfirmationState))
}
}
break
}
}
return .none
// MARK: - Request ZEC Confirmation
case .path(.element(id: _, action: .requestZecConfirmation(.goBackTappedFromRequestZec))):
for (id, element) in zip(state.path.ids, state.path) {
if element.is(\.sendForm) {
state.path.pop(to: id)
}
}
return .none
case .path(.element(id: _, action: .requestZecConfirmation(.sendRequested))):
for element in state.path {
if case .requestZecConfirmation(let sendConfirmationState) = element {
state.path.append(.sending(sendConfirmationState))
break
}
}
return .none
case .path(.element(id: _, action: .requestZecConfirmation(.saveAddressTapped(let address)))):
var addressBookState = AddressBook.State.initial
addressBookState.isNameFocused = true
addressBookState.address = address.data
addressBookState.isValidZcashAddress = true
state.path.append(.addressBookContact(addressBookState))
return .none
case .path(.element(id: _, action: .requestZecConfirmation(.updateResult(let result)))):
for element in state.path {
if case .requestZecConfirmation(let sendConfirmationState) = element {
return .send(.resolveSendResult(result, sendConfirmationState))
}
}
return .none
// MARK: - Scan
case .path(.element(id: _, action: .scan(.foundAddress(let address)))):
for (id, element) in zip(state.path.ids, state.path) {
if element.is(\.sendForm) {
let _ = state.path.removeLast()
audioServices.systemSoundVibrate()
return .send(.path(.element(id: id, action: .sendForm(.addressUpdated(address)))))
}
}
return .none
case .path(.element(id: _, action: .scan(.cancelTapped))):
let _ = state.path.popLast()
return .none
case .path(.element(id: _, action: .scan(.foundRequestZec(let requestPayment)))):
if case .legacy(let address) = requestPayment {
for (id, element) in zip(state.path.ids, state.path) {
if element.is(\.sendForm) {
let _ = state.path.removeLast()
audioServices.systemSoundVibrate()
state.path[id: id, case: \.sendForm]?.memoState.text = ""
return .merge(
.send(.path(.element(id: id, action: .sendForm(.zecAmountUpdated("".redacted))))),
.send(.path(.element(id: id, action: .sendForm(.addressUpdated(address.value.redacted)))))
)
}
}
} else if case .request(let paymentRequest) = requestPayment {
return .send(.getProposal(paymentRequest))
}
return .none
// MARK: - Self
case .scan(.foundAddress(let address)):
audioServices.systemSoundVibrate()
state.path.append(.sendForm(SendForm.State.initial))
if let first = state.path.ids.first {
return .send(.path(.element(id: first, action: .sendForm(.addressUpdated(address)))))
}
return .none
case .scan(.foundRequestZec(let requestPayment)):
if case .legacy(let address) = requestPayment {
return .send(.scan(.foundAddress(address.value.redacted)))
} else if case .request(let paymentRequest) = requestPayment {
return .send(.getProposal(paymentRequest))
}
return .none
case .getProposal(let paymentRequest):
guard let account = state.selectedWalletAccount else {
return .none
}
do {
if let payment = paymentRequest.payments.first {
var textMemo = ""
if let memoBytes = payment.memo, let memo = try? Memo(bytes: [UInt8](memoBytes.memoData)) {
textMemo = memo.toString() ?? ""
}
let numberLocale = numberFormatter.convertUSToLocale(payment.amount.toString()) ?? ""
state.recipient = try Recipient(payment.recipientAddress.value, network: zcashSDKEnvironment.network.networkType)
state.memo = textMemo.isEmpty ? nil : try Memo(string: textMemo)
if let number = numberFormatter.number(numberLocale) {
state.amount = Zatoshi(NSDecimalNumber(
decimal: number.decimalValue * Decimal(Zatoshi.Constants.oneZecInZatoshi)
).roundedZec.int64Value)
}
}
} catch {
return .send(.requestZecFailed)
}
return .run { [state] send in
guard let recipient = state.recipient else {
return
}
do {
let proposal = try await sdkSynchronizer.proposeTransfer(account.id, recipient, state.amount, state.memo)
await send(.proposalResolved(proposal))
} catch {
await send(.requestZecFailed)
}
}
case .proposalResolved(let proposal):
if state.path.ids.isEmpty {
return .send(.proposalResolvedNoSendForm(proposal))
}
return .send(.proposalResolvedExistingSendForm(proposal))
case .proposalResolvedExistingSendForm(let proposal):
state.proposal = proposal
guard let address = state.recipient?.stringEncoded else {
return .send(.requestZecFailed)
}
var sendConfirmationState = SendConfirmation.State.initial
sendConfirmationState.amount = state.amount
sendConfirmationState.address = address
sendConfirmationState.proposal = proposal
sendConfirmationState.feeRequired = proposal.totalFeeRequired()
sendConfirmationState.message = state.memo?.toString() ?? ""
sendConfirmationState.currencyAmount = state.currencyConversion?.convert(state.amount).redacted ?? .empty
state.path.append(.requestZecConfirmation(sendConfirmationState))
audioServices.systemSoundVibrate()
if let first = state.path.ids.first {
state.path[id: first, case: \.sendForm]?.memoState.text = sendConfirmationState.message
return .merge(
.send(.path(.element(id: first, action: .sendForm(.zecAmountUpdated(state.amount.decimalString().redacted))))),
.send(.path(.element(id: first, action: .sendForm(.addressUpdated(address.redacted)))))
)
}
return .none
case .proposalResolvedNoSendForm(let proposal):
state.proposal = proposal
guard let address = state.recipient?.stringEncoded else {
return .send(.requestZecFailed)
}
var sendFormState = SendForm.State.initial
sendFormState.memoState.text = state.memo?.toString() ?? ""
state.path.append(.sendForm(sendFormState))
var sendConfirmationState = SendConfirmation.State.initial
sendConfirmationState.amount = state.amount
sendConfirmationState.address = address
sendConfirmationState.proposal = proposal
sendConfirmationState.feeRequired = proposal.totalFeeRequired()
sendConfirmationState.message = sendFormState.memoState.text
sendConfirmationState.currencyAmount = state.currencyConversion?.convert(state.amount).redacted ?? .empty
state.path.append(.requestZecConfirmation(sendConfirmationState))
audioServices.systemSoundVibrate()
if let first = state.path.ids.first {
return .merge(
.send(.path(.element(id: first, action: .sendForm(.zecAmountUpdated(state.amount.decimalString().redacted))))),
.send(.path(.element(id: first, action: .sendForm(.addressUpdated(address.redacted)))))
)
}
return .none
case .requestZecFailed:
if state.path.ids.isEmpty {
return .send(.requestZecFailedNoSendForm)
}
return .send(.requestZecFailedExistingSendForm)
case .requestZecFailedExistingSendForm:
let _ = state.path.removeLast()
for (id, element) in zip(state.path.ids, state.path) {
if element.is(\.sendForm) {
audioServices.systemSoundVibrate()
let address = state.recipient?.stringEncoded ?? ""
let memo = state.memo?.toString() ?? ""
state.path[id: id, case: \.sendForm]?.memoState.text = memo
return .merge(
.send(.path(.element(id: id, action: .sendForm(.zecAmountUpdated(state.amount.decimalString().redacted))))),
.send(.path(.element(id: id, action: .sendForm(.addressUpdated(address.redacted)))))
)
}
}
return .none
case .requestZecFailedNoSendForm:
let address = state.recipient?.stringEncoded ?? ""
var sendFormState = SendForm.State.initial
sendFormState.memoState.text = state.memo?.toString() ?? ""
state.path.append(.sendForm(sendFormState))
audioServices.systemSoundVibrate()
if let first = state.path.ids.first {
return .merge(
.send(.path(.element(id: first, action: .sendForm(.zecAmountUpdated(state.amount.decimalString().redacted))))),
.send(.path(.element(id: first, action: .sendForm(.addressUpdated(address.redacted)))))
)
}
return .none
case let .resolveSendResult(result, sendConfirmationState):
switch result {
case .failure:
state.path.append(.sendResultFailure(sendConfirmationState))
break
case .partial:
var partialProposalErrorState = PartialProposalError.State.initial
partialProposalErrorState.statuses = sendConfirmationState.partialFailureStatuses
partialProposalErrorState.txIds = sendConfirmationState.partialFailureTxIds
state.path.append(.sendResultPartial(partialProposalErrorState))
break
case .resubmission:
state.path.append(.sendResultResubmission(sendConfirmationState))
break
case .success:
state.path.append(.sendResultSuccess(sendConfirmationState))
default: break
}
return .none
case .viewTransactionRequested(let sendConfirmationState):
if let txid = sendConfirmationState.txIdToExpand {
if let index = state.transactions.index(id: txid) {
var transactionDetailsState = TransactionDetails.State.initial
transactionDetailsState.transaction = state.transactions[index]
transactionDetailsState.isCloseButtonRequired = true
state.path.append(.transactionDetails(transactionDetailsState))
}
}
return .none
// MARK: - Send
case .path(.element(id: _, action: .sendForm(.addressBookTapped))):
var addressBookState = AddressBook.State.initial
addressBookState.isInSelectMode = true
state.path.append(.addressBook(addressBookState))
return .none
case .path(.element(id: _, action: .sendForm(.addNewContactTapped(let address)))):
var addressBookState = AddressBook.State.initial
addressBookState.isNameFocused = true
addressBookState.address = address.data
addressBookState.isValidZcashAddress = true
state.path.append(.addressBookContact(addressBookState))
return .none
case .path(.element(id: _, action: .sendForm(.scanTapped))):
var scanState = Scan.State.initial
scanState.checkers = [.zcashAddressScanChecker, .requestZecScanChecker]
state.path.append(.scan(scanState))
return .none
case .path(.element(id: _, action: .sendForm(.confirmationRequired(let confirmationType)))):
for element in state.path {
if case .sendForm(let sendFormState) = element {
var sendConfirmationState = SendConfirmation.State.initial
sendConfirmationState.amount = sendFormState.amount
sendConfirmationState.address = sendFormState.address.data
sendConfirmationState.proposal = sendFormState.proposal
sendConfirmationState.feeRequired = sendFormState.feeRequired
sendConfirmationState.message = sendFormState.message
let currencyAmount = sendFormState.currencyConversion?.convert(sendFormState.amount).redacted ?? .empty
sendConfirmationState.currencyAmount = currencyAmount
if confirmationType == .send {
state.path.append(.sendConfirmation(sendConfirmationState))
}
}
}
return .none
// MARK: - Send Confirmation
case .path(.element(id: _, action: .sendConfirmation(.cancelTapped))):
let _ = state.path.removeLast()
return .none
case .path(.element(id: _, action: .sendConfirmation(.sendRequested))):
for element in state.path {
if case .sendConfirmation(let sendConfirmationState) = element {
state.path.append(.sending(sendConfirmationState))
break
}
}
return .none
case .path(.element(id: _, action: .sendConfirmation(.updateResult(let result)))):
for element in state.path {
if case .sendConfirmation(let sendConfirmationState) = element {
return .send(.resolveSendResult(result, sendConfirmationState))
}
}
return .none
case .path(.element(id: _, action: .sendResultFailure(.backFromFailureTapped))),
.path(.element(id: _, action: .preSendingFailure(.backFromPCZTFailureTapped))):
for (id, element) in zip(state.path.ids, state.path) {
if element.is(\.sendForm) {
state.path.pop(to: id)
}
}
return .none
case .path(.element(id: _, action: .sendResultSuccess(.viewTransactionTapped))),
.path(.element(id: _, action: .sendResultFailure(.viewTransactionTapped))),
.path(.element(id: _, action: .sendResultResubmission(.viewTransactionTapped))):
for element in state.path.reversed() {
if case .sendConfirmation(let sendConfirmationState) = element {
return .send(.viewTransactionRequested(sendConfirmationState))
} else if case .requestZecConfirmation(let sendConfirmationState) = element {
return .send(.viewTransactionRequested(sendConfirmationState))
} else if case .confirmWithKeystone(let sendConfirmationState) = element {
return .send(.viewTransactionRequested(sendConfirmationState))
}
}
return .none
// MARK: - Transaction Details
case .path(.element(id: _, action: .transactionDetails(.saveAddressTapped))):
for element in state.path {
if case .transactionDetails(let transactionDetailsState) = element {
var addressBookState = AddressBook.State.initial
addressBookState.address = transactionDetailsState.transaction.address
addressBookState.isNameFocused = true
addressBookState.isValidZcashAddress = true
state.path.append(.addressBookContact(addressBookState))
}
}
return .none
case .path(.element(id: _, action: .transactionDetails(.sendAgainTapped))):
for (id, element) in zip(state.path.ids, state.path) {
if element.is(\.sendForm) {
state.path.pop(to: id)
}
}
return .none
// MARK: -
// Handling of scan inside address book
// for element in state.path {
// if element.is(\.addressBook) {
// var addressBookState = AddressBook.State.initial
// addressBookState.address = address.data
// addressBookState.isValidZcashAddress = true
// addressBookState.isNameFocused = true
// state.path.append(.addressBookContact(addressBookState))
// audioServices.systemSoundVibrate()
// return .none
// }
// }
// // handling of scan for the send form
// let _ = state.path.popLast()
// audioServices.systemSoundVibrate()
// return .send(.sendForm(.addressUpdated(address)))
// case .scan(.foundRequestZec(let requestPayment)):
// return .send(.sendForm(.requestZec(requestPayment)))
/*
// MARK: Address Book
case .path(.element(id: _, action: .addressBook(.editId(let address)))):
state.path.removeAll()
audioServices.systemSoundVibrate()
return .send(.sendForm(.addressUpdated(address.redacted)))
case .path(.element(id: _, action: .addressBook(.addManualButtonTapped))):
var addressBookState = AddressBook.State.initial
addressBookState.isAddressFocused = true
state.path.append(.addressBookContact(addressBookState))
return .none
case .path(.element(id: _, action: .addressBook(.scanButtonTapped))):
var scanState = Scan.State.initial
scanState.checkers = [.zcashAddressScanChecker]
state.path.append(.scan(scanState))
return .none
// MARK: Address Book Contact
case .path(.element(id: _, action: .addressBookContact(.dismissAddContactRequired))):
let _ = state.path.popLast()
for element in state.path {
if element.is(\.scan) {
let _ = state.path.popLast()
return .none
}
}
return .none
// MARK: Request ZEC Confirmation
case .path(.element(id: _, action: .requestZecConfirmation(.goBackTappedFromRequestZec))):
state.path.removeAll()
return .none
case .path(.element(id: _, action: .requestZecConfirmation(.sendTapped))):
for element in state.path {
if case .requestZecConfirmation(let sendConfirmationState) = element {
state.path.append(.sending(sendConfirmationState))
break
}
}
return .none
case .path(.element(id: _, action: .requestZecConfirmation(.sendFailed))):
state.path.removeAll()
return .none
// MARK: Send
case .sendForm(.addressBookTapped):
var addressBookState = AddressBook.State.initial
addressBookState.isInSelectMode = true
state.path.append(.addressBook(addressBookState))
return .none
case .sendForm(.scanTapped):
var scanState = Scan.State.initial
scanState.checkers = [.zcashAddressScanChecker, .requestZecScanChecker]
state.path.append(.scan(scanState))
return .none
case .sendForm(.confirmationRequired(let confirmationType)):
var sendConfirmationState = SendConfirmation.State.initial
sendConfirmationState.amount = state.sendFormState.amount
sendConfirmationState.address = state.sendFormState.address.data
sendConfirmationState.proposal = state.sendFormState.proposal
sendConfirmationState.feeRequired = state.sendFormState.feeRequired
sendConfirmationState.message = state.sendFormState.message
let currencyAmount = state.sendFormState.currencyConversion?.convert(state.sendFormState.amount).redacted ?? .empty
sendConfirmationState.currencyAmount = currencyAmount
if confirmationType == .send {
state.path.append(.sendConfirmation(sendConfirmationState))
} else if confirmationType == .requestPayment {
state.path.append(.requestZecConfirmation(sendConfirmationState))
}
return .none
case let .sendForm(.sendFailed(_, confirmationType)):
if confirmationType == .requestPayment {
state.path.removeAll()
}
return .none
// MARK: Send Confirmation
case .path(.element(id: _, action: .sendConfirmation(.cancelTapped))):
let _ = state.path.popLast()
return .none
case .path(.element(id: _, action: .sendConfirmation(.sendTapped))):
for element in state.path {
if case .sendConfirmation(let sendConfirmationState) = element {
state.path.append(.sending(sendConfirmationState))
break
}
}
return .none
case .path(.element(id: _, action: .sendConfirmation(.updateResult(let result)))),
.path(.element(id: _, action: .requestZecConfirmation(.updateResult(let result)))):
for element in state.path {
if case .sending(let sendConfirmationState) = element {
switch result {
case .failure:
state.path.append(.sendResultFailure(sendConfirmationState))
break
case .partial:
var partialProposalErrorState = PartialProposalError.State.initial
partialProposalErrorState.statuses = sendConfirmationState.partialFailureStatuses
partialProposalErrorState.txIds = sendConfirmationState.partialFailureTxIds
state.path.append(.sendResultPartial(partialProposalErrorState))
break
case .resubmission:
state.path.append(.sendResultResubmission(sendConfirmationState))
break
case .success:
state.path.append(.sendResultSuccess(sendConfirmationState))
default: break
}
}
}
return .none
case .path(.element(id: _, action: .sendResultFailure(.backFromFailureTapped))):
state.path.removeAll()
return .none
case .path(.element(id: _, action: .preSendingFailure(.backFromPCZTFailureTapped))):
state.path.removeAll()
return .none
case .path(.element(id: _, action: .sendResultSuccess(.viewTransactionTapped))),
.path(.element(id: _, action: .sendResultFailure(.viewTransactionTapped))),
.path(.element(id: _, action: .sendResultResubmission(.viewTransactionTapped))):
var transactionDetailsState = TransactionDetails.State.initial
for element in state.path {
if case .sendConfirmation(let sendConfirmationState) = element {
if let txid = sendConfirmationState.txIdToExpand {
if let index = state.transactions.index(id: txid) {
transactionDetailsState.transaction = state.transactions[index]
transactionDetailsState.isCloseButtonRequired = true
state.path.append(.transactionDetails(transactionDetailsState))
break
}
}
}
}
return .none
// MARK: Self
case .dismissRequired:
return .none
// MARK: Transaction Details
case .path(.element(id: _, action: .transactionDetails(.saveAddressTapped))):
var addressBookState = AddressBook.State.initial
addressBookState.address = state.sendFormState.address.data
addressBookState.isNameFocused = true
addressBookState.isValidZcashAddress = true
state.path.append(.addressBookContact(addressBookState))
return .none
case .path(.element(id: _, action: .transactionDetails(.sendAgainTapped))):
state.path.removeAll()
return .none
*/
default: return .none
}
}
}
}