From 2b10dd9c541e9af5046f12be7ba558cea56a3874 Mon Sep 17 00:00:00 2001 From: Diana Savatina Date: Fri, 13 Dec 2024 12:11:06 +0000 Subject: [PATCH] feat: WalletConnect integration, part 7, sign --- apps/web/src/components/SendFlow/utils.tsx | 11 +++ .../WalletConnect/useHandleWcRequest.tsx | 54 ++++++++++-- .../beacon/useHandleBeaconMessage.test.tsx | 4 +- .../beacon/useHandleBeaconMessage.tsx | 19 ++++- .../common/SignPayloadRequestModal.test.tsx | 84 +++++++++++++++---- .../common/SignPayloadRequestModal.tsx | 48 +++++------ apps/web/src/components/common/index.ts | 1 + 7 files changed, 168 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/common/index.ts diff --git a/apps/web/src/components/SendFlow/utils.tsx b/apps/web/src/components/SendFlow/utils.tsx index a2c639c54d..dfc8ff88f3 100644 --- a/apps/web/src/components/SendFlow/utils.tsx +++ b/apps/web/src/components/SendFlow/utils.tsx @@ -1,3 +1,4 @@ +import { type SigningType } from "@airgap/beacon-wallet"; import { Button, type ButtonProps } from "@chakra-ui/react"; import { type TezosToolkit } from "@taquito/taquito"; import { useDynamicModalContext } from "@umami/components"; @@ -5,6 +6,7 @@ import { type Account, type AccountOperations, type EstimatedAccountOperations, + type ImplicitAccount, type Operation, estimate, executeOperations, @@ -82,6 +84,15 @@ export type SdkSignPageProps = { headerProps: SignHeaderProps; }; +export type SignPayloadProps = { + requestId: SignRequestId; + appName: string; + appIcon?: string; + payload: string; + signer: ImplicitAccount; + signingType: SigningType; +}; + export const FormSubmitButton = ({ title = "Preview", ...props }: ButtonProps) => { const { formState: { isValid }, diff --git a/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx index c1a4b68da8..89f476e89f 100644 --- a/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx +++ b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx @@ -1,12 +1,26 @@ +import { SigningType } from "@airgap/beacon-wallet"; import { useDynamicModalContext } from "@umami/components"; import { type ImplicitAccount, estimate, toAccountOperations } from "@umami/core"; -import { useAsyncActionHandler, useFindNetwork, useGetOwnedAccountSafe } from "@umami/state"; +import { + useAsyncActionHandler, + useFindNetwork, + useGetImplicitAccount, + useGetOwnedAccountSafe, + walletKit, +} from "@umami/state"; import { WalletConnectError } from "@umami/utils"; +import { formatJsonRpcError } 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"; import { SingleSignPage } from "../SendFlow/common/SingleSignPage"; -import { type SdkSignPageProps, type SignHeaderProps } from "../SendFlow/utils"; +import { + type SdkSignPageProps, + type SignHeaderProps, + type SignPayloadProps, +} from "../SendFlow/utils"; /** * @returns a function that handles a beacon message and opens a modal with the appropriate content @@ -18,6 +32,7 @@ export const useHandleWcRequest = () => { const { openWith } = useDynamicModalContext(); const { handleAsyncActionUnsafe } = useAsyncActionHandler(); const getAccount = useGetOwnedAccountSafe(); + const getImplicitAccount = useGetImplicitAccount(); const findNetwork = useFindNetwork(); return async ( @@ -50,11 +65,36 @@ export const useHandleWcRequest = () => { } case "tezos_sign": { - throw new WalletConnectError( - "Sign is not supported yet", - "WC_METHOD_UNSUPPORTED", - session - ); + if (!request.params.account) { + throw new WalletConnectError("Missing account in request", "INVALID_EVENT", session); + } + const signer = getImplicitAccount(request.params.account); + const network = findNetwork(chainId.split(":")[1]); + if (!network) { + throw new WalletConnectError( + `Unsupported network ${chainId}`, + "UNSUPPORTED_CHAINS", + session + ); + } + const signPayloadProps: SignPayloadProps = { + appName: session.peer.metadata.name, + appIcon: session.peer.metadata.icons[0], + payload: request.params.payload, + signer: signer, + signingType: SigningType.RAW, + requestId: { sdkType: "walletconnect", id: id, topic }, + }; + + 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 }); + }; + return openWith(modal, { onClose }); } case "tezos_send": { diff --git a/apps/web/src/components/beacon/useHandleBeaconMessage.test.tsx b/apps/web/src/components/beacon/useHandleBeaconMessage.test.tsx index 63c484b9ca..00eb28aeed 100644 --- a/apps/web/src/components/beacon/useHandleBeaconMessage.test.tsx +++ b/apps/web/src/components/beacon/useHandleBeaconMessage.test.tsx @@ -113,7 +113,7 @@ describe("", () => { act(() => handleMessage(message)); - await screen.findByText("mockDappName/dApp Pairing Request"); + await screen.findByText("Sign Payload Request from mockDappName"); }); it("sends an error response to the dapp on close", async () => { @@ -134,7 +134,7 @@ describe("", () => { act(() => handleMessage(message)); - await screen.findByText("mockDappName/dApp Pairing Request"); + await screen.findByText("Sign Payload Request from mockDappName"); act(() => screen.getByRole("button", { name: "Close" }).click()); diff --git a/apps/web/src/components/beacon/useHandleBeaconMessage.tsx b/apps/web/src/components/beacon/useHandleBeaconMessage.tsx index a5af8daeeb..a2ae139318 100644 --- a/apps/web/src/components/beacon/useHandleBeaconMessage.tsx +++ b/apps/web/src/components/beacon/useHandleBeaconMessage.tsx @@ -10,6 +10,7 @@ import { WalletClient, useAsyncActionHandler, useFindNetwork, + useGetImplicitAccount, useGetOwnedAccountSafe, useRemoveBeaconPeerBySenderId, } from "@umami/state"; @@ -20,7 +21,11 @@ import { PermissionRequestModal } from "./PermissionRequestModal"; import { SignPayloadRequestModal } from "../common/SignPayloadRequestModal"; import { BatchSignPage } from "../SendFlow/common/BatchSignPage"; import { SingleSignPage } from "../SendFlow/common/SingleSignPage"; -import { type SdkSignPageProps, type SignHeaderProps } from "../SendFlow/utils"; +import { + type SdkSignPageProps, + type SignHeaderProps, + type SignPayloadProps, +} from "../SendFlow/utils"; /** * @returns a function that handles a beacon message and opens a modal with the appropriate content @@ -32,6 +37,7 @@ export const useHandleBeaconMessage = () => { const { openWith } = useDynamicModalContext(); const { handleAsyncAction } = useAsyncActionHandler(); const getAccount = useGetOwnedAccountSafe(); + const getImplicitAccount = useGetImplicitAccount(); const findNetwork = useFindNetwork(); const removePeer = useRemoveBeaconPeerBySenderId(); @@ -83,7 +89,16 @@ export const useHandleBeaconMessage = () => { break; } case BeaconMessageType.SignPayloadRequest: { - modal = ; + const signer = getImplicitAccount(message.sourceAddress); + const signPayloadProps: SignPayloadProps = { + appName: message.appMetadata.name, + appIcon: message.appMetadata.icon, + payload: message.payload, + signer: signer, + signingType: message.signingType, + requestId: { sdkType: "beacon", id: message.id }, + }; + modal = ; onClose = async () => { await WalletClient.respond({ id: message.id, diff --git a/apps/web/src/components/common/SignPayloadRequestModal.test.tsx b/apps/web/src/components/common/SignPayloadRequestModal.test.tsx index 9923d4dbe4..fa1b76184f 100644 --- a/apps/web/src/components/common/SignPayloadRequestModal.test.tsx +++ b/apps/web/src/components/common/SignPayloadRequestModal.test.tsx @@ -1,27 +1,46 @@ -import { - BeaconMessageType, - type SignPayloadRequestOutput, - SigningType, -} from "@airgap/beacon-wallet"; +import { SigningType } from "@airgap/beacon-wallet"; import { mockImplicitAccount, mockMnemonicAccount } from "@umami/core"; -import { type UmamiStore, WalletClient, accountsActions, makeStore } from "@umami/state"; +import { type UmamiStore, WalletClient, accountsActions, makeStore, walletKit } from "@umami/state"; import { encryptedMnemonic1 } from "@umami/test-utils"; +import { type JsonRpcResult } from "@walletconnect/jsonrpc-utils"; import { SignPayloadRequestModal } from "./SignPayloadRequestModal"; import { act, renderInModal, screen, userEvent, waitFor } from "../../testUtils"; +import { type SignPayloadProps } from "../SendFlow/utils"; + +jest.mock("@umami/state", () => ({ + ...jest.requireActual("@umami/state"), + walletKit: { + core: {}, + metadata: { + name: "AppMenu test", + description: "Umami Wallet with WalletConnect", + url: "https://umamiwallet.com", + icons: ["https://umamiwallet.com/assets/favicon-32-45gq0g6M.png"], + }, + respondSessionRequest: jest.fn(), + }, + createWalletKit: jest.fn(), +})); const payload = "05010000004254657a6f73205369676e6564204d6573736167653a206d79646170702e636f6d20323032312d30312d31345431353a31363a30345a2048656c6c6f20776f726c6421"; const decodedPayload = "Tezos Signed Message: mydapp.com 2021-01-14T15:16:04Z Hello world!"; -const request: SignPayloadRequestOutput = { +const beaconOpts: SignPayloadProps = { + appName: "mockBeaconDappName", + appIcon: "", + payload, + signer: mockImplicitAccount(1), + signingType: SigningType.RAW, + requestId: { sdkType: "beacon", id: "mockMessageId" }, +}; +const wcOpts: SignPayloadProps = { + appName: "mockWalletConnectDappName", + appIcon: "", payload, - senderId: "mockSenderId", - type: BeaconMessageType.SignPayloadRequest, - version: "2", - sourceAddress: mockImplicitAccount(1).address.pkh, + signer: mockImplicitAccount(1), signingType: SigningType.RAW, - id: "mockMessageId", - appMetadata: { name: "mockDappName", senderId: "mockSenderId" }, + requestId: { sdkType: "walletconnect", id: 123, topic: "mockTopic" }, }; const account = mockMnemonicAccount(1); @@ -41,23 +60,23 @@ beforeEach(() => { describe("", () => { it("renders the dapp name", async () => { - await renderInModal(, store); + await renderInModal(, store); await waitFor(() => - expect(screen.getByText("mockDappName/dApp Pairing Request")).toBeVisible() + expect(screen.getByText("Sign Payload Request from mockBeaconDappName")).toBeVisible() ); }); it("renders the payload to sign", async () => { - await renderInModal(, store); + await renderInModal(, store); await waitFor(() => expect(screen.getByText(new RegExp(decodedPayload))).toBeVisible()); }); - it("sends the signed payload back to the DApp", async () => { + it("Beacon sends the signed payload back to the DApp", async () => { const user = userEvent.setup(); jest.spyOn(WalletClient, "respond"); - await renderInModal(, store); + await renderInModal(, store); await act(() => user.click(screen.getByLabelText("Password"))); await act(() => user.type(screen.getByLabelText("Password"), "123123123")); @@ -76,4 +95,33 @@ describe("", () => { }) ); }); + it("WalletConnect sends the signed payload back to the DApp", async () => { + const user = userEvent.setup(); + jest.spyOn(walletKit, "respondSessionRequest"); + await renderInModal(, store); + + await waitFor(() => + expect(screen.getByText("Sign Payload Request from mockWalletConnectDappName")).toBeVisible() + ); + await waitFor(() => expect(screen.getByText(new RegExp(decodedPayload))).toBeVisible()); + + await act(() => user.click(screen.getByLabelText("Password"))); + await act(() => user.type(screen.getByLabelText("Password"), "123123123")); + const confirmButton = screen.getByRole("button", { name: "Sign" }); + expect(confirmButton).toBeEnabled(); + + await act(() => user.click(confirmButton)); + + const response: JsonRpcResult = { + id: 123, + jsonrpc: "2.0", + result: { + signature: + "edsigtqC1pJWaJ7rGm75PZAWyX75hH2BiKCb1EM3MotDSjEqHEA2tVZ1FPd8k4SwRMR74ytDVcCXrZqKJ9LtsDoduCJLMAeBq88", + }, + } as unknown as JsonRpcResult; + await waitFor(() => + expect(walletKit.respondSessionRequest).toHaveBeenCalledWith({ topic: "mockTopic", response }) + ); + }); }); diff --git a/apps/web/src/components/common/SignPayloadRequestModal.tsx b/apps/web/src/components/common/SignPayloadRequestModal.tsx index 72a7f98877..8f2a77f001 100644 --- a/apps/web/src/components/common/SignPayloadRequestModal.tsx +++ b/apps/web/src/components/common/SignPayloadRequestModal.tsx @@ -1,8 +1,4 @@ -import { - BeaconMessageType, - type SignPayloadRequestOutput, - type SignPayloadResponseInput, -} from "@airgap/beacon-wallet"; +import { BeaconMessageType, type SignPayloadResponseInput } from "@airgap/beacon-wallet"; import { WarningIcon } from "@chakra-ui/icons"; import { Box, @@ -20,41 +16,45 @@ import { import { type TezosToolkit } from "@taquito/taquito"; import { useDynamicModalContext } from "@umami/components"; import { decodeBeaconPayload } from "@umami/core"; -import { WalletClient, useGetImplicitAccount } from "@umami/state"; +import { WalletClient, walletKit } from "@umami/state"; +import { formatJsonRpcResult } from "@walletconnect/jsonrpc-utils"; import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useColor } from "../../styles/useColor"; import { SignButton } from "../SendFlow/SignButton"; +import { type SignPayloadProps } from "../SendFlow/utils"; -export const SignPayloadRequestModal = ({ request }: { request: SignPayloadRequestOutput }) => { +export const SignPayloadRequestModal = ({ opts }: { opts: SignPayloadProps }) => { const { goBack } = useDynamicModalContext(); - const getAccount = useGetImplicitAccount(); - const signerAccount = getAccount(request.sourceAddress); const toast = useToast(); const form = useForm(); const color = useColor(); const [showRaw, setShowRaw] = useState(false); const { result: parsedPayload, error: parsingError } = decodeBeaconPayload( - request.payload, - request.signingType + opts.payload, + opts.signingType ); const sign = async (tezosToolkit: TezosToolkit) => { - const result = await tezosToolkit.signer.sign(request.payload); - - const response: SignPayloadResponseInput = { - type: BeaconMessageType.SignPayloadResponse, - id: request.id, - signingType: request.signingType, - signature: result.prefixSig, - }; + const result = await tezosToolkit.signer.sign(opts.payload); - await WalletClient.respond(response); + if (opts.requestId.sdkType === "beacon") { + const response: SignPayloadResponseInput = { + type: BeaconMessageType.SignPayloadResponse, + id: opts.requestId.id.toString(), + signingType: opts.signingType, + signature: result.prefixSig, + }; + await WalletClient.respond(response); + } else { + const response = formatJsonRpcResult(opts.requestId.id, { signature: result.prefixSig }); + await walletKit.respondSessionRequest({ topic: opts.requestId.topic, response }); + } toast({ - description: "Successfully submitted Beacon operation", + description: "Successfully signed the payload", status: "success", }); goBack(); @@ -64,7 +64,7 @@ export const SignPayloadRequestModal = ({ request }: { request: SignPayloadReque - {`${request.appMetadata.name}/dApp Pairing Request`} + {`Sign Payload Request from ${opts.appName}`} @@ -90,7 +90,7 @@ export const SignPayloadRequestModal = ({ request }: { request: SignPayloadReque backgroundColor={color("100")} > - {showRaw ? request.payload : parsedPayload.trim()} + {showRaw ? opts.payload : parsedPayload.trim()} @@ -105,7 +105,7 @@ export const SignPayloadRequestModal = ({ request }: { request: SignPayloadReque - + diff --git a/apps/web/src/components/common/index.ts b/apps/web/src/components/common/index.ts new file mode 100644 index 0000000000..94068949fa --- /dev/null +++ b/apps/web/src/components/common/index.ts @@ -0,0 +1 @@ +export * from "./SignPayloadRequestModal";