Skip to content

Commit

Permalink
feat: WallectConnect validates proposals and requests
Browse files Browse the repository at this point in the history
  • Loading branch information
dianasavvatina committed Feb 19, 2025
1 parent 54b535e commit aacfef0
Show file tree
Hide file tree
Showing 10 changed files with 349 additions and 101 deletions.
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> = {
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

1 comment on commit aacfef0

@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.74% (1788/2135) 79.43% (850/1070) 78.27% (454/580)
apps/web Coverage: 83%
83.74% (1788/2135) 79.43% (850/1070) 78.27% (454/580)
packages/components Coverage: 97%
97.53% (198/203) 95.69% (89/93) 88.33% (53/60)
packages/core Coverage: 81%
82.37% (215/261) 72.51% (95/131) 81.66% (49/60)
packages/crypto Coverage: 100%
100% (43/43) 90.9% (10/11) 100% (7/7)
packages/data-polling Coverage: 96%
94.66% (142/150) 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: 95%
95.45% (21/22) 91.66% (11/12) 100% (3/3)
packages/state Coverage: 83%
83.07% (864/1040) 80.08% (197/246) 76.9% (313/407)
packages/tezos Coverage: 88%
88.14% (119/135) 92.68% (38/41) 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.