Skip to content

Commit 1063b22

Browse files
authored
Process permission requests sequentially (#150)
* handle permission request sequentially * add test and linter updates * only store permissions if all permissions in a batch are approved * Restrict in kernal that only one rpc can be handled at a time. * Allow on testing page for multiple permissions to be requested in parallel. * linter fixes * remove unnecessary check
1 parent 6670baa commit 1063b22

File tree

5 files changed

+224
-45
lines changed

5 files changed

+224
-45
lines changed

packages/gator-permissions-snap/src/rpc/rpcHandler.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PermissionResponse } from '@metamask/7715-permissions-shared/types';
12
import { logger } from '@metamask/7715-permissions-shared/utils';
23
import { InvalidInputError, type Json } from '@metamask/snaps-sdk';
34

@@ -58,30 +59,37 @@ export function createRpcHandler(config: {
5859
const { permissionsRequest, siteOrigin } =
5960
validatePermissionRequestParam(params);
6061

61-
const responses = await Promise.all(
62-
permissionsRequest.map(async (request) => {
63-
const handler =
64-
permissionHandlerFactory.createPermissionHandler(request);
62+
const permissionsToStore: {
63+
permissionResponse: PermissionResponse;
64+
siteOrigin: string;
65+
}[] = [];
6566

66-
const permissionResponse =
67-
await handler.handlePermissionRequest(siteOrigin);
67+
// First, process all permissions to collect responses and validate all are approved
68+
for (const request of permissionsRequest) {
69+
const handler = permissionHandlerFactory.createPermissionHandler(request);
6870

69-
if (!permissionResponse.approved) {
70-
throw new InvalidInputError(permissionResponse.reason);
71-
}
71+
const permissionResponse =
72+
await handler.handlePermissionRequest(siteOrigin);
7273

73-
if (permissionResponse.response) {
74-
await profileSyncManager.storeGrantedPermission({
75-
permissionResponse: permissionResponse.response,
76-
siteOrigin,
77-
});
78-
}
74+
if (!permissionResponse.approved) {
75+
throw new InvalidInputError(permissionResponse.reason);
76+
}
7977

80-
return permissionResponse.response;
81-
}),
82-
);
78+
permissionsToStore.push({
79+
permissionResponse: permissionResponse.response,
80+
siteOrigin,
81+
});
82+
}
83+
84+
// Only after all permissions have been successfully processed, store them all in batch
85+
if (permissionsToStore.length > 0) {
86+
await profileSyncManager.storeGrantedPermissionBatch(permissionsToStore);
87+
}
8388

84-
return responses as Json[];
89+
// Return the permission responses
90+
return permissionsToStore.map(
91+
(permission) => permission.permissionResponse as Json,
92+
);
8593
};
8694

8795
/**

packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,67 @@ describe('RpcHandler', () => {
287287
expect(mockHandler.handlePermissionRequest).toHaveBeenCalledTimes(2);
288288
});
289289

290+
it('should process multiple permission requests sequentially (no concurrency)', async () => {
291+
const secondPermissionRequest = {
292+
...VALID_PERMISSION_REQUEST,
293+
chainId: '0x2' as const,
294+
};
295+
296+
const secondResponse = {
297+
...VALID_PERMISSION_RESPONSE,
298+
chainId: '0x2' as const,
299+
};
300+
301+
const request: Json = {
302+
permissionsRequest: [
303+
VALID_PERMISSION_REQUEST,
304+
secondPermissionRequest,
305+
] as unknown as Json[],
306+
siteOrigin: TEST_SITE_ORIGIN,
307+
};
308+
309+
let resolveFirst: (value: unknown) => void;
310+
const firstPromise = new Promise((resolve) => {
311+
resolveFirst = resolve;
312+
});
313+
314+
const firstMockHandler: jest.Mocked<PermissionHandlerType> = {
315+
handlePermissionRequest: jest
316+
.fn()
317+
.mockImplementation(
318+
async () => firstPromise as unknown as Promise<any>,
319+
),
320+
} as unknown as jest.Mocked<PermissionHandlerType>;
321+
322+
const secondMockHandler: jest.Mocked<PermissionHandlerType> = {
323+
handlePermissionRequest: jest.fn().mockImplementation(async () => ({
324+
approved: true,
325+
response: secondResponse,
326+
})),
327+
} as unknown as jest.Mocked<PermissionHandlerType>;
328+
329+
mockHandlerFactory.createPermissionHandler
330+
.mockImplementationOnce(() => firstMockHandler)
331+
.mockImplementationOnce(() => secondMockHandler);
332+
333+
const resultPromise = handler.grantPermission(request);
334+
335+
// Yield to allow the first await to be hit
336+
await Promise.resolve();
337+
expect(secondMockHandler.handlePermissionRequest).not.toHaveBeenCalled();
338+
339+
// Resolve the first request and then ensure the second starts
340+
// @ts-expect-error - resolveFirst is assigned above
341+
resolveFirst({ approved: true, response: VALID_PERMISSION_RESPONSE });
342+
const result = await resultPromise;
343+
344+
expect(firstMockHandler.handlePermissionRequest).toHaveBeenCalledTimes(1);
345+
expect(secondMockHandler.handlePermissionRequest).toHaveBeenCalledTimes(
346+
1,
347+
);
348+
expect(result).toStrictEqual([VALID_PERMISSION_RESPONSE, secondResponse]);
349+
});
350+
290351
it('should throw an error if orchestrator creation fails', async () => {
291352
mockHandlerFactory.createPermissionHandler.mockImplementation(() => {
292353
throw new Error('Failed to create orchestrator');

packages/permissions-kernel-snap/src/index.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { logger } from '@metamask/7715-permissions-shared/utils';
22
import {
3+
LimitExceededError,
34
MethodNotFoundError,
45
type Json,
56
type JsonRpcParams,
@@ -27,6 +28,10 @@ const boundRpcHandlers: {
2728
rpcHandler.requestExecutionPermissions.bind(rpcHandler),
2829
};
2930

31+
// Processing lock to ensure only one RPC request is processed at a time
32+
// Use a token-based lock to avoid race conditions across async boundaries
33+
let activeProcessingLock: symbol | null = null;
34+
3035
/**
3136
* Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`.
3237
*
@@ -41,26 +46,48 @@ export const onRpcRequest: OnRpcRequestHandler = async ({
4146
origin,
4247
request,
4348
}) => {
44-
logger.info(
45-
`Custom request (origin="${origin}"):`,
46-
JSON.stringify(request, null, 2),
47-
);
48-
49-
// Use Object.prototype.hasOwnProperty.call() to prevent prototype pollution attacks
50-
// This ensures we only access methods that exist on boundRpcHandlers itself
51-
if (!Object.prototype.hasOwnProperty.call(boundRpcHandlers, request.method)) {
52-
throw new MethodNotFoundError(`Method ${request.method} not found.`);
49+
// Check if another request is already being processed
50+
if (activeProcessingLock !== null) {
51+
logger.warn(
52+
`RPC request rejected (origin="${origin}"): another request is already being processed`,
53+
);
54+
throw new LimitExceededError('Another request is already being processed.');
5355
}
5456

55-
// We know that the method exists, so we can cast to NonNullable
56-
const handler = boundRpcHandlers[request.method] as NonNullable<
57-
(typeof boundRpcHandlers)[string]
58-
>;
57+
// Acquire the processing lock
58+
const myLock = Symbol('processing-lock');
59+
activeProcessingLock = myLock;
60+
61+
try {
62+
logger.info(
63+
`Custom request (origin="${origin}"):`,
64+
JSON.stringify(request, undefined, 2),
65+
);
5966

60-
const result = await handler({
61-
siteOrigin: origin,
62-
params: request.params as JsonRpcParams,
63-
});
67+
// Use Object.prototype.hasOwnProperty.call() to prevent prototype pollution attacks
68+
// This ensures we only access methods that exist on boundRpcHandlers itself
69+
if (
70+
!Object.prototype.hasOwnProperty.call(boundRpcHandlers, request.method)
71+
) {
72+
throw new MethodNotFoundError(`Method ${request.method} not found.`);
73+
}
6474

65-
return result;
75+
// We know that the method exists, so we can cast to NonNullable
76+
const handler = boundRpcHandlers[request.method] as NonNullable<
77+
(typeof boundRpcHandlers)[string]
78+
>;
79+
80+
const result = await handler({
81+
siteOrigin: origin,
82+
params: request.params as JsonRpcParams,
83+
});
84+
85+
return result;
86+
} finally {
87+
// Always release the processing lock we acquired, regardless of success or failure
88+
// Only release if we still hold the lock to avoid clobbering a newer lock
89+
if (activeProcessingLock === myLock) {
90+
activeProcessingLock = null;
91+
}
92+
}
6693
};

packages/permissions-kernel-snap/test/end-to-end/index.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,67 @@ describe('Kernel Snap', () => {
4848
}
4949
});
5050
});
51+
52+
describe('processing lock', () => {
53+
beforeEach(async () => {
54+
const { request } = await installSnap({
55+
options: {
56+
state: {
57+
permissionOfferRegistry: {},
58+
},
59+
},
60+
});
61+
62+
snapRequest = request;
63+
});
64+
65+
it('should reject a concurrent request, and allow a later one', async () => {
66+
const requestBody: RequestOptions = {
67+
method: 'wallet_requestExecutionPermissions',
68+
params: [
69+
{
70+
chainId: '0x1',
71+
signer: {
72+
type: 'account',
73+
data: {
74+
address: '0x1234567890123456789012345678901234567890',
75+
},
76+
},
77+
permission: {
78+
type: 'native-token-transfer',
79+
isAdjustmentAllowed: true,
80+
data: {
81+
justification: 'Test permission',
82+
allowance: '0x1000',
83+
},
84+
},
85+
},
86+
],
87+
};
88+
89+
// Fire two requests without awaiting the first
90+
const first = snapRequest(requestBody);
91+
const second = snapRequest(requestBody);
92+
93+
// Assert the second one is rejected with the internal error that wraps LimitExceededError
94+
const secondResponse = await second;
95+
expect(secondResponse).toRespondWithError({
96+
code: -32005,
97+
message: 'Another request is already being processed.',
98+
stack: expect.any(String),
99+
});
100+
101+
// Allow the first to settle (it may error due to test setup, which is fine)
102+
await first.catch(() => undefined);
103+
104+
// Make a third request; it must not fail due to the lock
105+
const thirdResponse = await snapRequest(requestBody);
106+
expect(thirdResponse).not.toRespondWithError({
107+
code: -32005,
108+
message: 'Another request is already being processed.',
109+
stack: expect.any(String),
110+
});
111+
});
112+
});
51113
});
52114
});

packages/site/src/pages/index.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ const Index = () => {
135135
const [data, setData] = useState<Hex>('0x');
136136
const [value, setValue] = useState<bigint>(0n);
137137
const [receipt, setReceipt] = useState<UserOperationReceipt | null>(null);
138-
const [isWorking, setIsWorking] = useState(false);
138+
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<Set<string>>(new Set());
139139

140140
const handleChainChange = ({
141141
target: { value: inputValue },
@@ -175,7 +175,13 @@ const Index = () => {
175175
if (!delegateAccount) {
176176
throw new Error('Delegate account not found');
177177
}
178-
setIsWorking(true);
178+
179+
// Generate a unique identifier for this redemption request
180+
const requestId = `redeem-${Date.now()}-${Math.random()}`;
181+
182+
// Add this request to pending requests
183+
setPendingPermissionRequests(prev => new Set(prev).add(requestId));
184+
179185
setReceipt(null);
180186
setPermissionResponseError(null);
181187

@@ -214,8 +220,14 @@ const Index = () => {
214220
setReceipt(operationReceipt);
215221
} catch (error) {
216222
setPermissionResponseError(error as Error);
223+
} finally {
224+
// Remove this request from pending requests
225+
setPendingPermissionRequests(prev => {
226+
const newSet = new Set(prev);
227+
newSet.delete(requestId);
228+
return newSet;
229+
});
217230
}
218-
setIsWorking(false);
219231
};
220232

221233
const handleGrantPermissions = async () => {
@@ -253,7 +265,12 @@ const Index = () => {
253265
} as const,
254266
];
255267

256-
setIsWorking(true);
268+
// Generate a unique identifier for this permission request
269+
const requestId = `${type}-${Date.now()}-${Math.random()}`;
270+
271+
// Add this request to pending requests
272+
setPendingPermissionRequests(prev => new Set(prev).add(requestId));
273+
257274
setPermissionResponse(null);
258275
setReceipt(null);
259276
setPermissionResponseError(null);
@@ -265,8 +282,14 @@ const Index = () => {
265282
} catch (error) {
266283
setPermissionResponse(null);
267284
setPermissionResponseError(error as Error);
285+
} finally {
286+
// Remove this request from pending requests
287+
setPendingPermissionRequests(prev => {
288+
const newSet = new Set(prev);
289+
newSet.delete(requestId);
290+
return newSet;
291+
});
268292
}
269-
setIsWorking(false);
270293
};
271294

272295
const handleCopyToClipboard = () => {
@@ -304,7 +327,6 @@ const Index = () => {
304327
<Subtitle>
305328
Get started by installing snaps and sending permissions requests.
306329
</Subtitle>
307-
{isWorking && <p>Loading...</p>}
308330
<CardContainer>
309331
{errors.map((error, idx) => (
310332
<ErrorMessage key={idx}>
@@ -372,7 +394,7 @@ const Index = () => {
372394
<CustomMessageButton
373395
$text="Redeem Permission"
374396
onClick={handleRedeemPermission}
375-
disabled={isWorking}
397+
disabled={pendingPermissionRequests.size > 0}
376398
/>
377399
</Box>
378400
)}
@@ -459,7 +481,6 @@ const Index = () => {
459481
<CustomMessageButton
460482
$text="Grant Permission"
461483
onClick={handleGrantPermissions}
462-
disabled={isWorking}
463484
/>
464485
</Box>
465486
)}

0 commit comments

Comments
 (0)