Skip to content

Commit

Permalink
feat: add OPFS backup layer (#1742)
Browse files Browse the repository at this point in the history
- Closes #1743 
- Closes FE-1231
  • Loading branch information
LuizAsFight authored Dec 28, 2024
1 parent 197d175 commit 4bd6e86
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 51 deletions.
5 changes: 5 additions & 0 deletions .changeset/rotten-singers-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fuels-wallet": patch
---

feat: add OPFS backup
178 changes: 132 additions & 46 deletions packages/app/src/systems/Account/services/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Maybe } from '~/systems/Core/types';
import { db } from '~/systems/Core/utils/database';
import { getUniqueString } from '~/systems/Core/utils/string';
import { getTestNoDexieDbData } from '../utils/getTestNoDexieDbData';
import { readFromOPFS } from '~/systems/Core/utils/opfs';

export type AccountInputs = {
addAccount: {
Expand Down Expand Up @@ -215,68 +216,105 @@ export class AccountService {
allVaults,
backupNetworks,
allNetworks,
opfsBackupData,
] = await Promise.all([
chromeStorage.accounts.getAll(),
db.accounts.toArray(),
chromeStorage.vaults.getAll(),
db.vaults.toArray(),
chromeStorage.networks.getAll(),
db.networks.toArray(),
readFromOPFS(),
]);

const chromeStorageBackupData = {
accounts: backupAccounts,
vaults: backupVaults,
networks: backupNetworks,
};

// if there is no accounts, means the user lost it. try recovering it
const needsAccRecovery =
allAccounts?.length === 0 && backupAccounts?.length > 0;
allAccounts?.length === 0 &&
(chromeStorageBackupData.accounts?.length > 0 ||
opfsBackupData?.accounts?.length > 0);
const needsVaultRecovery =
allVaults?.length === 0 && backupVaults?.length > 0;
allVaults?.length === 0 &&
(chromeStorageBackupData.vaults?.length > 0 ||
opfsBackupData?.vaults?.length > 0);
const needsNetworkRecovery =
allNetworks?.length === 0 && backupNetworks?.length > 0;
allNetworks?.length === 0 &&
(chromeStorageBackupData.networks?.length > 0 ||
opfsBackupData?.networks?.length > 0);
const needsRecovery =
needsAccRecovery || needsVaultRecovery || needsNetworkRecovery;

return {
backupAccounts,
backupVaults,
backupNetworks,
needsRecovery,
needsAccRecovery,
needsVaultRecovery,
needsNetworkRecovery,
chromeStorageBackupData,
opfsBackupData,
};
}

static async recoverWallet() {
const {
backupAccounts,
backupVaults,
backupNetworks,
needsRecovery,
needsAccRecovery,
needsVaultRecovery,
needsNetworkRecovery,
} = await AccountService.fetchRecoveryState();
const { chromeStorageBackupData, needsRecovery, opfsBackupData } =
await AccountService.fetchRecoveryState();

if (needsRecovery) {
(async () => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const dataToLog: any = {};
try {
dataToLog.backupAccounts = JSON.stringify(
backupAccounts?.map((account) => account?.data?.address) || []
);
dataToLog.backupNetworks = JSON.stringify(backupNetworks || []);
dataToLog.chromeStorageBackupData = {
...chromeStorageBackupData,
accounts:
chromeStorageBackupData.accounts?.map(
(account) => account?.data?.address
) || [],
vaults: chromeStorageBackupData.vaults?.length || 0,
};
// try getting data from indexedDB (outside of dexie) to check if it's also corrupted
const testNoDexieDbData = await getTestNoDexieDbData();
dataToLog.testNoDexieDbData = testNoDexieDbData;
} catch (_) {}
try {
dataToLog.ofpsBackupupData = {
...opfsBackupData,
accounts:
opfsBackupData.accounts?.map(
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
(account: any) => account?.address
) || [],
vaults: opfsBackupData.vaults?.length || 0,
};
} catch (_) {}

Sentry.captureException(
'Disaster on DB. Start recovering accounts / vaults / networks',
{
extra: dataToLog,
tags: { manual: true },
}
);
const hasOPFSBackup =
!!opfsBackupData?.accounts?.length ||
!!opfsBackupData?.vaults?.length ||
!!opfsBackupData?.networks?.length;
const hasChromeStorageBackup =
!!chromeStorageBackupData.accounts?.length ||
!!chromeStorageBackupData.vaults?.length ||
!!chromeStorageBackupData.networks?.length;
let sentryMsg = 'DB is cleaned. ';
if (!hasOPFSBackup && !hasChromeStorageBackup) {
sentryMsg += 'No backup found. ';
}
if (hasOPFSBackup) {
sentryMsg += 'OPFS backup is found. Recovering...';
}
if (hasChromeStorageBackup) {
sentryMsg += 'Chrome Storage backup is found. Recovering...';
}

Sentry.captureException(sentryMsg, {
extra: dataToLog,
tags: { manual: true },
});
})();

await db.transaction(
Expand All @@ -285,36 +323,84 @@ export class AccountService {
db.vaults,
db.networks,
async () => {
if (needsAccRecovery) {
let isCurrentFlag = true;
console.log('recovering accounts', backupAccounts);
for (const account of backupAccounts) {
console.log('opfsBackupData', opfsBackupData);
console.log('chromeStorageBackupData', chromeStorageBackupData);
// accounts recovery
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
async function recoverAccounts(accounts: any) {
await db.accounts.clear();
for (const account of accounts) {
// in case of recovery, the first account will be the current
if (account.key && account.data.address) {
await db.accounts.add({
...account.data,
isCurrent: isCurrentFlag,
});
isCurrentFlag = false;
if (account.address) {
await db.accounts.add(account);
}
}
}
if (needsVaultRecovery) {
console.log('recovering vaults', backupVaults);
for (const vault of backupVaults) {
if (vault.key && vault.data) {
await db.vaults.add(vault.data);
// vaults recovery
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
async function recoverVaults(vaults: any) {
await db.vaults.clear();
for (const vault of vaults) {
if (vault.key) {
await db.vaults.add(vault);
}
}
}
if (needsNetworkRecovery) {
console.log('recovering networks', backupNetworks);
for (const network of backupNetworks) {
if (network.key && network.data.id) {
await db.networks.add(network.data);
// networks recovery
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
async function recoverNetworks(networks: any) {
await db.networks.clear();
for (const network of networks) {
if (network.url) {
await db.networks.add(network);
}
}
}

if (opfsBackupData?.accounts?.length) {
console.log(
'recovering accounts from OPFS',
opfsBackupData.accounts
);
await recoverAccounts(opfsBackupData.accounts);
} else if (chromeStorageBackupData.accounts?.length) {
console.log(
'recovering accounts from Chrome Storage',
chromeStorageBackupData.accounts
);
await recoverAccounts(
chromeStorageBackupData.accounts?.map((account) => account.data)
);
}

if (opfsBackupData?.vaults?.length) {
console.log('recovering vaults from OPFS', opfsBackupData.vaults);
await recoverVaults(opfsBackupData.vaults);
} else if (chromeStorageBackupData.vaults?.length) {
console.log(
'recovering vaults from Chrome Storage',
chromeStorageBackupData.vaults
);
await recoverVaults(
chromeStorageBackupData.vaults?.map((vault) => vault.data)
);
}

if (opfsBackupData?.networks?.length) {
console.log(
'recovering networks from OPFS',
opfsBackupData.networks
);
await recoverNetworks(opfsBackupData.networks);
} else if (chromeStorageBackupData.networks?.length) {
console.log(
'recovering networks from Chrome Storage',
chromeStorageBackupData.networks
);
await recoverNetworks(
chromeStorageBackupData.networks?.map((network) => network.data)
);
}
}
);
}
Expand Down
28 changes: 23 additions & 5 deletions packages/app/src/systems/Core/utils/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { applyDbVersioning } from './databaseVersioning';
import { createParallelDb } from '~/systems/Core/utils/databaseNoDexie';
import { IS_LOGGED_KEY } from '~/config';
import { Storage } from '~/systems/Core/utils/storage';
import { saveToOPFS } from './opfs';

type FailureEvents = Extract<keyof DbEvents, 'close' | 'blocked'>;
export type FuelCachedAsset = AssetData &
Expand Down Expand Up @@ -47,6 +48,18 @@ export class FuelDB extends Dexie {
this.on('close', () => this.restart('close'));
}

async syncDbToOPFS() {
const accounts = await this.accounts.toArray();
const vaults = await this.vaults.toArray();
const networks = await this.networks.toArray();
const backupData = {
accounts,
vaults,
networks,
};
await saveToOPFS(backupData);
}

async syncDbToChromeStorage() {
const accounts = await this.accounts.toArray();
const vaults = await this.vaults.toArray();
Expand All @@ -55,23 +68,24 @@ export class FuelDB extends Dexie {
// @TODO: this is a temporary solution to avoid the storage accounts of being wrong and
// users losing funds in case of no backup
// if has account, save to chrome storage
if (accounts.length) {
if (accounts.length && vaults.length && networks.length) {
console.log('saving data to chrome storage', {
accounts,
vaults,
networks,
});
for (const account of accounts) {
await chromeStorage.accounts.set({
key: account.address,
data: account,
});
}
}
if (vaults.length) {
for (const vault of vaults) {
await chromeStorage.vaults.set({
key: vault.key,
data: vault,
});
}
}
if (networks.length) {
for (const network of networks) {
await chromeStorage.networks.set({
key: network.id || '',
Expand All @@ -93,6 +107,10 @@ export class FuelDB extends Dexie {
(() => this.syncDbToChromeStorage())();
} catch (_) {}

try {
(() => this.syncDbToOPFS())();
} catch (_) {}

try {
(async () => {
const accounts = await this.accounts.toArray();
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/systems/Core/utils/opfs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
async function initOPFS() {
const root = await navigator?.storage?.getDirectory();
return root;
}

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
export async function saveToOPFS(data: any) {
if (
!data.accounts?.length ||
!data.vaults?.length ||
!data.networks?.length
) {
return;
}

const root = await initOPFS();
if (!root) return;
console.log('saving data to opfs', data);
const fileHandle = await root.getFileHandle('backup.json', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data));
await writable.close();
}

export async function readFromOPFS() {
const root = await initOPFS();
if (!root) return;
try {
const fileHandle = await root.getFileHandle('backup.json');
const file = await fileHandle.getFile();
const text = await file.text();
return JSON.parse(text);
} catch (_) {
// Create empty backup file if it doesn't exist
return {};
}
}

0 comments on commit 4bd6e86

Please sign in to comment.