From 8b1293488c11c81d2bb27b5879ef8765f8579725 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 --- .../beacon/SignPayloadRequestModal.test.tsx | 2 +- .../beacon/useHandleBeaconMessage.test.tsx | 4 +- apps/web/src/components/SendFlow/utils.tsx | 11 ++ .../WalletConnect/useHandleWcRequest.tsx | 47 ++++++- .../beacon/SignPayloadRequestModal.test.tsx | 79 ----------- apps/web/src/components/beacon/index.ts | 1 - .../beacon/useHandleBeaconMessage.test.tsx | 4 +- .../beacon/useHandleBeaconMessage.tsx | 21 ++- .../common/SignPayloadRequestModal.test.tsx | 127 ++++++++++++++++++ .../SignPayloadRequestModal.tsx | 48 +++---- apps/web/src/components/common/index.ts | 1 + 11 files changed, 226 insertions(+), 119 deletions(-) delete mode 100644 apps/web/src/components/beacon/SignPayloadRequestModal.test.tsx create mode 100644 apps/web/src/components/common/SignPayloadRequestModal.test.tsx rename apps/web/src/components/{beacon => common}/SignPayloadRequestModal.tsx (65%) create mode 100644 apps/web/src/components/common/index.ts diff --git a/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx b/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx index 3600c8f2cf..c84effde80 100644 --- a/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx +++ b/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx @@ -44,7 +44,7 @@ describe("", () => { render(, { store }); await waitFor(() => - expect(screen.getByText("mockDappName/dApp Pairing Request")).toBeVisible() + expect(screen.getByText("Sign Payload Request from mockDappName")).toBeVisible() ); }); diff --git a/apps/desktop/src/utils/beacon/useHandleBeaconMessage.test.tsx b/apps/desktop/src/utils/beacon/useHandleBeaconMessage.test.tsx index 05f3a45112..896084e8d1 100644 --- a/apps/desktop/src/utils/beacon/useHandleBeaconMessage.test.tsx +++ b/apps/desktop/src/utils/beacon/useHandleBeaconMessage.test.tsx @@ -107,7 +107,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 () => { @@ -128,7 +128,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/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..47c5facca4 100644 --- a/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx +++ b/apps/web/src/components/WalletConnect/useHandleWcRequest.tsx @@ -1,12 +1,23 @@ +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, +} from "@umami/state"; import { WalletConnectError } from "@umami/utils"; import { type SessionTypes, type SignClientTypes, type Verify } from "@walletconnect/types"; +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 +29,7 @@ export const useHandleWcRequest = () => { const { openWith } = useDynamicModalContext(); const { handleAsyncActionUnsafe } = useAsyncActionHandler(); const getAccount = useGetOwnedAccountSafe(); + const getImplicitAccount = useGetImplicitAccount(); const findNetwork = useFindNetwork(); return async ( @@ -50,11 +62,32 @@ 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 = () => { + throw new WalletConnectError("Rejected by user", "USER_REJECTED", session); + }; + return openWith(modal, { onClose }); } case "tezos_send": { diff --git a/apps/web/src/components/beacon/SignPayloadRequestModal.test.tsx b/apps/web/src/components/beacon/SignPayloadRequestModal.test.tsx deleted file mode 100644 index 9923d4dbe4..0000000000 --- a/apps/web/src/components/beacon/SignPayloadRequestModal.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { - BeaconMessageType, - type SignPayloadRequestOutput, - SigningType, -} from "@airgap/beacon-wallet"; -import { mockImplicitAccount, mockMnemonicAccount } from "@umami/core"; -import { type UmamiStore, WalletClient, accountsActions, makeStore } from "@umami/state"; -import { encryptedMnemonic1 } from "@umami/test-utils"; - -import { SignPayloadRequestModal } from "./SignPayloadRequestModal"; -import { act, renderInModal, screen, userEvent, waitFor } from "../../testUtils"; - -const payload = - "05010000004254657a6f73205369676e6564204d6573736167653a206d79646170702e636f6d20323032312d30312d31345431353a31363a30345a2048656c6c6f20776f726c6421"; -const decodedPayload = "Tezos Signed Message: mydapp.com 2021-01-14T15:16:04Z Hello world!"; -const request: SignPayloadRequestOutput = { - payload, - senderId: "mockSenderId", - type: BeaconMessageType.SignPayloadRequest, - version: "2", - sourceAddress: mockImplicitAccount(1).address.pkh, - signingType: SigningType.RAW, - id: "mockMessageId", - appMetadata: { name: "mockDappName", senderId: "mockSenderId" }, -}; - -const account = mockMnemonicAccount(1); - -let store: UmamiStore; - -beforeEach(() => { - store = makeStore(); - store.dispatch( - accountsActions.addMnemonicAccounts({ - seedFingerprint: account.seedFingerPrint, - accounts: [account], - encryptedMnemonic: encryptedMnemonic1, - }) - ); -}); - -describe("", () => { - it("renders the dapp name", async () => { - await renderInModal(, store); - - await waitFor(() => - expect(screen.getByText("mockDappName/dApp Pairing Request")).toBeVisible() - ); - }); - - it("renders the payload to sign", async () => { - await renderInModal(, store); - - await waitFor(() => expect(screen.getByText(new RegExp(decodedPayload))).toBeVisible()); - }); - - it("sends the signed payload back to the DApp", async () => { - const user = userEvent.setup(); - jest.spyOn(WalletClient, "respond"); - await renderInModal(, store); - - 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)); - - await waitFor(() => - expect(WalletClient.respond).toHaveBeenCalledWith({ - id: "mockMessageId", - signingType: "raw", - type: "sign_payload_response", - signature: - "edsigtqC1pJWaJ7rGm75PZAWyX75hH2BiKCb1EM3MotDSjEqHEA2tVZ1FPd8k4SwRMR74ytDVcCXrZqKJ9LtsDoduCJLMAeBq88", - }) - ); - }); -}); diff --git a/apps/web/src/components/beacon/index.ts b/apps/web/src/components/beacon/index.ts index eec4203f8a..36a1700562 100644 --- a/apps/web/src/components/beacon/index.ts +++ b/apps/web/src/components/beacon/index.ts @@ -1,4 +1,3 @@ export * from "./BeaconProvider"; export * from "./PermissionRequestModal"; -export * from "./SignPayloadRequestModal"; export * from "./useHandleBeaconMessage"; 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 472cec316f..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"; @@ -17,10 +18,14 @@ import { type Network } from "@umami/tezos"; import { CustomError } from "@umami/utils"; import { PermissionRequestModal } from "./PermissionRequestModal"; -import { SignPayloadRequestModal } from "./SignPayloadRequestModal"; +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 new file mode 100644 index 0000000000..fa1b76184f --- /dev/null +++ b/apps/web/src/components/common/SignPayloadRequestModal.test.tsx @@ -0,0 +1,127 @@ +import { SigningType } from "@airgap/beacon-wallet"; +import { mockImplicitAccount, mockMnemonicAccount } from "@umami/core"; +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 beaconOpts: SignPayloadProps = { + appName: "mockBeaconDappName", + appIcon: "", + payload, + signer: mockImplicitAccount(1), + signingType: SigningType.RAW, + requestId: { sdkType: "beacon", id: "mockMessageId" }, +}; +const wcOpts: SignPayloadProps = { + appName: "mockWalletConnectDappName", + appIcon: "", + payload, + signer: mockImplicitAccount(1), + signingType: SigningType.RAW, + requestId: { sdkType: "walletconnect", id: 123, topic: "mockTopic" }, +}; + +const account = mockMnemonicAccount(1); + +let store: UmamiStore; + +beforeEach(() => { + store = makeStore(); + store.dispatch( + accountsActions.addMnemonicAccounts({ + seedFingerprint: account.seedFingerPrint, + accounts: [account], + encryptedMnemonic: encryptedMnemonic1, + }) + ); +}); + +describe("", () => { + it("renders the dapp name", async () => { + await renderInModal(, store); + + await waitFor(() => + expect(screen.getByText("Sign Payload Request from mockBeaconDappName")).toBeVisible() + ); + }); + + it("renders the payload to sign", async () => { + await renderInModal(, store); + + await waitFor(() => expect(screen.getByText(new RegExp(decodedPayload))).toBeVisible()); + }); + + it("Beacon sends the signed payload back to the DApp", async () => { + const user = userEvent.setup(); + jest.spyOn(WalletClient, "respond"); + await renderInModal(, store); + + 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)); + + await waitFor(() => + expect(WalletClient.respond).toHaveBeenCalledWith({ + id: "mockMessageId", + signingType: "raw", + type: "sign_payload_response", + signature: + "edsigtqC1pJWaJ7rGm75PZAWyX75hH2BiKCb1EM3MotDSjEqHEA2tVZ1FPd8k4SwRMR74ytDVcCXrZqKJ9LtsDoduCJLMAeBq88", + }) + ); + }); + 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/beacon/SignPayloadRequestModal.tsx b/apps/web/src/components/common/SignPayloadRequestModal.tsx similarity index 65% rename from apps/web/src/components/beacon/SignPayloadRequestModal.tsx rename to apps/web/src/components/common/SignPayloadRequestModal.tsx index 332ca3724e..70332f2cb7 100644 --- a/apps/web/src/components/beacon/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 { onClose } = 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", }); onClose(); @@ -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";