diff --git a/packages/sdk-multichain/src/domain/multichain/api/constants.ts b/packages/sdk-multichain/src/domain/multichain/api/constants.ts index e3d1dfa5a..1b7c87e24 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/constants.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/constants.ts @@ -64,88 +64,3 @@ export const infuraRpcUrls: RPC_URLS_MAP = { 'eip155:44787': 'https://celo-alfajores.infura.io/v3/', }; -/** - * Standard RPC method names used in the MetaMask ecosystem. - * - * This constant provides a centralized registry of all supported - * RPC methods, making it easier to reference them consistently - * throughout the codebase. - */ -export const RPC_METHODS = { - /** Get the current provider state */ - METAMASK_GETPROVIDERSTATE: 'metamask_getProviderState', - /** Connect and sign in a single operation */ - METAMASK_CONNECTSIGN: 'metamask_connectSign', - /** Connect with specific parameters */ - METAMASK_CONNECTWITH: 'metamask_connectWith', - /** Open MetaMask interface */ - METAMASK_OPEN: 'metamask_open', - /** Execute multiple operations in a batch */ - METAMASK_BATCH: 'metamask_batch', - /** Sign a message with personal_sign */ - PERSONAL_SIGN: 'personal_sign', - /** Request wallet permissions */ - WALLET_REQUESTPERMISSIONS: 'wallet_requestPermissions', - /** Revoke wallet permissions */ - WALLET_REVOKEPERMISSIONS: 'wallet_revokePermissions', - /** Get current wallet permissions */ - WALLET_GETPERMISSIONS: 'wallet_getPermissions', - /** Watch/add a token to the wallet */ - WALLET_WATCHASSET: 'wallet_watchAsset', - /** Add a new Ethereum chain */ - WALLET_ADDETHEREUMCHAIN: 'wallet_addEthereumChain', - /** Switch to a different Ethereum chain */ - WALLET_SWITCHETHEREUMCHAIN: 'wallet_switchEthereumChain', - /** Request account access */ - ETH_REQUESTACCOUNTS: 'eth_requestAccounts', - /** Get available accounts */ - ETH_ACCOUNTS: 'eth_accounts', - /** Get current chain ID */ - ETH_CHAINID: 'eth_chainId', - /** Send a transaction */ - ETH_SENDTRANSACTION: 'eth_sendTransaction', - /** Sign typed data */ - ETH_SIGNTYPEDDATA: 'eth_signTypedData', - /** Sign typed data v3 */ - ETH_SIGNTYPEDDATA_V3: 'eth_signTypedData_v3', - /** Sign typed data v4 */ - ETH_SIGNTYPEDDATA_V4: 'eth_signTypedData_v4', - /** Sign a transaction */ - ETH_SIGNTRANSACTION: 'eth_signTransaction', - /** Sign arbitrary data */ - ETH_SIGN: 'eth_sign', - /** Recover address from signature */ - PERSONAL_EC_RECOVER: 'personal_ecRecover', -}; - -/** - * Configuration mapping for RPC methods that should be redirected to the wallet. - * - * This constant defines which RPC methods require user interaction through - * the wallet interface (true) versus those that can be handled locally (false). - * Methods marked as true will redirect to the wallet for user approval. - */ -export const METHODS_TO_REDIRECT: { [method: string]: boolean } = { - [RPC_METHODS.ETH_REQUESTACCOUNTS]: true, - [RPC_METHODS.ETH_SENDTRANSACTION]: true, - [RPC_METHODS.ETH_SIGNTRANSACTION]: true, - [RPC_METHODS.ETH_SIGN]: true, - [RPC_METHODS.PERSONAL_SIGN]: true, - // stop redirecting these as we are caching values in the provider - [RPC_METHODS.ETH_ACCOUNTS]: false, - [RPC_METHODS.ETH_CHAINID]: false, - // - [RPC_METHODS.ETH_SIGNTYPEDDATA]: true, - [RPC_METHODS.ETH_SIGNTYPEDDATA_V3]: true, - [RPC_METHODS.ETH_SIGNTYPEDDATA_V4]: true, - [RPC_METHODS.WALLET_REQUESTPERMISSIONS]: true, - [RPC_METHODS.WALLET_GETPERMISSIONS]: true, - [RPC_METHODS.WALLET_WATCHASSET]: true, - [RPC_METHODS.WALLET_ADDETHEREUMCHAIN]: true, - [RPC_METHODS.WALLET_SWITCHETHEREUMCHAIN]: true, - [RPC_METHODS.METAMASK_CONNECTSIGN]: true, - [RPC_METHODS.METAMASK_CONNECTWITH]: true, - [RPC_METHODS.PERSONAL_EC_RECOVER]: true, - [RPC_METHODS.METAMASK_BATCH]: true, - [RPC_METHODS.METAMASK_OPEN]: true, -}; diff --git a/packages/sdk-multichain/src/invoke.test.ts b/packages/sdk-multichain/src/invoke.test.ts index 58ec5ff50..848c1671a 100644 --- a/packages/sdk-multichain/src/invoke.test.ts +++ b/packages/sdk-multichain/src/invoke.test.ts @@ -167,7 +167,8 @@ function testSuite({ platform, createSDK, options: { timeout: 100000 }, ); - t.it(`${platform} should invoke readonly method successfully from client if infuraAPIKey exists`, async () => { + // TODO: Re-enable this test once we handle RPC node routing (INTERCEPT_AND_ROUTE_TO_RPC_NODE strategy) + t.it.skip(`${platform} should invoke readonly method successfully from client if infuraAPIKey exists`, async () => { const scopes = ['eip155:1'] as Scope[]; const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; diff --git a/packages/sdk-multichain/src/multichain/rpc/client.test.ts b/packages/sdk-multichain/src/multichain/rpc/client.test.ts index b08bc4196..84e274585 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.test.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.test.ts @@ -1,45 +1,17 @@ /** 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 { type InvokeMethodOptions, - type MultichainOptions, - RPC_METHODS, - RPCHttpErr, RPCInvokeMethodErr, - RPCReadonlyRequestErr, - RPCReadonlyResponseErr, type Scope, } from '../../domain'; import type { RPCClient } from './client'; -// Mock cross-fetch with proper implementation -vi.mock('cross-fetch', () => { - const mockFetch = vi.fn(); - return { - default: mockFetch, - __mockFetch: mockFetch, - }; -}); - -// Mock the entire client module -vi.mock('./client', async () => { - const actual = await vi.importActual('./client'); - return { - ...actual, - }; -}); - t.describe('RPCClient', () => { let mockTransport: any; 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 () => { @@ -47,8 +19,8 @@ t.describe('RPCClient', () => { baseOptions = { scope: 'eip155:1' as Scope, request: { - method: 'eth_getBalance', - params: { address: '0x123', blockNumber: 'latest' }, + method: 'eth_sendTransaction', + params: { to: '0x123', value: '0x100' }, }, }; mockTransport = { @@ -62,23 +34,9 @@ t.describe('RPCClient', () => { }, }, }; - sdkInfo = 'Sdk/Javascript SdkVersion/1.0.0 Platform/web'; - rpcClient = new clientModule.RPCClient(mockTransport, mockConfig, sdkInfo); - rpcClientModule = clientModule.RPCClient; - // Get mock fetch from the module mock - const fetchModule = await import('cross-fetch'); - mockFetch = (fetchModule as any).__mockFetch; + rpcClient = new clientModule.RPCClient(mockTransport, mockConfig); // Reset mocks mockTransport.request.mockClear(); - mockFetch.mockClear(); - defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - headers = { - ...defaultHeaders, - 'Metamask-Sdk-Info': sdkInfo, - }; }); t.afterEach(async () => { @@ -86,229 +44,112 @@ t.describe('RPCClient', () => { 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('invokeMethod', () => { - t.describe('redirect to provider cases', () => { - t.it('should not redirect to provider for readonly methods', async () => { - // Mock successful fetch response - 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.invokeMethod(baseOptions); - - t.expect(result).toBe('0x1234567890abcdef'); - t.expect(mockTransport.request).not.toHaveBeenCalled(); - t.expect(mockFetch).toHaveBeenCalledWith('https://mainnet.infura.io/v3/test-infura-key', { - method: 'POST', - headers: headers, - body: t.expect.stringContaining('"method":"eth_getBalance"'), - }); - t.expect(mockResponse.json).toHaveBeenCalled(); - }); - - 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.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyResponseErr); - await t.expect(rpcClient.invokeMethod(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.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCHttpErr); + 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 throw RPCReadonlyRequestErr when fetch throws', async () => { - const fetchError = new Error('Network error'); - mockFetch.mockRejectedValue(fetchError); - - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyRequestErr); - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('Network error'); + 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 use only Infura URLs when readonlyRPCMapConfig is not provided', async () => { - const configWithoutRPCMap = { - api: { - infuraAPIKey: 'test-infura-key', - // No readonlyRPCMap provided - }, - } as any; - const clientWithoutRPCMap = new rpcClientModule(mockTransport, configWithoutRPCMap, sdkInfo); - - const mockJsonResponse = { - jsonrpc: '2.0', - result: '0xabcdef123456', - id: 1, - }; - - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - - mockFetch.mockResolvedValue(mockResponse); - - const result = await clientWithoutRPCMap.invokeMethod(baseOptions); - - t.expect(result).toBe('0xabcdef123456'); - t.expect(mockTransport.request).not.toHaveBeenCalled(); - - // Should still use Infura URL since infuraAPIKey is provided - t.expect(mockFetch).toHaveBeenCalledWith('https://mainnet.infura.io/v3/test-infura-key', { - method: 'POST', - headers: headers, - body: t.expect.stringMatching('"method":"eth_getBalance"'), - }); + 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 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(mockTransport, 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.invokeMethod(baseOptions); - t.expect(result).toBe('0x123456account12345'); - t.expect(mockTransport.request).not.toHaveBeenCalled(); - 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 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 redirect to provider for methods in METHODS_TO_REDIRECT', async () => { - const redirectOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: RPC_METHODS.ETH_SENDTRANSACTION, - params: { to: '0x123', value: '0x100' }, - }, - }; - mockTransport.request.mockResolvedValue({ result: '0xhash' }); - const result = await rpcClient.invokeMethod(redirectOptions); - t.expect(result).toBe('0xhash'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: redirectOptions, - }); - t.expect(mockFetch).not.toHaveBeenCalled(); + 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 redirect to provider for personal_sign method', async () => { - const signOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: RPC_METHODS.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.expect(mockFetch).not.toHaveBeenCalled(); - }); + t.it('should throw RPCInvokeMethodErr when transport request fails', async () => { + mockTransport.request.mockRejectedValue(new Error('Transport error')); - t.it('should redirect to provider when no RPC endpoint is available', async () => { - const noRpcOptions: InvokeMethodOptions = { - 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.invokeMethod(noRpcOptions); - t.expect(result).toBe('0xbalance'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: noRpcOptions, - }); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('Transport error'); + }); - t.it('should redirect to provider when no infura API key and no custom RPC map', async () => { - const noConfigClient = new rpcClientModule(mockTransport, {} as any, sdkInfo); - mockTransport.request.mockResolvedValue({ result: '0xfallback' }); - const result = await noConfigClient.invokeMethod(baseOptions); - t.expect(result).toBe('0xfallback'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: baseOptions, - }); - t.expect(mockFetch).not.toHaveBeenCalled(); + t.it('should throw RPCInvokeMethodErr when response contains an error', async () => { + mockTransport.request.mockResolvedValue({ + error: { code: -32603, message: 'Internal error' } }); - t.it('should throw RPCInvokeMethodErr when provider throws', async () => { - const redirectOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: RPC_METHODS.ETH_SENDTRANSACTION, - params: { to: '0x123', value: '0x100' }, - }, - }; - mockTransport.request.mockRejectedValue(new Error('Provider error')); - await t.expect(rpcClient.invokeMethod(redirectOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); + 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/client.ts b/packages/sdk-multichain/src/multichain/rpc/client.ts index 7e42f8cad..c4d696b65 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.ts @@ -1,26 +1,18 @@ -import type { InvokeMethodParams } from '@metamask/multichain-api-client'; import type { Json } from '@metamask/utils'; -import fetch from 'cross-fetch'; - +import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from '../../config'; import { type ExtendedTransport, - getInfuraRpcUrls, type InvokeMethodOptions, isSecure, - METHODS_TO_REDIRECT, type MultichainOptions, - type RPC_URLS_MAP, - type RPCAPI, - RPCHttpErr, RPCInvokeMethodErr, - RPCReadonlyRequestErr, - RPCReadonlyResponseErr, - type RPCResponse, - type Scope, -} from '../../domain'; -import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from 'src/config'; +} from '../../domain' + +import { createLogger } from '../../domain/logger'; import { openDeeplink } from '../utils'; +import { getRequestHandlingStrategy, RequestHandlingStrategy } from './strategy'; +const logger = createLogger('metamask-sdk:core'); let rpcId = 1; export function getNextRpcId() { @@ -32,96 +24,40 @@ export class RPCClient { constructor( private readonly transport: ExtendedTransport, private readonly config: MultichainOptions, - private readonly sdkInfo: string, - ) {} + ) { } - 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); - } - } + /** + * The main entry point for invoking an RPC method. + * This method acts as a router, determining the correct handling strategy + * for the request and delegating to the appropriate private handler. + */ + async invokeMethod(options: InvokeMethodOptions): Promise { + const strategy = getRequestHandlingStrategy(options.request.method); - 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); - } - } + switch (strategy) { + case RequestHandlingStrategy.WALLET: + return this.handleWithWallet(options); - 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; - } + case RequestHandlingStrategy.RPC: + return this.handleWithRpcNode(options); - private async runReadOnlyMethod(options: InvokeMethodOptions, rpcEndpoint: string) { - const { request } = options; - const body = JSON.stringify({ - jsonrpc: '2.0', - method: request.method, - params: request.params, - id: getNextRpcId(), - }); - const rpcRequest = await this.fetch(rpcEndpoint, body, 'POST', this.getHeaders(rpcEndpoint)); - const response = await this.parseResponse(rpcRequest); - return response; + case RequestHandlingStrategy.SDK: + return this.handleWithSdkState(options); + } } - async invokeMethod(options: InvokeMethodOptions) { - const { config } = this; - const { request } = options; - const { api, ui, mobile } = config; - - const { preferDesktop = false, headless: _headless = false } = ui ?? {}; - const { infuraAPIKey, readonlyRPCMap: readonlyRPCMapConfig } = api ?? {}; - - let readonlyRPCMap: RPC_URLS_MAP = readonlyRPCMapConfig ?? {}; - if (infuraAPIKey) { - const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); - if (readonlyRPCMapConfig) { - readonlyRPCMap = { - ...readonlyRPCMapConfig, - ...urlsWithToken, - }; - } else { - readonlyRPCMap = urlsWithToken; - } - } - const rpcEndpoint = readonlyRPCMap[options.scope]; - const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; - if (rpcEndpoint && isReadOnlyMethod) { - const response = await this.runReadOnlyMethod(options, rpcEndpoint); - return response; - } + /** + * Forwards the request directly to the wallet via the transport. + */ + private async handleWithWallet(options: InvokeMethodOptions): Promise { try { const request = this.transport.request({ method: 'wallet_invokeMethod', params: options, }); + const { ui, mobile } = this.config; + const { preferDesktop = false } = ui ?? {}; const secure = isSecure(); const shouldOpenDeeplink = secure && !preferDesktop; @@ -132,16 +68,34 @@ export class RPCClient { } else { openDeeplink(this.config, METAMASK_DEEPLINK_BASE, METAMASK_CONNECT_BASE_URL); } - }, 250); + }, 10); // small delay to ensure the message encryption and dispatch completes } const response = await request; if (response.error) { throw new RPCInvokeMethodErr(`RPC Request failed with code ${response.error.code}: ${response.error.message}`); } - return response.result; + return response.result as Json; } catch (error) { throw new RPCInvokeMethodErr(error.message); } } + + /** + * 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); + } + + /** + * Responds directly from the SDK's session state. + */ + private async handleWithSdkState(options: InvokeMethodOptions): Promise { + // TODO: to be implemented + console.warn(`Method "${options.request.method}" is configured for SDK state handling, but this is not yet implemented. Falling back to wallet passthrough.`); + return this.handleWithWallet(options); + } } diff --git a/packages/sdk-multichain/src/multichain/rpc/strategy.ts b/packages/sdk-multichain/src/multichain/rpc/strategy.ts new file mode 100644 index 000000000..283f76d62 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/strategy.ts @@ -0,0 +1,89 @@ +/** + * 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; +}