diff --git a/api/_errors.ts b/api/_errors.ts new file mode 100644 index 000000000..8076aa59a --- /dev/null +++ b/api/_errors.ts @@ -0,0 +1,284 @@ +import type { VercelResponse } from "@vercel/node"; +import { AxiosError } from "axios"; +import { StructError } from "superstruct"; +import { relayFeeCalculator, typeguards } from "@across-protocol/sdk"; +import { ethers } from "ethers"; + +type AcrossApiErrorCodeKey = keyof typeof AcrossErrorCode; + +export const HttpErrorToStatusCode = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, + REQUEST_TIMEOUT: 408, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, +} as const; + +export const AcrossErrorCode = { + // Status: 40X + INVALID_PARAM: "INVALID_PARAM", + MISSING_PARAM: "MISSING_PARAM", + SIMULATION_ERROR: "SIMULATION_ERROR", + AMOUNT_TOO_LOW: "AMOUNT_TOO_LOW", + AMOUNT_TOO_HIGH: "AMOUNT_TOO_HIGH", + ROUTE_NOT_ENABLED: "ROUTE_NOT_ENABLED", + + // Status: 50X + UPSTREAM_RPC_ERROR: "UPSTREAM_RPC_ERROR", + UPSTREAM_HTTP_ERROR: "UPSTREAM_HTTP_ERROR", +} as const; + +export class AcrossApiError extends Error { + code?: AcrossApiErrorCodeKey; + status: number; + message: string; + param?: string; + + constructor( + args: { + code?: AcrossApiErrorCodeKey; + status: number; + message: string; + param?: string; + }, + opts?: ErrorOptions + ) { + super(args.message, opts); + this.code = args.code; + this.status = args.status; + this.message = args.message; + this.param = args.param; + } + + toJSON() { + return { + type: "AcrossApiError", + code: this.code, + status: this.status, + message: this.message, + param: this.param, + }; + } +} + +export class InputError extends AcrossApiError { + constructor( + args: { + message: string; + code: AcrossApiErrorCodeKey; + param?: string; + }, + opts?: ErrorOptions + ) { + super( + { + ...args, + status: HttpErrorToStatusCode.BAD_REQUEST, + }, + opts + ); + } +} + +export class InvalidParamError extends InputError { + constructor(args: { message: string; param?: string }) { + super({ + message: args.message, + code: AcrossErrorCode.INVALID_PARAM, + param: args.param, + }); + } +} + +export class MissingParamError extends InputError { + constructor(args: { message: string; param?: string }) { + super({ + message: args.message, + code: AcrossErrorCode.MISSING_PARAM, + param: args.param, + }); + } +} + +export class SimulationError extends InputError { + constructor(args: { message: string }, opts?: ErrorOptions) { + super( + { + message: args.message, + code: AcrossErrorCode.SIMULATION_ERROR, + }, + opts + ); + } +} + +export class RouteNotEnabledError extends InputError { + constructor(args: { message: string }, opts?: ErrorOptions) { + super( + { + message: args.message, + code: AcrossErrorCode.ROUTE_NOT_ENABLED, + }, + opts + ); + } +} + +export class AmountTooLowError extends InputError { + constructor(args: { message: string }, opts?: ErrorOptions) { + super( + { + message: args.message, + code: AcrossErrorCode.AMOUNT_TOO_LOW, + }, + opts + ); + } +} + +export class AmountTooHighError extends InputError { + constructor(args: { message: string }, opts?: ErrorOptions) { + super( + { + message: args.message, + code: AcrossErrorCode.AMOUNT_TOO_HIGH, + }, + opts + ); + } +} + +/** + * Handles the recurring case of error handling + * @param endpoint A string numeric to indicate to the logging utility where this error occurs + * @param response A VercelResponse object that is used to interract with the returning reponse + * @param logger A logging utility to write to a cloud logging provider + * @param error The error that will be returned to the user + * @returns The `response` input with a status/send sent. Note: using this object again will cause an exception + */ +export function handleErrorCondition( + endpoint: string, + response: VercelResponse, + logger: relayFeeCalculator.Logger, + error: unknown +): VercelResponse { + let acrossApiError: AcrossApiError; + + // Handle superstruct validation errors + if (error instanceof StructError) { + const { type, path } = error; + // Sanitize the error message that will be sent to client + const message = `Invalid parameter at path '${path}'. Expected type '${type}'`; + acrossApiError = new InputError({ + message, + code: AcrossErrorCode.INVALID_PARAM, + param: path.join("."), + }); + } + // Handle axios errors + else if (error instanceof AxiosError) { + const { response } = error; + + // If upstream error is an AcrossApiError, we just return it + if (response?.data?.type === "AcrossApiError") { + acrossApiError = new AcrossApiError( + { + message: response.data.message, + status: response.data.status, + code: response.data.code, + param: response.data.param, + }, + { cause: error } + ); + } else { + const message = `Upstream http request to ${error.request?.url} failed with ${error.status} ${error.message}`; + acrossApiError = new AcrossApiError( + { + message, + status: HttpErrorToStatusCode.BAD_GATEWAY, + code: AcrossErrorCode.UPSTREAM_HTTP_ERROR, + }, + { cause: error } + ); + } + } + // Handle ethers errors + else if (typeguards.isEthersError(error)) { + acrossApiError = resolveEthersError(error); + } + // Rethrow instances of `AcrossApiError` + else if (error instanceof AcrossApiError) { + acrossApiError = error; + } + // Handle other errors + else { + acrossApiError = new AcrossApiError( + { + message: (error as Error).message, + status: HttpErrorToStatusCode.INTERNAL_SERVER_ERROR, + }, + { cause: error } + ); + } + + const logLevel = acrossApiError.status >= 500 ? "error" : "warn"; + logger[logLevel]({ + at: endpoint, + message: `Status ${acrossApiError.status} - ${acrossApiError.message}`, + }); + + return response.status(acrossApiError.status).json(acrossApiError); +} + +export function resolveEthersError(err: unknown) { + if (!typeguards.isEthersError(err)) { + return new AcrossApiError( + { + message: err instanceof Error ? err.message : "Unknown error", + status: HttpErrorToStatusCode.INTERNAL_SERVER_ERROR, + code: AcrossErrorCode.UPSTREAM_RPC_ERROR, + }, + { cause: err } + ); + } + + const { reason, code } = err; + const method = "method" in err ? (err.method as string) : undefined; + + // Simulation errors + if ( + method === "estimateGas" && + (code === ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT || + code === ethers.utils.Logger.errors.CALL_EXCEPTION) + ) { + const rpcErrorMessageMatch = reason + .replace(/\\/g, "") + .match(/"message":"((?:[^"\\]|\\.)*)"/); + const rpcErrorMessage = rpcErrorMessageMatch + ? rpcErrorMessageMatch[1] + : reason; + + return new SimulationError( + { + message: rpcErrorMessage, + }, + { cause: err } + ); + } + + return new AcrossApiError( + { + message: `${err.reason}: ${err.code} - ${err.error}`, + status: HttpErrorToStatusCode.INTERNAL_SERVER_ERROR, + code: AcrossErrorCode.UPSTREAM_RPC_ERROR, + }, + { cause: err } + ); +} diff --git a/api/_utils.ts b/api/_utils.ts index b0be0fb3e..3c4e4dd71 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -23,7 +23,7 @@ import { utils, Signer, } from "ethers"; -import { StructError, define } from "superstruct"; +import { define } from "superstruct"; import enabledMainnetRoutesAsJson from "../src/data/routes_1_0xc186fA914353c44b2E33eBE05f21846F1048bEda.json"; import enabledSepoliaRoutesAsJson from "../src/data/routes_11155111_0x14224e63716afAcE30C9a417E0542281869f7d9e.json"; @@ -60,6 +60,15 @@ import { getCachedValue, makeCacheGetterAndSetter, } from "./_cache"; +import { + InputError, + MissingParamError, + InvalidParamError, + handleErrorCondition, + RouteNotEnabledError, +} from "./_errors"; + +export { InputError, handleErrorCondition } from "./_errors"; type LoggingUtility = sdk.relayFeeCalculator.Logger; type RpcProviderName = keyof typeof rpcProvidersJson.providers.urls; @@ -245,17 +254,22 @@ export const validateChainAndTokenParams = ( } = queryParams; if (!_destinationChainId) { - throw new InputError("Query param 'destinationChainId' must be provided"); + throw new MissingParamError({ + message: "Query param 'destinationChainId' must be provided", + }); } if (originChainId === _destinationChainId) { - throw new InputError("Origin and destination chains cannot be the same"); + throw new InvalidParamError({ + message: "Origin and destination chains cannot be the same", + }); } if (!token && (!inputTokenAddress || !outputTokenAddress)) { - throw new InputError( - "Query param 'token' or 'inputToken' and 'outputToken' must be provided" - ); + throw new MissingParamError({ + message: + "Query param 'token' or 'inputToken' and 'outputToken' must be provided", + }); } const destinationChainId = Number(_destinationChainId); @@ -284,7 +298,9 @@ export const validateChainAndTokenParams = ( outputToken.address ) ) { - throw new InputError(`Route is not enabled.`); + throw new RouteNotEnabledError({ + message: "Route is not enabled.", + }); } return { @@ -306,19 +322,26 @@ export const validateDepositMessage = async ( ) => { if (!sdk.utils.isMessageEmpty(message)) { if (!ethers.utils.isHexString(message)) { - throw new InputError("Message must be a hex string"); + throw new InvalidParamError({ + message: "Message must be a hex string", + param: "message", + }); } if (message.length % 2 !== 0) { // Our message encoding is a hex string, so we need to check that the length is even. - throw new InputError("Message must be an even hex string"); + throw new InvalidParamError({ + message: "Message must be an even hex string", + param: "message", + }); } const isRecipientAContract = getStaticIsContract(destinationChainId, recipient) || (await isContractCache(destinationChainId, recipient).get()); if (!isRecipientAContract) { - throw new InputError( - "Recipient must be a contract when a message is provided" - ); + throw new InvalidParamError({ + message: "Recipient must be a contract when a message is provided", + param: "recipient", + }); } else { // If we're in this case, it's likely that we're going to have to simulate the execution of // a complex message handling from the specified relayer to the specified recipient by calling @@ -332,10 +355,12 @@ export const validateDepositMessage = async ( outputTokenAddress ); if (balanceOfToken.lt(amountInput)) { - throw new InputError( - `Relayer Address (${relayer}) doesn't have enough funds to support this deposit;` + - ` for help, please reach out to https://discord.across.to` - ); + throw new InvalidParamError({ + message: + `Relayer Address (${relayer}) doesn't have enough funds to support this deposit;` + + ` for help, please reach out to https://discord.across.to`, + param: "relayer", + }); } } } @@ -379,11 +404,12 @@ export const getRouteDetails = ( const inputToken = getTokenByAddress(inputTokenAddress, originChainId); if (!inputToken) { - throw new InputError( - originChainId + throw new InvalidParamError({ + message: originChainId ? "Unsupported token on given origin chain" - : "Unsupported token address" - ); + : "Unsupported token address", + param: "inputTokenAddress", + }); } const l1TokenAddress = @@ -392,7 +418,10 @@ export const getRouteDetails = ( const l1Token = getTokenByAddress(l1TokenAddress, HUB_POOL_CHAIN_ID); if (!l1Token) { - throw new InputError("No L1 token found for given input token address"); + throw new InvalidParamError({ + message: "No L1 token found for given input token address", + param: "inputTokenAddress", + }); } outputTokenAddress ??= @@ -404,9 +433,10 @@ export const getRouteDetails = ( : undefined; if (!outputToken) { - throw new InputError( - "Unsupported token address on given destination chain" - ); + throw new InvalidParamError({ + message: "Unsupported token address on given destination chain", + param: "outputTokenAddress", + }); } const possibleOriginChainIds = originChainId @@ -414,13 +444,18 @@ export const getRouteDetails = ( : _getChainIdsOfToken(inputTokenAddress, inputToken); if (possibleOriginChainIds.length === 0) { - throw new InputError("Unsupported token address"); + throw new InvalidParamError({ + message: "Unsupported token address", + param: "inputTokenAddress", + }); } if (possibleOriginChainIds.length > 1) { - throw new InputError( - "More than one route is enabled for the provided inputs causing ambiguity. Please specify the originChainId." - ); + throw new InvalidParamError({ + message: + "More than one route is enabled for the provided inputs causing ambiguity. Please specify the originChainId.", + param: "inputTokenAddress", + }); } const resolvedOriginChainId = possibleOriginChainIds[0]; @@ -498,7 +533,7 @@ const _getChainIdsOfToken = ( const _getBridgedUsdcTokenSymbol = (tokenSymbol: string, chainId: number) => { if (!sdk.utils.isBridgedUsdc(tokenSymbol)) { - throw new InputError(`Token ${tokenSymbol} is not a bridged USDC token`); + throw new Error(`Token ${tokenSymbol} is not a bridged USDC token`); } switch (chainId) { @@ -515,12 +550,13 @@ const _getAddressOrThrowInputError = (address: string, paramName: string) => { try { return ethers.utils.getAddress(address); } catch (err) { - throw new InputError(`Invalid address provided for '${paramName}'`); + throw new InvalidParamError({ + message: `Invalid address provided for '${paramName}'`, + param: paramName, + }); } }; -export class InputError extends Error {} - export const getHubPool = (provider: providers.Provider) => { return HubPool__factory.connect(ENABLED_ROUTES.hubPoolAddress, provider); }; @@ -641,22 +677,6 @@ const getRelayerFeeCalculatorQueries = ( ); }; -/** - * Resolves a tokenAddress to a given textual symbol - * @param tokenAddress The token address to convert into a symbol - * @returns A corresponding symbol to the given `tokenAddress` - */ -export const getTokenSymbol = (tokenAddress: string): string => { - const symbol = Object.entries(TOKEN_SYMBOLS_MAP).find( - ([_symbol, { addresses }]) => - addresses[HUB_POOL_CHAIN_ID]?.toLowerCase() === tokenAddress.toLowerCase() - )?.[0]; - if (!symbol) { - throw new InputError("Token address provided was not whitelisted."); - } - return symbol; -}; - /** * Retrieves the results of the `relayFeeCalculator` SDK function: `relayerFeeDetails` * @param inputToken A valid input token address @@ -697,28 +717,23 @@ export const getRelayerFeeDetails = async ( const relayFeeCalculator = getRelayerFeeCalculator(destinationChainId, { relayerAddress, }); - try { - return await relayFeeCalculator.relayerFeeDetails( - buildDepositForSimulation({ - amount: amount.toString(), - inputToken, - outputToken, - recipientAddress, - originChainId, - destinationChainId, - message, - }), - amount, - sdk.utils.isMessageEmpty(message), - relayerAddress, - tokenPrice, - undefined, - gasUnits - ); - } catch (err: unknown) { - const reason = resolveEthersError(err); - throw new InputError(`Relayer fill simulation failed - ${reason}`); - } + return await relayFeeCalculator.relayerFeeDetails( + buildDepositForSimulation({ + amount: amount.toString(), + inputToken, + outputToken, + recipientAddress, + originChainId, + destinationChainId, + message, + }), + amount, + sdk.utils.isMessageEmpty(message), + relayerAddress, + tokenPrice, + undefined, + gasUnits + ); }; export const buildDepositForSimulation = (depositArgs: { @@ -1206,60 +1221,6 @@ export function applyMapFilter( }, []); } -export function resolveEthersError(err: unknown): string { - // prettier-ignore - return sdk.typeguards.isEthersError(err) - ? `${err.reason}: ${err.code} - ${err.error}` - : sdk.typeguards.isError(err) - ? err.message - : "unknown error"; -} - -/** - * Handles the recurring case of error handling - * @param endpoint A string numeric to indicate to the logging utility where this error occurs - * @param response A VercelResponse object that is used to interract with the returning reponse - * @param logger A logging utility to write to a cloud logging provider - * @param error The error that will be returned to the user - * @returns The `response` input with a status/send sent. Note: using this object again will cause an exception - */ -export function handleErrorCondition( - endpoint: string, - response: VercelResponse, - logger: LoggingUtility, - error: unknown -): VercelResponse { - if (!(error instanceof Error)) { - console.error("Error could not be defined.", error); - return response.status(500).send("Error could not be defined."); - } - let status: number; - if (error instanceof InputError) { - logger.warn({ - at: endpoint, - message: `400 input error: ${error.message}`, - }); - status = 400; - } else if (error instanceof StructError) { - logger.warn({ - at: endpoint, - message: `400 validation error: ${error.message}`, - }); - status = 400; - const { type, path } = error; - // Sanitize the error message that will be sent to client - error.message = `ValidationError - At path: ${path}. Expected type: ${type}`; - } else { - logger.error({ - at: endpoint, - message: "500 server error", - }); - status = 500; - } - console.error(error); - return response.status(status).send(error.message); -} - /* ------------------------- superstruct validators ------------------------- */ export function parsableBigNumberString() { @@ -1449,7 +1410,10 @@ export async function getExternalPoolState( case "balancer": return getBalancerPoolState(tokenAddress); default: - throw new InputError("Invalid external pool provider"); + throw new InvalidParamError({ + message: "Invalid external pool provider", + param: "externalPoolProvider", + }); } } @@ -1497,9 +1461,10 @@ async function getBalancerPoolState(poolTokenAddress: string) { ); if (!poolEntry) { - throw new InputError( - `Balancer pool with address ${poolTokenAddress} not found` - ); + throw new InvalidParamError({ + message: `Balancer pool with address ${poolTokenAddress} not found`, + param: "poolTokenAddress", + }); } const poolId = poolEntry[1].id as string; @@ -1523,9 +1488,10 @@ async function getBalancerPoolState(poolTokenAddress: string) { const pool = await balancer.pools.find(poolId); if (!pool) { - throw new InputError( - `Balancer pool with address ${poolTokenAddress} not found` - ); + throw new InvalidParamError({ + message: `Balancer pool with address ${poolTokenAddress} not found`, + param: "poolTokenAddress", + }); } const apr = await balancer.pools.apr(pool); diff --git a/api/batch-account-balance.ts b/api/batch-account-balance.ts index 2fc366624..7aeda5541 100644 --- a/api/batch-account-balance.ts +++ b/api/batch-account-balance.ts @@ -5,9 +5,9 @@ import { getBatchBalanceViaMulticall3, getLogger, handleErrorCondition, - InputError, validAddress, } from "./_utils"; +import { InvalidParamError } from "./_errors"; const BatchAccountBalanceQueryParamsSchema = type({ tokenAddresses: union([validAddress(), array(validAddress())]), @@ -86,9 +86,9 @@ const handler = async ( const tokenAddresses = paramToArray(_tokenAddresses); if (addresses.length === 0 || tokenAddresses.length === 0) { - throw new InputError( - "Params 'addresses' and 'tokenAddresses' must not be empty" - ); + throw new InvalidParamError({ + message: "Params 'addresses' and 'tokenAddresses' must not be empty", + }); } const result = await getBatchBalanceViaMulticall3( diff --git a/api/build-deposit-tx.ts b/api/build-deposit-tx.ts index deaad97ae..621c037f1 100644 --- a/api/build-deposit-tx.ts +++ b/api/build-deposit-tx.ts @@ -4,7 +4,6 @@ import { type, assert, Infer, optional, string, pattern } from "superstruct"; import { TypedVercelRequest } from "./_types"; import { getLogger, - InputError, handleErrorCondition, parsableBigNumberString, validAddress, @@ -15,6 +14,7 @@ import { validAddressOrENS, tagDomain, } from "./_utils"; +import { InvalidParamError } from "./_errors"; const BuildDepositTxQueryParamsSchema = type({ amount: parsableBigNumberString(), @@ -74,7 +74,9 @@ const handler = async ( const isNative = isNativeBoolStr === "true"; if (originChainId === destinationChainId) { - throw new InputError("Origin and destination chains cannot be the same"); + throw new InvalidParamError({ + message: "Origin and destination chains cannot be the same", + }); } const spokePool = getSpokePool(originChainId); diff --git a/api/coingecko.ts b/api/coingecko.ts index b9eaca146..681357d43 100644 --- a/api/coingecko.ts +++ b/api/coingecko.ts @@ -4,7 +4,6 @@ import { object, assert, Infer, optional, string, pattern } from "superstruct"; import { TypedVercelRequest } from "./_types"; import { getLogger, - InputError, handleErrorCondition, validAddress, getBalancerV2TokenPrice, @@ -17,6 +16,7 @@ import { TOKEN_SYMBOLS_MAP, coinGeckoAssetPlatformLookup, } from "./_constants"; +import { InvalidParamError } from "./_errors"; import { coingecko } from "@across-protocol/sdk"; @@ -57,11 +57,12 @@ const handler = async ( // Confirm that the base Currency is supported by Coingecko const isDerivedCurrency = SUPPORTED_CG_DERIVED_CURRENCIES.has(baseCurrency); if (!SUPPORTED_CG_BASE_CURRENCIES.has(baseCurrency) && !isDerivedCurrency) { - throw new InputError( - `Base currency supplied is not supported by this endpoint. Supported currencies: [${Array.from( + throw new InvalidParamError({ + message: `Base currency supplied is not supported by this endpoint. Supported currencies: [${Array.from( SUPPORTED_CG_BASE_CURRENCIES - ).join(", ")}].` - ); + ).join(", ")}].`, + param: "baseCurrency", + }); } // Resolve the optional address lookup that maps one token's @@ -93,16 +94,18 @@ const handler = async ( if (balancerV2PoolTokens.includes(ethers.utils.getAddress(l1Token))) { if (dateStr) { - throw new InputError( - "Historical price not supported for BalancerV2 tokens" - ); + throw new InvalidParamError({ + message: "Historical price not supported for BalancerV2 tokens", + param: "date", + }); } if (baseCurrency === "usd") { price = await getBalancerV2TokenPrice(l1Token); } else { - throw new InputError( - "Only CG base currency allowed for BalancerV2 tokens is usd" - ); + throw new InvalidParamError({ + message: "Only CG base currency allowed for BalancerV2 tokens is usd", + param: "baseCurrency", + }); } } // Fetch price dynamically from Coingecko API. If a historical diff --git a/api/limits.ts b/api/limits.ts index 601bb43af..b8424b5e6 100644 --- a/api/limits.ts +++ b/api/limits.ts @@ -32,9 +32,9 @@ import { getCachedLatestBlock, parsableBigNumberString, validateDepositMessage, - InputError, getCachedFillGasUsage, } from "./_utils"; +import { MissingParamError } from "./_errors"; const LimitsQueryParamsSchema = object({ token: optional(validAddress()), @@ -105,9 +105,11 @@ const handler = async ( const isMessageDefined = sdk.utils.isDefined(message); if (isMessageDefined) { if (!sdk.utils.isDefined(amountInput)) { - throw new InputError( - "Parameter 'amount' must be defined when 'message' is defined" - ); + throw new MissingParamError({ + message: + "Parameter 'amount' must be defined when 'message' is defined", + param: "amount", + }); } await validateDepositMessage( recipient, diff --git a/api/liquid-reserves.ts b/api/liquid-reserves.ts index 05c907786..2428d7109 100644 --- a/api/liquid-reserves.ts +++ b/api/liquid-reserves.ts @@ -6,7 +6,6 @@ import { object, assert, Infer, string } from "superstruct"; import { HUB_POOL_CHAIN_ID, - InputError, callViaMulticall3, getHubPool, getLogger, @@ -16,6 +15,7 @@ import { handleErrorCondition, sendResponse, } from "./_utils"; +import { InvalidParamError } from "./_errors"; const LiquidReservesQueryParamsSchema = object({ l1Tokens: string(), @@ -56,9 +56,10 @@ const handler = async ( getTokenByAddress(_l1Token, HUB_POOL_CHAIN_ID) ); if (!l1TokenDetails) { - throw new InputError( - `Query contains an unsupported L1 token address: ${query.l1Tokens}` - ); + throw new InvalidParamError({ + message: `Query contains an unsupported L1 token address: ${query.l1Tokens}`, + param: "l1Tokens", + }); } const provider = getProvider(HUB_POOL_CHAIN_ID); diff --git a/api/suggested-fees.ts b/api/suggested-fees.ts index bfb454cfd..7eb562937 100644 --- a/api/suggested-fees.ts +++ b/api/suggested-fees.ts @@ -9,7 +9,6 @@ import { import { TypedVercelRequest } from "./_types"; import { getLogger, - InputError, getProvider, getCachedTokenPrice, handleErrorCondition, @@ -30,6 +29,11 @@ import { import { selectExclusiveRelayer } from "./_exclusivity"; import { resolveTiming, resolveRebalanceTiming } from "./_timings"; import { parseUnits } from "ethers/lib/utils"; +import { + InvalidParamError, + AmountTooHighError, + AmountTooLowError, +} from "./_errors"; const { BigNumber } = ethers; @@ -131,7 +135,10 @@ const handler = async ( else { // Don't attempt to provide quotes for future timestamps. if (parsedTimestamp > latestBlock.timestamp) { - throw new InputError("Invalid quote timestamp"); + throw new InvalidParamError({ + message: "Provided timestamp can not be in the future", + param: "timestamp", + }); } const blockFinder = new sdk.utils.BlockFinder(provider, [latestBlock]); @@ -201,12 +208,12 @@ const handler = async ( .div(parseUnits("1", inputToken.decimals)); if (amount.gt(maxDeposit)) { - throw new InputError( - `Amount exceeds max. deposit limit: ${ethers.utils.formatUnits( + throw new AmountTooHighError({ + message: `Amount exceeds max. deposit limit: ${ethers.utils.formatUnits( maxDeposit, inputToken.decimals - )} ${inputToken.symbol}` - ); + )} ${inputToken.symbol}`, + }); } const parsedL1TokenConfig = @@ -228,7 +235,9 @@ const handler = async ( const skipAmountLimitEnabled = skipAmountLimit === "true"; if (!skipAmountLimitEnabled && isAmountTooLow) { - throw new InputError("Sent amount is too low relative to fees"); + throw new AmountTooLowError({ + message: `Sent amount is too low relative to fees`, + }); } // Across V3's new `deposit` function requires now a total fee that includes the LP fee diff --git a/api/swap-quote.ts b/api/swap-quote.ts index 8dedcb74f..342e6e29b 100644 --- a/api/swap-quote.ts +++ b/api/swap-quote.ts @@ -16,6 +16,7 @@ import { } from "./_utils"; import { getUniswapQuoteAndCalldata } from "./_dexes/uniswap"; import { get1inchQuoteAndCalldata } from "./_dexes/1inch"; +import { InvalidParamError } from "./_errors"; const SwapQuoteQueryParamsSchema = type({ swapToken: validAddress(), @@ -68,9 +69,9 @@ const handler = async ( const _swapToken = getTokenByAddress(swapTokenAddress, originChainId); if (!_swapToken) { - throw new InputError( - `Unsupported swap token ${swapTokenAddress} on chain ${originChainId}` - ); + throw new InvalidParamError({ + message: `Unsupported swap token ${swapTokenAddress} on chain ${originChainId}`, + }); } const swapToken = { @@ -91,7 +92,9 @@ const handler = async ( swapTokenAddress, }) ) { - throw new InputError(`Unsupported swap route`); + throw new InvalidParamError({ + message: `Unsupported swap route`, + }); } const swap = { diff --git a/test/api/main.test.ts b/test/api/main.test.ts index 05500b487..90dd7e8f4 100644 --- a/test/api/main.test.ts +++ b/test/api/main.test.ts @@ -34,32 +34,32 @@ describe("API Test", () => { test("limits has no load-time errors", async () => { await limitsHandler(request as TypedVercelRequest, response); expect(response.status).toHaveBeenCalledWith(400); - expect(response.send).toHaveBeenCalledWith( - expect.stringMatching(/At path: destinationChainId/) + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining(/At path: destinationChainId/) ); }); test("suggested-fees has no load-time errors", async () => { await feesHandler(request as TypedVercelRequest, response); expect(response.status).toHaveBeenCalledWith(400); - expect(response.send).toHaveBeenCalledWith( - expect.stringMatching(/At path: amount/) + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining(/At path: amount/) ); }); test("pools has no load-time errors", async () => { await poolsHandler(request as TypedVercelRequest, response); expect(response.status).toHaveBeenCalledWith(400); - expect(response.send).toHaveBeenCalledWith( - expect.stringMatching(/At path: token/) + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining(/At path: token/) ); }); test("coingecko has no load-time errors", async () => { await coingeckoHandler(request as TypedVercelRequest, response); expect(response.status).toHaveBeenCalledWith(400); - expect(response.send).toHaveBeenCalledWith( - expect.stringMatching(/At path: l1Token/) + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining(/At path: l1Token/) ); });