diff --git a/apps/web/src/components/SendFlow/WalletConnect/useSignWithWalletConnect.tsx b/apps/web/src/components/SendFlow/WalletConnect/useSignWithWalletConnect.tsx
new file mode 100644
index 000000000..0711d4c72
--- /dev/null
+++ b/apps/web/src/components/SendFlow/WalletConnect/useSignWithWalletConnect.tsx
@@ -0,0 +1,53 @@
+import { type TezosToolkit } from "@taquito/taquito";
+import { useDynamicModalContext } from "@umami/components";
+import { executeOperations, totalFee } from "@umami/core";
+import { useAsyncActionHandler, walletKit } from "@umami/state";
+import { formatJsonRpcResult } from "@walletconnect/jsonrpc-utils";
+import { useForm } from "react-hook-form";
+
+import { SuccessStep } from "../SuccessStep";
+import { type CalculatedSignProps, type SdkSignPageProps } from "../utils";
+
+export const useSignWithWalletConnect = ({
+ operation,
+ headerProps,
+ requestId,
+}: SdkSignPageProps): CalculatedSignProps => {
+ const { isLoading: isSigning, handleAsyncAction } = useAsyncActionHandler();
+ const { openWith } = useDynamicModalContext();
+
+ const form = useForm({ defaultValues: { executeParams: operation.estimates } });
+
+ if (requestId.sdkType !== "walletconnect") {
+ return {
+ fee: 0,
+ isSigning: false,
+ onSign: async () => {},
+ network: null,
+ };
+ }
+
+ const onSign = async (tezosToolkit: TezosToolkit) =>
+ handleAsyncAction(
+ async () => {
+ const { opHash } = await executeOperations(
+ { ...operation, estimates: form.watch("executeParams") },
+ tezosToolkit
+ );
+
+ const response = formatJsonRpcResult(requestId.id, { hash: opHash });
+ await walletKit.respondSessionRequest({ topic: requestId.topic, response });
+ return openWith();
+ },
+ error => ({
+ description: `Failed to confirm WalletConnect operation: ${error.message}`,
+ })
+ );
+
+ return {
+ fee: totalFee(form.watch("executeParams")),
+ isSigning,
+ onSign,
+ network: headerProps.network,
+ };
+};
diff --git a/apps/web/src/components/SendFlow/common/BatchSignPage.tsx b/apps/web/src/components/SendFlow/common/BatchSignPage.tsx
index aec74b54c..eee5749c0 100644
--- a/apps/web/src/components/SendFlow/common/BatchSignPage.tsx
+++ b/apps/web/src/components/SendFlow/common/BatchSignPage.tsx
@@ -23,6 +23,7 @@ import { useSignWithBeacon } from "../Beacon/useSignWithBeacon";
import { SignButton } from "../SignButton";
import { SignPageFee } from "../SignPageFee";
import { type SdkSignPageProps } from "../utils";
+import { useSignWithWalletConnect } from "../WalletConnect/useSignWithWalletConnect";
export const BatchSignPage = (
signProps: SdkSignPageProps,
@@ -31,7 +32,9 @@ export const BatchSignPage = (
const color = useColor();
const beaconCalculatedProps = useSignWithBeacon({ ...signProps });
- const calculatedProps = beaconCalculatedProps;
+ const walletConnectCalculatedProps = useSignWithWalletConnect({ ...signProps });
+ const calculatedProps =
+ signProps.requestId.sdkType === "beacon" ? beaconCalculatedProps : walletConnectCalculatedProps;
const { isSigning, onSign, network, fee } = calculatedProps;
const { signer, operations } = signProps.operation;
diff --git a/apps/web/src/components/SendFlow/common/OriginationOperationSignPage.tsx b/apps/web/src/components/SendFlow/common/OriginationOperationSignPage.tsx
index 87a978c16..77d5bea39 100644
--- a/apps/web/src/components/SendFlow/common/OriginationOperationSignPage.tsx
+++ b/apps/web/src/components/SendFlow/common/OriginationOperationSignPage.tsx
@@ -37,7 +37,6 @@ export const OriginationOperationSignPage = ({
}: SdkSignPageProps & CalculatedSignProps) => {
const color = useColor();
const { code, storage } = operation.operations[0] as ContractOrigination;
-
const form = useForm({ defaultValues: { executeParams: operation.estimates } });
return (
diff --git a/apps/web/src/components/SendFlow/common/SingleSignPage.tsx b/apps/web/src/components/SendFlow/common/SingleSignPage.tsx
index 4f7034684..5c35f7add 100644
--- a/apps/web/src/components/SendFlow/common/SingleSignPage.tsx
+++ b/apps/web/src/components/SendFlow/common/SingleSignPage.tsx
@@ -10,12 +10,15 @@ import { TezSignPage } from "./TezSignPage";
import { UndelegationSignPage } from "./UndelegationSignPage";
import { UnstakeSignPage } from "./UnstakeSignPage";
import { useSignWithBeacon } from "../Beacon/useSignWithBeacon";
+import { useSignWithWalletConnect } from "../WalletConnect/useSignWithWalletConnect";
export const SingleSignPage = (signProps: SdkSignPageProps) => {
const operationType = signProps.operation.operations[0].type;
const beaconCalculatedProps = useSignWithBeacon({ ...signProps });
- const calculatedProps = beaconCalculatedProps;
+ const walletConnectCalculatedProps = useSignWithWalletConnect({ ...signProps });
+ const calculatedProps =
+ signProps.requestId.sdkType === "beacon" ? beaconCalculatedProps : walletConnectCalculatedProps;
switch (operationType) {
case "tez": {
diff --git a/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx b/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx
index dfcb2ae58..af17e4cad 100644
--- a/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx
+++ b/apps/web/src/components/WalletConnect/WalletConnectProvider.tsx
@@ -12,13 +12,14 @@ import {
walletKit,
} from "@umami/state";
import { type Network } from "@umami/tezos";
-import { CustomError } from "@umami/utils";
+import { CustomError, WalletConnectError } from "@umami/utils";
import { formatJsonRpcError } from "@walletconnect/jsonrpc-utils";
import { type SessionTypes } from "@walletconnect/types";
-import { getSdkError } from "@walletconnect/utils";
+import { type SdkErrorKey, getSdkError } from "@walletconnect/utils";
import { type PropsWithChildren, useCallback, useEffect, useRef } from "react";
import { SessionProposalModal } from "./SessionProposalModal";
+import { useHandleWcRequest } from "./useHandleWcRequest";
enum WalletKitState {
NOT_INITIALIZED,
@@ -36,6 +37,8 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {
const availableNetworks: Network[] = useAvailableNetworks();
+ const handleWcRequest = useHandleWcRequest();
+
const onSessionProposal = useCallback(
(proposal: WalletKitTypes.SessionProposal) =>
handleAsyncActionUnsafe(async () => {
@@ -87,43 +90,37 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {
);
const onSessionRequest = useCallback(
- async (event: WalletKitTypes.SessionRequest) => {
- try {
+ async (event: WalletKitTypes.SessionRequest) =>
+ handleAsyncActionUnsafe(async () => {
const activeSessions: Record = walletKit.getActiveSessions();
if (!(event.topic in activeSessions)) {
- console.error("WalletConnect session request failed. Session not found", event);
- throw new CustomError("WalletConnect session request failed. Session not found");
+ throw new WalletConnectError("Session not found", "INVALID_EVENT", null);
}
const session = activeSessions[event.topic];
-
toast({
description: `Session request from dApp ${session.peer.metadata.name}`,
status: "info",
});
- throw new CustomError("Not implemented");
- } catch (error) {
+ await handleWcRequest(event, session);
+ }).catch(async error => {
const { id, topic } = event;
- const activeSessions: Record = walletKit.getActiveSessions();
- console.error("WalletConnect session request failed", event, error);
- if (event.topic in activeSessions) {
- const session = activeSessions[event.topic];
- toast({
- description: `Session request for dApp ${session.peer.metadata.name} failed. It was rejected.`,
- status: "error",
- });
+ let sdkErrorKey: SdkErrorKey =
+ error instanceof WalletConnectError ? error.sdkError : "SESSION_SETTLEMENT_FAILED";
+ if (sdkErrorKey === "USER_REJECTED") {
+ console.info("WC request rejected", sdkErrorKey, event, error);
} else {
- toast({
- description: `Session request for dApp ${topic} failed. It was rejected. Peer not found by topic.`,
- status: "error",
- });
+ if (error.message.includes("delegate.unchanged")) {
+ sdkErrorKey = "INVALID_EVENT";
+ }
+ console.warn("WC request failed", sdkErrorKey, event, error);
}
// dApp is waiting so we need to notify it
- const response = formatJsonRpcError(id, getSdkError("INVALID_METHOD").message);
+ const sdkErrorMessage = getSdkError(sdkErrorKey).message;
+ const response = formatJsonRpcError(id, sdkErrorMessage);
await walletKit.respondSessionRequest({ topic, response });
- }
- },
- [toast]
+ }),
+ [handleAsyncActionUnsafe, handleWcRequest, toast]
);
useEffect(() => {
diff --git a/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx
new file mode 100644
index 000000000..c1a4b68da
--- /dev/null
+++ b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx
@@ -0,0 +1,116 @@
+import { useDynamicModalContext } from "@umami/components";
+import { type ImplicitAccount, estimate, toAccountOperations } from "@umami/core";
+import { useAsyncActionHandler, useFindNetwork, useGetOwnedAccountSafe } from "@umami/state";
+import { WalletConnectError } from "@umami/utils";
+import { type SessionTypes, type SignClientTypes, type Verify } from "@walletconnect/types";
+
+import { BatchSignPage } from "../SendFlow/common/BatchSignPage";
+import { SingleSignPage } from "../SendFlow/common/SingleSignPage";
+import { type SdkSignPageProps, type SignHeaderProps } from "../SendFlow/utils";
+
+/**
+ * @returns a function that handles a beacon message and opens a modal with the appropriate content
+ *
+ * For operation requests it will also try to convert the operation(s) to our {@link Operation} format,
+ * estimate the fee and open the BeaconSignPage only if it succeeds
+ */
+export const useHandleWcRequest = () => {
+ const { openWith } = useDynamicModalContext();
+ const { handleAsyncActionUnsafe } = useAsyncActionHandler();
+ const getAccount = useGetOwnedAccountSafe();
+ const findNetwork = useFindNetwork();
+
+ return async (
+ event: {
+ verifyContext: Verify.Context;
+ } & SignClientTypes.BaseEventArgs<{
+ request: {
+ method: string;
+ params: any;
+ expiryTimestamp?: number;
+ };
+ chainId: string;
+ }>,
+ session: SessionTypes.Struct
+ ) => {
+ await handleAsyncActionUnsafe(async () => {
+ const { id, topic, params } = event;
+ const { request, chainId } = params;
+
+ let modal;
+ let onClose;
+
+ switch (request.method) {
+ case "tezos_getAccounts": {
+ throw new WalletConnectError(
+ "Getting accounts is not supported yet",
+ "WC_METHOD_UNSUPPORTED",
+ session
+ );
+ }
+
+ case "tezos_sign": {
+ throw new WalletConnectError(
+ "Sign is not supported yet",
+ "WC_METHOD_UNSUPPORTED",
+ session
+ );
+ }
+
+ case "tezos_send": {
+ if (!request.params.account) {
+ throw new WalletConnectError("Missing account in request", "INVALID_EVENT", session);
+ }
+ const signer = getAccount(request.params.account);
+ if (!signer) {
+ throw new WalletConnectError(
+ `Unknown account, no signer: ${request.params.account}`,
+ "UNAUTHORIZED_EVENT",
+ session
+ );
+ }
+ const operation = toAccountOperations(
+ request.params.operations,
+ signer as ImplicitAccount
+ );
+ const network = findNetwork(chainId.split(":")[1]);
+ if (!network) {
+ throw new WalletConnectError(
+ `Unsupported network ${chainId}`,
+ "UNSUPPORTED_CHAINS",
+ session
+ );
+ }
+ const estimatedOperations = await estimate(operation, network);
+ const headerProps: SignHeaderProps = {
+ network,
+ appName: session.peer.metadata.name,
+ appIcon: session.peer.metadata.icons[0],
+ };
+ const signProps: SdkSignPageProps = {
+ headerProps: headerProps,
+ operation: estimatedOperations,
+ requestId: { sdkType: "walletconnect", id: id, topic },
+ };
+
+ if (operation.operations.length === 1) {
+ modal = ;
+ } else {
+ modal = ;
+ }
+ onClose = () => {
+ throw new WalletConnectError("Rejected by user", "USER_REJECTED", session);
+ };
+
+ return openWith(modal, { onClose });
+ }
+ default:
+ throw new WalletConnectError(
+ `Unsupported method ${request.method}`,
+ "WC_METHOD_UNSUPPORTED",
+ session
+ );
+ }
+ });
+ };
+};
diff --git a/packages/utils/src/ErrorContext.test.ts b/packages/utils/src/ErrorContext.test.ts
index 28fc72059..fd3ef7160 100644
--- a/packages/utils/src/ErrorContext.test.ts
+++ b/packages/utils/src/ErrorContext.test.ts
@@ -1,4 +1,4 @@
-import { CustomError, getErrorContext, handleTezError } from "./ErrorContext";
+import { CustomError, WalletConnectError, getErrorContext, handleTezError } from "./ErrorContext";
describe("getErrorContext", () => {
it("should handle error object with message and stack", () => {
@@ -53,6 +53,16 @@ describe("getErrorContext", () => {
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 context = getErrorContext(error);
+
+ expect(context.technicalDetails).toBe("");
+ expect(context.description).toBe("Custom WC error message");
+ expect(context.stacktrace).toBeDefined();
+ expect(context.timestamp).toBeDefined();
+ });
});
describe("handleTezError", () => {
@@ -78,6 +88,11 @@ describe("handleTezError", () => {
);
});
+ it("catches delegate.unchanged", () => {
+ const res = handleTezError(new Error("delegate.unchanged"));
+ expect(res).toBe("The delegate is unchanged. Delegation to this address is already done.");
+ });
+
it("returns undefined for unknown errors", () => {
const err = new Error("unknown error");
expect(handleTezError(err)).toBeUndefined();
diff --git a/packages/utils/src/ErrorContext.ts b/packages/utils/src/ErrorContext.ts
index 1766f220f..6e1a9bad6 100644
--- a/packages/utils/src/ErrorContext.ts
+++ b/packages/utils/src/ErrorContext.ts
@@ -1,3 +1,5 @@
+import { type SessionTypes } from "@walletconnect/types";
+import { type SdkErrorKey } from "@walletconnect/utils";
export type ErrorContext = {
timestamp: string;
description: string;
@@ -12,6 +14,16 @@ export class CustomError extends Error {
}
}
+export class WalletConnectError extends CustomError {
+ sdkError: SdkErrorKey;
+ constructor(message: string, sdkError: SdkErrorKey, session: SessionTypes.Struct | null) {
+ const dappName = session?.peer.metadata.name ?? "unknown";
+ super(session ? `Request from ${dappName} is rejected. ${message}` : message);
+ this.name = "WalletConnectError";
+ this.sdkError = sdkError;
+ }
+}
+
// 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")) {
@@ -22,6 +34,8 @@ export const handleTezError = (err: Error): string | undefined => {
return "The baker you are trying to stake to does not accept external staking.";
} else if (err.message.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")) {
+ return "The delegate is unchanged. Delegation to this address is already done.";
}
};
@@ -41,7 +55,7 @@ export const getErrorContext = (error: any): ErrorContext => {
technicalDetails = error;
}
- if (error.name === "CustomError") {
+ if (error instanceof CustomError) {
description = error.message;
technicalDetails = "";
} else if (error instanceof Error) {