Skip to content

Commit

Permalink
Refactor wallet permissions (#18)
Browse files Browse the repository at this point in the history
* Update playwright and add @types/node

* Add support for latest metamask dapp

* Partially implement Wallet Permissions System
  • Loading branch information
cawabunga authored Mar 25, 2024
1 parent 3c222a5 commit 9212c2b
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 55 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 23 additions & 26 deletions src/Web3ProviderBackend.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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<PendingRequest[]>([])
#wallets: ethers.Signer[] = []
#wps: WalletPermissionSystem

private _activeChainId: number
private _rpc: Record<number, ethers.providers.JsonRpcProvider> = {}
private _config: { debug: boolean; logger: typeof console.log }
private _authorizedRequests: { [K in Web3RequestKind | string]?: boolean } =
{}

constructor(
privateKeys: string[],
Expand All @@ -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<string[]>
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -264,21 +266,16 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider {

waitAuthorization<T>(
requestInfo: PendingRequest['requestInfo'],
task: () => Promise<T>,
permanentPermission = false
task: () => Promise<T>
) {
if (this._authorizedRequests[requestInfo.method]) {
if (this.#wps.isPermitted(requestInfo.method, '')) {
return task()
}

return new Promise((resolve, reject) => {
const pendingRequest: PendingRequest = {
requestInfo: requestInfo,
authorize: async () => {
if (permanentPermission) {
this._authorizedRequests[requestInfo.method] = true
}

resolve(await task())
},
reject(err) {
Expand Down
84 changes: 84 additions & 0 deletions src/wallet/WalletPermissionSystem.ts
Original file line number Diff line number Diff line change
@@ -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<string, Permission[]> = 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
)
)
}
}
31 changes: 31 additions & 0 deletions tests/e2e/metamask-permission.spec.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
20 changes: 10 additions & 10 deletions tests/e2e/metamask.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
17 changes: 12 additions & 5 deletions tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<Web3ProviderBackend>

export const test = base.extend<{
Expand All @@ -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
Loading

0 comments on commit 9212c2b

Please sign in to comment.