Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/eip-5792-middleware/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Bump `@metamask/transaction-controller` from `^62.7.0` to `^62.13.0` ([#7596](https://github.com/MetaMask/core/pull/7596), [#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642), [#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802))
- Bump `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511))
- Make `wallet_sendCalls` more reliable ([#7816](https://github.com/MetaMask/core/pull/7816))

## [2.1.0]

Expand Down
28 changes: 21 additions & 7 deletions packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
CustomNetworkClientConfiguration,
NetworkControllerGetNetworkClientByIdAction,
} from '@metamask/network-controller';
import { providerErrors } from '@metamask/rpc-errors';
import type { TransactionController } from '@metamask/transaction-controller';
import type { Hex, JsonRpcRequest } from '@metamask/utils';

Expand Down Expand Up @@ -89,6 +90,9 @@ describe('EIP-5792', () => {

const isAuxiliaryFundsSupportedMock: jest.Mock = jest.fn();

const getPermittedAccountsForOriginMock: jest.MockedFn<() => Promise<Hex[]>> =
jest.fn();

let rootMessenger: RootMessenger;

let messenger: Messenger<'EIP5792', AllActions, never, RootMessenger>;
Expand All @@ -100,6 +104,7 @@ describe('EIP-5792', () => {
getDismissSmartAccountSuggestionEnabledMock,
isAtomicBatchSupported: isAtomicBatchSupportedMock,
validateSecurity: validateSecurityMock,
getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock,
isAuxiliaryFundsSupported: isAuxiliaryFundsSupportedMock,
};

Expand All @@ -115,11 +120,6 @@ describe('EIP-5792', () => {
getNetworkClientByIdMock,
);

rootMessenger.registerActionHandler(
'AccountsController:getSelectedAccount',
getSelectedAccountMock,
);

rootMessenger.registerActionHandler(
'AccountsController:getState',
getAccountsStateMock,
Expand All @@ -134,7 +134,6 @@ describe('EIP-5792', () => {
messenger,
actions: [
'AccountsController:getState',
'AccountsController:getSelectedAccount',
'PreferencesController:getState',
'NetworkController:getNetworkClientById',
'NetworkController:getState',
Expand All @@ -156,6 +155,8 @@ describe('EIP-5792', () => {

isAuxiliaryFundsSupportedMock.mockReturnValue(true);

getPermittedAccountsForOriginMock.mockResolvedValue([FROM_MOCK] as Hex[]);

isAtomicBatchSupportedMock.mockResolvedValue([
{
chainId: CHAIN_ID_MOCK,
Expand Down Expand Up @@ -258,7 +259,7 @@ describe('EIP-5792', () => {
expect(addTransactionBatchMock).toHaveBeenCalledTimes(1);
});

it('calls adds transaction batch hook with selected account if no from', async () => {
it('calls adds transaction batch hook with selected permitted account if no `from` param is provided', async () => {
getSelectedAccountMock.mockReturnValue({
address: SEND_CALLS_MOCK.from,
} as InternalAccount);
Expand Down Expand Up @@ -467,6 +468,19 @@ describe('EIP-5792', () => {
);
});

it('throws if no `from` param is provided and no accounts are returned by `getPermittedAccountsForOrigin`', async () => {
getPermittedAccountsForOriginMock.mockResolvedValueOnce([]);

await expect(
processSendCalls(
sendCallsHooks,
messenger,
{ ...SEND_CALLS_MOCK, from: undefined },
REQUEST_MOCK,
),
).rejects.toThrow(providerErrors.unauthorized());
});

it('validates auxiliary funds with unsupported account type', async () => {
await expect(
processSendCalls(
Expand Down
14 changes: 10 additions & 4 deletions packages/eip-5792-middleware/src/hooks/processSendCalls.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { KeyringTypes } from '@metamask/keyring-controller';
import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
import { JsonRpcError, providerErrors, rpcErrors } from '@metamask/rpc-errors';
import type {
BatchTransactionParams,
IsAtomicBatchSupportedResultEntry,
Expand Down Expand Up @@ -47,6 +47,7 @@ export type ProcessSendCallsHooks = {
request: ValidateSecurityRequest,
chainId: Hex,
) => Promise<void>;
getPermittedAccountsForOrigin: () => Promise<Hex[]>;
/** Function to validate if auxiliary funds capability is supported. */
isAuxiliaryFundsSupported: (chainId: Hex) => boolean;
};
Expand Down Expand Up @@ -82,6 +83,7 @@ export async function processSendCalls(
getDismissSmartAccountSuggestionEnabled,
isAtomicBatchSupported,
validateSecurity: validateSecurityHook,
getPermittedAccountsForOrigin,
isAuxiliaryFundsSupported,
} = hooks;

Expand All @@ -94,9 +96,13 @@ export async function processSendCalls(
networkClientId,
).configuration;

const from =
paramFrom ??
(messenger.call('AccountsController:getSelectedAccount').address as Hex);
// The first account returned by `getPermittedAccountsForOrigin` is the selected account for the origin
const [selectedAccount] = await getPermittedAccountsForOrigin();
const from = paramFrom ?? selectedAccount;

Copy link

Choose a reason for hiding this comment

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

Sender selection relies on account ordering

High Severity

When from is omitted, processSendCalls uses the first element returned by getPermittedAccountsForOrigin() as the selected account. The hook’s contract only says “permitted accounts”, so if ordering is not guaranteed, the chosen sender can be the wrong permitted account.

Fix in Cursor Fix in Web

if (!from) {
throw providerErrors.unauthorized();
}
Copy link

Choose a reason for hiding this comment

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

Unneeded accounts lookup can break sendCalls

Medium Severity

processSendCalls now always awaits getPermittedAccountsForOrigin() even when params.from is provided. This introduces a new failure point and latency for requests that previously did not need any accounts lookup, so transient hook errors can now reject otherwise-valid wallet_sendCalls requests.

Fix in Cursor Fix in Web


const securityAlertId = uuid();
const validateSecurity = validateSecurityHook.bind(null, securityAlertId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import type {
GetCapabilitiesResult,
} from '../types';

type GetAccounts = (req: JsonRpcRequest) => Promise<string[]>;
type GetPermittedAccountsForOrigin = () => Promise<string[]>;

const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a';
const CHAIN_ID_MOCK = '0x1';
const CHAIN_ID_2_MOCK = '0x2';
const ORIGIN_MOCK = 'https://example.com';

const RESULT_MOCK = {
testCapability: {
Expand All @@ -21,14 +22,15 @@ const RESULT_MOCK = {
};

const REQUEST_MOCK = {
origin: ORIGIN_MOCK,
params: [ADDRESS_MOCK],
};

describe('wallet_getCapabilities', () => {
let request: JsonRpcRequest;
let request: JsonRpcRequest & { origin: string };
let params: GetCapabilitiesParams;
let response: PendingJsonRpcResponse<GetCapabilitiesResult>;
let getAccountsMock: jest.MockedFn<GetAccounts>;
let getPermittedAccountsForOriginMock: jest.MockedFn<GetPermittedAccountsForOrigin>;
let getCapabilitiesMock: jest.MockedFunction<GetCapabilitiesHook>;

/**
Expand All @@ -37,19 +39,21 @@ describe('wallet_getCapabilities', () => {
*/
async function callMethod() {
return walletGetCapabilities(request, response, {
getAccounts: getAccountsMock,
getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock,
getCapabilities: getCapabilitiesMock,
});
}

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

request = klona(REQUEST_MOCK) as JsonRpcRequest;
request = klona(REQUEST_MOCK) as JsonRpcRequest & { origin: string };
params = request.params as GetCapabilitiesParams;
response = {} as PendingJsonRpcResponse<GetCapabilitiesResult>;

getAccountsMock = jest.fn().mockResolvedValue([ADDRESS_MOCK]);
getPermittedAccountsForOriginMock = jest
.fn()
.mockResolvedValue([ADDRESS_MOCK]);
getCapabilitiesMock = jest.fn().mockResolvedValue(RESULT_MOCK);
});

Expand Down Expand Up @@ -82,7 +86,7 @@ describe('wallet_getCapabilities', () => {
it('throws if no hook', async () => {
await expect(
walletGetCapabilities(request, response, {
getAccounts: getAccountsMock,
getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock,
}),
).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`);
});
Expand Down Expand Up @@ -128,7 +132,7 @@ describe('wallet_getCapabilities', () => {
});

it('throws if from is not in accounts', async () => {
getAccountsMock.mockResolvedValueOnce([]);
getPermittedAccountsForOriginMock.mockResolvedValueOnce([]);

await expect(callMethod()).rejects.toMatchInlineSnapshot(
`[Error: The requested account and/or method has not been authorized by the user.]`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ import { validateAndNormalizeKeyholder, validateParams } from '../utils';
* @param req - The JSON RPC request's end callback.
* @param res - The JSON RPC request's pending response object.
* @param hooks - The hooks object.
* @param hooks.getAccounts - Function that retrieves available accounts.
* @param hooks.getPermittedAccountsForOrigin - Function that retrieves permitted accounts for the requester's origin.
* @param hooks.getCapabilities - Function that retrieves the capabilities for atomic transactions on specified chains.
*/
export async function walletGetCapabilities(
req: JsonRpcRequest,
req: JsonRpcRequest & { origin: string },
res: PendingJsonRpcResponse,
{
getAccounts,
getPermittedAccountsForOrigin,
getCapabilities,
}: {
getAccounts: (req: JsonRpcRequest) => Promise<string[]>;
getPermittedAccountsForOrigin: () => Promise<string[]>;
getCapabilities?: GetCapabilitiesHook;
},
): Promise<void> {
Expand All @@ -34,8 +34,8 @@ export async function walletGetCapabilities(
const address = req.params[0];
const chainIds = req.params[1];

await validateAndNormalizeKeyholder(address, req, {
getAccounts,
await validateAndNormalizeKeyholder(address, {
getPermittedAccountsForOrigin,
});

const capabilities = await getCapabilities(address, chainIds, req);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import type {
SendCallsParams,
} from '../types';

type GetAccounts = (req: JsonRpcRequest) => Promise<string[]>;
type GetPermittedAccountsForOrigin = () => Promise<string[]>;

const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a';
const HEX_MOCK = '0x123abc';
const ID_MOCK = '0x12345678';
const ORIGIN_MOCK = 'https://example.com';

const REQUEST_MOCK = {
origin: ORIGIN_MOCK,
params: [
{
version: '1.0',
Expand All @@ -30,13 +32,13 @@ const REQUEST_MOCK = {
],
},
],
} as unknown as JsonRpcRequest<SendCallsParams>;
} as unknown as JsonRpcRequest<SendCallsParams> & { origin: string };

describe('wallet_sendCalls', () => {
let request: JsonRpcRequest<SendCallsParams>;
let request: JsonRpcRequest<SendCallsParams> & { origin: string };
let params: SendCallsParams;
let response: PendingJsonRpcResponse<string>;
let getAccountsMock: jest.MockedFn<GetAccounts>;
let getPermittedAccountsForOriginMock: jest.MockedFn<GetPermittedAccountsForOrigin>;
let processSendCallsMock: jest.MockedFunction<ProcessSendCallsHook>;

/**
Expand All @@ -45,7 +47,7 @@ describe('wallet_sendCalls', () => {
*/
async function callMethod() {
return walletSendCalls(request, response, {
getAccounts: getAccountsMock,
getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock,
processSendCalls: processSendCallsMock,
});
}
Expand All @@ -57,10 +59,10 @@ describe('wallet_sendCalls', () => {
params = request.params as SendCallsParams;
response = {} as PendingJsonRpcResponse<string>;

getAccountsMock = jest.fn();
getPermittedAccountsForOriginMock = jest.fn();
processSendCallsMock = jest.fn();

getAccountsMock.mockResolvedValue([ADDRESS_MOCK]);
getPermittedAccountsForOriginMock.mockResolvedValue([ADDRESS_MOCK]);

processSendCallsMock.mockResolvedValue({
id: ID_MOCK,
Expand Down Expand Up @@ -108,7 +110,7 @@ describe('wallet_sendCalls', () => {
it('throws if no hook', async () => {
await expect(
walletSendCalls(request, response, {
getAccounts: getAccountsMock,
getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock,
}),
).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`);
});
Expand Down Expand Up @@ -207,7 +209,7 @@ describe('wallet_sendCalls', () => {
});

it('throws if from is not in accounts', async () => {
getAccountsMock.mockResolvedValueOnce([]);
getPermittedAccountsForOriginMock.mockResolvedValueOnce([]);

await expect(callMethod()).rejects.toMatchInlineSnapshot(
`[Error: The requested account and/or method has not been authorized by the user.]`,
Expand Down
12 changes: 6 additions & 6 deletions packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ import { validateAndNormalizeKeyholder, validateParams } from '../utils';
* @param req - The JSON RPC request's end callback.
* @param res - The JSON RPC request's pending response object.
* @param hooks - The hooks object.
* @param hooks.getAccounts - Function that retrieves available accounts.
* @param hooks.getPermittedAccountsForOrigin - Function that retrieves permitted accounts for the requester's origin.
* @param hooks.processSendCalls - Function that processes a sendCalls request for EIP-5792 transactions.
*/
export async function walletSendCalls(
req: JsonRpcRequest,
req: JsonRpcRequest & { origin: string },
res: PendingJsonRpcResponse,
{
getAccounts,
getPermittedAccountsForOrigin,
processSendCalls,
}: {
getAccounts: (req: JsonRpcRequest) => Promise<string[]>;
getPermittedAccountsForOrigin: () => Promise<string[]>;
processSendCalls?: ProcessSendCallsHook;
},
): Promise<void> {
Expand All @@ -34,8 +34,8 @@ export async function walletSendCalls(
const params = req.params[0];

const from = params.from
? await validateAndNormalizeKeyholder(params.from, req, {
getAccounts,
? await validateAndNormalizeKeyholder(params.from, {
getPermittedAccountsForOrigin,
})
: undefined;

Expand Down
Loading