From 4a6b68b613685e1c9b18c06477a52846e7a66958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Thu, 26 Jun 2025 21:58:12 +0200 Subject: [PATCH 1/2] chore: getChecksumAddress memoized and faster --- package.json | 2 ++ src/hex.test.ts | 25 ++++++++++++++++++++++++- src/hex.ts | 49 +++++++++++++++++++++++++++++++++---------------- src/index.ts | 17 ++++++++++++++++- yarn.lock | 20 +++++++++++++++++++- 5 files changed, 94 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 6ff5c706c..2b6ae74bd 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", + "lodash.memoize": "^4.1.2", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" @@ -83,6 +84,7 @@ "@ts-bridge/shims": "^0.1.1", "@types/jest": "^28.1.7", "@types/jest-when": "^3.5.3", + "@types/lodash.memoize": "^4.1.9", "@types/node": "~18.18.14", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.43.0", diff --git a/src/hex.test.ts b/src/hex.test.ts index 3de8478fb..e8d2b6748 100644 --- a/src/hex.test.ts +++ b/src/hex.test.ts @@ -8,7 +8,7 @@ import { isStrictHexString, isValidHexAddress, remove0x, - getChecksumAddress, + getChecksumAddressUnmemoized as getChecksumAddress, } from './hex'; describe('isHexString', () => { @@ -211,6 +211,22 @@ describe('getChecksumAddress', () => { getChecksumAddress('0xde709f2102306220921060314715629080e2fb77'), ).toBe('0xde709f2102306220921060314715629080e2fb77'); + expect( + getChecksumAddress('0x8617e340b3d01fa5f11f306f4090fd50e238070d'), + ).toBe('0x8617E340B3D01FA5F11F306F4090FD50E238070D'); + + expect( + getChecksumAddress('0x27b1fdb04752bbc536007a920d24acb045561c26'), + ).toBe('0x27b1fdb04752bbc536007a920d24acb045561c26'); + + expect( + getChecksumAddress('0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb'), + ).toBe('0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB'); + + expect( + getChecksumAddress('0xd1220a0cf47c7b9be7a2e6ba89f429762e7b9adb'), + ).toBe('0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb'); + expect( getChecksumAddress('0x0000000000000000000000000000000000000000'), ).toBe('0x0000000000000000000000000000000000000000'); @@ -228,6 +244,13 @@ describe('isValidChecksumAddress', () => { '0xCf5609B003B2776699eEA1233F7C82D5695cC9AA' as Hex, '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Hex, '0x8617E340B3D01FA5F11F306F4090FD50E238070D' as Hex, + '0x52908400098527886E0F7030069857D2E4169EE7' as Hex, + '0xde709f2102306220921060314715629080e2fb77' as Hex, + '0x27b1fdb04752bbc536007a920d24acb045561c26' as Hex, + '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed' as Hex, + '0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359' as Hex, + '0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB' as Hex, + '0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb' as Hex, ])('returns true for a valid checksum address', (hexString) => { expect(isValidChecksumAddress(hexString)).toBe(true); }); diff --git a/src/hex.ts b/src/hex.ts index 46bdbf648..2664f07d4 100644 --- a/src/hex.ts +++ b/src/hex.ts @@ -1,9 +1,9 @@ import type { Struct } from '@metamask/superstruct'; import { is, pattern, string } from '@metamask/superstruct'; import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; +import memoize from 'lodash.memoize'; import { assert } from './assert'; -import { bytesToHex } from './bytes'; export type Hex = `0x${string}`; @@ -82,27 +82,44 @@ export function isValidHexAddress(possibleAddress: Hex) { /** * Encode a passed hex string as an ERC-55 mixed-case checksum address. + * This is the unmemoized version, primarily used for testing. * - * @param address - The hex address to encode. + * @param hexAddress - The hex address to encode. * @returns The address encoded according to ERC-55. * @see https://eips.ethereum.org/EIPS/eip-55 */ -export function getChecksumAddress(address: Hex): Hex { - assert(is(address, HexChecksumAddressStruct), 'Invalid hex address.'); - const unPrefixed = remove0x(address.toLowerCase()); - const unPrefixedHash = remove0x(bytesToHex(keccak256(unPrefixed))); - return `0x${unPrefixed - .split('') - .map((character, nibbleIndex) => { - const hashCharacter = unPrefixedHash[nibbleIndex]; - assert(is(hashCharacter, string()), 'Hash shorter than address.'); - return parseInt(hashCharacter, 16) > 7 - ? character.toUpperCase() - : character; - }) - .join('')}`; +export function getChecksumAddressUnmemoized(hexAddress: Hex): Hex { + assert(is(hexAddress, HexChecksumAddressStruct), 'Invalid hex address.'); + const address = remove0x(hexAddress).toLowerCase(); + + const hashBytes = keccak256(address); + const { length } = address; + const result = new Array(length); // Pre-allocate array + + for (let i = 0; i < length; i++) { + /* eslint-disable no-bitwise */ + const byteIndex = i >> 1; // Faster than Math.floor(i / 2) + const nibbleIndex = i & 1; // Faster than i % 2 + const byte = hashBytes[byteIndex] as number; + const nibble = nibbleIndex === 0 ? byte >> 4 : byte & 0x0f; + /* eslint-enable no-bitwise */ + + result[i] = nibble >= 8 ? (address[i] as string).toUpperCase() : address[i]; + } + + return `0x${result.join('')}`; } +/** + * Encode a passed hex string as an ERC-55 mixed-case checksum address. + * This function is memoized for performance. + * + * @param hexAddress - The hex address to encode. + * @returns The address encoded according to ERC-55. + * @see https://eips.ethereum.org/EIPS/eip-55 + */ +export const getChecksumAddress = memoize(getChecksumAddressUnmemoized); + /** * Validate that the passed hex string is a valid ERC-55 mixed-case * checksum address. diff --git a/src/index.ts b/src/index.ts index 81c653a0d..1f8c6c24d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,22 @@ export * from './coercers'; export * from './collections'; export * from './encryption-types'; export * from './errors'; -export * from './hex'; +export type { Hex } from './hex'; +export { + HexStruct, + StrictHexStruct, + HexAddressStruct, + HexChecksumAddressStruct, + isHexString, + isStrictHexString, + assertIsHexString, + assertIsStrictHexString, + isValidHexAddress, + getChecksumAddress, + isValidChecksumAddress, + add0x, + remove0x, +} from './hex'; export * from './json'; export * from './keyring'; export * from './logging'; diff --git a/yarn.lock b/yarn.lock index 48b06910e..6bdbf3797 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1081,6 +1081,7 @@ __metadata: "@types/debug": ^4.1.7 "@types/jest": ^28.1.7 "@types/jest-when": ^3.5.3 + "@types/lodash.memoize": ^4.1.9 "@types/node": ~18.18.14 "@types/uuid": ^9.0.8 "@typescript-eslint/eslint-plugin": ^5.43.0 @@ -1098,6 +1099,7 @@ __metadata: jest: ^29.2.2 jest-it-up: ^2.0.2 jest-when: ^3.6.0 + lodash.memoize: ^4.1.2 pony-cause: ^2.1.10 prettier: ^2.7.1 prettier-plugin-packagejson: ^2.3.0 @@ -1551,6 +1553,22 @@ __metadata: languageName: node linkType: hard +"@types/lodash.memoize@npm:^4.1.9": + version: 4.1.9 + resolution: "@types/lodash.memoize@npm:4.1.9" + dependencies: + "@types/lodash": "*" + checksum: d11efe604911aabbf9c49eb02e944de856619d6e0ab348d83be3ff07de245ee605ea71b1f3ee24b5c134286d02625119edf3ac2c0e6aa4732f699b1f4aa55240 + languageName: node + linkType: hard + +"@types/lodash@npm:*": + version: 4.17.19 + resolution: "@types/lodash@npm:4.17.19" + checksum: a75452bd0ed21c9dba98c0d395ef0f9de3220a8410dc07dfb041f84c7f1733b88648f1ba8e62b14f70ff9e935797b618f7060fba40abdf754cdbfb27a99ec27e + languageName: node + linkType: hard + "@types/minimatch@npm:^3.0.3": version: 3.0.5 resolution: "@types/minimatch@npm:3.0.5" @@ -5279,7 +5297,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:4.x": +"lodash.memoize@npm:4.x, lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 9ff3942feeccffa4f1fafa88d32f0d24fdc62fd15ded5a74a5f950ff5f0c6f61916157246744c620173dddf38d37095a92327d5fd3861e2063e736a5c207d089 From e237cb80f362021f285b927937c7ece03dc2a421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Fri, 27 Jun 2025 14:25:37 +0200 Subject: [PATCH 2/2] fix: add more tests + exclude index from coverage --- jest.config.js | 1 + src/hex.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/jest.config.js b/jest.config.js index 6f51ea99d..5ec597fe5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,7 @@ module.exports = { './src/**/*.ts', '!./src/__fixtures__/**/*', '!./src/**/*.test-d.ts', + '!./src/index.ts', ], // The directory where Jest should output its coverage files diff --git a/src/hex.test.ts b/src/hex.test.ts index e8d2b6748..e6a2d4979 100644 --- a/src/hex.test.ts +++ b/src/hex.test.ts @@ -9,6 +9,7 @@ import { isValidHexAddress, remove0x, getChecksumAddressUnmemoized as getChecksumAddress, + getChecksumAddress as getChecksumAddressMemoized, } from './hex'; describe('isHexString', () => { @@ -237,6 +238,36 @@ describe('getChecksumAddress', () => { }); }); +describe('getChecksumAddress (memoized)', () => { + it('memoizes results for repeated calls with the same input', () => { + const address = '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed' as Hex; + const expected = '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'; + + // First call should compute the result + const result1 = getChecksumAddressMemoized(address); + expect(result1).toBe(expected); + + // Second call with the same input should return the cached result + const result2 = getChecksumAddressMemoized(address); + expect(result2).toBe(expected); + + // Results should be the same object reference (memoized) + expect(result1).toBe(result2); + }); + + it('handles different inputs correctly', () => { + const address1 = '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed' as Hex; + const address2 = '0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359' as Hex; + + const result1 = getChecksumAddressMemoized(address1); + const result2 = getChecksumAddressMemoized(address2); + + expect(result1).toBe('0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'); + expect(result2).toBe('0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359'); + expect(result1).not.toBe(result2); + }); +}); + describe('isValidChecksumAddress', () => { it.each([ '0x0000000000000000000000000000000000000000' as Hex,