382 lines
13 KiB
Swift
382 lines
13 KiB
Swift
//
|
|
// SendFlowStore.swift
|
|
// secant-testnet
|
|
//
|
|
// Created by Lukáš Korba on 04/25/2022.
|
|
//
|
|
|
|
import SwiftUI
|
|
import ComposableArchitecture
|
|
import ZcashLightClientKit
|
|
import AudioServices
|
|
import Utils
|
|
import Scan
|
|
import MnemonicClient
|
|
import SDKSynchronizer
|
|
import WalletStorage
|
|
import ZcashSDKEnvironment
|
|
import UIComponents
|
|
import Models
|
|
import Generated
|
|
import BalanceFormatter
|
|
|
|
public typealias SendFlowStore = Store<SendFlowReducer.State, SendFlowReducer.Action>
|
|
public typealias SendFlowViewStore = ViewStore<SendFlowReducer.State, SendFlowReducer.Action>
|
|
|
|
public struct SendFlowReducer: Reducer {
|
|
private enum SyncStatusUpdatesID { case timer }
|
|
|
|
public struct State: Equatable {
|
|
public enum Destination: Equatable {
|
|
case sendConfirmation
|
|
case scanQR
|
|
}
|
|
|
|
@PresentationState public var alert: AlertState<Action>?
|
|
public var addMemoState: Bool
|
|
public var destination: Destination?
|
|
public var isSending = false
|
|
public var memoState: MessageEditorReducer.State
|
|
public var scanState: Scan.State
|
|
public var shieldedBalance = Zatoshi.zero
|
|
public var totalBalance = Zatoshi.zero
|
|
public var transactionAddressInputState: TransactionAddressTextFieldReducer.State
|
|
public var transactionAmountInputState: TransactionAmountTextFieldReducer.State
|
|
|
|
public var address: String {
|
|
get { transactionAddressInputState.textFieldState.text.data }
|
|
set { transactionAddressInputState.textFieldState.text = newValue.redacted }
|
|
}
|
|
|
|
public var amount: Zatoshi {
|
|
get { Zatoshi(transactionAmountInputState.amount.data) }
|
|
set {
|
|
transactionAmountInputState.amount = newValue.amount.redacted
|
|
transactionAmountInputState.textFieldState.text = newValue.amount == 0 ?
|
|
"".redacted :
|
|
newValue.decimalString().redacted
|
|
}
|
|
}
|
|
|
|
public var feeFormat: String {
|
|
L10n.Send.fee(ZatoshiStringRepresentation.feeFormat)
|
|
}
|
|
|
|
public var message: String {
|
|
memoState.text.data
|
|
}
|
|
|
|
public var isInvalidAddressFormat: Bool {
|
|
!transactionAddressInputState.isValidAddress
|
|
&& !transactionAddressInputState.textFieldState.text.data.isEmpty
|
|
}
|
|
|
|
public var isInvalidAmountFormat: Bool {
|
|
!transactionAmountInputState.textFieldState.valid
|
|
&& !transactionAmountInputState.textFieldState.text.data.isEmpty
|
|
}
|
|
|
|
public var isValidForm: Bool {
|
|
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
|
|
|
|
return transactionAmountInputState.amount.data > zcashSDKEnvironment.network.constants.defaultFee().amount
|
|
&& transactionAddressInputState.isValidAddress
|
|
&& !isInsufficientFunds
|
|
&& memoState.isValid
|
|
}
|
|
|
|
public var isInsufficientFunds: Bool {
|
|
transactionAmountInputState.amount.data > transactionAmountInputState.maxValue.data
|
|
}
|
|
|
|
public var isMemoInputEnabled: Bool {
|
|
transactionAddressInputState.textFieldState.text.data.isEmpty ||
|
|
!transactionAddressInputState.isValidTransparentAddress
|
|
}
|
|
|
|
public var totalCurrencyBalance: Zatoshi {
|
|
Zatoshi.from(decimal: shieldedBalance.decimalValue.decimalValue * transactionAmountInputState.zecPrice)
|
|
}
|
|
|
|
public var spendableBalanceString: String {
|
|
shieldedBalance.decimalString(formatter: NumberFormatter.zashiBalanceFormatter)
|
|
}
|
|
|
|
public init(
|
|
addMemoState: Bool,
|
|
destination: Destination? = nil,
|
|
memoState: MessageEditorReducer.State,
|
|
scanState: Scan.State,
|
|
shieldedBalance: Zatoshi = .zero,
|
|
totalBalance: Zatoshi = .zero,
|
|
transactionAddressInputState: TransactionAddressTextFieldReducer.State,
|
|
transactionAmountInputState: TransactionAmountTextFieldReducer.State
|
|
) {
|
|
self.addMemoState = addMemoState
|
|
self.destination = destination
|
|
self.memoState = memoState
|
|
self.scanState = scanState
|
|
self.shieldedBalance = shieldedBalance
|
|
self.totalBalance = totalBalance
|
|
self.transactionAddressInputState = transactionAddressInputState
|
|
self.transactionAmountInputState = transactionAmountInputState
|
|
}
|
|
}
|
|
|
|
public enum Action: Equatable {
|
|
case alert(PresentationAction<Action>)
|
|
case goBackPressed
|
|
case memo(MessageEditorReducer.Action)
|
|
case onAppear
|
|
case onDisappear
|
|
case reviewPressed
|
|
case scan(Scan.Action)
|
|
case sendPressed
|
|
case sendDone(TransactionState)
|
|
case sendFailed(ZcashError)
|
|
case synchronizerStateChanged(RedactableSynchronizerState)
|
|
case transactionAddressInput(TransactionAddressTextFieldReducer.Action)
|
|
case transactionAmountInput(TransactionAmountTextFieldReducer.Action)
|
|
case updateDestination(SendFlowReducer.State.Destination?)
|
|
}
|
|
|
|
@Dependency(\.audioServices) var audioServices
|
|
@Dependency(\.derivationTool) var derivationTool
|
|
@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> {
|
|
Scope(state: \.memoState, action: /Action.memo) {
|
|
MessageEditorReducer()
|
|
}
|
|
|
|
Scope(state: \.transactionAddressInputState, action: /Action.transactionAddressInput) {
|
|
TransactionAddressTextFieldReducer()
|
|
}
|
|
|
|
Scope(state: \.transactionAmountInputState, action: /Action.transactionAmountInput) {
|
|
TransactionAmountTextFieldReducer()
|
|
}
|
|
|
|
Scope(state: \.scanState, action: /Action.scan) {
|
|
Scan()
|
|
}
|
|
|
|
Reduce { state, action in
|
|
switch action {
|
|
case .alert(.presented(let action)):
|
|
return Effect.send(action)
|
|
|
|
case .alert(.dismiss):
|
|
state.alert = nil
|
|
return .none
|
|
|
|
case .alert:
|
|
return .none
|
|
|
|
case .onAppear:
|
|
state.memoState.charLimit = zcashSDKEnvironment.memoCharLimit
|
|
return Effect.publisher {
|
|
sdkSynchronizer.stateStream()
|
|
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
|
|
.map{ $0.redacted }
|
|
.map(SendFlowReducer.Action.synchronizerStateChanged)
|
|
}
|
|
.cancellable(id: SyncStatusUpdatesID.timer, cancelInFlight: true)
|
|
|
|
case .onDisappear:
|
|
return .cancel(id: SyncStatusUpdatesID.timer)
|
|
|
|
case .goBackPressed:
|
|
state.destination = nil
|
|
state.isSending = false
|
|
return .none
|
|
|
|
case let .updateDestination(destination):
|
|
state.destination = destination
|
|
return .none
|
|
|
|
case .reviewPressed:
|
|
state.destination = .sendConfirmation
|
|
return .none
|
|
|
|
case .sendPressed:
|
|
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()))
|
|
}
|
|
}
|
|
} catch {
|
|
return .send(.sendFailed(error.toZcashError()))
|
|
}
|
|
|
|
case .sendDone:
|
|
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):
|
|
state.isSending = false
|
|
state.alert = AlertState.sendFailure(error)
|
|
return .none
|
|
|
|
case .transactionAmountInput:
|
|
return .none
|
|
|
|
case .transactionAddressInput(.scanQR):
|
|
return Effect.send(.updateDestination(.scanQR))
|
|
|
|
case .transactionAddressInput:
|
|
return .none
|
|
|
|
case .synchronizerStateChanged(let latestState):
|
|
state.shieldedBalance = latestState.data.accountBalance?.data?.saplingBalance.spendableValue ?? .zero
|
|
state.totalBalance = latestState.data.accountBalance?.data?.saplingBalance.total() ?? .zero
|
|
state.transactionAmountInputState.maxValue = state.shieldedBalance.amount.redacted
|
|
return .none
|
|
|
|
case .memo:
|
|
return .none
|
|
|
|
case .scan(.found(let address)):
|
|
state.transactionAddressInputState.textFieldState.text = address
|
|
// The is valid Zcash address check is already covered in the scan feature
|
|
// so we can be sure it's valid and thus `true` value here.
|
|
state.transactionAddressInputState.isValidAddress = true
|
|
state.transactionAddressInputState.isValidTransparentAddress = derivationTool.isTransparentAddress(
|
|
address.data,
|
|
zcashSDKEnvironment.network.networkType
|
|
)
|
|
audioServices.systemSoundVibrate()
|
|
return Effect.send(.updateDestination(nil))
|
|
|
|
case .scan(.cancelPressed):
|
|
state.destination = nil
|
|
return .none
|
|
|
|
case .scan:
|
|
return .none
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Alerts
|
|
|
|
extension AlertState where Action == SendFlowReducer.Action {
|
|
public static func sendFailure(_ error: ZcashError) -> AlertState {
|
|
AlertState {
|
|
TextState(L10n.Send.Alert.Failure.title)
|
|
} message: {
|
|
TextState(L10n.Send.Alert.Failure.message(error.message, error.code.rawValue))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Store
|
|
|
|
extension SendFlowStore {
|
|
func memoStore() -> MessageEditorStore {
|
|
self.scope(
|
|
state: \.memoState,
|
|
action: SendFlowReducer.Action.memo
|
|
)
|
|
}
|
|
|
|
func scanStore() -> StoreOf<Scan> {
|
|
self.scope(
|
|
state: \.scanState,
|
|
action: SendFlowReducer.Action.scan
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - ViewStore
|
|
|
|
extension SendFlowViewStore {
|
|
var destinationBinding: Binding<SendFlowReducer.State.Destination?> {
|
|
self.binding(
|
|
get: \.destination,
|
|
send: SendFlowReducer.Action.updateDestination
|
|
)
|
|
}
|
|
|
|
var bindingForScanQR: Binding<Bool> {
|
|
self.destinationBinding.map(
|
|
extract: {
|
|
$0 == .scanQR
|
|
},
|
|
embed: { $0 ? SendFlowReducer.State.Destination.scanQR : nil }
|
|
)
|
|
}
|
|
|
|
var bindingForSendConfirmation: Binding<Bool> {
|
|
self.destinationBinding.map(
|
|
extract: {
|
|
$0 == .sendConfirmation
|
|
},
|
|
embed: { $0 ? SendFlowReducer.State.Destination.sendConfirmation : nil }
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: Placeholders
|
|
|
|
extension SendFlowReducer.State {
|
|
public static var initial: Self {
|
|
.init(
|
|
addMemoState: true,
|
|
destination: nil,
|
|
memoState: .initial,
|
|
scanState: .initial,
|
|
transactionAddressInputState: .initial,
|
|
transactionAmountInputState: .initial
|
|
)
|
|
}
|
|
}
|
|
|
|
// #if DEBUG // FIX: Issue #306 - Release build is broken
|
|
extension SendFlowStore {
|
|
public static var placeholder: SendFlowStore {
|
|
SendFlowStore(
|
|
initialState: .initial
|
|
) {
|
|
SendFlowReducer()
|
|
}
|
|
}
|
|
}
|
|
// #endif
|