diff --git a/packages/examples/packages/ethereum-provider/src/index.test.ts b/packages/examples/packages/ethereum-provider/src/index.test.ts index 1dcca5834b..5ecbd55a18 100644 --- a/packages/examples/packages/ethereum-provider/src/index.test.ts +++ b/packages/examples/packages/ethereum-provider/src/index.test.ts @@ -71,7 +71,7 @@ describe('onRpcRequest', () => { }); describe('getChainId', () => { - const MOCK_CHAIN_ID = '0x01'; // Ethereum Mainnet + const MOCK_CHAIN_ID = '0x1'; // Ethereum Mainnet it('returns the current network version', async () => { const { request } = await installSnap(); diff --git a/packages/examples/packages/ethers-js/package.json b/packages/examples/packages/ethers-js/package.json index 90d1d018cd..bf8913ea6d 100644 --- a/packages/examples/packages/ethers-js/package.json +++ b/packages/examples/packages/ethers-js/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "@metamask/snaps-sdk": "workspace:^", - "ethers": "^6.3.0" + "ethers": "^6.16.0" }, "devDependencies": { "@jest/globals": "^29.5.0", diff --git a/packages/examples/packages/ethers-js/snap.manifest.json b/packages/examples/packages/ethers-js/snap.manifest.json index ee6f960d2e..88d1eed025 100644 --- a/packages/examples/packages/ethers-js/snap.manifest.json +++ b/packages/examples/packages/ethers-js/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "JvI40BHRDgrfeB75rQhlRoaHO7csK0diC0RrBhW4jxw=", + "shasum": "z6v9zCXkSPMKSydCdc98zmr9xKkXRzMb2WARZE2DWuo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-simulation/package.json b/packages/snaps-simulation/package.json index 804d161f90..bf2a0d9449 100644 --- a/packages/snaps-simulation/package.json +++ b/packages/snaps-simulation/package.json @@ -55,13 +55,13 @@ "test:watch": "jest --watch" }, "dependencies": { - "@metamask/eth-json-rpc-middleware": "^17.0.1", "@metamask/json-rpc-engine": "^10.1.0", "@metamask/json-rpc-middleware-stream": "^8.0.8", "@metamask/key-tree": "^10.1.1", "@metamask/messenger": "^0.3.0", "@metamask/permission-controller": "^12.1.1", "@metamask/phishing-controller": "^16.1.0", + "@metamask/rpc-errors": "^7.0.3", "@metamask/snaps-controllers": "workspace:^", "@metamask/snaps-execution-environments": "workspace:^", "@metamask/snaps-rpc-methods": "workspace:^", @@ -70,6 +70,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.9.0", "@reduxjs/toolkit": "^1.9.5", + "ethers": "^6.16.0", "fast-deep-equal": "^3.1.3", "immer": "^9.0.21", "mime": "^3.0.0", diff --git a/packages/snaps-simulation/src/constants.ts b/packages/snaps-simulation/src/constants.ts index d2bae46204..bc62637706 100644 --- a/packages/snaps-simulation/src/constants.ts +++ b/packages/snaps-simulation/src/constants.ts @@ -24,11 +24,6 @@ export const DEFAULT_LOCALE = 'en'; */ export const DEFAULT_CURRENCY = 'usd'; -/** - * The default JSON-RPC endpoint for Ethereum requests. - */ -export const DEFAULT_JSON_RPC_ENDPOINT = 'https://cloudflare-eth.com/'; - /** * The types of inputs that can be used in the `typeInField` interface action. */ diff --git a/packages/snaps-simulation/src/methods/hooks/chain.test.ts b/packages/snaps-simulation/src/methods/hooks/chain.test.ts new file mode 100644 index 0000000000..404dabb157 --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/chain.test.ts @@ -0,0 +1,17 @@ +import { getSetCurrentChainImplementation } from './chain'; +import { createStore } from '../../store'; +import { getMockOptions } from '../../test-utils'; + +describe('getSetCurrentChainImplementation', () => { + it('returns the implementation of the `setCurrentChain` hook', async () => { + const { store, runSaga } = createStore(getMockOptions()); + + expect(store.getState().chain.chainId).toBe('0x1'); + + const fn = getSetCurrentChainImplementation(runSaga); + + expect(fn('0x2')).toBeNull(); + + expect(store.getState().chain.chainId).toBe('0x2'); + }); +}); diff --git a/packages/snaps-simulation/src/methods/hooks/chain.ts b/packages/snaps-simulation/src/methods/hooks/chain.ts new file mode 100644 index 0000000000..6dc118b295 --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/chain.ts @@ -0,0 +1,30 @@ +import type { Hex } from '@metamask/utils'; +import type { SagaIterator } from 'redux-saga'; +import { put } from 'redux-saga/effects'; + +import { setChain } from '../../store'; +import type { RunSagaFunction } from '../../store'; + +/** + * Set the current chain ID in state. + * + * @param chainId - The chain ID. + * @yields Puts the chain ID in the store. + * @returns `null`. + */ +function* setCurrentChainImplementation(chainId: Hex): SagaIterator { + yield put(setChain(chainId)); + return null; +} + +/** + * Get a method that can be used to set the current chain. + * + * @param runSaga - A function to run a saga outside the usual Redux flow. + * @returns A method that can be used to set the current chain. + */ +export function getSetCurrentChainImplementation(runSaga: RunSagaFunction) { + return (...args: Parameters) => { + return runSaga(setCurrentChainImplementation, ...args).result(); + }; +} diff --git a/packages/snaps-simulation/src/methods/hooks/index.ts b/packages/snaps-simulation/src/methods/hooks/index.ts index 8a7de29983..6668d05319 100644 --- a/packages/snaps-simulation/src/methods/hooks/index.ts +++ b/packages/snaps-simulation/src/methods/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './chain'; export * from './end-trace'; export * from './get-entropy-sources'; export * from './get-mnemonic'; diff --git a/packages/snaps-simulation/src/middleware/engine.ts b/packages/snaps-simulation/src/middleware/engine.ts index ab3639d747..1ccfcfb1c9 100644 --- a/packages/snaps-simulation/src/middleware/engine.ts +++ b/packages/snaps-simulation/src/middleware/engine.ts @@ -1,4 +1,3 @@ -import { createFetchMiddleware } from '@metamask/eth-json-rpc-middleware'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { RestrictedMethodParameters } from '@metamask/permission-controller'; @@ -7,7 +6,7 @@ import type { Json } from '@metamask/utils'; import { createInternalMethodsMiddleware } from './internal-methods'; import { createMockMiddleware } from './mock'; -import { DEFAULT_JSON_RPC_ENDPOINT } from '../constants'; +import { createProviderMiddleware } from './provider'; import type { PermittedMiddlewareHooks, RestrictedMiddlewareHooks, @@ -33,7 +32,6 @@ export type CreateJsonRpcEngineOptions = { * @param options.restrictedHooks - Any hooks used by the middleware handlers. * @param options.permittedHooks - Any hooks used by the middleware handlers. * @param options.permissionMiddleware - The permission middleware to use. - * @param options.endpoint - The JSON-RPC endpoint to use for Ethereum requests. * @returns A JSON-RPC engine. */ export function createJsonRpcEngine({ @@ -41,7 +39,6 @@ export function createJsonRpcEngine({ restrictedHooks, permittedHooks, permissionMiddleware, - endpoint = DEFAULT_JSON_RPC_ENDPOINT, }: CreateJsonRpcEngineOptions) { const engine = new JsonRpcEngine(); engine.push(createMockMiddleware(store)); @@ -52,13 +49,7 @@ export function createJsonRpcEngine({ engine.push(createSnapsMethodMiddleware(true, permittedHooks)); engine.push(permissionMiddleware); - engine.push( - createFetchMiddleware({ - btoa: globalThis.btoa, - fetch: globalThis.fetch, - rpcUrl: endpoint, - }), - ); + engine.push(createProviderMiddleware(store)); return engine; } diff --git a/packages/snaps-simulation/src/middleware/internal-methods/chain-id.test.ts b/packages/snaps-simulation/src/middleware/internal-methods/chain-id.test.ts index c5f8288600..afd737eec4 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/chain-id.test.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/chain-id.test.ts @@ -1,9 +1,12 @@ import type { PendingJsonRpcResponse } from '@metamask/utils'; import { getChainIdHandler } from './chain-id'; +import { createStore } from '../../store'; +import { getMockOptions } from '../../test-utils'; describe('getChainIdHandler', () => { it('returns the chain id', async () => { + const { store } = createStore(getMockOptions()); const end = jest.fn(); const result: PendingJsonRpcResponse = { jsonrpc: '2.0' as const, @@ -20,9 +23,10 @@ describe('getChainIdHandler', () => { result, jest.fn(), end, + { getSimulationState: store.getState }, ); expect(end).toHaveBeenCalled(); - expect(result.result).toBe('0x01'); + expect(result.result).toBe('0x1'); }); }); diff --git a/packages/snaps-simulation/src/middleware/internal-methods/chain-id.ts b/packages/snaps-simulation/src/middleware/internal-methods/chain-id.ts index 33f8bc5c90..ae62d4d104 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/chain-id.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/chain-id.ts @@ -4,6 +4,8 @@ import type { } from '@metamask/json-rpc-engine'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import type { InternalMethodsMiddlewareHooks } from './middleware'; + /** * A mock handler for eth_chainId that always returns a specific * hardcoded result. @@ -14,6 +16,7 @@ import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; * result. * @param _next - The `json-rpc-engine` middleware next handler. * @param end - The `json-rpc-engine` middleware end handler. + * @param hooks - The method hooks. * @returns The JSON-RPC response. */ export async function getChainIdHandler( @@ -21,10 +24,9 @@ export async function getChainIdHandler( response: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, + hooks: Pick, ) { - // For now this will return a mocked result, this should probably match - // whatever network the simulation is using. - response.result = '0x01'; + response.result = hooks.getSimulationState().chain.chainId; return end(); } diff --git a/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts b/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts index 6465e2a048..69b8732db7 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts @@ -1,12 +1,12 @@ import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import { logError } from '@metamask/snaps-utils'; -import type { Json, JsonRpcParams } from '@metamask/utils'; +import type { Hex, Json, JsonRpcParams } from '@metamask/utils'; import { getAccountsHandler } from './accounts'; import { getChainIdHandler } from './chain-id'; import { getNetworkVersionHandler } from './net-version'; -import { getProviderStateHandler } from './provider-state'; import { getSwitchEthereumChainHandler } from './switch-ethereum-chain'; +import type { ApplicationState } from '../../store'; export type InternalMethodsMiddlewareHooks = { /** @@ -15,11 +15,24 @@ export type InternalMethodsMiddlewareHooks = { * @returns The user's secret recovery phrase. */ getMnemonic: () => Promise; + + /** + * A hook that returns the simulation state. + * + * @returns The simulation state. + */ + getSimulationState: () => ApplicationState; + + /** + * A hook that sets the current chain ID. + * + * @param chainId - The chain ID. + */ + setCurrentChain: (chainId: Hex) => null; }; const methodHandlers = { /* eslint-disable @typescript-eslint/naming-convention */ - metamask_getProviderState: getProviderStateHandler, eth_requestAccounts: getAccountsHandler, eth_accounts: getAccountsHandler, eth_chainId: getChainIdHandler, diff --git a/packages/snaps-simulation/src/middleware/internal-methods/net-version.test.ts b/packages/snaps-simulation/src/middleware/internal-methods/net-version.test.ts index f160ea321e..1b8cf66c7f 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/net-version.test.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/net-version.test.ts @@ -1,9 +1,12 @@ import type { PendingJsonRpcResponse } from '@metamask/utils'; import { getNetworkVersionHandler } from './net-version'; +import { createStore } from '../../store'; +import { getMockOptions } from '../../test-utils'; describe('getNetworkVersionHandler', () => { it('returns the network version', async () => { + const { store } = createStore(getMockOptions()); const end = jest.fn(); const result: PendingJsonRpcResponse = { jsonrpc: '2.0' as const, @@ -20,6 +23,7 @@ describe('getNetworkVersionHandler', () => { result, jest.fn(), end, + { getSimulationState: store.getState }, ); expect(end).toHaveBeenCalled(); diff --git a/packages/snaps-simulation/src/middleware/internal-methods/net-version.ts b/packages/snaps-simulation/src/middleware/internal-methods/net-version.ts index 1095b90ddf..94b706e550 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/net-version.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/net-version.ts @@ -2,8 +2,11 @@ import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, } from '@metamask/json-rpc-engine'; +import { hexToBigInt } from '@metamask/utils'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import type { InternalMethodsMiddlewareHooks } from './middleware'; + /** * A mock handler for net_version that always returns a specific * hardcoded result. @@ -14,6 +17,7 @@ import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; * result. * @param _next - The `json-rpc-engine` middleware next handler. * @param end - The `json-rpc-engine` middleware end handler. + * @param hooks - The method hooks. * @returns The JSON-RPC response. */ export async function getNetworkVersionHandler( @@ -21,10 +25,10 @@ export async function getNetworkVersionHandler( response: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, + hooks: Pick, ) { - // For now this will return a mocked result, this should probably match - // whatever network the simulation is using. - response.result = '1'; + const hexChainId = hooks.getSimulationState().chain.chainId; + response.result = hexToBigInt(hexChainId).toString(10); return end(); } diff --git a/packages/snaps-simulation/src/middleware/internal-methods/provider-state.test.ts b/packages/snaps-simulation/src/middleware/internal-methods/provider-state.test.ts deleted file mode 100644 index 6c9eb646d6..0000000000 --- a/packages/snaps-simulation/src/middleware/internal-methods/provider-state.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { PendingJsonRpcResponse } from '@metamask/utils'; - -import { getProviderStateHandler } from './provider-state'; - -describe('getProviderStateHandler', () => { - it('returns the provider state', async () => { - const end = jest.fn(); - const result: PendingJsonRpcResponse = { - jsonrpc: '2.0' as const, - id: 1, - }; - - await getProviderStateHandler( - { - jsonrpc: '2.0', - id: 1, - method: 'metamask_getProviderState', - params: [], - }, - result, - jest.fn(), - end, - ); - - expect(end).toHaveBeenCalled(); - expect(result.result).toStrictEqual({ - isUnlocked: true, - chainId: '0x01', - networkVersion: '0x01', - accounts: [], - }); - }); -}); diff --git a/packages/snaps-simulation/src/middleware/internal-methods/provider-state.ts b/packages/snaps-simulation/src/middleware/internal-methods/provider-state.ts deleted file mode 100644 index 7fa386d8ae..0000000000 --- a/packages/snaps-simulation/src/middleware/internal-methods/provider-state.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { - JsonRpcEngineEndCallback, - JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; -import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; - -/** - * A mock handler for metamask_getProviderState that always returns a specific - * hardcoded result. - * - * @param _request - Incoming JSON-RPC request. Ignored for this specific - * handler. - * @param response - The outgoing JSON-RPC response, modified to return the - * result. - * @param _next - The `json-rpc-engine` middleware next handler. - * @param end - The `json-rpc-engine` middleware end handler. - * @returns The JSON-RPC response. - */ -export async function getProviderStateHandler( - _request: JsonRpcRequest, - response: PendingJsonRpcResponse, - _next: JsonRpcEngineNextCallback, - end: JsonRpcEngineEndCallback, -) { - // For now this will return a mocked result, this should probably match - // whatever network the simulation is using. - response.result = { - isUnlocked: true, - chainId: '0x01', - networkVersion: '0x01', - accounts: [], - }; - - return end(); -} diff --git a/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts index 4732d53dcb..4126116ee3 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts @@ -9,20 +9,24 @@ describe('getSwitchEthereumChainHandler', () => { jsonrpc: '2.0' as const, id: 1, }; + const hooks = { setCurrentChain: jest.fn().mockResolvedValue(undefined) }; + const chainId = '0xaa36a7'; await getSwitchEthereumChainHandler( { jsonrpc: '2.0', id: 1, method: 'wallet_switchEthereumChain', - params: [], + params: [{ chainId }], }, result, jest.fn(), end, + hooks, ); expect(end).toHaveBeenCalled(); expect(result.result).toBeNull(); + expect(hooks.setCurrentChain).toHaveBeenCalledWith(chainId); }); }); diff --git a/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts index 3681955df2..399e4cd6f7 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts @@ -2,26 +2,41 @@ import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, } from '@metamask/json-rpc-engine'; -import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { + assert, + type Hex, + type JsonRpcRequest, + type PendingJsonRpcResponse, +} from '@metamask/utils'; + +export type SwitchEthereumChainHooks = { + setCurrentChain: (chain: Hex) => null; +}; /** * A mock handler for the `wallet_switchEthereumChain` method that always * returns `null`. * - * @param _request - Incoming JSON-RPC request. This is ignored for this - * specific handler. + * @param request - Incoming JSON-RPC request. * @param response - The outgoing JSON-RPC response, modified to return the * result. * @param _next - The `json-rpc-engine` middleware next handler. * @param end - The `json-rpc-engine` middleware end handler. + * @param hooks - The method hooks. * @returns The response. */ export async function getSwitchEthereumChainHandler( - _request: JsonRpcRequest, + request: JsonRpcRequest, response: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, + hooks: SwitchEthereumChainHooks, ) { + const castRequest = request as JsonRpcRequest<[{ chainId: Hex }]>; + + assert(castRequest.params?.[0]?.chainId, 'No chain ID passed.'); + hooks.setCurrentChain(castRequest.params[0].chainId); + response.result = null; return end(); } diff --git a/packages/snaps-simulation/src/middleware/provider.test.ts b/packages/snaps-simulation/src/middleware/provider.test.ts new file mode 100644 index 0000000000..e228037495 --- /dev/null +++ b/packages/snaps-simulation/src/middleware/provider.test.ts @@ -0,0 +1,154 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { stringToBytes } from '@metamask/utils'; +import { FetchRequest } from 'ethers'; + +import { createProviderMiddleware } from './provider'; +import { createStore, setChain } from '../store'; +import { getMockOptions } from '../test-utils'; + +/** + * Sets up the JSON-RPC engine, middleware and Redux store for testing. + * + * @returns The store and the JSON-RPC engine. + */ +function createMiddleware() { + const { store } = createStore(getMockOptions()); + const middleware = createProviderMiddleware(store); + const engine = new JsonRpcEngine(); + engine.push(middleware); + return { engine, store }; +} + +describe('createProviderMiddleware', () => { + const fetchMock = jest.fn(); + FetchRequest.registerGetUrl(async (request) => { + const { statusCode, result, statusMessage, headers } = await fetchMock( + request.url, + ); + const body = stringToBytes(JSON.stringify(result)); + return { + statusCode, + body, + statusMessage: statusMessage ?? '', + headers: headers ?? {}, + }; + }); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'net_version', + params: [], + }; + + it('responds to RPC requests', async () => { + fetchMock.mockResolvedValue({ + statusCode: 200, + result: { id: 1, jsonrpc: '2.0', result: '1' }, + }); + + const { engine } = createMiddleware(); + + const result = await engine.handle(request); + expect(result).toStrictEqual({ id: 1, jsonrpc: '2.0', result: '1' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://mainnet.infura.io'), + ); + }); + + it('routes RPC requests to proper chain ID', async () => { + fetchMock.mockResolvedValue({ + statusCode: 200, + result: { id: 1, jsonrpc: '2.0', result: '11155111' }, + }); + + const { store, engine } = createMiddleware(); + + store.dispatch(setChain('0xaa36a7')); + + const result = await engine.handle(request); + expect(result).toStrictEqual({ id: 1, jsonrpc: '2.0', result: '11155111' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://sepolia.infura.io'), + ); + }); + + it('handles errors nested in the info property', async () => { + fetchMock.mockResolvedValue({ + statusCode: 200, + result: { + id: 1, + jsonrpc: '2.0', + error: { + code: -32601, + message: 'The method abc does not exist/is not available', + }, + }, + }); + + const { engine } = createMiddleware(); + + const result = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'abc', + params: [], + }); + expect(result).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32601, + message: 'The method abc does not exist/is not available', + }, + }); + }); + + it('handles errors nested in the error property', async () => { + fetchMock.mockResolvedValue({ + statusCode: 200, + result: { + id: 1, + jsonrpc: '2.0', + error: { + code: -32602, + message: 'missing value for required argument 0', + }, + }, + }); + + const { engine } = createMiddleware(); + + const result = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getBlockByNumber', + }); + expect(result).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32602, + message: 'missing value for required argument 0', + }, + }); + }); + + it('falls back to internal RPC error', async () => { + fetchMock.mockRejectedValue(new Error('Unknown error')); + + const { engine } = createMiddleware(); + + const result = await engine.handle(request); + expect(result).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32603, + message: 'Internal JSON-RPC error.', + }), + }); + }); +}); diff --git a/packages/snaps-simulation/src/middleware/provider.ts b/packages/snaps-simulation/src/middleware/provider.ts new file mode 100644 index 0000000000..f457037873 --- /dev/null +++ b/packages/snaps-simulation/src/middleware/provider.ts @@ -0,0 +1,41 @@ +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import { rpcErrors, serializeCause } from '@metamask/rpc-errors'; +import type { Json, JsonRpcParams } from '@metamask/utils'; +import { hasProperty, hexToBigInt } from '@metamask/utils'; +import { InfuraProvider } from 'ethers'; + +import type { Store } from '../store'; +import { getChainId } from '../store'; + +/** + * Create a middleware that uses a JSON-RPC provider to respond to RPC requests. + * + * @param store - The Redux store. + * @returns A middleware that responds to JSON-RPC requests. + */ +export function createProviderMiddleware( + store: Store, +): JsonRpcMiddleware { + return createAsyncMiddleware(async (request, response) => { + try { + const chainId = getChainId(store.getState()); + const provider = new InfuraProvider(hexToBigInt(chainId)); + + const result = await provider.send(request.method, request.params ?? []); + response.result = result; + } catch (error) { + if (hasProperty(error, 'info') && hasProperty(error.info, 'error')) { + response.error = error.info.error; + return; + } + if (hasProperty(error, 'error')) { + response.error = error.error; + return; + } + response.error = rpcErrors.internal({ + data: { cause: serializeCause(error) }, + }); + } + }); +} diff --git a/packages/snaps-simulation/src/simulation.test.ts b/packages/snaps-simulation/src/simulation.test.ts index 995eceb548..726c877903 100644 --- a/packages/snaps-simulation/src/simulation.test.ts +++ b/packages/snaps-simulation/src/simulation.test.ts @@ -215,23 +215,36 @@ describe('installSnap', () => { }); describe('getRestrictedHooks', () => { + const options = getMockOptions(); + const { runSaga, store } = createStore(getMockOptions()); + it('returns the `getMnemonic` hook', async () => { - const { getMnemonic } = getRestrictedHooks(getMockOptions()); + const { getMnemonic } = getRestrictedHooks(options, store, runSaga); expect(await getMnemonic()).toStrictEqual( mnemonicPhraseToBytes(DEFAULT_SRP), ); }); it('returns the `getIsLocked` hook', async () => { - const { getIsLocked } = getRestrictedHooks(getMockOptions()); + const { getIsLocked } = getRestrictedHooks(options, store, runSaga); expect(getIsLocked()).toBe(false); }); it('returns the `getClientCryptography` hook', async () => { - const { getClientCryptography } = getRestrictedHooks(getMockOptions()); + const { getClientCryptography } = getRestrictedHooks( + options, + store, + runSaga, + ); expect(getClientCryptography()).toStrictEqual({}); }); + + it('returns the `getSimulationState` hook', async () => { + const { getSimulationState } = getRestrictedHooks(options, store, runSaga); + + expect(getSimulationState()).toStrictEqual(store.getState()); + }); }); describe('getPermittedHooks', () => { diff --git a/packages/snaps-simulation/src/simulation.ts b/packages/snaps-simulation/src/simulation.ts index 7cdec6dba3..5a95ec994f 100644 --- a/packages/snaps-simulation/src/simulation.ts +++ b/packages/snaps-simulation/src/simulation.ts @@ -30,7 +30,7 @@ import type { } from '@metamask/snaps-sdk'; import type { FetchedSnapFiles, Snap } from '@metamask/snaps-utils'; import { logError } from '@metamask/snaps-utils'; -import type { CaipAssetType, Json } from '@metamask/utils'; +import type { CaipAssetType, Hex, Json } from '@metamask/utils'; import type { Duplex } from 'readable-stream'; import { pipeline } from 'readable-stream'; import type { SagaIterator } from 'redux-saga'; @@ -54,12 +54,18 @@ import { getTrackErrorImplementation, getEndTraceImplementation, getStartTraceImplementation, + getSetCurrentChainImplementation, } from './methods/hooks'; import { getGetMnemonicSeedImplementation } from './methods/hooks/get-mnemonic-seed'; import { createJsonRpcEngine } from './middleware'; import type { SimulationOptions, SimulationUserOptions } from './options'; import { getOptions } from './options'; -import type { Interface, RunSagaFunction, Store } from './store'; +import type { + ApplicationState, + Interface, + RunSagaFunction, + Store, +} from './store'; import { createStore, getCurrentInterface } from './store'; import { addSnapMetadataToAccount } from './utils/account'; @@ -155,6 +161,20 @@ export type RestrictedMiddlewareHooks = { * @returns The metadata for the given Snap. */ getSnap: (snapId: string) => Snap; + + /** + * A hook that sets the current chain ID. + * + * @param chainId - The chain ID. + */ + setCurrentChain: (chainId: Hex) => null; + + /** + * A hook that gets the current simulation state. + * + * @returns The simulation state. + */ + getSimulationState: () => ApplicationState; }; export type PermittedMiddlewareHooks = { @@ -373,7 +393,7 @@ export async function installSnap< registerActions(controllerMessenger, runSaga, options, snapId); // Set up controllers and JSON-RPC stack. - const restrictedHooks = getRestrictedHooks(options); + const restrictedHooks = getRestrictedHooks(options, store, runSaga); const permittedHooks = getPermittedHooks( snapId, snapFiles, @@ -457,10 +477,14 @@ export async function installSnap< * Get the hooks for the simulation. * * @param options - The simulation options. + * @param store - The Redux store. + * @param runSaga - The run saga function. * @returns The hooks for the simulation. */ export function getRestrictedHooks( options: SimulationOptions, + store: Store, + runSaga: RunSagaFunction, ): RestrictedMiddlewareHooks { return { getMnemonic: getGetMnemonicImplementation(options.secretRecoveryPhrase), @@ -470,6 +494,8 @@ export function getRestrictedHooks( getIsLocked: () => false, getClientCryptography: () => ({}), getSnap: getGetSnapImplementation(true), + setCurrentChain: getSetCurrentChainImplementation(runSaga), + getSimulationState: store.getState.bind(store), }; } diff --git a/packages/snaps-simulation/src/store/chain.ts b/packages/snaps-simulation/src/store/chain.ts new file mode 100644 index 0000000000..922ec86764 --- /dev/null +++ b/packages/snaps-simulation/src/store/chain.ts @@ -0,0 +1,36 @@ +import type { Hex } from '@metamask/utils'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +import type { ApplicationState } from './store'; + +export type ChainState = { + chainId: Hex; +}; + +/** + * The initial chain state. + */ +const INITIAL_STATE: ChainState = { + chainId: '0x1', +}; + +export const chainSlice = createSlice({ + name: 'chain', + initialState: INITIAL_STATE, + reducers: { + setChain: (state, action: PayloadAction) => { + state.chainId = action.payload; + }, + }, +}); + +export const { setChain } = chainSlice.actions; + +/** + * Get the chain ID from the state. + * + * @param state - The application state. + * @returns The chain ID. + */ +export const getChainId = (state: ApplicationState) => state.chain.chainId; diff --git a/packages/snaps-simulation/src/store/index.ts b/packages/snaps-simulation/src/store/index.ts index 88dff327d4..9a9cf75f04 100644 --- a/packages/snaps-simulation/src/store/index.ts +++ b/packages/snaps-simulation/src/store/index.ts @@ -1,3 +1,4 @@ +export * from './chain'; export * from './mocks'; export * from './notifications'; export * from './state'; diff --git a/packages/snaps-simulation/src/store/store.test.ts b/packages/snaps-simulation/src/store/store.test.ts index 94ed482ee7..6b1e9a0cab 100644 --- a/packages/snaps-simulation/src/store/store.test.ts +++ b/packages/snaps-simulation/src/store/store.test.ts @@ -8,6 +8,9 @@ describe('createStore', () => { expect(store).toBeDefined(); expect(store.getState()).toMatchInlineSnapshot(` { + "chain": { + "chainId": "0x1", + }, "mocks": { "jsonRpc": {}, }, @@ -43,6 +46,9 @@ describe('createStore', () => { expect(store).toBeDefined(); expect(store.getState()).toMatchInlineSnapshot(` { + "chain": { + "chainId": "0x1", + }, "mocks": { "jsonRpc": {}, }, @@ -78,6 +84,9 @@ describe('createStore', () => { expect(store).toBeDefined(); expect(store.getState()).toMatchInlineSnapshot(` { + "chain": { + "chainId": "0x1", + }, "mocks": { "jsonRpc": {}, }, diff --git a/packages/snaps-simulation/src/store/store.ts b/packages/snaps-simulation/src/store/store.ts index 4d0070a180..c233b8be69 100644 --- a/packages/snaps-simulation/src/store/store.ts +++ b/packages/snaps-simulation/src/store/store.ts @@ -1,6 +1,7 @@ import { configureStore } from '@reduxjs/toolkit'; import createSagaMiddleware from 'redux-saga'; +import { chainSlice } from './chain'; import { mocksSlice } from './mocks'; import { notificationsSlice } from './notifications'; import { setState, stateSlice } from './state'; @@ -25,6 +26,7 @@ export function createStore({ state, unencryptedState }: SimulationOptions) { state: stateSlice.reducer, trackables: trackablesSlice.reducer, ui: uiSlice.reducer, + chain: chainSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: false, serializableCheck: false }).concat( diff --git a/yarn.lock b/yarn.lock index 9e2d2961e2..c203bded58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2794,16 +2794,6 @@ __metadata: languageName: node linkType: hard -"@metamask/abi-utils@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/abi-utils@npm:3.0.0" - dependencies: - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.0.1" - checksum: 10/068b98185148b9e185b4af4392c6a6f82f1d4b1ff60013c57679c618f37afe9030e3ccc940e1a8b690be6f62ea91115ab18b73f3c3c09f4eff1794e31ababb9b - languageName: node - linkType: hard - "@metamask/action-utils@npm:^1.0.0": version: 1.1.1 resolution: "@metamask/action-utils@npm:1.1.1" @@ -3294,65 +3284,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^12.0.0": - version: 12.2.1 - resolution: "@metamask/eth-block-tracker@npm:12.2.1" - dependencies: - "@metamask/eth-json-rpc-provider": "npm:^5.0.0" - "@metamask/safe-event-emitter": "npm:^3.1.1" - "@metamask/utils": "npm:^11.0.1" - json-rpc-random-id: "npm:^1.0.1" - pify: "npm:^5.0.0" - checksum: 10/70f0f4179bb7d0d9d64d54887ea03302e74221c92971a7081db05811d97cc85b8d75d95cf00ea09b10a0188c1c3cde34896d97d1952623e99ea124ac5e5eeb67 - languageName: node - linkType: hard - -"@metamask/eth-json-rpc-middleware@npm:^17.0.1": - version: 17.0.1 - resolution: "@metamask/eth-json-rpc-middleware@npm:17.0.1" - dependencies: - "@metamask/eth-block-tracker": "npm:^12.0.0" - "@metamask/eth-json-rpc-provider": "npm:^4.1.7" - "@metamask/eth-sig-util": "npm:^8.1.2" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - "@types/bn.js": "npm:^5.1.5" - bn.js: "npm:^5.2.1" - klona: "npm:^2.0.6" - pify: "npm:^5.0.0" - safe-stable-stringify: "npm:^2.4.3" - checksum: 10/6a0709479f7187183f99bd76b2724cb72b4155ded506d939b7625ae17f63bff68bee9828e0d76af06e4d4009eecc87b63059e8796947442e96844a42af161e2f - languageName: node - linkType: hard - -"@metamask/eth-json-rpc-provider@npm:^4.1.7": - version: 4.1.8 - resolution: "@metamask/eth-json-rpc-provider@npm:4.1.8" - dependencies: - "@metamask/json-rpc-engine": "npm:^10.0.3" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.1.0" - uuid: "npm:^8.3.2" - checksum: 10/8247f22a23ec0cae7f80c7755b00bfa337a27cc4d2ea416ed08f65a898cd6110057a3710e55e0454db7406c114a4a570b9a286baa8136db6f1c485f62a6c2800 - languageName: node - linkType: hard - -"@metamask/eth-json-rpc-provider@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/eth-json-rpc-provider@npm:5.0.0" - dependencies: - "@metamask/json-rpc-engine": "npm:^10.1.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.8.0" - uuid: "npm:^8.3.2" - checksum: 10/b09a4c06bf570c09b045583733ba2cf5047937e84d42b4c13f8b6a1e39acae083f032aed16c17b37dd4b86cab16f6e52b0ba788d4f3a63c4301a614d69cad937 - languageName: node - linkType: hard - "@metamask/eth-query@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/eth-query@npm:4.0.0" @@ -3363,21 +3294,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-sig-util@npm:^8.1.2": - version: 8.2.0 - resolution: "@metamask/eth-sig-util@npm:8.2.0" - dependencies: - "@ethereumjs/rlp": "npm:^4.0.1" - "@ethereumjs/util": "npm:^8.1.0" - "@metamask/abi-utils": "npm:^3.0.0" - "@metamask/utils": "npm:^11.0.1" - "@scure/base": "npm:~1.1.3" - ethereum-cryptography: "npm:^2.1.2" - tweetnacl: "npm:^1.0.3" - checksum: 10/385df1ec541116e1bd725a1df1a519996bad167f99d1b2677126e398cdfda6fc3f03d2ff8f1ca523966bc0aae3ea92a9050953a45d5a7711f4128aacf9242bfc - languageName: node - linkType: hard - "@metamask/ethereum-provider-example-snap@workspace:^, @metamask/ethereum-provider-example-snap@workspace:packages/examples/packages/ethereum-provider": version: 0.0.0-use.local resolution: "@metamask/ethereum-provider-example-snap@workspace:packages/examples/packages/ethereum-provider" @@ -3419,7 +3335,7 @@ __metadata: deepmerge: "npm:^4.2.2" depcheck: "npm:^1.4.7" eslint: "npm:^9.11.0" - ethers: "npm:^6.3.0" + ethers: "npm:^6.16.0" jest: "npm:^29.0.2" jest-silent-reporter: "npm:^0.6.0" prettier: "npm:^3.3.3" @@ -3649,7 +3565,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0": +"@metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0": version: 10.2.0 resolution: "@metamask/json-rpc-engine@npm:10.2.0" dependencies: @@ -4554,13 +4470,13 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^3.4.0" "@metamask/auto-changelog": "npm:^5.0.2" - "@metamask/eth-json-rpc-middleware": "npm:^17.0.1" "@metamask/json-rpc-engine": "npm:^10.1.0" "@metamask/json-rpc-middleware-stream": "npm:^8.0.8" "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" "@metamask/permission-controller": "npm:^12.1.1" "@metamask/phishing-controller": "npm:^16.1.0" + "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/snaps-controllers": "workspace:^" "@metamask/snaps-execution-environments": "workspace:^" "@metamask/snaps-rpc-methods": "workspace:^" @@ -4577,6 +4493,7 @@ __metadata: deepmerge: "npm:^4.2.2" depcheck: "npm:^1.4.7" eslint: "npm:^9.11.0" + ethers: "npm:^6.16.0" express: "npm:^5.1.0" fast-deep-equal: "npm:^3.1.3" immer: "npm:^9.0.21" @@ -4769,7 +4686,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.0, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.9.0 resolution: "@metamask/utils@npm:11.9.0" dependencies: @@ -10929,9 +10846,9 @@ __metadata: languageName: node linkType: hard -"ethers@npm:^6.3.0": - version: 6.13.4 - resolution: "ethers@npm:6.13.4" +"ethers@npm:^6.16.0": + version: 6.16.0 + resolution: "ethers@npm:6.16.0" dependencies: "@adraffy/ens-normalize": "npm:1.10.1" "@noble/curves": "npm:1.2.0" @@ -10940,7 +10857,7 @@ __metadata: aes-js: "npm:4.0.0-beta.5" tslib: "npm:2.7.0" ws: "npm:8.17.1" - checksum: 10/221192fed93f6b0553f3e5e72bfd667d676220577d34ff854f677e955d6f608e60636a9c08b5d54039c532a9b9b7056384f0d7019eb6e111d53175806f896ac6 + checksum: 10/7e980f0a77963fbe14321a3b9746c3ca3cad44932e28bb3506406a66c4b4d9dc1e60ed68d9d784224e9f2582a53d6a0a2e55a7c9559659681f4ad1f70e00e325 languageName: node linkType: hard @@ -13550,7 +13467,7 @@ __metadata: languageName: node linkType: hard -"json-rpc-random-id@npm:^1.0.0, json-rpc-random-id@npm:^1.0.1": +"json-rpc-random-id@npm:^1.0.0": version: 1.0.1 resolution: "json-rpc-random-id@npm:1.0.1" checksum: 10/fcd2e884193a129ace4002bd65a86e9cdb206733b4693baea77bd8b372cf8de3043fbea27716a2c9a716581a908ca8d978d9dfec4847eb2cf77edb4cf4b2252c @@ -15378,13 +15295,6 @@ __metadata: languageName: node linkType: hard -"pify@npm:^5.0.0": - version: 5.0.0 - resolution: "pify@npm:5.0.0" - checksum: 10/443e3e198ad6bfa8c0c533764cf75c9d5bc976387a163792fb553ffe6ce923887cf14eebf5aea9b7caa8eab930da8c33612990ae85bd8c2bc18bedb9eae94ecb - languageName: node - linkType: hard - "pirates@npm:^4.0.4": version: 4.0.7 resolution: "pirates@npm:4.0.7" @@ -16620,13 +16530,6 @@ __metadata: languageName: node linkType: hard -"safe-stable-stringify@npm:^2.4.3": - version: 2.4.3 - resolution: "safe-stable-stringify@npm:2.4.3" - checksum: 10/a6c192bbefe47770a11072b51b500ed29be7b1c15095371c1ee1dc13e45ce48ee3c80330214c56764d006c485b88bd0b24940d868948170dddc16eed312582d8 - languageName: node - linkType: hard - "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -18156,13 +18059,6 @@ __metadata: languageName: node linkType: hard -"tweetnacl@npm:^1.0.3": - version: 1.0.3 - resolution: "tweetnacl@npm:1.0.3" - checksum: 10/ca122c2f86631f3c0f6d28efb44af2a301d4a557a62a3e2460286b08e97567b258c2212e4ad1cfa22bd6a57edcdc54ba76ebe946847450ab0999e6d48ccae332 - languageName: node - linkType: hard - "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0"