From 70e74a8c46195895188b477d46aa4207a3d8c0ff Mon Sep 17 00:00:00 2001 From: Daniel Izdebski Date: Sat, 11 Nov 2023 05:05:50 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(metamask):=20Add=20support=20f?= =?UTF-8?q?or=20`eth=5FsignTypedData=5F(v3|v4)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createCacheForWalletSetupFunction.ts | 2 +- wallets/metamask/src/metamask.ts | 7 ++- .../pages/NotificationPage/actions/index.ts | 1 + .../actions/signSimpleMessage.ts | 15 ++---- .../actions/signStructuredMessage.ts | 25 ++++++++++ .../src/pages/NotificationPage/page.ts | 46 +++++++++++++++-- .../selectors/signaturePage.ts | 13 ++++- wallets/metamask/src/utils/waitFor.ts | 50 +++++++++++++++++++ .../e2e/metamask/confirmSignature.spec.ts | 36 +++++++++++++ .../test/e2e/metamask/rejectSignature.spec.ts | 28 +++++++++++ 10 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 wallets/metamask/src/pages/NotificationPage/actions/signStructuredMessage.ts create mode 100644 wallets/metamask/src/utils/waitFor.ts diff --git a/packages/core/src/utils/createCacheForWalletSetupFunction.ts b/packages/core/src/utils/createCacheForWalletSetupFunction.ts index ca44be3a7..d73baac3c 100644 --- a/packages/core/src/utils/createCacheForWalletSetupFunction.ts +++ b/packages/core/src/utils/createCacheForWalletSetupFunction.ts @@ -2,7 +2,7 @@ import { chromium } from 'playwright-core' import type { WalletSetupFunction } from '../defineWalletSetup' import { waitForExtensionOnLoadPage } from './waitForExtensionOnLoadPage' -// Inlining the sleep function here cause this is the ONLY place in the entire codebase where sleep should be used! +// Inlining the sleep function here cause this is one of the few places in the entire codebase where sleep should be used! const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export async function createCacheForWalletSetupFunction( diff --git a/wallets/metamask/src/metamask.ts b/wallets/metamask/src/metamask.ts index e4783970f..9a91c2125 100644 --- a/wallets/metamask/src/metamask.ts +++ b/wallets/metamask/src/metamask.ts @@ -32,6 +32,7 @@ export class MetaMask { if (!this.extensionId) { throw NO_EXTENSION_ID_ERROR } + await this.notificationPage.connectToDapp(this.extensionId) } @@ -47,13 +48,15 @@ export class MetaMask { if (!this.extensionId) { throw NO_EXTENSION_ID_ERROR } - await this.notificationPage.signSimpleMessage(this.extensionId) + + await this.notificationPage.signMessage(this.extensionId) } async rejectSignature() { if (!this.extensionId) { throw NO_EXTENSION_ID_ERROR } - await this.notificationPage.rejectSimpleMessage(this.extensionId) + + await this.notificationPage.rejectMessage(this.extensionId) } } diff --git a/wallets/metamask/src/pages/NotificationPage/actions/index.ts b/wallets/metamask/src/pages/NotificationPage/actions/index.ts index 0045b804d..6ffeabc33 100644 --- a/wallets/metamask/src/pages/NotificationPage/actions/index.ts +++ b/wallets/metamask/src/pages/NotificationPage/actions/index.ts @@ -1,2 +1,3 @@ export * from './connectToDapp' export * from './signSimpleMessage' +export * from './signStructuredMessage' diff --git a/wallets/metamask/src/pages/NotificationPage/actions/signSimpleMessage.ts b/wallets/metamask/src/pages/NotificationPage/actions/signSimpleMessage.ts index 52e800a76..6a3bc4cb9 100644 --- a/wallets/metamask/src/pages/NotificationPage/actions/signSimpleMessage.ts +++ b/wallets/metamask/src/pages/NotificationPage/actions/signSimpleMessage.ts @@ -1,17 +1,12 @@ -import type { BrowserContext } from '@playwright/test' -import { getNotificationPageAndWaitForLoad } from '../../../utils/getNotificationPageAndWaitForLoad' +import type { Page } from '@playwright/test' import Selectors from '../selectors' -const signMessage = async (context: BrowserContext, extensionId: string) => { - const notificationPage = await getNotificationPageAndWaitForLoad(context, extensionId) - - await notificationPage.locator(Selectors.SignaturePage.signButton).click() +const signMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.SignaturePage.simpleMessage.signButton).click() } -const rejectMessage = async (context: BrowserContext, extensionId: string) => { - const notificationPage = await getNotificationPageAndWaitForLoad(context, extensionId) - - await notificationPage.locator(Selectors.SignaturePage.rejectButton).click() +const rejectMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.SignaturePage.simpleMessage.rejectButton).click() } // Used for: diff --git a/wallets/metamask/src/pages/NotificationPage/actions/signStructuredMessage.ts b/wallets/metamask/src/pages/NotificationPage/actions/signStructuredMessage.ts new file mode 100644 index 000000000..b0aef2d6c --- /dev/null +++ b/wallets/metamask/src/pages/NotificationPage/actions/signStructuredMessage.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test' +import Selectors from '../selectors' + +const signMessage = async (notificationPage: Page) => { + const scrollDownButton = notificationPage.locator(Selectors.SignaturePage.structuredMessage.scrollDownButton) + const signButton = notificationPage.locator(Selectors.SignaturePage.structuredMessage.signButton) + + while (await signButton.isDisabled()) { + await scrollDownButton.click() + } + + await notificationPage.locator(Selectors.SignaturePage.structuredMessage.signButton).click() +} + +const rejectMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.SignaturePage.structuredMessage.rejectButton).click() +} + +// Used for: +// - `eth_signTypedData_v3` +// - `eth_signTypedData_v4` +export const signStructuredMessage = { + sign: signMessage, + reject: rejectMessage +} diff --git a/wallets/metamask/src/pages/NotificationPage/page.ts b/wallets/metamask/src/pages/NotificationPage/page.ts index 97dee01f5..c8b057377 100644 --- a/wallets/metamask/src/pages/NotificationPage/page.ts +++ b/wallets/metamask/src/pages/NotificationPage/page.ts @@ -1,7 +1,13 @@ import type { Page } from '@playwright/test' -import { connectToDapp, signSimpleMessage } from './actions' +import { getNotificationPageAndWaitForLoad } from '../../utils/getNotificationPageAndWaitForLoad' +import { waitFor } from '../../utils/waitFor' +import { connectToDapp, signSimpleMessage, signStructuredMessage } from './actions' +import Selectors from './selectors' export class NotificationPage { + static readonly selectors = Selectors + readonly selectors = Selectors + readonly page: Page constructor(page: Page) { @@ -12,11 +18,41 @@ export class NotificationPage { await connectToDapp(this.page.context(), extensionId) } - async signSimpleMessage(extensionId: string) { - await signSimpleMessage.sign(this.page.context(), extensionId) + // TODO: Revisit this logic in the future to see if we can increase the performance by utilizing `Promise.race`. + private async beforeMessageSignature(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + // Most of the time, this function will be used to sign structured messages, so we check for the scroll button first. + const isScrollButtonVisible = await waitFor( + notificationPage.locator(Selectors.SignaturePage.structuredMessage.scrollDownButton), + 'visible', + 1_500, // TODO: Make this configurable. + false + ) + + return { + notificationPage, + isScrollButtonVisible + } } - async rejectSimpleMessage(extensionId: string) { - await signSimpleMessage.reject(this.page.context(), extensionId) + async signMessage(extensionId: string) { + const { notificationPage, isScrollButtonVisible } = await this.beforeMessageSignature(extensionId) + + if (isScrollButtonVisible) { + await signStructuredMessage.sign(notificationPage) + } else { + await signSimpleMessage.sign(notificationPage) + } + } + + async rejectMessage(extensionId: string) { + const { notificationPage, isScrollButtonVisible } = await this.beforeMessageSignature(extensionId) + + if (isScrollButtonVisible) { + await signStructuredMessage.reject(notificationPage) + } else { + await signSimpleMessage.reject(notificationPage) + } } } diff --git a/wallets/metamask/src/pages/NotificationPage/selectors/signaturePage.ts b/wallets/metamask/src/pages/NotificationPage/selectors/signaturePage.ts index 2f2879088..c9f0c5a07 100644 --- a/wallets/metamask/src/pages/NotificationPage/selectors/signaturePage.ts +++ b/wallets/metamask/src/pages/NotificationPage/selectors/signaturePage.ts @@ -1,6 +1,17 @@ import { createDataTestSelector } from '../../../utils/selectors/createDataTestSelector' -export default { +const simpleMessage = { signButton: `.request-signature__footer ${createDataTestSelector('request-signature__sign')}`, rejectButton: '.request-signature__footer button.btn-secondary' } + +const structuredMessage = { + scrollDownButton: `.signature-request-message ${createDataTestSelector('signature-request-scroll-button')}`, + signButton: `.signature-request-footer ${createDataTestSelector('signature-sign-button')}`, + rejectButton: `.signature-request-footer ${createDataTestSelector('signature-cancel-button')}` +} + +export default { + simpleMessage, + structuredMessage +} diff --git a/wallets/metamask/src/utils/waitFor.ts b/wallets/metamask/src/utils/waitFor.ts new file mode 100644 index 000000000..7028d145b --- /dev/null +++ b/wallets/metamask/src/utils/waitFor.ts @@ -0,0 +1,50 @@ +import type { Locator } from '@playwright/test' + +// Inlining the sleep function here cause this is one of the few places in the entire codebase where sleep should be used! +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const timeouts = [0, 20, 50, 100, 100, 500] as const + +type State = 'visible' | 'hidden' + +// TODO: Box this function. +// This functions mimics the one found in Playwright with a few small differences. +// Custom implementation is needed because Playwright lists errors in the report even if they are caught. +export async function waitFor(locator: Locator, state: State, timeout: number, shouldThrow = true) { + let timeoutsSum = 0 + let timeoutIndex = 0 + + let reachedTimeout = false + + while (!reachedTimeout) { + let nextTimeout = timeouts.at(Math.min(timeoutIndex++, timeouts.length - 1)) as number + + if (timeoutsSum + nextTimeout > timeout) { + nextTimeout = timeout - timeoutsSum + reachedTimeout = true + } else { + timeoutsSum += nextTimeout + } + + await sleep(nextTimeout) + + const result = await action(locator, state) + if (result) { + return result + } + } + + if (shouldThrow) { + throw new Error(`Timeout ${timeout}ms exceeded.`) + } + + return false +} + +async function action(locator: Locator, state: State) { + if (state === 'hidden') { + return locator.isHidden() + } + + return locator.isVisible() +} diff --git a/wallets/metamask/test/e2e/metamask/confirmSignature.spec.ts b/wallets/metamask/test/e2e/metamask/confirmSignature.spec.ts index 653bf9112..7b29cbc42 100644 --- a/wallets/metamask/test/e2e/metamask/confirmSignature.spec.ts +++ b/wallets/metamask/test/e2e/metamask/confirmSignature.spec.ts @@ -47,3 +47,39 @@ test('should confirm `eth_signTypedData`', async ({ context, metamaskPage, page, await expect(page.locator('#signTypedDataVerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') }) + +test('should confirm `eth_signTypedData_v3`', async ({ context, metamaskPage, page, extensionId }) => { + const metamask = new MetaMask(context, metamaskPage, connectedSetup.walletPassword, extensionId) + + await page.goto('https://metamask.github.io/test-dapp/') + + await page.locator('#signTypedDataV3').click() + + await metamask.confirmSignature() + + await expect(page.locator('#signTypedDataV3Result')).toHaveText( + '0x6ea8bb309a3401225701f3565e32519f94a0ea91a5910ce9229fe488e773584c0390416a2190d9560219dab757ecca2029e63fa9d1c2aebf676cc25b9f03126a1b' + ) + + await page.locator('#signTypedDataV3Verify').click() + + await expect(page.locator('#signTypedDataV3VerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_signTypedData_v4`', async ({ context, metamaskPage, page, extensionId }) => { + const metamask = new MetaMask(context, metamaskPage, connectedSetup.walletPassword, extensionId) + + await page.goto('https://metamask.github.io/test-dapp/') + + await page.locator('#signTypedDataV4').click() + + await metamask.confirmSignature() + + await expect(page.locator('#signTypedDataV4Result')).toHaveText( + '0x789d9365fe0fbf1485b8069cbb000b78abd56b92608f9bc11a0d78e8810cd0434a60e93790c52348e5ac8770a8c5b0bb89411c2fbc61cbb4f56d67d60a3374961c' + ) + + await page.locator('#signTypedDataV4Verify').click() + + await expect(page.locator('#signTypedDataV4VerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) diff --git a/wallets/metamask/test/e2e/metamask/rejectSignature.spec.ts b/wallets/metamask/test/e2e/metamask/rejectSignature.spec.ts index 5d1d4d8bf..315799593 100644 --- a/wallets/metamask/test/e2e/metamask/rejectSignature.spec.ts +++ b/wallets/metamask/test/e2e/metamask/rejectSignature.spec.ts @@ -35,3 +35,31 @@ test('should reject `eth_signTypedData`', async ({ context, metamaskPage, page, 'Error: MetaMask Message Signature: User denied message signature.' ) }) + +test('should reject `eth_signTypedData_v3`', async ({ context, metamaskPage, page, extensionId }) => { + const metamask = new MetaMask(context, metamaskPage, connectedSetup.walletPassword, extensionId) + + await page.goto('https://metamask.github.io/test-dapp/') + + await page.locator('#signTypedDataV3').click() + + await metamask.rejectSignature() + + await expect(page.locator('#signTypedDataV3Result')).toHaveText( + 'Error: MetaMask Message Signature: User denied message signature.' + ) +}) + +test('should reject `eth_signTypedData_v4`', async ({ context, metamaskPage, page, extensionId }) => { + const metamask = new MetaMask(context, metamaskPage, connectedSetup.walletPassword, extensionId) + + await page.goto('https://metamask.github.io/test-dapp/') + + await page.locator('#signTypedDataV4').click() + + await metamask.rejectSignature() + + await expect(page.locator('#signTypedDataV4Result')).toHaveText( + 'Error: MetaMask Message Signature: User denied message signature.' + ) +})