diff --git a/README.md b/README.md index cd092c3..2b6be0d 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ test('connect the wallet', async ({ page, injectWeb3Provider }) => { await page.goto('https://metamask.github.io/test-dapp/') // Request connecting the wallet - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() // You can either authorize or reject the request await wallet.authorize(Web3RequestKind.RequestAccounts) diff --git a/package.json b/package.json index 248f217..1044474 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@parcel/packager-ts": "2.7.0", "@parcel/transformer-typescript-types": "^2.7.0", "@playwright/test": "^1.26.1", + "@types/node": "^16.0.0", "generic-pool": "^3.9.0", "parcel": "^2.7.0", "prettier": "^2.7.1", diff --git a/src/Web3ProviderBackend.ts b/src/Web3ProviderBackend.ts index 3ac6adb..c72d93d 100644 --- a/src/Web3ProviderBackend.ts +++ b/src/Web3ProviderBackend.ts @@ -1,14 +1,15 @@ import assert from 'node:assert/strict' import { + BehaviorSubject, filter, + first, firstValueFrom, - BehaviorSubject, - switchMap, from, - first, + switchMap, tap, } from 'rxjs' import { ethers, Wallet } from 'ethers' +import { toUtf8String } from 'ethers/lib/utils' import { signTypedData, SignTypedDataVersion } from '@metamask/eth-sig-util' import { Web3RequestKind } from './utils' @@ -22,7 +23,7 @@ import { } from './errors' import { IWeb3Provider, PendingRequest } from './types' import { EventEmitter } from './EventEmitter' -import { toUtf8String } from 'ethers/lib/utils' +import { WalletPermissionSystem } from './wallet/WalletPermissionSystem' interface ChainConnection { chainId: number @@ -32,16 +33,17 @@ interface ChainConnection { export interface Web3ProviderConfig { debug?: boolean logger?: typeof console.log + permitted?: (Web3RequestKind | string)[] } export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { #pendingRequests$ = new BehaviorSubject([]) #wallets: ethers.Signer[] = [] + #wps: WalletPermissionSystem + private _activeChainId: number private _rpc: Record = {} private _config: { debug: boolean; logger: typeof console.log } - private _authorizedRequests: { [K in Web3RequestKind | string]?: boolean } = - {} constructor( privateKeys: string[], @@ -52,6 +54,7 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { this.#wallets = privateKeys.map((key) => new ethers.Wallet(key)) this._activeChainId = chains[0].chainId this._config = Object.assign({ debug: false, logger: console.log }, config) + this.#wps = new WalletPermissionSystem(config.permitted) } request(args: { method: 'eth_accounts'; params: [] }): Promise @@ -107,23 +110,21 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { return this.getRpc().send(method, params) case 'eth_requestAccounts': { - return this.waitAuthorization( - { method, params }, - async () => { - const accounts = await Promise.all( - this.#wallets.map(async (wallet) => - (await wallet.getAddress()).toLowerCase() - ) + return this.waitAuthorization({ method, params }, async () => { + this.#wps.permit(Web3RequestKind.Accounts, '') + + const accounts = await Promise.all( + this.#wallets.map(async (wallet) => + (await wallet.getAddress()).toLowerCase() ) - this.emit('accountsChanged', accounts) - return accounts - }, - true - ) + ) + this.emit('accountsChanged', accounts) + return accounts + }) } case 'eth_accounts': { - if (this._authorizedRequests['eth_requestAccounts']) { + if (this.#wps.isPermitted(Web3RequestKind.Accounts, '')) { return await Promise.all( this.#wallets.map(async (wallet) => (await wallet.getAddress()).toLowerCase() @@ -175,6 +176,7 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { }) } + // todo: use the Wallet Permissions System (WPS) to handle method case 'wallet_requestPermissions': { if (params.length === 0 || params[0].eth_accounts === undefined) { throw Deny() @@ -264,10 +266,9 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { waitAuthorization( requestInfo: PendingRequest['requestInfo'], - task: () => Promise, - permanentPermission = false + task: () => Promise ) { - if (this._authorizedRequests[requestInfo.method]) { + if (this.#wps.isPermitted(requestInfo.method, '')) { return task() } @@ -275,10 +276,6 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { const pendingRequest: PendingRequest = { requestInfo: requestInfo, authorize: async () => { - if (permanentPermission) { - this._authorizedRequests[requestInfo.method] = true - } - resolve(await task()) }, reject(err) { diff --git a/src/wallet/WalletPermissionSystem.ts b/src/wallet/WalletPermissionSystem.ts new file mode 100644 index 0000000..6cc1706 --- /dev/null +++ b/src/wallet/WalletPermissionSystem.ts @@ -0,0 +1,84 @@ +// EIP-2255: Wallet Permissions System (https://eips.ethereum.org/EIPS/eip-2255) + +import type { Web3RequestKind } from '../utils' + +// Caveat for `eth_accounts` could be: +// { "type": "requiredMethods", "value": ["signTypedData_v3"] } +interface Caveat { + type: string + value: any // JSON object, meaning depends on the caveat type +} + +interface Permission { + invoker: string // DApp origin + parentCapability: string // RPC method name + caveats: Caveat[] +} + +type ShorthandPermission = Web3RequestKind | string + +export class WalletPermissionSystem { + #permissions: Map = new Map() + #wildcardOrigin: string = '*' + + constructor(perms: ShorthandPermission[] = []) { + this.#permissions.set( + this.#wildcardOrigin, + perms.map((perm) => ({ + invoker: this.#wildcardOrigin, + parentCapability: perm, + caveats: [], + })) + ) + } + + /** + * @param rpcMethod + * @param origin not used in the current implementation, empty string can be passed + */ + permit(rpcMethod: Web3RequestKind | string, origin: string) { + const permissions = this.#permissions.get(origin) || [] + + const updatedPermissions = permissions.filter( + (permission) => permission.parentCapability !== rpcMethod + ) + updatedPermissions.push({ + invoker: origin, + parentCapability: rpcMethod, + caveats: [], // Caveats are not implemented + }) + + this.#permissions.set(origin, updatedPermissions) + } + + /** + * @param rpcMethod + * @param origin not used in the current implementation, empty string can be passed + */ + revoke(rpcMethod: Web3RequestKind | string, origin: string) { + const permissions = this.#permissions.get(origin) || [] + const updatedPermissions = permissions.filter( + (permission) => permission.parentCapability !== rpcMethod + ) + this.#permissions.set(origin, updatedPermissions) + } + + /** + * @param rpcMethod + * @param origin not used in the current implementation, empty string can be passed + */ + isPermitted(rpcMethod: Web3RequestKind | string, origin: string): boolean { + const permissions = this.#permissions.get(origin) || [] + const wildcardPermissions = + this.#permissions.get(this.#wildcardOrigin) || [] + + return ( + permissions.some( + (permission) => permission.parentCapability === rpcMethod + ) || + wildcardPermissions.some( + (permission) => permission.parentCapability === rpcMethod + ) + ) + } +} diff --git a/tests/e2e/metamask-permission.spec.ts b/tests/e2e/metamask-permission.spec.ts new file mode 100644 index 0000000..59e2d47 --- /dev/null +++ b/tests/e2e/metamask-permission.spec.ts @@ -0,0 +1,31 @@ +import { expect, test, describe } from '../fixtures' +import { Web3ProviderBackend, Web3RequestKind } from '../../src' + +let wallet: Web3ProviderBackend + +describe('Auto connected', () => { + test.beforeEach(async ({ page, injectWeb3Provider }) => { + // Inject window.ethereum instance + wallet = await injectWeb3Provider(undefined, [Web3RequestKind.Accounts]) + + // In order to make https://metamask.github.io/test-dapp/ work flag should be set + await page.addInitScript(() => (window.ethereum.isMetaMask = true)) + + await page.goto('https://metamask.github.io/test-dapp/') + }) + + test('my address', async ({ page, accounts }) => { + // Already connected, no need to connect again + await page + .getByRole('button', { name: 'Connected', exact: true }) + .isDisabled() + + // Verify if the wallet is really connected + await expect(page.locator('text=' + accounts[0])).toBeVisible() + + // Accounts should be available + await expect(page.locator('#getAccountsResult')).toBeEmpty() + await page.getByRole('button', { name: 'ETH_ACCOUNTS' }).click() + await expect(page.locator('#getAccountsResult')).toContainText(accounts[0]) + }) +}) diff --git a/tests/e2e/metamask.spec.ts b/tests/e2e/metamask.spec.ts index 7f7c866..ea0bf11 100644 --- a/tests/e2e/metamask.spec.ts +++ b/tests/e2e/metamask.spec.ts @@ -23,7 +23,7 @@ test('connect the wallet', async ({ page, accounts }) => { expect(ethAccounts).toEqual([]) // Request connecting the wallet - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() expect( wallet.getPendingRequestCount(Web3RequestKind.RequestAccounts) @@ -83,7 +83,7 @@ test('switch a new network', async ({ page }) => { }) test('request permissions', async ({ page, accounts }) => { - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() await wallet.authorize(Web3RequestKind.RequestAccounts) // Request permissions @@ -107,15 +107,15 @@ test('request permissions', async ({ page, accounts }) => { }) test('deploy a token', async ({ page }) => { - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() await wallet.authorize(Web3RequestKind.RequestAccounts) - await expect(page.locator('#tokenAddress')).toBeEmpty() + await expect(page.locator('#tokenAddresses')).toBeEmpty() await page.locator('text=Create Token').click() - await expect(page.locator('#tokenAddress')).toBeEmpty() + await expect(page.locator('#tokenAddresses')).toBeEmpty() await wallet.authorize(Web3RequestKind.SendTransaction) - await expect(page.locator('#tokenAddress')).toContainText(/0x.+/) + await expect(page.locator('#tokenAddresses')).toContainText(/0x.+/) }) const getTransactionCount = async ( @@ -134,7 +134,7 @@ const getTransactionCount = async ( } test('send legacy transaction', async ({ page, accounts }) => { - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() await wallet.authorize(Web3RequestKind.RequestAccounts) await page.locator('text=Send Legacy Transaction').click() @@ -146,7 +146,7 @@ test('send legacy transaction', async ({ page, accounts }) => { }) test('send EIP-1559 transaction', async ({ page, accounts }) => { - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() await wallet.authorize(Web3RequestKind.RequestAccounts) await page.locator('text=Send EIP 1559 Transaction').click() @@ -162,7 +162,7 @@ test('send EIP-1559 transaction', async ({ page, accounts }) => { */ test('sign a message', async ({ page, signers }) => { // Establish a connection with the wallet - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() // Authorize the request for account access await wallet.authorize(Web3RequestKind.RequestAccounts) @@ -223,7 +223,7 @@ for (const { } of data) { test(`sign a typed message (${version})`, async ({ page, signers }) => { // Establish a connection with the wallet - await page.locator('text=Connect').click() + await page.getByRole('button', { name: 'Connect', exact: true }).click() // Authorize the request for account access await wallet.authorize(Web3RequestKind.RequestAccounts) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index c15d3c7..f99446e 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -1,10 +1,15 @@ import { ethers } from 'ethers' import { test as base } from '@playwright/test' -import { injectHeadlessWeb3Provider, Web3ProviderBackend } from '../src' +import { + injectHeadlessWeb3Provider, + Web3ProviderBackend, + Web3RequestKind, +} from '../src' import { getAnvilInstance } from './services/anvil/anvilPoolClient' type InjectWeb3Provider = ( - privateKeys?: string[] + privateKeys?: string[], + permitted?: (Web3RequestKind | string)[] ) => Promise export const test = base.extend<{ @@ -29,10 +34,12 @@ export const test = base.extend<{ }, injectWeb3Provider: async ({ page, signers, anvilRpcUrl }, use) => { - await use((privateKeys = signers) => - injectHeadlessWeb3Provider(page, privateKeys, 31337, anvilRpcUrl) + await use((privateKeys = signers, permitted = []) => + injectHeadlessWeb3Provider(page, privateKeys, 31337, anvilRpcUrl, { + permitted, + }) ) }, }) -export const { expect } = test +export const { expect, describe } = test diff --git a/yarn.lock b/yarn.lock index 951196e..0193295 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1256,12 +1256,11 @@ nullthrows "^1.1.1" "@playwright/test@^1.26.1": - version "1.26.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.26.1.tgz#73ada4e70f618bca69ba7509c4ba65b5a41c4b10" - integrity sha512-bNxyZASVt2adSZ9gbD7NCydzcb5JaI0OR9hc7s+nmPeH604gwp0zp17NNpwXY4c8nvuBGQQ9oGDx72LE+cUWvw== + version "1.42.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.42.1.tgz#9eff7417bcaa770e9e9a00439e078284b301f31c" + integrity sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ== dependencies: - "@types/node" "*" - playwright-core "1.26.1" + playwright "1.42.1" "@scure/base@~1.1.0": version "1.1.1" @@ -1297,10 +1296,10 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@types/node@*": - version "18.7.23" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.23.tgz#75c580983846181ebe5f4abc40fe9dfb2d65665f" - integrity sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg== +"@types/node@^16.0.0": + version "16.18.91" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.91.tgz#3e7b3b3d28f740e3e2d4ceb7ad9d16e6b9277c91" + integrity sha512-h8Q4klc8xzc9kJKr7UYNtJde5TU2qEePVyH3WyzJaUC+3ptyc5kPQbWOIUcn8ZsG5+KSkq+P0py0kC0VqxgAXw== "@types/parse-json@^4.0.0": version "4.0.0" @@ -1668,6 +1667,11 @@ ethjs-util@^0.1.6: is-hex-prefixed "1.0.0" strip-hex-prefix "1.0.0" +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -2000,10 +2004,19 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -playwright-core@1.26.1: - version "1.26.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.26.1.tgz#a162f476488312dcf12638d97685144de6ada512" - integrity sha512-hzFchhhxnEiPc4qVPs9q2ZR+5eKNifY2hQDHtg1HnTTUuphYCBP8ZRb2si+B1TR7BHirgXaPi48LIye5SgrLAA== +playwright-core@1.42.1: + version "1.42.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.42.1.tgz#13c150b93c940a3280ab1d3fbc945bc855c9459e" + integrity sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA== + +playwright@1.42.1: + version "1.42.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.42.1.tgz#79c828b51fe3830211137550542426111dc8239f" + integrity sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg== + dependencies: + playwright-core "1.42.1" + optionalDependencies: + fsevents "2.3.2" postcss-value-parser@^4.2.0: version "4.2.0"