Skip to content

Commit

Permalink
feat: WalletConnect integration, part 7, sign
Browse files Browse the repository at this point in the history
  • Loading branch information
dianasavvatina committed Dec 18, 2024
1 parent cb2e26c commit fa5d9f0
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 53 deletions.
11 changes: 11 additions & 0 deletions apps/web/src/components/SendFlow/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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";
import {
type Account,
type AccountOperations,
type EstimatedAccountOperations,
type ImplicitAccount,
type Operation,
estimate,
executeOperations,
Expand Down Expand Up @@ -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 },
Expand Down
47 changes: 40 additions & 7 deletions apps/web/src/components/WalletConnect/useHandleWcRequest.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +29,7 @@ export const useHandleWcRequest = () => {
const { openWith } = useDynamicModalContext();
const { handleAsyncActionUnsafe } = useAsyncActionHandler();
const getAccount = useGetOwnedAccountSafe();
const getImplicitAccount = useGetImplicitAccount();
const findNetwork = useFindNetwork();

return async (
Expand Down Expand Up @@ -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 = <SignPayloadRequestModal opts={signPayloadProps} />;
onClose = () => {
throw new WalletConnectError("Rejected by user", "USER_REJECTED", session);
};
return openWith(modal, { onClose });
}

case "tezos_send": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe("<useHandleBeaconMessage />", () => {

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 () => {
Expand All @@ -134,7 +134,7 @@ describe("<useHandleBeaconMessage />", () => {

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());

Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/beacon/useHandleBeaconMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
WalletClient,
useAsyncActionHandler,
useFindNetwork,
useGetImplicitAccount,
useGetOwnedAccountSafe,
useRemoveBeaconPeerBySenderId,
} from "@umami/state";
Expand All @@ -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
Expand All @@ -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();

Expand Down Expand Up @@ -83,7 +89,16 @@ export const useHandleBeaconMessage = () => {
break;
}
case BeaconMessageType.SignPayloadRequest: {
modal = <SignPayloadRequestModal request={message} />;
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 = <SignPayloadRequestModal opts={signPayloadProps} />;
onClose = async () => {
await WalletClient.respond({
id: message.id,
Expand Down
84 changes: 66 additions & 18 deletions apps/web/src/components/common/SignPayloadRequestModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -41,23 +60,23 @@ beforeEach(() => {

describe("<SignPayloadRequestModal />", () => {
it("renders the dapp name", async () => {
await renderInModal(<SignPayloadRequestModal request={request} />, store);
await renderInModal(<SignPayloadRequestModal opts={beaconOpts} />, 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(<SignPayloadRequestModal request={request} />, store);
await renderInModal(<SignPayloadRequestModal opts={beaconOpts} />, 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(<SignPayloadRequestModal request={request} />, store);
await renderInModal(<SignPayloadRequestModal opts={beaconOpts} />, store);

await act(() => user.click(screen.getByLabelText("Password")));
await act(() => user.type(screen.getByLabelText("Password"), "123123123"));
Expand All @@ -76,4 +95,33 @@ describe("<SignPayloadRequestModal />", () => {
})
);
});
it("WalletConnect sends the signed payload back to the DApp", async () => {
const user = userEvent.setup();
jest.spyOn(walletKit, "respondSessionRequest");
await renderInModal(<SignPayloadRequestModal opts={wcOpts} />, 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 })
);
});
});
48 changes: 24 additions & 24 deletions apps/web/src/components/common/SignPayloadRequestModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand All @@ -64,7 +64,7 @@ export const SignPayloadRequestModal = ({ request }: { request: SignPayloadReque
<FormProvider {...form}>
<ModalContent>
<ModalHeader marginBottom="32px" textAlign="center">
{`${request.appMetadata.name}/dApp Pairing Request`}
{`Sign Payload Request from ${opts.appName}`}
</ModalHeader>
<ModalCloseButton />

Expand All @@ -90,7 +90,7 @@ export const SignPayloadRequestModal = ({ request }: { request: SignPayloadReque
backgroundColor={color("100")}
>
<Text color={color("600")} size="md">
{showRaw ? request.payload : parsedPayload.trim()}
{showRaw ? opts.payload : parsedPayload.trim()}
</Text>
</Box>

Expand All @@ -105,7 +105,7 @@ export const SignPayloadRequestModal = ({ request }: { request: SignPayloadReque
</ModalBody>

<ModalFooter justifyContent="center" display="flex" padding="16px 0 0 0">
<SignButton onSubmit={sign} signer={signerAccount} text="Sign" />
<SignButton onSubmit={sign} signer={opts.signer} text="Sign" />
</ModalFooter>
</ModalContent>
</FormProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./SignPayloadRequestModal";

1 comment on commit fa5d9f0

@github-actions
Copy link

Choose a reason for hiding this comment

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

Title Lines Statements Branches Functions
apps/desktop Coverage: 83%
83.81% (1786/2131) 79.58% (850/1068) 78.27% (454/580)
apps/web Coverage: 83%
83.81% (1786/2131) 79.58% (850/1068) 78.27% (454/580)
packages/components Coverage: 97%
97.51% (196/201) 95.91% (94/98) 88.13% (52/59)
packages/core Coverage: 81%
82.47% (207/251) 72.72% (88/121) 81.35% (48/59)
packages/crypto Coverage: 100%
100% (43/43) 90.9% (10/11) 100% (7/7)
packages/data-polling Coverage: 97%
95.27% (141/148) 87.5% (21/24) 92.85% (39/42)
packages/multisig Coverage: 98%
98.47% (129/131) 85.71% (18/21) 100% (36/36)
packages/social-auth Coverage: 100%
100% (21/21) 100% (11/11) 100% (3/3)
packages/state Coverage: 85%
84.79% (820/967) 81.03% (188/232) 78.59% (301/383)
packages/tezos Coverage: 89%
88.72% (118/133) 94.59% (35/37) 86.84% (33/38)
packages/tzkt Coverage: 89%
87.32% (62/71) 87.5% (14/16) 80.48% (33/41)

Please sign in to comment.