542 lines
21 KiB
Swift
542 lines
21 KiB
Swift
import ComposableArchitecture
|
|
import ZcashLightClientKit
|
|
import Foundation
|
|
|
|
typealias AppReducer = Reducer<AppState, AppAction, AppEnvironment>
|
|
typealias AppStore = Store<AppState, AppAction>
|
|
typealias AppViewStore = ViewStore<AppState, AppAction>
|
|
|
|
// MARK: - State
|
|
|
|
struct AppState: Equatable {
|
|
enum Route: Equatable {
|
|
case welcome
|
|
case startup
|
|
case onboarding
|
|
case sandbox
|
|
case home
|
|
case phraseValidation
|
|
case phraseDisplay
|
|
}
|
|
|
|
var appInitializationState: InitializationState = .uninitialized
|
|
var homeState: HomeState
|
|
var onboardingState: OnboardingFlowState
|
|
var phraseValidationState: RecoveryPhraseValidationFlowState
|
|
var phraseDisplayState: RecoveryPhraseDisplayState
|
|
var prevRoute: Route?
|
|
var internalRoute: Route = .welcome
|
|
var sandboxState: SandboxState
|
|
var storedWallet: StoredWallet?
|
|
var welcomeState: WelcomeState
|
|
|
|
var route: Route {
|
|
get { internalRoute }
|
|
set {
|
|
prevRoute = internalRoute
|
|
internalRoute = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Action
|
|
|
|
enum AppAction: Equatable {
|
|
case appDelegate(AppDelegateAction)
|
|
case checkBackupPhraseValidation
|
|
case checkWalletInitialization
|
|
case createNewWallet
|
|
case deeplink(URL)
|
|
case deeplinkHome
|
|
case deeplinkSend(Zatoshi, String, String)
|
|
case home(HomeAction)
|
|
case initializeSDK
|
|
case nukeWallet
|
|
case onboarding(OnboardingFlowAction)
|
|
case phraseDisplay(RecoveryPhraseDisplayAction)
|
|
case phraseValidation(RecoveryPhraseValidationFlowAction)
|
|
case respondToWalletInitializationState(InitializationState)
|
|
case sandbox(SandboxAction)
|
|
case updateRoute(AppState.Route)
|
|
case welcome(WelcomeAction)
|
|
}
|
|
|
|
// MARK: - Environment
|
|
|
|
struct AppEnvironment {
|
|
let audioServices: WrappedAudioServices
|
|
let databaseFiles: WrappedDatabaseFiles
|
|
let deeplinkHandler: WrappedDeeplinkHandler
|
|
let derivationTool: WrappedDerivationTool
|
|
let diskSpaceChecker: WrappedDiskSpaceChecker
|
|
let feedbackGenerator: WrappedFeedbackGenerator
|
|
let mnemonic: WrappedMnemonic
|
|
let recoveryPhraseRandomizer: WrappedRecoveryPhraseRandomizer
|
|
let scheduler: AnySchedulerOf<DispatchQueue>
|
|
let SDKSynchronizer: WrappedSDKSynchronizer
|
|
let walletStorage: WrappedWalletStorage
|
|
let zcashSDKEnvironment: ZCashSDKEnvironment
|
|
}
|
|
|
|
extension AppEnvironment {
|
|
static let live = AppEnvironment(
|
|
audioServices: .haptic,
|
|
databaseFiles: .live(),
|
|
deeplinkHandler: .live,
|
|
derivationTool: .live(derivationTool: DerivationTool(networkType: .testnet)),
|
|
diskSpaceChecker: .live,
|
|
feedbackGenerator: .haptic,
|
|
mnemonic: .live,
|
|
recoveryPhraseRandomizer: .live,
|
|
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
|
|
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
|
|
walletStorage: .live(),
|
|
zcashSDKEnvironment: .testnet
|
|
)
|
|
|
|
static let mock = AppEnvironment(
|
|
audioServices: .silent,
|
|
databaseFiles: .live(),
|
|
deeplinkHandler: .live,
|
|
derivationTool: .live(derivationTool: DerivationTool(networkType: .testnet)),
|
|
diskSpaceChecker: .mockEmptyDisk,
|
|
feedbackGenerator: .silent,
|
|
mnemonic: .mock,
|
|
recoveryPhraseRandomizer: .live,
|
|
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
|
|
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
|
|
walletStorage: .live(),
|
|
zcashSDKEnvironment: .testnet
|
|
)
|
|
}
|
|
|
|
// MARK: - Reducer
|
|
|
|
extension AppReducer {
|
|
private struct CancelId: Hashable {}
|
|
|
|
static let `default` = AppReducer.combine(
|
|
[
|
|
appReducer,
|
|
homeReducer,
|
|
onboardingReducer,
|
|
phraseValidationReducer,
|
|
phraseDisplayReducer,
|
|
routeReducer,
|
|
sandboxReducer
|
|
]
|
|
)
|
|
.debug()
|
|
|
|
private static let appReducer = AppReducer { state, action, environment in
|
|
switch action {
|
|
case .appDelegate(.didFinishLaunching):
|
|
/// We need to fetch data from keychain, in order to be 100% sure the kecyhain can be read we delay the check a bit
|
|
return Effect(value: .checkWalletInitialization)
|
|
.delay(for: 0.02, scheduler: environment.scheduler)
|
|
.eraseToEffect()
|
|
|
|
/// Evaluate the wallet's state based on keychain keys and database files presence
|
|
case .checkWalletInitialization:
|
|
let walletState = walletInitializationState(environment)
|
|
return Effect(value: .respondToWalletInitializationState(walletState))
|
|
|
|
/// Respond to all possible states of the wallet and initiate appropriate side effects including errors handling
|
|
case .respondToWalletInitializationState(let walletState):
|
|
switch walletState {
|
|
case .failed:
|
|
// TODO [#221]: error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
state.appInitializationState = .failed
|
|
case .keysMissing:
|
|
// TODO [#221]: error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
state.appInitializationState = .keysMissing
|
|
case .initialized, .filesMissing:
|
|
if walletState == .filesMissing {
|
|
state.appInitializationState = .filesMissing
|
|
}
|
|
return .concatenate(
|
|
Effect(value: .initializeSDK),
|
|
Effect(value: .checkBackupPhraseValidation)
|
|
)
|
|
case .uninitialized:
|
|
state.appInitializationState = .uninitialized
|
|
return Effect(value: .updateRoute(.onboarding))
|
|
.delay(for: 3, scheduler: environment.scheduler)
|
|
.eraseToEffect()
|
|
.cancellable(id: CancelId(), cancelInFlight: true)
|
|
}
|
|
|
|
return .none
|
|
|
|
/// Stored wallet is present, database files may or may not be present, trying to initialize app state variables and environments.
|
|
/// When initialization succeeds user is taken to the home screen.
|
|
case .initializeSDK:
|
|
do {
|
|
state.storedWallet = try environment.walletStorage.exportWallet()
|
|
|
|
guard let storedWallet = state.storedWallet else {
|
|
state.appInitializationState = .failed
|
|
// TODO [#221]: fatal error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
return .none
|
|
}
|
|
|
|
try environment.mnemonic.isValid(storedWallet.seedPhrase)
|
|
|
|
let birthday = state.storedWallet?.birthday ?? environment.zcashSDKEnvironment.defaultBirthday
|
|
|
|
let initializer = try prepareInitializer(
|
|
for: storedWallet.seedPhrase,
|
|
birthday: birthday,
|
|
with: environment
|
|
)
|
|
try environment.SDKSynchronizer.prepareWith(initializer: initializer)
|
|
try environment.SDKSynchronizer.start()
|
|
} catch {
|
|
state.appInitializationState = .failed
|
|
// TODO [#221]: error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
}
|
|
return .none
|
|
|
|
case .checkBackupPhraseValidation:
|
|
guard let storedWallet = state.storedWallet else {
|
|
state.appInitializationState = .failed
|
|
// TODO [#221]: fatal error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
return .none
|
|
}
|
|
|
|
var landingRoute: AppState.Route = .home
|
|
|
|
if !storedWallet.hasUserPassedPhraseBackupTest {
|
|
do {
|
|
let phraseWords = try environment.mnemonic.asWords(storedWallet.seedPhrase)
|
|
|
|
let recoveryPhrase = RecoveryPhrase(words: phraseWords)
|
|
state.phraseDisplayState.phrase = recoveryPhrase
|
|
state.phraseValidationState = environment.recoveryPhraseRandomizer.random(recoveryPhrase)
|
|
landingRoute = .phraseDisplay
|
|
} catch {
|
|
// TODO [#201]: - merge with issue 201 (https://github.com/zcash/secant-ios-wallet/issues/201) and its Error States
|
|
return .none
|
|
}
|
|
}
|
|
|
|
state.appInitializationState = .initialized
|
|
|
|
return Effect(value: .updateRoute(landingRoute))
|
|
.delay(for: 3, scheduler: environment.scheduler)
|
|
.eraseToEffect()
|
|
.cancellable(id: CancelId(), cancelInFlight: true)
|
|
|
|
case .createNewWallet:
|
|
do {
|
|
// get the random english mnemonic
|
|
let randomPhrase = try environment.mnemonic.randomMnemonic()
|
|
let birthday = try environment.zcashSDKEnvironment.lightWalletService.latestBlockHeight()
|
|
|
|
// store the wallet to the keychain
|
|
try environment.walletStorage.importWallet(randomPhrase, birthday, .english, false)
|
|
|
|
// start the backup phrase validation test
|
|
let randomPhraseWords = try environment.mnemonic.asWords(randomPhrase)
|
|
let recoveryPhrase = RecoveryPhrase(words: randomPhraseWords)
|
|
state.phraseDisplayState.phrase = recoveryPhrase
|
|
state.phraseValidationState = environment.recoveryPhraseRandomizer.random(recoveryPhrase)
|
|
|
|
return .concatenate(
|
|
Effect(value: .initializeSDK),
|
|
Effect(value: .phraseValidation(.displayBackedUpPhrase))
|
|
)
|
|
} catch {
|
|
// TODO [#201]: - merge with issue 201 (https://github.com/zcash/secant-ios-wallet/issues/201) and its Error States
|
|
}
|
|
|
|
return .none
|
|
|
|
case .phraseValidation(.succeed):
|
|
do {
|
|
try environment.walletStorage.markUserPassedPhraseBackupTest()
|
|
} catch {
|
|
// TODO [#221]: error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
}
|
|
return .none
|
|
|
|
case .nukeWallet:
|
|
environment.walletStorage.nukeWallet()
|
|
do {
|
|
try environment.databaseFiles.nukeDbFilesFor(environment.zcashSDKEnvironment.network)
|
|
} catch {
|
|
// TODO [#221]: error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
}
|
|
return .none
|
|
|
|
case .welcome(.debugMenuStartup), .home(.debugMenuStartup):
|
|
return .concatenate(
|
|
Effect.cancel(id: CancelId()),
|
|
Effect(value: .updateRoute(.startup))
|
|
)
|
|
|
|
case .onboarding(.importWallet(.successfullyRecovered)):
|
|
return Effect(value: .updateRoute(.home))
|
|
|
|
case .onboarding(.importWallet(.initializeSDK)):
|
|
return Effect(value: .initializeSDK)
|
|
|
|
/// Default is meaningful here because there's `routeReducer` handling routes and this reducer is handling only actions. We don't here plenty of unused cases.
|
|
default:
|
|
return .none
|
|
}
|
|
}
|
|
|
|
private static let routeReducer = AppReducer { state, action, environment in
|
|
switch action {
|
|
case let .updateRoute(route):
|
|
state.route = route
|
|
|
|
case .sandbox(.reset):
|
|
state.route = .startup
|
|
|
|
case .onboarding(.createNewWallet):
|
|
return Effect(value: .createNewWallet)
|
|
|
|
case .phraseValidation(.proceedToHome):
|
|
state.route = .home
|
|
|
|
case .phraseValidation(.displayBackedUpPhrase),
|
|
.phraseDisplay(.createPhrase):
|
|
state.route = .phraseDisplay
|
|
|
|
case .phraseDisplay(.finishedPressed):
|
|
// user is still supposed to do the backup phrase validation test
|
|
if state.prevRoute == .welcome || state.prevRoute == .onboarding {
|
|
state.route = .phraseValidation
|
|
}
|
|
// user wanted to see the backup phrase once again (at validation finished screen)
|
|
if state.prevRoute == .phraseValidation {
|
|
state.route = .home
|
|
}
|
|
|
|
case .deeplink(let url):
|
|
// get the latest synchronizer state
|
|
var synchronizerStatus = WrappedSDKSynchronizerState.unknown
|
|
_ = environment.SDKSynchronizer.stateChanged.sink { synchronizerStatus = $0 }
|
|
|
|
// process the deeplink only if app is initialized and synchronizer synced
|
|
guard state.appInitializationState == .initialized && synchronizerStatus == .synced else {
|
|
// TODO [#370]: There are many different states and edge cases we need to handle here
|
|
// (https://github.com/zcash/secant-ios-wallet/issues/370)
|
|
return .none
|
|
}
|
|
return .run { send in
|
|
do {
|
|
await send(try await process(url: url, with: environment))
|
|
} catch {
|
|
// TODO [#221]: error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221)
|
|
}
|
|
}
|
|
|
|
case .deeplinkHome:
|
|
state.route = .home
|
|
state.homeState.route = nil
|
|
return .none
|
|
|
|
case let .deeplinkSend(amount, address, memo):
|
|
state.route = .home
|
|
state.homeState.route = .send
|
|
state.homeState.sendState.amount = amount
|
|
state.homeState.sendState.address = address
|
|
state.homeState.sendState.memoState.text = memo
|
|
return .none
|
|
|
|
case .home(.walletEvents(.replyTo(let address))):
|
|
guard let url = URL(string: "zcash:\(address)") else {
|
|
return .none
|
|
}
|
|
return Effect(value: .deeplink(url))
|
|
|
|
/// Default is meaningful here because there's `appReducer` handling actions and this reducer is handling only routes. We don't here plenty of unused cases.
|
|
default:
|
|
break
|
|
}
|
|
|
|
return .none
|
|
}
|
|
|
|
private static let homeReducer: AppReducer = HomeReducer.default.pullback(
|
|
state: \AppState.homeState,
|
|
action: /AppAction.home,
|
|
environment: { environment in
|
|
HomeEnvironment(
|
|
audioServices: environment.audioServices,
|
|
derivationTool: environment.derivationTool,
|
|
diskSpaceChecker: environment.diskSpaceChecker,
|
|
feedbackGenerator: environment.feedbackGenerator,
|
|
mnemonic: environment.mnemonic,
|
|
scheduler: environment.scheduler,
|
|
SDKSynchronizer: environment.SDKSynchronizer,
|
|
walletStorage: environment.walletStorage,
|
|
zcashSDKEnvironment: environment.zcashSDKEnvironment
|
|
)
|
|
}
|
|
)
|
|
|
|
private static let onboardingReducer: AppReducer = OnboardingFlowReducer.default.pullback(
|
|
state: \AppState.onboardingState,
|
|
action: /AppAction.onboarding,
|
|
environment: { environment in
|
|
OnboardingFlowEnvironment(
|
|
mnemonic: environment.mnemonic,
|
|
walletStorage: environment.walletStorage,
|
|
zcashSDKEnvironment: environment.zcashSDKEnvironment
|
|
)
|
|
}
|
|
)
|
|
|
|
private static let phraseValidationReducer: AppReducer = RecoveryPhraseValidationFlowReducer.default.pullback(
|
|
state: \AppState.phraseValidationState,
|
|
action: /AppAction.phraseValidation,
|
|
environment: { environment in
|
|
RecoveryPhraseValidationFlowEnvironment(
|
|
scheduler: environment.scheduler,
|
|
pasteboard: .test,
|
|
feedbackGenerator: .silent,
|
|
recoveryPhraseRandomizer: environment.recoveryPhraseRandomizer
|
|
)
|
|
}
|
|
)
|
|
|
|
private static let phraseDisplayReducer: AppReducer = RecoveryPhraseDisplayReducer.default.pullback(
|
|
state: \AppState.phraseDisplayState,
|
|
action: /AppAction.phraseDisplay,
|
|
environment: { environment in
|
|
RecoveryPhraseDisplayEnvironment(
|
|
scheduler: environment.scheduler,
|
|
newPhrase: { .init(words: RecoveryPhrase.placeholder.words) },
|
|
pasteboard: .live,
|
|
feedbackGenerator: environment.feedbackGenerator
|
|
)
|
|
}
|
|
)
|
|
|
|
private static let sandboxReducer: AppReducer = SandboxReducer.default.pullback(
|
|
state: \AppState.sandboxState,
|
|
action: /AppAction.sandbox,
|
|
environment: { _ in SandboxEnvironment() }
|
|
)
|
|
|
|
private static let welcomeReducer: AppReducer = WelcomeReducer.default.pullback(
|
|
state: \AppState.welcomeState,
|
|
action: /AppAction.welcome,
|
|
environment: { _ in WelcomeEnvironment() }
|
|
)
|
|
}
|
|
|
|
extension AppReducer {
|
|
static func walletInitializationState(_ environment: AppEnvironment) -> InitializationState {
|
|
var keysPresent = false
|
|
do {
|
|
keysPresent = try environment.walletStorage.areKeysPresent()
|
|
let databaseFilesPresent = try environment.databaseFiles.areDbFilesPresentFor(
|
|
environment.zcashSDKEnvironment.network
|
|
)
|
|
|
|
switch (keysPresent, databaseFilesPresent) {
|
|
case (false, false):
|
|
return .uninitialized
|
|
case (false, true):
|
|
return .keysMissing
|
|
case (true, false):
|
|
return .filesMissing
|
|
case (true, true):
|
|
return .initialized
|
|
}
|
|
} catch DatabaseFiles.DatabaseFilesError.filesPresentCheck {
|
|
if keysPresent {
|
|
return .filesMissing
|
|
}
|
|
} catch WalletStorage.WalletStorageError.uninitializedWallet {
|
|
do {
|
|
if try environment.databaseFiles.areDbFilesPresentFor(
|
|
environment.zcashSDKEnvironment.network
|
|
) {
|
|
return .keysMissing
|
|
}
|
|
} catch {
|
|
return .uninitialized
|
|
}
|
|
} catch {
|
|
return .failed
|
|
}
|
|
|
|
return .uninitialized
|
|
}
|
|
|
|
static func prepareInitializer(
|
|
for seedPhrase: String,
|
|
birthday: BlockHeight,
|
|
with environment: AppEnvironment
|
|
) throws -> Initializer {
|
|
do {
|
|
let seedBytes = try environment.mnemonic.toSeed(seedPhrase)
|
|
let viewingKeys = try environment.derivationTool.deriveUnifiedViewingKeysFromSeed(seedBytes, 1)
|
|
|
|
let network = environment.zcashSDKEnvironment.network
|
|
|
|
let initializer = Initializer(
|
|
cacheDbURL: try environment.databaseFiles.cacheDbURLFor(network),
|
|
dataDbURL: try environment.databaseFiles.dataDbURLFor(network),
|
|
pendingDbURL: try environment.databaseFiles.pendingDbURLFor(network),
|
|
endpoint: environment.zcashSDKEnvironment.endpoint,
|
|
network: environment.zcashSDKEnvironment.network,
|
|
spendParamsURL: try environment.databaseFiles.spendParamsURLFor(network),
|
|
outputParamsURL: try environment.databaseFiles.outputParamsURLFor(network),
|
|
viewingKeys: viewingKeys,
|
|
walletBirthday: birthday
|
|
)
|
|
|
|
return initializer
|
|
} catch {
|
|
throw SDKInitializationError.failed
|
|
}
|
|
}
|
|
|
|
static func process(url: URL, with environment: AppEnvironment) async throws -> AppAction {
|
|
let deeplink = try environment.deeplinkHandler.resolveDeeplinkURL(url, environment.derivationTool)
|
|
|
|
switch deeplink {
|
|
case .home:
|
|
return .deeplinkHome
|
|
case let .send(amount, address, memo):
|
|
return .deeplinkSend(Zatoshi(amount), address, memo)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Placeholders
|
|
|
|
extension AppState {
|
|
static var placeholder: Self {
|
|
.init(
|
|
homeState: .placeholder,
|
|
onboardingState: .init(
|
|
importWalletState: .placeholder
|
|
),
|
|
phraseValidationState: .placeholder,
|
|
phraseDisplayState: RecoveryPhraseDisplayState(
|
|
phrase: .placeholder
|
|
),
|
|
sandboxState: .placeholder,
|
|
welcomeState: .placeholder
|
|
)
|
|
}
|
|
}
|
|
|
|
extension AppStore {
|
|
static var placeholder: AppStore {
|
|
AppStore(
|
|
initialState: .placeholder,
|
|
reducer: .default,
|
|
environment: .live
|
|
)
|
|
}
|
|
}
|