Skip to content

Commit 8bf1e1b

Browse files
feat: Add WebSocket logic to network access Snap (#3458)
Expands the network access Snap to support opening a WebSocket connection and subscribing to block updates from a local Ethereum node. Additionally fixes some mistakes made in the original WebSocket PR. Closes #3456
1 parent b2086e6 commit 8bf1e1b

5 files changed

Lines changed: 198 additions & 16 deletions

File tree

packages/examples/packages/network-access/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# `@metamask/network-access-example-snap`
22

33
This snap demonstrates how to use the `endowment:network-access` permission to
4-
get access to the `fetch` function from a snap.
4+
get access to the `fetch` function from a Snap. It also demonstrates how to use
5+
WebSockets.
56

67
## Snap manifest
78

8-
> **Note**: Using `fetch` requires the `endowment:network-access`
9+
> **Note**: Using `fetch` or WebSockets requires the `endowment:network-access`
910
> permissions. Refer to [the documentation](https://docs.metamask.io/snaps/reference/permissions/#endowmentnetwork-access)
1011
> for more information.
1112
@@ -29,6 +30,11 @@ JSON-RPC methods:
2930

3031
- `fetch` - Fetch a JSON document from the optional `url`, and return the
3132
fetched data.
33+
- `startWebSocket` - Open a WebSocket connection to a local Ethereum node
34+
and subscribe to block updates.
35+
- `stopWebSocket` - Close a WebSocket connection, if one exists.
36+
- `getState` - Get the state of the Snap, including the block number and whether
37+
the WebSocket connection is active.
3238

3339
For more information, you can refer to
3440
[the end-to-end tests](./src/index.test.ts).

packages/examples/packages/network-access/snap.manifest.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snaps.git"
88
},
99
"source": {
10-
"shasum": "jIA8HIUJKUlasU0G8ZppBteN6gVgSaBMddSWhYFhFNk=",
10+
"shasum": "rrThY3oXtGgw6Jm85xyEQ0mdHgxmVC7nfb2CFQlwrsY=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",
@@ -21,7 +21,8 @@
2121
"dapps": true,
2222
"snaps": false
2323
},
24-
"endowment:network-access": {}
24+
"endowment:network-access": {},
25+
"snap_manageState": {}
2526
},
2627
"platformVersion": "8.0.0",
2728
"manifestVersion": "0.1"
Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
22
MethodNotFoundError,
33
type OnRpcRequestHandler,
4+
type OnWebSocketEventHandler,
45
} from '@metamask/snaps-sdk';
56
import { assert } from '@metamask/utils';
67

7-
import type { FetchParams } from './types';
8+
import type { UrlParams } from './types';
9+
10+
const DEFAULT_WEBSOCKET_URL = 'ws://localhost:8545';
811

912
/**
1013
* Fetch a JSON file from the provided URL. This uses the standard `fetch`
@@ -16,21 +19,78 @@ import type { FetchParams } from './types';
1619
*
1720
* @param url - The URL to fetch the data from. This function assumes that the
1821
* provided URL is a JSON document.
19-
* @returns There response as JSON.
22+
* @returns The response as JSON.
2023
* @throws If the provided URL is not a JSON document.
2124
*/
2225
async function getJson(url: string) {
2326
const response = await fetch(url);
2427
return await response.json();
2528
}
2629

30+
/**
31+
* Open a WebSocket connection to a local Ethereum node and subscribe to
32+
* block updates.
33+
*
34+
* @param url - The URL of the WebSocket connection.
35+
* @returns Null.
36+
* @throws If the WebSocket connection fails to open.
37+
*/
38+
async function subscribe(url: string = DEFAULT_WEBSOCKET_URL) {
39+
const id = await snap.request({
40+
method: 'snap_openWebSocket',
41+
params: {
42+
url,
43+
},
44+
});
45+
46+
const message = JSON.stringify({
47+
jsonrpc: '2.0',
48+
id: 1,
49+
method: 'eth_subscribe',
50+
params: ['newHeads'],
51+
});
52+
53+
return await snap.request({
54+
method: 'snap_sendWebSocketMessage',
55+
params: { id, message },
56+
});
57+
}
58+
59+
/**
60+
* Close a WebSocket connection to a local Ethereum node, if it exists.
61+
*
62+
* @param url - The URL of the WebSocket connection.
63+
* @returns Null.
64+
*/
65+
async function unsubscribe(url: string = DEFAULT_WEBSOCKET_URL) {
66+
const sockets = await snap.request({
67+
method: 'snap_getWebSockets',
68+
});
69+
70+
const socket = sockets.find((socketInfo) => socketInfo.url === url);
71+
72+
if (!socket) {
73+
return null;
74+
}
75+
76+
return await snap.request({
77+
method: 'snap_closeWebSocket',
78+
params: { id: socket.id },
79+
});
80+
}
81+
2782
/**
2883
* Handle incoming JSON-RPC requests from the dapp, sent through the
29-
* `wallet_invokeSnap` method. This handler handles a single method:
84+
* `wallet_invokeSnap` method. This handler handles four methods:
3085
*
3186
* - `fetch`: Fetch a JSON document from the provided URL. This demonstrates
3287
* that a snap can make network requests through the `fetch` function. Note that
3388
* the `endowment:network-access` permission must be enabled for this to work.
89+
* - `startWebSocket`: Open a WebSocket connection to a local Ethereum node
90+
* and subscribe to block updates.
91+
* - `closeWebSocket`: Close a WebSocket connection, if one exists.
92+
* - `getState`: Get the state of the Snap, including the block number and whether
93+
* the WebSocket connection is active.
3494
*
3595
* @param params - The request parameters.
3696
* @param params.request - The JSON-RPC request object.
@@ -40,14 +100,77 @@ async function getJson(url: string) {
40100
* @see https://docs.metamask.io/snaps/reference/permissions/#endowmentnetwork-access
41101
*/
42102
export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
103+
const params = request.params as UrlParams | undefined;
104+
const url = params?.url;
105+
43106
switch (request.method) {
44107
case 'fetch': {
45-
const params = request.params as FetchParams | undefined;
46-
assert(params?.url, 'Required url parameter was not specified.');
47-
return await getJson(params.url);
108+
assert(url, 'Required url parameter was not specified.');
109+
return await getJson(url);
110+
}
111+
112+
case 'startWebSocket':
113+
return subscribe(url);
114+
115+
case 'stopWebSocket':
116+
return unsubscribe(url);
117+
118+
case 'getState': {
119+
const state = await snap.request({
120+
method: 'snap_getState',
121+
params: { encrypted: false },
122+
});
123+
return state ?? { blockNumber: null, open: false };
48124
}
49125

50126
default:
51127
throw new MethodNotFoundError({ method: request.method });
52128
}
53129
};
130+
131+
/**
132+
* Handle incoming WebSocket events sent by a client.
133+
*
134+
* @param params - The request parameters.
135+
* @param params.event - The WebSocket event.
136+
* @returns Nothing.
137+
*/
138+
export const onWebSocketEvent: OnWebSocketEventHandler = async ({ event }) => {
139+
const { origin } = event;
140+
141+
if (event.type === 'open') {
142+
await snap.request({
143+
method: 'snap_setState',
144+
params: {
145+
value: { blockNumber: null, origin, open: true },
146+
encrypted: false,
147+
},
148+
});
149+
return;
150+
}
151+
152+
if (event.type === 'close') {
153+
await snap.request({
154+
method: 'snap_setState',
155+
params: {
156+
value: { blockNumber: null, origin: null, open: false },
157+
encrypted: false,
158+
},
159+
});
160+
return;
161+
}
162+
163+
assert(event.data.type === 'text');
164+
const json = JSON.parse(event.data.message);
165+
166+
if (!json.params?.result) {
167+
return;
168+
}
169+
170+
const blockNumber = parseInt(json.params.result.number.slice(2), 16);
171+
172+
await snap.request({
173+
method: 'snap_setState',
174+
params: { key: 'blockNumber', value: blockNumber, encrypted: false },
175+
});
176+
};

packages/examples/packages/network-access/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
2-
* The params for the `fetch` JSON-RPC method.
2+
* Generic parameters containing an URL.
33
*/
4-
export type FetchParams = {
4+
export type UrlParams = {
55
/**
66
* The URL to fetch.
77
*/

packages/test-snaps/src/features/snaps/network-access/NetworkAccess.tsx

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { logError } from '@metamask/snaps-utils';
22
import type { ChangeEvent, FunctionComponent } from 'react';
33
import { useState } from 'react';
4-
import { Button, Form } from 'react-bootstrap';
4+
import { Button, ButtonGroup, Form } from 'react-bootstrap';
55

66
import {
77
NETWORK_ACCESS_PORT,
@@ -20,14 +20,37 @@ export const NetworkAccess: FunctionComponent = () => {
2020
setUrl(event.target.value);
2121
};
2222

23-
const handleSubmit = () => {
23+
const snapId = getSnapId(NETWORK_ACCESS_SNAP_ID, NETWORK_ACCESS_PORT);
24+
25+
const handleFetch = () => {
2426
invokeSnap({
25-
snapId: getSnapId(NETWORK_ACCESS_SNAP_ID, NETWORK_ACCESS_PORT),
27+
snapId,
2628
method: 'fetch',
2729
params: { url },
2830
}).catch(logError);
2931
};
3032

33+
const handleStartWebSocket = () => {
34+
invokeSnap({
35+
snapId,
36+
method: 'startWebSocket',
37+
}).catch(logError);
38+
};
39+
40+
const handleStopWebSocket = () => {
41+
invokeSnap({
42+
snapId,
43+
method: 'stopWebSocket',
44+
}).catch(logError);
45+
};
46+
47+
const handleGetState = () => {
48+
invokeSnap({
49+
snapId,
50+
method: 'getState',
51+
}).catch(logError);
52+
};
53+
3154
return (
3255
<Snap
3356
name="Network Access Snap"
@@ -49,10 +72,39 @@ export const NetworkAccess: FunctionComponent = () => {
4972
id="sendNetworkAccessTest"
5073
className="mb-3"
5174
disabled={isLoading}
52-
onClick={handleSubmit}
75+
onClick={handleFetch}
5376
>
5477
Fetch
5578
</Button>
79+
80+
<ButtonGroup className="mb-3">
81+
<Button
82+
variant="primary"
83+
id="startWebSocket"
84+
disabled={isLoading}
85+
onClick={handleStartWebSocket}
86+
>
87+
Start WebSocket
88+
</Button>
89+
<Button
90+
variant="primary"
91+
id="stopWebSocket"
92+
disabled={isLoading}
93+
onClick={handleStopWebSocket}
94+
>
95+
Stop WebSocket
96+
</Button>
97+
98+
<Button
99+
variant="primary"
100+
id="getWebSocketState"
101+
disabled={isLoading}
102+
onClick={handleGetState}
103+
>
104+
Get State
105+
</Button>
106+
</ButtonGroup>
107+
56108
<Result>
57109
<span id="networkAccessResult">
58110
{JSON.stringify(data, null, 2)}

0 commit comments

Comments
 (0)