import { ExposedPromiseRegistry , flattened } from '@airgap/angular-core' ; import { AirGapWallet , AirGapWalletStatus } from '@airgap/coinlib-core' ; import { Injectable } from '@angular/core' ; import { Actions , createEffect , ofType } from '@ngrx/effects' ; import { Action , Store } from '@ngrx/store' ; import { entropyToMnemonic } from 'bip39' ; import { concat , from , Observable , of , Subscriber } from 'rxjs' ; import { first , switchMap , tap , withLatestFrom } from 'rxjs/operators' ; import { MnemonicSecret } from '../../models/secret' ; import { MigrationService } from '../../services/migration/migration.service' ; import { NavigationService } from '../../services/navigation/navigation.service' ; import { SecretsService } from '../../services/secrets/secrets.service' ; import { isSecretMigrated , isWalletMigrated } from '../../utils/migration' ; import { retry } from '../../utils/retry' ; import * as actions from './migration.actions' ; import * as fromMigration from './migration.reducers' ; enum PromiseKey { PARANOIA_ACCEPTED = 'paranoiaAccepted' , BIP39_PASSPHRASE = 'bip39Passphrase' } interface PromiseResolveType { [ PromiseKey . PARANOIA_ACCEPTED ] : boolean ; [ PromiseKey . BIP39_PASSPHRASE ] : string | undefined ; } @ Injectable ( ) export class MigrationEffects { public onSuccess : ( ( ) => void ) | undefined ; public onError : ( ( ) => void ) | undefined ; public readonly exposedPromises : ExposedPromiseRegistry < PromiseKey , PromiseResolveType > = new ExposedPromiseRegistry ( ) ; public navigationData$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . viewWillEnter ) , withLatestFrom ( this . secretsService . getSecretsObservable ( ) ) , switchMap ( ( [ _ , secrets ] ) => concat ( of ( actions . navigationDataLoading ( ) ) , from ( this . loadNavigationData ( secrets ) ) . pipe ( first ( ) ) ) ) ) ) ; public migrationProgress$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . migrationStarted ) , withLatestFrom ( this . store . select ( fromMigration . selectTargetSecrets ) ) , withLatestFrom ( this . store . select ( fromMigration . selectTargetWalletKeys ) ) , switchMap ( ( [ [ _ , secrets ] , targetWalletKeys ] ) => this . migrate ( secrets . value ?? [ ] , targetWalletKeys ) ) ) ) ; public paranoiaAccepted$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . paranoiaAccepted ) , tap ( ( ) => { this . exposedPromises . resolve ( PromiseKey . PARANOIA_ACCEPTED , true ) ; } ) ) , { dispatch : false } ) ; public paranoiaRejected$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . paranoiaRejected ) , tap ( ( ) => { this . exposedPromises . resolve ( PromiseKey . PARANOIA_ACCEPTED , false ) ; } ) ) , { dispatch : false } ) ; public bip39Passphrase$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . bip39PassphraseProvided ) , tap ( ( action ) => { this . exposedPromises . resolve ( PromiseKey . BIP39_PASSPHRASE , action . passphrase ) ; } ) ) , { dispatch : false } ) ; public bip39PassphraseRejected$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . bip39PassphraseRejected ) , tap ( ( ) => { this . exposedPromises . resolve ( PromiseKey . BIP39_PASSPHRASE , undefined ) ; } ) ) , { dispatch : false } ) ; public finished$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . finished ) , tap ( ( ) => { this . finish ( ) ; } ) ) , { dispatch : false } ) ; public onLeft$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( actions . viewLeft ) , tap ( ( ) => { this . callCallbacks ( ) ; } ) ) , { dispatch : false } ) ; constructor ( private readonly actions$ , private readonly store , private readonly navigationService , private readonly secretsService , private readonly migrationService ) { } private async loadNavigationData ( allSecrets ) { const state = this . navigationService . getState ( ) ; this . onSuccess = state . onSuccess ; this . onError = state . onError ; const [ secrets , targetWalletKeys ] = this . getSecretsAndTargetWalletKeys ( state , allSecrets ) ; return secrets !== undefined && targetWalletKeys !== undefined ? actions . navigationDataLoaded ( { secrets , targetWalletKeys } ) : actions . invalidData ( ) ; } private getSecretsAndTargetWalletKeys ( state , allSecrets ) { if ( state . secrets !== undefined ) { const secrets = state . secrets . filter ( ( secret ) => ! isSecretMigrated ( secret ) ) ; const walletKeys = flattened ( secrets . map ( ( secret ) => secret . wallets . filter ( ( wallet ) => wallet . status === AirGapWalletStatus . ACTIVE && ! isWalletMigrated ( wallet ) ) . map ( ( wallet ) => wallet . publicKey ) ) ) ; return [ secrets , walletKeys ] ; } if ( state . wallets !== undefined ) { const wallets = state . wallets ; const walletKeys = wallets . filter ( ( wallet ) => ! isWalletMigrated ( wallet ) ) . map ( ( wallet ) => wallet . publicKey ) ; const walletKeysSet = new Set ( walletKeys ) ; const secrets = allSecrets . filter ( ( secret ) => secret . wallets . some ( ( wallet ) => walletKeysSet . has ( wallet . publicKey ) ) ) ; return [ secrets , walletKeys ] ; } return [ undefined , undefined ] ; } private migrate ( secrets , targetWalletKeys ) { return new Observable ( ( subscriber ) => { new Promise < void > ( async ( resolve ) => { const targetWalletKeysSet = new Set ( targetWalletKeys ) ; for ( const secret of secrets ) { await this . migrateSecret ( subscriber , secret , targetWalletKeysSet ) ; } subscriber . next ( actions . allSecretsHandled ( ) ) ; resolve ( ) ; } ) . catch ( ( error ) => { subscriber . next ( actions . unknownError ( { message : typeof error === 'string' ? error : error . message } ) ) ; } ) . finally ( ( ) => { subscriber . complete ( ) ; } ) ; } ) ; } private async migrateSecret ( subscriber , secret , targetWalletKeys ) { subscriber . next ( actions . nextSecret ( { id : secret . id } ) ) ; if ( secret . isParanoia ) { const paranoiaAccepted = await this . waitForParanoiaAccepted ( subscriber , secret ) ; if ( ! paranoiaAccepted ) { subscriber . next ( actions . secretSkipped ( { id : secret . id } ) ) ; return ; } } const entropy = await this . secretsService . retrieveEntropyForSecret ( secret ) ; const mnemonic = entropyToMnemonic ( entropy ) ; await this . migrationService . migrateSecret ( secret , { mnemonic , persist : false } ) ; for ( const wallet of secret . wallets . filter ( ( wallet ) => targetWalletKeys . has ( wallet . publicKey ) ) ) { await this . migrateWallet ( subscriber , wallet , mnemonic ) ; } subscriber . next ( actions . allWalletsHandled ( ) ) ; await this . secretsService . addOrUpdateSecret ( secret , { setActive : false } ) ; } private async waitForParanoiaAccepted ( subscriber , secret ) { subscriber . next ( actions . paranoiaDetected ( { secretId : secret . id } ) ) ; return this . exposedPromises . yield ( PromiseKey . PARANOIA_ACCEPTED ) ; } private async migrateWallet ( subscriber , wallet , mnemonic ) { subscriber . next ( actions . nextWallet ( { publicKey : wallet . publicKey } ) ) ; await retry ( { initArgs : '' , action : ( bip39Passphrase ) => this . migrationService . migrateWallet ( wallet , { mnemonic , bip39Passphrase , persist : false } ) , onFailure : async ( error ) => { if ( this . isInvalidBip39PassphraseError ( error ) ) { const bip39Passphrase = await this . askForBip39Passphrase ( subscriber , wallet ) ; if ( bip39Passphrase === undefined ) { subscriber . next ( actions . walletSkipped ( { publicKey : wallet . publicKey } ) ) ; return { type : 'abort' } ; } return { type : 'retry' , nextArgs : bip39Passphrase } ; } else { subscriber . next ( actions . unknownError ( { message : typeof error === 'string' ? error : error . message } ) ) ; return { type : 'abort' } ; } } } ) ; } private async askForBip39Passphrase ( subscriber , wallet ) { subscriber . next ( actions . invalidBip39Passphrase ( { protocolIdentifier : wallet . protocol . identifier , publicKey : wallet . publicKey } ) ) ; return this . exposedPromises . yield ( PromiseKey . BIP39_PASSPHRASE ) ; } private finish ( ) { this . navigationService . back ( ) ; } private callCallbacks ( ) { if ( this . onSuccess !== undefined ) { this . onSuccess ( ) ; } } private isInvalidBip39PassphraseError ( error ) { return error . message ?. toLowerCase ( ) . startsWith ( 'invalid bip-39 passphrase' ) ; } }