Skip to content

Commit

Permalink
Refactor rest JSON-RPC methods (cawabunga#20)
Browse files Browse the repository at this point in the history
* Add Accounts middleware

* Add SignMessage middleware

* Add Network middleware

* Add Transaction middleware

* Add Permission middleware

* Refactor catching unhandled methods
  • Loading branch information
cawabunga authored Mar 27, 2024
1 parent d013a51 commit 96d5b2f
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 223 deletions.
231 changes: 9 additions & 222 deletions src/Web3ProviderBackend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import assert from 'node:assert/strict'
import {
BehaviorSubject,
filter,
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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<JsonRpcRequest, 'method' | 'params'>): Promise<any> {
Expand All @@ -93,158 +79,6 @@ export class Web3ProviderBackend extends EventEmitter implements IWeb3Provider {
}
}

async _request(req: JsonRpcRequest): Promise<any> {
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]

Expand Down Expand Up @@ -401,50 +235,3 @@ function without<T>(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
}
35 changes: 34 additions & 1 deletion src/jsonRpcEngine.ts
Original file line number Diff line number Diff line change
@@ -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<void>
) => Promise<void>
wps: WalletPermissionSystem
}) {
const engine = new JsonRpcEngine()

Expand All @@ -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
}
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ export interface PendingRequest {
reject: (err: { message?: string; code?: number }) => void
authorize: () => Promise<void>
}

export interface ChainConnection {
chainId: number
rpcUrl: string
}
Loading

0 comments on commit 96d5b2f

Please sign in to comment.