From 82d0fb4812922baceb6b76e8b18a4ffc086094db Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Wed, 12 Aug 2026 12:37:37 -0500 Subject: [PATCH 01/10] feat(phishing-controller): export extractSignatureAddresses --- packages/phishing-controller/CHANGELOG.md | 9 + packages/phishing-controller/src/index.ts | 5 + .../src/signature-address-extraction.test.ts | 542 ++++++++++++++++++ .../src/signature-address-extraction.ts | 300 ++++++++++ 4 files changed, 856 insertions(+) create mode 100644 packages/phishing-controller/src/signature-address-extraction.test.ts create mode 100644 packages/phishing-controller/src/signature-address-extraction.ts diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 66ab0fe4ea1..3bdb5417988 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `extractSignatureAddresses` utility, plus `ExtractedSignatureAddresses` and `ExtractSignatureAddressesOptions` types, to collect the `address`-typed values from an EIP-712 typed-data message for real-time address scanning ([#9999](https://github.com/MetaMask/core/pull/9999)) + - Walks the `types` schema from `primaryType`, matching fields by declared type (`address`/`address[]`, including nested structs and arrays) rather than by field name, so custom and unknown message shapes are covered without per-protocol handling. + - Normalizes non-canonical `address` encodings (variable-length hex and decimal strings) into canonical lower-case 20-byte hex, reduced mod 2^160 as the signer does, and de-duplicates case-insensitively. + - Excludes the zero address, a caller-provided `exclude` list (e.g. the signer), and caller-provided top-level `excludeFields`. + - Bounds work with a distinct-address cap (10), a traversal depth limit, and a node budget, reporting `overflow` when the message could not be fully walked. + - Returns the field name each address was found under so callers can attribute alerts. + ### Changed - Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.2` ([#9780](https://github.com/MetaMask/core/pull/9780), [#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 0f963ea60c4..89827eed051 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -37,6 +37,11 @@ export { getPhishingDetectionScanUrlParam, isPhishingDetectionPathBasedHostname, } from './utils.js'; +export { extractSignatureAddresses } from './signature-address-extraction.js'; +export type { + ExtractedSignatureAddresses, + ExtractSignatureAddressesOptions, +} from './signature-address-extraction.js'; export type { PhishingControllerMaybeUpdateStateAction, diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts new file mode 100644 index 00000000000..a63c39d6d2a --- /dev/null +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -0,0 +1,542 @@ +import { extractSignatureAddresses } from './signature-address-extraction.js'; + +const ADDR_A = '0x1111111111111111111111111111111111111111'; +const ADDR_B = '0x2222222222222222222222222222222222222222'; +const ADDR_C = '0x3333333333333333333333333333333333333333'; +const ADDR_D = '0x5555555555555555555555555555555555555555'; +const SIGNER = '0x4444444444444444444444444444444444444444'; +const ZERO = '0x0000000000000000000000000000000000000000'; + +// 2^160 written as a hex literal (0x1 + 40 zeros) to avoid the `**` operator. +const ADDRESS_MODULUS = 0x10000000000000000000000000000000000000000n; + +const DOMAIN_TYPE = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +type TypedDataFixture = { + types: Record; + primaryType: string; + domain: { verifyingContract: string }; + message: Record; +}; + +const build = ( + primaryType: string, + types: Record, + message: Record, +): TypedDataFixture => ({ + types: { EIP712Domain: DOMAIN_TYPE, ...types }, + primaryType, + domain: { verifyingContract: ADDR_C }, + message, +}); + +const addressesOf = ( + ...args: Parameters +): string[] => extractSignatureAddresses(...args).addresses; + +const nAddresses = (count: number): string[] => + Array.from( + { length: count }, + (_, i) => `0x${(i + 1).toString(16).padStart(2, '0').repeat(20)}`, + ); + +describe('extractSignatureAddresses', () => { + it('extracts a permit `spender` from the schema', () => { + const data = build( + 'Permit', + { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + ], + }, + { owner: SIGNER, spender: ADDR_A, value: '1' }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('extracts an EIP-3009 `to`', () => { + const data = build( + 'ReceiveWithAuthorization', + { + ReceiveWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + ], + }, + { from: SIGNER, to: ADDR_A, value: '1' }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('extracts an address field with a protocol-specific name', () => { + const data = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 8453, + verifyingContract: ZERO, + }, + message: { + hyperliquidChain: 'Mainnet', + signatureChainId: '0x2105', + agentAddress: ADDR_A, + agentName: '', + nonce: 1784737070579, + type: 'approveAgent', + }, + primaryType: 'HyperliquidTransaction:ApproveAgent', + types: { + EIP712Domain: DOMAIN_TYPE, + 'HyperliquidTransaction:ApproveAgent': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'agentAddress', type: 'address' }, + { name: 'agentName', type: 'string' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + }; + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('extracts EVERY address field in a Seaport order (offerer/zone/token/recipient), nested structs + arrays', () => { + const data = build( + 'OrderComponents', + { + OrderComponents: [ + { name: 'offerer', type: 'address' }, + { name: 'zone', type: 'address' }, + { name: 'offer', type: 'OfferItem[]' }, + { name: 'consideration', type: 'ConsiderationItem[]' }, + { name: 'startTime', type: 'uint256' }, + ], + OfferItem: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + ConsiderationItem: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'recipient', type: 'address' }, + ], + }, + { + offerer: SIGNER, + zone: ADDR_A, + offer: [{ token: ADDR_B, amount: '1' }], + consideration: [{ token: ADDR_C, amount: '1', recipient: ADDR_D }], + startTime: '0', + }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ + ADDR_A, + ADDR_B, + ADDR_C, + ADDR_D, + ]); + }); + + it('extracts addresses from a Permit2 batch (struct array + top-level spender)', () => { + const data = build( + 'PermitBatch', + { + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + ], + }, + { + details: [ + { token: ADDR_A, amount: '1' }, + { token: ADDR_B, amount: '2' }, + ], + spender: ADDR_C, + sigDeadline: '0', + }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B, ADDR_C]); + }); + + it('extracts an `address[]` field', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: [ADDR_A, ADDR_B] }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); + }); + + it('handles a fixed-size `address[N]` field', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[2]' }] }, + { recipients: [ADDR_A, ADDR_B] }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); + }); + + it('ignores an array-typed field whose value is not an array', () => { + const data = build( + 'Airdrop', + { + Airdrop: [ + { name: 'recipients', type: 'address[]' }, + { name: 'to', type: 'address' }, + ], + }, + { recipients: 'not-an-array', to: ADDR_A }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('extracts an arbitrarily-named address field in an unknown schema', () => { + const data = build( + 'Weird', + { + Weird: [ + { name: 'maker', type: 'address' }, + { name: 'superSecretSink', type: 'address' }, + ], + }, + { maker: SIGNER, superSecretSink: ADDR_A }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('ignores non-address typed fields even if the value looks like an address', () => { + const data = build( + 'T', + { + T: [ + { name: 'owner', type: 'address' }, + { name: 'notAnAddress', type: 'uint256' }, + { name: 'blob', type: 'bytes32' }, + ], + }, + // notAnAddress carries an address-shaped string but is typed uint256. + { owner: ADDR_A, notAnAddress: ADDR_B, blob: `0x${'ab'.repeat(32)}` }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('canonicalizes to lower case and de-duplicates case-insensitively', () => { + const lower = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const upper = '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD'; + const data = build( + 'Two', + { + Two: [ + { name: 'to', type: 'address' }, + { name: 'recipient', type: 'address' }, + ], + }, + { to: lower, recipient: upper }, + ); + expect(addressesOf(data)).toStrictEqual([lower]); + }); + + it('excludes the zero address and provided addresses', () => { + const data = build( + 'Three', + { + Three: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'address' }, + { name: 'c', type: 'address' }, + ], + }, + { a: ZERO, b: SIGNER, c: ADDR_A }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('ignores non-address-like entries in the exclude list', () => { + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: ADDR_A }, + ); + // A garbage exclude value normalizes to undefined and is skipped, so the + // real address is still returned. + expect(addressesOf(data, { exclude: ['not-an-address'] })).toStrictEqual([ + ADDR_A, + ]); + }); + + it('honors excludeFields (e.g. spender handled elsewhere)', () => { + const data = build( + 'Permit', + { + Permit: [ + { name: 'spender', type: 'address' }, + { name: 'to', type: 'address' }, + ], + }, + { spender: ADDR_A, to: ADDR_B }, + ); + expect(addressesOf(data, { excludeFields: ['spender'] })).toStrictEqual([ + ADDR_B, + ]); + }); + + it('only excludes fields at the top level', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'spender', type: 'address' }, + { name: 'inner', type: 'Inner' }, + ], + Inner: [{ name: 'spender', type: 'address' }], + }, + { spender: ADDR_A, inner: { spender: ADDR_B } }, + ); + expect(addressesOf(data, { excludeFields: ['spender'] })).toStrictEqual([ + ADDR_B, + ]); + }); + + it('skips malformed schema fields', () => { + const data = build( + 'T', + { + T: [ + // Malformed entries the walker must skip without throwing. + null as unknown as { name: string; type: string }, + { name: 123 as unknown as string, type: 'address' }, + { name: 'to', type: 456 as unknown as string }, + { name: 'good', type: 'address' }, + ], + }, + { to: ADDR_A, good: ADDR_B }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_B]); + }); + + it('normalizes decimal and non-canonical hex address encodings', () => { + const data = build( + 'Batch', + { + Batch: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'address' }, + ], + }, + { a: BigInt(ADDR_A).toString(10), b: '0x1' }, + ); + expect(addressesOf(data)).toStrictEqual([ + ADDR_A, + '0x0000000000000000000000000000000000000001', + ]); + }); + + it('normalizes a whitespace-padded address value', () => { + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: ` ${ADDR_A} ` }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('normalizes a non-negative integer number value', () => { + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: 1 }, + ); + expect(addressesOf(data)).toStrictEqual([ + '0x0000000000000000000000000000000000000001', + ]); + }); + + it('ignores non-address-like values (negative, float, object, null)', () => { + const data = build( + 'X', + { + X: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'address' }, + { name: 'c', type: 'address' }, + { name: 'd', type: 'address' }, + { name: 'e', type: 'address' }, + ], + }, + { a: -1, b: 1.5, c: {}, d: null, e: ADDR_A }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('canonicalizes mixed-case addresses to lower case', () => { + const mixed = '0xAbCdEf0000000000000000000000000000000001'; + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: mixed }, + ); + expect(addressesOf(data)).toStrictEqual([mixed.toLowerCase()]); + }); + + it('reduces an oversized decimal-encoded address to the signed address', () => { + // The signer reduces an `address` mod 2^160, so `value + 2^160` signs as + // `value`. The extractor must resolve it to the same address. + const oversized = (BigInt(ADDR_A) + ADDRESS_MODULUS).toString(10); + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: oversized }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('bounds traversal work for a very large array', () => { + const huge = Array.from({ length: 100000 }, () => ADDR_A); + const data = build( + 'Batch', + { Batch: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: huge }, + ); + // Returns the distinct address without walking every element. + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('does not flag overflow at exactly the cap', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(10) }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(false); + }); + + it('caps returned addresses and flags overflow past the cap', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(15) }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(true); + }); + + it('flags overflow when the work budget truncates the walk', () => { + // A long run of non-address nodes ahead of a trailing address exhausts the + // node budget, so the address is never reached. + const pad = Array.from({ length: 6000 }, (_, i) => i); + const data = build( + 'Batch', + { + Batch: [ + { name: 'pad', type: 'uint256[]' }, + { name: 'evil', type: 'address' }, + ], + }, + { pad, evil: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([]); + expect(result.overflow).toBe(true); + }); + + it('flags overflow when nesting exceeds the depth limit', () => { + const depth = 14; + const types: Record = {}; + for (let i = 0; i < depth; i++) { + types[`L${i}`] = [ + i < depth - 1 + ? { name: 'next', type: `L${i + 1}` } + : { name: 'addr', type: 'address' }, + ]; + } + let message: Record = { addr: ADDR_A }; + for (let i = depth - 2; i >= 0; i--) { + message = { next: message }; + } + const result = extractSignatureAddresses(build('L0', types, message)); + expect(result.addresses).toStrictEqual([]); + expect(result.overflow).toBe(true); + }); + + it('reports the field name each address was found under', () => { + const data = build( + 'T', + { + T: [ + { name: 'to', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + }, + { to: ADDR_A, spender: ADDR_B }, + ); + expect(extractSignatureAddresses(data).fields).toStrictEqual({ + [ADDR_A]: 'to', + [ADDR_B]: 'spender', + }); + }); + + it('returns the full result shape with defaults for a benign payload', () => { + const data = build( + 'T', + { T: [{ name: 'to', type: 'address' }] }, + { to: ADDR_A }, + ); + expect(extractSignatureAddresses(data)).toStrictEqual({ + addresses: [ADDR_A], + fields: { [ADDR_A]: 'to' }, + overflow: false, + }); + }); + + it('ignores a struct-typed field whose value is not an object', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'inner', type: 'Inner' }, + { name: 'to', type: 'address' }, + ], + Inner: [{ name: 'addr', type: 'address' }], + }, + { inner: 'not-an-object', to: ADDR_A }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('returns [] for nullish payloads, missing types, or unknown primaryType', () => { + expect(addressesOf(undefined)).toStrictEqual([]); + expect(addressesOf(null)).toStrictEqual([]); + expect( + addressesOf({ primaryType: 'X', message: { to: ADDR_A } }), + ).toStrictEqual([]); + expect( + addressesOf({ + types: { Y: [{ name: 'to', type: 'address' }] }, + primaryType: 'X', + message: { to: ADDR_A }, + }), + ).toStrictEqual([]); + // primaryType present, types present, but message missing/invalid. + expect( + addressesOf({ + types: { X: [{ name: 'to', type: 'address' }] }, + primaryType: 'X', + message: null, + }), + ).toStrictEqual([]); + }); +}); diff --git a/packages/phishing-controller/src/signature-address-extraction.ts b/packages/phishing-controller/src/signature-address-extraction.ts new file mode 100644 index 00000000000..96d9eb6e6d9 --- /dev/null +++ b/packages/phishing-controller/src/signature-address-extraction.ts @@ -0,0 +1,300 @@ +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const HEX_STRING_REGEX = /^0x[0-9a-fA-F]+$/u; +const DECIMAL_STRING_REGEX = /^[0-9]+$/u; +// The address space (2^160), written as a literal (0x1 followed by 40 hex +// zeros) so the value is not produced with the `**` operator, which some build +// targets down-compile to `Math.pow` and cannot evaluate on BigInt operands. +// Values are reduced into this space, as the signer does. +const ADDRESS_MODULUS = 0x10000000000000000000000000000000000000000n; + +// Cap the number of addresses returned for a single signature. A legitimate +// signature references far fewer; exceeding this is treated as unusual and +// surfaced to the caller (via `overflow`) rather than scanned in full. +const MAX_SIGNATURE_ADDRESSES = 10; + +// Limit recursion depth when walking nested types. +const MAX_TRAVERSAL_DEPTH = 12; + +// Limit total nodes walked so a large or highly-repetitive payload cannot stall +// traversal, independent of how many distinct addresses are found. +const MAX_TRAVERSAL_NODES = 5000; + +type Eip712Field = { name: string; type: string }; +type Eip712Types = Record; + +/** + * The result of walking an EIP-712 typed-data message for `address`-typed + * values. + */ +export type ExtractedSignatureAddresses = { + /** + * Distinct canonical addresses to scan, capped at `MAX_SIGNATURE_ADDRESSES`. + */ + addresses: string[]; + /** + * Canonical address -> the field name it was first found under, so a caller + * can name the specific field in an alert. + */ + fields: Record; + /** + * True when the message could not be fully walked: more distinct addresses + * than the cap, or traversal stopped by the depth or work budget. Some + * addresses may be unscanned, so the caller should surface a caution. + */ + overflow: boolean; +}; + +/** + * Options for {@link extractSignatureAddresses}. + */ +export type ExtractSignatureAddressesOptions = { + /** + * Addresses to skip (e.g. the signer). The zero address is always excluded. + */ + exclude?: string[]; + /** + * Top-level field names to skip, used to avoid a duplicate scan/alert for a + * field already handled elsewhere (e.g. permit `spender`). Only applied to + * the primary type, not nested structs. + */ + excludeFields?: string[]; +}; + +/** + * Reduce an `address`-typed value to canonical 20-byte hex. + * + * The signer accepts more than canonical hex for an `address` field (hex of any + * length, or a decimal string) and reduces it into the 20-byte address space, + * so matching only `0x` + 40 hex would miss an address encoded in another form. + * Values are reduced the same way the signer does and returned in a single + * canonical form for de-duping. + * + * @param value - The raw field value from the message. + * @returns Canonical lower-case address, or undefined if not address-like. + */ +function normalizeAddress(value: unknown): string | undefined { + let numeric: bigint; + + // The regexes and integer check below only admit values `BigInt` accepts, so + // the conversion cannot throw. + if (typeof value === 'string') { + const trimmed = value.trim(); + if ( + !HEX_STRING_REGEX.test(trimmed) && + !DECIMAL_STRING_REGEX.test(trimmed) + ) { + return undefined; + } + numeric = BigInt(trimmed); + } else if ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 0 + ) { + numeric = BigInt(value); + } else { + return undefined; + } + + // Reduce into the 20-byte address space, matching how the signer encodes an + // `address` field, so non-canonical encodings resolve to the signed address. + numeric %= ADDRESS_MODULUS; + + return `0x${numeric.toString(16).padStart(40, '0')}`; +} + +/** + * Collect every `address`-typed value in an EIP-712 message. + * + * Walks the `types` schema from `primaryType` and returns the value of each + * field declared as `address` or `address[]`, recursing into nested structs and + * arrays. Matching on the declared type rather than the field name means custom + * and unknown message shapes are covered without per-protocol handling. + * + * `domain` is not traversed; its `verifyingContract` is expected to be scanned + * separately by the caller. + * + * @param typedData - Parsed EIP-712 payload (`types`, `primaryType`, `message`). + * @param options - Optional configuration. + * @param options.exclude - Addresses to skip (e.g. the signer). The zero + * address is always excluded. + * @param options.excludeFields - Top-level field names to skip, used to avoid a + * duplicate scan/alert for a field already handled elsewhere (e.g. permit + * `spender`). Only applied to the primary type, not nested structs. + * @returns Up to `MAX_SIGNATURE_ADDRESSES` distinct canonical addresses, the + * field each was found under, and whether the message could not be fully walked + * (address cap, depth limit, or work budget reached). + */ +export function extractSignatureAddresses( + typedData: + | { types?: unknown; primaryType?: unknown; message?: unknown } + | null + | undefined, + options: ExtractSignatureAddressesOptions = {}, +): ExtractedSignatureAddresses { + const types = typedData?.types as Eip712Types | undefined; + const primaryType = typedData?.primaryType as string | undefined; + const { message } = typedData ?? {}; + + if ( + !types || + typeof types !== 'object' || + !primaryType || + !Array.isArray(types[primaryType]) || + !message || + typeof message !== 'object' + ) { + return { addresses: [], fields: {}, overflow: false }; + } + + // Narrowed alias so the hoisted helpers below see a defined `types`. + const schema = types; + + // ZERO_ADDRESS is already canonical (lower-case, 20 bytes), so it is added + // directly rather than round-tripped through `normalizeAddress`. + const excluded = new Set([ZERO_ADDRESS]); + for (const address of options.exclude ?? []) { + const normalized = normalizeAddress(address); + if (normalized) { + excluded.add(normalized); + } + } + + const excludedFields = new Set( + (options.excludeFields ?? []).map((field) => field.toLowerCase()), + ); + + // Canonical address -> the field name it was first found under. + const found = new Map(); + + // Set when the message could not be fully walked, so some addresses may be + // unscanned: the address cap, the depth limit, or the work budget was hit. + let overflow = false; + + // Total nodes walked, bounded by MAX_TRAVERSAL_NODES. + let visited = 0; + + // Stopping the walk (depth or work budget) leaves later fields unscanned, so + // it is treated as overflow the same way the distinct-address cap is. + const truncated = (depth: number): boolean => { + if (depth > MAX_TRAVERSAL_DEPTH || visited >= MAX_TRAVERSAL_NODES) { + overflow = true; + return true; + } + return false; + }; + + /** + * Record a candidate address value under a field name, applying exclusions, + * de-duplication, and the distinct-address cap. + * + * @param field - The field name the value was found under. + * @param value - The raw field value to normalize and collect. + */ + function collect(field: string, value: unknown): void { + const address = normalizeAddress(value); + if (!address || excluded.has(address) || found.has(address)) { + return; + } + if (found.size >= MAX_SIGNATURE_ADDRESSES) { + overflow = true; + return; + } + found.set(address, field); + } + + /** + * Walk the fields of a struct type, recursing per field. + * + * @param structName - The name of the struct type in the schema. + * @param value - The message object corresponding to the struct. + * @param depth - The current traversal depth. + */ + function visitStruct( + structName: string, + value: unknown, + depth: number, + ): void { + if (truncated(depth)) { + return; + } + const structFields = schema[structName]; + if (!Array.isArray(structFields) || !value || typeof value !== 'object') { + return; + } + for (const field of structFields) { + if (truncated(depth)) { + return; + } + if ( + !field || + typeof field.name !== 'string' || + typeof field.type !== 'string' || + // Field exclusions only apply to the primary type (depth 0), matching + // the top-level field a dedicated caller already covers. + (depth === 0 && excludedFields.has(field.name.toLowerCase())) + ) { + continue; + } + visitField( + field.name, + field.type, + (value as Record)[field.name], + depth, + ); + } + } + + /** + * Walk a single field value, handling arrays, `address`, and nested structs. + * + * @param field - The field name. + * @param type - The declared EIP-712 type of the field. + * @param value - The field value from the message. + * @param depth - The current traversal depth. + */ + function visitField( + field: string, + type: string, + value: unknown, + depth: number, + ): void { + visited += 1; + if (truncated(depth)) { + return; + } + + // Handle one array dimension at a time, e.g. `address[]` or `Type[][]`. + const arrayMatch = type.match(/^(.*)\[\d*\]$/u); + if (arrayMatch) { + if (Array.isArray(value)) { + for (const item of value) { + if (truncated(depth)) { + return; + } + visitField(field, arrayMatch[1], item, depth + 1); + } + } + return; + } + + if (type === 'address') { + collect(field, value); + return; + } + + // Recurse into custom struct types; other primitives carry no address. + if (Array.isArray(schema[type])) { + visitStruct(type, value, depth + 1); + } + } + + visitStruct(primaryType, message, 0); + + return { + addresses: Array.from(found.keys()), + fields: Object.fromEntries(found), + overflow, + }; +} From fdd350950bb6e22477d4595a7657cf753544c3e9 Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Wed, 2 Sep 2026 17:32:01 -0400 Subject: [PATCH 02/10] fix(phishing-controller): match signer address encoding in extractSignatureAddresses Address review: take leading 20 bytes (not mod 2^160), exact excludeFields, signer type-dispatch order, and changelog #9875. --- packages/phishing-controller/CHANGELOG.md | 4 +- packages/phishing-controller/package.json | 1 + .../src/signature-address-extraction.test.ts | 168 +++++++++++++++-- .../src/signature-address-extraction.ts | 173 ++++++++++++------ yarn.lock | 1 + 5 files changed, 272 insertions(+), 75 deletions(-) diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 3bdb5417988..dbff296e5e1 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `extractSignatureAddresses` utility, plus `ExtractedSignatureAddresses` and `ExtractSignatureAddressesOptions` types, to collect the `address`-typed values from an EIP-712 typed-data message for real-time address scanning ([#9999](https://github.com/MetaMask/core/pull/9999)) +- Add `extractSignatureAddresses` utility, plus `ExtractedSignatureAddresses` and `ExtractSignatureAddressesOptions` types, to collect the `address`-typed values from an EIP-712 typed-data message for real-time address scanning ([#9875](https://github.com/MetaMask/core/pull/9875)) - Walks the `types` schema from `primaryType`, matching fields by declared type (`address`/`address[]`, including nested structs and arrays) rather than by field name, so custom and unknown message shapes are covered without per-protocol handling. - - Normalizes non-canonical `address` encodings (variable-length hex and decimal strings) into canonical lower-case 20-byte hex, reduced mod 2^160 as the signer does, and de-duplicates case-insensitively. + - Normalizes non-canonical `address` encodings (variable-length hex and decimal strings) into canonical lower-case 20-byte hex by taking the leading 20 bytes of the signer-compatible big-endian encoding, and de-duplicates case-insensitively. - Excludes the zero address, a caller-provided `exclude` list (e.g. the signer), and caller-provided top-level `excludeFields`. - Bounds work with a distinct-address cap (10), a traversal depth limit, and a node budget, reporting `overflow` when the message could not be fully walked. - Returns the field name each address was found under so callers can attribute alerts. diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 179d6998abb..d69ec932d8c 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -68,6 +68,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-sig-util": "^8.2.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts index a63c39d6d2a..0bfffd805e5 100644 --- a/packages/phishing-controller/src/signature-address-extraction.test.ts +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -1,3 +1,5 @@ +import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util'; + import { extractSignatureAddresses } from './signature-address-extraction.js'; const ADDR_A = '0x1111111111111111111111111111111111111111'; @@ -7,9 +9,6 @@ const ADDR_D = '0x5555555555555555555555555555555555555555'; const SIGNER = '0x4444444444444444444444444444444444444444'; const ZERO = '0x0000000000000000000000000000000000000000'; -// 2^160 written as a hex literal (0x1 + 40 zeros) to avoid the `**` operator. -const ADDRESS_MODULUS = 0x10000000000000000000000000000000000000000n; - const DOMAIN_TYPE = [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, @@ -187,7 +186,7 @@ describe('extractSignatureAddresses', () => { expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); }); - it('ignores an array-typed field whose value is not an array', () => { + it('flags overflow when an address-array field value is not an array', () => { const data = build( 'Airdrop', { @@ -199,6 +198,44 @@ describe('extractSignatureAddresses', () => { { recipients: 'not-an-array', to: ADDR_A }, ); expect(addressesOf(data)).toStrictEqual([ADDR_A]); + expect(extractSignatureAddresses(data).overflow).toBe(true); + }); + + it('does not flag overflow when a non-address array type is not an array', () => { + const data = build( + 'T', + { + T: [ + { name: 'amounts', type: 'uint256[]' }, + { name: 'to', type: 'address' }, + ], + }, + { amounts: 'not-an-array', to: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([ADDR_A]); + expect(result.overflow).toBe(false); + }); + + it('treats `address[abc]` as an array (signer matches on a trailing `]`)', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[abc]' }] }, + { recipients: [ADDR_A, ADDR_B] }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); + }); + + it('treats a custom type named `address[]` as a struct (signer schema first)', () => { + const data = build( + 'Mail', + { + Mail: [{ name: 'wrapper', type: 'address[]' }], + 'address[]': [{ name: 'to', type: 'address' }], + }, + { wrapper: { to: ADDR_A } }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); }); it('extracts an arbitrarily-named address field in an unknown schema', () => { @@ -291,6 +328,23 @@ describe('extractSignatureAddresses', () => { ]); }); + it('matches excludeFields exactly (does not collect-skip `Spender` for `spender`)', () => { + const data = build( + 'Permit', + { + Permit: [ + { name: 'Spender', type: 'address' }, + { name: 'to', type: 'address' }, + ], + }, + { Spender: ADDR_A, to: ADDR_B }, + ); + expect(addressesOf(data, { excludeFields: ['spender'] })).toStrictEqual([ + ADDR_A, + ADDR_B, + ]); + }); + it('only excludes fields at the top level', () => { const data = build( 'Order', @@ -352,11 +406,7 @@ describe('extractSignatureAddresses', () => { }); it('normalizes a non-negative integer number value', () => { - const data = build( - 'X', - { X: [{ name: 'a', type: 'address' }] }, - { a: 1 }, - ); + const data = build('X', { X: [{ name: 'a', type: 'address' }] }, { a: 1 }); expect(addressesOf(data)).toStrictEqual([ '0x0000000000000000000000000000000000000001', ]); @@ -389,10 +439,10 @@ describe('extractSignatureAddresses', () => { expect(addressesOf(data)).toStrictEqual([mixed.toLowerCase()]); }); - it('reduces an oversized decimal-encoded address to the signed address', () => { - // The signer reduces an `address` mod 2^160, so `value + 2^160` signs as - // `value`. The extractor must resolve it to the same address. - const oversized = (BigInt(ADDR_A) + ADDRESS_MODULUS).toString(10); + it('takes the leading 20 bytes of an oversized decimal address (ADDR_A * 256 + 0x42)', () => { + // The signer encodes an address as big-endian bytes and keeps the high / + // first 20 bytes, so a trailing extra byte is dropped. + const oversized = (BigInt(ADDR_A) * 256n + 0x42n).toString(10); const data = build( 'X', { X: [{ name: 'a', type: 'address' }] }, @@ -401,6 +451,61 @@ describe('extractSignatureAddresses', () => { expect(addressesOf(data)).toStrictEqual([ADDR_A]); }); + it('takes the leading 20 bytes of an oversized hex address', () => { + const oversizedHex = `0x${ADDR_A.slice(2)}42`; + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: oversizedHex }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('agrees with eth-sig-util encodeData / eip712Hash on leading-20-byte addresses', () => { + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const domain = { + name: 't', + version: '1', + chainId: 1, + verifyingContract: ADDR_C, + }; + const oversized = (BigInt(ADDR_A) * 256n + 0x42n).toString(10); + const canonical = build('Mail', { Mail: types.Mail }, { to: ADDR_A }); + const shifted = build('Mail', { Mail: types.Mail }, { to: oversized }); + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: oversized }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: ADDR_A }, + types, + SignTypedDataVersion.V4, + ), + ); + expect( + TypedDataUtils.eip712Hash( + { types, primaryType: 'Mail', domain, message: { to: oversized } }, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.eip712Hash( + { types, primaryType: 'Mail', domain, message: { to: ADDR_A } }, + SignTypedDataVersion.V4, + ), + ); + expect(addressesOf(shifted)).toStrictEqual([ADDR_A]); + expect(addressesOf(canonical)).toStrictEqual([ADDR_A]); + }); + it('bounds traversal work for a very large array', () => { const huge = Array.from({ length: 100000 }, () => ADDR_A); const data = build( @@ -502,7 +607,7 @@ describe('extractSignatureAddresses', () => { }); }); - it('ignores a struct-typed field whose value is not an object', () => { + it('flags overflow when an address-bearing struct field value is not an object', () => { const data = build( 'Order', { @@ -515,6 +620,41 @@ describe('extractSignatureAddresses', () => { { inner: 'not-an-object', to: ADDR_A }, ); expect(addressesOf(data)).toStrictEqual([ADDR_A]); + expect(extractSignatureAddresses(data).overflow).toBe(true); + }); + + it('does not flag overflow for a cyclic non-address struct whose value is not an object', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'inner', type: 'Loop' }, + { name: 'to', type: 'address' }, + ], + Loop: [{ name: 'next', type: 'Loop' }], + }, + { inner: 'not-an-object', to: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([ADDR_A]); + expect(result.overflow).toBe(false); + }); + + it('does not flag overflow when a non-address struct value is not an object', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'inner', type: 'Inner' }, + { name: 'to', type: 'address' }, + ], + Inner: [{ name: 'amount', type: 'uint256' }], + }, + { inner: 'not-an-object', to: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([ADDR_A]); + expect(result.overflow).toBe(false); }); it('returns [] for nullish payloads, missing types, or unknown primaryType', () => { diff --git a/packages/phishing-controller/src/signature-address-extraction.ts b/packages/phishing-controller/src/signature-address-extraction.ts index 96d9eb6e6d9..e0497185a73 100644 --- a/packages/phishing-controller/src/signature-address-extraction.ts +++ b/packages/phishing-controller/src/signature-address-extraction.ts @@ -2,11 +2,6 @@ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const HEX_STRING_REGEX = /^0x[0-9a-fA-F]+$/u; const DECIMAL_STRING_REGEX = /^[0-9]+$/u; -// The address space (2^160), written as a literal (0x1 followed by 40 hex -// zeros) so the value is not produced with the `**` operator, which some build -// targets down-compile to `Math.pow` and cannot evaluate on BigInt operands. -// Values are reduced into this space, as the signer does. -const ADDRESS_MODULUS = 0x10000000000000000000000000000000000000000n; // Cap the number of addresses returned for a single signature. A legitimate // signature references far fewer; exceeding this is treated as unusual and @@ -39,8 +34,10 @@ export type ExtractedSignatureAddresses = { fields: Record; /** * True when the message could not be fully walked: more distinct addresses - * than the cap, or traversal stopped by the depth or work budget. Some - * addresses may be unscanned, so the caller should surface a caution. + * than the cap, traversal stopped by the depth or work budget, or an + * address-bearing type could not be walked (array type with a non-array + * value, or struct type with a non-object value). Some addresses may be + * unscanned, so the caller should surface a caution. */ overflow: boolean; }; @@ -55,53 +52,61 @@ export type ExtractSignatureAddressesOptions = { exclude?: string[]; /** * Top-level field names to skip, used to avoid a duplicate scan/alert for a - * field already handled elsewhere (e.g. permit `spender`). Only applied to - * the primary type, not nested structs. + * field already handled elsewhere (e.g. permit `spender`). Names must match + * the declared EIP-712 field exactly. Only applied to the primary type + * (depth 0), not nested structs. */ excludeFields?: string[]; }; +/** + * Encode a non-negative integer as big-endian hex (even length) and take the + * leading 20 bytes. + * + * @param numeric - A non-negative integer. + * @returns Canonical lower-case 20-byte address. + */ +function leadingTwentyBytesFromInteger(numeric: bigint): string { + let digits = numeric.toString(16); + if (digits.length % 2 === 1) { + digits = `0${digits}`; + } + return `0x${digits.slice(0, 40).padStart(40, '0')}`; +} + /** * Reduce an `address`-typed value to canonical 20-byte hex. * * The signer accepts more than canonical hex for an `address` field (hex of any - * length, or a decimal string) and reduces it into the 20-byte address space, - * so matching only `0x` + 40 hex would miss an address encoded in another form. - * Values are reduced the same way the signer does and returned in a single - * canonical form for de-duping. + * length, or a decimal string) and takes the high / leading 20 bytes of the + * big-endian encoding (`reallyStrangeAddressToBytes(value).subarray(0, 20)` / + * `hexToBytes(value).subarray(0, 20)` in `@metamask/eth-sig-util`), so matching + * only `0x` + 40 hex would miss an address encoded in another form. * * @param value - The raw field value from the message. * @returns Canonical lower-case address, or undefined if not address-like. */ function normalizeAddress(value: unknown): string | undefined { - let numeric: bigint; - - // The regexes and integer check below only admit values `BigInt` accepts, so - // the conversion cannot throw. if (typeof value === 'string') { const trimmed = value.trim(); - if ( - !HEX_STRING_REGEX.test(trimmed) && - !DECIMAL_STRING_REGEX.test(trimmed) - ) { - return undefined; + if (HEX_STRING_REGEX.test(trimmed)) { + let digits = trimmed.slice(2); + if (digits.length % 2 === 1) { + digits = `0${digits}`; + } + return `0x${digits.slice(0, 40).padStart(40, '0').toLowerCase()}`; + } + if (DECIMAL_STRING_REGEX.test(trimmed)) { + return leadingTwentyBytesFromInteger(BigInt(trimmed)); } - numeric = BigInt(trimmed); - } else if ( - typeof value === 'number' && - Number.isInteger(value) && - value >= 0 - ) { - numeric = BigInt(value); - } else { return undefined; } - // Reduce into the 20-byte address space, matching how the signer encodes an - // `address` field, so non-canonical encodings resolve to the signed address. - numeric %= ADDRESS_MODULUS; + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { + return leadingTwentyBytesFromInteger(BigInt(value)); + } - return `0x${numeric.toString(16).padStart(40, '0')}`; + return undefined; } /** @@ -112,6 +117,10 @@ function normalizeAddress(value: unknown): string | undefined { * arrays. Matching on the declared type rather than the field name means custom * and unknown message shapes are covered without per-protocol handling. * + * Type dispatch matches the signer: a custom struct in `types` is walked first + * (even if its name looks like `address` or `address[]`), then `address`, then + * types whose name ends in `]` as arrays. + * * `domain` is not traversed; its `verifyingContract` is expected to be scanned * separately by the caller. * @@ -119,12 +128,13 @@ function normalizeAddress(value: unknown): string | undefined { * @param options - Optional configuration. * @param options.exclude - Addresses to skip (e.g. the signer). The zero * address is always excluded. - * @param options.excludeFields - Top-level field names to skip, used to avoid a - * duplicate scan/alert for a field already handled elsewhere (e.g. permit - * `spender`). Only applied to the primary type, not nested structs. + * @param options.excludeFields - Top-level field names to skip. Names must + * match the declared EIP-712 field exactly. Only applied to the primary type + * (depth 0), not nested structs. * @returns Up to `MAX_SIGNATURE_ADDRESSES` distinct canonical addresses, the * field each was found under, and whether the message could not be fully walked - * (address cap, depth limit, or work budget reached). + * (address cap, depth limit, work budget, or an unwalkable address-bearing + * value). */ export function extractSignatureAddresses( typedData: @@ -161,15 +171,14 @@ export function extractSignatureAddresses( } } - const excludedFields = new Set( - (options.excludeFields ?? []).map((field) => field.toLowerCase()), - ); + const excludedFields = new Set(options.excludeFields ?? []); // Canonical address -> the field name it was first found under. const found = new Map(); // Set when the message could not be fully walked, so some addresses may be - // unscanned: the address cap, the depth limit, or the work budget was hit. + // unscanned: the address cap, the depth limit, the work budget, or an + // address-bearing type whose value could not be walked. let overflow = false; // Total nodes walked, bounded by MAX_TRAVERSAL_NODES. @@ -185,6 +194,44 @@ export function extractSignatureAddresses( return false; }; + /** + * Whether `type` can contain `address` values: the `address` primitive, an + * array of an address-bearing type, or a custom struct that contains one. + * + * @param type - The declared EIP-712 type. + * @param seen - Types already inspected, to break recursive structs. + * @returns True when walking this type can yield addresses. + */ + function isAddressBearing( + type: string, + seen: Set = new Set(), + ): boolean { + if (seen.has(type)) { + return false; + } + seen.add(type); + + const structFields = schema[type]; + if (Array.isArray(structFields)) { + return structFields.some( + (field) => + Boolean(field) && + typeof field.type === 'string' && + isAddressBearing(field.type, seen), + ); + } + + if (type === 'address') { + return true; + } + + if (type.endsWith(']')) { + return isAddressBearing(type.slice(0, type.lastIndexOf('[')), seen); + } + + return false; + } + /** * Record a candidate address value under a field name, applying exclusions, * de-duplication, and the distinct-address cap. @@ -221,6 +268,9 @@ export function extractSignatureAddresses( } const structFields = schema[structName]; if (!Array.isArray(structFields) || !value || typeof value !== 'object') { + if (Array.isArray(structFields) && isAddressBearing(structName)) { + overflow = true; + } return; } for (const field of structFields) { @@ -232,8 +282,9 @@ export function extractSignatureAddresses( typeof field.name !== 'string' || typeof field.type !== 'string' || // Field exclusions only apply to the primary type (depth 0), matching - // the top-level field a dedicated caller already covers. - (depth === 0 && excludedFields.has(field.name.toLowerCase())) + // the top-level field a dedicated caller already covers. Names must + // match the declared EIP-712 field exactly. + (depth === 0 && excludedFields.has(field.name)) ) { continue; } @@ -247,7 +298,11 @@ export function extractSignatureAddresses( } /** - * Walk a single field value, handling arrays, `address`, and nested structs. + * Walk a single field value, handling custom structs, `address`, and arrays. + * + * Precedence matches `@metamask/eth-sig-util` `encodeField`: a type present + * in the schema is a struct first; otherwise `address`; otherwise a name + * ending in `]` is treated as an array (`type.slice(0, lastIndexOf('['))`). * * @param field - The field name. * @param type - The declared EIP-712 type of the field. @@ -265,17 +320,8 @@ export function extractSignatureAddresses( return; } - // Handle one array dimension at a time, e.g. `address[]` or `Type[][]`. - const arrayMatch = type.match(/^(.*)\[\d*\]$/u); - if (arrayMatch) { - if (Array.isArray(value)) { - for (const item of value) { - if (truncated(depth)) { - return; - } - visitField(field, arrayMatch[1], item, depth + 1); - } - } + if (Array.isArray(schema[type])) { + visitStruct(type, value, depth + 1); return; } @@ -284,9 +330,18 @@ export function extractSignatureAddresses( return; } - // Recurse into custom struct types; other primitives carry no address. - if (Array.isArray(schema[type])) { - visitStruct(type, value, depth + 1); + if (type.endsWith(']')) { + if (Array.isArray(value)) { + const innerType = type.slice(0, type.lastIndexOf('[')); + for (const item of value) { + if (truncated(depth)) { + return; + } + visitField(field, innerType, item, depth + 1); + } + } else if (isAddressBearing(type)) { + overflow = true; + } } } diff --git a/yarn.lock b/yarn.lock index fbead227c86..1673a7c0331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8454,6 +8454,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/transaction-controller": "npm:^69.5.2" "@noble/hashes": "npm:^1.8.0" From 9fb5b04256d6822127ea843ad2525ada3b751bf0 Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Thu, 3 Sep 2026 10:33:39 -0400 Subject: [PATCH 03/10] test(phishing-controller): pin odd-length hex agreement with eth-sig-util Document that 0x-hex (odd or even) is isStrictHexString; oversized hex fails to encode rather than signing as a different address. --- .../src/signature-address-extraction.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts index 0bfffd805e5..2f5bcb11e70 100644 --- a/packages/phishing-controller/src/signature-address-extraction.test.ts +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -506,6 +506,78 @@ describe('extractSignatureAddresses', () => { expect(addressesOf(canonical)).toStrictEqual([ADDR_A]); }); + it('agrees with eth-sig-util on odd-length 0x-hex (isStrictHexString is true)', () => { + // Odd-length 0x-hex is still isStrictHexString in @metamask/utils, so the + // signer hexToBytes-pads the nibble and takes 20 bytes — same as we do. + // It does not fall through to reallyStrangeAddressToBytes. + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const oddShort = '0x1'; + const oddPadded = '0x0111111111111111111111111111111111111111'; + const odd39 = `0x${'1'.repeat(39)}`; + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: oddShort }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: '0x0000000000000000000000000000000000000001' }, + types, + SignTypedDataVersion.V4, + ), + ); + expect(addressesOf(build('Mail', { Mail: types.Mail }, { to: oddShort }))) + .toStrictEqual(['0x0000000000000000000000000000000000000001']); + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: odd39 }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: oddPadded }, + types, + SignTypedDataVersion.V4, + ), + ); + expect(addressesOf(build('Mail', { Mail: types.Mail }, { to: odd39 }))) + .toStrictEqual([oddPadded]); + }); + + it('does not treat oversized 0x-hex as a signable address (encoder rejects 21 bytes)', () => { + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const oversizedHex = `0x${ADDR_A.slice(2)}42`; + + expect(() => + TypedDataUtils.encodeData( + 'Mail', + { to: oversizedHex }, + types, + SignTypedDataVersion.V4, + ), + ).toThrow(/21 bytes/u); + + // We still collect the leading 20 bytes. That is an extra scan of a value + // that cannot be signed, not a signed-but-unscanned address. + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: oversizedHex })), + ).toStrictEqual([ADDR_A]); + }); + it('bounds traversal work for a very large array', () => { const huge = Array.from({ length: 100000 }, () => ADDR_A); const data = build( From d63027070f12bc4cb927a49049b1fbd16a6932c1 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 3 Sep 2026 13:50:10 -0500 Subject: [PATCH 04/10] fix(phishing-controller): satisfy oxfmt and yarn constraints Two failures surfaced by the first full CI run on this branch: - `lint:misc:check`: reformat two assertions in `signature-address-extraction.test.ts` per oxfmt. No semantic change. - `constraints`: align the `@metamask/eth-sig-util` devDependency with the rest of the monorepo (`^9.0.0`), which eth-json-rpc-middleware, keyring-controller, message-manager and signature-controller all use. The 41 tests in the suite cross-check directly against `TypedDataUtils.encodeData` and pass unchanged under 9.0.0. --- packages/phishing-controller/package.json | 2 +- .../src/signature-address-extraction.test.ts | 10 ++++++---- yarn.lock | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 2e448b08556..e57f6819778 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -68,7 +68,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/eth-sig-util": "^8.2.0", + "@metamask/eth-sig-util": "^9.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts index 2f5bcb11e70..ef428811754 100644 --- a/packages/phishing-controller/src/signature-address-extraction.test.ts +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -533,8 +533,9 @@ describe('extractSignatureAddresses', () => { SignTypedDataVersion.V4, ), ); - expect(addressesOf(build('Mail', { Mail: types.Mail }, { to: oddShort }))) - .toStrictEqual(['0x0000000000000000000000000000000000000001']); + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: oddShort })), + ).toStrictEqual(['0x0000000000000000000000000000000000000001']); expect( TypedDataUtils.encodeData( @@ -551,8 +552,9 @@ describe('extractSignatureAddresses', () => { SignTypedDataVersion.V4, ), ); - expect(addressesOf(build('Mail', { Mail: types.Mail }, { to: odd39 }))) - .toStrictEqual([oddPadded]); + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: odd39 })), + ).toStrictEqual([oddPadded]); }); it('does not treat oversized 0x-hex as a signable address (encoder rejects 21 bytes)', () => { diff --git a/yarn.lock b/yarn.lock index 60c6d956ab4..f224bffdac4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8315,7 +8315,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/eth-sig-util": "npm:^9.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/transaction-controller": "npm:^69.8.0" "@noble/hashes": "npm:^1.8.0" From 5539d333951e950e5956bb8ccf52a2ab65e5da1a Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Fri, 4 Sep 2026 01:53:10 -0400 Subject: [PATCH 05/10] feat(phishing-controller): allow overriding extractSignatureAddresses cap Callers can pass maxAddresses (default 10, ceiling 50) so clients can raise the budget later without a core change. --- packages/phishing-controller/CHANGELOG.md | 2 +- packages/phishing-controller/src/index.ts | 6 +- .../src/signature-address-extraction.test.ts | 126 +++++++++++++++++- .../src/signature-address-extraction.ts | 46 +++++-- 4 files changed, 165 insertions(+), 15 deletions(-) diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 0406d736314..5de8fb143fe 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Walks the `types` schema from `primaryType`, matching fields by declared type (`address`/`address[]`, including nested structs and arrays) rather than by field name, so custom and unknown message shapes are covered without per-protocol handling. - Normalizes non-canonical `address` encodings (variable-length hex and decimal strings) into canonical lower-case 20-byte hex by taking the leading 20 bytes of the signer-compatible big-endian encoding, and de-duplicates case-insensitively. - Excludes the zero address, a caller-provided `exclude` list (e.g. the signer), and caller-provided top-level `excludeFields`. - - Bounds work with a distinct-address cap (10), a traversal depth limit, and a node budget, reporting `overflow` when the message could not be fully walked. + - Bounds work with a distinct-address cap (default 10, caller-overridable via `maxAddresses`, hard ceiling 50), a traversal depth limit, and a node budget, reporting `overflow` when the message could not be fully walked. Exports `DEFAULT_MAX_SIGNATURE_ADDRESSES` and `MAX_SIGNATURE_ADDRESSES_CEILING`. - Returns the field name each address was found under so callers can attribute alerts. ## [17.4.1] diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index bd8abbdf75d..5a656acf38c 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -38,7 +38,11 @@ export { isAddressScanSupportedChainId, isPhishingDetectionPathBasedHostname, } from './utils.js'; -export { extractSignatureAddresses } from './signature-address-extraction.js'; +export { + extractSignatureAddresses, + DEFAULT_MAX_SIGNATURE_ADDRESSES, + MAX_SIGNATURE_ADDRESSES_CEILING, +} from './signature-address-extraction.js'; export type { ExtractedSignatureAddresses, ExtractSignatureAddressesOptions, diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts index ef428811754..efbf26730cc 100644 --- a/packages/phishing-controller/src/signature-address-extraction.test.ts +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -1,6 +1,10 @@ import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util'; -import { extractSignatureAddresses } from './signature-address-extraction.js'; +import { + DEFAULT_MAX_SIGNATURE_ADDRESSES, + MAX_SIGNATURE_ADDRESSES_CEILING, + extractSignatureAddresses, +} from './signature-address-extraction.js'; const ADDR_A = '0x1111111111111111111111111111111111111111'; const ADDR_B = '0x2222222222222222222222222222222222222222'; @@ -609,8 +613,125 @@ describe('extractSignatureAddresses', () => { { recipients: nAddresses(15) }, ); const result = extractSignatureAddresses(data); - expect(result.addresses).toHaveLength(10); + expect(result.addresses).toHaveLength(DEFAULT_MAX_SIGNATURE_ADDRESSES); expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(DEFAULT_MAX_SIGNATURE_ADDRESSES); + }); + + describe('maxAddresses', () => { + const permitBatch = (tokenCount: number) => + build( + 'PermitBatch', + { + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + ], + }, + { + details: nAddresses(tokenCount).map((token) => ({ + token, + amount: '1', + })), + spender: ADDR_D, + sigDeadline: '1', + }, + ); + + it('honors a caller override below the ceiling', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(20) }, + ); + const result = extractSignatureAddresses(data, { maxAddresses: 20 }); + expect(result.addresses).toHaveLength(20); + expect(result.overflow).toBe(false); + expect(result.maxAddresses).toBe(20); + }); + + it('clamps an override above the ceiling', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(MAX_SIGNATURE_ADDRESSES_CEILING + 5) }, + ); + const result = extractSignatureAddresses(data, { maxAddresses: 100 }); + expect(result.addresses).toHaveLength(MAX_SIGNATURE_ADDRESSES_CEILING); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(MAX_SIGNATURE_ADDRESSES_CEILING); + }); + + it('uses the default for non-finite or sub-one values', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(15) }, + ); + for (const maxAddresses of [ + NaN, + Infinity, + -1, + 0, + 0.4, + '20' as unknown as number, + ]) { + const result = extractSignatureAddresses(data, { maxAddresses }); + expect(result.addresses).toHaveLength(DEFAULT_MAX_SIGNATURE_ADDRESSES); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(DEFAULT_MAX_SIGNATURE_ADDRESSES); + } + }); + + it('floors a fractional override', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(15) }, + ); + const result = extractSignatureAddresses(data, { maxAddresses: 12.9 }); + expect(result.addresses).toHaveLength(12); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(12); + }); + + it('does not overflow a 10-token PermitBatch when spender is excluded', () => { + const result = extractSignatureAddresses(permitBatch(10), { + excludeFields: ['spender'], + }); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(false); + }); + + it('overflows an 11-token PermitBatch when spender is excluded', () => { + const result = extractSignatureAddresses(permitBatch(11), { + excludeFields: ['spender'], + }); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(true); + }); + + it('overflows a 10-token PermitBatch when spender is not excluded', () => { + const result = extractSignatureAddresses(permitBatch(10)); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(true); + }); + + it('returns the resolved cap for an unwalkable payload', () => { + expect(extractSignatureAddresses(null, { maxAddresses: 25 })).toStrictEqual( + { + addresses: [], + fields: {}, + overflow: false, + maxAddresses: 25, + }, + ); + }); }); it('flags overflow when the work budget truncates the walk', () => { @@ -678,6 +799,7 @@ describe('extractSignatureAddresses', () => { addresses: [ADDR_A], fields: { [ADDR_A]: 'to' }, overflow: false, + maxAddresses: DEFAULT_MAX_SIGNATURE_ADDRESSES, }); }); diff --git a/packages/phishing-controller/src/signature-address-extraction.ts b/packages/phishing-controller/src/signature-address-extraction.ts index e0497185a73..de014c7a6a7 100644 --- a/packages/phishing-controller/src/signature-address-extraction.ts +++ b/packages/phishing-controller/src/signature-address-extraction.ts @@ -3,10 +3,9 @@ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const HEX_STRING_REGEX = /^0x[0-9a-fA-F]+$/u; const DECIMAL_STRING_REGEX = /^[0-9]+$/u; -// Cap the number of addresses returned for a single signature. A legitimate -// signature references far fewer; exceeding this is treated as unusual and -// surfaced to the caller (via `overflow`) rather than scanned in full. -const MAX_SIGNATURE_ADDRESSES = 10; +export const DEFAULT_MAX_SIGNATURE_ADDRESSES = 10; + +export const MAX_SIGNATURE_ADDRESSES_CEILING = 50; // Limit recursion depth when walking nested types. const MAX_TRAVERSAL_DEPTH = 12; @@ -24,7 +23,7 @@ type Eip712Types = Record; */ export type ExtractedSignatureAddresses = { /** - * Distinct canonical addresses to scan, capped at `MAX_SIGNATURE_ADDRESSES`. + * Distinct canonical addresses to scan, capped at the effective `maxAddresses`. */ addresses: string[]; /** @@ -40,6 +39,10 @@ export type ExtractedSignatureAddresses = { * unscanned, so the caller should surface a caution. */ overflow: boolean; + /** + * Effective address cap after applying the default and ceiling. + */ + maxAddresses: number; }; /** @@ -57,8 +60,25 @@ export type ExtractSignatureAddressesOptions = { * (depth 0), not nested structs. */ excludeFields?: string[]; + /** + * Distinct-address cap for this call. Defaults to + * {@link DEFAULT_MAX_SIGNATURE_ADDRESSES}. Clamped to + * {@link MAX_SIGNATURE_ADDRESSES_CEILING}. Invalid values use the default. + */ + maxAddresses?: number; }; +function resolveMaxAddresses(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_MAX_SIGNATURE_ADDRESSES; + } + const floored = Math.floor(value); + if (floored < 1) { + return DEFAULT_MAX_SIGNATURE_ADDRESSES; + } + return Math.min(floored, MAX_SIGNATURE_ADDRESSES_CEILING); +} + /** * Encode a non-negative integer as big-endian hex (even length) and take the * leading 20 bytes. @@ -131,10 +151,12 @@ function normalizeAddress(value: unknown): string | undefined { * @param options.excludeFields - Top-level field names to skip. Names must * match the declared EIP-712 field exactly. Only applied to the primary type * (depth 0), not nested structs. - * @returns Up to `MAX_SIGNATURE_ADDRESSES` distinct canonical addresses, the - * field each was found under, and whether the message could not be fully walked - * (address cap, depth limit, work budget, or an unwalkable address-bearing - * value). + * @param options.maxAddresses - Distinct-address cap. Defaults to + * {@link DEFAULT_MAX_SIGNATURE_ADDRESSES} and is clamped to + * {@link MAX_SIGNATURE_ADDRESSES_CEILING}. + * @returns Up to `maxAddresses` distinct canonical addresses, the field each + * was found under, whether the message could not be fully walked, and the + * effective cap. */ export function extractSignatureAddresses( typedData: @@ -143,6 +165,7 @@ export function extractSignatureAddresses( | undefined, options: ExtractSignatureAddressesOptions = {}, ): ExtractedSignatureAddresses { + const maxAddresses = resolveMaxAddresses(options.maxAddresses); const types = typedData?.types as Eip712Types | undefined; const primaryType = typedData?.primaryType as string | undefined; const { message } = typedData ?? {}; @@ -155,7 +178,7 @@ export function extractSignatureAddresses( !message || typeof message !== 'object' ) { - return { addresses: [], fields: {}, overflow: false }; + return { addresses: [], fields: {}, overflow: false, maxAddresses }; } // Narrowed alias so the hoisted helpers below see a defined `types`. @@ -244,7 +267,7 @@ export function extractSignatureAddresses( if (!address || excluded.has(address) || found.has(address)) { return; } - if (found.size >= MAX_SIGNATURE_ADDRESSES) { + if (found.size >= maxAddresses) { overflow = true; return; } @@ -351,5 +374,6 @@ export function extractSignatureAddresses( addresses: Array.from(found.keys()), fields: Object.fromEntries(found), overflow, + maxAddresses, }; } From af2bc7095e6a4202c27e5dd274c0de16fb2cbe2f Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Fri, 4 Sep 2026 11:45:41 -0500 Subject: [PATCH 06/10] fix(ci): support fork PR refs in changelog checks --- .../check-merge-queue-changelogs/action.yml | 20 +++++++++---------- .github/workflows/changelog-check.yml | 2 +- .../src/signature-address-extraction.test.ts | 18 ++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/actions/check-merge-queue-changelogs/action.yml b/.github/actions/check-merge-queue-changelogs/action.yml index 644844318c1..3831e5efefa 100644 --- a/.github/actions/check-merge-queue-changelogs/action.yml +++ b/.github/actions/check-merge-queue-changelogs/action.yml @@ -40,23 +40,20 @@ runs: const number = parseInt(match[1], 10); core.setOutput('pr-number', number); - - name: Get pull request branch - id: pr-branch + - name: Get pull request head ref + id: pr-head-ref shell: bash env: - REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ steps.pr-number.outputs.pr-number }} - GH_TOKEN: ${{ inputs.github-token }} run: | - BRANCH=$(gh api "/repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq=.head.ref) - echo "pr-branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "pr-head-ref=refs/pull/${PR_NUMBER}/head" >> "$GITHUB_OUTPUT" - name: Check changelog changes id: changelog-check shell: bash env: BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} - PR_BRANCH: ${{ steps.pr-branch.outputs.pr-branch }} + PR_HEAD_REF: ${{ steps.pr-head-ref.outputs.pr-head-ref }} ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail @@ -69,10 +66,13 @@ runs: BASE_REF="${BASH_REMATCH[1]}" fi - TARGET_REF=$(git merge-base "origin/$BASE_REF" "origin/$PR_BRANCH") + git fetch origin "$PR_HEAD_REF" + PR_HEAD_SHA=$(git rev-parse FETCH_HEAD) + + TARGET_REF=$(git merge-base "origin/$BASE_REF" "$PR_HEAD_SHA") git fetch origin "$TARGET_REF" - UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "origin/$PR_BRANCH" | grep -E 'CHANGELOG\.md$' || true) + UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "$PR_HEAD_SHA" | grep -E 'CHANGELOG\.md$' || true) if [ -n "$UPDATED_CHANGELOGS" ]; then for FILE in $UPDATED_CHANGELOGS; do if [ ! -f "$FILE" ]; then @@ -87,7 +87,7 @@ runs: echo "Checking changelog file: $FILE" git show "$TARGET_REF":"$FILE" > /tmp/base-changelog.md - git show origin/"$PR_BRANCH":"$FILE" > /tmp/pr-changelog.md + git show "$PR_HEAD_SHA":"$FILE" > /tmp/pr-changelog.md node "${ACTION_PATH}/check-changelog-diff.cjs" \ /tmp/base-changelog.md \ diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index b6395b18192..7e2aa79bda8 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -16,7 +16,7 @@ jobs: uses: MetaMask/github-tools/.github/actions/check-changelog@v1 with: base-branch: ${{ github.event.pull_request.base.ref }} - head-ref: ${{ github.head_ref }} + head-ref: refs/pull/${{ github.event.pull_request.number }}/head labels: ${{ toJSON(github.event.pull_request.labels) }} pr-number: ${{ github.event.pull_request.number }} repo: ${{ github.repository }} diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts index efbf26730cc..8207eaa9b0a 100644 --- a/packages/phishing-controller/src/signature-address-extraction.test.ts +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -619,7 +619,7 @@ describe('extractSignatureAddresses', () => { }); describe('maxAddresses', () => { - const permitBatch = (tokenCount: number) => + const permitBatch = (tokenCount: number): TypedDataFixture => build( 'PermitBatch', { @@ -723,14 +723,14 @@ describe('extractSignatureAddresses', () => { }); it('returns the resolved cap for an unwalkable payload', () => { - expect(extractSignatureAddresses(null, { maxAddresses: 25 })).toStrictEqual( - { - addresses: [], - fields: {}, - overflow: false, - maxAddresses: 25, - }, - ); + expect( + extractSignatureAddresses(null, { maxAddresses: 25 }), + ).toStrictEqual({ + addresses: [], + fields: {}, + overflow: false, + maxAddresses: 25, + }); }); }); From f5a3236927ef497e10d5f6b431a0bf3704ab62d9 Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Tue, 8 Sep 2026 14:56:02 -0400 Subject: [PATCH 07/10] revert: drop fork-only changelog CI workaround Those .github changes are unrelated to extractSignatureAddresses and are not required to merge this PR. --- .../check-merge-queue-changelogs/action.yml | 20 +++++++++---------- .github/workflows/changelog-check.yml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/check-merge-queue-changelogs/action.yml b/.github/actions/check-merge-queue-changelogs/action.yml index 3831e5efefa..644844318c1 100644 --- a/.github/actions/check-merge-queue-changelogs/action.yml +++ b/.github/actions/check-merge-queue-changelogs/action.yml @@ -40,20 +40,23 @@ runs: const number = parseInt(match[1], 10); core.setOutput('pr-number', number); - - name: Get pull request head ref - id: pr-head-ref + - name: Get pull request branch + id: pr-branch shell: bash env: + REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ steps.pr-number.outputs.pr-number }} + GH_TOKEN: ${{ inputs.github-token }} run: | - echo "pr-head-ref=refs/pull/${PR_NUMBER}/head" >> "$GITHUB_OUTPUT" + BRANCH=$(gh api "/repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq=.head.ref) + echo "pr-branch=$BRANCH" >> "$GITHUB_OUTPUT" - name: Check changelog changes id: changelog-check shell: bash env: BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} - PR_HEAD_REF: ${{ steps.pr-head-ref.outputs.pr-head-ref }} + PR_BRANCH: ${{ steps.pr-branch.outputs.pr-branch }} ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail @@ -66,13 +69,10 @@ runs: BASE_REF="${BASH_REMATCH[1]}" fi - git fetch origin "$PR_HEAD_REF" - PR_HEAD_SHA=$(git rev-parse FETCH_HEAD) - - TARGET_REF=$(git merge-base "origin/$BASE_REF" "$PR_HEAD_SHA") + TARGET_REF=$(git merge-base "origin/$BASE_REF" "origin/$PR_BRANCH") git fetch origin "$TARGET_REF" - UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "$PR_HEAD_SHA" | grep -E 'CHANGELOG\.md$' || true) + UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "origin/$PR_BRANCH" | grep -E 'CHANGELOG\.md$' || true) if [ -n "$UPDATED_CHANGELOGS" ]; then for FILE in $UPDATED_CHANGELOGS; do if [ ! -f "$FILE" ]; then @@ -87,7 +87,7 @@ runs: echo "Checking changelog file: $FILE" git show "$TARGET_REF":"$FILE" > /tmp/base-changelog.md - git show "$PR_HEAD_SHA":"$FILE" > /tmp/pr-changelog.md + git show origin/"$PR_BRANCH":"$FILE" > /tmp/pr-changelog.md node "${ACTION_PATH}/check-changelog-diff.cjs" \ /tmp/base-changelog.md \ diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 7e2aa79bda8..b6395b18192 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -16,7 +16,7 @@ jobs: uses: MetaMask/github-tools/.github/actions/check-changelog@v1 with: base-branch: ${{ github.event.pull_request.base.ref }} - head-ref: refs/pull/${{ github.event.pull_request.number }}/head + head-ref: ${{ github.head_ref }} labels: ${{ toJSON(github.event.pull_request.labels) }} pr-number: ${{ github.event.pull_request.number }} repo: ${{ github.repository }} From 541dff0d9dd7d1bc164a9b2cc5b7a9100d1fc125 Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Wed, 9 Sep 2026 12:38:23 -0400 Subject: [PATCH 08/10] chore(phishing-controller): confine changelog diff to Unreleased The earlier automated conflict resolution also dropped a blank line in a released section. Restore that section verbatim from main so the only changelog delta on this branch is the Unreleased entry. --- packages/phishing-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 02abfcc2bdc..e3ac3a22419 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -279,6 +279,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/base-controller` from `^8.0.1` to `^8.4.0` ([#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632)) - Bump `@metamask/controller-utils` from `^11.11.0` to `^11.14.0` ([#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) + - Bump `@noble/hashes` from `^1.4.0` to `^1.8.0` ([#6101](https://github.com/MetaMask/core/pull/6101)) ## [13.1.0] From fd1bfcef4d78ed77bf886557ad5be41c9bc4ac39 Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Thu, 10 Sep 2026 11:55:52 -0400 Subject: [PATCH 09/10] chore(phishing-controller): link Unreleased changelog entry to #10170 The changelog check requires user-facing entries to cite the current PR. --- packages/phishing-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index ecc3e6a488e..c6bc1b0af55 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `extractSignatureAddresses` utility, plus `ExtractedSignatureAddresses` and `ExtractSignatureAddressesOptions` types, to collect the `address`-typed values from an EIP-712 typed-data message for real-time address scanning ([#9875](https://github.com/MetaMask/core/pull/9875)) +- Add `extractSignatureAddresses` utility, plus `ExtractedSignatureAddresses` and `ExtractSignatureAddressesOptions` types, to collect the `address`-typed values from an EIP-712 typed-data message for real-time address scanning ([#10170](https://github.com/MetaMask/core/pull/10170)) - Walks the `types` schema from `primaryType`, matching fields by declared type (`address`/`address[]`, including nested structs and arrays) rather than by field name, so custom and unknown message shapes are covered without per-protocol handling. - Normalizes non-canonical `address` encodings (variable-length hex and decimal strings) into canonical lower-case 20-byte hex by taking the leading 20 bytes of the signer-compatible big-endian encoding, and de-duplicates case-insensitively. - Excludes the zero address, a caller-provided `exclude` list (e.g. the signer), and caller-provided top-level `excludeFields`. From 7b367e9134c0b2dbea7a9ae9aebb31a66d742b06 Mon Sep 17 00:00:00 2001 From: wzrdk3lly Date: Thu, 10 Sep 2026 12:25:50 -0400 Subject: [PATCH 10/10] fix(phishing-controller): accept 0X-prefixed addresses in extractSignatureAddresses Match isStrictHexString so a signable uppercase 0X prefix is scanned, not dropped. --- .../src/signature-address-extraction.test.ts | 29 +++++++++++++++++++ .../src/signature-address-extraction.ts | 4 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts index 8207eaa9b0a..7e53a162469 100644 --- a/packages/phishing-controller/src/signature-address-extraction.test.ts +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -561,6 +561,35 @@ describe('extractSignatureAddresses', () => { ).toStrictEqual([oddPadded]); }); + it('agrees with eth-sig-util on a 0X-prefixed address (isStrictHexString is true)', () => { + // The signer treats 0X-hex as isStrictHexString (regex is case-insensitive) + // and encodes it as the same address as 0x-hex. + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const upperPrefix = `0X${ADDR_A.slice(2)}`; + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: upperPrefix }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: ADDR_A }, + types, + SignTypedDataVersion.V4, + ), + ); + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: upperPrefix })), + ).toStrictEqual([ADDR_A]); + }); + it('does not treat oversized 0x-hex as a signable address (encoder rejects 21 bytes)', () => { const types = { EIP712Domain: DOMAIN_TYPE, diff --git a/packages/phishing-controller/src/signature-address-extraction.ts b/packages/phishing-controller/src/signature-address-extraction.ts index de014c7a6a7..1f401bcdbec 100644 --- a/packages/phishing-controller/src/signature-address-extraction.ts +++ b/packages/phishing-controller/src/signature-address-extraction.ts @@ -1,6 +1,8 @@ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -const HEX_STRING_REGEX = /^0x[0-9a-fA-F]+$/u; +// Same as `@metamask/utils` `isStrictHexString` (`/^0x[0-9a-f]+$/iu`): the +// signer accepts a `0X` prefix, so we must too. +const HEX_STRING_REGEX = /^0x[0-9a-f]+$/iu; const DECIMAL_STRING_REGEX = /^[0-9]+$/u; export const DEFAULT_MAX_SIGNATURE_ADDRESSES = 10;