Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export type {
SendCallsParams,
SendCallsResult,
} from './methods/wallet-send-calls';
export type {
RequestExecutionPermissionsRequestParams,
RequestExecutionPermissionsResult,
ProcessRequestExecutionPermissionsHook,
} from './methods/wallet-request-execution-permissions';
export type {
ProcessRevokeExecutionPermissionHook,
RevokeExecutionPermissionsRequestParams,
RevokeExecutionPermissionsResult,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these two type names be singular to stay consistent with the singular form of wallet_revokeExecutionPermission?

Copy link
Contributor Author

@jeffsmale90 jeffsmale90 Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are absolutely correct!

} from './methods/wallet-revoke-execution-permission';
export * from './providerAsMiddleware';
export * from './retryOnEmpty';
export * from './wallet';
201 changes: 201 additions & 0 deletions src/methods/wallet-request-execution-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import type {
Json,
JsonRpcRequest,
PendingJsonRpcResponse,
} from '@metamask/utils';
import { klona } from 'klona';

import type {
ProcessRequestExecutionPermissionsHook,
RequestExecutionPermissionsRequestParams,
RequestExecutionPermissionsResult,
} from './wallet-request-execution-permissions';
import { walletRequestExecutionPermissions } from './wallet-request-execution-permissions';

const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a';
const CHAIN_ID_MOCK = '0x1';
const CONTEXT_MOCK = '0x123abc';

const REQUEST_MOCK = {
params: [
{
chainId: CHAIN_ID_MOCK,
address: ADDRESS_MOCK,
signer: {
type: 'account',
data: {
address: ADDRESS_MOCK,
},
},
permission: {
type: 'test-permission',
isAdjustmentAllowed: true,
data: { key: 'value' },
},
rules: [
{
type: 'test-rule',
isAdjustmentAllowed: false,
data: { ruleKey: 'ruleValue' },
},
],
},
],
} as unknown as JsonRpcRequest;

const RESULT_MOCK: RequestExecutionPermissionsResult = [
{
chainId: CHAIN_ID_MOCK,
address: ADDRESS_MOCK,
signer: {
type: 'account',
data: { address: ADDRESS_MOCK },
},
permission: {
type: 'test-permission',
isAdjustmentAllowed: true,
data: { key: 'value' },
},
rules: [
{
type: 'test-rule',
isAdjustmentAllowed: false,
data: { ruleKey: 'ruleValue' },
},
],
context: CONTEXT_MOCK,
},
];

describe('wallet_requestExecutionPermissions', () => {
let request: JsonRpcRequest;
let params: RequestExecutionPermissionsRequestParams;
let response: PendingJsonRpcResponse<Json>;
let processRequestExecutionPermissionsMock: jest.MockedFunction<ProcessRequestExecutionPermissionsHook>;

async function callMethod() {
return walletRequestExecutionPermissions(request, response, {
processRequestExecutionPermissions:
processRequestExecutionPermissionsMock,
});
}

beforeEach(() => {
jest.resetAllMocks();

request = klona(REQUEST_MOCK);
params = request.params as RequestExecutionPermissionsRequestParams;
response = {} as PendingJsonRpcResponse<Json>;

processRequestExecutionPermissionsMock = jest.fn();
processRequestExecutionPermissionsMock.mockResolvedValue(RESULT_MOCK);
});

it('calls hook', async () => {
await callMethod();
expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
params,
request,
);
});

it('returns result from hook', async () => {
await callMethod();
expect(response.result).toStrictEqual(RESULT_MOCK);
});

it('supports null rules', async () => {
params[0].rules = null as never;

await callMethod();

expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
params,
request,
);
});

it('supports optional address', async () => {
params[0].address = undefined as never;

await callMethod();

expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
params,
request,
);
});

it('throws if no hook', async () => {
await expect(
walletRequestExecutionPermissions(request, response, {}),
).rejects.toMatchInlineSnapshot(
`[Error: wallet_requestExecutionPermissions - no middleware configured]`,
);
});

it('throws if no params', async () => {
request.params = undefined;

await expect(callMethod()).rejects.toMatchInlineSnapshot(`
[Error: Invalid params

Expected an array value, but received: undefined]
`);
});

it('throws if missing properties', async () => {
params[0].chainId = undefined as never;
params[0].signer = undefined as never;
params[0].permission = undefined as never;

await expect(callMethod()).rejects.toMatchInlineSnapshot(`
[Error: Invalid params

0 > chainId - Expected a string, but received: undefined
0 > signer - Expected an object, but received: undefined
0 > permission - Expected an object, but received: undefined]
`);
});

it('throws if wrong types', async () => {
params[0].chainId = 123 as never;
params[0].address = 123 as never;
params[0].permission = '123' as never;
params[0].signer = {
// Make signer an object but invalid to ensure object-type error messages are stable
type: 123 as never,
data: '123' as never,
} as never;
params[0].rules = [{} as never];

await expect(callMethod()).rejects.toMatchInlineSnapshot(`
[Error: Invalid params

0 > chainId - Expected a string, but received: 123
0 > address - Expected a string, but received: 123
0 > signer > type - Expected the literal \`"account"\`, but received: 123
0 > signer > data - Expected an object, but received: "123"
0 > permission - Expected an object, but received: "123"
0 > rules - Expected the value to satisfy a union of \`array | literal\`, but received: [object Object]
0 > rules > 0 > type - Expected a string, but received: undefined
0 > rules > 0 > isAdjustmentAllowed - Expected a value of type \`boolean\`, but received: \`undefined\`
0 > rules > 0 > data - Expected an object, but received: undefined
0 > rules - Expected the literal \`null\`, but received: [object Object]]
`);
});

it('throws if not hex', async () => {
params[0].chainId = '123' as never;
params[0].address = '123' as never;
params[0].signer.data.address = '123' as never;

await expect(callMethod()).rejects.toMatchInlineSnapshot(`
[Error: Invalid params

0 > chainId - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"
0 > address - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123"
0 > signer > data > address - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123"]
`);
});
});
89 changes: 89 additions & 0 deletions src/methods/wallet-request-execution-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { rpcErrors } from '@metamask/rpc-errors';
import type { Infer } from '@metamask/superstruct';
import {
array,
boolean,
literal,
object,
optional,
record,
string,
union,
unknown,
} from '@metamask/superstruct';
import {
HexChecksumAddressStruct,
type Hex,
type Json,
type JsonRpcRequest,
type PendingJsonRpcResponse,
StrictHexStruct,
} from '@metamask/utils';

import { validateParams } from '../utils/validation';

const PermissionStruct = object({
type: string(),
isAdjustmentAllowed: boolean(),
data: record(string(), unknown()),
});

const RuleStruct = object({
type: string(),
isAdjustmentAllowed: boolean(),
data: record(string(), unknown()),
});

const AccountSignerStruct = object({
type: literal('account'),
data: object({
address: HexChecksumAddressStruct,
}),
});

const PermissionRequestStruct = object({
chainId: StrictHexStruct,
address: optional(HexChecksumAddressStruct),
signer: AccountSignerStruct,
permission: PermissionStruct,
rules: optional(union([array(RuleStruct), literal(null)])),
});

export const RequestExecutionPermissionsStruct = array(PermissionRequestStruct);

// RequestExecutionPermissions API types
export type RequestExecutionPermissionsRequestParams = Infer<
typeof RequestExecutionPermissionsStruct
>;

export type RequestExecutionPermissionsResult = Json &
(Infer<typeof PermissionRequestStruct> & {
context: Hex;
})[];

export type ProcessRequestExecutionPermissionsHook = (
request: RequestExecutionPermissionsRequestParams,
req: JsonRpcRequest,
) => Promise<RequestExecutionPermissionsResult>;

export async function walletRequestExecutionPermissions(
req: JsonRpcRequest,
res: PendingJsonRpcResponse<Json>,
{
processRequestExecutionPermissions,
}: {
processRequestExecutionPermissions?: ProcessRequestExecutionPermissionsHook;
},
): Promise<void> {
if (!processRequestExecutionPermissions) {
throw rpcErrors.resourceNotFound(
'wallet_requestExecutionPermissions - no middleware configured',
);
}

const { params } = req;

validateParams(params, RequestExecutionPermissionsStruct);

res.result = await processRequestExecutionPermissions(params, req);
}
Comment on lines +88 to +89
Copy link
Contributor

@adonesky1 adonesky1 Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you'll need to pass end in as a param as well for the JSON RPC Engine to handle this gracefully

Suggested change
res.result = await processRequestExecutionPermissions(params, req);
}
res.result = await processRequestExecutionPermissions(params, req);
return end()
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess they aren't doing this in the 5792 methods you used as a model. Need to figure out why that's ok 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From @metamask/json-rpc-engine - createAsyncMiddleware:

* Async middleware have no "end" function. Instead, they "end" if they return
* without calling "next". Rather than passing in explicit return handlers,
* async middleware can simply await "next", and perform operations on the
* response object when execution resumes.

The async middleware function accepts a next parameter, but no end parameter. 😅

Loading
Loading