Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit a10c7fb

Browse files
authored
feat: add RPC methods described in (revised) EIP-7715 (#396)
Adds methods `wallet_requestExecutionPermissions` and `wallet_revokeExecutionPermission`, as defined in this revision of the EIP-7715 specification ethereum/ERCs#1098. This supports Readable Permissions project, and is related to the following PRs: - MetaMask/smart-accounts-kit#60 - MetaMask/metamask-extension#35193 Note: workflows are failing due to existing problems, fixed by #397
1 parent 45ed998 commit a10c7fb

6 files changed

Lines changed: 443 additions & 6 deletions

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ export type {
2121
SendCallsParams,
2222
SendCallsResult,
2323
} from './methods/wallet-send-calls';
24+
export type {
25+
RequestExecutionPermissionsRequestParams,
26+
RequestExecutionPermissionsResult,
27+
ProcessRequestExecutionPermissionsHook,
28+
} from './methods/wallet-request-execution-permissions';
29+
export type {
30+
ProcessRevokeExecutionPermissionHook,
31+
RevokeExecutionPermissionRequestParams,
32+
RevokeExecutionPermissionResult,
33+
} from './methods/wallet-revoke-execution-permission';
2434
export * from './providerAsMiddleware';
2535
export * from './retryOnEmpty';
2636
export * from './wallet';
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type {
2+
Json,
3+
JsonRpcRequest,
4+
PendingJsonRpcResponse,
5+
} from '@metamask/utils';
6+
import { klona } from 'klona';
7+
8+
import type {
9+
ProcessRequestExecutionPermissionsHook,
10+
RequestExecutionPermissionsRequestParams,
11+
RequestExecutionPermissionsResult,
12+
} from './wallet-request-execution-permissions';
13+
import { walletRequestExecutionPermissions } from './wallet-request-execution-permissions';
14+
15+
const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a';
16+
const CHAIN_ID_MOCK = '0x1';
17+
const CONTEXT_MOCK = '0x123abc';
18+
19+
const REQUEST_MOCK = {
20+
params: [
21+
{
22+
chainId: CHAIN_ID_MOCK,
23+
address: ADDRESS_MOCK,
24+
signer: {
25+
type: 'account',
26+
data: {
27+
address: ADDRESS_MOCK,
28+
},
29+
},
30+
permission: {
31+
type: 'test-permission',
32+
isAdjustmentAllowed: true,
33+
data: { key: 'value' },
34+
},
35+
rules: [
36+
{
37+
type: 'test-rule',
38+
isAdjustmentAllowed: false,
39+
data: { ruleKey: 'ruleValue' },
40+
},
41+
],
42+
},
43+
],
44+
} as unknown as JsonRpcRequest;
45+
46+
const RESULT_MOCK: RequestExecutionPermissionsResult = [
47+
{
48+
chainId: CHAIN_ID_MOCK,
49+
address: ADDRESS_MOCK,
50+
signer: {
51+
type: 'account',
52+
data: { address: ADDRESS_MOCK },
53+
},
54+
permission: {
55+
type: 'test-permission',
56+
isAdjustmentAllowed: true,
57+
data: { key: 'value' },
58+
},
59+
rules: [
60+
{
61+
type: 'test-rule',
62+
isAdjustmentAllowed: false,
63+
data: { ruleKey: 'ruleValue' },
64+
},
65+
],
66+
context: CONTEXT_MOCK,
67+
},
68+
];
69+
70+
describe('wallet_requestExecutionPermissions', () => {
71+
let request: JsonRpcRequest;
72+
let params: RequestExecutionPermissionsRequestParams;
73+
let response: PendingJsonRpcResponse<Json>;
74+
let processRequestExecutionPermissionsMock: jest.MockedFunction<ProcessRequestExecutionPermissionsHook>;
75+
76+
async function callMethod() {
77+
return walletRequestExecutionPermissions(request, response, {
78+
processRequestExecutionPermissions:
79+
processRequestExecutionPermissionsMock,
80+
});
81+
}
82+
83+
beforeEach(() => {
84+
jest.resetAllMocks();
85+
86+
request = klona(REQUEST_MOCK);
87+
params = request.params as RequestExecutionPermissionsRequestParams;
88+
response = {} as PendingJsonRpcResponse<Json>;
89+
90+
processRequestExecutionPermissionsMock = jest.fn();
91+
processRequestExecutionPermissionsMock.mockResolvedValue(RESULT_MOCK);
92+
});
93+
94+
it('calls hook', async () => {
95+
await callMethod();
96+
expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
97+
params,
98+
request,
99+
);
100+
});
101+
102+
it('returns result from hook', async () => {
103+
await callMethod();
104+
expect(response.result).toStrictEqual(RESULT_MOCK);
105+
});
106+
107+
it('supports null rules', async () => {
108+
params[0].rules = null as never;
109+
110+
await callMethod();
111+
112+
expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
113+
params,
114+
request,
115+
);
116+
});
117+
118+
it('supports optional address', async () => {
119+
params[0].address = undefined as never;
120+
121+
await callMethod();
122+
123+
expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
124+
params,
125+
request,
126+
);
127+
});
128+
129+
it('throws if no hook', async () => {
130+
await expect(
131+
walletRequestExecutionPermissions(request, response, {}),
132+
).rejects.toThrow(
133+
`wallet_requestExecutionPermissions - no middleware configured`,
134+
);
135+
});
136+
137+
it('throws if no params', async () => {
138+
request.params = undefined;
139+
140+
await expect(callMethod()).rejects.toThrow('Invalid params');
141+
});
142+
143+
it('throws if missing properties', async () => {
144+
params[0].chainId = undefined as never;
145+
params[0].signer = undefined as never;
146+
params[0].permission = undefined as never;
147+
148+
await expect(callMethod()).rejects.toThrow('Invalid params');
149+
});
150+
151+
it('throws if wrong types', async () => {
152+
params[0].chainId = 123 as never;
153+
params[0].address = 123 as never;
154+
params[0].permission = '123' as never;
155+
params[0].signer = {
156+
// Make signer an object but invalid to ensure object-type error messages are stable
157+
type: 123 as never,
158+
data: '123' as never,
159+
} as never;
160+
params[0].rules = [{} as never];
161+
162+
await expect(callMethod()).rejects.toThrow('Invalid params');
163+
});
164+
165+
it('throws if not hex', async () => {
166+
params[0].chainId = '123' as never;
167+
params[0].address = '123' as never;
168+
params[0].signer.data.address = '123' as never;
169+
170+
await expect(callMethod()).rejects.toThrow('Invalid params');
171+
});
172+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { rpcErrors } from '@metamask/rpc-errors';
2+
import type { Infer } from '@metamask/superstruct';
3+
import {
4+
array,
5+
boolean,
6+
literal,
7+
object,
8+
optional,
9+
record,
10+
string,
11+
union,
12+
unknown,
13+
} from '@metamask/superstruct';
14+
import {
15+
HexChecksumAddressStruct,
16+
type Hex,
17+
type Json,
18+
type JsonRpcRequest,
19+
type PendingJsonRpcResponse,
20+
StrictHexStruct,
21+
} from '@metamask/utils';
22+
23+
import { validateParams } from '../utils/validation';
24+
25+
const PermissionStruct = object({
26+
type: string(),
27+
isAdjustmentAllowed: boolean(),
28+
data: record(string(), unknown()),
29+
});
30+
31+
const RuleStruct = object({
32+
type: string(),
33+
isAdjustmentAllowed: boolean(),
34+
data: record(string(), unknown()),
35+
});
36+
37+
const AccountSignerStruct = object({
38+
type: literal('account'),
39+
data: object({
40+
address: HexChecksumAddressStruct,
41+
}),
42+
});
43+
44+
const PermissionRequestStruct = object({
45+
chainId: StrictHexStruct,
46+
address: optional(HexChecksumAddressStruct),
47+
signer: AccountSignerStruct,
48+
permission: PermissionStruct,
49+
rules: optional(union([array(RuleStruct), literal(null)])),
50+
});
51+
52+
export const RequestExecutionPermissionsStruct = array(PermissionRequestStruct);
53+
54+
// RequestExecutionPermissions API types
55+
export type RequestExecutionPermissionsRequestParams = Infer<
56+
typeof RequestExecutionPermissionsStruct
57+
>;
58+
59+
export type RequestExecutionPermissionsResult = Json &
60+
(Infer<typeof PermissionRequestStruct> & {
61+
context: Hex;
62+
})[];
63+
64+
export type ProcessRequestExecutionPermissionsHook = (
65+
request: RequestExecutionPermissionsRequestParams,
66+
req: JsonRpcRequest,
67+
) => Promise<RequestExecutionPermissionsResult>;
68+
69+
export async function walletRequestExecutionPermissions(
70+
req: JsonRpcRequest,
71+
res: PendingJsonRpcResponse<Json>,
72+
{
73+
processRequestExecutionPermissions,
74+
}: {
75+
processRequestExecutionPermissions?: ProcessRequestExecutionPermissionsHook;
76+
},
77+
): Promise<void> {
78+
if (!processRequestExecutionPermissions) {
79+
throw rpcErrors.methodNotSupported(
80+
'wallet_requestExecutionPermissions - no middleware configured',
81+
);
82+
}
83+
84+
const { params } = req;
85+
86+
validateParams(params, RequestExecutionPermissionsStruct);
87+
88+
res.result = await processRequestExecutionPermissions(params, req);
89+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type {
2+
Json,
3+
JsonRpcRequest,
4+
PendingJsonRpcResponse,
5+
} from '@metamask/utils';
6+
import { klona } from 'klona';
7+
8+
import type {
9+
ProcessRevokeExecutionPermissionHook,
10+
RevokeExecutionPermissionRequestParams,
11+
} from './wallet-revoke-execution-permission';
12+
import { walletRevokeExecutionPermission } from './wallet-revoke-execution-permission';
13+
14+
const HEX_MOCK = '0x123abc';
15+
16+
const REQUEST_MOCK = {
17+
params: {
18+
permissionContext: HEX_MOCK,
19+
},
20+
} as unknown as JsonRpcRequest<RevokeExecutionPermissionRequestParams>;
21+
22+
describe('wallet_revokeExecutionPermission', () => {
23+
let request: JsonRpcRequest<RevokeExecutionPermissionRequestParams>;
24+
let params: RevokeExecutionPermissionRequestParams;
25+
let response: PendingJsonRpcResponse<Json>;
26+
let processRevokeExecutionPermissionMock: jest.MockedFunction<ProcessRevokeExecutionPermissionHook>;
27+
28+
async function callMethod() {
29+
return walletRevokeExecutionPermission(request, response, {
30+
processRevokeExecutionPermission: processRevokeExecutionPermissionMock,
31+
});
32+
}
33+
34+
beforeEach(() => {
35+
jest.resetAllMocks();
36+
37+
request = klona(REQUEST_MOCK);
38+
params = request.params as RevokeExecutionPermissionRequestParams;
39+
response = {} as PendingJsonRpcResponse<Json>;
40+
41+
processRevokeExecutionPermissionMock = jest.fn();
42+
processRevokeExecutionPermissionMock.mockResolvedValue({});
43+
});
44+
45+
it('calls hook', async () => {
46+
await callMethod();
47+
expect(processRevokeExecutionPermissionMock).toHaveBeenCalledWith(
48+
params,
49+
request,
50+
);
51+
});
52+
53+
it('returns result from hook', async () => {
54+
await callMethod();
55+
expect(response.result).toStrictEqual({});
56+
});
57+
58+
it('throws if no hook', async () => {
59+
await expect(
60+
walletRevokeExecutionPermission(request, response, {}),
61+
).rejects.toThrow(
62+
'wallet_revokeExecutionPermission - no middleware configured',
63+
);
64+
});
65+
66+
it('throws if no params', async () => {
67+
(request as JsonRpcRequest).params = undefined;
68+
69+
await expect(callMethod()).rejects.toThrow('Invalid params');
70+
});
71+
72+
it('throws if missing properties', async () => {
73+
(request as JsonRpcRequest).params = {} as never;
74+
75+
await expect(callMethod()).rejects.toThrow('Invalid params');
76+
});
77+
78+
it('throws if wrong types', async () => {
79+
params.permissionContext = 123 as never;
80+
81+
await expect(callMethod()).rejects.toThrow('Invalid params');
82+
});
83+
84+
it('throws if not hex', async () => {
85+
params.permissionContext = '123' as never;
86+
87+
await expect(callMethod()).rejects.toThrow('Invalid params');
88+
});
89+
});

0 commit comments

Comments
 (0)