Skip to content

Commit c7e59b6

Browse files
fix: Use mutex when modifying state using snap_setState (#3742)
Adds a mutex per Snap that can be used to make usage of `snap_setState` in parallel more safe. The assumption was that this was already safe since getting and setting the state nearly always is a synchronous task. However, in practice, at least on mobile, this assumption has been proven false. This change may include a performance penalty, but it is important for `snap_setState` to be safe to use without data loss. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Wraps snap_setState in a per-Snap mutex to safely handle parallel state updates and adds tests; updates deps and minor coverage thresholds. > > - **snaps-rpc-methods**: > - **State updates**: Wrap `snap_setState` logic in a per-Snap `async-mutex` (`getMutex`, `mutexes` map, `runExclusive`) to serialize concurrent updates. > - **Types**: Use `SnapId` for mutex keying. > - **Tests**: Add concurrency test ensuring mutex serializes updates; adjust imports to use `createDeferredPromise`. > - **Dependencies**: Add `async-mutex`. > - **Config**: Slightly adjust Jest coverage thresholds. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 358aad0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 7ad4e66 commit c7e59b6

File tree

5 files changed

+146
-27
lines changed

5 files changed

+146
-27
lines changed

packages/snaps-rpc-methods/jest.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, {
1010
],
1111
coverageThreshold: {
1212
global: {
13-
branches: 95.68,
14-
functions: 98.75,
13+
branches: 95.7,
14+
functions: 98.76,
1515
lines: 98.99,
16-
statements: 98.69,
16+
statements: 98.7,
1717
},
1818
},
1919
});

packages/snaps-rpc-methods/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
"@metamask/snaps-utils": "workspace:^",
6363
"@metamask/superstruct": "^3.2.1",
6464
"@metamask/utils": "^11.8.1",
65-
"@noble/hashes": "^1.7.1"
65+
"@noble/hashes": "^1.7.1",
66+
"async-mutex": "^0.5.0"
6667
},
6768
"devDependencies": {
6869
"@lavamoat/allow-scripts": "^3.4.0",

packages/snaps-rpc-methods/src/permitted/setState.test.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
22
import { errorCodes } from '@metamask/rpc-errors';
33
import type { SetStateResult } from '@metamask/snaps-sdk';
4-
import type {
5-
Json,
6-
JsonRpcRequest,
7-
PendingJsonRpcResponse,
4+
import {
5+
createDeferredPromise,
6+
type Json,
7+
type JsonRpcRequest,
8+
type PendingJsonRpcResponse,
89
} from '@metamask/utils';
910

1011
import { setStateHandler, type SetStateParameters, set } from './setState';
@@ -196,6 +197,93 @@ describe('snap_setState', () => {
196197
});
197198
});
198199

200+
it('uses a mutex to protect state updates', async () => {
201+
const { implementation } = setStateHandler;
202+
203+
const { promise: getStateCalled, resolve: resolveGetStateCalled } =
204+
createDeferredPromise();
205+
const getSnapState = jest.fn().mockImplementation(() => {
206+
resolveGetStateCalled();
207+
return {};
208+
});
209+
210+
const { promise: updateSnapStatePromise, resolve } =
211+
createDeferredPromise();
212+
213+
const updateSnapState = jest.fn().mockReturnValue(updateSnapStatePromise);
214+
const getUnlockPromise = jest.fn().mockResolvedValue(undefined);
215+
const hasPermission = jest.fn().mockReturnValue(true);
216+
const getSnap = jest.fn().mockReturnValue({ preinstalled: false });
217+
218+
const hooks = {
219+
getSnapState,
220+
updateSnapState,
221+
getUnlockPromise,
222+
hasPermission,
223+
getSnap,
224+
};
225+
226+
const engine = new JsonRpcEngine();
227+
228+
engine.push((request, response, next, end) => {
229+
const result = implementation(
230+
request as JsonRpcRequest<SetStateParameters>,
231+
response as PendingJsonRpcResponse<SetStateResult>,
232+
next,
233+
end,
234+
hooks,
235+
);
236+
237+
result?.catch(end);
238+
});
239+
240+
const responsePromise1 = engine.handle({
241+
jsonrpc: '2.0',
242+
id: 1,
243+
method: 'snap_setState',
244+
params: {
245+
key: 'foo',
246+
value: 'baz',
247+
encrypted: false,
248+
},
249+
});
250+
251+
const responsePromise2 = engine.handle({
252+
jsonrpc: '2.0',
253+
id: 2,
254+
method: 'snap_setState',
255+
params: {
256+
key: 'foo',
257+
value: 'bar',
258+
encrypted: false,
259+
},
260+
});
261+
262+
await getStateCalled;
263+
264+
expect(getSnapState).toHaveBeenCalledTimes(1);
265+
266+
resolve();
267+
268+
const response1 = await responsePromise1;
269+
const response2 = await responsePromise2;
270+
271+
expect(getSnapState).toHaveBeenCalledTimes(2);
272+
expect(updateSnapState).toHaveBeenNthCalledWith(2, { foo: 'bar' }, false);
273+
274+
expect(response1).toStrictEqual({
275+
jsonrpc: '2.0',
276+
id: 1,
277+
result: null,
278+
});
279+
280+
expect(response2).toStrictEqual({
281+
jsonrpc: '2.0',
282+
id: 2,
283+
result: null,
284+
});
285+
});
286+
199287
it('throws if the requesting origin does not have the required permission', async () => {
200288
const { implementation } = setStateHandler;
201289

packages/snaps-rpc-methods/src/permitted/setState.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine';
22
import type { PermittedHandlerExport } from '@metamask/permission-controller';
33
import { providerErrors, rpcErrors } from '@metamask/rpc-errors';
4-
import type { SetStateParams, SetStateResult } from '@metamask/snaps-sdk';
4+
import type {
5+
SetStateParams,
6+
SetStateResult,
7+
SnapId,
8+
} from '@metamask/snaps-sdk';
59
import type { JsonObject } from '@metamask/snaps-sdk/jsx';
610
import {
711
getJsonSizeUnsafe,
@@ -21,6 +25,7 @@ import type {
2125
JsonRpcRequest,
2226
} from '@metamask/utils';
2327
import { hasProperty, isObject, assert, JsonStruct } from '@metamask/utils';
28+
import { Mutex } from 'async-mutex';
2429

2530
import {
2631
manageStateBuilder,
@@ -93,6 +98,21 @@ export type SetStateHooks = {
9398
getSnap: (snapId: string) => Snap | undefined;
9499
};
95100

101+
const mutexes = new Map();
102+
103+
/**
104+
* Get the corresponding state modification mutex for a given Snap ID.
105+
*
106+
* @param snapId - The Snap ID.
107+
* @returns A mutex for that specific Snap.
108+
*/
109+
function getMutex(snapId: SnapId) {
110+
if (!mutexes.has(snapId)) {
111+
mutexes.set(snapId, new Mutex());
112+
}
113+
return mutexes.get(snapId);
114+
}
115+
96116
const SetStateParametersStruct = objectStruct({
97117
key: optional(StateKeyStruct),
98118
value: JsonStruct,
@@ -156,26 +176,35 @@ async function setStateImplementation(
156176
await getUnlockPromise(true);
157177
}
158178

159-
const newState = await getNewState(key, value, encrypted, getSnapState);
160-
161-
const snap = getSnap(
162-
(request as JsonRpcRequest<SetStateParams> & { origin: string }).origin,
163-
);
164-
165-
if (!snap?.preinstalled) {
166-
// We know that the state is valid JSON as per previous validation.
167-
const size = getJsonSizeUnsafe(newState, true);
168-
if (size > STORAGE_SIZE_LIMIT) {
169-
throw rpcErrors.invalidParams({
170-
message: `Invalid params: The new state must not exceed ${
171-
STORAGE_SIZE_LIMIT / 1_000_000
172-
} MB in size.`,
173-
});
179+
const snapId = (
180+
request as JsonRpcRequest<SetStateParams> & { origin: string }
181+
).origin as SnapId;
182+
183+
const mutex = getMutex(snapId);
184+
185+
// The expectation when using `snap_setState` is for the operation to safe
186+
// to do in parallel. The mutex ensures that and prevents a bug that was
187+
// mostly prevalent on mobile and caused data loss.
188+
await mutex.runExclusive(async () => {
189+
const newState = await getNewState(key, value, encrypted, getSnapState);
190+
191+
const snap = getSnap(snapId);
192+
193+
if (!snap?.preinstalled) {
194+
// We know that the state is valid JSON as per previous validation.
195+
const size = getJsonSizeUnsafe(newState, true);
196+
if (size > STORAGE_SIZE_LIMIT) {
197+
throw rpcErrors.invalidParams({
198+
message: `Invalid params: The new state must not exceed ${
199+
STORAGE_SIZE_LIMIT / 1_000_000
200+
} MB in size.`,
201+
});
202+
}
174203
}
175-
}
176204

177-
await updateSnapState(newState, encrypted);
178-
response.result = null;
205+
await updateSnapState(newState, encrypted);
206+
response.result = null;
207+
});
179208
} catch (error) {
180209
return end(error);
181210
}

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4470,6 +4470,7 @@ __metadata:
44704470
"@swc/jest": "npm:^0.2.38"
44714471
"@ts-bridge/cli": "npm:^0.6.1"
44724472
"@types/node": "npm:18.14.2"
4473+
async-mutex: "npm:^0.5.0"
44734474
deepmerge: "npm:^4.2.2"
44744475
depcheck: "npm:^1.4.7"
44754476
eslint: "npm:^9.11.0"

0 commit comments

Comments
 (0)