diff --git a/apps/desktop-e2e/src/helpers/AccountGroup.ts b/apps/desktop-e2e/src/helpers/AccountGroup.ts index 3091c6210a..9a000aa8dc 100644 --- a/apps/desktop-e2e/src/helpers/AccountGroup.ts +++ b/apps/desktop-e2e/src/helpers/AccountGroup.ts @@ -42,14 +42,14 @@ export class AccountGroupBuilder { setDerivationPathTemplate(derivationPathTemplate: string): void { if (this.accountGroup.type !== "mnemonic") { - throw new CustomError(`Derivation path is not used for ${this.accountGroup.type} accounts}`); + throw new CustomError(`Derivation path is not used for ${this.accountGroup.type} accounts`); } this.derivationPathTemplate = derivationPathTemplate; } setSeedPhrase(seedPhrase: string[]): void { if (this.accountGroup.type !== "mnemonic") { - throw new CustomError(`Seed phrase is not used for ${this.accountGroup.type} accounts}`); + throw new CustomError(`Seed phrase is not used for ${this.accountGroup.type} accounts`); } this.seedPhrase = seedPhrase; } @@ -58,7 +58,7 @@ export class AccountGroupBuilder { async setSecretKey(secretKey: string, accountIndex = 0): Promise { if (this.accountGroup.type !== "secret_key") { - throw new CustomError(`Secret key is not used for ${this.accountGroup.type} accounts}`); + throw new CustomError(`Secret key is not used for ${this.accountGroup.type} accounts`); } this.accountGroup.accounts[accountIndex].pkh = await ( await InMemorySigner.fromSecretKey(secretKey) diff --git a/packages/utils/package.json b/packages/utils/package.json index 45df2b9849..48a37178ec 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -19,6 +19,7 @@ "@types/eslint": "^8", "@types/jest": "^29.5.14", "@types/lodash": "^4", + "@types/sanitize-html": "^2.13.0", "@umami/eslint-config": "workspace:^", "@umami/jest-config": "workspace:^", "@umami/test-utils": "workspace:^", @@ -60,6 +61,7 @@ }, "dependencies": { "bignumber.js": "^9.1.2", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "sanitize-html": "^2.14.0" } } diff --git a/packages/utils/src/ErrorContext.test.ts b/packages/utils/src/ErrorContext.test.ts index 4fc40cde8f..3e3fe3a24f 100644 --- a/packages/utils/src/ErrorContext.test.ts +++ b/packages/utils/src/ErrorContext.test.ts @@ -5,6 +5,7 @@ import { WalletConnectError, WcErrorCode, getErrorContext, + getHttpErrorMessage, getTezErrorMessage, getWcErrorResponse, } from "./ErrorContext"; @@ -62,6 +63,7 @@ describe("getErrorContext", () => { expect(context.stacktrace).toBeDefined(); expect(context.timestamp).toBeDefined(); }); + it("should handle WalletConnectError instances", () => { const error = new WalletConnectError( "Custom WC error message", @@ -76,6 +78,41 @@ describe("getErrorContext", () => { expect(context.stacktrace).toBeDefined(); expect(context.timestamp).toBeDefined(); }); + + it("should handle HttpErrorResponse instances", () => { + const error = { + status: 503, + message: + "Http error response: (503) 503 Service Temporarily Unavailable", + url: "https://example.com/api", + }; + + const context = getErrorContext(error); + expect(context.description).toBe( + "HTTP request failed for https://example.com/api (503) Service Unavailable - The server is temporarily unable to handle the request. Please try again later or contact support." + ); + expect(context.code).toBe(503); + expect(context.technicalDetails).toEqual([ + 503, + "Service Unavailable - The server is temporarily unable to handle the request. Please try again later or contact support.", + "https://example.com/api", + "Http error response: (503) 503 Service Temporarily Unavailable", + ]); + expect(context.stacktrace).toBeDefined(); + expect(context.timestamp).toBeDefined(); + }); + + it("should recognize well known http error codes", () => { + expect(getHttpErrorMessage(123)).toEqual( + "Unknown Error - Status code: 123. Please try again later or contact support." + ); + for (const status of [400, 401, 403, 404, 405, 408, 409, 410, 500, 501, 502, 503, 504]) { + expect(getHttpErrorMessage(status)).toBeDefined(); + expect(getHttpErrorMessage(status)).not.toEqual( + "Unknown Error - Status code: 123. Please try again later or contact support." + ); + } + }); }); describe("getTezErrorMessage", () => { @@ -141,7 +178,6 @@ describe("getTezErrorMessage", () => { }); it("should return TezosOperationError message", () => { - // const error = new TezosOperationError(errors:[], lastError: { id: 'michelson_v1.script_rejected', with: { prim: 'Unit' } }); const mockError: TezosOperationErrorWithMessage = { kind: "temporary", id: "proto.020-PsParisC.michelson_v1.script_rejected", @@ -154,7 +190,7 @@ describe("getTezErrorMessage", () => { ); const context = getErrorContext(error); expect(context.description).toContain( - "Rejected by chain. The contract code failed to run. Please check the contract. Details: Fail entrypoint" + "Rejected by chain. The contract code failed to run. Please check the contract.\nDetails: Fail entrypoint" ); expect(context.technicalDetails).toEqual([ "proto.020-PsParisC.michelson_v1.script_rejected", diff --git a/packages/utils/src/ErrorContext.ts b/packages/utils/src/ErrorContext.ts index 7085f90f62..098055e3b5 100644 --- a/packages/utils/src/ErrorContext.ts +++ b/packages/utils/src/ErrorContext.ts @@ -2,6 +2,7 @@ import { type MichelsonV1ExpressionBase, type TezosGenericOperationError } from import { TezosOperationError, type TezosOperationErrorWithMessage } from "@taquito/taquito"; import { type ErrorResponse } from "@walletconnect/jsonrpc-utils"; import { type SessionTypes } from "@walletconnect/types"; +import sanitizeHtml from "sanitize-html"; import { TezosRpcErrors } from "./TezosRpcErrors"; @@ -88,6 +89,40 @@ export const getTezErrorMessage = (err: string): string | undefined => { } }; +export const getHttpErrorMessage = (status: number): string => { + const defaultAction = "Please try again later or contact support."; + const httpErrorDescriptions: { [key: number]: string } = { + 400: "Bad Request - The server could not understand the request. Please check your input and try again.", + 401: "Unauthorized - Authentication is required or has failed. Please log in and try again.", + 403: "Forbidden - You do not have permission to access the requested resource. Contact support if you believe this is an error.", + 404: `Not Found - The requested resource could not be found. ${defaultAction}`, + 405: `Method Not Allowed - The HTTP method is not supported by the resource. ${defaultAction}`, + 408: "Request Timeout - The server timed out waiting for the request. Please check your network connection and try again.", + 409: `Conflict - There is a conflict with the current state of the resource. ${defaultAction}`, + 410: `Gone - The resource is no longer available. It may have been removed or retired. ${defaultAction}`, + 500: `Internal Server Error - An unexpected error occurred on the server. ${defaultAction}`, + 501: "Not Implemented - The server does not support the functionality required to fulfill the request. Contact support for assistance.", + 502: "Bad Gateway - The server received an invalid response from the upstream server. Please try again later.", + 503: `Service Unavailable - The server is temporarily unable to handle the request. ${defaultAction}`, + 504: "Gateway Timeout - The server did not receive a timely response from the upstream server. Check your network and try again.", + }; + + return ( + httpErrorDescriptions[status] || + `Unknown Error - Status code: ${status}. Please try again later or contact support.` + ); +}; + +function stripHtmlTags(html: string): string { + return sanitizeHtml(html, { + allowedTags: [], + allowedAttributes: {}, + }) + .replace(/[\r\n]/g, " ") // replace new lines with spaces + .replace(/\s+/g, " ") // replace multiple spaces with single space + .trim(); +} + const isTezosOperationErrorWithMessage = ( error: TezosGenericOperationError ): error is TezosOperationErrorWithMessage => "with" in error; @@ -97,7 +132,7 @@ export const getErrorContext = (error: any, silent: boolean = false): ErrorConte "Something went wrong. Please try again. Contact support if the issue persists."; let description = defaultDescription; let technicalDetails: any = undefined; - let code = WcErrorCode.INTERNAL_ERROR; + let code: number = WcErrorCode.INTERNAL_ERROR; const errorMessage = typeof error === "string" ? error : error.message; let stacktrace = ""; @@ -119,7 +154,7 @@ export const getErrorContext = (error: any, silent: boolean = false): ErrorConte description = "Rejected by chain. " + (getTezErrorMessage(lastError.id) ?? "") + - " Details: " + + "\nDetails: " + errorMessage; if (isTezosOperationErrorWithMessage(lastError)) { const failswith: MichelsonV1ExpressionBase = lastError.with; @@ -127,6 +162,24 @@ export const getErrorContext = (error: any, silent: boolean = false): ErrorConte } else { technicalDetails = [lastError.id]; } + } else if ( + typeof error === "object" && + error !== null && + "status" in error && + typeof error.status === "number" && + "url" in error && + typeof error.url === "string" + ) { + // HttpErrorResponse exception has these fields. Since we need the fields only, we can use them directly. + const httpError = getHttpErrorMessage(error.status); + const plainMessage = stripHtmlTags(error.message); + description = `HTTP request failed for ${error.url} (${error.status}) ${httpError}`; + code = error.status; + console.log("HTTP ERROR", error); + if (code === 500) { + description = `${description}\nDetails: ${plainMessage}`; + } + technicalDetails = [error.status, httpError, error.url, plainMessage]; } else if (error instanceof Error || Object.prototype.hasOwnProperty.call(error, "message")) { description = getTezErrorMessage(errorMessage) ?? defaultDescription; technicalDetails = errorMessage; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f6e79c33a..f2779c2097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2184,6 +2184,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + sanitize-html: + specifier: ^2.14.0 + version: 2.14.0 devDependencies: '@babel/core': specifier: ^7.26.0 @@ -2200,6 +2203,9 @@ importers: '@types/lodash': specifier: ^4 version: 4.17.7 + '@types/sanitize-html': + specifier: ^2.13.0 + version: 2.13.0 '@umami/eslint-config': specifier: workspace:^ version: link:../eslint-config @@ -6189,6 +6195,9 @@ packages: '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/sanitize-html@2.13.0': + resolution: {integrity: sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -8922,6 +8931,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser2@9.1.0: resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} @@ -9251,6 +9263,10 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -10646,6 +10662,9 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -11491,6 +11510,9 @@ packages: sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} + sanitize-html@2.14.0: + resolution: {integrity: sha512-CafX+IUPxZshXqqRaG9ZClSlfPVjSxI0td7n07hk8QO2oO+9JDnlcL8iM8TWeOXOIBFgIOx6zioTzM53AOMn3g==} + sass-lookup@6.0.1: resolution: {integrity: sha512-nl9Wxbj9RjEJA5SSV0hSDoU2zYGtE+ANaDS4OFUR7nYrquvBFvPKZZtQHe3lvnxCcylEDV00KUijjdMTUElcVQ==} engines: {node: '>=18'} @@ -18822,6 +18844,10 @@ snapshots: '@types/retry@0.12.5': {} + '@types/sanitize-html@2.13.0': + dependencies: + htmlparser2: 8.0.2 + '@types/stack-utils@2.0.3': {} '@types/tough-cookie@4.0.5': {} @@ -22647,6 +22673,13 @@ snapshots: html-escaper@2.0.2: {} + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + htmlparser2@9.1.0: dependencies: domelementtype: 2.3.0 @@ -22959,6 +22992,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-regex@1.1.4: @@ -24770,6 +24805,8 @@ snapshots: parse-passwd@1.0.0: {} + parse-srcset@1.0.2: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -25725,6 +25762,15 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 + sanitize-html@2.14.0: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.4.49 + sass-lookup@6.0.1: dependencies: commander: 12.1.0