From 81be0119df258cbe57464a01a75f68b04c2d1d1a Mon Sep 17 00:00:00 2001 From: Louis Singer Date: Mon, 10 Apr 2023 17:49:39 +0200 Subject: [PATCH 1/6] add Backup syncer service --- src/application/account.ts | 11 +- src/application/backup.ts | 64 +++++++ src/background/background-script.ts | 22 ++- src/background/backup-syncer.ts | 95 ++++++++++ src/domain/backup.ts | 37 ++++ src/domain/repository.ts | 8 +- .../components/ionio-restoration-form.tsx | 2 +- .../components/restoration-backup-form.tsx | 176 ++++++++++++++++++ .../onboarding/end-of-flow/index.tsx | 3 +- src/extension/onboarding/onboarding-form.tsx | 2 +- .../onboarding/wallet-restore/index.tsx | 44 ++--- .../settings/accounts-restore-ionio.tsx | 2 +- src/extension/settings/accounts.tsx | 2 +- src/infrastructure/storage/app-repository.ts | 23 +++ .../storage/onboarding-repository.ts | 17 +- src/port/browser-sync-backup.ts | 31 +++ 16 files changed, 493 insertions(+), 46 deletions(-) create mode 100644 src/application/backup.ts create mode 100644 src/background/backup-syncer.ts create mode 100644 src/domain/backup.ts create mode 100644 src/extension/components/restoration-backup-form.tsx create mode 100644 src/port/browser-sync-backup.ts diff --git a/src/application/account.ts b/src/application/account.ts index 8f93ce87..6634f371 100644 --- a/src/application/account.ts +++ b/src/application/account.ts @@ -19,6 +19,7 @@ import { Contract } from '@ionio-lang/ionio'; import type { ZKPInterface } from 'liquidjs-lib/src/confidential'; import { h2b } from './utils'; import type { ChainSource } from '../domain/chainsource'; +import type { RestorationJSON, RestorationJSONDictionary } from '../domain/backup'; export const MainAccountLegacy = 'mainAccountLegacy'; export const MainAccount = 'mainAccount'; @@ -44,16 +45,6 @@ type AccountOpts = { type contractName = string; -export type RestorationJSON = { - accountName: string; - artifacts: Record; - pathToArguments: Record; -}; - -export type RestorationJSONDictionary = { - [network: string]: RestorationJSON[]; -}; - export function makeAccountXPub(seed: Buffer, basePath: string) { return bip32.fromSeed(seed).derivePath(basePath).neutered().toBase58(); } diff --git a/src/application/backup.ts b/src/application/backup.ts new file mode 100644 index 00000000..5de5a33d --- /dev/null +++ b/src/application/backup.ts @@ -0,0 +1,64 @@ +import type { NetworkString } from 'marina-provider'; +import type { BackupConfig, BackupService} from '../domain/backup'; +import { BackupServiceType } from '../domain/backup'; +import type { AppRepository, WalletRepository } from '../domain/repository'; +import { BrowserSyncBackup } from '../port/browser-sync-backup'; +import { AccountFactory } from './account'; + +export function makeBackupService(config: BackupConfig): BackupService { + switch (config.type) { + case BackupServiceType.BROWSER_SYNC: + return new BrowserSyncBackup(); + default: + throw new Error('Invalid backup service configuration'); + } +} + +function isNetworkString(str: string): str is NetworkString { + return str === 'liquid' || str === 'testnet' || str === 'regtest'; +} + +export async function loadFromBackupServices( + appRepository: AppRepository, + walletRepository: WalletRepository, + backupServices: BackupService[] +) { + const backupData = await Promise.all(backupServices.map((service) => service.load())); + + const chainSourceLiquid = await appRepository.getChainSource('liquid'); + const chainSourceTestnet = await appRepository.getChainSource('testnet'); + const chainSourceRegtest = await appRepository.getChainSource('regtest'); + const chainSource = (network: string) => + network === 'liquid' + ? chainSourceLiquid + : network === 'testnet' + ? chainSourceTestnet + : chainSourceRegtest; + + try { + const accountFactory = await AccountFactory.create(walletRepository); + + for (const { ionioAccountsRestorationDictionary } of backupData) { + for (const [network, restorations] of Object.entries(ionioAccountsRestorationDictionary)) { + if (!isNetworkString(network)) continue; + const chain = chainSource(network); + if (!chain) continue; + + for (const restoration of restorations) { + try { + const account = await accountFactory.make(network, restoration.accountName); + await account.restoreFromJSON(chain, restoration); + } catch (e) { + console.error(e); + } + } + } + } + } finally { + await Promise.all([ + chainSourceLiquid?.close(), + chainSourceTestnet?.close(), + chainSourceRegtest?.close(), + ]).catch(console.error); + } +} diff --git a/src/background/background-script.ts b/src/background/background-script.ts index 708a5fe1..7c9c1bd1 100644 --- a/src/background/background-script.ts +++ b/src/background/background-script.ts @@ -25,6 +25,7 @@ import { BlockHeadersAPI } from '../infrastructure/storage/blockheaders-reposito import type { ChainSource } from '../domain/chainsource'; import { WalletRepositoryUnblinder } from '../application/unblinder'; import { Transaction } from 'liquidjs-lib'; +import { BackupSyncer } from './backup-syncer'; // manifest v2 needs BrowserAction, v3 needs action const action = Browser.browserAction ?? Browser.action; @@ -59,6 +60,7 @@ const subscriberService = new SubscriberService( blockHeadersRepository ); const taxiService = new TaxiUpdater(taxiRepository, appRepository, assetRepository); +const backupSyncerService = new BackupSyncer(appRepository, walletRepository); let started = false; @@ -77,11 +79,17 @@ async function startBackgroundServices() { if (started) return; started = true; await walletRepository.unlockUtxos(); // unlock all utxos at startup - await Promise.allSettled([ + const results = await Promise.allSettled([ updaterService.start(), subscriberService.start(), Promise.resolve(taxiService.start()), + backupSyncerService.start(), ]); + results.forEach((result) => { + if (result.status === 'rejected') { + console.error(result.reason); + } + }); } async function restoreTask(restoreMessage: RestoreMessage): Promise { @@ -116,7 +124,17 @@ async function restoreTask(restoreMessage: RestoreMessage): Promise { async function stopBackgroundServices() { started = false; - await Promise.allSettled([updaterService.stop(), subscriberService.stop(), taxiService.stop()]); + const results = await Promise.allSettled([ + updaterService.stop(), + subscriberService.stop(), + taxiService.stop(), + backupSyncerService.stop(), + ]); + results.forEach((result) => { + if (result.status === 'rejected') { + console.error(result.reason); + } + }); } /** diff --git a/src/background/backup-syncer.ts b/src/background/backup-syncer.ts new file mode 100644 index 00000000..a99011fc --- /dev/null +++ b/src/background/backup-syncer.ts @@ -0,0 +1,95 @@ +import { AccountType, isIonioScriptDetails, NetworkString } from 'marina-provider'; +import Browser from 'webextension-polyfill'; +import { AccountFactory } from '../application/account'; +import { loadFromBackupServices, makeBackupService } from '../application/backup'; +import type { RestorationJSONDictionary } from '../domain/backup'; +import type { AppRepository, WalletRepository } from '../domain/repository'; + +export class BackupSyncer { + static ALARM = 'backup-syncer'; + + private closeFn: () => Promise = () => Promise.resolve(); + + constructor(private appRepository: AppRepository, private walletRepository: WalletRepository) {} + + private async loadBackupData() { + const backupConfigs = await this.appRepository.getBackupServiceConfigs(); + const backupServices = backupConfigs.map(makeBackupService); + await loadFromBackupServices(this.appRepository, this.walletRepository, backupServices); + } + + private async saveBackupData() { + const restoration: RestorationJSONDictionary = { + liquid: [], + testnet: [], + regtest: [], + }; + const allAccounts = await this.walletRepository.getAccountDetails(); + const ionioAccounts = Object.values(allAccounts).filter( + ({ type }) => type === AccountType.Ionio + ); + const factory = await AccountFactory.create(this.walletRepository); + + for (const details of ionioAccounts) { + for (const net of details.accountNetworks) { + const account = await factory.make(net, details.accountID); + const restorationJSON = await account.restorationJSON(); + restoration[net].push(restorationJSON); + } + } + + const backupConfigs = await this.appRepository.getBackupServiceConfigs(); + const backupServices = backupConfigs.map(makeBackupService); + const results = await Promise.allSettled([ + ...backupServices.map((service) => + service.save({ ionioAccountsRestorationDictionary: restoration }) + ), + ]); + + for (const result of results) { + if (result.status === 'rejected') { + console.error(result.reason); + } + } + } + + async start() { + this.closeFn = () => Promise.resolve(); + const closeFns: (() => void | Promise)[] = []; + + // set up onNewScript & onNetworkChanged callbacks triggering backup saves + closeFns.push( + this.walletRepository.onNewScript(async (_, scriptDetails) => { + if (isIonioScriptDetails(scriptDetails)) { + await this.saveBackupData(); + } + }) + ); + + closeFns.push( + this.appRepository.onNetworkChanged(async () => { + await this.saveBackupData(); + }) + ); + + // set up an alarm triggering backup loads + Browser.alarms.create(BackupSyncer.ALARM, { periodInMinutes: 10 }); + Browser.alarms.onAlarm.addListener(async (alarm: Browser.Alarms.Alarm) => { + if (alarm.name !== BackupSyncer.ALARM) return; + await this.loadBackupData(); + }); + closeFns.push(async () => { + await Browser.alarms.clear(BackupSyncer.ALARM); + }); + + this.closeFn = async () => { + await Promise.all(closeFns.map((fn) => Promise.resolve(fn()))); + }; + await this.loadBackupData(); // load backup data on start + } + + async stop() { + await this.saveBackupData(); // save backup data on stop + await this.closeFn(); + } +} diff --git a/src/domain/backup.ts b/src/domain/backup.ts new file mode 100644 index 00000000..3dc07e28 --- /dev/null +++ b/src/domain/backup.ts @@ -0,0 +1,37 @@ +import type { Argument, Artifact } from '@ionio-lang/ionio'; + +type contractName = string; + +export type RestorationJSON = { + accountName: string; + artifacts: Record; + pathToArguments: Record; +}; + +export type RestorationJSONDictionary = { + [network: string]: RestorationJSON[]; +}; + +// attach a version number for later updates +export type BackupDataVersion = 0; + +export interface BackupData { + version: BackupDataVersion; + ionioAccountsRestorationDictionary: RestorationJSONDictionary; +} + +export interface BackupService { + save(data: Partial): Promise; + load(): Promise; + delete(): Promise; + initialize(): Promise; +} + +export enum BackupServiceType { + BROWSER_SYNC = 'browser-sync', +} + +export interface BackupConfig { + ID: string; // Unique ID for the backup service + type: BackupServiceType; +} diff --git a/src/domain/repository.ts b/src/domain/repository.ts index 747b61ed..c1e81ddc 100644 --- a/src/domain/repository.ts +++ b/src/domain/repository.ts @@ -13,7 +13,6 @@ import type { UnblindingData, CoinSelection, TxDetails, UnblindedOutput } from ' import Browser from 'webextension-polyfill'; import type { Encrypted } from './encryption'; import { encrypt } from './encryption'; -import type { RestorationJSONDictionary } from '../application/account'; import { Account, MainAccount, @@ -24,6 +23,7 @@ import { import { mnemonicToSeed } from 'bip39'; import { SLIP77Factory } from 'slip77'; import type { BlockHeader, ChainSource } from './chainsource'; +import type { BackupConfig, RestorationJSONDictionary } from './backup'; export interface AppStatus { isMnemonicVerified: boolean; @@ -73,6 +73,10 @@ export interface AppRepository { onNetworkChanged: EventEmitter<[NetworkString]>; onIsAuthenticatedChanged: EventEmitter<[authenticated: boolean]>; + addBackupServiceConfig(...config: BackupConfig[]): Promise; + removeBackupServiceConfig(ID: BackupConfig['ID']): Promise; + getBackupServiceConfigs(): Promise; + /** loaders **/ restorerLoader: Loader; updaterLoader: Loader; @@ -182,6 +186,8 @@ export interface OnboardingRepository { setOnboardingPasswordAndMnemonic(password: string, mnemonic: string): Promise; setRestorationJSONDictionary(json: RestorationJSONDictionary): Promise; getRestorationJSONDictionary(): Promise; + setBackupServicesConfiguration(configs: BackupConfig[]): Promise; + getBackupServicesConfiguration(): Promise; setIsFromPopupFlow(mnemonicToBackup: string): Promise; flush(): Promise; // flush all data } diff --git a/src/extension/components/ionio-restoration-form.tsx b/src/extension/components/ionio-restoration-form.tsx index ffea65f0..02c999ee 100644 --- a/src/extension/components/ionio-restoration-form.tsx +++ b/src/extension/components/ionio-restoration-form.tsx @@ -1,10 +1,10 @@ import type { FormikProps } from 'formik'; import { withFormik } from 'formik'; -import type { RestorationJSONDictionary } from '../../application/account'; import { checkRestorationDictionary } from '../../application/account'; import Button from './button'; import Input from './input'; import * as Yup from 'yup'; +import type { RestorationJSONDictionary } from '../../domain/backup'; interface FormProps { onSubmit: (dict: RestorationJSONDictionary, password: string) => Promise; diff --git a/src/extension/components/restoration-backup-form.tsx b/src/extension/components/restoration-backup-form.tsx new file mode 100644 index 00000000..f75d752a --- /dev/null +++ b/src/extension/components/restoration-backup-form.tsx @@ -0,0 +1,176 @@ +import type { AccountID } from 'marina-provider'; +import type { ChangeEvent} from 'react'; +import { useRef, useState } from 'react'; +import { checkRestorationDictionary } from '../../application/account'; +import type { BackupConfig, RestorationJSONDictionary } from '../../domain/backup'; +import { BackupServiceType } from '../../domain/backup'; +import { BrowserSyncBackup } from '../../port/browser-sync-backup'; +import { extractErrorMessage } from '../utility/error'; +import Button from './button'; +import { Spinner } from './spinner'; + +export type BackupFormValues = { + restoration: RestorationJSONDictionary; + backupServicesConfigs: BackupConfig[]; +}; + +export type RestorationBackupFormProps = { + onSubmit: (values: BackupFormValues) => void; +}; + +export const RestorationBackupForm: React.FC = ({ onSubmit }) => { + const hiddenFileInputRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [restorationError, setRestorationError] = useState(); + const [values, setValues] = useState({ + restoration: {}, + backupServicesConfigs: [], + }); + const [loadingText, setLoadingText] = useState(); + + const submitValues = () => { + onSubmit(values); + }; + + const handleFileChange = async (e: ChangeEvent) => { + try { + setIsLoading(true); + setLoadingText('Restoring from file...'); + setRestorationError(undefined); + const strContent = await e.target.files![0].text(); + const restoration = JSON.parse(strContent); + if (!checkRestorationDictionary(restoration)) throw new Error('invalid restoration file'); + const newValues = { + ...values, + restoration, + }; + setValues(newValues); + submitValues(); + } catch (e) { + console.error(e); + setRestorationError(extractErrorMessage(e)); + } finally { + setIsLoading(false); + setLoadingText(undefined); + } + }; + + const handleRestoreFromBrowserSync = async () => { + setIsLoading(true); + setLoadingText('Restoring from Browser Sync...'); + setRestorationError(undefined); + try { + const browserSyncBackupService = new BrowserSyncBackup(); + await browserSyncBackupService.initialize(); + const { ionioAccountsRestorationDictionary } = await browserSyncBackupService.load(); + setValues({ + ...values, + restoration: ionioAccountsRestorationDictionary, + backupServicesConfigs: [ + ...values.backupServicesConfigs, + { ID: 'browser-sync', type: BackupServiceType.BROWSER_SYNC }, + ], + }); + submitValues(); + } catch (e) { + console.error(e); + setRestorationError(extractErrorMessage(e)); + } finally { + setIsLoading(false); + setLoadingText(undefined); + } + }; + + return ( +
+ {Object.keys(values.restoration).length === 0 && values.backupServicesConfigs.length === 0 ? ( +
+ {!isLoading ? ( +
+ + + +
+ ) : ( + <> + +

{loadingText || 'Loading...'}

+ + )} +
+ ) : ( +
+
{ + setRestorationError(undefined); + setLoadingText(undefined); + setValues({ + restoration: {}, + backupServicesConfigs: [], + }); + setIsLoading(false); + }} + className="absolute top-1 right-0.5" + > + +
+ {Object.keys(values.restoration).length > 0 ? ( + <> +

Successfully restored

+

Networks: {Object.keys(values.restoration).join(', ') || 0}

+

+ Number of accounts:{' '} + { + Object.entries(values.restoration).reduce( + (acc, [_, restorations]) => + new Set([...acc, ...restorations.map((r) => r.accountName)]), + new Set() + ).size + }{' '} +

+ + ) : ( +

Successfully loaded, no backup found

+ )} + {values.backupServicesConfigs.length > 0 && ( +

Browser backup will be enabled

+ )} +
+ )} + + {restorationError &&

{restorationError}

} +
+ ); +}; diff --git a/src/extension/onboarding/end-of-flow/index.tsx b/src/extension/onboarding/end-of-flow/index.tsx index 3b5adbdc..f207549f 100644 --- a/src/extension/onboarding/end-of-flow/index.tsx +++ b/src/extension/onboarding/end-of-flow/index.tsx @@ -52,7 +52,6 @@ const EndOfFlowOnboarding: React.FC = () => { try { const onboardingMnemonic = await onboardingRepository.getOnboardingMnemonic(); const onboardingPassword = await onboardingRepository.getOnboardingPassword(); - if (!onboardingMnemonic || !onboardingPassword) { throw new Error('onboarding Mnemonic or password not found'); } @@ -60,6 +59,8 @@ const EndOfFlowOnboarding: React.FC = () => { setErrorMsg(undefined); checkPassword(onboardingPassword); + const backupServicesConfigs = await onboardingRepository.getBackupServicesConfiguration(); + await appRepository.addBackupServiceConfig(...(backupServicesConfigs ?? [])); await initWalletRepository(walletRepository, onboardingMnemonic, onboardingPassword); await (Browser.browserAction ?? Browser.action).setPopup({ popup: 'popup.html' }); await appRepository.updateStatus({ isOnboardingCompleted: true }); diff --git a/src/extension/onboarding/onboarding-form.tsx b/src/extension/onboarding/onboarding-form.tsx index 5490c009..aef4a6bb 100644 --- a/src/extension/onboarding/onboarding-form.tsx +++ b/src/extension/onboarding/onboarding-form.tsx @@ -38,7 +38,7 @@ const OnboardingFormView = (props: FormikProps) => { const { values, touched, errors, isSubmitting, handleChange, handleBlur, handleSubmit } = props; return ( -
+