Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: WalletConnect - advanced error handling #2315

Merged
merged 1 commit into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/web/src/components/Menu/ErrorLogsMenu/ErrorLogsMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -61,8 +60,7 @@ export const ErrorLogsMenu = () => {
</Text>
{errorLog.technicalDetails && (
<Text marginTop="12px" color={color("700")} size="sm">
{handleTezError({ name: "unknown", message: errorLog.technicalDetails }) ??
""}
{JSON.stringify(errorLog.technicalDetails)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't errorLog.technicalDetails already a string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's any.

  1. If for any reason we ever store data in technicalDetails, the errorLog would crash on every attempt of opening even after the bug is fixed (and I faced it). So it's safer to get ready.
  2. TezzosOperationError and other error types provide valuable context (tech details) so I think we'd better keep them as is. It would simplify debug. And also Taquito test dApp expects that the wallet provides a data structure data.with.* describing what exactly failed, e.g. in the contract

</Text>
)}
</Flex>
Expand Down
19 changes: 4 additions & 15 deletions apps/web/src/components/WalletConnect/WalletConnectProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import {
walletKit,
} from "@umami/state";
import { type Network } from "@umami/tezos";
import { CustomError, WalletConnectError } from "@umami/utils";
import { CustomError, WalletConnectError, WcErrorCode, 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";
Expand Down Expand Up @@ -94,7 +94,7 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {
handleAsyncActionUnsafe(async () => {
const activeSessions: Record<string, SessionTypes.Struct> = walletKit.getActiveSessions();
if (!(event.topic in activeSessions)) {
throw new WalletConnectError("Session not found", "INVALID_EVENT", null);
throw new WalletConnectError("Session not found", WcErrorCode.SESSION_NOT_FOUND, null);
}

const session = activeSessions[event.topic];
Expand All @@ -105,19 +105,8 @@ 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);
} else {
if (error.message.includes("delegate.unchanged")) {
sdkErrorKey = "INVALID_EVENT";
}
console.warn("WC request failed", sdkErrorKey, event, error);
}
const response = formatJsonRpcError(id, getWcErrorResponse(error));
// 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]
Expand Down
53 changes: 36 additions & 17 deletions apps/web/src/components/WalletConnect/useHandleWcRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import {
useGetOwnedAccountSafe,
walletKit,
} from "@umami/state";
import { WalletConnectError } from "@umami/utils";
import { WalletConnectError, WcErrorCode } 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";
Expand Down Expand Up @@ -61,11 +60,25 @@ export const useHandleWcRequest = () => {
let modal;
let onClose;

const handleUserRejected = () => {
// dApp is waiting so we need to notify it
const response = formatJsonRpcError(id, {
code: WcErrorCode.USER_REJECTED,
message: "User rejected the request",
});
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}`,
WcErrorCode.SESSION_NOT_FOUND,
null
);
}
const session = wcPeers[topic];
const accountPkh = session.namespaces.tezos.accounts[0].split(":")[2];
Expand All @@ -89,15 +102,20 @@ 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",
WcErrorCode.MISSING_ACCOUNT_IN_REQUEST,
session
);
}
const signer = getImplicitAccount(request.params.account);
const network = findNetwork(chainId.split(":")[1]);
if (!network) {
throw new WalletConnectError(
`Unsupported network ${chainId}`,
"UNSUPPORTED_CHAINS",
session
WcErrorCode.UNSUPPORTED_CHAINS,
session,
chainId
);
}

Expand All @@ -115,24 +133,24 @@ export const useHandleWcRequest = () => {

modal = <SignPayloadRequestModal opts={signPayloadProps} />;
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",
WcErrorCode.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",
WcErrorCode.INTERNAL_SIGNER_IS_MISSING,
session
);
}
Expand All @@ -144,7 +162,7 @@ export const useHandleWcRequest = () => {
if (!network) {
throw new WalletConnectError(
`Unsupported network ${chainId}`,
"UNSUPPORTED_CHAINS",
WcErrorCode.UNSUPPORTED_CHAINS,
session
);
}
Expand All @@ -168,16 +186,17 @@ export const useHandleWcRequest = () => {
modal = <BatchSignPage {...signProps} {...event.params.request.params} />;
}
onClose = () => {
throw new WalletConnectError("Rejected by user", "USER_REJECTED", session);
handleUserRejected();
};

return openWith(modal, { onClose });
}
default:
throw new WalletConnectError(
`Unsupported method ${request.method}`,
"WC_METHOD_UNSUPPORTED",
session
WcErrorCode.METHOD_UNSUPPORTED,
session,
request.method
);
}
});
Expand Down
3 changes: 2 additions & 1 deletion packages/data-polling/src/useReactQueryErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ export const useReactQueryErrorHandler = () => {
return;
}
dispatch(errorsActions.add(getErrorContext(error)));
const context = getErrorContext(error);

if (!toast.isActive(toastId)) {
toast({
id: toastId,
description: `Data fetching error: ${error.message}`,
description: `Data fetching error: ${context.description}`,
status: "error",
isClosable: true,
duration: 10000,
Expand Down
2 changes: 1 addition & 1 deletion packages/state/src/hooks/useAsyncActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const useAsyncActionHandler = () => {
try {
return await fn();
} catch (error: any) {
const errorContext = getErrorContext(error);
const errorContext = getErrorContext(error, true);

// Prevent double toast and record of the same error if case of nested handleAsyncActionUnsafe calls.
// Still we need to re-throw the error to propagate it to the caller.
Expand Down
1 change: 1 addition & 0 deletions packages/state/src/slices/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("Errors reducer", () => {
description: `error ${i}`,
stacktrace: "stacktrace",
technicalDetails: "technicalDetails",
code: i,
})
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/test-utils/src/errorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ export const errorContext1 = {
timestamp: "2023-08-03T19:27:43.735Z",
description: "error1",
stacktrace: "stacktrace",
code: 100,
technicalDetails: "technicalDetails",
};

export const errorContext2 = {
timestamp: "2023-08-03T20:21:58.395Z",
description: "error1",
stacktrace: "stacktrace",
code: 200,
technicalDetails: "technicalDetails",
};
102 changes: 88 additions & 14 deletions packages/utils/src/ErrorContext.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { CustomError, WalletConnectError, getErrorContext, handleTezError } from "./ErrorContext";
import { TezosOperationError, type TezosOperationErrorWithMessage } from "@taquito/taquito";

import {
CustomError,
WalletConnectError,
WcErrorCode,
getErrorContext,
getTezErrorMessage,
getWcErrorResponse,
} from "./ErrorContext";

describe("getErrorContext", () => {
it("should handle error object with message and stack", () => {
Expand All @@ -12,7 +21,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."
);
expect(context.timestamp).toBeDefined();
});
Expand All @@ -25,7 +34,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();
});
Expand All @@ -48,53 +57,118 @@ 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",
WcErrorCode.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("getTezErrorMessage", () => {
it("catches subtraction_underflow", () => {
const res = handleTezError(new Error("subtraction_underflow"));
const res = getTezErrorMessage("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 = getTezErrorMessage("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 = getTezErrorMessage("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 = getTezErrorMessage("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 = getTezErrorMessage("delegate.unchanged");
expect(res).toBe("The delegate is unchanged. Delegation to this address is already done.");
});

it("catches contract.manager.unregistered_delegate", () => {
const res = getTezErrorMessage("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(getTezErrorMessage(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."
);
});

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", WcErrorCode.INTERNAL_ERROR, null);
const context = getErrorContext(error);
expect(context.description).toBe("WC error custom text");
expect(context.code).toBe(WcErrorCode.INTERNAL_ERROR);
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."
);
expect(response.code).toBe(WcErrorCode.INTERNAL_ERROR);
expect(response.data).toBe("Unknown error");
});
});
Loading
Loading