Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Web][UMA-1116][UMA-1046] WallectConnect validates proposals and requests #2373

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { type TezosToolkit } from "@taquito/taquito";
import { useDynamicModalContext } from "@umami/components";
import { executeOperations, totalFee } from "@umami/core";
import { useAsyncActionHandler, walletKit } from "@umami/state";
import {
WcScenarioType,
useAsyncActionHandler,
useValidateWcRequest,
walletKit,
} from "@umami/state";
import { getErrorContext } from "@umami/utils";
import { formatJsonRpcResult } from "@walletconnect/jsonrpc-utils";
import { useForm } from "react-hook-form";
Expand All @@ -14,7 +19,8 @@ export const useSignWithWalletConnect = ({
headerProps,
}: SdkSignPageProps): CalculatedSignProps => {
const { isLoading: isSigning, handleAsyncAction } = useAsyncActionHandler();
const { openWith } = useDynamicModalContext();
const { openWith, goBack } = useDynamicModalContext();
const validateWcRequest = useValidateWcRequest();

const form = useForm({ defaultValues: { executeParams: operation.estimates } });
const requestId = headerProps.requestId;
Expand All @@ -31,6 +37,8 @@ export const useSignWithWalletConnect = ({
const onSign = async (tezosToolkit: TezosToolkit) =>
handleAsyncAction(
async () => {
validateWcRequest("request", requestId.id, WcScenarioType.APPROVE, goBack);

const { opHash } = await executeOperations(
{ ...operation, estimates: form.watch("executeParams") },
tezosToolkit
Expand Down
208 changes: 132 additions & 76 deletions apps/web/src/components/SendFlow/common/SingleSignPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ import {
mockUndelegationOperation,
mockUnstakeOperation,
} from "@umami/core";
import { WalletClient, makeStore, networksActions, useGetSecretKey } from "@umami/state";
import {
WalletClient,
makeStore,
networksActions,
useGetSecretKey,
useValidateWcRequest,
walletKit,
} from "@umami/state";
import { executeParams } from "@umami/test-utils";
import { GHOSTNET, makeToolkit } from "@umami/tezos";
import { type JsonRpcResult } from "@walletconnect/jsonrpc-utils";

import {
act,
Expand Down Expand Up @@ -45,108 +53,156 @@ jest.mock("@umami/tezos", () => ({
jest.mock("@umami/state", () => ({
...jest.requireActual("@umami/state"),
useGetSecretKey: jest.fn(),
useValidateWcRequest: jest.fn(),
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(),
}));

describe("<SingleSignPage />", () => {
it("calls the correct modal", async () => {
const store = makeStore();
const user = userEvent.setup();

const message = {
id: "messageid",
type: BeaconMessageType.OperationRequest,
network: { type: NetworkType.GHOSTNET },
appMetadata: { name: "mockDappName", icon: "mockIcon" },
} as OperationRequestOutput;

// check all types of Modals called by SingleSignOperation
const mockedOperations: Record<string, Operation> = {
TezSignPage: mockTezOperation(0),
ContractCallSignPage: mockContractCall(0),
DelegationSignPage: mockDelegationOperation(0),
UndelegationSignPage: mockUndelegationOperation(0),
OriginationOperationSignPage: mockContractOrigination(0),
StakeSignPage: mockStakeOperation(0),
UnstakeSignPage: mockUnstakeOperation(0),
FinalizeUnstakeSignPage: mockFinalizeUnstakeOperation(0),
};

const titles: Record<string, string> = {
TezSignPage: Titles.TezSignPage,
ContractCallSignPage: Titles.ContractCallSignPage,
DelegationSignPage: Titles.DelegationSignPage,
UndelegationSignPage: Titles.UndelegationSignPage,
OriginationOperationSignPage: Titles.OriginationOperationSignPage,
StakeSignPage: Titles.StakeSignPage,
UnstakeSignPage: Titles.UnstakeSignPage,
FinalizeUnstakeSignPage: Titles.FinalizeUnstakeSignPage,
};
const store = makeStore();
const user = userEvent.setup();

const message = {
id: "messageid",
type: BeaconMessageType.OperationRequest,
network: { type: NetworkType.GHOSTNET },
appMetadata: { name: "mockDappName", icon: "mockIcon" },
} as OperationRequestOutput;

const mockedOperations: Record<string, Operation> = {
TezSignPage: mockTezOperation(0),
ContractCallSignPage: mockContractCall(0),
DelegationSignPage: mockDelegationOperation(0),
UndelegationSignPage: mockUndelegationOperation(0),
OriginationOperationSignPage: mockContractOrigination(0),
StakeSignPage: mockStakeOperation(0),
UnstakeSignPage: mockUnstakeOperation(0),
FinalizeUnstakeSignPage: mockFinalizeUnstakeOperation(0),
};

const titles: Record<string, string> = {
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need this explicit type annotation

TezSignPage: Titles.TezSignPage,
ContractCallSignPage: Titles.ContractCallSignPage,
DelegationSignPage: Titles.DelegationSignPage,
UndelegationSignPage: Titles.UndelegationSignPage,
OriginationOperationSignPage: Titles.OriginationOperationSignPage,
StakeSignPage: Titles.StakeSignPage,
UnstakeSignPage: Titles.UnstakeSignPage,
FinalizeUnstakeSignPage: Titles.FinalizeUnstakeSignPage,
};

const operation: EstimatedAccountOperations = {
type: "implicit" as const,
sender: mockImplicitAccount(0),
signer: mockImplicitAccount(0),
operations: [],
estimates: [executeParams({ fee: 123 })],
};

beforeEach(() => {
store.dispatch(networksActions.setCurrent(GHOSTNET));
jest.spyOn(walletKit, "respondSessionRequest");
});

const operation: EstimatedAccountOperations = {
type: "implicit" as const,
sender: mockImplicitAccount(0),
signer: mockImplicitAccount(0),
operations: [],
estimates: [executeParams({ fee: 123 })],
};
const testOperation = async (key: string, sdkType: "beacon" | "walletconnect") => {
operation.operations = [mockedOperations[key]];
const headerProps: SignHeaderProps = {
network: GHOSTNET,
appName: message.appMetadata.name,
appIcon: message.appMetadata.icon,
requestId: { sdkType: "beacon", id: message.id },
requestId:
sdkType === "beacon"
? { sdkType, id: message.id }
: { sdkType, id: 123, topic: "mockTopic" },
};
const signProps: SdkSignPageProps = {
headerProps,
operation,
};
store.dispatch(networksActions.setCurrent(GHOSTNET));

for (const key in mockedOperations) {
operation.operations = [mockedOperations[key]];
const signProps: SdkSignPageProps = {
headerProps: headerProps,
operation: operation,
};
jest.mocked(useGetSecretKey).mockImplementation(() => () => Promise.resolve("secretKey"));

jest.mocked(useGetSecretKey).mockImplementation(() => () => Promise.resolve("secretKey"));
jest.mocked(executeOperations).mockResolvedValue({ opHash: "ophash" } as BatchWalletOperation);

jest
.mocked(executeOperations)
.mockResolvedValue({ opHash: "ophash" } as BatchWalletOperation);
jest.spyOn(WalletClient, "respond").mockResolvedValue();
await renderInModal(<SingleSignPage {...signProps} />, store);

await renderInModal(<SingleSignPage {...signProps} />, store);
expect(screen.getByText("Ghostnet")).toBeInTheDocument();
expect(screen.queryByText("Mainnet")).not.toBeInTheDocument();
expect(screen.getByTestId(key)).toBeInTheDocument(); // e.g. TezSignPage

expect(screen.getByText("Ghostnet")).toBeInTheDocument();
expect(screen.queryByText("Mainnet")).not.toBeInTheDocument();
expect(screen.getByTestId(key)).toBeInTheDocument(); // e.g. TezSignPage
expect(screen.getByTestId("sign-page-header")).toHaveTextContent(titles[key]);
expect(screen.getByTestId("app-name")).toHaveTextContent("mockDappName");

expect(screen.getByTestId("sign-page-header")).toHaveTextContent(titles[key]);
expect(screen.getByTestId("app-name")).toHaveTextContent("mockDappName");
const signButton = screen.getByRole("button", { name: "Confirm transaction" });
await waitFor(() => expect(signButton).toBeDisabled());

const signButton = screen.getByRole("button", {
name: "Confirm transaction",
});
await waitFor(() => expect(signButton).toBeDisabled());
await act(() => user.type(screen.getByLabelText("Password"), "ThisIsAPassword"));

await act(() => user.type(screen.getByLabelText("Password"), "ThisIsAPassword"));
await waitFor(() => expect(signButton).toBeEnabled());

await waitFor(() => expect(signButton).toBeEnabled());
await act(() => user.click(signButton));
await act(() => user.click(signButton));

expect(makeToolkit).toHaveBeenCalledWith({
type: "mnemonic",
secretKey: "secretKey",
network: GHOSTNET,
});
expect(makeToolkit).toHaveBeenCalledWith({
type: "mnemonic",
secretKey: "secretKey",
network: GHOSTNET,
});

if (sdkType === "beacon") {
await waitFor(() =>
expect(WalletClient.respond).toHaveBeenCalledWith({
type: BeaconMessageType.OperationResponse,
id: message.id,
transactionHash: "ophash",
})
);
expect(dynamicModalContextMock.openWith).toHaveBeenCalledWith(<SuccessStep hash="ophash" />, {
canBeOverridden: true,
});
dynamicModalContextMock.openWith.mockClear();
} else {
const response: JsonRpcResult = {
id: 123,
jsonrpc: "2.0",
result: {
hash: "ophash",
operationHash: "ophash",
},
} as unknown as JsonRpcResult;

await waitFor(() =>
expect(walletKit.respondSessionRequest).toHaveBeenCalledWith({
topic: "mockTopic",
response,
})
);
expect(useValidateWcRequest).toHaveBeenCalledTimes(1);
}

expect(dynamicModalContextMock.openWith).toHaveBeenCalledWith(<SuccessStep hash="ophash" />, {
canBeOverridden: true,
});
dynamicModalContextMock.openWith.mockClear();
};

it("Beacon: handles operation and responds", async () => {
jest.spyOn(WalletClient, "respond").mockResolvedValue();

for (const key in mockedOperations) {
await testOperation(key, "beacon");
}
});

it("WalletConnect: handles valid operation and responds", async () => {
for (const key in mockedOperations) {
jest.mocked(useValidateWcRequest).mockImplementation(() => () => true);
await testOperation(key, "walletconnect");
jest.mocked(useValidateWcRequest).mockClear();
}
});
});
40 changes: 28 additions & 12 deletions apps/web/src/components/WalletConnect/SessionProposalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ import {
import { type WalletKitTypes } from "@reown/walletkit";
import { useDynamicModalContext } from "@umami/components";
import {
WcScenarioType,
useAsyncActionHandler,
useGetImplicitAccount,
useToggleWcPeerListUpdated,
useValidateWcRequest,
walletKit,
} from "@umami/state";
import { WalletConnectError, WcErrorCode } from "@umami/utils";
import { type SessionTypes, type Verify } from "@walletconnect/types";
import { buildApprovedNamespaces, getSdkError } from "@walletconnect/utils";
import { FormProvider, useForm } from "react-hook-form";
Expand All @@ -41,6 +44,7 @@ export const SessionProposalModal = ({
}) => {
const getAccount = useGetImplicitAccount();
const toggleWcPeerListUpdated = useToggleWcPeerListUpdated();
const validateWcRequest = useValidateWcRequest();
const color = useColor();

const { goBack } = useDynamicModalContext();
Expand All @@ -61,7 +65,7 @@ export const SessionProposalModal = ({
const onApprove = () =>
handleAsyncAction(async () => {
const account = getAccount(getValues("address"));

validateWcRequest("session proposal", proposal.id, WcScenarioType.APPROVE, goBack);
// prepare the list of accounts and networks to approve
const namespaces = buildApprovedNamespaces({
proposal: proposal.params,
Expand All @@ -75,25 +79,37 @@ export const SessionProposalModal = ({
},
});

const session: SessionTypes.Struct = await walletKit.approveSession({
id: proposal.id,
namespaces,
sessionProperties: {},
});
console.log("WC session approved", session);
toggleWcPeerListUpdated();
goBack();
try {
const session: SessionTypes.Struct = await walletKit.approveSession({
id: proposal.id,
namespaces,
sessionProperties: {},
});
console.log("WC session approved", session);
toggleWcPeerListUpdated();
} catch (error: any) {
throw new WalletConnectError(
"Failed to approve the session. Check the connection at dApp side and try again.",
WcErrorCode.SESSION_NOT_FOUND,
null,
error?.message
);
}
});

const onReject = () =>
handleAsyncAction(async () => {
// Close immediately assuming that the user wants to get rid of the modal
goBack();

console.log("WC session rejected");
await walletKit.rejectSession({
id: proposal.id,
reason: getSdkError("USER_REJECTED_METHODS"),
});
if (validateWcRequest("session proposal", proposal.id, WcScenarioType.REJECT, goBack)) {
await walletKit.rejectSession({
id: proposal.id,
reason: getSdkError("USER_REJECTED_METHODS"),
});
}
});

return (
Expand Down
Loading