Skip to content

Commit 43c756b

Browse files
feat: add trust signal middlware (#22800)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Add trust signal middleware to scan addresses and origins ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces `TrustSignalsMiddleware` to scan origins and addresses in transactions/typed data via `PhishingController`, integrates it into the provider pipeline, adds supporting utilities/tests, and bumps `@metamask/phishing-controller`. > > - **Core RPC pipeline**: > - Add `createTrustSignalsMiddleware` that scans `req.origin` URLs and EVM addresses (tx `to`, approval spender; typed data verifying contract and permit spender) using `PhishingController` and current `chainId` from `NetworkController`. > - Integrate middleware into `BackgroundBridge` engine after origin throttling and before user-facing RPC methods. > - **Utilities**: > - New `app/lib/address-scanning/address-scan-util.ts` with helpers: `parseTypedDataMessage`, `extractSpenderFromApprovalData`, `extractSpenderFromPermitMessage`, validation helpers, method checks, and `scanAddress`/`scanUrl` wrappers with error logging. > - **Tests**: > - Add unit tests for middleware (`TrustSignalsMiddleware.test.ts`) and utilities (`address-scan-util.test.ts`). > - **State/Config**: > - Initialize `PhishingController.addressScanCache` in `initial-background-state.json`. > - Bump dependency `@metamask/phishing-controller` to `^16.1.0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c732c5e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 364ec1b commit 43c756b

File tree

5 files changed

+1242
-0
lines changed

5 files changed

+1242
-0
lines changed

app/core/BackgroundBridge/BackgroundBridge.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import PPOMUtil from '../../lib/ppom/ppom-util';
9191
import { isRelaySupported } from '../../util/transactions/transaction-relay';
9292
import { selectSmartTransactionsEnabled } from '../../selectors/smartTransactionsController';
9393
import { AccountTreeController } from '@metamask/account-tree-controller';
94+
import { createTrustSignalsMiddleware } from '../RPCMethods/TrustSignalsMiddleware';
9495

9596
const legacyNetworkId = () => {
9697
const { networksMetadata, selectedNetworkClientId } =
@@ -634,6 +635,13 @@ export class BackgroundBridge extends EventEmitter {
634635
// Origin throttling middleware for spam filtering
635636
engine.push(createOriginThrottlingMiddleware(this.navigation));
636637

638+
engine.push(
639+
createTrustSignalsMiddleware({
640+
phishingController: Engine.context.PhishingController,
641+
networkController: Engine.context.NetworkController,
642+
}),
643+
);
644+
637645
// user-facing RPC methods
638646
engine.push(
639647
this.createMiddleware({
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
2+
import type { JsonRpcRequest, JsonRpcParams } from '@metamask/utils';
3+
import type { PhishingController } from '@metamask/phishing-controller';
4+
import type { NetworkController } from '@metamask/network-controller';
5+
import Logger from '../../util/Logger';
6+
import { createTrustSignalsMiddleware } from './TrustSignalsMiddleware';
7+
import {
8+
parseTypedDataMessage,
9+
extractSpenderFromApprovalData,
10+
extractSpenderFromPermitMessage,
11+
hasValidTransactionParams,
12+
hasValidTypedDataParams,
13+
isEthSendTransaction,
14+
isEthSignTypedData,
15+
scanAddress,
16+
scanUrl,
17+
} from '../../lib/address-scanning/address-scan-util';
18+
19+
jest.mock('../../util/Logger', () => ({
20+
log: jest.fn(),
21+
}));
22+
23+
jest.mock('../../lib/address-scanning/address-scan-util', () => {
24+
const actual = jest.requireActual<
25+
typeof import('../../lib/address-scanning/address-scan-util')
26+
>('../../lib/address-scanning/address-scan-util');
27+
return {
28+
...actual,
29+
parseTypedDataMessage: jest.fn(),
30+
extractSpenderFromApprovalData: jest.fn(),
31+
extractSpenderFromPermitMessage: jest.fn(),
32+
hasValidTransactionParams: jest.fn(),
33+
hasValidTypedDataParams: jest.fn(),
34+
isEthSendTransaction: jest.fn(),
35+
isEthSignTypedData: jest.fn(),
36+
scanAddress: jest.fn(),
37+
scanUrl: jest.fn(),
38+
};
39+
});
40+
41+
const mockLogger = Logger as jest.Mocked<typeof Logger>;
42+
const mockParseTypedDataMessage = parseTypedDataMessage as jest.MockedFunction<
43+
typeof parseTypedDataMessage
44+
>;
45+
const mockExtractSpenderFromApprovalData =
46+
extractSpenderFromApprovalData as jest.MockedFunction<
47+
typeof extractSpenderFromApprovalData
48+
>;
49+
const mockExtractSpenderFromPermitMessage =
50+
extractSpenderFromPermitMessage as jest.MockedFunction<
51+
typeof extractSpenderFromPermitMessage
52+
>;
53+
const mockHasValidTransactionParams =
54+
hasValidTransactionParams as jest.MockedFunction<
55+
typeof hasValidTransactionParams
56+
>;
57+
const mockHasValidTypedDataParams =
58+
hasValidTypedDataParams as jest.MockedFunction<
59+
typeof hasValidTypedDataParams
60+
>;
61+
const mockIsEthSendTransaction = isEthSendTransaction as jest.MockedFunction<
62+
typeof isEthSendTransaction
63+
>;
64+
const mockIsEthSignTypedData = isEthSignTypedData as jest.MockedFunction<
65+
typeof isEthSignTypedData
66+
>;
67+
const mockScanAddress = scanAddress as jest.MockedFunction<typeof scanAddress>;
68+
const mockScanUrl = scanUrl as jest.MockedFunction<typeof scanUrl>;
69+
70+
const jsonrpc = '2.0' as const;
71+
72+
function createMockPhishingController(): PhishingController {
73+
return {
74+
scanAddress: jest.fn().mockResolvedValue(undefined),
75+
scanUrl: jest.fn().mockResolvedValue(undefined),
76+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
77+
state: { addressScanCache: {}, urlScanCache: {} } as any,
78+
} as unknown as PhishingController;
79+
}
80+
81+
function createMockNetworkController(options?: {
82+
chainId?: string;
83+
selectedNetworkClientId?: string;
84+
}): NetworkController {
85+
const chainId = options?.chainId ?? '0x1';
86+
const selectedNetworkClientId =
87+
options?.selectedNetworkClientId ?? 'test-network';
88+
89+
return {
90+
state: {
91+
selectedNetworkClientId,
92+
},
93+
getNetworkConfigurationByNetworkClientId: jest
94+
.fn()
95+
.mockReturnValue({ chainId }),
96+
} as unknown as NetworkController;
97+
}
98+
99+
function createNetworkControllerWithoutChain(): NetworkController {
100+
return {
101+
state: {},
102+
getNetworkConfigurationByNetworkClientId: jest
103+
.fn()
104+
.mockReturnValue(undefined),
105+
} as unknown as NetworkController;
106+
}
107+
108+
interface TrustSignalsTestRequest extends JsonRpcRequest<JsonRpcParams> {
109+
origin?: string;
110+
}
111+
112+
async function callThroughMiddleware({
113+
middleware,
114+
request,
115+
}: {
116+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117+
middleware: any;
118+
request: TrustSignalsTestRequest;
119+
}) {
120+
const engine = new JsonRpcEngine();
121+
engine.push(middleware);
122+
123+
const nextMiddleware = jest
124+
.fn()
125+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
126+
.mockImplementation((_req: any, res: any, _next: any, end: () => void) => {
127+
res.result = 'next-result';
128+
end();
129+
});
130+
131+
engine.push(nextMiddleware);
132+
133+
const response = await engine.handle(request);
134+
return { response, nextMiddleware };
135+
}
136+
137+
describe('createTrustSignalsMiddleware', () => {
138+
beforeEach(() => {
139+
jest.clearAllMocks();
140+
});
141+
142+
it('scans origin URL when origin is provided', async () => {
143+
const phishingController = createMockPhishingController();
144+
const networkController = createMockNetworkController();
145+
const origin = 'https://example.com';
146+
147+
mockIsEthSendTransaction.mockReturnValue(false);
148+
mockIsEthSignTypedData.mockReturnValue(false);
149+
150+
const middleware = createTrustSignalsMiddleware({
151+
phishingController,
152+
networkController,
153+
});
154+
155+
await callThroughMiddleware({
156+
middleware,
157+
request: {
158+
jsonrpc,
159+
id: 1,
160+
method: 'eth_chainId',
161+
origin,
162+
params: [] as JsonRpcParams,
163+
},
164+
});
165+
166+
expect(mockScanUrl).toHaveBeenCalledWith(phishingController, origin);
167+
});
168+
169+
it('does not scan origin URL when origin is not provided', async () => {
170+
const phishingController = createMockPhishingController();
171+
const networkController = createMockNetworkController();
172+
173+
mockIsEthSendTransaction.mockReturnValue(false);
174+
mockIsEthSignTypedData.mockReturnValue(false);
175+
176+
const middleware = createTrustSignalsMiddleware({
177+
phishingController,
178+
networkController,
179+
});
180+
181+
await callThroughMiddleware({
182+
middleware,
183+
request: {
184+
jsonrpc,
185+
id: null,
186+
method: 'eth_chainId',
187+
params: [],
188+
origin: '',
189+
},
190+
});
191+
192+
expect(mockScanUrl).not.toHaveBeenCalled();
193+
});
194+
195+
it('does not scan addresses when chainId is not available', async () => {
196+
const phishingController = createMockPhishingController();
197+
const networkController = createNetworkControllerWithoutChain();
198+
199+
mockIsEthSendTransaction.mockReturnValue(true);
200+
mockHasValidTransactionParams.mockReturnValue(true);
201+
202+
const middleware = createTrustSignalsMiddleware({
203+
phishingController,
204+
networkController,
205+
});
206+
207+
await callThroughMiddleware({
208+
middleware,
209+
request: {
210+
jsonrpc,
211+
id: 1,
212+
method: 'eth_sendTransaction',
213+
params: [{ to: '0x1234' }] as JsonRpcParams,
214+
},
215+
});
216+
217+
expect(mockScanAddress).not.toHaveBeenCalled();
218+
});
219+
220+
it('scans transaction recipient and spender addresses for eth_sendTransaction', async () => {
221+
const phishingController = createMockPhishingController();
222+
const networkController = createMockNetworkController({ chainId: '0x1' });
223+
const to = '0xabc';
224+
const data = '0xdata';
225+
226+
mockIsEthSendTransaction.mockReturnValue(true);
227+
mockHasValidTransactionParams.mockReturnValue(true);
228+
mockExtractSpenderFromApprovalData.mockReturnValue('0xspender');
229+
230+
const middleware = createTrustSignalsMiddleware({
231+
phishingController,
232+
networkController,
233+
});
234+
235+
await callThroughMiddleware({
236+
middleware,
237+
request: {
238+
jsonrpc,
239+
id: 1,
240+
method: 'eth_sendTransaction',
241+
params: [{ to, data }] as JsonRpcParams,
242+
},
243+
});
244+
245+
expect(mockExtractSpenderFromApprovalData).toHaveBeenCalledWith(data);
246+
expect(mockScanAddress).toHaveBeenCalledWith(phishingController, '0x1', to);
247+
expect(mockScanAddress).toHaveBeenCalledWith(
248+
phishingController,
249+
'0x1',
250+
'0xspender',
251+
);
252+
});
253+
254+
it('scans verifying contract and spender addresses for eth_signTypedData', async () => {
255+
const phishingController = createMockPhishingController();
256+
const networkController = createMockNetworkController({ chainId: '0x1' });
257+
258+
const verifyingContract = '0xcontract';
259+
const spender = '0xpermitSpender';
260+
261+
mockIsEthSendTransaction.mockReturnValue(false);
262+
mockIsEthSignTypedData.mockReturnValue(true);
263+
mockHasValidTypedDataParams.mockReturnValue(true);
264+
mockParseTypedDataMessage.mockReturnValue({
265+
domain: { verifyingContract },
266+
message: {},
267+
primaryType: 'Permit',
268+
});
269+
mockExtractSpenderFromPermitMessage.mockReturnValue(spender);
270+
271+
const middleware = createTrustSignalsMiddleware({
272+
phishingController,
273+
networkController,
274+
});
275+
276+
await callThroughMiddleware({
277+
middleware,
278+
request: {
279+
jsonrpc,
280+
id: 1,
281+
method: 'eth_signTypedData_v4',
282+
params: ['0x1', { message: 'data' }] as JsonRpcParams,
283+
},
284+
});
285+
286+
expect(mockParseTypedDataMessage).toHaveBeenCalled();
287+
expect(mockScanAddress).toHaveBeenCalledWith(
288+
phishingController,
289+
'0x1',
290+
verifyingContract,
291+
);
292+
expect(mockScanAddress).toHaveBeenCalledWith(
293+
phishingController,
294+
'0x1',
295+
spender,
296+
);
297+
});
298+
299+
it('logs unexpected error and still calls next middleware', async () => {
300+
const phishingController = createMockPhishingController();
301+
const networkController = createMockNetworkController();
302+
303+
mockIsEthSendTransaction.mockImplementation(() => {
304+
throw new Error('unexpected failure');
305+
});
306+
mockIsEthSignTypedData.mockReturnValue(false);
307+
308+
const middleware = createTrustSignalsMiddleware({
309+
phishingController,
310+
networkController,
311+
});
312+
313+
const { response, nextMiddleware } = await callThroughMiddleware({
314+
middleware,
315+
request: {
316+
jsonrpc,
317+
id: 1,
318+
method: 'eth_sendTransaction',
319+
params: [] as JsonRpcParams,
320+
},
321+
});
322+
323+
expect(mockLogger.log).toHaveBeenCalledWith(
324+
'[TrustSignalsMiddleware] Unexpected error:',
325+
expect.any(Error),
326+
);
327+
expect(nextMiddleware).toHaveBeenCalled();
328+
expect('result' in response && response.result).toBe('next-result');
329+
});
330+
});

0 commit comments

Comments
 (0)