Skip to content

Commit f212564

Browse files
feat: support EIP-5792 methods (#359)
Support the EIP-5792 methods: - `wallet_sendCalls` - `wallet_getCallsStatus` - `wallet_getCapabilities` Specifically: - Modularise new method support into separate files under `src/methods`. - As opposed to continually extending `src/wallet.ts`. - Use `@metamask/superstruct` to easily validate request parameters against a schema / struct. - Move common validation functions to new `src/validation.ts`. - Export new types inferred from structs. - Specifically as opposed to `*` for easier API diffs. An additional benefit of using `@metamask/superstruct` is including multiple validation errors within a single RPC error: ``` Error: Invalid params 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" 0 > chainId - Expected a string, but received: 123 0 > calls - Expected an array value, but received: "123" ```
1 parent fb247ec commit f212564

12 files changed

+870
-32
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@metamask/eth-sig-util": "^8.1.2",
3535
"@metamask/json-rpc-engine": "^10.0.2",
3636
"@metamask/rpc-errors": "^7.0.2",
37+
"@metamask/superstruct": "^3.1.0",
3738
"@metamask/utils": "^11.1.0",
3839
"@types/bn.js": "^5.1.5",
3940
"bn.js": "^5.2.1",

src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ export * from './block-ref';
44
export * from './block-tracker-inspector';
55
export * from './fetch';
66
export * from './inflight-cache';
7+
export type {
8+
GetCallsStatusParams,
9+
GetCallsStatusReceipt,
10+
GetCallsStatusResult,
11+
GetTransactionReceiptsByBatchIdHook,
12+
} from './methods/wallet-get-calls-status';
13+
export type {
14+
GetCapabilitiesHook,
15+
GetCapabilitiesParams,
16+
GetCapabilitiesResult,
17+
} from './methods/wallet-get-capabilities';
18+
export type {
19+
ProcessSendCallsHook,
20+
SendCalls,
21+
SendCallsParams,
22+
} from './methods/wallet-send-calls';
723
export * from './providerAsMiddleware';
824
export * from './retryOnEmpty';
925
export * from './wallet';
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils';
2+
import { klona } from 'klona';
3+
4+
import type {
5+
GetCallsStatusParams,
6+
GetCallsStatusResult,
7+
GetTransactionReceiptsByBatchIdHook,
8+
} from './wallet-get-calls-status';
9+
import { walletGetCallsStatus } from './wallet-get-calls-status';
10+
11+
const ID_MOCK = '1234-5678';
12+
13+
const RECEIPT_MOCK = {
14+
logs: [
15+
{
16+
address: '0x123abc123abc123abc123abc123abc123abc123a',
17+
data: '0x123abc',
18+
topics: ['0x123abc'],
19+
},
20+
],
21+
status: '0x1',
22+
chainId: '0x1',
23+
blockHash: '0x123abc',
24+
blockNumber: '0x1',
25+
gasUsed: '0x1',
26+
transactionHash: '0x123abc',
27+
};
28+
29+
const REQUEST_MOCK = {
30+
params: [ID_MOCK],
31+
} as unknown as JsonRpcRequest<GetCallsStatusParams>;
32+
33+
describe('wallet_getCallsStatus', () => {
34+
let request: JsonRpcRequest<GetCallsStatusParams>;
35+
let params: GetCallsStatusParams;
36+
let response: PendingJsonRpcResponse<GetCallsStatusResult>;
37+
let getTransactionReceiptsByBatchIdMock: jest.MockedFunction<GetTransactionReceiptsByBatchIdHook>;
38+
39+
async function callMethod() {
40+
return walletGetCallsStatus(request, response, {
41+
getTransactionReceiptsByBatchId: getTransactionReceiptsByBatchIdMock,
42+
});
43+
}
44+
45+
beforeEach(() => {
46+
jest.resetAllMocks();
47+
48+
request = klona(REQUEST_MOCK);
49+
params = request.params as GetCallsStatusParams;
50+
response = {} as PendingJsonRpcResponse<GetCallsStatusResult>;
51+
52+
getTransactionReceiptsByBatchIdMock = jest
53+
.fn()
54+
.mockResolvedValue([RECEIPT_MOCK, RECEIPT_MOCK]);
55+
});
56+
57+
it('calls hook', async () => {
58+
await callMethod();
59+
expect(getTransactionReceiptsByBatchIdMock).toHaveBeenCalledWith(
60+
params[0],
61+
request,
62+
);
63+
});
64+
65+
it('returns confirmed status if all receipts available', async () => {
66+
await callMethod();
67+
expect(response.result?.status).toBe('CONFIRMED');
68+
});
69+
70+
it('returns pending status if missing receipts', async () => {
71+
getTransactionReceiptsByBatchIdMock = jest
72+
.fn()
73+
.mockResolvedValue([RECEIPT_MOCK, undefined]);
74+
75+
await callMethod();
76+
expect(response.result?.status).toBe('PENDING');
77+
expect(response.result?.receipts).toBeNull();
78+
});
79+
80+
it('returns receipts', async () => {
81+
await callMethod();
82+
83+
expect(response.result?.receipts).toStrictEqual([
84+
RECEIPT_MOCK,
85+
RECEIPT_MOCK,
86+
]);
87+
});
88+
89+
it('returns null if no receipts', async () => {
90+
getTransactionReceiptsByBatchIdMock = jest.fn().mockResolvedValue([]);
91+
92+
await callMethod();
93+
expect(response.result).toBeNull();
94+
});
95+
96+
it('throws if no hook', async () => {
97+
await expect(
98+
walletGetCallsStatus(request, response, {}),
99+
).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`);
100+
});
101+
102+
it('throws if no params', async () => {
103+
request.params = undefined;
104+
105+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
106+
[Error: Invalid params
107+
108+
Expected an array, but received: undefined]
109+
`);
110+
});
111+
112+
it('throws if wrong type', async () => {
113+
params[0] = 123 as never;
114+
115+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
116+
[Error: Invalid params
117+
118+
0 - Expected a string, but received: 123]
119+
`);
120+
});
121+
122+
it('throws if empty', async () => {
123+
params[0] = '';
124+
125+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
126+
[Error: Invalid params
127+
128+
0 - Expected a nonempty string but received an empty one]
129+
`);
130+
});
131+
132+
it('removes excess properties from receipts', async () => {
133+
getTransactionReceiptsByBatchIdMock.mockResolvedValue([
134+
{
135+
...RECEIPT_MOCK,
136+
extra: 'value1',
137+
logs: [{ ...RECEIPT_MOCK.logs[0], extra2: 'value2' }],
138+
} as never,
139+
]);
140+
141+
await callMethod();
142+
143+
expect(response.result?.receipts).toStrictEqual([RECEIPT_MOCK]);
144+
});
145+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { rpcErrors } from '@metamask/rpc-errors';
2+
import type { Infer } from '@metamask/superstruct';
3+
import {
4+
nonempty,
5+
optional,
6+
mask,
7+
string,
8+
array,
9+
object,
10+
tuple,
11+
} from '@metamask/superstruct';
12+
import type {
13+
Json,
14+
JsonRpcRequest,
15+
PendingJsonRpcResponse,
16+
} from '@metamask/utils';
17+
import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils';
18+
19+
import { validateParams } from '../utils/validation';
20+
21+
const GetCallsStatusStruct = tuple([nonempty(string())]);
22+
23+
const GetCallsStatusReceiptStruct = object({
24+
logs: optional(
25+
array(
26+
object({
27+
address: optional(HexChecksumAddressStruct),
28+
data: optional(StrictHexStruct),
29+
topics: optional(array(StrictHexStruct)),
30+
}),
31+
),
32+
),
33+
status: optional(StrictHexStruct),
34+
chainId: optional(StrictHexStruct),
35+
blockHash: optional(StrictHexStruct),
36+
blockNumber: optional(StrictHexStruct),
37+
gasUsed: optional(StrictHexStruct),
38+
transactionHash: optional(StrictHexStruct),
39+
});
40+
41+
export type GetCallsStatusParams = Infer<typeof GetCallsStatusStruct>;
42+
export type GetCallsStatusReceipt = Infer<typeof GetCallsStatusReceiptStruct>;
43+
44+
export type GetCallsStatusResult = {
45+
status: 'PENDING' | 'CONFIRMED';
46+
receipts?: GetCallsStatusReceipt[];
47+
};
48+
49+
export type GetTransactionReceiptsByBatchIdHook = (
50+
batchId: string,
51+
req: JsonRpcRequest,
52+
) => Promise<GetCallsStatusReceipt[]>;
53+
54+
export async function walletGetCallsStatus(
55+
req: JsonRpcRequest,
56+
res: PendingJsonRpcResponse<Json>,
57+
{
58+
getTransactionReceiptsByBatchId,
59+
}: {
60+
getTransactionReceiptsByBatchId?: GetTransactionReceiptsByBatchIdHook;
61+
},
62+
): Promise<void> {
63+
if (!getTransactionReceiptsByBatchId) {
64+
throw rpcErrors.methodNotSupported();
65+
}
66+
67+
validateParams(req.params, GetCallsStatusStruct);
68+
69+
const batchId = req.params[0];
70+
const rawReceipts = await getTransactionReceiptsByBatchId(batchId, req);
71+
72+
if (!rawReceipts.length) {
73+
res.result = null;
74+
return;
75+
}
76+
77+
const isComplete = rawReceipts.every((receipt) => Boolean(receipt));
78+
const status = isComplete ? 'CONFIRMED' : 'PENDING';
79+
80+
const receipts = isComplete
81+
? rawReceipts.map((receipt) => mask(receipt, GetCallsStatusReceiptStruct))
82+
: null;
83+
84+
res.result = { status, receipts };
85+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils';
2+
import { klona } from 'klona';
3+
4+
import type {
5+
GetCapabilitiesHook,
6+
GetCapabilitiesParams,
7+
GetCapabilitiesResult,
8+
} from './wallet-get-capabilities';
9+
import { walletGetCapabilities } from './wallet-get-capabilities';
10+
11+
const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a';
12+
13+
const RESULT_MOCK = {
14+
testCapability: {
15+
testKey: 'testValue',
16+
},
17+
};
18+
19+
const REQUEST_MOCK = {
20+
params: [ADDRESS_MOCK],
21+
} as unknown as JsonRpcRequest<GetCapabilitiesParams>;
22+
23+
describe('wallet_getCapabilities', () => {
24+
let request: JsonRpcRequest<GetCapabilitiesParams>;
25+
let params: GetCapabilitiesParams;
26+
let response: PendingJsonRpcResponse<GetCapabilitiesResult>;
27+
let getCapabilitiesMock: jest.MockedFunction<GetCapabilitiesHook>;
28+
29+
async function callMethod() {
30+
return walletGetCapabilities(request, response, {
31+
getCapabilities: getCapabilitiesMock,
32+
});
33+
}
34+
35+
beforeEach(() => {
36+
jest.resetAllMocks();
37+
38+
request = klona(REQUEST_MOCK);
39+
params = request.params as GetCapabilitiesParams;
40+
response = {} as PendingJsonRpcResponse<GetCapabilitiesResult>;
41+
42+
getCapabilitiesMock = jest.fn().mockResolvedValue(RESULT_MOCK);
43+
});
44+
45+
it('calls hook', async () => {
46+
await callMethod();
47+
expect(getCapabilitiesMock).toHaveBeenCalledWith(params[0], request);
48+
});
49+
50+
it('returns capabilities from hook', async () => {
51+
await callMethod();
52+
53+
expect(response.result).toStrictEqual(RESULT_MOCK);
54+
});
55+
56+
it('throws if no hook', async () => {
57+
await expect(
58+
walletGetCapabilities(request, response, {}),
59+
).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`);
60+
});
61+
62+
it('throws if no params', async () => {
63+
request.params = undefined;
64+
65+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
66+
[Error: Invalid params
67+
68+
Expected an array, but received: undefined]
69+
`);
70+
});
71+
72+
it('throws if wrong type', async () => {
73+
params[0] = 123 as never;
74+
75+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
76+
[Error: Invalid params
77+
78+
0 - Expected a string, but received: 123]
79+
`);
80+
});
81+
82+
it('throws if not hex', async () => {
83+
params[0] = 'test' as never;
84+
85+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
86+
[Error: Invalid params
87+
88+
0 - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "test"]
89+
`);
90+
});
91+
92+
it('throws if wrong length', async () => {
93+
params[0] = '0x123' as never;
94+
95+
await expect(callMethod()).rejects.toMatchInlineSnapshot(`
96+
[Error: Invalid params
97+
98+
0 - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"]
99+
`);
100+
});
101+
});

0 commit comments

Comments
 (0)