Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloud-based custom scripts restoration #465

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"lottie-web": "^5.7.8",
"marina-provider": "^2.0.0",
"moment": "^2.29.4",
"octokit": "^2.0.14",
"path-browserify": "^1.0.1",
"postcss": "^7.0.35",
"qrcode.react": "^1.0.1",
Expand Down
11 changes: 1 addition & 10 deletions src/application/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -44,16 +45,6 @@ type AccountOpts = {

type contractName = string;

export type RestorationJSON = {
accountName: string;
artifacts: Record<contractName, Artifact>;
pathToArguments: Record<string, [contractName, Argument[]]>;
};

export type RestorationJSONDictionary = {
[network: string]: RestorationJSON[];
};

export function makeAccountXPub(seed: Buffer, basePath: string) {
return bip32.fromSeed(seed).derivePath(basePath).neutered().toBase58();
}
Expand Down
69 changes: 69 additions & 0 deletions src/application/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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-service';
import { AccountFactory } from './account';
import { GithubBackupService, isBackupGithubServiceConfig } from '../port/github-backup-service';

export function makeBackupService(config: BackupConfig): BackupService {
switch (config.type) {
case BackupServiceType.BROWSER_SYNC:
return new BrowserSyncBackup();
case BackupServiceType.GITHUB:
if (!isBackupGithubServiceConfig(config))
throw new Error('Invalid backup service configuration for Github');
return new GithubBackupService(config);
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);
}
}
24 changes: 21 additions & 3 deletions src/background/background-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,7 +43,7 @@ const backgroundPort = getBackgroundPortImplementation();

const walletRepository = new WalletStorageAPI();
const appRepository = new AppStorageAPI();
const assetRepository = new AssetStorageAPI(walletRepository);
const assetRepository = new AssetStorageAPI();
const taxiRepository = new TaxiStorageAPI(assetRepository, appRepository);
const blockHeadersRepository = new BlockHeadersAPI();

Expand All @@ -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;

Expand All @@ -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<void> {
Expand Down Expand Up @@ -116,7 +124,17 @@ async function restoreTask(restoreMessage: RestoreMessage): Promise<void> {

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);
}
});
}

/**
Expand Down
95 changes: 95 additions & 0 deletions src/background/backup-syncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AccountType, isIonioScriptDetails } 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<void> = () => 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<void>)[] = [];

// 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();
}
}
2 changes: 1 addition & 1 deletion src/content/marina/marinaBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default class MarinaBroker extends Broker<keyof Marina> {
this.hostname = hostname;
this.walletRepository = new WalletStorageAPI();
this.appRepository = new AppStorageAPI();
this.assetRepository = new AssetStorageAPI(this.walletRepository);
this.assetRepository = new AssetStorageAPI();
this.taxiRepository = new TaxiStorageAPI(this.assetRepository, this.appRepository);
this.popupsRepository = new PopupsStorageAPI();
}
Expand Down
38 changes: 38 additions & 0 deletions src/domain/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Argument, Artifact } from '@ionio-lang/ionio';

type contractName = string;

export type RestorationJSON = {
accountName: string;
artifacts: Record<contractName, Artifact>;
pathToArguments: Record<string, [contractName, Argument[]]>;
};

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<BackupData>): Promise<void>;
load(): Promise<BackupData>;
delete(): Promise<void>;
initialize(): Promise<void>;
}

export enum BackupServiceType {
BROWSER_SYNC = 'browser-sync',
GITHUB = 'github',
}

export interface BackupConfig {
ID: string; // Unique ID for the backup service
type: BackupServiceType;
}
8 changes: 7 additions & 1 deletion src/domain/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -73,6 +73,10 @@ export interface AppRepository {
onNetworkChanged: EventEmitter<[NetworkString]>;
onIsAuthenticatedChanged: EventEmitter<[authenticated: boolean]>;

addBackupServiceConfig(...config: BackupConfig[]): Promise<void>;
removeBackupServiceConfig(ID: BackupConfig['ID']): Promise<void>;
getBackupServiceConfigs(): Promise<BackupConfig[]>;

/** loaders **/
restorerLoader: Loader;
updaterLoader: Loader;
Expand Down Expand Up @@ -182,6 +186,8 @@ export interface OnboardingRepository {
setOnboardingPasswordAndMnemonic(password: string, mnemonic: string): Promise<void>;
setRestorationJSONDictionary(json: RestorationJSONDictionary): Promise<void>;
getRestorationJSONDictionary(): Promise<RestorationJSONDictionary | undefined>;
setBackupServicesConfiguration(configs: BackupConfig[]): Promise<void>;
getBackupServicesConfiguration(): Promise<BackupConfig[] | undefined>;
setIsFromPopupFlow(mnemonicToBackup: string): Promise<void>;
flush(): Promise<void>; // flush all data
}
Expand Down
51 changes: 51 additions & 0 deletions src/extension/components/github-backup-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as Yup from 'yup';
import type { FormikProps } from 'formik';
import { withFormik } from 'formik';
import Input from './input';
import Button from './button';

interface FormProps {
onSubmit: (githubToken: string) => void;
}

interface FormValues {
githubToken: string;
}

const Form = (props: FormikProps<FormValues>) => {
return (
<form onSubmit={props.handleSubmit} className="w-full mt-8">
<Input
{...props}
name="githubToken"
placeholder="paste your GitHub token here"
type="textarea"
value={props.values.githubToken}
title="GitHub Token"
/>
<div className="text-right">
<Button
className="-mt-2 text-base"
disabled={props.isSubmitting || !!(props.errors.githubToken && props.touched.githubToken)}
type="submit"
>
OK
</Button>
</div>
</form>
);
};

const GithubBackupForm = withFormik<FormProps, FormValues>({
mapPropsToValues: () => ({
githubToken: '',
}),
validationSchema: Yup.object().shape({
githubToken: Yup.string().required('Required'),
}),
handleSubmit: (values, { props }) => {
props.onSubmit(values.githubToken);
},
})(Form);

export default GithubBackupForm;
Loading