Skip to content

Commit 9001b25

Browse files
Merge pull request #2029 from trilitech/add-wc-request
feat: WalletConnect integration, part 6, request
2 parents 28166c5 + e6cee09 commit 9001b25

File tree

8 files changed

+230
-30
lines changed

8 files changed

+230
-30
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { type TezosToolkit } from "@taquito/taquito";
2+
import { useDynamicModalContext } from "@umami/components";
3+
import { executeOperations, totalFee } from "@umami/core";
4+
import { useAsyncActionHandler, walletKit } from "@umami/state";
5+
import { formatJsonRpcResult } from "@walletconnect/jsonrpc-utils";
6+
import { useForm } from "react-hook-form";
7+
8+
import { SuccessStep } from "../SuccessStep";
9+
import { type CalculatedSignProps, type SdkSignPageProps } from "../utils";
10+
11+
export const useSignWithWalletConnect = ({
12+
operation,
13+
headerProps,
14+
requestId,
15+
}: SdkSignPageProps): CalculatedSignProps => {
16+
const { isLoading: isSigning, handleAsyncAction } = useAsyncActionHandler();
17+
const { openWith } = useDynamicModalContext();
18+
19+
const form = useForm({ defaultValues: { executeParams: operation.estimates } });
20+
21+
if (requestId.sdkType !== "walletconnect") {
22+
return {
23+
fee: 0,
24+
isSigning: false,
25+
onSign: async () => {},
26+
network: null,
27+
};
28+
}
29+
30+
const onSign = async (tezosToolkit: TezosToolkit) =>
31+
handleAsyncAction(
32+
async () => {
33+
const { opHash } = await executeOperations(
34+
{ ...operation, estimates: form.watch("executeParams") },
35+
tezosToolkit
36+
);
37+
38+
const response = formatJsonRpcResult(requestId.id, { hash: opHash });
39+
await walletKit.respondSessionRequest({ topic: requestId.topic, response });
40+
return openWith(<SuccessStep hash={opHash} />);
41+
},
42+
error => ({
43+
description: `Failed to confirm WalletConnect operation: ${error.message}`,
44+
})
45+
);
46+
47+
return {
48+
fee: totalFee(form.watch("executeParams")),
49+
isSigning,
50+
onSign,
51+
network: headerProps.network,
52+
};
53+
};

apps/web/src/components/SendFlow/common/BatchSignPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useSignWithBeacon } from "../Beacon/useSignWithBeacon";
2323
import { SignButton } from "../SignButton";
2424
import { SignPageFee } from "../SignPageFee";
2525
import { type SdkSignPageProps } from "../utils";
26+
import { useSignWithWalletConnect } from "../WalletConnect/useSignWithWalletConnect";
2627

2728
export const BatchSignPage = (
2829
signProps: SdkSignPageProps,
@@ -31,7 +32,9 @@ export const BatchSignPage = (
3132
const color = useColor();
3233

3334
const beaconCalculatedProps = useSignWithBeacon({ ...signProps });
34-
const calculatedProps = beaconCalculatedProps;
35+
const walletConnectCalculatedProps = useSignWithWalletConnect({ ...signProps });
36+
const calculatedProps =
37+
signProps.requestId.sdkType === "beacon" ? beaconCalculatedProps : walletConnectCalculatedProps;
3538

3639
const { isSigning, onSign, network, fee } = calculatedProps;
3740
const { signer, operations } = signProps.operation;

apps/web/src/components/SendFlow/common/OriginationOperationSignPage.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ export const OriginationOperationSignPage = ({
3737
}: SdkSignPageProps & CalculatedSignProps) => {
3838
const color = useColor();
3939
const { code, storage } = operation.operations[0] as ContractOrigination;
40-
4140
const form = useForm({ defaultValues: { executeParams: operation.estimates } });
4241

4342
return (

apps/web/src/components/SendFlow/common/SingleSignPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import { TezSignPage } from "./TezSignPage";
1010
import { UndelegationSignPage } from "./UndelegationSignPage";
1111
import { UnstakeSignPage } from "./UnstakeSignPage";
1212
import { useSignWithBeacon } from "../Beacon/useSignWithBeacon";
13+
import { useSignWithWalletConnect } from "../WalletConnect/useSignWithWalletConnect";
1314

1415
export const SingleSignPage = (signProps: SdkSignPageProps) => {
1516
const operationType = signProps.operation.operations[0].type;
1617

1718
const beaconCalculatedProps = useSignWithBeacon({ ...signProps });
18-
const calculatedProps = beaconCalculatedProps;
19+
const walletConnectCalculatedProps = useSignWithWalletConnect({ ...signProps });
20+
const calculatedProps =
21+
signProps.requestId.sdkType === "beacon" ? beaconCalculatedProps : walletConnectCalculatedProps;
1922

2023
switch (operationType) {
2124
case "tez": {

apps/web/src/components/WalletConnect/WalletConnectProvider.tsx

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ import {
1212
walletKit,
1313
} from "@umami/state";
1414
import { type Network } from "@umami/tezos";
15-
import { CustomError } from "@umami/utils";
15+
import { CustomError, WalletConnectError } from "@umami/utils";
1616
import { formatJsonRpcError } from "@walletconnect/jsonrpc-utils";
1717
import { type SessionTypes } from "@walletconnect/types";
18-
import { getSdkError } from "@walletconnect/utils";
18+
import { type SdkErrorKey, getSdkError } from "@walletconnect/utils";
1919
import { type PropsWithChildren, useCallback, useEffect, useRef } from "react";
2020

2121
import { SessionProposalModal } from "./SessionProposalModal";
22+
import { useHandleWcRequest } from "./useHandleWcRequest";
2223

2324
enum WalletKitState {
2425
NOT_INITIALIZED,
@@ -36,6 +37,8 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {
3637

3738
const availableNetworks: Network[] = useAvailableNetworks();
3839

40+
const handleWcRequest = useHandleWcRequest();
41+
3942
const onSessionProposal = useCallback(
4043
(proposal: WalletKitTypes.SessionProposal) =>
4144
handleAsyncActionUnsafe(async () => {
@@ -87,43 +90,37 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {
8790
);
8891

8992
const onSessionRequest = useCallback(
90-
async (event: WalletKitTypes.SessionRequest) => {
91-
try {
93+
async (event: WalletKitTypes.SessionRequest) =>
94+
handleAsyncActionUnsafe(async () => {
9295
const activeSessions: Record<string, SessionTypes.Struct> = walletKit.getActiveSessions();
9396
if (!(event.topic in activeSessions)) {
94-
console.error("WalletConnect session request failed. Session not found", event);
95-
throw new CustomError("WalletConnect session request failed. Session not found");
97+
throw new WalletConnectError("Session not found", "INVALID_EVENT", null);
9698
}
9799

98100
const session = activeSessions[event.topic];
99-
100101
toast({
101102
description: `Session request from dApp ${session.peer.metadata.name}`,
102103
status: "info",
103104
});
104-
throw new CustomError("Not implemented");
105-
} catch (error) {
105+
await handleWcRequest(event, session);
106+
}).catch(async error => {
106107
const { id, topic } = event;
107-
const activeSessions: Record<string, SessionTypes.Struct> = walletKit.getActiveSessions();
108-
console.error("WalletConnect session request failed", event, error);
109-
if (event.topic in activeSessions) {
110-
const session = activeSessions[event.topic];
111-
toast({
112-
description: `Session request for dApp ${session.peer.metadata.name} failed. It was rejected.`,
113-
status: "error",
114-
});
108+
let sdkErrorKey: SdkErrorKey =
109+
error instanceof WalletConnectError ? error.sdkError : "SESSION_SETTLEMENT_FAILED";
110+
if (sdkErrorKey === "USER_REJECTED") {
111+
console.info("WC request rejected", sdkErrorKey, event, error);
115112
} else {
116-
toast({
117-
description: `Session request for dApp ${topic} failed. It was rejected. Peer not found by topic.`,
118-
status: "error",
119-
});
113+
if (error.message.includes("delegate.unchanged")) {
114+
sdkErrorKey = "INVALID_EVENT";
115+
}
116+
console.warn("WC request failed", sdkErrorKey, event, error);
120117
}
121118
// dApp is waiting so we need to notify it
122-
const response = formatJsonRpcError(id, getSdkError("INVALID_METHOD").message);
119+
const sdkErrorMessage = getSdkError(sdkErrorKey).message;
120+
const response = formatJsonRpcError(id, sdkErrorMessage);
123121
await walletKit.respondSessionRequest({ topic, response });
124-
}
125-
},
126-
[toast]
122+
}),
123+
[handleAsyncActionUnsafe, handleWcRequest, toast]
127124
);
128125

129126
useEffect(() => {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useDynamicModalContext } from "@umami/components";
2+
import { type ImplicitAccount, estimate, toAccountOperations } from "@umami/core";
3+
import { useAsyncActionHandler, useFindNetwork, useGetOwnedAccountSafe } from "@umami/state";
4+
import { WalletConnectError } from "@umami/utils";
5+
import { type SessionTypes, type SignClientTypes, type Verify } from "@walletconnect/types";
6+
7+
import { BatchSignPage } from "../SendFlow/common/BatchSignPage";
8+
import { SingleSignPage } from "../SendFlow/common/SingleSignPage";
9+
import { type SdkSignPageProps, type SignHeaderProps } from "../SendFlow/utils";
10+
11+
/**
12+
* @returns a function that handles a beacon message and opens a modal with the appropriate content
13+
*
14+
* For operation requests it will also try to convert the operation(s) to our {@link Operation} format,
15+
* estimate the fee and open the BeaconSignPage only if it succeeds
16+
*/
17+
export const useHandleWcRequest = () => {
18+
const { openWith } = useDynamicModalContext();
19+
const { handleAsyncActionUnsafe } = useAsyncActionHandler();
20+
const getAccount = useGetOwnedAccountSafe();
21+
const findNetwork = useFindNetwork();
22+
23+
return async (
24+
event: {
25+
verifyContext: Verify.Context;
26+
} & SignClientTypes.BaseEventArgs<{
27+
request: {
28+
method: string;
29+
params: any;
30+
expiryTimestamp?: number;
31+
};
32+
chainId: string;
33+
}>,
34+
session: SessionTypes.Struct
35+
) => {
36+
await handleAsyncActionUnsafe(async () => {
37+
const { id, topic, params } = event;
38+
const { request, chainId } = params;
39+
40+
let modal;
41+
let onClose;
42+
43+
switch (request.method) {
44+
case "tezos_getAccounts": {
45+
throw new WalletConnectError(
46+
"Getting accounts is not supported yet",
47+
"WC_METHOD_UNSUPPORTED",
48+
session
49+
);
50+
}
51+
52+
case "tezos_sign": {
53+
throw new WalletConnectError(
54+
"Sign is not supported yet",
55+
"WC_METHOD_UNSUPPORTED",
56+
session
57+
);
58+
}
59+
60+
case "tezos_send": {
61+
if (!request.params.account) {
62+
throw new WalletConnectError("Missing account in request", "INVALID_EVENT", session);
63+
}
64+
const signer = getAccount(request.params.account);
65+
if (!signer) {
66+
throw new WalletConnectError(
67+
`Unknown account, no signer: ${request.params.account}`,
68+
"UNAUTHORIZED_EVENT",
69+
session
70+
);
71+
}
72+
const operation = toAccountOperations(
73+
request.params.operations,
74+
signer as ImplicitAccount
75+
);
76+
const network = findNetwork(chainId.split(":")[1]);
77+
if (!network) {
78+
throw new WalletConnectError(
79+
`Unsupported network ${chainId}`,
80+
"UNSUPPORTED_CHAINS",
81+
session
82+
);
83+
}
84+
const estimatedOperations = await estimate(operation, network);
85+
const headerProps: SignHeaderProps = {
86+
network,
87+
appName: session.peer.metadata.name,
88+
appIcon: session.peer.metadata.icons[0],
89+
};
90+
const signProps: SdkSignPageProps = {
91+
headerProps: headerProps,
92+
operation: estimatedOperations,
93+
requestId: { sdkType: "walletconnect", id: id, topic },
94+
};
95+
96+
if (operation.operations.length === 1) {
97+
modal = <SingleSignPage {...signProps} />;
98+
} else {
99+
modal = <BatchSignPage {...signProps} {...event.params.request.params} />;
100+
}
101+
onClose = () => {
102+
throw new WalletConnectError("Rejected by user", "USER_REJECTED", session);
103+
};
104+
105+
return openWith(modal, { onClose });
106+
}
107+
default:
108+
throw new WalletConnectError(
109+
`Unsupported method ${request.method}`,
110+
"WC_METHOD_UNSUPPORTED",
111+
session
112+
);
113+
}
114+
});
115+
};
116+
};

packages/utils/src/ErrorContext.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CustomError, getErrorContext, handleTezError } from "./ErrorContext";
1+
import { CustomError, WalletConnectError, getErrorContext, handleTezError } from "./ErrorContext";
22

33
describe("getErrorContext", () => {
44
it("should handle error object with message and stack", () => {
@@ -53,6 +53,16 @@ describe("getErrorContext", () => {
5353
expect(context.stacktrace).toBeDefined();
5454
expect(context.timestamp).toBeDefined();
5555
});
56+
it("should handle WalletConnectError instances", () => {
57+
const error = new WalletConnectError("Custom WC error message", "UNSUPPORTED_EVENTS", null);
58+
59+
const context = getErrorContext(error);
60+
61+
expect(context.technicalDetails).toBe("");
62+
expect(context.description).toBe("Custom WC error message");
63+
expect(context.stacktrace).toBeDefined();
64+
expect(context.timestamp).toBeDefined();
65+
});
5666
});
5767

5868
describe("handleTezError", () => {
@@ -78,6 +88,11 @@ describe("handleTezError", () => {
7888
);
7989
});
8090

91+
it("catches delegate.unchanged", () => {
92+
const res = handleTezError(new Error("delegate.unchanged"));
93+
expect(res).toBe("The delegate is unchanged. Delegation to this address is already done.");
94+
});
95+
8196
it("returns undefined for unknown errors", () => {
8297
const err = new Error("unknown error");
8398
expect(handleTezError(err)).toBeUndefined();

packages/utils/src/ErrorContext.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { type SessionTypes } from "@walletconnect/types";
2+
import { type SdkErrorKey } from "@walletconnect/utils";
13
export type ErrorContext = {
24
timestamp: string;
35
description: string;
@@ -12,6 +14,16 @@ export class CustomError extends Error {
1214
}
1315
}
1416

17+
export class WalletConnectError extends CustomError {
18+
sdkError: SdkErrorKey;
19+
constructor(message: string, sdkError: SdkErrorKey, session: SessionTypes.Struct | null) {
20+
const dappName = session?.peer.metadata.name ?? "unknown";
21+
super(session ? `Request from ${dappName} is rejected. ${message}` : message);
22+
this.name = "WalletConnectError";
23+
this.sdkError = sdkError;
24+
}
25+
}
26+
1527
// Converts a known L1 error message to a more user-friendly one
1628
export const handleTezError = (err: Error): string | undefined => {
1729
if (err.message.includes("subtraction_underflow")) {
@@ -22,6 +34,8 @@ export const handleTezError = (err: Error): string | undefined => {
2234
return "The baker you are trying to stake to does not accept external staking.";
2335
} else if (err.message.includes("empty_implicit_delegated_contract")) {
2436
return "Emptying an implicit delegated account is not allowed. End delegation before trying again.";
37+
} else if (err.message.includes("delegate.unchanged")) {
38+
return "The delegate is unchanged. Delegation to this address is already done.";
2539
}
2640
};
2741

@@ -41,7 +55,7 @@ export const getErrorContext = (error: any): ErrorContext => {
4155
technicalDetails = error;
4256
}
4357

44-
if (error.name === "CustomError") {
58+
if (error instanceof CustomError) {
4559
description = error.message;
4660
technicalDetails = "";
4761
} else if (error instanceof Error) {

0 commit comments

Comments
 (0)