From 85acc70e4a27d7d011cbb9afe9d0cff029223470 Mon Sep 17 00:00:00 2001 From: Diana Savatina Date: Tue, 7 Jan 2025 16:33:47 +0000 Subject: [PATCH] feat: WalletConnect: error handling --- .../Menu/ErrorLogsMenu/ErrorLogsMenu.tsx | 4 +- .../WalletConnect/WalletConnectProvider.tsx | 28 ++-- .../WalletConnect/useHandleWcRequest.tsx | 42 +++--- packages/state/src/slices/errors.test.ts | 1 + packages/test-utils/src/errorContext.ts | 2 + packages/utils/src/ErrorContext.test.ts | 97 ++++++++++++-- packages/utils/src/ErrorContext.ts | 122 ++++++++++++++---- 7 files changed, 229 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx b/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx index 983fc21b95..3cc761f8e1 100644 --- a/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx +++ b/apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx @@ -1,6 +1,5 @@ import { Box, Button, Divider, Flex, Heading, Link, Text, VStack } from "@chakra-ui/react"; import { errorsActions, useAppDispatch, useAppSelector } from "@umami/state"; -import { handleTezError } from "@umami/utils"; import { useColor } from "../../../styles/useColor"; import { EmptyMessage } from "../../EmptyMessage"; @@ -61,8 +60,7 @@ export const ErrorLogsMenu = () => { {errorLog.technicalDetails && ( - {handleTezError({ name: "unknown", message: errorLog.technicalDetails }) ?? - ""} + {JSON.stringify(errorLog.technicalDetails)} )} diff --git a/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx b/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx index af17e4cad1..d212325ae1 100644 --- a/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx +++ b/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx @@ -3,6 +3,7 @@ import type EventEmitter from "events"; import { type NetworkType } from "@airgap/beacon-wallet"; import { useToast } from "@chakra-ui/react"; import { type WalletKitTypes } from "@reown/walletkit"; +import { TezosOperationError } from "@taquito/taquito"; import { useDynamicModalContext } from "@umami/components"; import { createWalletKit, @@ -12,10 +13,10 @@ import { walletKit, } from "@umami/state"; import { type Network } from "@umami/tezos"; -import { CustomError, WalletConnectError } from "@umami/utils"; +import { CustomError, WalletConnectError, type WcErrorKey, getWcErrorResponse } from "@umami/utils"; import { formatJsonRpcError } from "@walletconnect/jsonrpc-utils"; import { type SessionTypes } from "@walletconnect/types"; -import { type SdkErrorKey, getSdkError } from "@walletconnect/utils"; +import { getSdkError } from "@walletconnect/utils"; import { type PropsWithChildren, useCallback, useEffect, useRef } from "react"; import { SessionProposalModal } from "./SessionProposalModal"; @@ -94,7 +95,7 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => { handleAsyncActionUnsafe(async () => { const activeSessions: Record = walletKit.getActiveSessions(); if (!(event.topic in activeSessions)) { - throw new WalletConnectError("Session not found", "INVALID_EVENT", null); + throw new WalletConnectError("Session not found", "SESSION_NOT_FOUND", null); } const session = activeSessions[event.topic]; @@ -105,19 +106,20 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => { await handleWcRequest(event, session); }).catch(async error => { const { id, topic } = event; - let sdkErrorKey: SdkErrorKey = - error instanceof WalletConnectError ? error.sdkError : "SESSION_SETTLEMENT_FAILED"; - if (sdkErrorKey === "USER_REJECTED") { - console.info("WC request rejected", sdkErrorKey, event, error); + let wcErrorKey: WcErrorKey = "UNKNOWN_ERROR"; + + if (error instanceof WalletConnectError) { + wcErrorKey = error.wcError; + } else if (error instanceof TezosOperationError) { + wcErrorKey = "REJECTED_BY_CHAIN"; + } + const response = formatJsonRpcError(id, getWcErrorResponse(error)); + if (wcErrorKey === "USER_REJECTED") { + console.info("WC request rejected", wcErrorKey, event, error); } else { - if (error.message.includes("delegate.unchanged")) { - sdkErrorKey = "INVALID_EVENT"; - } - console.warn("WC request failed", sdkErrorKey, event, error); + console.warn("WC request failed", wcErrorKey, event, error, response); } // dApp is waiting so we need to notify it - const sdkErrorMessage = getSdkError(sdkErrorKey).message; - const response = formatJsonRpcError(id, sdkErrorMessage); await walletKit.respondSessionRequest({ topic, response }); }), [handleAsyncActionUnsafe, handleWcRequest, toast] diff --git a/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx index 3a666d0290..a1743b0056 100644 --- a/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx +++ b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx @@ -14,10 +14,9 @@ import { useGetOwnedAccountSafe, walletKit, } from "@umami/state"; -import { WalletConnectError } from "@umami/utils"; +import { WC_ERRORS, WalletConnectError } from "@umami/utils"; import { formatJsonRpcError, formatJsonRpcResult } from "@walletconnect/jsonrpc-utils"; import { type SessionTypes, type SignClientTypes, type Verify } from "@walletconnect/types"; -import { type SdkErrorKey, getSdkError } from "@walletconnect/utils"; import { SignPayloadRequestModal } from "../common/SignPayloadRequestModal"; import { BatchSignPage } from "../SendFlow/common/BatchSignPage"; @@ -61,11 +60,18 @@ export const useHandleWcRequest = () => { let modal; let onClose; + const handleUserRejected = () => { + // dApp is waiting so we need to notify it + const response = formatJsonRpcError(id, WC_ERRORS.USER_REJECTED); + console.info("WC request rejected by user", event, response); + void walletKit.respondSessionRequest({ topic, response }); + }; + switch (request.method) { case "tezos_getAccounts": { const wcPeers = walletKit.getActiveSessions(); if (!(topic in wcPeers)) { - throw new WalletConnectError(`Unknown session ${topic}`, "UNAUTHORIZED_EVENT", null); + throw new WalletConnectError(`Unknown session ${topic}`, "SESSION_NOT_FOUND", null); } const session = wcPeers[topic]; const accountPkh = session.namespaces.tezos.accounts[0].split(":")[2]; @@ -89,7 +95,11 @@ export const useHandleWcRequest = () => { case "tezos_sign": { if (!request.params.account) { - throw new WalletConnectError("Missing account in request", "INVALID_EVENT", session); + throw new WalletConnectError( + "Missing account in request", + "MISSING_ACCOUNT_IN_REQUEST", + session + ); } const signer = getImplicitAccount(request.params.account); const network = findNetwork(chainId.split(":")[1]); @@ -97,7 +107,8 @@ export const useHandleWcRequest = () => { throw new WalletConnectError( `Unsupported network ${chainId}`, "UNSUPPORTED_CHAINS", - session + session, + chainId ); } @@ -115,24 +126,24 @@ export const useHandleWcRequest = () => { modal = ; onClose = () => { - const sdkErrorKey: SdkErrorKey = "USER_REJECTED"; - console.info("WC request rejected by user", sdkErrorKey, event); - // dApp is waiting so we need to notify it - const response = formatJsonRpcError(id, getSdkError(sdkErrorKey).message); - void walletKit.respondSessionRequest({ topic, response }); + handleUserRejected(); }; return openWith(modal, { onClose }); } case "tezos_send": { if (!request.params.account) { - throw new WalletConnectError("Missing account in request", "INVALID_EVENT", session); + throw new WalletConnectError( + "Missing account in request", + "MISSING_ACCOUNT_IN_REQUEST", + session + ); } const signer = getAccount(request.params.account); if (!signer) { throw new WalletConnectError( `Unknown account, no signer: ${request.params.account}`, - "UNAUTHORIZED_EVENT", + "INTERNAL_SIGNER_IS_MISSING", session ); } @@ -168,7 +179,7 @@ export const useHandleWcRequest = () => { modal = ; } onClose = () => { - throw new WalletConnectError("Rejected by user", "USER_REJECTED", session); + handleUserRejected(); }; return openWith(modal, { onClose }); @@ -176,8 +187,9 @@ export const useHandleWcRequest = () => { default: throw new WalletConnectError( `Unsupported method ${request.method}`, - "WC_METHOD_UNSUPPORTED", - session + "METHOD_UNSUPPORTED", + session, + request.method ); } }); diff --git a/packages/state/src/slices/errors.test.ts b/packages/state/src/slices/errors.test.ts index d5e29e1bd1..b50cc36b75 100644 --- a/packages/state/src/slices/errors.test.ts +++ b/packages/state/src/slices/errors.test.ts @@ -29,6 +29,7 @@ describe("Errors reducer", () => { description: `error ${i}`, stacktrace: "stacktrace", technicalDetails: "technicalDetails", + code: i, }) ); } diff --git a/packages/test-utils/src/errorContext.ts b/packages/test-utils/src/errorContext.ts index 9e3369b971..7970eef22b 100644 --- a/packages/test-utils/src/errorContext.ts +++ b/packages/test-utils/src/errorContext.ts @@ -2,6 +2,7 @@ export const errorContext1 = { timestamp: "2023-08-03T19:27:43.735Z", description: "error1", stacktrace: "stacktrace", + code: 100, technicalDetails: "technicalDetails", }; @@ -9,5 +10,6 @@ export const errorContext2 = { timestamp: "2023-08-03T20:21:58.395Z", description: "error1", stacktrace: "stacktrace", + code: 200, technicalDetails: "technicalDetails", }; diff --git a/packages/utils/src/ErrorContext.test.ts b/packages/utils/src/ErrorContext.test.ts index fd3ef71609..e2defd2abe 100644 --- a/packages/utils/src/ErrorContext.test.ts +++ b/packages/utils/src/ErrorContext.test.ts @@ -1,4 +1,12 @@ -import { CustomError, WalletConnectError, getErrorContext, handleTezError } from "./ErrorContext"; +import { TezosOperationError, type TezosOperationErrorWithMessage } from "@taquito/taquito"; + +import { + CustomError, + WalletConnectError, + explainTezError, + getErrorContext, + getWcErrorResponse, +} from "./ErrorContext"; describe("getErrorContext", () => { it("should handle error object with message and stack", () => { @@ -12,7 +20,7 @@ describe("getErrorContext", () => { expect(context.technicalDetails).toBe("some error message"); expect(context.stacktrace).toBe("some stacktrace"); expect(context.description).toBe( - "Something went wrong. Please try again or contact support if the issue persists." + "Something went wrong. Please try again. Contact support if the issue persists. Details: some error message" ); expect(context.timestamp).toBeDefined(); }); @@ -25,7 +33,7 @@ describe("getErrorContext", () => { expect(context.technicalDetails).toBe("string error message"); expect(context.stacktrace).toBe(""); expect(context.description).toBe( - "Something went wrong. Please try again or contact support if the issue persists." + "Something went wrong. Please try again. Contact support if the issue persists." ); expect(context.timestamp).toBeDefined(); }); @@ -48,53 +56,114 @@ describe("getErrorContext", () => { const context = getErrorContext(error); - expect(context.technicalDetails).toBe(""); + expect(context.technicalDetails).toBeUndefined(); expect(context.description).toBe("Custom error message"); expect(context.stacktrace).toBeDefined(); expect(context.timestamp).toBeDefined(); }); it("should handle WalletConnectError instances", () => { - const error = new WalletConnectError("Custom WC error message", "UNSUPPORTED_EVENTS", null); + const error = new WalletConnectError("Custom WC error message", "INTERNAL_ERROR", null); const context = getErrorContext(error); - expect(context.technicalDetails).toBe(""); + expect(context.technicalDetails).toBeUndefined(); expect(context.description).toBe("Custom WC error message"); expect(context.stacktrace).toBeDefined(); expect(context.timestamp).toBeDefined(); }); }); -describe("handleTezError", () => { +describe("explainTezError", () => { it("catches subtraction_underflow", () => { - const res = handleTezError(new Error("subtraction_underflow")); + const res = explainTezError("subtraction_underflow"); expect(res).toBe("Insufficient balance, please make sure you have enough funds."); }); it("catches non_existing_contract", () => { - const res = handleTezError(new Error("contract.non_existing_contract")); + const res = explainTezError("contract.non_existing_contract"); expect(res).toBe("Contract does not exist, please check if the correct network is selected."); }); it("catches staking_to_delegate_that_refuses_external_staking", () => { - const res = handleTezError(new Error("staking_to_delegate_that_refuses_external_staking")); + const res = explainTezError("staking_to_delegate_that_refuses_external_staking"); expect(res).toBe("The baker you are trying to stake to does not accept external staking."); }); it("catches empty_implicit_delegated_contract", () => { - const res = handleTezError(new Error("empty_implicit_delegated_contract")); + const res = explainTezError("empty_implicit_delegated_contract"); expect(res).toBe( "Emptying an implicit delegated account is not allowed. End delegation before trying again." ); }); it("catches delegate.unchanged", () => { - const res = handleTezError(new Error("delegate.unchanged")); + const res = explainTezError("delegate.unchanged"); expect(res).toBe("The delegate is unchanged. Delegation to this address is already done."); }); + it("catches contract.manager.unregistered_delegate", () => { + const res = explainTezError("contract.manager.unregistered_delegate"); + expect(res).toBe( + "The provided delegate address is not registered as a delegate. Verify the delegate address and ensure it is active." + ); + }); + it("returns undefined for unknown errors", () => { - const err = new Error("unknown error"); - expect(handleTezError(err)).toBeUndefined(); + const err = "unknown error"; + expect(explainTezError(err)).toBeUndefined(); + }); + + it("should return default error message for unknown error", () => { + const error = new Error("Unknown error"); + const context = getErrorContext(error); + expect(context.description).toBe( + "Something went wrong. Please try again. Contact support if the issue persists. Details: Unknown error" + ); + }); + + it("should return custom error message for CustomError", () => { + const error = new CustomError("Custom error message"); + const context = getErrorContext(error); + expect(context.description).toBe("Custom error message"); + }); + + it("should return WalletConnectError message", () => { + const error = new WalletConnectError("WC error custom text", "INTERNAL_ERROR", null); + const context = getErrorContext(error); + expect(context.description).toBe("WC error custom text"); + expect(context.code).toBe(4011); + expect(context.technicalDetails).toBeUndefined(); + }); + + 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", + with: { string: "Fail entrypoint" }, // Include the `with` field for testing + }; + const error = new TezosOperationError( + [mockError], + "Operation failed due to a rejected script.", + [] + ); + const context = getErrorContext(error); + expect(context.description).toContain( + "Rejected by chain. The contract code failed to run. Please check the contract. Details: Fail entrypoint" + ); + expect(context.technicalDetails).toEqual([ + "proto.020-PsParisC.michelson_v1.script_rejected", + { with: { string: "Fail entrypoint" } }, + ]); + }); + + it("should return error response for getWcErrorResponse", () => { + const error = new Error("Unknown error"); + const response = getWcErrorResponse(error); + expect(response.message).toBe( + "Something went wrong. Please try again. Contact support if the issue persists. Details: Unknown error" + ); + expect(response.code).toBe(4011); + expect(response.data).toBe("Unknown error"); }); }); diff --git a/packages/utils/src/ErrorContext.ts b/packages/utils/src/ErrorContext.ts index 6e1a9bad6d..364aecbe25 100644 --- a/packages/utils/src/ErrorContext.ts +++ b/packages/utils/src/ErrorContext.ts @@ -1,10 +1,15 @@ +import { type MichelsonV1ExpressionBase, type TezosGenericOperationError } from "@taquito/rpc"; +import { TezosOperationError, type TezosOperationErrorWithMessage } from "@taquito/taquito"; +import { type ErrorResponse } from "@walletconnect/jsonrpc-utils"; import { type SessionTypes } from "@walletconnect/types"; -import { type SdkErrorKey } from "@walletconnect/utils"; + export type ErrorContext = { timestamp: string; description: string; stacktrace: string; - technicalDetails: string; + technicalDetails: any; + code: number; + data?: any; }; export class CustomError extends Error { @@ -15,51 +20,113 @@ export class CustomError extends Error { } export class WalletConnectError extends CustomError { - sdkError: SdkErrorKey; - constructor(message: string, sdkError: SdkErrorKey, session: SessionTypes.Struct | null) { + wcError: WcErrorKey; + context?: string | number; + constructor( + message: string, + wcError: WcErrorKey, + session: SessionTypes.Struct | null, + context?: string | number + ) { const dappName = session?.peer.metadata.name ?? "unknown"; super(session ? `Request from ${dappName} is rejected. ${message}` : message); this.name = "WalletConnectError"; - this.sdkError = sdkError; + this.wcError = wcError; + this.context = context; } } +export type WcErrorKey = keyof typeof WC_ERRORS; +export const WC_ERRORS = { + // JSON-RPC reserved error codes + PARSE_ERROR: { code: -32700, message: "Invalid JSON received by the server." }, + INVALID_REQUEST: { code: -32600, message: "The JSON sent is not a valid request object." }, + METHOD_NOT_FOUND: { code: -32601, message: "The method does not exist or is not available." }, + INVALID_PARAMS: { code: -32602, message: "Invalid method parameters." }, + INTERNAL_ERROR: { code: -32603, message: "Internal JSON-RPC error." }, + + // Application-specific errors (codes >= 0 for clarity) + USER_REJECTED: { code: 4001, message: "User rejected the request." }, + UNSUPPORTED_CHAINS: { code: 4002, message: "Unsupported chains." }, + METHOD_UNSUPPORTED: { code: 4003, message: "Method unsupported." }, + SESSION_NOT_FOUND: { code: 4004, message: "Session not found." }, + MISSING_ACCOUNT_IN_REQUEST: { code: 4005, message: "Missing account in request." }, + INTERNAL_SIGNER_IS_MISSING: { code: 4006, message: "Internal signer is missing." }, + SIGNER_ADDRESS_NOT_REVEALED: { + code: 4007, + message: + "Signer address is not revealed on the chain. To reveal it, send any amount, e.g., 0.000001ęś©, from that address to yourself. Wait several minutes and try again.", + }, + UNKNOWN_CURVE_FOR_PUBLIC_KEY: { code: 4008, message: "Unknown curve for the public key." }, + REJECTED_BY_CHAIN: { code: 4009, message: "Request rejected by chain." }, + DELEGATE_UNCHANGED: { code: 4010, message: "The delegate is unchanged." }, + UNKNOWN_ERROR: { code: 4011, message: "Unknown error." }, +}; + // Converts a known L1 error message to a more user-friendly one -export const handleTezError = (err: Error): string | undefined => { - if (err.message.includes("subtraction_underflow")) { +export const explainTezError = (err: string): string | undefined => { + if (err.includes("subtraction_underflow")) { return "Insufficient balance, please make sure you have enough funds."; - } else if (err.message.includes("contract.non_existing_contract")) { + } else if (err.includes("contract.non_existing_contract")) { return "Contract does not exist, please check if the correct network is selected."; - } else if (err.message.includes("staking_to_delegate_that_refuses_external_staking")) { + } else if (err.includes("staking_to_delegate_that_refuses_external_staking")) { return "The baker you are trying to stake to does not accept external staking."; - } else if (err.message.includes("empty_implicit_delegated_contract")) { + } else if (err.includes("empty_implicit_delegated_contract")) { return "Emptying an implicit delegated account is not allowed. End delegation before trying again."; - } else if (err.message.includes("delegate.unchanged")) { + } else if (err.includes("delegate.unchanged")) { return "The delegate is unchanged. Delegation to this address is already done."; + } else if (err.includes("contract.manager.unregistered_delegate")) { + return "The provided delegate address is not registered as a delegate. Verify the delegate address and ensure it is active."; + } else if (err.includes("michelson_v1.script_rejected")) { + return "The contract code failed to run. Please check the contract."; } }; +const isTezosOperationErrorWithMessage = ( + error: TezosGenericOperationError +): error is TezosOperationErrorWithMessage => "with" in error; + export const getErrorContext = (error: any): ErrorContext => { - let description = - "Something went wrong. Please try again or contact support if the issue persists."; - let technicalDetails; + const defaultDescription = + "Something went wrong. Please try again. Contact support if the issue persists."; + let description = defaultDescription; + let technicalDetails: any = undefined; + let code = WC_ERRORS.UNKNOWN_ERROR.code; + const errorMessage = typeof error === "string" ? error : error.message; let stacktrace = ""; if (typeof error === "object" && "stack" in error) { stacktrace = error.stack; - } - - if (typeof error === "object" && "message" in error) { - technicalDetails = error.message; } else if (typeof error === "string") { technicalDetails = error; } if (error instanceof CustomError) { - description = error.message; - technicalDetails = ""; - } else if (error instanceof Error) { - description = handleTezError(error) ?? description; + description = errorMessage; + } else if (error instanceof WalletConnectError) { + const message = WC_ERRORS[error.wcError].message; + code = WC_ERRORS[error.wcError].code; + description = message + errorMessage; + technicalDetails = error.context; + } else if (error instanceof TezosOperationError) { + code = WC_ERRORS.REJECTED_BY_CHAIN.code; + const lastError = error.lastError; + description = + "Rejected by chain. " + (explainTezError(lastError.id) ?? "") + " Details: " + errorMessage; + if (isTezosOperationErrorWithMessage(lastError)) { + const failswith: MichelsonV1ExpressionBase = lastError.with; + technicalDetails = [lastError.id, { with: failswith }]; + } else { + technicalDetails = [lastError.id]; + } + } else if (error instanceof Error || Object.prototype.hasOwnProperty.call(error, "message")) { + const explanation = explainTezError(errorMessage); + if (explanation) { + description = explanation; + } else { + description = `${defaultDescription} Details: ${errorMessage}`; + } + technicalDetails = errorMessage; } return { @@ -67,5 +134,16 @@ export const getErrorContext = (error: any): ErrorContext => { description, stacktrace, technicalDetails, + code, + }; +}; + +export const getWcErrorResponse = (error: any): ErrorResponse => { + const context = getErrorContext(error); + const response: ErrorResponse = { + code: context.code, + message: context.description, + data: context.technicalDetails, }; + return response; };