diff --git a/src/background/services/background.ts b/src/background/services/background.ts index a67cafd9..41ee1cc9 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -194,7 +194,7 @@ export class Background { case 'CONNECT_WALLET': await this.openPaymentsService.connectWallet(message.payload); - if (message.payload.recurring) { + if (message.payload?.recurring) { this.scheduleResetOutOfFundsState(); } return success(undefined); diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts index b2725ac9..2a08df54 100644 --- a/src/background/services/keyAutoAdd.ts +++ b/src/background/services/keyAutoAdd.ts @@ -1,8 +1,10 @@ import { ErrorWithKey, ensureEnd, + errorWithKeyToJSON, isErrorWithKey, withResolvers, + type ErrorWithKeyLike, } from '@/shared/helpers'; import type { Browser, Runtime, Tabs } from 'webextension-polyfill'; import type { WalletAddress } from '@interledger/open-payments'; @@ -49,7 +51,7 @@ export class KeyAutoAddService { 'publicKey', 'keyId', ]); - this.setConnectState('connecting:key'); + this.updateConnectState(); await this.process(info.url, { publicKey, keyId, @@ -59,7 +61,9 @@ export class KeyAutoAddService { }); await this.validate(walletAddress.id, keyId); } catch (error) { - this.setConnectState('error:key'); + if (!error.key || !error.key.startsWith('connectWallet_error_')) { + this.updateConnectState(error); + } throw error; } } @@ -152,9 +156,17 @@ export class KeyAutoAddService { } } - private setConnectState(status: 'connecting:key' | 'error:key' | null) { - const state = status ? { status } : null; - this.storage.setPopupTransientState('connect', () => state); + private updateConnectState(err?: ErrorWithKeyLike | { message: string }) { + if (err) { + this.storage.setPopupTransientState('connect', () => ({ + status: 'error:key', + error: isErrorWithKey(err) ? errorWithKeyToJSON(err) : err.message, + })); + } else { + this.storage.setPopupTransientState('connect', () => ({ + status: 'connecting:key', + })); + } } } diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 84220c41..1f7ecec0 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -29,10 +29,13 @@ import { exportJWK, generateEd25519KeyPair } from '@/shared/crypto'; import { bytesToHex } from '@noble/hashes/utils'; import { ErrorWithKey, + errorWithKeyToJSON, getWalletInformation, + isErrorWithKey, withResolvers, + type ErrorWithKeyLike, } from '@/shared/helpers'; -import { AddFundsPayload, ConnectWalletPayload } from '@/shared/messages'; +import type { AddFundsPayload, ConnectWalletPayload } from '@/shared/messages'; import { DEFAULT_RATE_OF_PAY, MAX_RATE_OF_PAY, @@ -332,12 +335,13 @@ export class OpenPaymentsService { }); } - async connectWallet({ - walletAddressUrl, - amount, - recurring, - skipAutoKeyShare, - }: ConnectWalletPayload) { + async connectWallet(params: ConnectWalletPayload | null) { + if (!params) { + this.setConnectState(null); + return; + } + const { walletAddressUrl, amount, recurring, skipAutoKeyShare } = params; + const walletAddress = await getWalletInformation(walletAddressUrl); const exchangeRates = await getExchangeRates(); @@ -379,7 +383,8 @@ export class OpenPaymentsService { ); } catch (error) { if ( - error.message === this.t('connectWallet_error_invalidClient') && + isErrorWithKey(error) && + error.key === 'connectWallet_error_invalidClient' && !skipAutoKeyShare ) { // add key to wallet and try again @@ -394,11 +399,11 @@ export class OpenPaymentsService { tabId, ); } catch (error) { - this.setConnectState('error'); + this.updateConnectStateError(error); throw error; } } else { - this.setConnectState('error'); + this.updateConnectStateError(error); throw error; } } @@ -457,6 +462,9 @@ export class OpenPaymentsService { amount: transformedAmount, }).catch((err) => { if (isInvalidClientError(err)) { + if (intent === InteractionIntent.CONNECT) { + throw new ErrorWithKey('connectWallet_error_invalidClient'); + } const msg = this.t('connectWallet_error_invalidClient'); throw new Error(msg, { cause: err }); } @@ -578,10 +586,21 @@ export class OpenPaymentsService { } } - private setConnectState(status: 'connecting' | 'error' | null) { + private setConnectState(status: 'connecting' | null) { const state = status ? { status } : null; this.storage.setPopupTransientState('connect', () => state); } + private updateConnectStateError(err: ErrorWithKeyLike | { message: string }) { + this.storage.setPopupTransientState('connect', (state) => { + if (state?.status === 'error:key') { + return state; + } + return { + status: 'error', + error: isErrorWithKey(err) ? errorWithKeyToJSON(err) : err.message, + }; + }); + } private async redirectToWelcomeScreen( tabId: NonNullable, diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 65f82858..ece16642 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -37,6 +37,7 @@ interface ConnectWalletFormProps { saveValue?: (key: keyof Inputs, val: Inputs[typeof key]) => void; getWalletInfo: (walletAddressUrl: string) => Promise; connectWallet: (data: ConnectWalletPayload) => Promise; + clearConnectState: () => Promise; onConnect?: () => void; } @@ -46,6 +47,7 @@ export const ConnectWalletForm = ({ state, getWalletInfo, connectWallet, + clearConnectState, saveValue = () => {}, onConnect = () => {}, }: ConnectWalletFormProps) => { @@ -60,7 +62,15 @@ export const ConnectWalletForm = ({ const [recurring, setRecurring] = React.useState( defaultValues.recurring || false, ); - const [autoKeyShareFailed, setAutoKeyShareFailed] = React.useState(false); + const [autoKeyShareFailed, setAutoKeyShareFailed] = React.useState( + isAutoKeyAddFailed(state), + ); + + const resetState = React.useCallback(async () => { + await clearConnectState(); + setErrors((_) => ({ ..._, keyPair: '', connect: '' })); + setAutoKeyShareFailed(false); + }, [clearConnectState]); const [walletAddressInfo, setWalletAddressInfo] = React.useState(null); @@ -68,8 +78,18 @@ export const ConnectWalletForm = ({ const [errors, setErrors] = React.useState({ walletAddressUrl: '', amount: '', - keyPair: '', - connect: '', + keyPair: + state?.status === 'error:key' + ? isErrorWithKey(state.error) + ? t(state.error) + : state.error + : '', + connect: + state?.status === 'error' + ? isErrorWithKey(state.error) + ? t(state.error) + : state.error + : '', }); const [isValidating, setIsValidating] = React.useState({ walletAddressUrl: false, @@ -239,6 +259,7 @@ export const ConnectWalletForm = ({ autoComplete="on" spellCheck={false} enterKeyHint="go" + readOnly={isSubmitting} onPaste={async (ev) => { const input = ev.currentTarget; let value = ev.clipboardData.getData('text'); @@ -255,6 +276,7 @@ export const ConnectWalletForm = ({ } } const ok = await handleWalletAddressUrlChange(value, input); + resetState(); if (ok) document.getElementById('connectAmount')?.focus(); }} onBlur={async (ev) => { @@ -265,6 +287,7 @@ export const ConnectWalletForm = ({ } } await handleWalletAddressUrlChange(value, ev.currentTarget); + resetState(); }} /> @@ -286,7 +309,7 @@ export const ConnectWalletForm = ({ placeholder="5.00" className="max-w-32" defaultValue={amount} - readOnly={!walletAddressInfo?.assetCode} + readOnly={!walletAddressInfo?.assetCode || isSubmitting} addOn={{currencySymbol.symbol}} aria-invalid={!!errors.amount} aria-describedby={errors.amount} @@ -325,7 +348,7 @@ export const ConnectWalletForm = ({ details: errors.keyPair, whyText: t('connectWallet_error_failedAutoKeyAddWhy'), }} - hideError={autoKeyShareFailed} + hideError={!errors.keyPair} text={t('connectWallet_label_publicKey')} learnMoreText={t('connectWallet_text_publicKeyLearnMore')} publicKey={publicKey} @@ -408,6 +431,21 @@ const ManualKeyPairNeeded: React.FC<{ ); }; +function isAutoKeyAddFailed(state: PopupTransientState['connect']) { + if (state?.status === 'error') { + return ( + isErrorWithKey(state.error) && + state.error.key !== 'connectWallet_error_tabClosed' + ); + } else if (state?.status === 'error:key') { + return ( + isErrorWithKey(state.error) && + state.error.key.startsWith('connectWalletKeyService_error_') + ); + } + return false; +} + const Footer: React.FC<{ text: string; learnMoreText: string; diff --git a/src/popup/pages/Settings.tsx b/src/popup/pages/Settings.tsx index 34cff522..5e10314a 100644 --- a/src/popup/pages/Settings.tsx +++ b/src/popup/pages/Settings.tsx @@ -34,6 +34,7 @@ export const Component = () => { // But we reload it, as it's open all-time when running E2E tests window.location.reload(); }} + clearConnectState={() => message.send('CONNECT_WALLET', null)} /> ); } diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 89176c0c..fc0e8ccc 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -115,7 +115,7 @@ export type PopupToBackgroundMessage = { output: PopupState; }; CONNECT_WALLET: { - input: ConnectWalletPayload; + input: null | ConnectWalletPayload; output: void; }; RECONNECT_WALLET: { diff --git a/src/shared/types.ts b/src/shared/types.ts index 1bbe913e..5898e09f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,6 @@ import type { WalletAddress } from '@interledger/open-payments/dist/types'; import type { Tabs } from 'webextension-polyfill'; +import type { ErrorWithKeyLike } from './helpers'; /** Bigint amount, before transformation with assetScale */ export type AmountValue = string; @@ -109,9 +110,10 @@ export type PopupTabInfo = { }; export type PopupTransientState = Partial<{ - connect: Partial<{ - status: 'connecting' | 'connecting:key' | 'error' | 'error:key' | null; - }> | null; + connect: + | null + | { status: 'connecting' | 'connecting:key' } + | { status: 'error' | 'error:key'; error: string | ErrorWithKeyLike }; }>; export type PopupStore = Omit<