diff --git a/packages/types/package.json b/packages/types/package.json index e75266d9ed01..51baccc89314 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -74,7 +74,8 @@ "types": "lib/index.d.ts", "dependencies": { "@chainsafe/ssz": "^0.14.0", - "@lodestar/params": "^1.15.0" + "@lodestar/params": "^1.15.0", + "ethereum-cryptography": "^2.0.0" }, "keywords": [ "ethereum", diff --git a/packages/types/src/bellatrix/sszTypes.ts b/packages/types/src/bellatrix/sszTypes.ts index c6919d04b631..08f0378ef92a 100644 --- a/packages/types/src/bellatrix/sszTypes.ts +++ b/packages/types/src/bellatrix/sszTypes.ts @@ -9,7 +9,7 @@ import { import {ssz as primitiveSsz} from "../primitive/index.js"; import {ssz as phase0Ssz} from "../phase0/index.js"; import {ssz as altairSsz} from "../altair/index.js"; -import {stringType} from "../utils/StringType.js"; +import {stringType} from "../utils/stringType.js"; const { Bytes32, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d90b55909884..6931271aaa29 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -3,6 +3,6 @@ export * as ssz from "./sszTypes.js"; // Typeguards export * from "./utils/typeguards.js"; // String type -export {StringType, stringType} from "./utils/StringType.js"; +export {StringType, stringType} from "./utils/stringType.js"; // Container utils export * from "./utils/container.js"; diff --git a/packages/types/src/primitive/sszTypes.ts b/packages/types/src/primitive/sszTypes.ts index 65c81d1247b9..068a32e2cc17 100644 --- a/packages/types/src/primitive/sszTypes.ts +++ b/packages/types/src/primitive/sszTypes.ts @@ -1,4 +1,5 @@ import {ByteVectorType, UintNumberType, UintBigintType, BooleanType} from "@chainsafe/ssz"; +import {ExecutionAddressType} from "../utils/executionAddress.js"; export const Boolean = new BooleanType(); export const Byte = new UintNumberType(1); @@ -61,4 +62,4 @@ export const BLSPubkey = Bytes48; export const BLSSignature = Bytes96; export const Domain = Bytes32; export const ParticipationFlags = new UintNumberType(1, {setBitwiseOR: true}); -export const ExecutionAddress = Bytes20; +export const ExecutionAddress = new ExecutionAddressType(); diff --git a/packages/types/src/utils/executionAddress.ts b/packages/types/src/utils/executionAddress.ts new file mode 100644 index 000000000000..9d555c016f04 --- /dev/null +++ b/packages/types/src/utils/executionAddress.ts @@ -0,0 +1,48 @@ +import {keccak256} from "ethereum-cryptography/keccak.js"; +import {ByteVectorType} from "@chainsafe/ssz"; + +export type ByteVector = Uint8Array; + +export class ExecutionAddressType extends ByteVectorType { + constructor() { + super(20, {typeName: "ExecutionAddress"}); + } + toJson(value: ByteVector): unknown { + const str = super.toJson(value) as string; + return toChecksumAddress(str); + } +} + +function isAddressValid(address: string): boolean { + return /^(0x)?[0-9a-f]{40}$/i.test(address); +} + +/** + * Formats an address according to [ERC55](https://eips.ethereum.org/EIPS/eip-55) + */ +export function toChecksumAddress(address: string): string { + if (!isAddressValid(address)) { + throw Error(`Invalid address: ${address}`); + } + + const rawAddress = (address.startsWith("0x") ? address.slice(2) : address).toLowerCase(); + const chars = rawAddress.split(""); + + // Inspired by https://github.com/ethers-io/ethers.js/blob/cac1da1f912c2ae9ba20f25aa51a91766673cd76/src.ts/address/address.ts#L8 + const expanded = new Uint8Array(chars.length); + for (let i = 0; i < expanded.length; i++) { + expanded[i] = rawAddress[i].charCodeAt(0); + } + + const hashed = keccak256(expanded); + for (let i = 0; i < chars.length; i += 2) { + if (hashed[i >> 1] >> 4 >= 8) { + chars[i] = chars[i].toUpperCase(); + } + if ((hashed[i >> 1] & 0x0f) >= 8) { + chars[i + 1] = chars[i + 1].toUpperCase(); + } + } + + return "0x" + chars.join(""); +} diff --git a/packages/types/src/utils/StringType.ts b/packages/types/src/utils/stringType.ts similarity index 100% rename from packages/types/src/utils/StringType.ts rename to packages/types/src/utils/stringType.ts diff --git a/packages/types/test/unit/executionAddress.test.ts b/packages/types/test/unit/executionAddress.test.ts new file mode 100644 index 000000000000..841cd52468f5 --- /dev/null +++ b/packages/types/test/unit/executionAddress.test.ts @@ -0,0 +1,72 @@ +import {describe, it, expect} from "vitest"; +import {toChecksumAddress} from "../../src/utils/executionAddress.js"; + +describe("toChecksumAddress", () => { + it("should fail with invalid addresses", () => { + expect(() => toChecksumAddress("1234")).toThrowError("Invalid address: 1234"); + expect(() => toChecksumAddress("0x1234")).toThrowError("Invalid address: 0x1234"); + }); + + it("should format addresses as ERC55", () => { + type TestCase = { + address: string; + checksumAddress: string; + }; + + const testCases: TestCase[] = [ + // Input all caps + { + address: "0x52908400098527886E0F7030069857D2E4169EE7", + checksumAddress: "0x52908400098527886E0F7030069857D2E4169EE7", + }, + { + address: "0xDE709F2102306220921060314715629080E2FB77", + checksumAddress: "0xde709f2102306220921060314715629080e2fb77", + }, + // Without 0x prefix + { + address: "52908400098527886e0f7030069857d2e4169ee7", + checksumAddress: "0x52908400098527886E0F7030069857D2E4169EE7", + }, + // All caps + { + address: "0x52908400098527886e0f7030069857d2e4169ee7", + checksumAddress: "0x52908400098527886E0F7030069857D2E4169EE7", + }, + { + address: "0x8617e340b3d01fa5f11f306f4090fd50e238070d", + checksumAddress: "0x8617E340B3D01FA5F11F306F4090FD50E238070D", + }, + // All lower + { + address: "0xde709f2102306220921060314715629080e2fb77", + checksumAddress: "0xde709f2102306220921060314715629080e2fb77", + }, + { + address: "0x27b1fdb04752bbc536007a920d24acb045561c26", + checksumAddress: "0x27b1fdb04752bbc536007a920d24acb045561c26", + }, + // Normal + { + address: "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed", + checksumAddress: "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + }, + { + address: "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359", + checksumAddress: "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + }, + { + address: "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb", + checksumAddress: "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + }, + { + address: "0xd1220a0cf47c7b9be7a2e6ba89f429762e7b9adb", + checksumAddress: "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", + }, + ]; + + for (const {address, checksumAddress} of testCases) { + expect(toChecksumAddress(address)).toBe(checksumAddress); + } + }); +});