diff --git a/apps/browser-extension-wallet/src/components/Crash/Crash.module.scss b/apps/browser-extension-wallet/src/components/Crash/Crash.module.scss
new file mode 100644
index 000000000..ef6ded8e1
--- /dev/null
+++ b/apps/browser-extension-wallet/src/components/Crash/Crash.module.scss
@@ -0,0 +1,12 @@
+@import '../../../../../packages/common/src/ui/styles/theme.scss';
+
+.crashContainer {
+ @include flex-center;
+ flex-direction: column;
+ height: 100%;
+ gap: size_unit(2);
+
+ .crashText {
+ color: var(--text-color-primary);
+ }
+}
diff --git a/apps/browser-extension-wallet/src/components/Crash/Crash.tsx b/apps/browser-extension-wallet/src/components/Crash/Crash.tsx
new file mode 100644
index 000000000..16d2247e6
--- /dev/null
+++ b/apps/browser-extension-wallet/src/components/Crash/Crash.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import classNames from 'classnames';
+import { useTranslation } from 'react-i18next';
+import styles from './Crash.module.scss';
+import { Button } from '@input-output-hk/lace-ui-toolkit';
+import { useRuntime } from '@hooks/useRuntime';
+
+export const Crash = (): React.ReactElement => {
+ const { t } = useTranslation();
+ const runtime = useRuntime();
+
+ return (
+
+
+ {t('general.errors.crash')}
+
+
runtime.reload()}
+ label={t('general.errors.reloadExtension')}
+ data-testid="crash-reload"
+ />
+
+ );
+};
diff --git a/apps/browser-extension-wallet/src/components/Crash/index.ts b/apps/browser-extension-wallet/src/components/Crash/index.ts
new file mode 100644
index 000000000..9c2efe9e2
--- /dev/null
+++ b/apps/browser-extension-wallet/src/components/Crash/index.ts
@@ -0,0 +1 @@
+export * from './Crash';
diff --git a/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx
index 80f2732b8..955d14e4e 100644
--- a/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx
+++ b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx
@@ -9,6 +9,8 @@ import { WalletSetupLayout } from '@views/browser/components';
import { Portal } from '@views/browser/features/wallet-setup/components/Portal';
import { useAnalyticsContext } from '@providers';
import { postHogNamiMigrationActions } from '@providers/AnalyticsProvider/analyticsTracker';
+import { useFatalError } from '@hooks/useFatalError';
+import { Crash } from '@components/Crash';
const urlPath = walletRoutePaths.namiMigration;
@@ -23,6 +25,11 @@ export const NamiMigration = (): JSX.Element => {
analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.OPEN);
}, [analytics]);
+ const fatalError = useFatalError();
+ if (fatalError) {
+ return ;
+ }
+
return (
diff --git a/apps/browser-extension-wallet/src/hooks/useFatalError.ts b/apps/browser-extension-wallet/src/hooks/useFatalError.ts
new file mode 100644
index 000000000..bbebedac0
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useFatalError.ts
@@ -0,0 +1,59 @@
+import { ObservableWallet } from '@cardano-sdk/wallet';
+import { useObservable } from '@lace/common';
+import { useBackgroundServiceAPIContext } from '@providers';
+import { useWalletStore } from '@src/stores';
+import { useMemo } from 'react';
+import { catchError, take, of, merge, EMPTY } from 'rxjs';
+import { toEmpty } from '@cardano-sdk/util-rxjs';
+import { getErrorMessage } from '@src/utils/get-error-message';
+
+const anyError = (wallet: ObservableWallet | undefined) =>
+ wallet
+ ? merge(
+ wallet.addresses$,
+ wallet.assetInfo$,
+ wallet.balance.rewardAccounts.deposit$,
+ wallet.balance.rewardAccounts.rewards$,
+ wallet.balance.utxo.available$,
+ wallet.balance.utxo.total$,
+ wallet.balance.utxo.unspendable$,
+ wallet.currentEpoch$,
+ wallet.delegation.distribution$,
+ wallet.delegation.portfolio$,
+ wallet.delegation.rewardAccounts$,
+ wallet.delegation.rewardsHistory$,
+ wallet.eraSummaries$,
+ wallet.genesisParameters$,
+ wallet.handles$,
+ wallet.protocolParameters$,
+ wallet.governance.isRegisteredAsDRep$,
+ wallet.publicStakeKeys$,
+ wallet.syncStatus.isAnyRequestPending$,
+ wallet.syncStatus.isSettled$,
+ wallet.syncStatus.isUpToDate$,
+ wallet.tip$,
+ wallet.transactions.history$,
+ wallet.transactions.rollback$,
+ wallet.utxo.available$,
+ wallet.utxo.total$,
+ wallet.utxo.unspendable$
+ ).pipe(
+ toEmpty,
+ catchError((error) => of({ type: 'base-wallet-error', message: getErrorMessage(error) })),
+ take(1)
+ )
+ : EMPTY;
+
+type FatalError = {
+ type: string;
+ message: string;
+};
+
+export const useFatalError = (): FatalError | undefined => {
+ const backgroundService = useBackgroundServiceAPIContext();
+ const unhandledServiceWorkerError = useObservable(backgroundService.unhandledError$);
+ const { cardanoWallet } = useWalletStore();
+ const walletError$ = useMemo(() => anyError(cardanoWallet?.wallet), [cardanoWallet?.wallet]);
+ const walletError = useObservable(walletError$);
+ return unhandledServiceWorkerError || walletError;
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useRuntime.ts b/apps/browser-extension-wallet/src/hooks/useRuntime.ts
new file mode 100644
index 000000000..6018f2399
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useRuntime.ts
@@ -0,0 +1,7 @@
+import { runtime } from 'webextension-polyfill';
+
+export type LaceRuntime = { reload: () => void };
+
+export const useRuntime = (): LaceRuntime => ({
+ reload: runtime.reload
+});
diff --git a/apps/browser-extension-wallet/src/hooks/useWalletState.ts b/apps/browser-extension-wallet/src/hooks/useWalletState.ts
index 1ebe909fe..13dc1510c 100644
--- a/apps/browser-extension-wallet/src/hooks/useWalletState.ts
+++ b/apps/browser-extension-wallet/src/hooks/useWalletState.ts
@@ -24,14 +24,16 @@ type RemoveObservableNameSuffix = T extends `${infer S}$` ? S : T;
type FlattenObservableProperties = T extends Map | String | Number | Array | Date | null | BigInt
? T
: T extends object
- ? {
- [k in keyof T as T[k] extends Function ? never : RemoveObservableNameSuffix]: T[k] extends Observable
- ? FlattenObservableProperties
- : FlattenObservableProperties;
- }
- : T;
+ ? {
+ [k in keyof T as T[k] extends Function ? never : RemoveObservableNameSuffix]: T[k] extends Observable<
+ infer O
+ >
+ ? FlattenObservableProperties
+ : FlattenObservableProperties;
+ }
+ : T;
export type ObservableWalletState = FlattenObservableProperties<
- Omit & {
+ Omit & {
transactions: {
history$: ObservableWallet['transactions']['history$'];
outgoing: Pick;
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts
index 20ac32585..5c2eb897d 100644
--- a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts
@@ -26,7 +26,8 @@ export const backgroundServiceProperties: RemoteApiProperties
getBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise,
setBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise,
resetStorage: RemoteApiPropertyType.MethodReturningPromise,
- backendFailures$: RemoteApiPropertyType.HotObservable
+ backendFailures$: RemoteApiPropertyType.HotObservable,
+ unhandledError$: RemoteApiPropertyType.HotObservable
};
const { BLOCKFROST_CONFIGS, BLOCKFROST_RATE_LIMIT_CONFIG } = config();
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts
index 9c1b0e1c6..474f0d1ad 100644
--- a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts
@@ -12,9 +12,10 @@ import {
TokenPrices,
CoinPrices,
ChangeModeData,
- LaceFeaturesApi
+ LaceFeaturesApi,
+ UnhandledError
} from '../../types';
-import { Subject, of, BehaviorSubject } from 'rxjs';
+import { Subject, of, BehaviorSubject, merge, map, fromEvent } from 'rxjs';
import { walletRoutePaths } from '@routes/wallet-paths';
import { backgroundServiceProperties } from '../config';
import { exposeApi } from '@cardano-sdk/web-extension';
@@ -24,6 +25,7 @@ import { getADAPriceFromBackgroundStorage, closeAllLaceWindows } from '../util';
import { currencies as currenciesMap, currencyCode } from '@providers/currency/constants';
import { clearBackgroundStorage, getBackgroundStorage, setBackgroundStorage } from '../storage';
import { laceFeaturesApiProperties, LACE_FEATURES_CHANNEL } from '../injectUtil';
+import { getErrorMessage } from '@src/utils/get-error-message';
export const requestMessage$ = new Subject();
export const backendFailures$ = new BehaviorSubject(0);
@@ -204,6 +206,17 @@ exposeApi(
{ logger: console, runtime }
);
+const toUnhandledError = (error: unknown, type: UnhandledError['type']): UnhandledError => ({
+ type,
+ message: getErrorMessage(error)
+});
+const unhandledError$ = merge(
+ fromEvent(globalThis, 'error').pipe(map((e: ErrorEvent): UnhandledError => toUnhandledError(e, 'error'))),
+ fromEvent(globalThis, 'unhandledrejection').pipe(
+ map((e: PromiseRejectionEvent): UnhandledError => toUnhandledError(e, 'unhandledrejection'))
+ )
+);
+
exposeApi(
{
api$: of({
@@ -222,7 +235,8 @@ exposeApi(
await clearBackgroundStorage();
await webStorage.local.set({ MIGRATION_STATE: { state: 'up-to-date' } as MigrationState });
},
- backendFailures$
+ backendFailures$,
+ unhandledError$
}),
baseChannel: BaseChannels.BACKGROUND_ACTIONS,
properties: backgroundServiceProperties
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts
index b249b01a3..862818c72 100644
--- a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts
@@ -11,6 +11,7 @@ import {
createSharedWallet
} from '@cardano-sdk/wallet';
import { handleHttpProvider } from '@cardano-sdk/cardano-services-client';
+import { Cardano, HandleProvider } from '@cardano-sdk/core';
import {
AnyWallet,
StoresFactory,
@@ -28,7 +29,6 @@ import {
walletRepositoryProperties
} from '@cardano-sdk/web-extension';
import { Wallet } from '@lace/cardano';
-import { Cardano, HandleProvider } from '@cardano-sdk/core';
import { cacheActivatedWalletAddressSubscription } from './cache-wallets-address';
import axiosFetchAdapter from '@shiroyasha9/axios-fetch-adapter';
import { SharedWalletScriptKind } from '@lace/core';
diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts
index dc0e8ef01..7f2368cd3 100644
--- a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts
+++ b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts
@@ -1,4 +1,4 @@
-import { BehaviorSubject, Subject } from 'rxjs';
+import { BehaviorSubject, Subject, Observable } from 'rxjs';
import { themes } from '@providers/ThemeProvider';
import { BackgroundStorage, MigrationState } from './storage';
import { CoinPrices } from './prices';
@@ -89,6 +89,11 @@ export type Message =
| OpenBrowserMessage
| ChangeMode;
+export type UnhandledError = {
+ type: 'error' | 'unhandledrejection';
+ message: string;
+};
+
export type BackgroundService = {
handleOpenBrowser: (data: OpenBrowserData, urlSearchParams?: string) => Promise;
handleOpenPopup: () => Promise;
@@ -103,6 +108,7 @@ export type BackgroundService = {
clearBackgroundStorage: typeof clearBackgroundStorage;
resetStorage: () => Promise;
backendFailures$: BehaviorSubject;
+ unhandledError$: Observable;
};
export type WalletMode = {
diff --git a/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx b/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx
index 1637cd6b1..8438f8990 100644
--- a/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx
+++ b/apps/browser-extension-wallet/src/routes/DappConnectorView.tsx
@@ -25,6 +25,8 @@ import { tabs } from 'webextension-polyfill';
import { useTranslation } from 'react-i18next';
import { DappSignDataSuccess } from '@src/features/dapp/components/DappSignDataSuccess';
import { DappSignDataFail } from '@src/features/dapp/components/DappSignDataFail';
+import { Crash } from '@components/Crash';
+import { useFatalError } from '@hooks/useFatalError';
dayjs.extend(duration);
@@ -57,17 +59,22 @@ export const DappConnectorView = (): React.ReactElement => {
}, [isWalletLocked, cardanoWallet]);
const isLoading = useMemo(() => hdDiscoveryStatus !== 'Idle', [hdDiscoveryStatus]);
+ const fatalError = useFatalError();
useEffect(() => {
- if (!isLoading) {
+ if (!isLoading || fatalError) {
document.querySelector('#preloader')?.remove();
}
- }, [isLoading]);
+ }, [isLoading, fatalError]);
const onCloseClick = useCallback(() => {
tabs.create({ url: `app.html#${walletRoutePaths.setup.home}` });
window.close();
}, []);
+ if (fatalError) {
+ return ;
+ }
+
if (hasNoAvailableWallet) {
return (
diff --git a/apps/browser-extension-wallet/src/routes/PopupView.tsx b/apps/browser-extension-wallet/src/routes/PopupView.tsx
index 2fb757c45..fd1c09205 100644
--- a/apps/browser-extension-wallet/src/routes/PopupView.tsx
+++ b/apps/browser-extension-wallet/src/routes/PopupView.tsx
@@ -12,6 +12,8 @@ import { getValueFromLocalStorage } from '@src/utils/local-storage';
import { MainLoader } from '@components/MainLoader';
import { useAppInit } from '@hooks';
import { ILocalStorage } from '@src/types';
+import { useFatalError } from '@hooks/useFatalError';
+import { Crash } from '@components/Crash';
dayjs.extend(duration);
@@ -55,19 +57,24 @@ export const PopupView = (): React.ReactElement => {
// (see useEffect in browser-view routes index)
}, [isWalletLocked, backgroundServices, currentChain, chainName, cardanoWallet]);
+ const fatalError = useFatalError();
const isLoaded = useMemo(
() => !!cardanoWallet && walletInfo && walletState && inMemoryWallet && initialHdDiscoveryCompleted,
[cardanoWallet, walletInfo, walletState, inMemoryWallet, initialHdDiscoveryCompleted]
);
useEffect(() => {
- if (isLoaded) {
+ if (isLoaded || fatalError) {
document.querySelector('#preloader')?.remove();
}
- }, [isLoaded]);
+ }, [isLoaded, fatalError]);
const checkMnemonicVerificationFrequency = () =>
mnemonicVerificationFrequency && isLastValidationExpired(lastMnemonicVerification, mnemonicVerificationFrequency);
+ if (fatalError) {
+ return ;
+ }
+
if (checkMnemonicVerificationFrequency() && walletLock) {
return ;
}
diff --git a/apps/browser-extension-wallet/src/utils/get-error-message.ts b/apps/browser-extension-wallet/src/utils/get-error-message.ts
new file mode 100644
index 000000000..5b08cb921
--- /dev/null
+++ b/apps/browser-extension-wallet/src/utils/get-error-message.ts
@@ -0,0 +1,2 @@
+export const getErrorMessage = (error: unknown): string =>
+ error && typeof error.toString === 'function' ? error.toString() : '';
diff --git a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx
index 68cc60ec5..31aff3bde 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx
@@ -36,6 +36,8 @@ import { BackgroundStorage, Message, MessageTypes } from '@lib/scripts/types';
import { getBackgroundStorage } from '@lib/scripts/background/storage';
import { useTranslation } from 'react-i18next';
import { POPUP_WINDOW_NAMI_TITLE } from '@src/utils/constants';
+import { useFatalError } from '@hooks/useFatalError';
+import { Crash } from '@components/Crash';
export const defaultRoutes: RouteMap = [
{
@@ -211,11 +213,17 @@ export const BrowserViewRoutes = ({ routesMap = defaultRoutes }: { routesMap?: R
[cardanoWallet, isLoadingWalletInfo, namiMigration?.mode]
);
+ const fatalError = useFatalError();
+
useEffect(() => {
- if (isLoaded || isOnboarding || isInNamiMode) {
+ if (isLoaded || isOnboarding || isInNamiMode || fatalError) {
document.querySelector('#preloader')?.remove();
}
- }, [isLoaded, isOnboarding, isInNamiMode]);
+ }, [isLoaded, isOnboarding, isInNamiMode, fatalError]);
+
+ if (fatalError) {
+ return ;
+ }
if (isInNamiMode) {
return (
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/index.tsx b/apps/browser-extension-wallet/src/views/nami-mode/index.tsx
index fa5d14b89..6169b88d4 100644
--- a/apps/browser-extension-wallet/src/views/nami-mode/index.tsx
+++ b/apps/browser-extension-wallet/src/views/nami-mode/index.tsx
@@ -8,6 +8,8 @@ import '../../lib/scripts/keep-alive-ui';
import './index.scss';
import { useBackgroundServiceAPIContext } from '@providers';
import { BrowserViewSections } from '@lib/scripts/types';
+import { Crash } from '@components/Crash';
+import { useFatalError } from '@hooks/useFatalError';
export const NamiPopup = withDappContext((): React.ReactElement => {
const {
@@ -24,11 +26,13 @@ export const NamiPopup = withDappContext((): React.ReactElement => {
() => !!cardanoWallet && walletInfo && walletState && inMemoryWallet && initialHdDiscoveryCompleted && currentChain,
[cardanoWallet, walletInfo, walletState, inMemoryWallet, initialHdDiscoveryCompleted, currentChain]
);
+
+ const fatalError = useFatalError();
useEffect(() => {
- if (isLoaded) {
+ if (isLoaded || fatalError) {
document.querySelector('#preloader')?.remove();
}
- }, [isLoaded]);
+ }, [isLoaded, fatalError]);
useAppInit();
@@ -38,5 +42,9 @@ export const NamiPopup = withDappContext((): React.ReactElement => {
}
}, [backgroundServices, cardanoWallet, deletingWallet]);
+ if (fatalError) {
+ return ;
+ }
+
return {isLoaded ? : }
;
});
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx b/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx
index aea8cec14..b919989d9 100644
--- a/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx
+++ b/apps/browser-extension-wallet/src/views/nami-mode/indexInternal.tsx
@@ -6,10 +6,14 @@ import { withDappContext } from '@src/features/dapp/context';
import { NamiDappConnectorView } from './NamiDappConnectorView';
import '../../lib/scripts/keep-alive-ui';
import './index.scss';
+import { useFatalError } from '@hooks/useFatalError';
+import { Crash } from '@components/Crash';
export const NamiDappConnector = withDappContext((): React.ReactElement => {
const { hdDiscoveryStatus } = useWalletStore();
const isLoaded = useMemo(() => hdDiscoveryStatus === 'Idle', [hdDiscoveryStatus]);
+
+ const fatalError = useFatalError();
useEffect(() => {
if (isLoaded) {
document.querySelector('#preloader')?.remove();
@@ -18,5 +22,9 @@ export const NamiDappConnector = withDappContext((): React.ReactElement => {
useAppInit();
+ if (fatalError) {
+ return ;
+ }
+
return {isLoaded ? : }
;
});
diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json
index fe16ee0d2..8e0d1c970 100644
--- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json
+++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json
@@ -670,6 +670,8 @@
"general.errors.maximumInputCountExceeded": "Maximum Input Count Exceeded",
"general.errors.networkError": "Network Error",
"general.errors.somethingWentWrong": "Something went wrong, please try again",
+ "general.errors.crash": "Something went wrong",
+ "general.errors.reloadExtension": "Reload extension",
"general.errors.tryAgain": "Try again",
"general.errors.uhoh": "Uh Oh!",
"general.errors.utxoFullyDepleted": "UTxO Fully Depleted",