Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
56 changes: 55 additions & 1 deletion src/hex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
isStrictHexString,
isValidHexAddress,
remove0x,
getChecksumAddress,
getChecksumAddressUnmemoized as getChecksumAddress,
getChecksumAddress as getChecksumAddressMemoized,
} from './hex';

describe('isHexString', () => {
Expand Down Expand Up @@ -211,6 +212,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');
Expand All @@ -221,13 +238,50 @@ 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,
'0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Hex,
'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);
});
Expand Down
49 changes: 33 additions & 16 deletions src/hex.ts
Original file line number Diff line number Diff line change
@@ -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}`;

Expand Down Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 19 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading