diff --git a/packages/status-js/src/utils/public-key-to-color-hash.test.ts b/packages/status-js/src/utils/public-key-to-color-hash.test.ts new file mode 100644 index 00000000..7544fd08 --- /dev/null +++ b/packages/status-js/src/utils/public-key-to-color-hash.test.ts @@ -0,0 +1,63 @@ +import { + hexToColorHash, + publicKeyToColorHash, +} from './public-key-to-color-hash' + +test('returns color hash from public key', () => { + expect( + publicKeyToColorHash( + '0x04e25da6994ea2dc4ac70727e07eca153ae92bf7609db7befb7ebdceaad348f4fc55bbe90abf9501176301db5aa103fc0eb3bc3750272a26c424a10887db2a7ea8' + ) + ).toEqual([ + [3, 30], + [2, 10], + [5, 5], + [3, 14], + [5, 4], + [4, 19], + [3, 16], + [4, 0], + [5, 28], + [4, 13], + [4, 15], + ]) +}) + +test('returns undefined for invalid public keys', () => { + expect(publicKeyToColorHash('abc')).toBeUndefined() + expect(publicKeyToColorHash('0x01')).toBeUndefined() + expect( + publicKeyToColorHash( + '0x01e25da6994ea2dc4ac70727e07eca153ae92bf7609db7befb7ebdceaad348f4fc55bbe90abf9501176301db5aa103fc0eb3bc3750272a26c424a10887db2a7ea8' + ) + ).toBeUndefined() + expect( + publicKeyToColorHash( + '0x04425da6994ea2dc4ac70727e07eca153ae92bf7609db7befb7ebdceaad348f4fc55bbe90abf9501176301db5aa103fc0eb3bc3750272a26c424a10887db2a7ea8' + ) + ).toBeUndefined() +}) + +test('returns color hash from hex', () => { + expect(hexToColorHash('0', 4, 4)).toEqual([[1, 0]]) + expect(hexToColorHash('1', 4, 4)).toEqual([[1, 1]]) + expect(hexToColorHash('4', 4, 4)).toEqual([[2, 0]]) + expect(hexToColorHash('F', 4, 4)).toEqual([[4, 3]]) +}) + +test('returns color hash from hex with redecued collision resistance', () => { + expect(hexToColorHash('FF', 4, 4)).toEqual([ + [4, 3], + [4, 0], + ]) + expect(hexToColorHash('FC', 4, 4)).toEqual([ + [4, 3], + [4, 0], + ]) + expect(hexToColorHash('FFFF', 4, 4)).toEqual([ + [4, 3], + [4, 0], + [4, 3], + [4, 0], + ]) +}) diff --git a/packages/status-js/src/utils/public-key-to-color-hash.ts b/packages/status-js/src/utils/public-key-to-color-hash.ts new file mode 100644 index 00000000..3aac05cf --- /dev/null +++ b/packages/status-js/src/utils/public-key-to-color-hash.ts @@ -0,0 +1,85 @@ +import * as secp256k1 from 'ethereum-cryptography/secp256k1' + +type ColorHash = number[][] + +const COLOR_HASH_COLORS_COUNT = 32 +const COLOR_HASH_SEGMENT_MAX_LENGTH = 5 + +export function publicKeyToColorHash(publicKey: string): ColorHash | undefined { + const publicKeyHex = publicKey.replace(/^0[xX]/, '') // ensures hexadecimal digits without "base prefix" + + let compressedPublicKeyDigits: string + try { + compressedPublicKeyDigits = + secp256k1.Point.fromHex(publicKeyHex).toHex(true) // validates and adds "sign prefix" too + } catch (error) { + return undefined + } + + const colorHashHex = compressedPublicKeyDigits.slice(43, 63) + const colorHash = hexToColorHash( + colorHashHex, + COLOR_HASH_COLORS_COUNT, + COLOR_HASH_SEGMENT_MAX_LENGTH + ) + + return colorHash +} + +export function hexToColorHash( + hex: string, + colorsCount: number, + segmentLength: number +): ColorHash { + const colorIndices = numberToIndices( + BigInt(`0x${hex}`), + BigInt(colorsCount * segmentLength) + ) + const colorHash = colorIndicesToColorHash(colorIndices, colorsCount) + + return colorHash +} + +function numberToIndices(number: bigint, base: bigint): bigint[] { + const indices: bigint[] = [] + let nextNumber = number + + if (nextNumber === 0n) { + return [0n] + } + + while (nextNumber > 0n) { + const modulo = secp256k1.utils.mod(nextNumber, base) + nextNumber = nextNumber / base // truncates fractional results + + indices.push(modulo) + } + + return indices.reverse() +} + +function colorIndicesToColorHash( + colorIndices: bigint[], + colorsCount: number +): ColorHash { + const colorHash: ColorHash = [] + let previousColorIndex: number | undefined = undefined + + for (const currentColorIndex of colorIndices) { + const colorLength = Math.ceil((Number(currentColorIndex) + 1) / colorsCount) + const nextColorIndex = Number(currentColorIndex % BigInt(colorsCount)) + + let colorIndex: number + if (nextColorIndex !== previousColorIndex) { + colorIndex = nextColorIndex + } else { + colorIndex = Number((currentColorIndex + 1n) % BigInt(colorsCount)) + } + + previousColorIndex = colorIndex + + colorHash.push([colorLength, colorIndex]) + } + + return colorHash +}