From 96d5b2f1a407473ec2ad0c656f5e9bfb5204c140 Mon Sep 17 00:00:00 2001 From: Emil Ibatullin Date: Wed, 27 Mar 2024 13:56:32 +0300 Subject: [PATCH] Refactor rest JSON-RPC methods (#20) * Add Accounts middleware * Add SignMessage middleware * Add Network middleware * Add Transaction middleware * Add Permission middleware * Refactor catching unhandled methods --- src/Web3ProviderBackend.ts | 231 ++-------------------------- src/jsonRpcEngine.ts | 35 ++++- src/types.ts | 5 + src/wallet/AccountsMiddleware.ts | 49 ++++++ src/wallet/NetworkMiddleware.ts | 59 +++++++ src/wallet/PermissionMiddleware.ts | 46 ++++++ src/wallet/SignMessageMiddleware.ts | 78 ++++++++++ src/wallet/TransactionMiddleware.ts | 81 ++++++++++ 8 files changed, 361 insertions(+), 223 deletions(-) create mode 100644 src/wallet/AccountsMiddleware.ts create mode 100644 src/wallet/NetworkMiddleware.ts create mode 100644 src/wallet/PermissionMiddleware.ts create mode 100644 src/wallet/SignMessageMiddleware.ts create mode 100644 src/wallet/TransactionMiddleware.ts diff --git a/src/Web3ProviderBackend.ts b/src/Web3ProviderBackend.ts index 341123b..e273009 100644 --- a/src/Web3ProviderBackend.ts +++ b/src/Web3ProviderBackend.ts @@ -1,4 +1,3 @@ -import assert from 'node:assert/strict' import { BehaviorSubject, filter, @@ -9,12 +8,7 @@ import { tap, } from 'rxjs' import { ethers, Wallet } from 'ethers' -import { toUtf8String } from 'ethers/lib/utils' -import { signTypedData, SignTypedDataVersion } from '@metamask/eth-sig-util' -import { - createAsyncMiddleware, - type JsonRpcEngine, -} from '@metamask/json-rpc-engine' +import { type JsonRpcEngine } from '@metamask/json-rpc-engine' import type { JsonRpcRequest } from '@metamask/utils' import { Web3RequestKind } from './utils' @@ -24,18 +18,12 @@ import { Disconnected, ErrorWithCode, Unauthorized, - UnsupportedMethod, } from './errors' -import { IWeb3Provider, PendingRequest } from './types' +import { ChainConnection, IWeb3Provider, PendingRequest } from './types' import { EventEmitter } from './EventEmitter' import { WalletPermissionSystem } from './wallet/WalletPermissionSystem' import { makeRpcEngine } from './jsonRpcEngine' -interface ChainConnection { - chainId: number - rpcUrl: string -} - export interface Web3ProviderConfig { debug?: boolean logger?: typeof console.log @@ -63,20 +51,18 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { this._config = Object.assign({ debug: false, logger: console.log }, config) this.#wps = new WalletPermissionSystem(config.permitted) this.#engine = makeRpcEngine({ + addNetwork: (chainId, rpcUrl) => this.addNetwork(chainId, rpcUrl), + currentChainThunk: () => this.getCurrentChain(), debug: this._config.debug, + emit: (eventName, ...args) => this.emit(eventName, ...args), logger: this._config.logger, providerThunk: () => this.getRpc(), + switchNetwork: (chainId) => this.switchNetwork(chainId), + walletThunk: () => this.#getCurrentWallet() as Wallet, + walletsThunk: () => this.#wallets, waitAuthorization: (req, task) => this.waitAuthorization(req, task), + wps: this.#wps, }) - this.#engine.push( - createAsyncMiddleware(async (req, res, next) => { - try { - res.result = await this._request(req) - } catch (err) { - res.error = err as any - } - }) - ) } async request(req: Pick): Promise { @@ -93,158 +79,6 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider { } } - async _request(req: JsonRpcRequest): Promise { - switch (req.method) { - case 'eth_requestAccounts': { - 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 - } - - case 'eth_accounts': { - if (this.#wps.isPermitted(Web3RequestKind.Accounts, '')) { - return await Promise.all( - this.#wallets.map(async (wallet) => - (await wallet.getAddress()).toLowerCase() - ) - ) - } - return [] - } - - case 'eth_chainId': { - const { chainId } = this.getCurrentChain() - return '0x' + chainId.toString(16) - } - - case 'net_version': { - const { chainId } = this.getCurrentChain() - return chainId - } - - case 'eth_sendTransaction': { - const wallet = this.#getCurrentWallet() - const rpc = this.getRpc() - // @ts-expect-error todo: parse params - const jsonRpcTx = req.params[0] - - const txRequest = convertJsonRpcTxToEthersTxRequest(jsonRpcTx) - try { - const tx = await wallet.connect(rpc).sendTransaction(txRequest) - return tx.hash - } catch (err) { - throw err - } - } - - case 'wallet_addEthereumChain': { - // @ts-expect-error todo: parse params - const chainId = Number(req.params[0].chainId) - // @ts-expect-error todo: parse params - const rpcUrl = req.params[0].rpcUrls[0] - this.addNetwork(chainId, rpcUrl) - return null - } - - case 'wallet_switchEthereumChain': { - // @ts-expect-error todo: parse params - if (this._activeChainId === Number(req.params[0].chainId)) { - return null - } - - // @ts-expect-error todo: parse params - const chainId = Number(req.params[0].chainId) - this.switchNetwork(chainId) - return null - } - - // todo: use the Wallet Permissions System (WPS) to handle method - case 'wallet_requestPermissions': { - if ( - // @ts-expect-error todo: parse params - req.params.length === 0 || - // @ts-expect-error todo: parse params - req.params[0].eth_accounts === undefined - ) { - throw Deny() - } - - const accounts = await Promise.all( - this.#wallets.map(async (wallet) => - (await wallet.getAddress()).toLowerCase() - ) - ) - this.emit('accountsChanged', accounts) - return [{ parentCapability: 'eth_accounts' }] - } - - case 'personal_sign': { - const wallet = this.#getCurrentWallet() - const address = await wallet.getAddress() - // @ts-expect-error todo: parse params - assert.equal(address, ethers.utils.getAddress(req.params[1])) - // @ts-expect-error todo: parse params - const message = toUtf8String(req.params[0]) - - const signature = await wallet.signMessage(message) - if (this._config.debug) { - this._config.logger('personal_sign', { - message, - signature, - }) - } - - return signature - } - - case 'eth_signTypedData': - case 'eth_signTypedData_v1': { - const wallet = this.#getCurrentWallet() as Wallet - const address = await wallet.getAddress() - // @ts-expect-error todo: parse params - assert.equal(address, ethers.utils.getAddress(req.params[1])) - - // @ts-expect-error todo: parse params - const msgParams = req.params[0] - - return signTypedData({ - privateKey: Buffer.from(wallet.privateKey.slice(2), 'hex'), - data: msgParams, - version: SignTypedDataVersion.V1, - }) - } - - case 'eth_signTypedData_v3': - case 'eth_signTypedData_v4': { - const wallet = this.#getCurrentWallet() as Wallet - const address = await wallet.getAddress() - // @ts-expect-error todo: parse params - assert.equal(address, ethers.utils.getAddress(req.params[0])) - - // @ts-expect-error todo: parse params - const msgParams = JSON.parse(req.params[1]) - - return signTypedData({ - privateKey: Buffer.from(wallet.privateKey.slice(2), 'hex'), - data: msgParams, - version: - req.method === 'eth_signTypedData_v4' - ? SignTypedDataVersion.V4 - : SignTypedDataVersion.V3, - }) - } - - default: - throw UnsupportedMethod() - } - } - #getCurrentWallet(): ethers.Signer { const wallet = this.#wallets[0] @@ -401,50 +235,3 @@ function without(list: T[], item: T): T[] { } return list } - -// Allowed keys for a JSON-RPC transaction as defined in: -// https://ethereum.github.io/execution-apis/api-documentation/ -const allowedTransactionKeys = [ - 'accessList', - 'chainId', - 'data', - 'from', - 'gas', - 'gasPrice', - 'maxFeePerGas', - 'maxPriorityFeePerGas', - 'nonce', - 'to', - 'type', - 'value', -] - -// Convert a JSON-RPC transaction to an ethers.js transaction. -// The reverse of this function can be found in the ethers.js library: -// https://github.com/ethers-io/ethers.js/blob/v5.7.2/packages/providers/src.ts/json-rpc-provider.ts#L701 -function convertJsonRpcTxToEthersTxRequest(tx: { - [key: string]: any -}): ethers.providers.TransactionRequest { - const result: any = {} - - allowedTransactionKeys.forEach((key) => { - if (tx[key] == null) { - return - } - - switch (key) { - // gasLimit is referred to as "gas" in JSON-RPC - case 'gas': - result['gasLimit'] = tx[key] - return - // ethers.js expects `chainId` and `type` to be a number - case 'chainId': - case 'type': - result[key] = Number(tx[key]) - return - default: - result[key] = tx[key] - } - }) - return result -} diff --git a/src/jsonRpcEngine.ts b/src/jsonRpcEngine.ts index 0c15684..98a56b9 100644 --- a/src/jsonRpcEngine.ts +++ b/src/jsonRpcEngine.ts @@ -1,22 +1,44 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine' import type { JsonRpcRequest } from '@metamask/utils' import type { ethers } from 'ethers' +import { UnsupportedMethod } from './errors' +import type { ChainConnection } from './types' import { makePassThroughMiddleware } from './wallet/PassthroughMiddleware' import { makeAuthorizeMiddleware } from './wallet/AuthorizeMiddleware' +import { makeAccountsMiddleware } from './wallet/AccountsMiddleware' +import { WalletPermissionSystem } from './wallet/WalletPermissionSystem' +import { makeSignMessageMiddleware } from './wallet/SignMessageMiddleware' +import { makeNetworkMiddleware } from './wallet/NetworkMiddleware' +import { makeTransactionMiddleware } from './wallet/TransactionMiddleware' +import { makePermissionMiddleware } from './wallet/PermissionMiddleware' export function makeRpcEngine({ + addNetwork, + currentChainThunk, debug, logger, + emit, providerThunk, + switchNetwork, + walletThunk, + walletsThunk, waitAuthorization, + wps, }: { + addNetwork: (chainId: number, rpcUrl: string) => void + currentChainThunk: () => ChainConnection debug?: boolean + emit: (eventName: string, ...args: any[]) => void logger?: (message: string) => void providerThunk: () => ethers.providers.JsonRpcProvider + switchNetwork: (chainId_: number) => void + walletThunk: () => ethers.Wallet + walletsThunk: () => ethers.Signer[] waitAuthorization: ( req: JsonRpcRequest, task: () => Promise ) => Promise + wps: WalletPermissionSystem }) { const engine = new JsonRpcEngine() @@ -26,9 +48,20 @@ export function makeRpcEngine({ next() }) - // Pass through safe requests to real node engine.push(makeAuthorizeMiddleware(waitAuthorization)) + engine.push(makeAccountsMiddleware(emit, walletsThunk, wps)) + engine.push(makeSignMessageMiddleware(walletThunk)) + engine.push( + makeNetworkMiddleware(currentChainThunk, addNetwork, switchNetwork) + ) + engine.push(makeTransactionMiddleware(providerThunk, walletThunk)) + engine.push(makePermissionMiddleware(emit, walletsThunk)) engine.push(makePassThroughMiddleware(providerThunk)) + // Catch unhandled methods + engine.push((req, res, next, end) => { + end(UnsupportedMethod()) + }) + return engine } diff --git a/src/types.ts b/src/types.ts index 3e071ef..47db982 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,3 +14,8 @@ export interface PendingRequest { reject: (err: { message?: string; code?: number }) => void authorize: () => Promise } + +export interface ChainConnection { + chainId: number + rpcUrl: string +} diff --git a/src/wallet/AccountsMiddleware.ts b/src/wallet/AccountsMiddleware.ts new file mode 100644 index 0000000..c94ae06 --- /dev/null +++ b/src/wallet/AccountsMiddleware.ts @@ -0,0 +1,49 @@ +import { + createAsyncMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine' +import type { Json, JsonRpcParams } from '@metamask/utils' +import type { Signer } from 'ethers' +import { Web3RequestKind } from '../utils' +import type { WalletPermissionSystem } from './WalletPermissionSystem' + +export function makeAccountsMiddleware( + emit: (eventName: string, ...args: any[]) => void, + walletsThunk: () => Signer[], + wps: WalletPermissionSystem +) { + const middleware: JsonRpcMiddleware = + createAsyncMiddleware(async (req, res, next) => { + switch (req.method) { + case 'eth_accounts': + if (wps.isPermitted(Web3RequestKind.Accounts, '')) { + res.result = await Promise.all( + walletsThunk().map(async (wallet) => + (await wallet.getAddress()).toLowerCase() + ) + ) + } else { + res.result = [] + } + break + + case 'eth_requestAccounts': + wps.permit(Web3RequestKind.Accounts, '') + + const accounts = await Promise.all( + walletsThunk().map(async (wallet) => + (await wallet.getAddress()).toLowerCase() + ) + ) + + emit('accountsChanged', accounts) + res.result = accounts + break + + default: + void next() + } + }) + + return middleware +} diff --git a/src/wallet/NetworkMiddleware.ts b/src/wallet/NetworkMiddleware.ts new file mode 100644 index 0000000..d0147c9 --- /dev/null +++ b/src/wallet/NetworkMiddleware.ts @@ -0,0 +1,59 @@ +import { + createAsyncMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine' +import type { Json, JsonRpcParams } from '@metamask/utils' +import type { ChainConnection } from '../types' + +export function makeNetworkMiddleware( + currentChainThunk: () => ChainConnection, + addNetwork: (chainId: number, rpcUrl: string) => void, + switchNetwork: (chainId_: number) => void +) { + const middleware: JsonRpcMiddleware = + createAsyncMiddleware(async (req, res, next) => { + switch (req.method) { + case 'eth_chainId': { + const { chainId } = currentChainThunk() + res.result = '0x' + chainId.toString(16) + break + } + + case 'net_version': { + const { chainId } = currentChainThunk() + res.result = chainId + break + } + + case 'wallet_addEthereumChain': { + // @ts-expect-error todo: parse params + const chainId = Number(req.params[0].chainId) + // @ts-expect-error todo: parse params + const rpcUrl = req.params[0].rpcUrls[0] + addNetwork(chainId, rpcUrl) + + res.result = null + break + } + + case 'wallet_switchEthereumChain': { + const { chainId } = currentChainThunk() + + // @ts-expect-error todo: parse params + if (chainId !== Number(req.params[0].chainId)) { + // @ts-expect-error todo: parse params + const chainId = Number(req.params[0].chainId) + switchNetwork(chainId) + } + + res.result = null + break + } + + default: + void next() + } + }) + + return middleware +} diff --git a/src/wallet/PermissionMiddleware.ts b/src/wallet/PermissionMiddleware.ts new file mode 100644 index 0000000..0127e4e --- /dev/null +++ b/src/wallet/PermissionMiddleware.ts @@ -0,0 +1,46 @@ +import { + createAsyncMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine' +import type { Json, JsonRpcParams } from '@metamask/utils' +import type { Signer } from 'ethers' +import { Web3RequestKind } from '../utils' +import type { WalletPermissionSystem } from './WalletPermissionSystem' +import { Deny } from '../errors' + +export function makePermissionMiddleware( + emit: (eventName: string, ...args: any[]) => void, + walletsThunk: () => Signer[] +) { + const middleware: JsonRpcMiddleware = + createAsyncMiddleware(async (req, res, next) => { + switch (req.method) { + // todo: use the Wallet Permissions System (WPS) to handle method + case 'wallet_requestPermissions': { + if ( + // @ts-expect-error todo: parse params + req.params.length === 0 || + // @ts-expect-error todo: parse params + req.params[0].eth_accounts === undefined + ) { + throw Deny() + } + + const accounts = await Promise.all( + walletsThunk().map(async (wallet) => + (await wallet.getAddress()).toLowerCase() + ) + ) + emit('accountsChanged', accounts) + + res.result = [{ parentCapability: 'eth_accounts' }] + break + } + + default: + void next() + } + }) + + return middleware +} diff --git a/src/wallet/SignMessageMiddleware.ts b/src/wallet/SignMessageMiddleware.ts new file mode 100644 index 0000000..16008af --- /dev/null +++ b/src/wallet/SignMessageMiddleware.ts @@ -0,0 +1,78 @@ +import { + createAsyncMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine' +import type { Json, JsonRpcParams } from '@metamask/utils' +import { signTypedData, SignTypedDataVersion } from '@metamask/eth-sig-util' +import { ethers } from 'ethers' +import assert from 'node:assert/strict' +import { toUtf8String } from 'ethers/lib/utils' + +export function makeSignMessageMiddleware(walletThunk: () => ethers.Wallet) { + const middleware: JsonRpcMiddleware = + createAsyncMiddleware(async (req, res, next) => { + switch (req.method) { + case 'personal_sign': { + const wallet = walletThunk() + const address = await wallet.getAddress() + // @ts-expect-error todo: parse params + assert.equal(address, ethers.utils.getAddress(req.params[1])) + // @ts-expect-error todo: parse params + const message = toUtf8String(req.params[0]) + + const signature = await wallet.signMessage(message) + + res.result = signature + break + } + + case 'eth_signTypedData': + case 'eth_signTypedData_v1': { + const wallet = walletThunk() + const address = await wallet.getAddress() + // @ts-expect-error todo: parse params + assert.equal(address, ethers.utils.getAddress(req.params[1])) + + // @ts-expect-error todo: parse params + const msgParams = req.params[0] + + const signature = signTypedData({ + privateKey: Buffer.from(wallet.privateKey.slice(2), 'hex'), + data: msgParams, + version: SignTypedDataVersion.V1, + }) + + res.result = signature + break + } + + case 'eth_signTypedData_v3': + case 'eth_signTypedData_v4': { + const wallet = walletThunk() + const address = await wallet.getAddress() + // @ts-expect-error todo: parse params + assert.equal(address, ethers.utils.getAddress(req.params[0])) + + // @ts-expect-error todo: parse params + const msgParams = JSON.parse(req.params[1]) + + const signature = signTypedData({ + privateKey: Buffer.from(wallet.privateKey.slice(2), 'hex'), + data: msgParams, + version: + req.method === 'eth_signTypedData_v4' + ? SignTypedDataVersion.V4 + : SignTypedDataVersion.V3, + }) + + res.result = signature + break + } + + default: + void next() + } + }) + + return middleware +} diff --git a/src/wallet/TransactionMiddleware.ts b/src/wallet/TransactionMiddleware.ts new file mode 100644 index 0000000..af07219 --- /dev/null +++ b/src/wallet/TransactionMiddleware.ts @@ -0,0 +1,81 @@ +import { + createAsyncMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine' +import type { Json, JsonRpcParams } from '@metamask/utils' +import { ethers } from 'ethers' + +export function makeTransactionMiddleware( + providerThunk: () => ethers.providers.JsonRpcProvider, + walletThunk: () => ethers.Wallet +) { + const middleware: JsonRpcMiddleware = + createAsyncMiddleware(async (req, res, next) => { + switch (req.method) { + case 'eth_sendTransaction': { + const wallet = walletThunk() + const rpc = providerThunk() + // @ts-expect-error todo: parse params + const jsonRpcTx = req.params[0] + + const txRequest = convertJsonRpcTxToEthersTxRequest(jsonRpcTx) + const tx = await wallet.connect(rpc).sendTransaction(txRequest) + + res.result = tx.hash + break + } + + default: + void next() + } + }) + + return middleware +} + +// Allowed keys for a JSON-RPC transaction as defined in: +// https://ethereum.github.io/execution-apis/api-documentation/ +const allowedTransactionKeys = [ + 'accessList', + 'chainId', + 'data', + 'from', + 'gas', + 'gasPrice', + 'maxFeePerGas', + 'maxPriorityFeePerGas', + 'nonce', + 'to', + 'type', + 'value', +] + +// Convert a JSON-RPC transaction to an ethers.js transaction. +// The reverse of this function can be found in the ethers.js library: +// https://github.com/ethers-io/ethers.js/blob/v5.7.2/packages/providers/src.ts/json-rpc-provider.ts#L701 +function convertJsonRpcTxToEthersTxRequest(tx: { + [key: string]: any +}): ethers.providers.TransactionRequest { + const result: any = {} + + allowedTransactionKeys.forEach((key) => { + if (tx[key] == null) { + return + } + + switch (key) { + // gasLimit is referred to as "gas" in JSON-RPC + case 'gas': + result['gasLimit'] = tx[key] + return + // ethers.js expects `chainId` and `type` to be a number + case 'chainId': + case 'type': + result[key] = Number(tx[key]) + return + default: + result[key] = tx[key] + } + }) + return result +}