diff --git a/src/core/EVM/EVMStepExecutor.ts b/src/core/EVM/EVMStepExecutor.ts index 252936e3..600deaa1 100644 --- a/src/core/EVM/EVMStepExecutor.ts +++ b/src/core/EVM/EVMStepExecutor.ts @@ -13,13 +13,12 @@ import { publicActions } from 'viem' import { config } from '../../config.js' import { getStepTransaction } from '../../services/api.js' import { - LiFiErrorCode, - TransactionError, - ValidationError, getTransactionFailedMessage, isZeroAddress, - parseError, } from '../../utils/index.js' +import { ValidationError, TransactionError } from '../../errors/errors.js' +import { LiFiErrorCode } from '../../errors/constants.js' +import { parseEVMErrors } from './parseEVMErrors.js' import { BaseStepExecutor } from '../BaseStepExecutor.js' import { checkBalance } from '../checkBalance.js' import { getSubstatusMessage } from '../processMessages.js' @@ -87,9 +86,13 @@ export class EVMStepExecutor extends BaseStepExecutor { }, }) this.statusManager.updateExecution(step, 'FAILED') - throw new TransactionError( - LiFiErrorCode.WalletChangedDuringExecution, - errorMessage + throw await parseEVMErrors( + new TransactionError( + LiFiErrorCode.WalletChangedDuringExecution, + errorMessage + ), + step, + process ) } return updatedWalletClient @@ -397,20 +400,21 @@ export class EVMStepExecutor extends BaseStepExecutor { process = this.statusManager.updateProcess(step, process.type, 'DONE') } } catch (e: any) { - const error = await parseError(e, step, process) + const error = await parseEVMErrors(e, step, process) process = this.statusManager.updateProcess( step, process.type, 'FAILED', { error: { - message: error.message, - htmlMessage: error.htmlMessage, + message: error.cause.message, + htmlMessage: error.cause.htmlMessage, code: error.code, }, } ) this.statusManager.updateExecution(step, 'FAILED') + throw error } } @@ -479,8 +483,7 @@ export class EVMStepExecutor extends BaseStepExecutor { }, }) this.statusManager.updateExecution(step, 'FAILED') - console.warn(e) - throw e + throw await parseEVMErrors(e as Error, step, process) } // DONE diff --git a/src/core/EVM/checkAllowance.ts b/src/core/EVM/checkAllowance.ts index 138926f8..e3c634e0 100644 --- a/src/core/EVM/checkAllowance.ts +++ b/src/core/EVM/checkAllowance.ts @@ -1,7 +1,7 @@ import type { Chain, LiFiStep, Process, ProcessType } from '@lifi/types' import type { Address, Hash, WalletClient } from 'viem' import { maxUint256 } from 'viem' -import { parseError } from '../../utils/parseError.js' +import { parseEVMErrors } from './parseEVMErrors.js' import type { StatusManager } from '../StatusManager.js' import type { ExecutionOptions } from '../types.js' import { getAllowance } from './getAllowance.js' @@ -100,15 +100,15 @@ export const checkAllowance = async ( } } } catch (e: any) { - const error = await parseError(e, step, allowanceProcess) + const error = await parseEVMErrors(e, step, allowanceProcess) allowanceProcess = statusManager.updateProcess( step, allowanceProcess.type, 'FAILED', { error: { - message: error.message, - htmlMessage: error.htmlMessage, + message: error.cause.message, + htmlMessage: error.cause.htmlMessage, code: error.code, }, } diff --git a/src/core/EVM/multisig.ts b/src/core/EVM/multisig.ts index cf4a03e6..a4a895c1 100644 --- a/src/core/EVM/multisig.ts +++ b/src/core/EVM/multisig.ts @@ -1,6 +1,7 @@ import type { ExtendedChain, LiFiStep, ProcessType } from '@lifi/types' import type { Hash } from 'viem' -import { LiFiErrorCode, TransactionError } from '../../utils/errors.js' +import { LiFiErrorCode } from '../../errors/constants.js' +import { TransactionError } from '../../errors/errors.js' import type { StatusManager } from '../StatusManager.js' import type { MultisigConfig, MultisigTxDetails } from './types.js' diff --git a/src/core/EVM/parseEVMErrors.ts b/src/core/EVM/parseEVMErrors.ts new file mode 100644 index 00000000..66b10e0a --- /dev/null +++ b/src/core/EVM/parseEVMErrors.ts @@ -0,0 +1,66 @@ +import { type LiFiStep, type Process } from '@lifi/types' +import { TransactionError, UnknownError } from '../../errors/errors.js' +import { SDKError } from '../../errors/SDKError.js' +import { ErrorMessage, LiFiErrorCode } from '../../errors/constants.js' +import { BaseError } from '../../utils/index.js' +import { fetchTxErrorDetails } from '../../helpers.js' + +export const parseEVMErrors = async ( + e: Error, + step?: LiFiStep, + process?: Process +): Promise => { + if (e instanceof SDKError) { + e.step = e.step ?? step + e.process = e.process ?? process + return e + } + + const baseError = await handleSpecificErrors(e, step, process) + + return new SDKError(baseError, step, process) +} + +const handleSpecificErrors = async ( + e: any, + step?: LiFiStep, + process?: Process +) => { + if (e.cause?.name === 'UserRejectedRequestError') { + return new TransactionError( + LiFiErrorCode.SignatureRejected, + e.message, + undefined, + e + ) + } + + if ( + step && + process?.txHash && + e.code === LiFiErrorCode.TransactionFailed && + e.message === ErrorMessage.TransactionReverted + ) { + const response = await fetchTxErrorDetails( + process.txHash, + step.action.fromChainId + ) + + const errorMessage = response?.error_message + + if (errorMessage?.toLowerCase().includes('out of gas')) { + return new TransactionError( + LiFiErrorCode.GasLimitError, + ErrorMessage.GasLimitLow, + undefined, + e + ) + } + } + + if (e instanceof BaseError) { + return e + } + + return new UnknownError(e.message || ErrorMessage.UnknownError, undefined, e) +} diff --git a/src/core/EVM/parseEVMErrors.unit.spec.ts b/src/core/EVM/parseEVMErrors.unit.spec.ts new file mode 100644 index 00000000..909e4269 --- /dev/null +++ b/src/core/EVM/parseEVMErrors.unit.spec.ts @@ -0,0 +1,215 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { setupTestEnvironment } from '../../../tests/setup.js' +import { parseEVMErrors } from './parseEVMErrors.js' +import { + ErrorName, + BaseError, + LiFiErrorCode, + SDKError, + TransactionError, + ErrorMessage, +} from '../../utils/index.js' +import { buildStepObject } from '../../../tests/fixtures.js' +import type { LiFiStep, Process } from '@lifi/types' +import * as helpers from '../../helpers.js' + +beforeAll(setupTestEnvironment) + +describe('parseEVMStepErrors', () => { + describe('when a SDKError is passed', async () => { + it('should return the original error', async () => { + const error = new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'there was an error' + ) + ) + + const parsedError = await parseEVMErrors(error) + + expect(parsedError).toBe(error) + + expect(parsedError.step).toBeUndefined() + expect(parsedError.process).toBeUndefined() + }) + }) + + describe('when step and process is passed', () => { + it('should return the original error with step and process added', async () => { + const error = new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'there was an error' + ) + ) + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution!.process[0] + + const parsedError = await parseEVMErrors(error, step, process) + + expect(parsedError).toBe(error) + + expect(parsedError.step).toBe(step) + expect(parsedError.process).toBe(process) + }) + }) + + describe('when the SDKError already has a step and process', () => { + it('should return the original error with teh existing step and process specified', async () => { + const expectedStep = buildStepObject({ includingExecution: true }) + const expectedProcess = expectedStep.execution!.process[0] + + const error = new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'there was an error' + ), + expectedStep, + expectedProcess + ) + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution!.process[0] + + const parsedError = await parseEVMErrors(error, step, process) + + expect(parsedError).toBe(error) + + expect(parsedError.step).toBe(expectedStep) + expect(parsedError.process).toBe(expectedProcess) + }) + }) + + describe('when a BaseError is passed', () => { + it('should return the BaseError as the cause on a SDKError', async () => { + const error = new BaseError( + ErrorName.BalanceError, + LiFiErrorCode.BalanceError, + 'there was an error' + ) + + const parsedError = await parseEVMErrors(error) + + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBeUndefined() + expect(parsedError.process).toBeUndefined() + expect(parsedError.cause).toBe(error) + }) + + describe('when step and process is passed', () => { + it('should return the SDKError with step and process added', async () => { + const error = new BaseError( + ErrorName.BalanceError, + LiFiErrorCode.BalanceError, + 'there was an error' + ) + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution!.process[0] + + const parsedError = await parseEVMErrors(error, step, process) + + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBe(step) + expect(parsedError.process).toBe(process) + expect(parsedError.cause).toBe(error) + }) + }) + }) + + describe('when a generic Error is passed', () => { + it('should return the Error as he cause on a BaseError which is wrapped in an SDKError', async () => { + const error = new Error('Somethings fishy') + + const parsedError = await parseEVMErrors(error) + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBeUndefined() + expect(parsedError.process).toBeUndefined() + + const baseError = parsedError.cause + expect(baseError).toBeInstanceOf(BaseError) + + const causeError = baseError.cause + expect(causeError).toBe(error) + }) + + describe('when step and process is passed', () => { + it('should return an SDKError with step and process added', async () => { + const error = new Error('Somethings fishy') + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution?.process[0] + + const parsedError = await parseEVMErrors(error, step, process) + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBe(step) + expect(parsedError.process).toBe(process) + }) + }) + }) + + describe('when specific Errors are passed', () => { + describe('when the error is the viem UserRejectedRequestError error', () => { + it('should return the BaseError with the SignatureRejected code as the cause on a SDKError', async () => { + const mockViemError = new Error() + const UserRejectedRequestError = new Error() + UserRejectedRequestError.name = 'UserRejectedRequestError' + mockViemError.cause = UserRejectedRequestError + + const parsedError = await parseEVMErrors(mockViemError) + + expect(parsedError).toBeInstanceOf(SDKError) + + const baseError = parsedError.cause + expect(baseError).toBeInstanceOf(TransactionError) + expect(baseError.code).toEqual(LiFiErrorCode.SignatureRejected) + + expect(baseError.cause?.cause).toBe(UserRejectedRequestError) + }) + }) + }) + + describe('when the error is a Transaction reverted error caused by low gas', () => { + it('should return the TransactionError with the GasLimitError code and GasLimitLow message', async () => { + vi.spyOn(helpers, 'fetchTxErrorDetails').mockResolvedValue({ + error_message: 'out of gas', + }) + + const mockTransactionError = new TransactionError( + LiFiErrorCode.TransactionFailed, + ErrorMessage.TransactionReverted + ) + + const mockStep = { + action: { + fromChainId: 10, + }, + } as LiFiStep + + const mockProcess = { + txHash: + '0x5c73f72a72a75d8b716ed42cd620042f53b958f028d0c9ad772908b7791c017b', + } as Process + + const parsedError = await parseEVMErrors( + mockTransactionError, + mockStep, + mockProcess + ) + + expect(parsedError).toBeInstanceOf(SDKError) + + const baseError = parsedError.cause + expect(baseError).toBeInstanceOf(TransactionError) + expect(baseError.code).toEqual(LiFiErrorCode.GasLimitError) + expect(baseError.message).toEqual(ErrorMessage.GasLimitLow) + expect(baseError.cause).toBe(mockTransactionError) + + vi.clearAllMocks() + }) + }) +}) diff --git a/src/core/EVM/switchChain.ts b/src/core/EVM/switchChain.ts index ffea24c2..0ef22071 100644 --- a/src/core/EVM/switchChain.ts +++ b/src/core/EVM/switchChain.ts @@ -1,5 +1,6 @@ import type { WalletClient } from 'viem' -import { LiFiErrorCode, ProviderError } from '../../utils/errors.js' +import { LiFiErrorCode } from '../../errors/constants.js' +import { ProviderError } from '../../errors/errors.js' import type { StatusManager } from '../StatusManager.js' import type { LiFiStepExtended, SwitchChainHook } from '../types.js' diff --git a/src/core/Solana/SolanaStepExecutor.ts b/src/core/Solana/SolanaStepExecutor.ts index b3bec592..94c5779b 100644 --- a/src/core/Solana/SolanaStepExecutor.ts +++ b/src/core/Solana/SolanaStepExecutor.ts @@ -9,12 +9,10 @@ import { import { config } from '../../config.js' import { getStepTransaction } from '../../services/api.js' import { base64ToUint8Array } from '../../utils/base64ToUint8Array.js' -import { - LiFiErrorCode, - TransactionError, - getTransactionFailedMessage, - parseError, -} from '../../utils/index.js' +import { getTransactionFailedMessage } from '../../utils/index.js' +import { TransactionError } from '../../errors/errors.js' +import { LiFiErrorCode } from '../../errors/constants.js' +import { parseSolanaErrors } from './parseSolanaErrors.js' import { BaseStepExecutor } from '../BaseStepExecutor.js' import { checkBalance } from '../checkBalance.js' import { getSubstatusMessage } from '../processMessages.js' @@ -260,15 +258,15 @@ export class SolanaStepExecutor extends BaseStepExecutor { process = this.statusManager.updateProcess(step, process.type, 'DONE') } } catch (e: any) { - const error = await parseError(e, step, process) + const error = await parseSolanaErrors(e, step, process) process = this.statusManager.updateProcess( step, process.type, 'FAILED', { error: { - message: error.message, - htmlMessage: error.htmlMessage, + message: error.cause.message, + htmlMessage: error.cause.htmlMessage, code: error.code, }, } diff --git a/src/core/Solana/parseSolanaError.unit.spec.ts b/src/core/Solana/parseSolanaError.unit.spec.ts new file mode 100644 index 00000000..5804bb24 --- /dev/null +++ b/src/core/Solana/parseSolanaError.unit.spec.ts @@ -0,0 +1,166 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { buildStepObject } from '../../../tests/fixtures.js' +import { setupTestEnvironment } from '../../../tests/setup.js' +import { SDKError } from '../../errors/SDKError.js' +import { BaseError } from '../../errors/baseError.js' +import { LiFiErrorCode, ErrorName } from '../../errors/constants.js' +import { TransactionError } from '../../errors/errors.js' +import { parseSolanaErrors } from './parseSolanaErrors.js' +beforeAll(setupTestEnvironment) + +describe('parseSolanaStepError', () => { + describe('when a SDKError is passed', () => { + it('should return the original error', async () => { + const error = new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'there was an error' + ) + ) + + const parsedError = await parseSolanaErrors(error) + + expect(parsedError).toBe(error) + + expect(parsedError.step).toBeUndefined() + expect(parsedError.process).toBeUndefined() + }) + + describe('when step and process is passed', () => { + it('should return the original error with step and process added', async () => { + const error = new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'there was an error' + ) + ) + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution!.process[0] + + const parsedError = await parseSolanaErrors(error, step, process) + + expect(parsedError).toBe(error) + + expect(parsedError.step).toBe(step) + expect(parsedError.process).toBe(process) + }) + + describe('when the SDKError already has a step and process', () => { + it('should return the original error with teh existing step and process specified', async () => { + const expectedStep = buildStepObject({ includingExecution: true }) + const expectedProcess = expectedStep.execution!.process[0] + + const error = new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'there was an error' + ), + expectedStep, + expectedProcess + ) + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution!.process[0] + + const parsedError = await parseSolanaErrors(error, step, process) + + expect(parsedError).toBe(error) + + expect(parsedError.step).toBe(expectedStep) + expect(parsedError.process).toBe(expectedProcess) + }) + }) + }) + }) + + describe('when a BaseError is passed', () => { + it('should return the BaseError as the cause on a SDKError', async () => { + const error = new BaseError( + ErrorName.BalanceError, + LiFiErrorCode.BalanceError, + 'there was an error' + ) + + const parsedError = await parseSolanaErrors(error) + + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBeUndefined() + expect(parsedError.process).toBeUndefined() + expect(parsedError.cause).toBe(error) + }) + + describe('when step and process is passed', () => { + it('should return the SDKError with step and process added', async () => { + const error = new BaseError( + ErrorName.BalanceError, + LiFiErrorCode.BalanceError, + 'there was an error' + ) + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution!.process[0] + + const parsedError = await parseSolanaErrors(error, step, process) + + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBe(step) + expect(parsedError.process).toBe(process) + expect(parsedError.cause).toBe(error) + }) + }) + }) + + describe('when a generic Error is passed', () => { + it('should return the Error as he cause on a BaseError which is wrapped in an SDKError', async () => { + const error = new Error('Somethings fishy') + + const parsedError = await parseSolanaErrors(error) + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBeUndefined() + expect(parsedError.process).toBeUndefined() + + const baseError = parsedError.cause + expect(baseError).toBeInstanceOf(BaseError) + + const causeError = baseError.cause + expect(causeError).toBe(error) + }) + + describe('when step and process is passed', () => { + it('should return an SDKError with step and process added', async () => { + const error = new Error('Somethings fishy') + + const step = buildStepObject({ includingExecution: true }) + const process = step.execution?.process[0] + + const parsedError = await parseSolanaErrors(error, step, process) + expect(parsedError).toBeInstanceOf(SDKError) + expect(parsedError.step).toBe(step) + expect(parsedError.process).toBe(process) + }) + }) + }) + + describe('when Solana Errors are passed', () => { + describe('when the error is a WalletSignTransactionError', () => { + it('should return the BaseError with the SignatureRejected code as the cause on a SDKError', async () => { + const MockSolanaError = new Error() + MockSolanaError.name = 'WalletSignTransactionError' + + const parsedError = await parseSolanaErrors(MockSolanaError) + + expect(parsedError).toBeInstanceOf(SDKError) + + const baseError = parsedError.cause + expect(baseError).toBeInstanceOf(TransactionError) + expect(baseError.code).toEqual(LiFiErrorCode.SignatureRejected) + + expect(baseError.cause).toBe(MockSolanaError) + }) + }) + }) +}) diff --git a/src/core/Solana/parseSolanaErrors.ts b/src/core/Solana/parseSolanaErrors.ts new file mode 100644 index 00000000..f0f5b07f --- /dev/null +++ b/src/core/Solana/parseSolanaErrors.ts @@ -0,0 +1,38 @@ +import type { LiFiStep, Process } from '@lifi/types' +import { BaseError } from '../../errors/baseError.js' +import { ErrorMessage, LiFiErrorCode } from '../../errors/constants.js' +import { TransactionError, UnknownError } from '../../errors/errors.js' +import { SDKError } from '../../errors/SDKError.js' + +export const parseSolanaErrors = async ( + e: Error, + step?: LiFiStep, + process?: Process +): Promise => { + if (e instanceof SDKError) { + e.step = e.step ?? step + e.process = e.process ?? process + return e + } + + const baseError = handleSpecificErrors(e) + + return new SDKError(baseError, step, process) +} + +const handleSpecificErrors = (e: any) => { + if (e.name === 'WalletSignTransactionError') { + return new TransactionError( + LiFiErrorCode.SignatureRejected, + e.message, + undefined, + e + ) + } + + if (e instanceof BaseError) { + return e + } + + return new UnknownError(e.message || ErrorMessage.UnknownError, undefined, e) +} diff --git a/src/core/checkBalance.ts b/src/core/checkBalance.ts index b810d8c5..96f9345d 100644 --- a/src/core/checkBalance.ts +++ b/src/core/checkBalance.ts @@ -1,7 +1,7 @@ import type { LiFiStep } from '@lifi/types' import { formatUnits } from 'viem' import { getTokenBalance } from '../services/balance.js' -import { BalanceError } from '../utils/errors.js' +import { BalanceError } from '../errors/errors.js' import { sleep } from './utils.js' export const checkBalance = async ( diff --git a/src/core/stepComparison.ts b/src/core/stepComparison.ts index 5e3a7a19..bdea6bb7 100644 --- a/src/core/stepComparison.ts +++ b/src/core/stepComparison.ts @@ -1,5 +1,6 @@ import type { LiFiStep } from '@lifi/types' -import { LiFiErrorCode, TransactionError } from '../utils/errors.js' +import { LiFiErrorCode } from '../errors/constants.js' +import { TransactionError } from '../errors/errors.js' import type { StatusManager } from './StatusManager.js' import type { ExecutionOptions } from './types.js' import { checkStepSlippageThreshold } from './utils.js' diff --git a/src/core/waitForReceivingTransaction.ts b/src/core/waitForReceivingTransaction.ts index 35f631e7..412c6e4d 100644 --- a/src/core/waitForReceivingTransaction.ts +++ b/src/core/waitForReceivingTransaction.ts @@ -5,7 +5,7 @@ import type { StatusResponse, } from '@lifi/types' import { getStatus } from '../services/api.js' -import { ServerError } from '../utils/errors.js' +import { ServerError } from '../errors/errors.js' import { repeatUntilDone } from '../utils/utils.js' import type { StatusManager } from './StatusManager.js' import { getSubstatusMessage } from './processMessages.js' diff --git a/src/errors/SDKError.ts b/src/errors/SDKError.ts new file mode 100644 index 00000000..2f7958f3 --- /dev/null +++ b/src/errors/SDKError.ts @@ -0,0 +1,25 @@ +import type { BaseError } from './baseError.js' +import { type ErrorCode } from './constants.js' +import type { LiFiStep, Process } from '@lifi/types' +import { version } from '../version.js' + +// Note: SDKError is used to wrapper and present errors at the top level +// Where opportunity allows we also add the step and the process related to the error +export class SDKError extends Error { + step?: LiFiStep + process?: Process + code: ErrorCode + override name = 'SDKError' + override cause: BaseError + + constructor(cause: BaseError, step?: LiFiStep, process?: Process) { + const errorMessage = `${cause.message ? `[${cause.name}] ${cause.message}` : 'Unknown error occurred'}\nLiFi SDK version: ${version}` + super(errorMessage) + this.name = 'SDKError' + this.step = step + this.process = process + this.cause = cause + this.stack = this.cause.stack + this.code = cause.code + } +} diff --git a/src/errors/SDKError.unit.spec.ts b/src/errors/SDKError.unit.spec.ts new file mode 100644 index 00000000..e77e287f --- /dev/null +++ b/src/errors/SDKError.unit.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { ErrorName, LiFiErrorCode } from './constants.js' +import { BaseError } from './baseError.js' +import { SDKError } from './SDKError.js' +import { version } from '../version.js' +import { HTTPError } from './httpError.js' + +const url = 'http://some.where' +const options = { method: 'POST' } +const responseBody = { message: 'Oops' } + +describe('SDKError', () => { + describe('when the cause is a http error', () => { + it('should present the causing errors stack trace for http errors', async () => { + expect.assertions(1) + + const testFunction = async () => { + try { + const mockResponse = { + status: 400, + statusText: 'Bad Request', + json: () => Promise.resolve(responseBody), + } as Response + + const httpError = new HTTPError(mockResponse, url, options) + + await httpError.buildAdditionalDetails() + + throw httpError + } catch (e: any) { + throw new SDKError(e) + } + } + + try { + await testFunction() + } catch (e: any) { + expect((e as SDKError).stack).toBe((e as SDKError).cause.stack) + } + }) + + it('should feature the causing http error message as part of its own message', async () => { + const mockResponse = { + status: 400, + statusText: 'Bad Request', + json: () => Promise.resolve(responseBody), + } as Response + + const httpError = new HTTPError(mockResponse, url, options) + + await httpError.buildAdditionalDetails() + + const testFunction = () => { + throw new SDKError(httpError) + } + + expect(() => testFunction()).toThrowError( + `[HTTPError] [ValidationError] Request failed with status code 400 Bad Request\n responseMessage: Oops\nLiFi SDK version: ${version}` + ) + }) + }) + + describe('when the cause is a base error', () => { + it('should present the causing errors stack trace for base errors', () => { + expect.assertions(1) + + const testFunction = () => { + try { + const baseError = new BaseError( + ErrorName.ValidationError, + LiFiErrorCode.ValidationError, + 'problem validating' + ) + + throw baseError + } catch (e: any) { + throw new SDKError(e) + } + } + + try { + testFunction() + } catch (e: any) { + expect((e as SDKError).stack).toBe((e as SDKError).cause.stack) + } + }) + + it('should present the causing errors stack trace for base errors own causing error', () => { + expect.assertions(1) + + const causingError = () => { + try { + throw new Error('this was the root cause') + } catch (e: any) { + throw new BaseError( + ErrorName.ValidationError, + LiFiErrorCode.ValidationError, + 'problem validating', + undefined, + e + ) + } + } + + const testFunction = () => { + try { + causingError() + } catch (e: any) { + throw new SDKError(e) + } + } + + try { + testFunction() + } catch (e: any) { + expect((e as SDKError).stack).toBe( + ((e as SDKError).cause as SDKError).cause.stack + ) + } + }) + + it('should feature the causing base error message as part of its own message', () => { + const testFunction = () => { + throw new SDKError( + new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'There was an error' + ) + ) + } + + expect(() => testFunction()).toThrowError( + `[UnknownError] There was an error\nLiFi SDK version: ${version}` + ) + }) + + it('should use a fail back error message if one is not defined on the base error', () => { + const testFunction = () => { + throw new SDKError( + new BaseError(ErrorName.BalanceError, LiFiErrorCode.BalanceError, '') + ) + } + + expect(() => testFunction()).toThrowError( + `Unknown error occurred\nLiFi SDK version: ${version}` + ) + }) + + it('should present the passed base error as the cause', () => { + const baseError = new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'There was an error' + ) + const sdkError = new SDKError(baseError) + + expect(sdkError.cause).toBe(baseError) + }) + }) +}) diff --git a/src/errors/baseError.ts b/src/errors/baseError.ts new file mode 100644 index 00000000..12983f09 --- /dev/null +++ b/src/errors/baseError.ts @@ -0,0 +1,30 @@ +import type { ErrorCode, ErrorName } from './constants.js' +import { getRootCause } from './utils/rootCause.js' + +// Note: we use the BaseErrors to capture errors at specific points in the code +// they can carry addition to help give more context +export class BaseError extends Error { + code: ErrorCode + htmlMessage?: string + override cause?: Error + + constructor( + name: ErrorName, + code: number, + message: string, + htmlMessage?: string, + cause?: Error + ) { + super(message) + + this.name = name + this.code = code + this.htmlMessage = htmlMessage + this.cause = cause + + const rootCause = getRootCause(this.cause) + if (rootCause && rootCause.stack) { + this.stack = rootCause.stack + } + } +} diff --git a/src/errors/baseError.unit.spec.ts b/src/errors/baseError.unit.spec.ts new file mode 100644 index 00000000..755ec7fb --- /dev/null +++ b/src/errors/baseError.unit.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { ErrorName, LiFiErrorCode } from './constants.js' +import { BaseError } from './baseError.js' + +describe('baseError', () => { + it('should set the stack to the same as the deep rooted cause', () => { + const rootError = new Error() + rootError.stack = 'root stack trace' + + const intermediateError = new Error() + intermediateError.cause = rootError + + const errorChain = new BaseError( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + 'There was an error', + undefined, + intermediateError + ) + + expect(errorChain.stack).toBe(rootError.stack) + }) +}) diff --git a/src/errors/constants.ts b/src/errors/constants.ts new file mode 100644 index 00000000..3f3feb88 --- /dev/null +++ b/src/errors/constants.ts @@ -0,0 +1,44 @@ +export enum ErrorName { + RPCError = 'RPCError', + ProviderError = 'ProviderError', + ServerError = 'ServerError', + TransactionError = 'TransactionError', + ValidationError = 'ValidationError', + BalanceError = 'BalanceError', + NotFoundError = 'NotFoundError', + UnknownError = 'UnknownError', + SlippageError = 'SlippageError', + HTTPError = 'HTTPError', +} + +export type ErrorCode = LiFiErrorCode + +export enum LiFiErrorCode { + InternalError = 1000, + ValidationError = 1001, + TransactionUnderpriced = 1002, + TransactionFailed = 1003, + Timeout = 1004, + ProviderUnavailable = 1005, + NotFound = 1006, + ChainSwitchError = 1007, + TransactionUnprepared = 1008, + GasLimitError = 1009, + TransactionCanceled = 1010, + SlippageError = 1011, + SignatureRejected = 1012, + BalanceError = 1013, + AllowanceRequired = 1014, + InsufficientFunds = 1015, + ExchangeRateUpdateCanceled = 1016, + WalletChangedDuringExecution = 1017, + TransactionExpired = 1018, +} + +export enum ErrorMessage { + UnknownError = 'Unknown error occurred.', + SlippageError = 'The slippage is larger than the defined threshold. Please request a new route to get a fresh quote.', + GasLimitLow = 'Gas limit is too low.', + TransactionUnderpriced = 'Transaction is underpriced.', + TransactionReverted = 'Transaction was reverted.', +} diff --git a/src/errors/errors.ts b/src/errors/errors.ts new file mode 100644 index 00000000..82b500a5 --- /dev/null +++ b/src/errors/errors.ts @@ -0,0 +1,71 @@ +import { ErrorName, LiFiErrorCode } from './constants.js' +import { BaseError } from './baseError.js' + +export class RPCError extends BaseError { + constructor( + code: LiFiErrorCode, + message: string, + htmlMessage?: string, + cause?: Error + ) { + super(ErrorName.RPCError, code, message, htmlMessage, cause) + } +} + +export class ProviderError extends BaseError { + constructor( + code: LiFiErrorCode, + message: string, + htmlMessage?: string, + cause?: Error + ) { + super(ErrorName.ProviderError, code, message, htmlMessage, cause) + } +} + +export class TransactionError extends BaseError { + constructor( + code: LiFiErrorCode, + message: string, + htmlMessage?: string, + cause?: Error + ) { + super(ErrorName.TransactionError, code, message, htmlMessage, cause) + } +} + +export class UnknownError extends BaseError { + constructor(message: string, htmlMessage?: string, cause?: Error) { + super( + ErrorName.UnknownError, + LiFiErrorCode.InternalError, + message, + htmlMessage, + cause + ) + } +} + +export class BalanceError extends BaseError { + constructor(message: string, htmlMessage?: string, cause?: Error) { + super( + ErrorName.BalanceError, + LiFiErrorCode.BalanceError, + message, + htmlMessage, + cause + ) + } +} + +export class ServerError extends BaseError { + constructor(message: string) { + super(ErrorName.ServerError, LiFiErrorCode.InternalError, message) + } +} + +export class ValidationError extends BaseError { + constructor(message: string) { + super(ErrorName.ValidationError, LiFiErrorCode.ValidationError, message) + } +} diff --git a/src/errors/httpError.ts b/src/errors/httpError.ts new file mode 100644 index 00000000..e605430e --- /dev/null +++ b/src/errors/httpError.ts @@ -0,0 +1,95 @@ +import type { UnavailableRoutes } from '@lifi/types' +import { LiFiErrorCode } from './constants.js' +import { BaseError } from './baseError.js' +import type { ExtendedRequestInit } from '../types/request.js' +import { ErrorName, ErrorMessage } from './constants.js' + +interface ServerErrorResponseBody { + code: number + message: string + errors?: UnavailableRoutes +} + +const statusCodeToErrorClassificationMap = new Map([ + [ + 400, + { type: ErrorName.ValidationError, code: LiFiErrorCode.ValidationError }, + ], + [404, { type: ErrorName.NotFoundError, code: LiFiErrorCode.NotFound }], + [ + 409, + { + type: ErrorName.SlippageError, + code: LiFiErrorCode.SlippageError, + htmlMessage: ErrorMessage.SlippageError, + }, + ], + [500, { type: ErrorName.ServerError, code: LiFiErrorCode.InternalError }], +]) + +const getErrorClassificationFromStatusCode = (code: number) => + statusCodeToErrorClassificationMap.get(code) ?? { + type: ErrorName.ServerError, + code: LiFiErrorCode.InternalError, + } + +const createInitialMessage = (response: Response) => { + const statusCode = + response.status || response.status === 0 ? response.status : '' + const title = response.statusText || '' + const status = `${statusCode} ${title}`.trim() + const reason = status ? `status code ${status}` : 'an unknown error' + return `Request failed with ${reason}` +} + +export class HTTPError extends BaseError { + public response: Response + public status: number + public url: RequestInfo | URL + public fetchOptions: ExtendedRequestInit + public type?: ErrorName + public responseBody?: ServerErrorResponseBody + + constructor( + response: Response, + url: RequestInfo | URL, + options: ExtendedRequestInit + ) { + const message = createInitialMessage(response) + const errorClassification = getErrorClassificationFromStatusCode( + response.status + ) + + super( + ErrorName.HTTPError, + errorClassification.code, + message, + errorClassification?.htmlMessage + ) + + this.type = errorClassification.type + this.response = response + this.status = response.status + this.message = message + this.url = url + this.fetchOptions = options + } + + async buildAdditionalDetails() { + if (this.type) { + this.message = `[${this.type}] ${this.message}` + } + + try { + this.responseBody = await this.response.json() + + const spacer = '\n ' + + if (this.responseBody) { + this.message += `${spacer}responseMessage: ${this.responseBody?.message.toString().replaceAll('\n', spacer)}` + } + } catch {} + + return this + } +} diff --git a/src/errors/httpError.unit.spec.ts b/src/errors/httpError.unit.spec.ts new file mode 100644 index 00000000..c7ac5337 --- /dev/null +++ b/src/errors/httpError.unit.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { HTTPError } from './httpError.js' +import { ErrorName, LiFiErrorCode } from './constants.js' + +const url = 'http://some.where' +const options = { method: 'POST' } +const responseBody = { message: 'Oops' } + +describe('HTTPError', () => { + it.each([ + [ + 'when status code is 400', + options, + 400, + 'Bad Request', + { + initialMessage: 'Request failed with status code 400 Bad Request', + type: ErrorName.ValidationError, + code: LiFiErrorCode.ValidationError, + jsonFunc: () => Promise.resolve(responseBody), + responseBody, + htmlMessage: undefined, + builtMessage: `[ValidationError] Request failed with status code 400 Bad Request + responseMessage: Oops`, + }, + ], + [ + 'when status code is 404', + options, + 404, + 'Not Found', + { + initialMessage: 'Request failed with status code 404 Not Found', + type: ErrorName.NotFoundError, + code: LiFiErrorCode.NotFound, + jsonFunc: () => Promise.resolve(responseBody), + responseBody, + htmlMessage: undefined, + builtMessage: `[NotFoundError] Request failed with status code 404 Not Found + responseMessage: Oops`, + }, + ], + [ + 'when status code is 409', + options, + 409, + 'Conflict', + { + initialMessage: 'Request failed with status code 409 Conflict', + type: ErrorName.SlippageError, + code: LiFiErrorCode.SlippageError, + jsonFunc: () => Promise.resolve(responseBody), + responseBody, + htmlMessage: + 'The slippage is larger than the defined threshold. Please request a new route to get a fresh quote.', + builtMessage: `[SlippageError] Request failed with status code 409 Conflict + responseMessage: Oops`, + }, + ], + [ + 'when status code is 500', + options, + 500, + 'Internal Server Error', + { + initialMessage: + 'Request failed with status code 500 Internal Server Error', + type: ErrorName.ServerError, + code: LiFiErrorCode.InternalError, + jsonFunc: () => Promise.resolve(responseBody), + responseBody, + htmlMessage: undefined, + builtMessage: `[ServerError] Request failed with status code 500 Internal Server Error + responseMessage: Oops`, + }, + ], + [ + 'when status code is undefined', + options, + undefined, + '', + { + initialMessage: 'Request failed with an unknown error', + type: ErrorName.ServerError, + code: LiFiErrorCode.InternalError, + jsonFunc: () => Promise.resolve(responseBody), + responseBody, + htmlMessage: undefined, + builtMessage: `[ServerError] Request failed with an unknown error + responseMessage: Oops`, + }, + ], + [ + 'when there is a problem processing the body', + options, + 400, + 'Bad Request', + { + initialMessage: 'Request failed with status code 400 Bad Request', + type: ErrorName.ValidationError, + code: LiFiErrorCode.ValidationError, + jsonFunc: () => Promise.reject(new Error('fail')), + htmlMessage: undefined, + responseBody: undefined, + builtMessage: `[ValidationError] Request failed with status code 400 Bad Request`, + }, + ], + ])( + 'should present correctly %s', + async (_, requestOptions, statusCode, statusText, expected) => { + const mockResponse = { + status: statusCode, + statusText, + json: expected.jsonFunc, + } as Response + + const error = new HTTPError(mockResponse, url, requestOptions) + + expect(error.status).toEqual(statusCode) + expect(error.message).toEqual(expected.initialMessage) + expect(error.url).toEqual(url) + expect(error.fetchOptions).toEqual(requestOptions) + + expect(error.type).toEqual(expected.type) + expect(error.code).toEqual(expected.code) + if (expected.htmlMessage) { + expect(error.htmlMessage).toEqual(expected.htmlMessage) + } + + await error.buildAdditionalDetails() + + expect(error.responseBody).toEqual(expected.responseBody) + expect(error.message).toEqual(expected.builtMessage) + } + ) +}) diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 00000000..ebc6998a --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,7 @@ +export * from './baseError.js' +export * from './constants.js' +export * from './errors.js' +export * from './httpError.js' +export * from './SDKError.js' +export * from './utils/rootCause.js' +export * from './utils/baseErrorRootCause.js' diff --git a/src/errors/utils/baseErrorRootCause.ts b/src/errors/utils/baseErrorRootCause.ts new file mode 100644 index 00000000..0fc26bbd --- /dev/null +++ b/src/errors/utils/baseErrorRootCause.ts @@ -0,0 +1,18 @@ +import { BaseError } from '../baseError.js' +import { HTTPError } from '../httpError.js' + +export const getRootCauseBaseError = (e: Error) => { + let rootCause = e + while (rootCause.cause && rootCause.cause instanceof BaseError) { + rootCause = rootCause.cause as BaseError + } + return rootCause as BaseError +} + +export const getRootCauseBaseErrorMessage = (e: Error) => { + const rootCause = getRootCauseBaseError(e) + + return rootCause instanceof HTTPError + ? (rootCause as HTTPError).responseBody?.message || rootCause.message + : rootCause.message +} diff --git a/src/errors/utils/baseErrorRootCause.unit.spec.ts b/src/errors/utils/baseErrorRootCause.unit.spec.ts new file mode 100644 index 00000000..f383e53a --- /dev/null +++ b/src/errors/utils/baseErrorRootCause.unit.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { SDKError } from '../SDKError.js' +import { BaseError } from '../baseError.js' +import { ErrorName, LiFiErrorCode } from '../constants.js' +import { + getRootCauseBaseError, + getRootCauseBaseErrorMessage, +} from './baseErrorRootCause.js' +import { HTTPError } from '../httpError.js' + +const getErrorChain = () => { + const NonLiFiErrorChain = new Error('non lifi error') + NonLiFiErrorChain.cause = new Error('root cause') + return new SDKError( + new BaseError( + ErrorName.ValidationError, + LiFiErrorCode.ValidationError, + 'something happened', + undefined, + NonLiFiErrorChain + ) + ) +} + +describe('getRootCauseBaseError', () => { + it('should return the top level error when there is no root cause', () => { + const error = new Error('top level') + + expect(getRootCauseBaseError(error).message).toEqual('top level') + }) + + it('should return the lowest BaseError in the cause chain', () => { + const errorChain = getErrorChain() + + expect(getRootCauseBaseError(errorChain).message).toEqual( + 'something happened' + ) + }) +}) + +describe('getRootCauseBaseErrorMessage', () => { + describe('when root cause is HTTP Error', () => { + it('should return the HTTP response message if present', async () => { + const mockResponse = { + status: 400, + statusText: 'Bad Request', + json: () => + Promise.resolve({ message: 'something went wrong on the server' }), + } as Response + + const httpError = new HTTPError(mockResponse, 'http://some.where', {}) + + await httpError.buildAdditionalDetails() + + const errorChain = new SDKError(httpError) + + expect(getRootCauseBaseErrorMessage(errorChain)).toEqual( + 'something went wrong on the server' + ) + }) + + it('should return the HTTP error message if response message not present', async () => { + const mockResponse = { + status: 400, + statusText: 'Bad Request', + json: () => Promise.resolve({}), + } as Response + + const httpError = new HTTPError(mockResponse, 'http://some.where', {}) + + await httpError.buildAdditionalDetails() + + const errorChain = new SDKError(httpError) + + expect(getRootCauseBaseErrorMessage(errorChain)).toEqual( + '[ValidationError] Request failed with status code 400 Bad Request' + ) + }) + }) + + describe('when root cause is base Error', () => { + it('should return the BaseError message', () => { + const errorChain = getErrorChain() + + expect(getRootCauseBaseErrorMessage(errorChain)).toEqual( + 'something happened' + ) + }) + }) +}) diff --git a/src/errors/utils/rootCause.ts b/src/errors/utils/rootCause.ts new file mode 100644 index 00000000..2a5dae72 --- /dev/null +++ b/src/errors/utils/rootCause.ts @@ -0,0 +1,7 @@ +export const getRootCause = (e: Error | undefined) => { + let rootCause = e + while (rootCause?.cause) { + rootCause = rootCause.cause as Error + } + return rootCause +} diff --git a/src/errors/utils/rootCause.unit.spec.ts b/src/errors/utils/rootCause.unit.spec.ts new file mode 100644 index 00000000..5fed0ae9 --- /dev/null +++ b/src/errors/utils/rootCause.unit.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { SDKError } from '../SDKError.js' +import { BaseError } from '../baseError.js' +import { ErrorName, LiFiErrorCode } from '../constants.js' +import { getRootCause } from './rootCause.js' + +const getErrorChain = () => { + const NonLiFiErrorChain = new Error('non lifi error') + NonLiFiErrorChain.cause = new Error('root cause') + return new SDKError( + new BaseError( + ErrorName.ValidationError, + LiFiErrorCode.ValidationError, + 'something happened', + undefined, + NonLiFiErrorChain + ) + ) +} + +describe('getRootCause', () => { + it('should return the top level error when there is no root cause', () => { + const error = new Error('top level') + + expect(getRootCause(error)!.message).toEqual('top level') + }) + + it('should return the root cause', () => { + const errorChain = getErrorChain() + + expect(getRootCause(errorChain)!.message).toEqual('root cause') + }) + + it('should return undefined when passed undefined', () => { + expect(getRootCause(undefined)).toBeUndefined() + }) +}) diff --git a/src/helper.unit.spec.ts b/src/helper.unit.spec.ts new file mode 100644 index 00000000..0ad44ed3 --- /dev/null +++ b/src/helper.unit.spec.ts @@ -0,0 +1,73 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' +import { checkPackageUpdates } from './helpers.js' + +const latestVersion = '2.5.6' + +describe('helpers', () => { + describe('checkPackageUpdates', () => { + beforeEach(() => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + json: () => Promise.resolve({ version: latestVersion }), + } as Response) + + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should be able to check the version number against npm', async () => { + const packageName = '@lifi/sdk' + const currentVersion = '0.0.0' + + await checkPackageUpdates(packageName, currentVersion) + + expect(global.fetch as Mock).toBeCalledWith( + `https://registry.npmjs.org/${packageName}/latest` + ) + + expect(console.warn).toBeCalledWith( + `${packageName}: new package version is available. Please update as soon as possible to enjoy the newest features. Current version: ${currentVersion}. Latest version: ${latestVersion}.` + ) + }) + + it('should not report if version matchs the latest on npm', async () => { + const packageName = '@lifi/sdk' + const currentVersion = '2.5.6' + + await checkPackageUpdates(packageName, currentVersion) + + expect(global.fetch as Mock).toBeCalledWith( + `https://registry.npmjs.org/${packageName}/latest` + ) + + expect(console.warn).not.toBeCalled() + }) + + it('should fail sliently if it encounters a problem', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue({ + json: () => Promise.resolve({ version: latestVersion }), + } as Response) + + const packageName = '@lifi/sdk' + const currentVersion = '0.0.0' + + await checkPackageUpdates(packageName, currentVersion) + + expect(global.fetch as Mock).toBeCalledWith( + `https://registry.npmjs.org/${packageName}/latest` + ) + + expect(console.warn).not.toBeCalled() + }) + }) +}) diff --git a/src/helpers.ts b/src/helpers.ts index 266b9b3a..b7a91c51 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,8 +1,7 @@ -import type { LiFiStep, Route } from '@lifi/types' -import { request } from './request.js' -import type { TenderlyResponse } from './types/index.js' -import { ValidationError } from './utils/errors.js' import { name, version } from './version.js' +import { ValidationError } from './errors/errors.js' +import { SDKError } from './errors/SDKError.js' +import type { Route, LiFiStep } from '@lifi/types' export const checkPackageUpdates = async ( packageName?: string, @@ -10,12 +9,11 @@ export const checkPackageUpdates = async ( ) => { try { const pkgName = packageName ?? name - const response = await request<{ version: string }>( - `https://registry.npmjs.org/${pkgName}/latest`, - { skipTrackingHeaders: true } - ) - const latestVersion = response.version + const response = await fetch(`https://registry.npmjs.org/${pkgName}/latest`) + const reponseBody = await response.json() + const latestVersion = reponseBody.version const currentVersion = packageVersion ?? version + if (latestVersion > currentVersion) { console.warn( // eslint-disable-next-line max-len @@ -30,16 +28,22 @@ export const checkPackageUpdates = async ( /** * Converts a quote to Route * @param step - Step returned from the quote endpoint. + * @param txHash + * @param chainId * @returns - The route to be executed. - * @throws {ValidationError} Throws a ValidationError if the step has missing values. + * @throws {BaseError} Throws a ValidationError if the step has missing values. */ export const convertQuoteToRoute = (step: LiFiStep): Route => { if (!step.estimate.fromAmountUSD) { - throw new ValidationError("Missing 'fromAmountUSD' in step estimate.") + throw new SDKError( + new ValidationError("Missing 'fromAmountUSD' in step estimate.") + ) } if (!step.estimate.toAmountUSD) { - throw new ValidationError("Missing 'toAmountUSD' in step estimate.") + throw new SDKError( + new ValidationError("Missing 'toAmountUSD' in step estimate.") + ) } const route: Route = { @@ -61,9 +65,12 @@ export const convertQuoteToRoute = (step: LiFiStep): Route => { } export const fetchTxErrorDetails = async (txHash: string, chainId: number) => { - const response = await request( - `https://api.tenderly.co/api/v1/public-contract/${chainId}/tx/${txHash}` - ) + try { + const response = await fetch( + `https://api.tenderly.co/api/v1/public-contract/${chainId}/tx/${txHash}` + ) + const reponseBody = await response.json() - return response + return reponseBody + } catch (_) {} } diff --git a/src/index.ts b/src/index.ts index 1d3faec6..472360d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,5 +26,5 @@ export * from './services/api.js' export * from './services/balance.js' export * from './services/getNameServiceAddress.js' export * from './types/index.js' -export * from './utils/errors.js' -export { LiFiError, type ErrorCode } from './utils/errors.js' +export * from './errors/index.js' +export { type ErrorCode } from './errors/index.js' diff --git a/src/request.ts b/src/request.ts index b94e56ff..556f903e 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,16 +1,21 @@ import { config } from './config.js' -import { HTTPError } from './utils/errors.js' +import { HTTPError } from './errors/httpError.js' import { wait } from './utils/utils.js' +import { ValidationError } from './errors/errors.js' +import { SDKError } from './errors/SDKError.js' +import type { ExtendedRequestInit } from './types/request.js' import { version } from './version.js' export const requestSettings = { retries: 1, } -interface ExtendedRequestInit extends RequestInit { - retries?: number - skipTrackingHeaders?: boolean -} +const stripExtendRequestInitProperties = ({ + retries, + ...rest +}: ExtendedRequestInit): RequestInit => ({ + ...rest, +}) export const request = async ( url: RequestInfo | URL, @@ -19,61 +24,70 @@ export const request = async ( } ): Promise => { const { userId, integrator, widgetVersion, apiKey } = config.get() + if (!integrator) { - throw new Error( - 'Integrator not found. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' + throw new SDKError( + new ValidationError( + 'You need to provide the Integrator property. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' + ) ) } + options.retries = options.retries ?? requestSettings.retries - try { - if (!options.skipTrackingHeaders) { - if (apiKey) { - options.headers = { - ...options.headers, - 'x-lifi-api-key': apiKey, - } - } - if (userId) { - options.headers = { - ...options.headers, - 'x-lifi-userid': userId, - } + try { + if (apiKey) { + options.headers = { + ...options.headers, + 'x-lifi-api-key': apiKey, } + } - if (widgetVersion) { - options.headers = { - ...options.headers, - 'x-lifi-widget': widgetVersion, - } + if (userId) { + options.headers = { + ...options.headers, + 'x-lifi-userid': userId, } + } - if (version) { - options.headers = { - ...options.headers, - 'x-lifi-sdk': version, - } + if (widgetVersion) { + options.headers = { + ...options.headers, + 'x-lifi-widget': widgetVersion, } + } - // integrator is mandatory during SDK initialization + if (version) { options.headers = { ...options.headers, - 'x-lifi-integrator': integrator, + 'x-lifi-sdk': version, } } - const response: Response = await fetch(url, options) + // integrator is mandatory during SDK initialization + options.headers = { + ...options.headers, + 'x-lifi-integrator': integrator, + } + + const response: Response = await fetch( + url, + stripExtendRequestInitProperties(options) + ) + if (!response.ok) { - throw new HTTPError(response) + throw new HTTPError(response, url, options) } - const data: T = await response.json() - return data + return await response.json() } catch (error) { - if (options.retries > 0 && (error as HTTPError)?.status === 500) { + if (options.retries > 0 && (error as HTTPError).status === 500) { await wait(500) return request(url, { ...options, retries: options.retries - 1 }) } - throw error + + await (error as HTTPError).buildAdditionalDetails?.() + + throw new SDKError(error as HTTPError) } } diff --git a/src/request.unit.spec.ts b/src/request.unit.spec.ts new file mode 100644 index 00000000..9c3a4c6d --- /dev/null +++ b/src/request.unit.spec.ts @@ -0,0 +1,183 @@ +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterAll, + afterEach, +} from 'vitest' +import { config } from './config.js' +import type { SDKBaseConfig } from './types/index.js' +import { request } from './request.js' +import { SDKError } from './errors/SDKError.js' +import { type HTTPError, ValidationError } from './utils/index.js' +import type { ExtendedRequestInit } from './types/request.js' +import { version } from './version.js' +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { handlers } from './services/api.unit.handlers.js' +import { setupTestEnvironment } from '../tests/setup.js' + +const apiUrl = config.get().apiUrl + +describe('request new', () => { + const server = setupServer(...handlers) + + beforeAll(() => { + setupTestEnvironment() + server.listen({ + onUnhandledRequest: 'warn', + }) + + vi.spyOn(global, 'fetch') + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => server.resetHandlers()) + + afterAll(() => { + server.close() + + vi.clearAllMocks() + }) + + it('should be able to successfully make a fetch request', async () => { + const url = `${apiUrl}/advanced/routes` + + const response = await request<{ message: string }>(url, { + method: 'POST', + retries: 0, + }) + + expect(response).toEqual({}) + }) + + it('should remove the extended request init properties that fetch does not care about', async () => { + const url = `${apiUrl}/advanced/routes` + const successResponse = { message: 'it works' } + + server.use( + http.post(url, async ({ request }) => { + expect(request.headers.get('x-lifi-integrator')).toEqual('lifi-sdk') + expect(request.headers.get('x-lifi-sdk')).toEqual(version) + expect(request.headers.get('x-lifi-api-key')).toBeNull() + expect(request.headers.get('x-lifi-userid')).toBeNull() + expect(request.headers.get('x-lifi-widget')).toBeNull() + + return HttpResponse.json(successResponse, { status: 200 }) + }) + ) + + const options: ExtendedRequestInit = { + method: 'POST', + retries: 0, + } + + const response = await request<{ message: string }>(url, options) + + expect(response).toEqual(successResponse) + }) + + it('should update the headers information available from config', async () => { + const url = `${apiUrl}/advanced/routes` + const successResponse = { message: 'it works' } + + server.use( + http.post(url, async ({ request }) => { + expect(request.headers.get('x-lifi-api-key')).toEqual('mock-apikey') + expect(request.headers.get('x-lifi-integrator')).toEqual('lifi-sdk') + expect(request.headers.get('x-lifi-sdk')).toEqual(version) + expect(request.headers.get('x-lifi-userid')).toEqual('user-id') + expect(request.headers.get('x-lifi-widget')).toEqual( + 'mock-widget-version' + ) + + return HttpResponse.json(successResponse, { status: 200 }) + }) + ) + + const options: ExtendedRequestInit = { + method: 'POST', + retries: 0, + headers: { + 'x-lifi-api-key': 'mock-apikey', + 'x-lifi-userid': 'user-id', + 'x-lifi-widget': 'mock-widget-version', + }, + } + + const response = await request<{ message: string }>(url, options) + + expect(response).toEqual(successResponse) + }) + + describe('when dealing with errors', () => { + it('should throw an error if the Integrator property is missing from the config', async () => { + const originalIntegrator = config.get().integrator + config.set({ integrator: '' } as SDKBaseConfig) + + const url = `${apiUrl}/advanced/routes` + + await expect( + request<{ message: string }>(url, { + method: 'POST', + retries: 0, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError( + 'You need to provide the Integrator property. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' + ) + ) + ) + + config.set({ integrator: originalIntegrator } as SDKBaseConfig) + }) + it('should throw a error with when the request fails', async () => { + expect.assertions(2) + + const url = `${apiUrl}/advanced/routes` + const errorResponse = { message: 'something went wrong on the server' } + + server.use( + http.post(url, async () => { + return HttpResponse.json(errorResponse, { status: 400 }) + }) + ) + + try { + await request<{ message: string }>(url, { method: 'POST', retries: 0 }) + } catch (e) { + expect((e as SDKError).name).toEqual('SDKError') + expect(((e as SDKError).cause as HTTPError).status).toEqual(400) + } + }) + it('should throw a error and attempt retries when the request fails with a 500', async () => { + expect.assertions(2) + + const url = `${apiUrl}/advanced/routes` + const errorResponse = { message: 'something went wrong on the server' } + + server.use( + http.post(url, async () => { + return HttpResponse.json(errorResponse, { status: 500 }) + }) + ) + + try { + await request<{ message: string }>(url, { + method: 'POST', + retries: 0, + }) + } catch (e) { + expect((e as SDKError).name).toEqual('SDKError') + expect(((e as SDKError).cause as HTTPError).status).toEqual(500) + } + }) + }) +}) diff --git a/src/services/api.ts b/src/services/api.ts index 908f2f19..e7ad137f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -27,8 +27,8 @@ import type { import { config } from '../config.js' import { request } from '../request.js' import { isRoutesRequest, isStep } from '../typeguards.js' -import { ValidationError } from '../utils/errors.js' -import { parseBackendError } from '../utils/parseBackendError.js' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' /** * Fetch information about a Token * @param chain - Id or key of the chain that contains the token @@ -43,25 +43,24 @@ export const getToken = async ( options?: RequestOptions ): Promise => { if (!chain) { - throw new ValidationError('Required parameter "chain" is missing.') + throw new SDKError( + new ValidationError('Required parameter "chain" is missing.') + ) } if (!token) { - throw new ValidationError('Required parameter "token" is missing.') - } - try { - const response = await request( - `${config.get().apiUrl}/token?${new URLSearchParams({ - chain, - token, - } as Record)}`, - { - signal: options?.signal, - } + throw new SDKError( + new ValidationError('Required parameter "token" is missing.') ) - return response - } catch (e) { - throw await parseBackendError(e) } + return await request( + `${config.get().apiUrl}/token?${new URLSearchParams({ + chain, + token, + } as Record)}`, + { + signal: options?.signal, + } + ) } /** @@ -85,8 +84,10 @@ export const getQuote = async ( ] requiredParameters.forEach((requiredParameter) => { if (!params[requiredParameter]) { - throw new ValidationError( - `Required parameter "${requiredParameter}" is missing.` + throw new SDKError( + new ValidationError( + `Required parameter "${requiredParameter}" is missing.` + ) ) } }) @@ -110,19 +111,14 @@ export const getQuote = async ( delete params[key as keyof QuoteRequest] ) - try { - const response = await request( - `${_config.apiUrl}/quote?${new URLSearchParams( - params as unknown as Record - )}`, - { - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request( + `${_config.apiUrl}/quote?${new URLSearchParams( + params as unknown as Record + )}`, + { + signal: options?.signal, + } + ) } /** @@ -148,8 +144,10 @@ export const getContractCallsQuote = async ( ] requiredParameters.forEach((requiredParameter) => { if (!params[requiredParameter]) { - throw new ValidationError( - `Required parameter "${requiredParameter}" is missing.` + throw new SDKError( + new ValidationError( + `Required parameter "${requiredParameter}" is missing.` + ) ) } }) @@ -167,22 +165,14 @@ export const getContractCallsQuote = async ( params.denyExchanges ??= _config.routeOptions?.exchanges?.deny params.preferExchanges ??= _config.routeOptions?.exchanges?.prefer // send request - try { - const response = await request( - `${_config.apiUrl}/quote/contractCalls`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request(`${_config.apiUrl}/quote/contractCalls`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + signal: options?.signal, + }) } /** @@ -197,22 +187,19 @@ export const getStatus = async ( options?: RequestOptions ): Promise => { if (!params.txHash) { - throw new ValidationError('Required parameter "txHash" is missing.') + throw new SDKError( + new ValidationError('Required parameter "txHash" is missing.') + ) } const queryParams = new URLSearchParams( params as unknown as Record ) - try { - const response = await request( - `${config.get().apiUrl}/status?${queryParams}`, - { - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request( + `${config.get().apiUrl}/status?${queryParams}`, + { + signal: options?.signal, + } + ) } /** @@ -233,19 +220,16 @@ export const getChains = async ( delete params[key as keyof ChainsRequest] ) } - try { - const response = await request( - `${config.get().apiUrl}/chains?${new URLSearchParams( - params as Record - )}`, - { - signal: options?.signal, - } - ) - return response.chains - } catch (e) { - throw await parseBackendError(e) - } + + const response = await request( + `${config.get().apiUrl}/chains?${new URLSearchParams( + params as Record + )}`, + { + signal: options?.signal, + } + ) + return response.chains } /** @@ -260,7 +244,7 @@ export const getRoutes = async ( options?: RequestOptions ): Promise => { if (!isRoutesRequest(params)) { - throw new ValidationError('Invalid routes request.') + throw new SDKError(new ValidationError('Invalid routes request.')) } const _config = config.get() // apply defaults @@ -269,23 +253,15 @@ export const getRoutes = async ( ..._config.routeOptions, ...params.options, } - // send request - try { - const response = await request( - `${_config.apiUrl}/advanced/routes`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + + return await request(`${_config.apiUrl}/advanced/routes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + signal: options?.signal, + }) } /** @@ -304,22 +280,18 @@ export const getStepTransaction = async ( // eslint-disable-next-line no-console console.warn('SDK Validation: Invalid Step', step) } - try { - const response = await request( - `${config.get().apiUrl}/advanced/stepTransaction`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(step), - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + + return await request( + `${config.get().apiUrl}/advanced/stepTransaction`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(step), + signal: options?.signal, + } + ) } /** @@ -339,19 +311,14 @@ export const getTools = async ( delete params[key as keyof ToolsRequest] ) } - try { - const response = await request( - `${config.get().apiUrl}/tools?${new URLSearchParams( - params as Record - )}`, - { - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request( + `${config.get().apiUrl}/tools?${new URLSearchParams( + params as Record + )}`, + { + signal: options?.signal, + } + ) } /** @@ -372,19 +339,14 @@ export const getTokens = async ( ) } - try { - const response = await request( - `${config.get().apiUrl}/tokens?${new URLSearchParams( - params as Record - )}`, - { - signal: options?.signal, - } - ) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request( + `${config.get().apiUrl}/tokens?${new URLSearchParams( + params as Record + )}`, + { + signal: options?.signal, + } + ) } /** @@ -399,7 +361,9 @@ export const getGasRecommendation = async ( options?: RequestOptions ): Promise => { if (!params.chainId) { - throw new ValidationError('Required parameter "chainId" is missing.') + throw new SDKError( + new ValidationError('Required parameter "chainId" is missing.') + ) } const url = new URL(`${config.get().apiUrl}/gas/suggestion/${params.chainId}`) @@ -410,14 +374,9 @@ export const getGasRecommendation = async ( url.searchParams.append('fromToken', params.fromToken) } - try { - const response = await request(url.toString(), { - signal: options?.signal, - }) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request(url.toString(), { + signal: options?.signal, + }) } /** @@ -465,12 +424,8 @@ export const getConnections = async ( }) } }) - try { - const response = await request(url, options) - return response - } catch (e) { - throw await parseBackendError(e) - } + + return await request(url, options) } export const getTransactionHistory = async ( @@ -478,7 +433,9 @@ export const getTransactionHistory = async ( options?: RequestOptions ): Promise => { if (!wallet) { - throw new ValidationError('Required parameter "wallet" is missing.') + throw new SDKError( + new ValidationError('Required parameter "wallet" is missing.') + ) } const _config = config.get() @@ -500,10 +457,5 @@ export const getTransactionHistory = async ( url.searchParams.append('toTimestamp', toTimestamp.toString()) } - try { - const response = await request(url, options) - return response - } catch (e) { - throw await parseBackendError(e) - } + return await request(url, options) } diff --git a/src/services/api.unit.handlers.ts b/src/services/api.unit.handlers.ts index fa3c6384..1d814ae5 100644 --- a/src/services/api.unit.handlers.ts +++ b/src/services/api.unit.handlers.ts @@ -6,11 +6,7 @@ import { config } from '../config.js' const _config = config.get() export const handlers = [ - http.post(`${_config.apiUrl}/advanced/routes`, async ({ request }) => { - const data = (await request.json()) as any - if (isNaN(parseFloat(data?.fromAmount))) { - return HttpResponse.json({ message: 'Oops' }, { status: 500 }) - } + http.post(`${_config.apiUrl}/advanced/routes`, async () => { return HttpResponse.json({}) }), http.post(`${_config.apiUrl}/advanced/possibilities`, async () => diff --git a/src/services/api.unit.spec.ts b/src/services/api.unit.spec.ts index 0c889bf2..d8931127 100644 --- a/src/services/api.unit.spec.ts +++ b/src/services/api.unit.spec.ts @@ -26,9 +26,10 @@ import { findDefaultToken } from '../../tests/tokens.js' import { config } from '../config.js' import * as request from '../request.js' import { requestSettings } from '../request.js' -import { ServerError, ValidationError } from '../utils/errors.js' +import { ValidationError } from '../errors/errors.js' import * as ApiService from './api.js' import { handlers } from './api.unit.handlers.js' +import { SDKError } from '../utils/index.js' const mockedFetch = vi.spyOn(request, 'request') @@ -143,16 +144,6 @@ describe('ApiService', () => { }) describe('user input is valid', () => { - describe('and the backend call fails', () => { - it('throw a the error', async () => { - const request = getRoutesRequest({ fromAmount: 'failed' }) - await expect(ApiService.getRoutes(request)).rejects.toThrowError( - new ServerError('Oops') - ) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { const request = getRoutesRequest({}) @@ -169,35 +160,24 @@ describe('ApiService', () => { await expect( ApiService.getToken(undefined as unknown as ChainId, 'DAI') ).rejects.toThrowError( - new ValidationError('Required parameter "chain" is missing.') + new SDKError( + new ValidationError('Required parameter "chain" is missing.') + ) ) expect(mockedFetch).toHaveBeenCalledTimes(0) await expect( ApiService.getToken(ChainId.ETH, undefined as unknown as string) ).rejects.toThrowError( - new ValidationError('Required parameter "token" is missing.') + new SDKError( + new ValidationError('Required parameter "token" is missing.') + ) ) expect(mockedFetch).toHaveBeenCalledTimes(0) }) }) describe('user input is valid', () => { - describe('and the backend call fails', () => { - it('throw an error', async () => { - server.use( - http.get(`${_config.apiUrl}/token`, async () => - HttpResponse.json({ message: 'Oops' }, { status: 500 }) - ) - ) - - await expect( - ApiService.getToken(ChainId.DAI, 'DAI') - ).rejects.toThrowError(new ServerError('Oops')) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { await ApiService.getToken(ChainId.DAI, 'DAI') @@ -228,7 +208,9 @@ describe('ApiService', () => { toToken, }) ).rejects.toThrowError( - new ValidationError('Required parameter "fromChain" is missing.') + new SDKError( + new ValidationError('Required parameter "fromChain" is missing.') + ) ) await expect( @@ -241,7 +223,9 @@ describe('ApiService', () => { toToken, }) ).rejects.toThrowError( - new ValidationError('Required parameter "fromToken" is missing.') + new SDKError( + new ValidationError('Required parameter "fromToken" is missing.') + ) ) await expect( @@ -254,7 +238,9 @@ describe('ApiService', () => { toToken, }) ).rejects.toThrowError( - new ValidationError('Required parameter "fromAddress" is missing.') + new SDKError( + new ValidationError('Required parameter "fromAddress" is missing.') + ) ) await expect( @@ -267,7 +253,9 @@ describe('ApiService', () => { toToken, }) ).rejects.toThrowError( - new ValidationError('Required parameter "fromAmount" is missing.') + new SDKError( + new ValidationError('Required parameter "fromAmount" is missing.') + ) ) await expect( @@ -280,7 +268,9 @@ describe('ApiService', () => { toToken, }) ).rejects.toThrowError( - new ValidationError('Required parameter "toChain" is missing.') + new SDKError( + new ValidationError('Required parameter "toChain" is missing.') + ) ) await expect( @@ -293,7 +283,9 @@ describe('ApiService', () => { toToken: undefined as unknown as string, }) ).rejects.toThrowError( - new ValidationError('Required parameter "toToken" is missing.') + new SDKError( + new ValidationError('Required parameter "toToken" is missing.') + ) ) expect(mockedFetch).toHaveBeenCalledTimes(0) @@ -301,28 +293,6 @@ describe('ApiService', () => { }) describe('user input is valid', () => { - describe('and the backend call fails', () => { - it('throw an error', async () => { - server.use( - http.get(`${_config.apiUrl}/quote`, async () => - HttpResponse.json({ message: 'Oops' }, { status: 500 }) - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken, - fromAddress, - fromAmount, - toChain, - toToken, - }) - ).rejects.toThrowError(new ServerError('Oops')) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { await ApiService.getQuote({ @@ -356,7 +326,9 @@ describe('ApiService', () => { txHash: undefined as unknown as string, }) ).rejects.toThrowError( - new ValidationError('Required parameter "txHash" is missing.') + new SDKError( + new ValidationError('Required parameter "txHash" is missing.') + ) ) expect(mockedFetch).toHaveBeenCalledTimes(0) @@ -364,21 +336,6 @@ describe('ApiService', () => { }) describe('user input is valid', () => { - describe('and the backend call fails', () => { - it('throw an error', async () => { - server.use( - http.get(`${_config.apiUrl}/status`, async () => - HttpResponse.json({ message: 'Oops' }, { status: 500 }) - ) - ) - - await expect( - ApiService.getStatus({ bridge, fromChain, toChain, txHash }) - ).rejects.toThrowError(new ServerError('Oops')) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { await ApiService.getStatus({ bridge, fromChain, toChain, txHash }) @@ -389,21 +346,6 @@ describe('ApiService', () => { }) describe('getChains', () => { - describe('and the backend call fails', () => { - it('throw an error', async () => { - server.use( - http.get(`${_config.apiUrl}/chains`, async () => - HttpResponse.json({ message: 'Oops' }, { status: 500 }) - ) - ) - - await expect(ApiService.getChains()).rejects.toThrowError( - new ServerError('Oops') - ) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { const chains = await ApiService.getChains() @@ -555,24 +497,6 @@ describe('ApiService', () => { }) describe('user input is valid', () => { - describe('and the backend call fails', () => { - it('throw a the error', async () => { - const step = getStep({}) - server.use( - http.post( - `${_config.apiUrl}/advanced/stepTransaction`, - async () => - HttpResponse.json({ message: 'Oops' }, { status: 500 }) - ) - ) - - await expect( - ApiService.getStepTransaction(step) - ).rejects.toThrowError(new ServerError('Oops')) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { const step = getStep({}) @@ -593,32 +517,15 @@ describe('ApiService', () => { chainId: undefined as unknown as number, }) ).rejects.toThrowError( - new ValidationError('Required parameter "chainId" is missing.') + new SDKError( + new ValidationError('Required parameter "chainId" is missing.') + ) ) expect(mockedFetch).toHaveBeenCalledTimes(0) }) }) describe('user input is valid', () => { - describe('and the backend call fails', () => { - it('throw an error', async () => { - server.use( - http.get( - `${_config.apiUrl}/gas/suggestion/${ChainId.OPT}`, - async () => - HttpResponse.json({ message: 'Oops' }, { status: 500 }) - ) - ) - - await expect( - ApiService.getGasRecommendation({ - chainId: ChainId.OPT, - }) - ).rejects.toThrowError(new ServerError('Oops')) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - describe('and the backend call is successful', () => { it('call the server once', async () => { await ApiService.getGasRecommendation({ diff --git a/src/services/balance.ts b/src/services/balance.ts index ea062330..542019ea 100644 --- a/src/services/balance.ts +++ b/src/services/balance.ts @@ -1,14 +1,14 @@ import type { Token, TokenAmount } from '@lifi/types' import { config } from '../config.js' import { isToken } from '../typeguards.js' -import { ValidationError } from '../utils/errors.js' +import { ValidationError } from '../errors/errors.js' /** * Returns the balances of a specific token a wallet holds across all aggregated chains. * @param walletAddress - A wallet address. * @param token - A Token object. * @returns An object containing the token and the amounts on different chains. - * @throws {ValidationError} Throws a ValidationError if parameters are invalid. + * @throws {BaseError} Throws a ValidationError if parameters are invalid. */ export const getTokenBalance = async ( walletAddress: string, @@ -23,7 +23,7 @@ export const getTokenBalance = async ( * @param walletAddress - A wallet address. * @param tokens - A list of Token objects. * @returns A list of objects containing the tokens and the amounts on different chains. - * @throws {ValidationError} Throws a ValidationError if parameters are invalid. + * @throws {BaseError} Throws a ValidationError if parameters are invalid. */ export const getTokenBalances = async ( walletAddress: string, @@ -53,7 +53,7 @@ export const getTokenBalances = async ( * @param walletAddress - A walletaddress. * @param tokensByChain - A list of Token objects organized by chain ids. * @returns A list of objects containing the tokens and the amounts on different chains organized by the chosen chains. - * @throws {ValidationError} Throws a ValidationError if parameters are invalid. + * @throws {BaseError} Throws a ValidationError if parameters are invalid. */ export const getTokenBalancesByChain = async ( walletAddress: string, diff --git a/src/types/request.ts b/src/types/request.ts new file mode 100644 index 00000000..bf697027 --- /dev/null +++ b/src/types/request.ts @@ -0,0 +1,3 @@ +export interface ExtendedRequestInit extends RequestInit { + retries?: number +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index da8d45da..e69de29b 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,233 +0,0 @@ -enum ErrorType { - RPCError = 'RPCError', - ProviderError = 'ProviderError', - ServerError = 'ServerError', - TransactionError = 'TransactionError', - ValidationError = 'ValidationError', - NotFoundError = 'NotFoundError', - UnknownError = 'UnknownError', - SlippageError = 'SlippageError', -} - -export enum LiFiErrorCode { - InternalError = 1000, - ValidationError = 1001, - TransactionUnderpriced = 1002, - TransactionFailed = 1003, - Timeout = 1004, - ProviderUnavailable = 1005, - NotFound = 1006, - ChainSwitchError = 1007, - TransactionUnprepared = 1008, - GasLimitError = 1009, - TransactionCanceled = 1010, - SlippageError = 1011, - SignatureRejected = 1012, - BalanceError = 1013, - AllowanceRequired = 1014, - InsufficientFunds = 1015, - ExchangeRateUpdateCanceled = 1016, - WalletChangedDuringExecution = 1017, - TransactionExpired = 1018, -} - -export enum EthersErrorType { - ActionRejected = 'ACTION_REJECTED', - CallExecption = 'CALL_EXCEPTION', - InsufficientFunds = 'INSUFFICIENT_FUNDS', -} - -export enum EthersErrorMessage { - ERC20Allowance = 'ERC20: transfer amount exceeds allowance', - LowGas = 'intrinsic gas too low', - OutOfGas = 'out of gas', - Underpriced = 'underpriced', - LowReplacementFee = 'replacement fee too low', -} - -export enum ErrorMessage { - UnknownError = 'Unknown error occurred.', - SlippageError = 'The slippage is larger than the defined threshold. Please request a new route to get a fresh quote.', - GasLimitLow = 'Gas limit is too low.', - TransactionUnderpriced = 'Transaction is underpriced.', - Default = 'Something went wrong.', -} - -export enum MetaMaskRPCErrorCode { - invalidInput = -32000, - resourceNotFound = -32001, - resourceUnavailable = -32002, - transactionRejected = -32003, - methodNotSupported = -32004, - limitExceeded = -32005, - parse = -32700, - invalidRequest = -32600, - methodNotFound = -32601, - invalidParams = -32602, - internal = -32603, -} - -export enum MetaMaskProviderErrorCode { - userRejectedRequest = 4001, - unauthorized = 4100, - unsupportedMethod = 4200, - disconnected = 4900, - chainDisconnected = 4901, -} - -export type ErrorCode = - | LiFiErrorCode - | MetaMaskRPCErrorCode - | MetaMaskProviderErrorCode - -export class LiFiError extends Error { - code: ErrorCode - htmlMessage?: string - - constructor( - type: ErrorType, - code: number, - message: string, - htmlMessage?: string, - stack?: string - ) { - super(message) - - // Set the prototype explicitly: https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work - Object.setPrototypeOf(this, LiFiError.prototype) - - this.code = code - - // the name property is used by toString(). It is a string and we can't use our custom ErrorTypes, that's why we have to cast - this.name = type.toString() - - this.htmlMessage = htmlMessage - - // passing a stack allows us to preserve the stack from errors that we caught and just want to transform in one of our custom errors - if (stack) { - this.stack = stack - } - } -} - -export class RPCError extends LiFiError { - constructor( - code: ErrorCode, - message: string, - htmlMessage?: string, - stack?: string - ) { - super(ErrorType.RPCError, code, message, htmlMessage, stack) - } -} - -export class ProviderError extends LiFiError { - constructor( - code: ErrorCode, - message: string, - htmlMessage?: string, - stack?: string - ) { - super(ErrorType.ProviderError, code, message, htmlMessage, stack) - } -} - -export class ServerError extends LiFiError { - constructor(message: string, htmlMessage?: string, stack?: string) { - super( - ErrorType.ServerError, - LiFiErrorCode.InternalError, - message, - htmlMessage, - stack - ) - } -} - -export class ValidationError extends LiFiError { - constructor(message: string, htmlMessage?: string, stack?: string) { - super( - ErrorType.ValidationError, - LiFiErrorCode.ValidationError, - message, - htmlMessage, - stack - ) - } -} - -export class TransactionError extends LiFiError { - constructor( - code: ErrorCode, - message: string, - htmlMessage?: string, - stack?: string - ) { - super(ErrorType.TransactionError, code, message, htmlMessage, stack) - } -} - -export class SlippageError extends LiFiError { - constructor(message: string, htmlMessage?: string, stack?: string) { - super( - ErrorType.SlippageError, - LiFiErrorCode.SlippageError, - message, - htmlMessage, - stack - ) - } -} - -export class BalanceError extends LiFiError { - constructor(message: string, htmlMessage?: string, stack?: string) { - super( - ErrorType.ValidationError, - LiFiErrorCode.BalanceError, - message, - htmlMessage, - stack - ) - } -} - -export class NotFoundError extends LiFiError { - constructor(message: string, htmlMessage?: string, stack?: string) { - super( - ErrorType.NotFoundError, - LiFiErrorCode.NotFound, - message, - htmlMessage, - stack - ) - } -} - -export class UnknownError extends LiFiError { - constructor( - code: ErrorCode, - message: string, - htmlMessage?: string, - stack?: string - ) { - super(ErrorType.UnknownError, code, message, htmlMessage, stack) - } -} - -export class HTTPError extends Error { - public response: Response - public status: number - - constructor(response: Response) { - const code = response.status || response.status === 0 ? response.status : '' - const title = response.statusText || '' - const status = `${code} ${title}`.trim() - const reason = status ? `status code ${status}` : 'an unknown error' - - super(`Request failed with ${reason}`) - - this.name = 'HTTPError' - this.response = response - this.status = response.status - } -} diff --git a/src/utils/index.ts b/src/utils/index.ts index c4e5a6ab..8d623951 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,4 @@ -export * from './errors.js' +export * from '../errors/index.js' export * from './getTransactionMessage.js' export * from './median.js' -export * from './parseError.js' export * from './utils.js' diff --git a/src/utils/parseBackendError.ts b/src/utils/parseBackendError.ts deleted file mode 100644 index 6ea009b1..00000000 --- a/src/utils/parseBackendError.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { LiFiError } from './errors.js' -import { - ErrorMessage, - NotFoundError, - ServerError, - SlippageError, - ValidationError, -} from './errors.js' - -export const parseBackendError = async (e: any): Promise => { - let data - try { - data = await e.response?.json() - } catch (error) { - // ignore - } - if (e.response?.status === 400) { - return new ValidationError( - data?.message || e.response?.statusText, - undefined, - e.stack - ) - } - - if (e.response?.status === 404) { - return new NotFoundError( - data?.message || e.response?.statusText, - undefined, - e.stack - ) - } - - if (e.response?.status === 409) { - return new SlippageError( - data?.message || e.response?.statusText, - ErrorMessage.SlippageError, - e.stack - ) - } - - if (e.response?.status === 500) { - return new ServerError( - data?.message || e.response?.statusText, - undefined, - e.stack - ) - } - - return new ServerError(ErrorMessage.Default, undefined, e.stack) -} diff --git a/src/utils/parseError.ts b/src/utils/parseError.ts deleted file mode 100644 index 4f459e5d..00000000 --- a/src/utils/parseError.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { LiFiStep, Process } from '@lifi/types' -import { - errorCodes as MetaMaskErrorCodes, - getMessageFromCode, -} from 'eth-rpc-errors' -import { fetchTxErrorDetails } from '../helpers.js' -import { - ErrorMessage, - EthersErrorMessage, - EthersErrorType, - LiFiError, - LiFiErrorCode, - MetaMaskProviderErrorCode, - ProviderError, - RPCError, - TransactionError, - UnknownError, -} from './errors.js' -import { getTransactionNotSentMessage } from './getTransactionMessage.js' - -/** - * Available MetaMask error codes: - * - * export const errorCodes: ErrorCodes = { - rpc: { - invalidInput: -32000, - resourceNotFound: -32001, - resourceUnavailable: -32002, - transactionRejected: -32003, - methodNotSupported: -32004, - limitExceeded: -32005, - parse: -32700, - invalidRequest: -32600, - methodNotFound: -32601, - invalidParams: -32602, - internal: -32603, - }, - provider: { - userRejectedRequest: 4001, - unauthorized: 4100, - unsupportedMethod: 4200, - disconnected: 4900, - chainDisconnected: 4901, - }, - }; - * - * For more information about error codes supported by metamask check - * https://github.com/MetaMask/eth-rpc-errors - * https://eips.ethereum.org/EIPS/eip-1474#error-codes - * https://eips.ethereum.org/EIPS/eip-1193#provider-errors - */ - -export const parseError = async ( - e: any, - step?: LiFiStep, - process?: Process -): Promise => { - if (e instanceof LiFiError) { - return e - } - - const errorCode = e.code || e.cause?.code - - switch (errorCode) { - case EthersErrorType.CallExecption: - const defaultErrorMessage = await getTransactionNotSentMessage( - step, - process - ) - try { - if (!step?.action.fromChainId) { - throw new Error('fromChainId is not defined.') - } - - const response = await fetchTxErrorDetails( - e.transactionHash, - step?.action.fromChainId - ) - - const errorMessage = response?.error_message ?? e.reason - - const isAllowanceError = - response?.error_message?.includes( - EthersErrorMessage.ERC20Allowance - ) || e.reason?.includes(EthersErrorMessage.ERC20Allowance) - - if (isAllowanceError) { - return new TransactionError( - LiFiErrorCode.AllowanceRequired, - e.reason, - errorMessage, - e.stack - ) - } - - // Error messages other than allowance error will be handled in catch block - throw new Error(e) - } catch (error) { - return new ProviderError( - LiFiErrorCode.TransactionFailed, - e.reason, - defaultErrorMessage, - e.stack - ) - } - case EthersErrorType.InsufficientFunds: - return new TransactionError( - LiFiErrorCode.InsufficientFunds, - e.message, - await getTransactionNotSentMessage(step, process), - e.stack - ) - case EthersErrorType.ActionRejected: - case MetaMaskProviderErrorCode.userRejectedRequest: - return new TransactionError( - LiFiErrorCode.SignatureRejected, - e.message, - await getTransactionNotSentMessage(step, process), - e.stack - ) - case LiFiErrorCode.TransactionUnprepared: - return new TransactionError( - LiFiErrorCode.TransactionUnprepared, - e.message, - await getTransactionNotSentMessage(step, process), - e.stack - ) - case LiFiErrorCode.ValidationError: - return new TransactionError( - LiFiErrorCode.ValidationError, - e.message, - e.htmlMessage - ) - case LiFiErrorCode.TransactionCanceled: - return new TransactionError( - LiFiErrorCode.TransactionCanceled, - e.message, - e.htmlMessage - ) - case LiFiErrorCode.ExchangeRateUpdateCanceled: - return new TransactionError( - LiFiErrorCode.ExchangeRateUpdateCanceled, - e.message, - e.htmlMessage - ) - case LiFiErrorCode.WalletChangedDuringExecution: - return new TransactionError( - LiFiErrorCode.WalletChangedDuringExecution, - e.message, - e.htmlMessage - ) - default: { - if (errorCode && typeof errorCode === 'number') { - if (Object.values(MetaMaskErrorCodes.rpc).includes(errorCode as any)) { - // rpc errors - // underpriced errors are sent as internal errors, so we need to parse the message manually - if ( - errorCode === MetaMaskErrorCodes.rpc.internal && - (e.message?.includes(EthersErrorMessage.Underpriced) || - e.message?.includes(EthersErrorMessage.LowReplacementFee)) - ) { - return new RPCError( - LiFiErrorCode.TransactionUnderpriced, - ErrorMessage.TransactionUnderpriced, - await getTransactionNotSentMessage(step, process), - e.stack - ) - } - - if ( - e.message?.includes(EthersErrorMessage.LowGas) || - e.message?.includes(EthersErrorMessage.OutOfGas) - ) { - return new TransactionError( - LiFiErrorCode.GasLimitError, - ErrorMessage.GasLimitLow, - await getTransactionNotSentMessage(step, process), - e.stack - ) - } - - return new RPCError( - errorCode, - getMessageFromCode(errorCode), - await getTransactionNotSentMessage(step, process), - e.stack - ) - } - - // provider errors - if ( - Object.values(MetaMaskErrorCodes.provider).includes(errorCode as any) - ) { - return new ProviderError( - errorCode, - getMessageFromCode(errorCode), - await getTransactionNotSentMessage(step, process), - e.stack - ) - } - } - return new UnknownError( - LiFiErrorCode.InternalError, - e.message || ErrorMessage.UnknownError, - undefined, - e.stack - ) - } - } -} diff --git a/src/utils/parseError.unit.spec.ts b/src/utils/parseError.unit.spec.ts deleted file mode 100644 index d35f7cb9..00000000 --- a/src/utils/parseError.unit.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - errorCodes as MetaMaskErrorCodes, - getMessageFromCode, -} from 'eth-rpc-errors' -import { beforeAll, describe, expect, it } from 'vitest' -import { buildStepObject } from '../../tests/fixtures.js' -import { setupTestEnvironment } from '../../tests/setup.js' -import { LiFiErrorCode } from './errors.js' -import { parseBackendError } from './parseBackendError.js' -import { parseError } from './parseError.js' - -beforeAll(setupTestEnvironment) - -describe('parseError', () => { - describe('parseWalletError', () => { - describe('when the error does not contain a code', () => { - it('should return an UnknownError with the default message if no message is set', async () => { - const parsedError = await parseError('Oops') - - expect(parsedError.message).toEqual('Unknown error occurred.') - expect(parsedError.code).toEqual(LiFiErrorCode.InternalError) - }) - - it('should return an UnknownError with the given message', async () => { - const parsedError = await parseError({ - message: 'Somethings fishy', - }) - - expect(parsedError.message).toEqual('Somethings fishy') - expect(parsedError.code).toEqual(LiFiErrorCode.InternalError) - }) - }) - - describe('when the error contains an unknown error code', () => { - it('should return an UnknownError', async () => { - const parsedError = await parseError({ - code: 1337, - message: 'Somethings fishy', - }) - - expect(parsedError.message).toEqual('Somethings fishy') - expect(parsedError.code).toEqual(LiFiErrorCode.InternalError) - }) - }) - - describe('when the error contains a rpc error code', () => { - it('should return a RPCError with the metamask error message', async () => { - const parsedError = await parseError({ - code: MetaMaskErrorCodes.rpc.methodNotFound, - message: 'Somethings fishy', - }) - - expect(parsedError.message).toEqual( - getMessageFromCode(MetaMaskErrorCodes.rpc.methodNotFound) - ) - expect(parsedError.code).toEqual(MetaMaskErrorCodes.rpc.methodNotFound) - }) - - it('should return a RPCError with a custom message if underpriced', async () => { - const parsedError = await parseError({ - code: MetaMaskErrorCodes.rpc.internal, - message: 'RPC called failed: transaction underpriced', - }) - - expect(parsedError.message).toEqual('Transaction is underpriced.') - expect(parsedError.code).toEqual(LiFiErrorCode.TransactionUnderpriced) - }) - }) - - describe('when the error contains a provider error code', () => { - it('should return a ProviderError with the metamask error message', async () => { - const parsedError = await parseError({ - code: MetaMaskErrorCodes.provider.unsupportedMethod, - message: 'Somethings fishy', - }) - - expect(parsedError.message).toEqual( - getMessageFromCode(MetaMaskErrorCodes.provider.unsupportedMethod) - ) - expect(parsedError.code).toEqual( - MetaMaskErrorCodes.provider.unsupportedMethod - ) - }) - }) - - describe('when no step is passed to the parser', () => { - it('should return a default htmlMessage', async () => { - const parsedError = await parseError({ - code: MetaMaskErrorCodes.rpc.methodNotFound, - message: 'Somethings fishy', - }) - - expect(parsedError.htmlMessage).toEqual( - // eslint-disable-next-line max-len - "Transaction was not sent, your funds are still in your wallet, please retry.
If it still doesn't work, it is safe to delete this transfer and start a new one." - ) - }) - }) - - describe('when a step is passed to the parser', () => { - it('should include the token information in the htmlMessage', async () => { - const parsedError = await parseError( - { - code: MetaMaskErrorCodes.rpc.methodNotFound, - message: 'Somethings fishy', - }, - buildStepObject({}) - ) - - expect(parsedError.htmlMessage).toEqual( - // eslint-disable-next-line max-len - "Transaction was not sent, your funds are still in your wallet (1.5 USDC on Polygon), please retry.
If it still doesn't work, it is safe to delete this transfer and start a new one." - ) - }) - }) - - describe('when a process is passed to the parser', () => { - it('should include the explorer link in the htmlMessage', async () => { - const step = buildStepObject({ includingExecution: true }) - const parsedError = await parseError( - { - code: MetaMaskErrorCodes.rpc.methodNotFound, - message: 'Somethings fishy', - }, - step, - step.execution?.process[0] - ) - - expect(parsedError.htmlMessage).toEqual( - // eslint-disable-next-line max-len - 'Transaction was not sent, your funds are still in your wallet (1.5 USDC on Polygon), please retry.
If it still doesn\'t work, it is safe to delete this transfer and start a new one.
You can check the failed transaction here.' - ) - }) - }) - }) - - describe('parseBackendError', () => { - describe("when the error doesn't contain a status", () => { - it('should return a ServerError with a default messsage', async () => { - const parsedError = await parseBackendError('Oops') - - expect(parsedError.message).toEqual('Something went wrong.') - expect(parsedError.code).toEqual(LiFiErrorCode.InternalError) - }) - }) - - describe('when the error contains a status', () => { - describe('when the status is 400', () => { - it('should return the error message if set', async () => { - const parsedError = await parseBackendError({ - response: { - status: 400, - json: () => Promise.resolve({ message: 'Oops' }), - }, - }) - - expect(parsedError.message).toEqual('Oops') - expect(parsedError.code).toEqual(LiFiErrorCode.ValidationError) - }) - - it('should return the statusText if message not set', async () => { - const parsedError = await parseBackendError({ - response: { - status: 400, - statusText: 'Request failed with statusCode 400', - }, - }) - - expect(parsedError.message).toEqual( - 'Request failed with statusCode 400' - ) - expect(parsedError.code).toEqual(LiFiErrorCode.ValidationError) - }) - }) - - describe('when the status is 500', () => { - it('should return the error message if set', async () => { - const parsedError = await parseBackendError({ - response: { - status: 500, - json: () => Promise.resolve({ message: 'Oops' }), - }, - }) - - expect(parsedError.message).toEqual('Oops') - expect(parsedError.code).toEqual(LiFiErrorCode.InternalError) - }) - - it('should return the statusText if message not set', async () => { - const parsedError = await parseBackendError({ - response: { - status: 500, - statusText: 'Request failed with statusCode 500', - }, - }) - - expect(parsedError.message).toEqual( - 'Request failed with statusCode 500' - ) - expect(parsedError.code).toEqual(LiFiErrorCode.InternalError) - }) - }) - }) - }) -})