diff --git a/src/utils/__tests__/error.test.ts b/src/utils/__tests__/error.test.ts index 9167de1..e07622e 100644 --- a/src/utils/__tests__/error.test.ts +++ b/src/utils/__tests__/error.test.ts @@ -51,4 +51,34 @@ describe('error', () => { sanitizeError(new Error('replacement fee too low')).message, ).to.equal(expectedErrorMessage); }); -}); + + it('Should sanitize insufficient gas errors', () => { + const expectedErrorMessage = 'Insufficient gas to process transaction.'; + expect( + sanitizeError(new Error('insufficient funds for intrinsic')).message, + ).to.equal(expectedErrorMessage); + }); + + it('Should sanitize low gas price errors', () => { + const expectedErrorMessage = 'Gas price rejected. Please select a higher gas price or resubmit.'; + + expect( + sanitizeError(new Error('intrinsic gas too low')).message, + ).to.equal(expectedErrorMessage); + }); + + it('Should handle undefined error', () => { + const error = sanitizeError(undefined as unknown as Error); + expect(error.message).to.equal('Unknown error. Please try again.'); + }); + + it('Should handle error without message', () => { + const error = sanitizeError(new Error()); + expect(error.message).to.equal('Unknown error. Please try again.'); + }); + + it('Should sanitize non-ASCII characters in unknown errors', () => { + const error = sanitizeError(new Error('Unknown error 🚫')); + expect(error.message).to.equal('Unknown error '); + }); +}); \ No newline at end of file diff --git a/src/utils/error.ts b/src/utils/error.ts deleted file mode 100644 index 957a88c..0000000 --- a/src/utils/error.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { isDefined } from './util'; - -const STRING_PREFIX_AFTER_UNICODE_REPLACEMENT = 'y %'; - -const validAscii = (str: string) => { - return str.replace( - // eslint-disable-next-line no-useless-escape - /[^A-Za-z 0-9 \.,\?""!@#\$%\^&\*\(\)-_=\+;:<>\/\\\|\}\{\[\]`~]*/g, - '', - ); -}; - -export const sanitizeError = (cause: Error): Error => { - if (isDefined(cause) && cause.message) { - const lowercaseMsg = cause.message.toLowerCase(); - if ( - lowercaseMsg.includes('quorum') || - lowercaseMsg.includes('could not connect to') - ) { - return new Error('Could not connect.', { cause }); - } - if (lowercaseMsg.includes('call revert exception')) { - return new Error('Failed to connect to RPC.', { cause }); - } - if (lowercaseMsg.includes('already known')) { - return new Error( - 'Transaction successful but ethers request for TXID failed.', - { cause }, - ); - } - if (lowercaseMsg.includes('missing revert data')) { - return new Error('RPC connection error.', { cause }); - } - if ( - lowercaseMsg.includes( - 'transaction may fail or may require manual gas limit', - ) - ) { - return new Error('Unknown error. Transaction failed.', { cause }); - } - if (lowercaseMsg.includes('replacement fee too low')) { - return new Error( - 'Nonce is used in a pending transaction, and replacement fee is too low. Please increase your network fee to replace the pending transaction.', - { cause }, - ); - } - if (lowercaseMsg.includes('intrinsic gas too low')) { - return new Error( - 'Gas price rejected. Please select a higher gas price or resubmit.', - { cause }, - ); - } - if (lowercaseMsg.includes('transaction underpriced')) { - return new Error( - 'Gas fee too low. Please select a higher gas price and resubmit.', - { cause }, - ); - } - if (lowercaseMsg.includes('insufficient funds for intrinsic')) { - return new Error('Insufficient gas to process transaction.', { cause }); - } - if (lowercaseMsg.includes('nonce has already been used')) { - return new Error( - // Do not change 'Nonce already used' string of Error message. - 'Nonce already used: the transaction was already completed.', - { cause }, - ); - } - if (lowercaseMsg.includes('error while dialing dial tcp')) { - return new Error( - 'Error while connecting to RPC provider. Please try again.', - { cause }, - ); - } - if ( - lowercaseMsg.includes('spendable private balance too low') && - lowercaseMsg.includes('broadcaster fee') - ) { - return new Error('Private balance too low to pay broadcaster fee.', { - cause, - }); - } - - // Custom RAILGUN contract error messages - if (lowercaseMsg.includes('railgunsmartwallet')) { - if (lowercaseMsg.includes('invalid nft note value')) { - return new Error('RailgunSmartWallet: Invalid NFT Note Value.', { - cause, - }); - } - if (lowercaseMsg.includes('unsupported token')) { - return new Error( - 'RailgunSmartWallet: Unsupported Token. This token cannot interact with the RAILGUN contract.', - { cause }, - ); - } - if (lowercaseMsg.includes('invalid note value')) { - return new Error( - 'RailgunSmartWallet: Invalid Note Value. Please submit transaction with a corrected amount.', - { cause }, - ); - } - if (lowercaseMsg.includes('invalid adapt contract as sender')) { - return new Error( - 'RailgunSmartWallet: Invalid Adapt Contract as Sender. Please update your frontend to current Adapt module versions.', - { cause }, - ); - } - if (lowercaseMsg.includes('invalid merkle root')) { - return new Error( - 'RailgunSmartWallet: Invalid Merkle Root. Please sync your balances and try again.', - { cause }, - ); - } - if (lowercaseMsg.includes('note already spent')) { - return new Error( - 'RailgunSmartWallet: Note Already Spent. Please sync your balances and try again.', - { cause }, - ); - } - if (lowercaseMsg.includes('invalid note ciphertext array length')) { - return new Error( - 'RailgunSmartWallet: Invalid Note Ciphertext Array Length. Please sync balances and re-prove your transaction.', - { cause }, - ); - } - if (lowercaseMsg.includes('invalid withdraw note')) { - return new Error( - 'RailgunSmartWallet: Invalid Unshield Note. Please sync balances and re-prove your transaction.', - { cause }, - ); - } - if (lowercaseMsg.includes('invalid snark proof')) { - return new Error( - 'RailgunSmartWallet: Invalid Snark Proof. Please re-prove your transaction.', - { cause }, - ); - } - } - - return new Error( - validAscii(cause.message).replace( - `:${STRING_PREFIX_AFTER_UNICODE_REPLACEMENT}`, - ': ', - ), - ); - } - - return new Error('Unknown error. Please try again.', { cause }); -}; diff --git a/src/utils/error/constants.ts b/src/utils/error/constants.ts new file mode 100644 index 0000000..6392094 --- /dev/null +++ b/src/utils/error/constants.ts @@ -0,0 +1,93 @@ +import { CustomErrorMapping } from "./types"; + +export const STRING_PREFIX_AFTER_UNICODE_REPLACEMENT = 'y %'; + +// Matches any characters that are NOT in the printable ASCII range (space to tilde) +// Printable ASCII characters are in the range of 32 (space) to 126 (tilde) +export const INVALID_ASCII_REGEX = /[^ -~\n]+/g; + +export const CUSTOM_ERRORS: CustomErrorMapping = { + CONNECTION_ERROR: { + matches: ['quorum', 'could not connect to'], + message: 'Could not connect.' + }, + RPC_ERROR: { + matches: ['call revert exception', 'error while dialing dial tcp'], + message: 'Failed to connect to RPC.' + }, + RPC_CONNECTION_ERROR: { + matches: ['missing revert data'], + message: 'RPC connection error.' + }, + KNOWN_TRANSACTION: { + matches: ['already known'], + message: 'Transaction successful but ethers request for TXID failed.' + }, + LOW_REPLACEMENT_FEE: { + matches: ['replacement fee too low'], + message: 'Nonce is used in a pending transaction, and replacement fee is too low. Please increase your network fee to replace the pending transaction.' + }, + LOW_GAS: { + matches: ['intrinsic gas too low'], + message: 'Gas price rejected. Please select a higher gas price or resubmit.' + }, + UNDERPRICED_TRANSACTION: { + matches: ['transaction underpriced'], + message: 'Gas price rejected. Please select a higher gas price and resubmit.' + }, + INSUFFICIENT_GAS: { + matches: ['insufficient funds for intrinsic'], + message: 'Insufficient gas to process transaction.' + }, + NONCE_USED: { + matches: ['nonce has already been used'], + message: 'Nonce already used: the transaction was already completed.' + }, + LOW_PRIVATE_BALANCE: { + matches: ['private balance too low', 'broadcaster fee'], + message: 'Private balance too low to pay broadcaster fee.' + }, + TRANSACTION_SIMULATION_FAILED: { + matches: ['transaction may fail or may require manual gas limit'], + message: 'Unknown error. Transaction failed.' + } +}; + +export const RAILGUN_ERRORS: CustomErrorMapping = { + INVALID_NFT_NOTE: { + matches: ['invalid nft note value'], + message: 'RailgunSmartWallet: Invalid NFT Note Value.' + }, + UNSUPPORTED_TOKEN: { + matches: ['unsupported token'], + message: 'RailgunSmartWallet: Unsupported Token. This token cannot interact with the RAILGUN contract.' + }, + INVALID_NOTE_VALUE: { + matches: ['invalid note value'], + message: 'RailgunSmartWallet: Invalid Note Value. Please submit transaction with a corrected amount.' + }, + INVALID_ADAPT_CONTRACT_SENDER: { + matches: ['invalid adapt contract as sender'], + message: 'RailgunSmartWallet: Invalid Adapt Contract as Sender. Please update your frontend to current Adapt module versions.' + }, + INVALID_MERKLE_ROOT: { + matches: ['invalid merkle root'], + message: 'RailgunSmartWallet: Invalid Merkle Root. Please sync your balances and try again.' + }, + NOTE_SPENT: { + matches: ['note already spent'], + message: 'RailgunSmartWallet: Note Already Spent. Please sync your balances and try again.' + }, + INVALID_NOTE_CIPHERTEXT_ARRAY_LENGTH: { + matches: ['invalid note ciphertext array length'], + message: 'RailgunSmartWallet: Invalid Note Ciphertext Array Length. Please sync balances and re-prove your transaction.' + }, + INVALID_WITHDRAW_NOTE: { + matches: ['invalid withdraw note'], + message: 'RailgunSmartWallet: Invalid Unshield Note. Please sync balances and re-prove your transaction.' + }, + INVALID_SNARK_PROOF: { + matches: ['invalid snark proof'], + message: 'RailgunSmartWallet: Invalid Snark Proof. Please re-prove your transaction.' + } +}; diff --git a/src/utils/error/index.ts b/src/utils/error/index.ts new file mode 100644 index 0000000..e0f52a2 --- /dev/null +++ b/src/utils/error/index.ts @@ -0,0 +1,48 @@ +import { CustomErrorMapping, ErrorDefinition } from './types'; +import { STRING_PREFIX_AFTER_UNICODE_REPLACEMENT, RAILGUN_ERRORS, CUSTOM_ERRORS, INVALID_ASCII_REGEX } from './constants'; + +const sanitizeAscii = (str: string) => str.replace(INVALID_ASCII_REGEX, ''); + +const findMatchingError = (errorMessage: string, errorMapping: CustomErrorMapping): ErrorDefinition | null => { + const lowercaseMsg = errorMessage.toLowerCase(); + + for (const [, errorDef] of Object.entries(errorMapping)) { + if (errorDef.matches.some(match => lowercaseMsg.includes(match))) { + return errorDef; + } + } + return null; +} + +const isRailgunError = (cause: Error): boolean => cause.message.toLowerCase().includes('railgunsmartwallet') + +export const sanitizeError = (cause: Error): Error => { + if (!cause?.message) { + return new Error('Unknown error. Please try again.', { cause }); + } + + if (isRailgunError(cause)) { + const matchedRailgunError = findMatchingError(cause.message, RAILGUN_ERRORS); + if (matchedRailgunError) { + return new Error(matchedRailgunError.message, { cause }); + } + return new Error('Uknown Railgun Smart Wallet Error.', { cause }); + } + + const matchedCustomError = findMatchingError(cause.message, CUSTOM_ERRORS); + + if (matchedCustomError) { + return new Error(matchedCustomError.message, { cause }); + } + + // If no error is matched we return the original sanitized error + const errorMessage = sanitizeAscii(cause.message).replace( + `:${STRING_PREFIX_AFTER_UNICODE_REPLACEMENT}`, + ': ', + ); + + return new Error( + errorMessage, + { cause } + ); +}; diff --git a/src/utils/error/types.ts b/src/utils/error/types.ts new file mode 100644 index 0000000..fca3229 --- /dev/null +++ b/src/utils/error/types.ts @@ -0,0 +1,8 @@ +export type ErrorDefinition = { + matches: string[]; + message: string; +}; + +export type CustomErrorMapping = { + [key: string]: ErrorDefinition; +};