Skip to content

Commit f339980

Browse files
committed
Add wallet_requestExecutionPermissions and wallet_revokeExecutionPermission to support EIP-7715
1 parent d2578c5 commit f339980

6 files changed

+479
-0
lines changed

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+
RevokeExecutionPermissionsRequestParams,
32+
RevokeExecutionPermissionsResult,
33+
} from './methods/wallet-revoke-execution-permission';
2434
export * from './providerAsMiddleware';
2535
export * from './retryOnEmpty';
2636
export * from './wallet';
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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.toMatchInlineSnapshot(
133+
`[Error: 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.toMatchInlineSnapshot(`
141+
[Error: Invalid params
142+
143+
Expected an array value, but received: undefined]
144+
`);
145+
});
146+
147+
it('throws if missing properties', async () => {
148+
params[0].chainId = undefined as never;
149+
params[0].signer = undefined as never;
150+
params[0].permission = undefined as never;
151+
152+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
153+
[Error: Invalid params
154+
155+
0 > chainId - Expected a string, but received: undefined
156+
0 > signer - Expected an object, but received: undefined
157+
0 > permission - Expected an object, but received: undefined]
158+
`);
159+
});
160+
161+
it('throws if wrong types', async () => {
162+
params[0].chainId = 123 as never;
163+
params[0].address = 123 as never;
164+
params[0].permission = '123' as never;
165+
params[0].signer = {
166+
// Make signer an object but invalid to ensure object-type error messages are stable
167+
type: 123 as never,
168+
data: '123' as never,
169+
} as never;
170+
params[0].rules = [{} as never];
171+
172+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
173+
[Error: Invalid params
174+
175+
0 > chainId - Expected a string, but received: 123
176+
0 > address - Expected a string, but received: 123
177+
0 > signer > type - Expected the literal \`"account"\`, but received: 123
178+
0 > signer > data - Expected an object, but received: "123"
179+
0 > permission - Expected an object, but received: "123"
180+
0 > rules - Expected the value to satisfy a union of \`array | literal\`, but received: [object Object]
181+
0 > rules > 0 > type - Expected a string, but received: undefined
182+
0 > rules > 0 > isAdjustmentAllowed - Expected a value of type \`boolean\`, but received: \`undefined\`
183+
0 > rules > 0 > data - Expected an object, but received: undefined
184+
0 > rules - Expected the literal \`null\`, but received: [object Object]]
185+
`);
186+
});
187+
188+
it('throws if not hex', async () => {
189+
params[0].chainId = '123' as never;
190+
params[0].address = '123' as never;
191+
params[0].signer.data.address = '123' as never;
192+
193+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
194+
[Error: Invalid params
195+
196+
0 > chainId - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"
197+
0 > address - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123"
198+
0 > signer > data > address - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123"]
199+
`);
200+
});
201+
});
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.resourceNotFound(
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+
}

0 commit comments

Comments
 (0)