diff --git a/package.json b/package.json index 7eb26aa85..80794aceb 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@ethereumjs/tx": "^4.1.2", + "@noble/hashes": "^1.3.1", "@types/debug": "^4.1.7", "debug": "^4.3.4", "semver": "^7.3.8", diff --git a/src/hex.test.ts b/src/hex.test.ts index 5fcc8a38b..5e5708363 100644 --- a/src/hex.test.ts +++ b/src/hex.test.ts @@ -1,10 +1,14 @@ import { + Hex, add0x, assertIsHexString, assertIsStrictHexString, + isValidChecksumAddress, isHexString, isStrictHexString, + isValidHexAddress, remove0x, + getChecksumAddress, } from './hex'; describe('isHexString', () => { @@ -151,6 +155,92 @@ describe('assertIsStrictHexString', () => { }); }); +describe('isValidHexAddress', () => { + it.each([ + '0x0000000000000000000000000000000000000000' as Hex, + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Hex, + ])('returns true for a valid prefixed hex address', (hexString) => { + expect(isValidHexAddress(hexString)).toBe(true); + }); + + it.each([ + '0000000000000000000000000000000000000000', + 'd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ])('returns false for a valid non-prefixed hex address', (hexString) => { + // @ts-expect-error - testing invalid input + expect(isValidHexAddress(hexString)).toBe(false); + }); + + it.each([ + '12345g', + '1234567890abcdefg', + '1234567890abcdefG', + '1234567890abcdefABCDEFg', + '1234567890abcdefABCDEF1234567890abcdefABCDEFg', + '0x', + '0x0', + '0x12345g', + '0x1234567890abcdefg', + '0x1234567890abcdefG', + '0x1234567890abcdefABCDEFg', + '0x1234567890abcdefABCDEF1234567890abcdefABCDEFg', + '0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045', + '0xCF5609B003B2776699EEA1233F7C82D5695CC9AA', + '0Xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ])('returns false for an invalid hex address', (hexString) => { + // @ts-expect-error - testing invalid input + expect(isValidHexAddress(hexString)).toBe(false); + }); +}); + +describe('getChecksumAddress', () => { + it('returns the checksum address for a valid hex address', () => { + expect( + getChecksumAddress('0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed'), + ).toBe('0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'); + + expect( + getChecksumAddress('0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359'), + ).toBe('0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359'); + + expect( + getChecksumAddress('0x52908400098527886e0f7030069857d2e4169ee7'), + ).toBe('0x52908400098527886E0F7030069857D2E4169EE7'); + + expect( + getChecksumAddress('0xde709f2102306220921060314715629080e2fb77'), + ).toBe('0xde709f2102306220921060314715629080e2fb77'); + + expect( + getChecksumAddress('0x0000000000000000000000000000000000000000'), + ).toBe('0x0000000000000000000000000000000000000000'); + }); + + it('throws for an invalid hex address', () => { + expect(() => getChecksumAddress('0x')).toThrow('Invalid hex address.'); + }); +}); + +describe('isValidChecksumAddress', () => { + it.each([ + '0x0000000000000000000000000000000000000000' as Hex, + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Hex, + '0xCf5609B003B2776699eEA1233F7C82D5695cC9AA' as Hex, + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Hex, + '0x8617E340B3D01FA5F11F306F4090FD50E238070D' as Hex, + ])('returns true for a valid checksum address', (hexString) => { + expect(isValidChecksumAddress(hexString)).toBe(true); + }); + + it.each([ + '0xz' as Hex, + '0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045' as Hex, + '0xCF5609B003B2776699EEA1233F7C82D5695CC9AA' as Hex, + ])('returns false for an invalid checksum address', (hexString) => { + expect(isValidChecksumAddress(hexString)).toBe(false); + }); +}); + describe('add0x', () => { it('adds a 0x-prefix to a string', () => { expect(add0x('12345')).toBe('0x12345'); diff --git a/src/hex.ts b/src/hex.ts index 9cb4d92d7..39bf32574 100644 --- a/src/hex.ts +++ b/src/hex.ts @@ -1,6 +1,8 @@ +import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; import { is, pattern, string, Struct } from 'superstruct'; import { assert } from './assert'; +import { bytesToHex } from './bytes'; export type Hex = `0x${string}`; @@ -9,6 +11,14 @@ export const StrictHexStruct = pattern(string(), /^0x[0-9a-f]+$/iu) as Struct< Hex, null >; +export const HexAddressStruct = pattern( + string(), + /^0x[0-9a-f]{40}$/u, +) as Struct; +export const HexChecksumAddressStruct = pattern( + string(), + /^0x[0-9a-fA-F]{40}$/u, +) as Struct; /** * Check if a string is a valid hex string. @@ -55,6 +65,58 @@ export function assertIsStrictHexString(value: unknown): asserts value is Hex { ); } +/** + * Validate that the passed prefixed hex string is an all-lowercase + * hex address, or a valid mixed-case checksum address. + * + * @param possibleAddress - Input parameter to check against. + * @returns Whether or not the input is a valid hex address. + */ +export function isValidHexAddress(possibleAddress: Hex) { + return ( + is(possibleAddress, HexAddressStruct) || + isValidChecksumAddress(possibleAddress) + ); +} + +/** + * Encode a passed hex string as an ERC-55 mixed-case checksum address. + * + * @param address - 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) { + 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('')}`; +} + +/** + * Validate that the passed hex string is a valid ERC-55 mixed-case + * checksum address. + * + * @param possibleChecksum - The hex address to check. + * @returns True if the address is a checksum address. + */ +export function isValidChecksumAddress(possibleChecksum: Hex) { + if (!is(possibleChecksum, HexChecksumAddressStruct)) { + return false; + } + + return getChecksumAddress(possibleChecksum) === possibleChecksum; +} + /** * Add the `0x`-prefix to a hexadecimal string. If the string already has the * prefix, it is returned as-is. diff --git a/yarn.lock b/yarn.lock index 92902c418..1cdcb33d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1069,6 +1069,7 @@ __metadata: "@metamask/eslint-config-jest": ^11.0.0 "@metamask/eslint-config-nodejs": ^11.0.1 "@metamask/eslint-config-typescript": ^11.0.0 + "@noble/hashes": ^1.3.1 "@types/debug": ^4.1.7 "@types/jest": ^28.1.7 "@types/node": ^17.0.23 @@ -1099,19 +1100,19 @@ __metadata: languageName: unknown linkType: soft -"@noble/curves@npm:1.0.0, @noble/curves@npm:~1.0.0": - version: 1.0.0 - resolution: "@noble/curves@npm:1.0.0" +"@noble/curves@npm:1.1.0, @noble/curves@npm:~1.1.0": + version: 1.1.0 + resolution: "@noble/curves@npm:1.1.0" dependencies: - "@noble/hashes": 1.3.0 - checksum: 6bcef44d626c640dc8961819d68dd67dffb907e3b973b7c27efe0ecdd9a5c6ce62c7b9e3dfc930c66605dced7f1ec0514d191c09a2ce98d6d52b66e3315ffa79 + "@noble/hashes": 1.3.1 + checksum: 2658cdd3f84f71079b4e3516c47559d22cf4b55c23ac8ee9d2b1f8e5b72916d9689e59820e0f9d9cb4a46a8423af5b56dc6bb7782405c88be06a015180508db5 languageName: node linkType: hard -"@noble/hashes@npm:1.3.0, @noble/hashes@npm:^1.3.0, @noble/hashes@npm:~1.3.0": - version: 1.3.0 - resolution: "@noble/hashes@npm:1.3.0" - checksum: d7ddb6d7c60f1ce1f87facbbef5b724cdea536fc9e7f59ae96e0fc9de96c8f1a2ae2bdedbce10f7dcc621338dfef8533daa73c873f2b5c87fa1a4e05a95c2e2e +"@noble/hashes@npm:1.3.1, @noble/hashes@npm:^1.3.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.1": + version: 1.3.1 + resolution: "@noble/hashes@npm:1.3.1" + checksum: 7fdefc0f7a0c1ec27acc6ff88841793e3f93ec4ce6b8a6a12bfc0dd70ae6b7c4c82fe305fdfeda1735d5ad4a9eebe761e6693b3d355689c559e91242f4bc95b1 languageName: node linkType: hard @@ -1211,24 +1212,24 @@ __metadata: languageName: node linkType: hard -"@scure/bip32@npm:1.3.0": - version: 1.3.0 - resolution: "@scure/bip32@npm:1.3.0" +"@scure/bip32@npm:1.3.1": + version: 1.3.1 + resolution: "@scure/bip32@npm:1.3.1" dependencies: - "@noble/curves": ~1.0.0 - "@noble/hashes": ~1.3.0 + "@noble/curves": ~1.1.0 + "@noble/hashes": ~1.3.1 "@scure/base": ~1.1.0 - checksum: 6eae997f9bdf41fe848134898960ac48e645fa10e63d579be965ca331afd0b7c1b8ebac170770d237ab4099dafc35e5a82995384510025ccf2abe669f85e8918 + checksum: 394d65f77a40651eba21a5096da0f4233c3b50d422864751d373fcf142eeedb94a1149f9ab1dbb078086dab2d0bc27e2b1afec8321bf22d4403c7df2fea5bfe2 languageName: node linkType: hard -"@scure/bip39@npm:1.2.0": - version: 1.2.0 - resolution: "@scure/bip39@npm:1.2.0" +"@scure/bip39@npm:1.2.1": + version: 1.2.1 + resolution: "@scure/bip39@npm:1.2.1" dependencies: "@noble/hashes": ~1.3.0 "@scure/base": ~1.1.0 - checksum: 980d761f53e63de04a9e4db840eb13bfb1bd1b664ecb04a71824c12c190f4972fd84146f3ed89b2a8e4c6bd2c17c15f8b592b7ac029e903323b0f9e2dae6916b + checksum: c5bd6f1328fdbeae2dcdd891825b1610225310e5e62a4942714db51066866e4f7bef242c7b06a1b9dcc8043a4a13412cf5c5df76d3b10aa9e36b82e9b6e3eeaa languageName: node linkType: hard @@ -3223,14 +3224,14 @@ __metadata: linkType: hard "ethereum-cryptography@npm:^2.0.0": - version: 2.0.0 - resolution: "ethereum-cryptography@npm:2.0.0" + version: 2.1.0 + resolution: "ethereum-cryptography@npm:2.1.0" dependencies: - "@noble/curves": 1.0.0 - "@noble/hashes": 1.3.0 - "@scure/bip32": 1.3.0 - "@scure/bip39": 1.2.0 - checksum: 958f8aab2d1b32aa759fb27a27877b3647410e8bb9aca7d65d1d477db4864cf7fc46b918eb52a1e246c25e98ee0a35a632c88b496aeaefa13469ee767a76c8db + "@noble/curves": 1.1.0 + "@noble/hashes": 1.3.1 + "@scure/bip32": 1.3.1 + "@scure/bip39": 1.2.1 + checksum: 47bd69103f0553e5c98e0645c295ca74e0da53a92b8d26237287f528521cd2aa13d5cd1e288c36e59ce885451199cef8e4de424a93c45bacf54a06bdd09946a4 languageName: node linkType: hard