From c90c4d1c2b87fbd090f88d037c21453da347b292 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Tue, 14 Oct 2025 13:38:35 -0700 Subject: [PATCH 1/8] Rename RpcUrlsMap. Invert METHODS_TO_REDIRECT. Do not overwrite readonlyRPCMapConfig --- .../src/domain/multichain/api/constants.ts | 77 +++++++++------ .../src/domain/multichain/api/infura.ts | 4 +- .../src/domain/multichain/api/types.ts | 5 +- .../src/domain/multichain/types.ts | 6 +- .../sdk-multichain/src/multichain/index.ts | 4 +- .../src/multichain/rpc/client.test.ts | 90 ++++++++++++++---- .../src/multichain/rpc/client.ts | 15 +-- .../src/multichain/transports/mwp/index.ts | 6 +- .../src/multichain/utils/index.test.ts | 93 +++---------------- .../src/multichain/utils/index.ts | 32 +++---- packages/sdk/src/index.ts | 4 +- packages/sdk/src/sdk.ts | 4 +- 12 files changed, 168 insertions(+), 172 deletions(-) diff --git a/packages/sdk-multichain/src/domain/multichain/api/constants.ts b/packages/sdk-multichain/src/domain/multichain/api/constants.ts index e3d1dfa5a..4013c88dd 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/', @@ -119,33 +119,50 @@ export const RPC_METHODS = { }; /** - * Configuration mapping for RPC methods that should be redirected to the wallet. + * Configuration mapping for EVM RPC methods that should be passed through to the provider. * - * 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. + * This constant defines which EVM RPC methods do not require user interaction through + * the wallet interface. Methods listed here will not 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, -}; + +export const EVM_RPC_PASSTHROUGH_METHODS = [ + 'eth_chainId', // cached + '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_accounts', // cached + 'eth_coinbase', // cached, but shouldn't be callable via multichain-api + 'eth_getFilterChanges', + 'eth_getFilterLogs', + 'eth_newBlockFilter', + 'eth_newFilter', + 'eth_newPendingTransactionFilter', + 'eth_sendRawTransaction', + 'eth_subscribe', + 'eth_syncing', + 'eth_uninstallFilter', + 'eth_unsubscribe', + 'web3_clientVersion', +]; 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/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 8cb491d81..9ebf7c688 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -454,11 +454,11 @@ 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); + const client = new RPCClient(transport, options, sdkInfo); return client.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 index b08bc4196..b3f2bf2fa 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.test.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.test.ts @@ -58,7 +58,7 @@ t.describe('RPCClient', () => { api: { infuraAPIKey: 'test-infura-key', readonlyRPCMap: { - 'eip155:1': 'https://custom-mainnet.com', + 'eip155:11155111': 'https://custom-mainnet.com', }, }, }; @@ -102,8 +102,76 @@ t.describe('RPCClient', () => { }); t.describe('invokeMethod', () => { + 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.invokeMethod({ ...baseOptions, scope: 'eip155:11155111' }); + + t.expect(result).toBe('0x1234567890abcdef'); + t.expect(mockTransport.request).not.toHaveBeenCalled(); + t.expect(mockFetch).toHaveBeenCalledWith('https://custom-mainnet.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('./client'); + const rpcClient = new clientModule.RPCClient( + mockTransport, + { + ...mockConfig, + api: { + ...mockConfig.api, + readonlyRPCMap: { + 'eip155:10000': 'https://custom-rpc.com', + }, + }, + }, + sdkInfo, + ); + + const result = await rpcClient.invokeMethod({ ...baseOptions, scope: 'eip155:10000' }); + + t.expect(result).toBe('0x1234567890abcdef'); + t.expect(mockTransport.request).not.toHaveBeenCalled(); + t.expect(mockFetch).toHaveBeenCalledWith('https://custom-rpc.com', { + method: 'POST', + headers: defaultHeaders, + body: t.expect.stringContaining('"method":"eth_getBalance"'), + }); + }, + ); + t.describe('redirect to provider cases', () => { - t.it('should not redirect to provider for readonly methods', async () => { + t.it.only('should not redirect to provider for passthrough methods', async () => { // Mock successful fetch response const mockJsonResponse = { jsonrpc: '2.0', @@ -231,24 +299,6 @@ t.describe('RPCClient', () => { }); }); - 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 redirect to provider for personal_sign method', async () => { const signOptions: InvokeMethodOptions = { scope: 'eip155:1' as Scope, diff --git a/packages/sdk-multichain/src/multichain/rpc/client.ts b/packages/sdk-multichain/src/multichain/rpc/client.ts index 7e42f8cad..ef43bd352 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.ts @@ -9,7 +9,7 @@ import { isSecure, METHODS_TO_REDIRECT, type MultichainOptions, - type RPC_URLS_MAP, + type RpcUrlsMap, type RPCAPI, RPCHttpErr, RPCInvokeMethodErr, @@ -17,6 +17,7 @@ import { RPCReadonlyResponseErr, type RPCResponse, type Scope, + EVM_RPC_PASSTHROUGH_METHODS, } from '../../domain'; import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from 'src/config'; import { openDeeplink } from '../utils'; @@ -77,7 +78,7 @@ export class RPCClient { return defaultHeaders; } - private async runReadOnlyMethod(options: InvokeMethodOptions, rpcEndpoint: string) { + private async requestFromRpcEndpoint(options: InvokeMethodOptions, rpcEndpoint: string) { const { request } = options; const body = JSON.stringify({ jsonrpc: '2.0', @@ -98,22 +99,22 @@ export class RPCClient { const { preferDesktop = false, headless: _headless = false } = ui ?? {}; const { infuraAPIKey, readonlyRPCMap: readonlyRPCMapConfig } = api ?? {}; - let readonlyRPCMap: RPC_URLS_MAP = readonlyRPCMapConfig ?? {}; + let readonlyRPCMap: RpcUrlsMap = readonlyRPCMapConfig ?? {}; if (infuraAPIKey) { const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); if (readonlyRPCMapConfig) { readonlyRPCMap = { - ...readonlyRPCMapConfig, ...urlsWithToken, + ...readonlyRPCMapConfig, }; } 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); + const isPassthroughMethod = EVM_RPC_PASSTHROUGH_METHODS.find((method) => method === request.method); + if (rpcEndpoint && isPassthroughMethod) { + const response = await this.requestFromRpcEndpoint(options, rpcEndpoint); return response; } try { diff --git a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts index 92c491cc8..0e0b9c96a 100644 --- a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts @@ -121,9 +121,9 @@ export class MWPTransport implements ExtendedTransport { if (walletSession && options) { const currentScopes = Object.keys(walletSession?.sessionScopes ?? {}) as Scope[]; const proposedScopes = options?.scopes ?? []; - const proposedCaipAccountIds = options?.caipAccountIds ?? []; - const hasSameScopesAndAccounts = isSameScopesAndAccounts(currentScopes, proposedScopes, walletSession, proposedCaipAccountIds); - if (!hasSameScopesAndAccounts) { + const proposedCaipAccountIds = options?.caipAccountIds ?? []; + const hasSameScopesAndAccounts = isSameScopesAndAccounts(currentScopes, proposedScopes, walletSession, proposedCaipAccountIds); + if (!hasSameScopesAndAccounts) { const optionalScopes = addValidAccounts(getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? [])); const sessionRequest: CreateSessionParams = { optionalScopes }; const response = await this.request({ method: 'wallet_createSession', params: sessionRequest }); diff --git a/packages/sdk-multichain/src/multichain/utils/index.test.ts b/packages/sdk-multichain/src/multichain/utils/index.test.ts index b206e6bb1..2d43f0d77 100644 --- a/packages/sdk-multichain/src/multichain/utils/index.test.ts +++ b/packages/sdk-multichain/src/multichain/utils/index.test.ts @@ -299,17 +299,9 @@ t.describe('Utils', () => { t.it('should return true when scopes and accounts match exactly', () => { const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedCaipAccountIds = [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ] as CaipAccountId[]; + const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890', 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - mockWalletSession, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); t.expect(result).toBe(true); }); @@ -317,16 +309,9 @@ t.describe('Utils', () => { t.it('should return false when scopes do not match', () => { const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; const proposedScopes: Scope[] = ['eip155:1', 'eip155:56']; // Different scope - const proposedCaipAccountIds = [ - 'eip155:1:0x1234567890123456789012345678901234567890', - ] as CaipAccountId[]; + const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - mockWalletSession, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); t.expect(result).toBe(false); }); @@ -339,12 +324,7 @@ t.describe('Utils', () => { 'eip155:1:0x9999999999999999999999999999999999999999', // Not in session ] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - mockWalletSession, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); t.expect(result).toBe(false); }); @@ -356,12 +336,7 @@ t.describe('Utils', () => { 'eip155:1:0x1234567890123456789012345678901234567890', // Only one account ] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - mockWalletSession, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); t.expect(result).toBe(true); }); @@ -371,12 +346,7 @@ t.describe('Utils', () => { const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; const proposedCaipAccountIds: CaipAccountId[] = []; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - mockWalletSession, - proposedCaipAccountIds, - ) + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); t.expect(result).toBe(true); }); @@ -387,12 +357,7 @@ t.describe('Utils', () => { const proposedScopes: Scope[] = []; const proposedCaipAccountIds: CaipAccountId[] = []; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - emptySession, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, emptySession, proposedCaipAccountIds); t.expect(result).toBe(true); }); @@ -412,12 +377,7 @@ t.describe('Utils', () => { const proposedScopes: Scope[] = ['eip155:1']; const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - sessionWithoutAccounts, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, sessionWithoutAccounts, proposedCaipAccountIds); t.expect(result).toBe(false); }); @@ -437,12 +397,7 @@ t.describe('Utils', () => { const proposedScopes: Scope[] = ['eip155:1']; const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - sessionWithEmptyAccounts, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, sessionWithEmptyAccounts, proposedCaipAccountIds); t.expect(result).toBe(false); }); @@ -450,17 +405,9 @@ t.describe('Utils', () => { t.it('should return true when scopes have different order but same content', () => { const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; const proposedScopes: Scope[] = ['eip155:137', 'eip155:1']; // Different order - const proposedCaipAccountIds = [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ] as CaipAccountId[]; + const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890', 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - mockWalletSession, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); t.expect(result).toBe(true); }); @@ -471,26 +418,16 @@ t.describe('Utils', () => { 'eip155:1': { methods: ['eth_sendTransaction'], notifications: ['chainChanged'], - accounts: [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:1:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890', 'eip155:1:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], }, }, } as any; const currentScopes: Scope[] = ['eip155:1']; const proposedScopes: Scope[] = ['eip155:1']; - const proposedCaipAccountIds = [ - 'eip155:1:0x1234567890123456789012345678901234567890', - ] as CaipAccountId[]; + const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - const result = utils.isSameScopesAndAccounts( - currentScopes, - proposedScopes, - sessionWithMultipleAccounts, - proposedCaipAccountIds, - ); + const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, sessionWithMultipleAccounts, proposedCaipAccountIds); t.expect(result).toBe(true); }); 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[]) { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5dde9a19f..0e7d7f89d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,7 +11,7 @@ import { } from '@metamask/sdk-communication-layer'; import { SDKProvider } from './provider/SDKProvider'; import { MetaMaskSDK, MetaMaskSDKOptions } from './sdk'; -import { RPC_URLS_MAP } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; +import { RpcUrlsMap } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; import { PROVIDER_UPDATE_TYPE } from './types/ProviderUpdateType'; import type { SDKLoggingOptions } from './types/SDKLoggingOptions'; @@ -26,7 +26,7 @@ export { StorageManagerAS } from './storage-manager/StorageManagerAS'; /*#endif */ export type { - RPC_URLS_MAP, + RpcUrlsMap, MetaMaskSDKOptions, SDKLoggingOptions, ServiceStatus, diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 1c26402ac..1883048dd 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -22,7 +22,7 @@ import { import { connectAndSign } from './services/MetaMaskSDK/ConnectionManager/connectAndSign'; import { connectWith } from './services/MetaMaskSDK/ConnectionManager/connectWith'; import { initializeMetaMaskSDK } from './services/MetaMaskSDK/InitializerManager'; -import { RPC_URLS_MAP } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; +import { RpcUrlsMap } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; import { RemoteConnection, RemoteConnectionProps, @@ -44,7 +44,7 @@ export interface MetaMaskSDKOptions { /** * A map of RPC URLs to use for read-only requests. */ - readonlyRPCMap?: RPC_URLS_MAP; + readonlyRPCMap?: RpcUrlsMap; /** * If true, the SDK will inject the provider into the global `window` object. From 35dc5da6d808c04c3d917c925fa2aae833dc6ea2 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Tue, 14 Oct 2025 13:41:34 -0700 Subject: [PATCH 2/8] lint --- packages/sdk-multichain/src/multichain/index.ts | 2 +- packages/sdk-multichain/src/multichain/rpc/client.test.ts | 3 +-- packages/sdk-multichain/src/multichain/rpc/client.ts | 4 ---- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 9ebf7c688..103fdd886 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -5,7 +5,7 @@ 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'; diff --git a/packages/sdk-multichain/src/multichain/rpc/client.test.ts b/packages/sdk-multichain/src/multichain/rpc/client.test.ts index b3f2bf2fa..db0cf35dc 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.test.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.test.ts @@ -4,7 +4,6 @@ import * as t from 'vitest'; import { vi } from 'vitest'; import { type InvokeMethodOptions, - type MultichainOptions, RPC_METHODS, RPCHttpErr, RPCInvokeMethodErr, @@ -171,7 +170,7 @@ t.describe('RPCClient', () => { ); t.describe('redirect to provider cases', () => { - t.it.only('should not redirect to provider for passthrough methods', async () => { + t.it('should not redirect to provider for passthrough methods', async () => { // Mock successful fetch response const mockJsonResponse = { jsonrpc: '2.0', diff --git a/packages/sdk-multichain/src/multichain/rpc/client.ts b/packages/sdk-multichain/src/multichain/rpc/client.ts index ef43bd352..2eea29b88 100644 --- a/packages/sdk-multichain/src/multichain/rpc/client.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.ts @@ -1,4 +1,3 @@ -import type { InvokeMethodParams } from '@metamask/multichain-api-client'; import type { Json } from '@metamask/utils'; import fetch from 'cross-fetch'; @@ -7,16 +6,13 @@ import { getInfuraRpcUrls, type InvokeMethodOptions, isSecure, - METHODS_TO_REDIRECT, type MultichainOptions, type RpcUrlsMap, - type RPCAPI, RPCHttpErr, RPCInvokeMethodErr, RPCReadonlyRequestErr, RPCReadonlyResponseErr, type RPCResponse, - type Scope, EVM_RPC_PASSTHROUGH_METHODS, } from '../../domain'; import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from 'src/config'; From b6be8c0591f2b6002fb3d7c388a1d744799d8df6 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Tue, 14 Oct 2025 14:34:01 -0700 Subject: [PATCH 3/8] restore sdk v1 changes --- packages/sdk/src/index.ts | 4 ++-- packages/sdk/src/sdk.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0e7d7f89d..5dde9a19f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,7 +11,7 @@ import { } from '@metamask/sdk-communication-layer'; import { SDKProvider } from './provider/SDKProvider'; import { MetaMaskSDK, MetaMaskSDKOptions } from './sdk'; -import { RpcUrlsMap } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; +import { RPC_URLS_MAP } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; import { PROVIDER_UPDATE_TYPE } from './types/ProviderUpdateType'; import type { SDKLoggingOptions } from './types/SDKLoggingOptions'; @@ -26,7 +26,7 @@ export { StorageManagerAS } from './storage-manager/StorageManagerAS'; /*#endif */ export type { - RpcUrlsMap, + RPC_URLS_MAP, MetaMaskSDKOptions, SDKLoggingOptions, ServiceStatus, diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 1883048dd..1c26402ac 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -22,7 +22,7 @@ import { import { connectAndSign } from './services/MetaMaskSDK/ConnectionManager/connectAndSign'; import { connectWith } from './services/MetaMaskSDK/ConnectionManager/connectWith'; import { initializeMetaMaskSDK } from './services/MetaMaskSDK/InitializerManager'; -import { RpcUrlsMap } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; +import { RPC_URLS_MAP } from './services/MetaMaskSDK/InitializerManager/setupReadOnlyRPCProviders'; import { RemoteConnection, RemoteConnectionProps, @@ -44,7 +44,7 @@ export interface MetaMaskSDKOptions { /** * A map of RPC URLs to use for read-only requests. */ - readonlyRPCMap?: RpcUrlsMap; + readonlyRPCMap?: RPC_URLS_MAP; /** * If true, the SDK will inject the provider into the global `window` object. From 7c813107339f8fa0f484d0001904467cb9f41365 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 16 Oct 2025 14:12:18 -0700 Subject: [PATCH 4/8] add RequestRouter and RpcClient --- packages/sdk-multichain/src/invoke.test.ts | 8 +- .../sdk-multichain/src/multichain/index.ts | 8 +- .../src/multichain/rpc/handlers/rpcClient.ts | 107 ++++++++++++++++ .../src/multichain/rpc/requestRouter.test.ts | 117 ++++++++++++++++++ .../src/multichain/rpc/requestRouter.ts | 100 +++++++++++++++ 5 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts create mode 100644 packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts create mode 100644 packages/sdk-multichain/src/multichain/rpc/requestRouter.ts 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 d8def7cf6..d677f44fb 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -11,12 +11,13 @@ import { type InvokeMethodOptions, type MultichainOptions, type RPCAPI, type Sco 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'); @@ -460,7 +461,8 @@ export class MultichainSDK extends MultichainCore { this.__provider ??= getMultichainClient({ transport }); - const client = new RPCClient(transport, 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/handlers/rpcClient.ts b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts new file mode 100644 index 000000000..115edabc6 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts @@ -0,0 +1,107 @@ +import type { Json } from '@metamask/utils'; +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 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 RPCReadonlyRequestErr(`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..9220ca468 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts @@ -0,0 +1,117 @@ +/** 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'; + +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); + }); + }); +}); diff --git a/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts new file mode 100644 index 000000000..39fde1631 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts @@ -0,0 +1,100 @@ +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 { openDeeplink } from '../utils'; +import { getRequestHandlingStrategy, RequestHandlingStrategy } from './strategy'; +import { RpcClient } from './handlers/rpcClient'; + +let rpcId = 1; + +export function getNextRpcId() { + rpcId += 1; + return rpcId; +} + +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. + * 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); + + switch (strategy) { + case RequestHandlingStrategy.WALLET: + return this.handleWithWallet(options); + + case RequestHandlingStrategy.RPC: + return this.handleWithRpcNode(options); + + case RequestHandlingStrategy.SDK: + return this.handleWithSdkState(options); + } + } + + /** + * 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; + + if (shouldOpenDeeplink) { + setTimeout(() => { + if (mobile?.preferredOpenLink) { + mobile.preferredOpenLink(METAMASK_DEEPLINK_BASE, '_self'); + } else { + openDeeplink(this.config, METAMASK_DEEPLINK_BASE, METAMASK_CONNECT_BASE_URL); + } + }, 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 as Json; + } catch (error) { + throw new RPCInvokeMethodErr(error.message); + } + } + + /** + * Routes the request to a configured RPC node. + */ + private async handleWithRpcNode(options: InvokeMethodOptions): Promise { + return this.rpcClient.request(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); + } +} From dfe5e5d28cec97eafe956a420b361ee011d6b262 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 16 Oct 2025 15:04:11 -0700 Subject: [PATCH 5/8] combine strategy into RequestRouter --- .../src/domain/multichain/api/constants.ts | 39 ++++++++ .../src/multichain/rpc/requestRouter.ts | 26 ++---- .../src/multichain/rpc/strategy.ts | 89 ------------------- 3 files changed, 47 insertions(+), 107 deletions(-) delete mode 100644 packages/sdk-multichain/src/multichain/rpc/strategy.ts diff --git a/packages/sdk-multichain/src/domain/multichain/api/constants.ts b/packages/sdk-multichain/src/domain/multichain/api/constants.ts index 0ac1059bf..ce631f547 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/constants.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/constants.ts @@ -63,3 +63,42 @@ export const infuraRpcUrls: RpcUrlsMap = { // Alfajores Testnet '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/multichain/rpc/requestRouter.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts index 39fde1631..53380b59b 100644 --- a/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts @@ -1,12 +1,6 @@ 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 { openDeeplink } from '../utils'; import { getRequestHandlingStrategy, RequestHandlingStrategy } from './strategy'; @@ -33,18 +27,14 @@ export class RequestRouter { * 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); } /** 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; -} From aa1acd571d68987bea5509c03a436e364fff151f Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 16 Oct 2025 15:04:19 -0700 Subject: [PATCH 6/8] lint --- packages/sdk-multichain/src/domain/platform/index.ts | 7 ++----- .../src/multichain/rpc/handlers/rpcClient.ts | 4 ++-- .../src/multichain/rpc/requestRouter.test.ts | 9 ++------- .../sdk-multichain/src/multichain/rpc/requestRouter.ts | 4 +--- .../src/multichain/transports/mwp/index.ts | 2 +- 5 files changed, 8 insertions(+), 18 deletions(-) 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/multichain/rpc/handlers/rpcClient.ts b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts index 115edabc6..b277ccc96 100644 --- a/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts +++ b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts @@ -9,7 +9,7 @@ import { RPCResponse, RpcUrlsMap, Scope, -} from '../../../domain' +} from '../../../domain'; let rpcId = 1; @@ -22,7 +22,7 @@ export class RpcClient { constructor( private readonly config: MultichainOptions, private readonly sdkInfo: string, - ) { } + ) {} /** * Routes the request to a configured RPC node. diff --git a/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts index 9220ca468..7192573aa 100644 --- a/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts @@ -1,11 +1,7 @@ /** 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 InvokeMethodOptions, RPCInvokeMethodErr, type Scope } from '../../domain'; import type { RequestRouter } from './requestRouter'; t.describe('RequestRouter', () => { @@ -41,7 +37,6 @@ t.describe('RequestRouter', () => { t.vi.resetAllMocks(); }); - t.describe('invokeMethod', () => { t.describe('when the request is a wallet request', () => { t.it('should route to the wallet', async () => { @@ -89,7 +84,7 @@ t.describe('RequestRouter', () => { t.it('should throw RPCInvokeMethodErr when response contains an error', async () => { mockTransport.request.mockResolvedValue({ - error: { code: -32603, message: 'Internal error' } + error: { code: -32603, message: 'Internal error' }, }); await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); diff --git a/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts index 53380b59b..1083da9cd 100644 --- a/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts @@ -3,7 +3,6 @@ import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from '../../config' import { type ExtendedTransport, type InvokeMethodOptions, isSecure, type MultichainOptions, RPC_HANDLED_METHODS, RPCInvokeMethodErr, SDK_HANDLED_METHODS } from '../../domain'; import { openDeeplink } from '../utils'; -import { getRequestHandlingStrategy, RequestHandlingStrategy } from './strategy'; import { RpcClient } from './handlers/rpcClient'; let rpcId = 1; @@ -18,8 +17,7 @@ export class RequestRouter { private readonly transport: ExtendedTransport, private readonly rpcClient: RpcClient, private readonly config: MultichainOptions, - ) { - } + ) {} /** * The main entry point for invoking an RPC method. 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) => { From b0e56cabcfe488ad4b2a8f94a2bc2da9a6643d53 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 16 Oct 2025 15:58:48 -0700 Subject: [PATCH 7/8] Fix cross-fetch mock? Add missing endpoint test --- .../multichain/rpc/handlers/rpcClient.test.ts | 237 ++++++++++++++++++ .../src/multichain/rpc/handlers/rpcClient.ts | 5 +- 2 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.test.ts 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 index b277ccc96..2ae7b56e4 100644 --- a/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts +++ b/packages/sdk-multichain/src/multichain/rpc/handlers/rpcClient.ts @@ -1,4 +1,5 @@ import type { Json } from '@metamask/utils'; +import fetch from 'cross-fetch'; import { getInfuraRpcUrls, type InvokeMethodOptions, @@ -18,6 +19,8 @@ export function getNextRpcId() { return rpcId; } +export class MissingRpcEndpointErr extends Error { }; + export class RpcClient { constructor( private readonly config: MultichainOptions, @@ -58,7 +61,7 @@ export class RpcClient { } const rpcEndpoint = readonlyRPCMap[scope]; if (!rpcEndpoint) { - throw new RPCReadonlyRequestErr(`No RPC endpoint found for scope ${scope}`); + throw new MissingRpcEndpointErr(`No RPC endpoint found for scope ${scope}`); } return rpcEndpoint; } From 9fdc2bb9c2ff33c38b51fb9c54012806d76c7da8 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 16 Oct 2025 15:59:09 -0700 Subject: [PATCH 8/8] Reroute to wallet if rpc missing endpoint --- .../src/multichain/rpc/requestRouter.test.ts | 21 +++++++++++++++++++ .../src/multichain/rpc/requestRouter.ts | 11 ++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts index 7192573aa..3349f0df1 100644 --- a/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.test.ts @@ -3,6 +3,7 @@ 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; @@ -108,5 +109,25 @@ t.describe('RequestRouter', () => { 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/requestRouter.ts b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts index 1083da9cd..e86610269 100644 --- a/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts +++ b/packages/sdk-multichain/src/multichain/rpc/requestRouter.ts @@ -3,7 +3,7 @@ import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from '../../config' import { type ExtendedTransport, type InvokeMethodOptions, isSecure, type MultichainOptions, RPC_HANDLED_METHODS, RPCInvokeMethodErr, SDK_HANDLED_METHODS } from '../../domain'; import { openDeeplink } from '../utils'; -import { RpcClient } from './handlers/rpcClient'; +import { MissingRpcEndpointErr, RpcClient } from './handlers/rpcClient'; let rpcId = 1; @@ -74,7 +74,14 @@ export class RequestRouter { * Routes the request to a configured RPC node. */ private async handleWithRpcNode(options: InvokeMethodOptions): Promise { - return this.rpcClient.request(options); + try { + return await this.rpcClient.request(options); + } catch (error) { + if (error instanceof MissingRpcEndpointErr) { + return this.handleWithWallet(options); + } + throw error; + } } /**