Skip to content

Commit ebf1100

Browse files
ffmcgee725wenfixjiexiadonesky1
authored
feat: add support for read only RPC calls (#33)
* feat: wip draft evm layer * chore: more wip * chore: update provider * basic feature set * add lint commands and update tsconfig * clean up and docs * lint fixes to match new repo * update build steps * simplify getEthAccounts and ensure unique addresses * modify eth_accounts and eth_coinbase logic * change currentAccount to a getter * better chain changed handling * fix chain configuration * clean up constants * remove eip155 constants * remove optional chain id parameter * explicit error when invoking ignored method * refactor and clean-up * set chain id upon connecting * port legacy react demo app into repo * clean up build and dts * use helpers from @metamask/chain-agnostic-permission * use @metamask/utils * update utils on provider * add logging * fix provider request chain id * add session recovery * fix sentTransaction in test dapp * feat: add send message capability to transports * chore: add vite --host script to allow access to playground app via other devices on network * feat: add vite plugign node polyfills for Buffer support * add transport notification handler after connect * fix: make sure qr preload function is supported on all build types * enable revoke permissions on disconnect * add connectAndSign * improve jsdoc * update README * add dev-watch mode to connect-multichain * update legacy playground readme * WIP: EVM Wrapper MwpTransport caching (#29) * WIP * WIP * fix build * v broken * a bit less broken * fix accountsChanged * clear cache on disconnect * remove notificationQueue. Attempt to get cached eth_accounts and eth_chainId * Fix onConnect and disconnect events? Not sure about this one * fix: de-parametrize TransportResponse for fixing build * add responses to the default transport --------- Co-authored-by: Alex Donesky <[email protected]> Co-authored-by: ffmcgee <[email protected]> Co-authored-by: Alex Mendonca <[email protected]> * cleanup listeners and separate listening roles across class * fix build * fix: make sure In App Browser uses default transport * Fix MWP disconnection by moving wallet_revokeSession call out of ConnectEvm and into default transport * Fix hasExtensionInstalled and resolve resume not working for IAB * fix: make sure initial chain id matches permitted chain id * refactor: properly type wallet_getSession response from attemptSessionRecory + minor docs update * Fix add/switchChain not prompting for deeplink (#30) * WIP * WIP * fix build * v broken * a bit less broken * fix accountsChanged * clear cache on disconnect * remove notificationQueue. Attempt to get cached eth_accounts and eth_chainId * Fix onConnect and disconnect events? Not sure about this one * fix: de-parametrize TransportResponse for fixing build * add responses to the default transport * Add opendeeplink method in core --------- Co-authored-by: Alex Donesky <[email protected]> Co-authored-by: ffmcgee <[email protected]> Co-authored-by: Alex Mendonca <[email protected]> * feat: add support for read only rpc calls * refactor: minor clean up * restore preload * fix specs * lint * changelog * cleanup console logs * changelog * changelog * changelog * changelog * refactor: validate that the chain is configured for all RPC calls * refactor: typing readonlyRpcMap * refactor: inline default timeout for RpcClient * revert: formatting requestRouter.ts * test: rpcClient test update (expect AbortSignal) * minor lint reverts --------- Co-authored-by: Alex Mendonca <[email protected]> Co-authored-by: Jiexi Luan <[email protected]> Co-authored-by: Alex Donesky <[email protected]>
1 parent c04b572 commit ebf1100

File tree

8 files changed

+161
-47
lines changed

8 files changed

+161
-47
lines changed

packages/connect-evm/src/connect.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ export class MetamaskConnectEVM {
8686
* @param options - The options for the MetamaskConnectEVM instance
8787
* @param options.core - The core instance of the Multichain SDK
8888
* @param options.eventHandlers - Optional event handlers for EIP-1193 provider events
89-
* @param options.notificationQueue - Optional queue of notifications received during initialization
9089
*/
9190
constructor({ core, eventHandlers }: MetamaskConnectEVMOptions) {
9291
this.#core = core;
@@ -423,7 +422,10 @@ export class MetamaskConnectEVM {
423422
}): Promise<unknown> {
424423
logger('direct request to metamask-provider called', request);
425424
const result = this.#core.transport.sendEip1193Message(request);
426-
if (request.method === 'wallet_addEthereumChain' || request.method === 'wallet_switchEthereumChain') {
425+
if (
426+
request.method === 'wallet_addEthereumChain' ||
427+
request.method === 'wallet_switchEthereumChain'
428+
) {
427429
this.#core.openDeeplinkIfNeeded();
428430
}
429431
return result;
@@ -613,27 +615,43 @@ export class MetamaskConnectEVM {
613615
* Creates a new Metamask Connect/EVM instance
614616
*
615617
* @param options - The options for the Metamask Connect/EVM layer
618+
* @param options.dapp - Dapp identification and branding settings
619+
* @param options.api - API configuration including read-only RPC map
620+
* @param options.api.readOnlyRpcMap - A map of CAIP chain IDs to RPC URLs for read-only requests
616621
* @param options.eventEmitter - The event emitter to use for the Metamask Connect/EVM layer
617622
* @param options.eventHandlers - The event handlers to use for the Metamask Connect/EVM layer
618623
* @returns The Metamask Connect/EVM layer instance
619624
*/
620625
export async function createMetamaskConnectEVM(
621-
options: Pick<MultichainOptions, 'dapp'> & {
626+
options: Pick<MultichainOptions, 'dapp' | 'api'> & {
622627
eventEmitter?: MinimalEventEmitter;
623628
eventHandlers?: EventHandlers;
624629
},
625630
): Promise<MetamaskConnectEVM> {
626631
logger('Creating Metamask Connect/EVM with options:', options);
627632

633+
// Validate that readOnlyRpcMap is provided and not empty
634+
if (
635+
!options.api?.readonlyRPCMap ||
636+
Object.keys(options.api.readonlyRPCMap).length === 0
637+
) {
638+
throw new Error(
639+
'readOnlyRpcMap is required and must contain at least one chain configuration',
640+
);
641+
}
642+
628643
try {
629-
// @ts-expect-error TODO: address this
630644
const core = await createMetamaskConnect({
631645
...options,
646+
api: {
647+
readonlyRPCMap: options.api.readonlyRPCMap,
648+
},
632649
});
633650

634651
return new MetamaskConnectEVM({
635652
core,
636653
eventHandlers: options.eventHandlers,
654+
readOnlyRpcMap: options.api.readonlyRPCMap,
637655
});
638656
} catch (error) {
639657
console.error('Error creating Metamask Connect/EVM', error);

packages/connect-evm/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
export { createMetamaskConnectEVM, MetamaskConnectEVM } from './connect';
1+
export { getInfuraRpcUrls } from '@metamask/connect-multichain';
2+
export { createMetamaskConnectEVM, type MetamaskConnectEVM } from './connect';
23
export type { EIP1193Provider } from './provider';
34

45
export type * from './types';

packages/connect-evm/src/provider.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MultichainCore } from '@metamask/connect-multichain';
1+
import type { MultichainCore, Scope } from '@metamask/connect-multichain';
22
import { EventEmitter } from '@metamask/connect-multichain';
33
import { hexToNumber, numberToHex } from '@metamask/utils';
44

@@ -57,9 +57,22 @@ export class EIP1193Provider extends EventEmitter<EIP1193ProviderEvents> {
5757
}
5858

5959
const chainId = hexToNumber(this.#selectedChainId);
60+
const scope: Scope = `eip155:${chainId}`;
61+
62+
// Validate that the chain is configured in readOnlyRpcMap
63+
// This check is performed here to provide better error messages
64+
// The RpcClient will also validate, but this gives us a chance to provide
65+
// a clearer error message before the request is routed
66+
const coreOptions = (this.#core as any).options; // TODO: options is `protected readonly` property, this needs to be refactored so `any` type assertion is not necessary
67+
const readonlyRPCMap = coreOptions?.api?.readonlyRPCMap ?? {};
68+
if (!readonlyRPCMap[scope]) {
69+
throw new Error(
70+
`Chain ${scope} is not configured in readOnlyRpcMap. Please add an RPC URL for this chain.`,
71+
);
72+
}
6073

6174
return this.#core.invokeMethod({
62-
scope: `eip155:${chainId}`,
75+
scope,
6376
request: {
6477
method: request.method,
6578
params: request.params,

packages/connect-evm/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { EIP1193Provider } from './provider';
66
export type Hex = `0x${string}`;
77
export type Address = Hex;
88
export type CaipAccountId = `${string}:${string}:${string}`;
9-
9+
export type CaipChainId = `${string}:${string}`;
1010
export type MinimalEventEmitter = Pick<EIP1193Provider, 'on' | 'off' | 'emit'>;
1111

1212
export type EIP1193ProviderEvents = {
@@ -27,6 +27,7 @@ export type MetamaskConnectEVMOptions = {
2727
core: MultichainCore;
2828
eventHandlers?: EventHandlers;
2929
notificationQueue?: unknown[];
30+
readOnlyRpcMap?: Record<CaipChainId, string>;
3031
};
3132

3233
export type AddEthereumChainParameter = {

packages/connect-multichain/src/multichain/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import {
3232
} from '@metamask/multichain-api-client';
3333
import type { CaipAccountId, Json } from '@metamask/utils';
3434

35-
import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE, MWP_RELAY_URL } from '../config';
35+
import {
36+
METAMASK_CONNECT_BASE_URL,
37+
METAMASK_DEEPLINK_BASE,
38+
MWP_RELAY_URL,
39+
} from '../config';
3640
import {
3741
getVersion,
3842
type InvokeMethodOptions,
@@ -59,12 +63,12 @@ import {
5963
isSecure,
6064
PlatformType,
6165
} from '../domain/platform';
66+
import { RpcClient } from './rpc/handlers/rpcClient';
6267
import { RequestRouter } from './rpc/requestRouter';
6368
import { DefaultTransport } from './transports/default';
6469
import { MWPTransport } from './transports/mwp';
6570
import { keymanager } from './transports/mwp/KeyManager';
6671
import { getDappId, openDeeplink, setupDappMetadata } from './utils';
67-
import { RpcClient } from './rpc/handlers/rpcClient';
6872

6973
export { getInfuraRpcUrls } from '../domain/multichain/api/infura';
7074

@@ -209,8 +213,6 @@ export class MultichainSDK extends MultichainCore {
209213
private async getStoredTransport(): Promise<
210214
DefaultTransport | MWPTransport | undefined
211215
> {
212-
const { ui } = this.options;
213-
const { preferExtension = true } = ui;
214216
const transportType = await this.storage.getTransport();
215217
const hasExtensionInstalled = await hasExtension();
216218
if (transportType) {
@@ -602,7 +604,7 @@ export class MultichainSDK extends MultichainCore {
602604

603605
const rpcClient = new RpcClient(options, sdkInfo);
604606
const requestRouter = new RequestRouter(transport, rpcClient, options);
605-
return requestRouter.invokeMethod(request) as Promise<Json>;
607+
return requestRouter.invokeMethod(request);
606608
}
607609

608610
// DRY THIS WITH REQUEST ROUTER

packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,12 @@ t.describe('RpcClient', () => {
9696
const result = await rpcClient.request({ ...baseOptions, scope: 'eip155:11155111' });
9797

9898
t.expect(result).toBe('0x1234567890abcdef');
99-
t.expect(mockFetch).toHaveBeenCalledWith('https://custom-sepolia.com', {
99+
t.expect(mockFetch).toHaveBeenCalledWith('https://custom-sepolia.com', t.expect.objectContaining({
100100
method: 'POST',
101101
headers: defaultHeaders,
102102
body: t.expect.stringContaining('"method":"eth_getBalance"'),
103-
});
103+
signal: t.expect.any(AbortSignal),
104+
}));
104105
});
105106

106107
t.it('should throw RPCReadonlyResponseErr when response cannot be parsed as JSON', async () => {
@@ -161,11 +162,12 @@ t.describe('RpcClient', () => {
161162

162163
const result = await clientWithCustomRPC.request(baseOptions);
163164
t.expect(result).toBe('0x123456account12345');
164-
t.expect(mockFetch).toHaveBeenCalledWith('https://custom-ethereum-node.com/rpc', {
165+
t.expect(mockFetch).toHaveBeenCalledWith('https://custom-ethereum-node.com/rpc', t.expect.objectContaining({
165166
method: 'POST',
166167
headers: defaultHeaders,
167168
body: t.expect.stringMatching(/^\{"jsonrpc":"2\.0","method":"eth_accounts","id":\d+\}$/),
168-
});
169+
signal: t.expect.any(AbortSignal),
170+
}));
169171
});
170172

171173
t.it('should throw MissingRpcEndpointErr when no RPC endpoint is available', async () => {

packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class RpcClient {
2929

3030
/**
3131
* Routes the request to a configured RPC node.
32+
* @param options - The invoke method options
3233
*/
3334
async request(options: InvokeMethodOptions): Promise<Json> {
3435
const { request } = options;
@@ -39,7 +40,7 @@ export class RpcClient {
3940
id: getNextRpcId(),
4041
});
4142
const rpcEndpoint = this.getRpcEndpoint(options.scope);
42-
const rpcRequest = await this.fetch(rpcEndpoint, body, 'POST', this.getHeaders(rpcEndpoint));
43+
const rpcRequest = await this.fetchWithTimeout(rpcEndpoint, body, 'POST', this.getHeaders(rpcEndpoint), 30_000); // 30 seconds default timeout
4344
const response = await this.parseResponse(rpcRequest);
4445
return response;
4546
}
@@ -55,22 +56,37 @@ export class RpcClient {
5556
return rpcEndpoint;
5657
}
5758

58-
private async fetch(endpoint: string, body: string, method: string, headers: Record<string, string>) {
59+
private async fetchWithTimeout(
60+
endpoint: string,
61+
body: string,
62+
method: string,
63+
headers: Record<string, string>,
64+
timeout: number,
65+
): Promise<Response> {
66+
const controller = new AbortController();
67+
const timeoutId = setTimeout(() => controller.abort(), timeout);
68+
5969
try {
6070
const response = await fetch(endpoint, {
6171
method,
6272
headers,
6373
body,
74+
signal: controller.signal,
6475
});
76+
clearTimeout(timeoutId);
6577
if (!response.ok) {
6678
throw new RPCHttpErr(endpoint, method, response.status);
6779
}
6880
return response;
6981
} catch (error) {
82+
clearTimeout(timeoutId);
7083
if (error instanceof RPCHttpErr) {
7184
throw error;
7285
}
73-
throw new RPCReadonlyRequestErr(error.message);
86+
if (error instanceof Error && error.name === 'AbortError') {
87+
throw new RPCReadonlyRequestErr(`Request timeout after ${timeout}ms`);
88+
}
89+
throw new RPCReadonlyRequestErr(error instanceof Error ? error.message : 'Unknown error');
7490
}
7591
}
7692

playground/legacy-evm-react-vite-playground/src/App.tsx

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
22
import {
33
MetamaskConnectEVM,
44
createMetamaskConnectEVM,
5+
getInfuraRpcUrls,
56
} from '@metamask/connect-evm';
67
import './App.css';
78
import { send_eth_signTypedData_v4, send_personal_sign } from './SignHelpers';
@@ -16,11 +17,25 @@ function useSDK() {
1617

1718
useEffect(() => {
1819
const setupSDK = async () => {
20+
const infuraApiKey = import.meta.env.VITE_INFURA_API_KEY || '';
21+
const readOnlyRpcMap = infuraApiKey
22+
? getInfuraRpcUrls(infuraApiKey)
23+
: {
24+
// Fallback public RPC endpoints if no Infura key is provided
25+
'eip155:1': 'https://eth.llamarpc.com',
26+
'eip155:5': 'https://goerli.infura.io/v3/demo',
27+
'eip155:11155111': 'https://sepolia.infura.io/v3/demo',
28+
'eip155:137': 'https://polygon-rpc.com',
29+
};
30+
1931
const clientSDK = await createMetamaskConnectEVM({
2032
dapp: {
2133
name: 'NEXTJS demo',
2234
url: 'https://localhost:3000',
2335
},
36+
api: {
37+
readonlyRPCMap: readOnlyRpcMap,
38+
},
2439
});
2540
const provider = await clientSDK.getProvider();
2641

@@ -99,26 +114,55 @@ export const App = () => {
99114
}
100115
};
101116

102-
const readOnlyCalls = async () => {
103-
// if (!sdk?.hasReadOnlyRPCCalls() && !provider) {
104-
// setResponse(
105-
// 'readOnlyCalls are not set and provider is not set. Please set your infuraAPIKey in the SDK Options',
106-
// );
107-
// return;
108-
// }
109-
// try {
110-
// const result = await provider?.request({
111-
// method: 'eth_blockNumber',
112-
// params: [],
113-
// });
114-
// const gotFrom = sdk?.hasReadOnlyRPCCalls()
115-
// ? 'infura'
116-
// : 'MetaMask provider';
117-
// setResponse(`(${gotFrom}) ${result}`);
118-
// } catch (e) {
119-
// console.log(`error getting the blockNumber`, e);
120-
// setResponse('error getting the blockNumber');
121-
// }
117+
const eth_getBalance = async () => {
118+
if (!provider || !account) {
119+
setResponse('Provider or account not available');
120+
return;
121+
}
122+
try {
123+
const result = await provider.request({
124+
method: 'eth_getBalance',
125+
params: [account, 'latest'],
126+
});
127+
setResponse(`Balance: ${result}`);
128+
} catch (e) {
129+
console.error('Error getting balance', e);
130+
setResponse(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
131+
}
132+
};
133+
134+
const eth_blockNumber = async () => {
135+
if (!provider) {
136+
setResponse('Provider not available');
137+
return;
138+
}
139+
try {
140+
const result = await provider.request({
141+
method: 'eth_blockNumber',
142+
params: [],
143+
});
144+
setResponse(`Block Number: ${result}`);
145+
} catch (e) {
146+
console.error('Error getting block number', e);
147+
setResponse(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
148+
}
149+
};
150+
151+
const eth_gasPrice = async () => {
152+
if (!provider) {
153+
setResponse('Provider not available');
154+
return;
155+
}
156+
try {
157+
const result = await provider.request({
158+
method: 'eth_gasPrice',
159+
params: [],
160+
});
161+
setResponse(`Gas Price: ${result}`);
162+
} catch (e) {
163+
console.error('Error getting gas price', e);
164+
setResponse(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
165+
}
122166
};
123167

124168
const addEthereumChain = () => {
@@ -295,13 +339,30 @@ export const App = () => {
295339
Add Polygon Chain
296340
</button>
297341

298-
<button
299-
className={'Button-Normal'}
300-
style={{ padding: 10, margin: 10 }}
301-
onClick={readOnlyCalls}
302-
>
303-
readOnlyCalls
304-
</button>
342+
<div style={{ marginTop: 20, borderTop: '1px solid #ccc', paddingTop: 10 }}>
343+
<h3>Read-Only RPC Calls</h3>
344+
<button
345+
className={'Button-Normal'}
346+
style={{ padding: 10, margin: 10 }}
347+
onClick={eth_getBalance}
348+
>
349+
eth_getBalance
350+
</button>
351+
<button
352+
className={'Button-Normal'}
353+
style={{ padding: 10, margin: 10 }}
354+
onClick={eth_blockNumber}
355+
>
356+
eth_blockNumber
357+
</button>
358+
<button
359+
className={'Button-Normal'}
360+
style={{ padding: 10, margin: 10 }}
361+
onClick={eth_gasPrice}
362+
>
363+
eth_gasPrice
364+
</button>
365+
</div>
305366
</div>
306367
) : (
307368
<div>

0 commit comments

Comments
 (0)