diff --git a/packages/sdk-multichain/src/domain/multichain/api/constants.ts b/packages/sdk-multichain/src/domain/multichain/api/constants.ts index 1b7c87e24..ce631f547 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/constants.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/constants.ts @@ -1,7 +1,7 @@ /* c8 ignore start */ -import type { RPC_URLS_MAP } from './types'; +import type { RpcUrlsMap } from './types'; -export const infuraRpcUrls: RPC_URLS_MAP = { +export const infuraRpcUrls: RpcUrlsMap = { // ###### Ethereum ###### // Mainnet 'eip155:1': 'https://mainnet.infura.io/v3/', @@ -64,3 +64,41 @@ export const infuraRpcUrls: RPC_URLS_MAP = { 'eip155:44787': 'https://celo-alfajores.infura.io/v3/', }; +export const RPC_HANDLED_METHODS = new Set([ + 'eth_blockNumber', + 'eth_gasPrice', + 'eth_maxPriorityFeePerGas', + 'eth_blobBaseFee', + 'eth_feeHistory', + 'eth_getBalance', + 'eth_getCode', + 'eth_getStorageAt', + 'eth_call', + 'eth_estimateGas', + 'eth_getLogs', + 'eth_getProof', + 'eth_getTransactionCount', + 'eth_getBlockByNumber', + 'eth_getBlockByHash', + 'eth_getBlockTransactionCountByNumber', + 'eth_getBlockTransactionCountByHash', + 'eth_getUncleCountByBlockNumber', + 'eth_getUncleCountByBlockHash', + 'eth_getTransactionByHash', + 'eth_getTransactionByBlockNumberAndIndex', + 'eth_getTransactionByBlockHashAndIndex', + 'eth_getTransactionReceipt', + 'eth_getUncleByBlockNumberAndIndex', + 'eth_getUncleByBlockHashAndIndex', + 'eth_getFilterChanges', + 'eth_getFilterLogs', + 'eth_newBlockFilter', + 'eth_newFilter', + 'eth_newPendingTransactionFilter', + 'eth_sendRawTransaction', + 'eth_syncing', + 'eth_uninstallFilter', + 'web3_clientVersion', +]); + +export const SDK_HANDLED_METHODS = new Set(['eth_accounts', 'eth_chainId']); diff --git a/packages/sdk-multichain/src/domain/multichain/api/infura.ts b/packages/sdk-multichain/src/domain/multichain/api/infura.ts index cb6c38fbb..90bd551de 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/infura.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/infura.ts @@ -1,10 +1,10 @@ import { infuraRpcUrls } from './constants'; -import type { RPC_URLS_MAP } from './types'; +import type { RpcUrlsMap } from './types'; export function getInfuraRpcUrls(infuraAPIKey: string) { return Object.keys(infuraRpcUrls).reduce((acc, key) => { const typedKey = key as keyof typeof infuraRpcUrls; acc[typedKey] = `${infuraRpcUrls[typedKey]}${infuraAPIKey}`; return acc; - }, {} as RPC_URLS_MAP); + }, {} as RpcUrlsMap); } diff --git a/packages/sdk-multichain/src/domain/multichain/api/types.ts b/packages/sdk-multichain/src/domain/multichain/api/types.ts index 4d212dd14..b4c3d2c97 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/types.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/types.ts @@ -1,3 +1,4 @@ +import { CaipChainId } from '@metamask/utils'; import type EIP155 from './eip155'; /** @@ -71,9 +72,9 @@ export type InvokeMethodOptions = { * This type defines the structure for providing custom RPC endpoints * for different blockchain networks using CAIP-2 format identifiers. */ -export type RPC_URLS_MAP = { +export type RpcUrlsMap = { /** CAIP-2 format chain ID mapped to its RPC URL (e.g., "eip155:1" -> "https://...") */ - [chainId: `${string}:${string}`]: string; + [chainId: CaipChainId]: string; }; /** diff --git a/packages/sdk-multichain/src/domain/multichain/types.ts b/packages/sdk-multichain/src/domain/multichain/types.ts index b8e73f42a..83e61ba56 100644 --- a/packages/sdk-multichain/src/domain/multichain/types.ts +++ b/packages/sdk-multichain/src/domain/multichain/types.ts @@ -1,11 +1,11 @@ import type { StoreClient } from '../store'; import type { MultichainCore } from '.'; -import type { RPC_URLS_MAP, Scope } from './api/types'; +import type { RpcUrlsMap, Scope } from './api/types'; import type { ModalFactory } from '../../ui'; import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; import type { PlatformType } from '../platform'; import type { Transport } from '@metamask/multichain-api-client'; -import type { CaipAccountId } from '@metamask/utils'; +import type { CaipAccountId, CaipChainId } from '@metamask/utils'; export type { SessionData } from '@metamask/multichain-api-client'; @@ -44,7 +44,7 @@ export type MultichainOptions = { /** The Infura API key to use for RPC requests */ infuraAPIKey?: string; /** A map of RPC URLs to use for read-only requests */ - readonlyRPCMap?: RPC_URLS_MAP; + readonlyRPCMap?: RpcUrlsMap; }; /** Analytics configuration */ analytics?: { enabled: false } | { enabled: true; integrationType: string }; diff --git a/packages/sdk-multichain/src/domain/platform/index.ts b/packages/sdk-multichain/src/domain/platform/index.ts index 23ee3d4c0..c123da689 100644 --- a/packages/sdk-multichain/src/domain/platform/index.ts +++ b/packages/sdk-multichain/src/domain/platform/index.ts @@ -65,7 +65,6 @@ export function getPlatformType() { return PlatformType.DesktopWeb; } - export function isSecure() { const platformType = getPlatformType(); return isReactNative() || platformType === PlatformType.MobileWeb; @@ -93,12 +92,10 @@ const detectionPromise: Promise = (() => { setTimeout(() => { window.removeEventListener('eip6963:announceProvider', handler); - const hasMetaMask = providers.some( - (p) => p?.info?.rdns === 'io.metamask', - ); + const hasMetaMask = providers.some((p) => p?.info?.rdns === 'io.metamask'); resolve(hasMetaMask); - }, 300); // default timeout + }, 300); // default timeout }); })(); diff --git a/packages/sdk-multichain/src/invoke.test.ts b/packages/sdk-multichain/src/invoke.test.ts index 848c1671a..ad8905bb5 100644 --- a/packages/sdk-multichain/src/invoke.test.ts +++ b/packages/sdk-multichain/src/invoke.test.ts @@ -8,7 +8,7 @@ import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobi import { Store } from './store'; import { mockSessionData, mockSessionRequestData } from '../tests/data'; import type { TestSuiteOptions, MockedData } from '../tests/types'; -import { RPCClient } from './multichain/rpc/client'; +import { RequestRouter } from './multichain/rpc/requestRouter'; vi.mock('cross-fetch', () => { const mockFetch = vi.fn(); @@ -127,7 +127,7 @@ function testSuite({ platform, createSDK, options: t.expect(sdk.storage).toBeDefined(); t.expect(sdk.transport).toBeDefined(); - const providerInvokeMethodSpy = t.vi.spyOn(RPCClient.prototype, 'invokeMethod'); + const providerInvokeMethodSpy = t.vi.spyOn(RequestRouter.prototype, 'invokeMethod'); const options = { id: 1, scope: 'eip155:1', @@ -144,7 +144,7 @@ function testSuite({ platform, createSDK, options: }); t.it( - `${platform} should reject invoke in case of failure in RPCClient`, + `${platform} should reject invoke in case of failure in RequestRouter`, async () => { const scopes = ['eip155:1'] as Scope[]; const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; @@ -176,7 +176,7 @@ function testSuite({ platform, createSDK, options: mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); - // Mock the RPCClient response + // Mock the RequestRouter response const mockJsonResponse = { result: 'success' }; const fetchModule = await import('cross-fetch'); const mockFetch = (fetchModule as any).__mockFetch; diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 0ea7bb540..d677f44fb 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -5,18 +5,19 @@ import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; import { getMultichainClient, type MultichainApiClient, type SessionData } from '@metamask/multichain-api-client'; import { analytics } from '@metamask/sdk-analytics'; import type { CaipAccountId, Json } from '@metamask/utils'; -import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE, MWP_RELAY_URL } from 'src/config'; +import { MWP_RELAY_URL } from 'src/config'; import packageJson from '../../package.json'; import { type InvokeMethodOptions, type MultichainOptions, type RPCAPI, type Scope, TransportType } from '../domain'; import { createLogger, enableDebug, isEnabled as isLoggerEnabled } from '../domain/logger'; import { type ConnectionRequest, type ExtendedTransport, MultichainCore, type SDKState } from '../domain/multichain'; import { getPlatformType, hasExtension, isSecure, PlatformType } from '../domain/platform'; -import { RPCClient } from './rpc/client'; +import { RequestRouter } from './rpc/requestRouter'; import { DefaultTransport } from './transports/default'; import { MWPTransport } from './transports/mwp'; import { keymanager } from './transports/mwp/KeyManager'; import { getDappId, getVersion, openDeeplink, setupDappMetadata, setupInfuraProvider } from './utils'; +import { RpcClient } from './rpc/handlers/rpcClient'; //ENFORCE NAMESPACE THAT CAN BE DISABLED const logger = createLogger('metamask-sdk:core'); @@ -456,11 +457,12 @@ export class MultichainSDK extends MultichainCore { } async invokeMethod(request: InvokeMethodOptions): Promise { - const { sdkInfo, transport } = this; + const { sdkInfo, transport, options } = this; this.__provider ??= getMultichainClient({ transport }); - const client = new RPCClient(this.transport, this.options, sdkInfo); - return client.invokeMethod(request) as Promise; + const rpcClient = new RpcClient(options, sdkInfo); + const requestRouter = new RequestRouter(transport, rpcClient, options); + return requestRouter.invokeMethod(request) as Promise; } } diff --git a/packages/sdk-multichain/src/multichain/rpc/client.test.ts b/packages/sdk-multichain/src/multichain/rpc/client.test.ts deleted file mode 100644 index 84e274585..000000000 --- a/packages/sdk-multichain/src/multichain/rpc/client.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ -/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ -import * as t from 'vitest'; -import { - type InvokeMethodOptions, - RPCInvokeMethodErr, - type Scope, -} from '../../domain'; -import type { RPCClient } from './client'; - -t.describe('RPCClient', () => { - let mockTransport: any; - let mockConfig: any; - let rpcClient: RPCClient; - let baseOptions: any; - - t.beforeEach(async () => { - const clientModule = await import('./client'); - baseOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_sendTransaction', - params: { to: '0x123', value: '0x100' }, - }, - }; - mockTransport = { - request: t.vi.fn(), - }; - mockConfig = { - api: { - infuraAPIKey: 'test-infura-key', - readonlyRPCMap: { - 'eip155:1': 'https://custom-mainnet.com', - }, - }, - }; - rpcClient = new clientModule.RPCClient(mockTransport, mockConfig); - // Reset mocks - mockTransport.request.mockClear(); - }); - - t.afterEach(async () => { - t.vi.clearAllMocks(); - t.vi.resetAllMocks(); - }); - - - t.describe('invokeMethod', () => { - t.it('should route to the wallet for eth_sendTransaction', async () => { - const sendTxOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_sendTransaction', - params: { to: '0x123', value: '0x100' }, - }, - }; - mockTransport.request.mockResolvedValue({ result: '0xhash' }); - const result = await rpcClient.invokeMethod(sendTxOptions); - - t.expect(result).toBe('0xhash'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: sendTxOptions, - }); - }); - - t.it('should route to the wallet for personal_sign', async () => { - const signOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'personal_sign', - params: { message: 'hello world' }, - }, - }; - mockTransport.request.mockResolvedValue({ result: '0xsignature' }); - const result = await rpcClient.invokeMethod(signOptions); - - t.expect(result).toBe('0xsignature'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: signOptions, - }); - }); - - t.it('should route to the wallet for eth_requestAccounts', async () => { - const requestAccountsOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_requestAccounts', - params: [], - }, - }; - mockTransport.request.mockResolvedValue({ result: ['0xaccount'] }); - const result = await rpcClient.invokeMethod(requestAccountsOptions); - - t.expect(result).toEqual(['0xaccount']); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: requestAccountsOptions, - }); - }); - - t.it('should route to the wallet for wallet_switchEthereumChain', async () => { - const switchChainOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x89' }], - }, - }; - mockTransport.request.mockResolvedValue({ result: null }); - const result = await rpcClient.invokeMethod(switchChainOptions); - - t.expect(result).toBeNull(); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: switchChainOptions, - }); - }); - - t.it('should fallback to the wallet for unknown methods', async () => { - const unknownOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'unknown_method', - params: [], - }, - }; - mockTransport.request.mockResolvedValue({ result: 'unknown_result' }); - const result = await rpcClient.invokeMethod(unknownOptions); - - t.expect(result).toBe('unknown_result'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: unknownOptions, - }); - }); - - t.it('should throw RPCInvokeMethodErr when transport request fails', async () => { - mockTransport.request.mockRejectedValue(new Error('Transport error')); - - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('Transport error'); - }); - - t.it('should throw RPCInvokeMethodErr when response contains an error', async () => { - mockTransport.request.mockResolvedValue({ - error: { code: -32603, message: 'Internal error' } - }); - - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('RPC Request failed with code -32603: Internal error'); - }); - }); -}); diff --git a/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.test.ts b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.test.ts new file mode 100644 index 000000000..56c76a23d --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.test.ts @@ -0,0 +1,237 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import * as t from 'vitest'; +import { vi } from 'vitest'; +import { MissingRpcEndpointErr, type RpcClient } from './rpcClient'; +import { RPCHttpErr, RPCReadonlyRequestErr, RPCReadonlyResponseErr, Scope } from '../../../domain'; + +// Mock cross-fetch with proper implementation +vi.mock('cross-fetch', () => { + const mockFetch = vi.fn(); + return { + default: mockFetch, + __mockFetch: mockFetch, + }; +}); + +t.describe('RpcClient', () => { + let mockConfig: any; + let sdkInfo: string; + let rpcClient: RpcClient; + let rpcClientModule: typeof RpcClient; + let defaultHeaders: Record; + let headers: Record; + let mockFetch: any; + let baseOptions: any; + + t.beforeEach(async () => { + const clientModule = await import('./rpcClient'); + baseOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_getBalance', + params: { address: '0x123', blockNumber: 'latest' }, + }, + }; + + mockConfig = { + api: { + infuraAPIKey: 'test-infura-key', + readonlyRPCMap: { + 'eip155:11155111': 'https://custom-sepolia.com', + }, + }, + }; + sdkInfo = 'Sdk/Javascript SdkVersion/1.0.0 Platform/web'; + rpcClient = new clientModule.RpcClient(mockConfig, sdkInfo); + rpcClientModule = clientModule.RpcClient; + // Get mock fetch from the module mock + const fetchModule = await import('cross-fetch'); + mockFetch = (fetchModule as any).__mockFetch; + // Reset mocks + mockFetch.mockClear(); + defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + headers = { + ...defaultHeaders, + 'Metamask-Sdk-Info': sdkInfo, + }; + }); + + t.afterEach(async () => { + t.vi.clearAllMocks(); + t.vi.resetAllMocks(); + }); + + t.describe('getHeaders', () => { + t.it('should return default headers when RPC endpoint does not include infura', () => { + const customRpcEndpoint = 'https://custom-ethereum-node.com/rpc'; + const headers = (rpcClient as any).getHeaders(customRpcEndpoint); + t.expect(headers).toEqual(defaultHeaders); + t.expect(headers).not.toHaveProperty('Metamask-Sdk-Info'); + }); + + t.it('should return headers with Metamask-Sdk-Info when RPC endpoint includes infura', () => { + const infuraEndpoint = 'https://mainnet.infura.io/v3/test-key'; + const currentHeaders = (rpcClient as any).getHeaders(infuraEndpoint); + t.expect(currentHeaders).toEqual(headers); + }); + }); + + t.describe('request', () => { + t.it('should use readonlyRPCMap rpc endpoint when infuraAPIKey is provided and readonlyRPCMap contains a chainId that also exists in the infura RPC constants', async () => { + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0x1234567890abcdef', + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await rpcClient.request({ ...baseOptions, scope: 'eip155:11155111' }); + + t.expect(result).toBe('0x1234567890abcdef'); + t.expect(mockFetch).toHaveBeenCalledWith('https://custom-sepolia.com', { + method: 'POST', + headers: defaultHeaders, + body: t.expect.stringContaining('"method":"eth_getBalance"'), + }); + }); + + t.it( + 'should use readonlyRPCMap rpc endpoint when infuraAPIKey is provided and readonlyRPCMap does not contain a chainId that also exists in the infura RPC constants', + async () => { + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0x1234567890abcdef', + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const clientModule = await import('./rpcClient'); + const rpcClient = new clientModule.RpcClient( + { + ...mockConfig, + api: { + ...mockConfig.api, + readonlyRPCMap: { + 'eip155:10000': 'https://custom-rpc.com', + }, + }, + }, + sdkInfo, + ); + + const result = await rpcClient.request({ ...baseOptions, scope: 'eip155:10000' }); + + t.expect(result).toBe('0x1234567890abcdef'); + t.expect(mockFetch).toHaveBeenCalledWith('https://custom-rpc.com', { + method: 'POST', + headers: defaultHeaders, + body: t.expect.stringContaining('"method":"eth_getBalance"'), + }); + }, + ); + + t.it('should throw RPCReadonlyResponseErr when response cannot be parsed as JSON', async () => { + const mockResponse = { + ok: true, + json: t.vi.fn().mockRejectedValue(new Error('Invalid JSON')), + }; + + mockFetch.mockResolvedValue(mockResponse); + + await t.expect(rpcClient.request(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyResponseErr); + await t.expect(rpcClient.request(baseOptions)).rejects.toThrow('Invalid JSON'); + }); + + t.it('should throw RPCHttpErr when fetch response is not ok', async () => { + const mockResponse = { + ok: false, + status: 500, + }; + + mockFetch.mockResolvedValue(mockResponse); + + await t.expect(rpcClient.request(baseOptions)).rejects.toBeInstanceOf(RPCHttpErr); + }); + + t.it('should throw RPCReadonlyRequestErr when fetch throws', async () => { + const fetchError = new Error('Network error'); + mockFetch.mockRejectedValue(fetchError); + + await t.expect(rpcClient.request(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyRequestErr); + await t.expect(rpcClient.request(baseOptions)).rejects.toThrow('Network error'); + }); + + t.it('should use only default headers when RPC endpoint does not include infura and custom readonly RPC is provided', async () => { + const configWithCustomRPC = { + api: { + readonlyRPCMap: { + 'eip155:1': 'https://custom-ethereum-node.com/rpc', + }, + }, + } as any; + const clientWithCustomRPC = new rpcClientModule(configWithCustomRPC, sdkInfo); + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0x123456account12345', + id: 1, + }; + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + baseOptions.request = { + method: 'eth_accounts', + params: undefined, + }; + + const result = await clientWithCustomRPC.request(baseOptions); + t.expect(result).toBe('0x123456account12345'); + t.expect(mockFetch).toHaveBeenCalledWith('https://custom-ethereum-node.com/rpc', { + method: 'POST', + headers: defaultHeaders, + body: t.expect.stringMatching(/^\{"jsonrpc":"2\.0","method":"eth_accounts","id":\d+\}$/), + }); + }); + + t.it('should throw MissingRpcEndpointErr when no RPC endpoint is available', async () => { + const options = { ...baseOptions, scope: 'eip155:999' as Scope }; + await t.expect(rpcClient.request(options)).rejects.toBeInstanceOf(MissingRpcEndpointErr); + }); + + // t.it('should redirect to provider when no RPC endpoint is available', async () => { + // const noRpcOptions: requestOptions = { + // scope: 'eip155:999' as Scope, // Unknown chain + // request: { + // method: 'eth_getBalance', + // params: { address: '0x123', blockNumber: 'latest' }, + // }, + // }; + // mockTransport.request.mockResolvedValue({ result: '0xbalance' }); + // const result = await rpcClient.request(noRpcOptions); + // t.expect(result).toBe('0xbalance'); + // t.expect(mockTransport.request).toHaveBeenCalledWith({ + // method: 'wallet_request', + // params: noRpcOptions, + // }); + // t.expect(mockFetch).not.toHaveBeenCalled(); + // }); + }); +}); diff --git a/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts new file mode 100644 index 000000000..2ae7b56e4 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts @@ -0,0 +1,110 @@ +import type { Json } from '@metamask/utils'; +import fetch from 'cross-fetch'; +import { + getInfuraRpcUrls, + type InvokeMethodOptions, + type MultichainOptions, + RPCHttpErr, + RPCReadonlyRequestErr, + RPCReadonlyResponseErr, + RPCResponse, + RpcUrlsMap, + Scope, +} from '../../../domain'; + +let rpcId = 1; + +export function getNextRpcId() { + rpcId += 1; + return rpcId; +} + +export class MissingRpcEndpointErr extends Error { }; + +export class RpcClient { + constructor( + private readonly config: MultichainOptions, + private readonly sdkInfo: string, + ) {} + + /** + * Routes the request to a configured RPC node. + */ + async request(options: InvokeMethodOptions): Promise { + const { request } = options; + const body = JSON.stringify({ + jsonrpc: '2.0', + method: request.method, + params: request.params, + id: getNextRpcId(), + }); + const rpcEndpoint = this.getRpcEndpoint(options.scope); + const rpcRequest = await this.fetch(rpcEndpoint, body, 'POST', this.getHeaders(rpcEndpoint)); + const response = await this.parseResponse(rpcRequest); + return response; + } + + private getRpcEndpoint(scope: Scope) { + let infuraAPIKey = this.config?.api?.infuraAPIKey; + + let readonlyRPCMap: RpcUrlsMap = this.config?.api?.readonlyRPCMap ?? {}; + if (infuraAPIKey) { + const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); + if (readonlyRPCMap) { + readonlyRPCMap = { + ...urlsWithToken, + ...readonlyRPCMap, + }; + } else { + readonlyRPCMap = urlsWithToken; + } + } + const rpcEndpoint = readonlyRPCMap[scope]; + if (!rpcEndpoint) { + throw new MissingRpcEndpointErr(`No RPC endpoint found for scope ${scope}`); + } + return rpcEndpoint; + } + + private async fetch(endpoint: string, body: string, method: string, headers: Record) { + try { + const response = await fetch(endpoint, { + method, + headers, + body, + }); + if (!response.ok) { + throw new RPCHttpErr(endpoint, method, response.status); + } + return response; + } catch (error) { + if (error instanceof RPCHttpErr) { + throw error; + } + throw new RPCReadonlyRequestErr(error.message); + } + } + + private async parseResponse(response: Response) { + try { + const rpcResponse = (await response.json()) as RPCResponse; + return rpcResponse.result as Json; + } catch (error) { + throw new RPCReadonlyResponseErr(error.message); + } + } + + private getHeaders(rpcEndpoint: string) { + const defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (rpcEndpoint.includes('infura')) { + return { + ...defaultHeaders, + 'Metamask-Sdk-Info': this.sdkInfo, + }; + } + return defaultHeaders; + } +} diff --git a/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts new file mode 100644 index 000000000..3349f0df1 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts @@ -0,0 +1,133 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import * as t from 'vitest'; +import { type InvokeMethodOptions, RPCInvokeMethodErr, type Scope } from '../../domain'; +import type { RequestRouter } from './requestRouter'; +import { MissingRpcEndpointErr } from './handlers/rpcClient'; + +t.describe('RequestRouter', () => { + let mockTransport: any; + let mockConfig: any; + let mockRpcClient: any; + let requestRouter: RequestRouter; + let baseOptions: any; + + t.beforeEach(async () => { + const requestRouterModule = await import('./requestRouter'); + baseOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_sendTransaction', + params: { to: '0x123', value: '0x100' }, + }, + }; + mockTransport = { + request: t.vi.fn(), + }; + mockRpcClient = { + request: t.vi.fn(), + }; + mockConfig = {}; + requestRouter = new requestRouterModule.RequestRouter(mockTransport, mockRpcClient, mockConfig); + // Reset mocks + mockTransport.request.mockClear(); + }); + + t.afterEach(async () => { + t.vi.clearAllMocks(); + t.vi.resetAllMocks(); + }); + + t.describe('invokeMethod', () => { + t.describe('when the request is a wallet request', () => { + t.it('should route to the wallet', async () => { + const signOptions: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'personal_sign', + params: { message: 'hello world' }, + }, + }; + mockTransport.request.mockResolvedValue({ result: '0xsignature' }); + const result = await requestRouter.invokeMethod(signOptions); + + t.expect(result).toBe('0xsignature'); + t.expect(mockTransport.request).toHaveBeenCalledWith({ + method: 'wallet_invokeMethod', + params: signOptions, + }); + }); + + t.it('should fallback to the wallet for unknown methods', async () => { + const unknownOptions: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'unknown_method', + params: [], + }, + }; + mockTransport.request.mockResolvedValue({ result: 'unknown_result' }); + const result = await requestRouter.invokeMethod(unknownOptions); + + t.expect(result).toBe('unknown_result'); + t.expect(mockTransport.request).toHaveBeenCalledWith({ + method: 'wallet_invokeMethod', + params: unknownOptions, + }); + }); + + t.it('should throw RPCInvokeMethodErr when transport request fails', async () => { + mockTransport.request.mockRejectedValue(new Error('Transport error')); + + await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); + await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toThrow('Transport error'); + }); + + t.it('should throw RPCInvokeMethodErr when response contains an error', async () => { + mockTransport.request.mockResolvedValue({ + error: { code: -32603, message: 'Internal error' }, + }); + + await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); + await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toThrow('RPC Request failed with code -32603: Internal error'); + }); + }); + }); + + t.describe('when the request is a rpc node request', () => { + t.it('should route to the rpc node', async () => { + const options: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_blockNumber', + params: [], + }, + }; + mockRpcClient.request.mockResolvedValue('0x123'); + const result = await requestRouter.invokeMethod(options); + + t.expect(result).toBe('0x123'); + t.expect(mockRpcClient.request).toHaveBeenCalledWith(options); + }); + + t.it('should re-route to the wallet if the rpc node request fails with a MissingRpcEndpointErr', async () => { + const options: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_blockNumber', + params: [], + }, + }; + mockTransport.request.mockResolvedValue({ result: '0x999' }); + mockRpcClient.request.mockRejectedValue(new MissingRpcEndpointErr('No RPC endpoint found for scope eip155:1')); + const result = await requestRouter.invokeMethod(options); + + t.expect(result).toBe('0x999'); + t.expect(mockRpcClient.request).toHaveBeenCalledWith(options); + t.expect(mockTransport.request).toHaveBeenCalledWith({ + method: 'wallet_invokeMethod', + params: options, + }); + }); + }); +}); diff --git a/packages/sdk-multichain/src/multichain/rpc/client.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts similarity index 73% rename from packages/sdk-multichain/src/multichain/rpc/client.ts rename to packages/sdk-multichain/src/multichain/rpc/requestRouter.ts index c4d696b65..e86610269 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.ts +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts @@ -1,18 +1,10 @@ import type { Json } from '@metamask/utils'; import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from '../../config'; -import { - type ExtendedTransport, - type InvokeMethodOptions, - isSecure, - type MultichainOptions, - RPCInvokeMethodErr, -} from '../../domain' +import { type ExtendedTransport, type InvokeMethodOptions, isSecure, type MultichainOptions, RPC_HANDLED_METHODS, RPCInvokeMethodErr, SDK_HANDLED_METHODS } from '../../domain'; -import { createLogger } from '../../domain/logger'; import { openDeeplink } from '../utils'; -import { getRequestHandlingStrategy, RequestHandlingStrategy } from './strategy'; +import { MissingRpcEndpointErr, RpcClient } from './handlers/rpcClient'; -const logger = createLogger('metamask-sdk:core'); let rpcId = 1; export function getNextRpcId() { @@ -20,11 +12,12 @@ export function getNextRpcId() { return rpcId; } -export class RPCClient { +export class RequestRouter { constructor( private readonly transport: ExtendedTransport, + private readonly rpcClient: RpcClient, private readonly config: MultichainOptions, - ) { } + ) {} /** * The main entry point for invoking an RPC method. @@ -32,18 +25,14 @@ export class RPCClient { * for the request and delegating to the appropriate private handler. */ async invokeMethod(options: InvokeMethodOptions): Promise { - const strategy = getRequestHandlingStrategy(options.request.method); - - switch (strategy) { - case RequestHandlingStrategy.WALLET: - return this.handleWithWallet(options); - - case RequestHandlingStrategy.RPC: - return this.handleWithRpcNode(options); - - case RequestHandlingStrategy.SDK: - return this.handleWithSdkState(options); + const method = options.request.method; + if (RPC_HANDLED_METHODS.has(method)) { + return this.handleWithRpcNode(options); } + if (SDK_HANDLED_METHODS.has(method)) { + return this.handleWithSdkState(options); + } + return this.handleWithWallet(options); } /** @@ -85,9 +74,14 @@ export class RPCClient { * Routes the request to a configured RPC node. */ private async handleWithRpcNode(options: InvokeMethodOptions): Promise { - // TODO: to be implemented - console.warn(`Method "${options.request.method}" is configured for RPC node handling, but this is not yet implemented. Falling back to wallet passthrough.`); - return this.handleWithWallet(options); + try { + return await this.rpcClient.request(options); + } catch (error) { + if (error instanceof MissingRpcEndpointErr) { + return this.handleWithWallet(options); + } + throw error; + } } /** diff --git a/packages/sdk-multichain/src/multichain/rpc/strategy.ts b/packages/sdk-multichain/src/multichain/rpc/strategy.ts deleted file mode 100644 index 283f76d62..000000000 --- a/packages/sdk-multichain/src/multichain/rpc/strategy.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Defines the strategy the SDK will use to handle an incoming RPC request. - * This provides a clear, high-level blueprint for the routing logic. - */ -export enum RequestHandlingStrategy { - /** - * STRATEGY 1: The request MUST be sent to the user's wallet. - * Use for requests requiring cryptographic signing, user consent, or access to private wallet state. - */ - WALLET, - - /** - * STRATEGY 2: The request can be fulfilled by a generic RPC node. - * The SDK will intercept the call and route it to a configured RPC endpoint, - * avoiding any interaction with the user's wallet. - */ - RPC, - - /** - * STRATEGY 3: The SDK already holds the required information in its session state. - * It will intercept the call and respond directly without any network request, - * providing an instantaneous response. - */ - SDK, -} - -const RPC_HANDLED_METHODS = new Set([ - 'eth_blockNumber', - 'eth_gasPrice', - 'eth_maxPriorityFeePerGas', - 'eth_blobBaseFee', - 'eth_feeHistory', - 'eth_getBalance', - 'eth_getCode', - 'eth_getStorageAt', - 'eth_call', - 'eth_estimateGas', - 'eth_getLogs', - 'eth_getProof', - 'eth_getTransactionCount', - 'eth_getBlockByNumber', - 'eth_getBlockByHash', - 'eth_getBlockTransactionCountByNumber', - 'eth_getBlockTransactionCountByHash', - 'eth_getUncleCountByBlockNumber', - 'eth_getUncleCountByBlockHash', - 'eth_getTransactionByHash', - 'eth_getTransactionByBlockNumberAndIndex', - 'eth_getTransactionByBlockHashAndIndex', - 'eth_getTransactionReceipt', - 'eth_getUncleByBlockNumberAndIndex', - 'eth_getUncleByBlockHashAndIndex', - 'eth_getFilterChanges', - 'eth_getFilterLogs', - 'eth_newBlockFilter', - 'eth_newFilter', - 'eth_newPendingTransactionFilter', - 'eth_sendRawTransaction', - 'eth_syncing', - 'eth_uninstallFilter', - 'web3_clientVersion', -]); - -const SDK_HANDLED_METHODS = new Set([ - 'eth_accounts', - 'eth_chainId', -]); - -/** - * Encapsulates the logic for determining the handling strategy for a given RPC method. - * Methods handled by the "RPC strategy" can be handled by a separately instantiated rpcClient rather - * than having to roundtrip back to the wallet - * Methods handled by the "SDK strategy" can be handled with wallet state that is cached in the SDK layer - * All other methods need to go to the Wallet since they cannot be handled otherwise. - * - * @param method - The name of the RPC method (e.g., 'eth_accounts'). - * @returns The appropriate RequestHandlingStrategy. - * @defaults {RequestHandlingStrategy.WALLET} for any unknown method. - */ -export function getRequestHandlingStrategy(method: string): RequestHandlingStrategy { - if (RPC_HANDLED_METHODS.has(method)) { - return RequestHandlingStrategy.RPC; - } - if (SDK_HANDLED_METHODS.has(method)) { - return RequestHandlingStrategy.SDK; - } - // Any unknown methods will default to the safest strategy. - return RequestHandlingStrategy.WALLET; -} diff --git a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts index 45ebbebdc..afd466883 100644 --- a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts @@ -170,7 +170,7 @@ export class MWPTransport implements ExtendedTransport { logger('active session found', activeSession); session = activeSession; } - } catch { } + } catch {} let timeout: NodeJS.Timeout; const connectionPromise = new Promise((resolve, reject) => { diff --git a/packages/sdk-multichain/src/multichain/utils/index.ts b/packages/sdk-multichain/src/multichain/utils/index.ts index 46dee7aa2..562ff8d29 100644 --- a/packages/sdk-multichain/src/multichain/utils/index.ts +++ b/packages/sdk-multichain/src/multichain/utils/index.ts @@ -16,7 +16,7 @@ function base64Encode(str: string): string { } else if (typeof Buffer !== 'undefined') { // Node.js return Buffer.from(str).toString('base64'); - } + } throw new Error('No base64 encoding method available'); } @@ -32,7 +32,6 @@ export function compressString(str: string): string { return base64Encode(binaryString); } - export function getDappId(dapp?: DappSettings) { if (typeof window === 'undefined' || typeof window.location === 'undefined') { return dapp?.name ?? dapp?.url ?? 'N/A'; @@ -176,29 +175,20 @@ export function setupDappMetadata(options: MultichainOptions): MultichainOptions * @param proposedCaipAccountIds - Proposed account IDs from the connect options * @returns true if scopes and accounts match, false otherwise */ -export function isSameScopesAndAccounts( - currentScopes: Scope[], - proposedScopes: Scope[], - walletSession: SessionData, - proposedCaipAccountIds: CaipAccountId[], -): boolean { - const isSameScopes = - currentScopes.every((scope) => proposedScopes.includes(scope)) && - proposedScopes.every((scope) => currentScopes.includes(scope)); +export function isSameScopesAndAccounts(currentScopes: Scope[], proposedScopes: Scope[], walletSession: SessionData, proposedCaipAccountIds: CaipAccountId[]): boolean { + const isSameScopes = currentScopes.every((scope) => proposedScopes.includes(scope)) && proposedScopes.every((scope) => currentScopes.includes(scope)); - if (!isSameScopes) { - return false; - } + if (!isSameScopes) { + return false; + } - const existingAccountIds: CaipAccountId[] = Object.values(walletSession.sessionScopes) - .filter(({ accounts }) => Boolean(accounts)) - .flatMap(({ accounts }) => accounts ?? []); + const existingAccountIds: CaipAccountId[] = Object.values(walletSession.sessionScopes) + .filter(({ accounts }) => Boolean(accounts)) + .flatMap(({ accounts }) => accounts ?? []); - const allProposedAccountsIncluded = proposedCaipAccountIds.every( - (proposedAccountId) => existingAccountIds.includes(proposedAccountId), - ); + const allProposedAccountsIncluded = proposedCaipAccountIds.every((proposedAccountId) => existingAccountIds.includes(proposedAccountId)); - return allProposedAccountsIncluded; + return allProposedAccountsIncluded; } export function getValidAccounts(caipAccountIds: CaipAccountId[]) {