diff --git a/.changeset/wise-breads-stop.md b/.changeset/wise-breads-stop.md new file mode 100644 index 000000000..d81ef9e8b --- /dev/null +++ b/.changeset/wise-breads-stop.md @@ -0,0 +1,6 @@ +--- +"@ensnode/ensrainbow-sdk": minor +"ensindexer": patch +--- + +Normalize labelhash for ENSRainbow Heal request diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index ac6c91187..a99d4b91f 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -38,7 +38,5 @@ export async function labelByHash(labelhash: Labelhash): Promise return null; } - throw new Error( - `Error healing labelhash: "${labelhash}". Error (${healResponse.errorCode}): ${healResponse.error}.`, - ); + throw new Error(`Error (${healResponse.errorCode}): ${healResponse.error}.`); } diff --git a/apps/ensindexer/test/graphnode-helpers.test.ts b/apps/ensindexer/test/graphnode-helpers.test.ts index 796f35c9f..381524191 100644 --- a/apps/ensindexer/test/graphnode-helpers.test.ts +++ b/apps/ensindexer/test/graphnode-helpers.test.ts @@ -20,27 +20,31 @@ describe("labelByHash", () => { it("throws an error for an invalid too short labelhash", async () => { await expect( - labelByHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0"), - ).rejects.toThrow("Invalid labelhash length 65 characters (expected 66)"); + labelByHash("0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0"), + ).rejects.toThrow( + "Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 31 bytes: 0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0.", + ); }); it("throws an error for an invalid too long labelhash", async () => { await expect( labelByHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067"), - ).rejects.toThrow("Invalid labelhash length 67 characters (expected 66)"); + ).rejects.toThrow( + "Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 32.5 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.", + ); }); - it("throws an error for an invalid labelhash not in lower-case", async () => { - await expect( - labelByHash("0x00Ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da06"), - ).rejects.toThrow("Labelhash must be in lowercase"); + it("heals a labelhash not in lower-case", async () => { + expect( + await labelByHash("0xAf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc"), + ).toEqual("vitalik"); }); - it("throws an error for an invalid labelhash missing 0x prefix", async () => { - await expect( - labelByHash( - "12ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0600" as Labelhash, + it("heals a labelhash missing 0x prefix", async () => { + expect( + await labelByHash( + "af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc" as `0x${string}`, ), - ).rejects.toThrow("Labelhash must be 0x-prefixed"); + ).toEqual("vitalik"); }); }); diff --git a/packages/ensrainbow-sdk/src/client.test.ts b/packages/ensrainbow-sdk/src/client.test.ts index 0e27089e2..f0dbda2ef 100644 --- a/packages/ensrainbow-sdk/src/client.test.ts +++ b/packages/ensrainbow-sdk/src/client.test.ts @@ -63,7 +63,7 @@ describe("EnsRainbowApiClient", () => { expect(response).toEqual({ status: StatusCode.Error, - error: "Invalid labelhash length 9 characters (expected 66)", + error: "Invalid labelhash length: expected 32 bytes (64 hex chars), got 3.5 bytes: 0xinvalid", errorCode: ErrorCode.BadRequest, } satisfies EnsRainbow.HealBadRequestError); }); diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 97f1cc237..6ca23287d 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -2,6 +2,7 @@ import type { Cache } from "@ensnode/utils/cache"; import { LruCache } from "@ensnode/utils/cache"; import type { Labelhash } from "@ensnode/utils/types"; import { DEFAULT_ENSRAINBOW_URL, ErrorCode, StatusCode } from "./consts"; +import { EncodedLabelhash, InvalidLabelhashError, parseLabelhashOrEncodedLabelhash } from "./utils"; export namespace EnsRainbow { export type ApiClientOptions = EnsRainbowApiClientOptions; @@ -9,7 +10,7 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; - heal(labelhash: Labelhash): Promise; + heal(labelhash: Labelhash | EncodedLabelhash | string): Promise; health(): Promise; @@ -194,8 +195,9 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * - Labels can contain any valid string, including dots, null bytes, or be empty * - Clients should handle all possible string values appropriately * - * @param labelhash all lowercase 64-digit hex string with 0x prefix (total length of 66 characters) + * @param labelhash - A labelhash to heal, either as a `Labelhash`, an `EncodedLabelhash`, or as a string that can be normalized to a 0x-prefixed, lowercased, 64-character hex string * @returns a `HealResponse` indicating the result of the request and the healed label if successful + * @throws {InvalidLabelhashError} If the provided labelhash is not valid. * @throws if the request fails due to network failures, DNS lookup failures, request timeouts, CORS violations, or Invalid URLs * * @example @@ -226,18 +228,35 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * // } * ``` */ - async heal(labelhash: Labelhash): Promise { - const cachedResult = this.cache.get(labelhash); + async heal(labelhash: Labelhash | EncodedLabelhash | string): Promise { + let normalizedLabelhash: Labelhash; + + try { + normalizedLabelhash = parseLabelhashOrEncodedLabelhash(labelhash); + } catch (error) { + if (error instanceof InvalidLabelhashError) { + return { + status: StatusCode.Error, + error: error.message, + errorCode: ErrorCode.BadRequest, + } as EnsRainbow.HealBadRequestError; + } + throw error; // Re-throw unexpected errors + } + + const cachedResult = this.cache.get(normalizedLabelhash); if (cachedResult) { return cachedResult; } - const response = await fetch(new URL(`/v1/heal/${labelhash}`, this.options.endpointUrl)); + const response = await fetch( + new URL(`/v1/heal/${normalizedLabelhash}`, this.options.endpointUrl), + ); const healResponse = (await response.json()) as EnsRainbow.HealResponse; if (isCacheableHealResponse(healResponse)) { - this.cache.set(labelhash, healResponse); + this.cache.set(normalizedLabelhash, healResponse); } return healResponse; diff --git a/packages/ensrainbow-sdk/src/index.ts b/packages/ensrainbow-sdk/src/index.ts index c88e08e96..dd016dc91 100644 --- a/packages/ensrainbow-sdk/src/index.ts +++ b/packages/ensrainbow-sdk/src/index.ts @@ -1,3 +1,4 @@ export * from "./client"; export * from "./consts"; export * from "./label-utils"; +export * from "./utils"; diff --git a/packages/ensrainbow-sdk/src/utils.test.ts b/packages/ensrainbow-sdk/src/utils.test.ts new file mode 100644 index 000000000..968682a9d --- /dev/null +++ b/packages/ensrainbow-sdk/src/utils.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + EncodedLabelhash, + InvalidLabelhashError, + parseEncodedLabelhash, + parseLabelhash, +} from "./utils"; + +describe("parseLabelhash", () => { + it("should normalize a valid labelhash", () => { + // 64 zeros + expect(parseLabelhash("0000000000000000000000000000000000000000000000000000000000000000")).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + + // 64 zeros with 0x prefix + expect( + parseLabelhash("0x0000000000000000000000000000000000000000000000000000000000000000"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + // 63 zeros + expect(parseLabelhash("000000000000000000000000000000000000000000000000000000000000000")).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + + // 63 zeros with 0x prefix + expect( + parseLabelhash("0x000000000000000000000000000000000000000000000000000000000000000"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + // 64 characters + expect(parseLabelhash("A000000000000000000000000000000000000000000000000000000000000000")).toBe( + "0xa000000000000000000000000000000000000000000000000000000000000000", + ); + + // 63 characters + expect(parseLabelhash("A00000000000000000000000000000000000000000000000000000000000000")).toBe( + "0x0a00000000000000000000000000000000000000000000000000000000000000", + ); + }); + + it("should throw for invalid labelhash", () => { + // Invalid characters + expect(() => + parseLabelhash("0xG000000000000000000000000000000000000000000000000000000000000000"), + ).toThrow(InvalidLabelhashError); + + // Too short + expect(() => parseLabelhash("0x00000")).toThrow(InvalidLabelhashError); + + // Too long + expect(() => + parseLabelhash("0x00000000000000000000000000000000000000000000000000000000000000000"), + ).toThrow(InvalidLabelhashError); + }); +}); + +describe("parseEncodedLabelhash", () => { + it("should normalize a valid encoded labelhash", () => { + // 64 zeros + expect( + parseEncodedLabelhash("[0000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + // 64 zeros with 0x prefix + expect( + parseEncodedLabelhash("[0x0000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + // 63 zeros + expect( + parseEncodedLabelhash("[000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + // 63 zeros with 0x prefix + expect( + parseEncodedLabelhash("[0x000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + // 64 characters + expect( + parseEncodedLabelhash("[A000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0xa000000000000000000000000000000000000000000000000000000000000000"); + + // 64 characters with 0x prefix + expect( + parseEncodedLabelhash("[A00000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0a00000000000000000000000000000000000000000000000000000000000000"); + }); + + it("should throw for invalid encoded labelhash", () => { + // Not enclosed in brackets + expect(() => + parseEncodedLabelhash( + "0000000000000000000000000000000000000000000000000000000000000000" as EncodedLabelhash, + ), + ).toThrow(InvalidLabelhashError); + expect(() => + parseEncodedLabelhash( + "[0000000000000000000000000000000000000000000000000000000000000000" as EncodedLabelhash, + ), + ).toThrow(InvalidLabelhashError); + expect(() => + parseEncodedLabelhash( + "0000000000000000000000000000000000000000000000000000000000000000]" as EncodedLabelhash, + ), + ).toThrow(InvalidLabelhashError); + + // 62 zeros - too short + expect(() => + parseEncodedLabelhash("[00000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + + // 65 zeros - too long + expect(() => + parseEncodedLabelhash("[00000000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + + // wrong 0X prefix + expect(() => + parseEncodedLabelhash("[0X0000000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + + // Invalid content + expect(() => parseEncodedLabelhash("[00000]")).toThrow(InvalidLabelhashError); + expect(() => + parseEncodedLabelhash("[0xG000000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + }); +}); diff --git a/packages/ensrainbow-sdk/src/utils.ts b/packages/ensrainbow-sdk/src/utils.ts new file mode 100644 index 000000000..b2a0f5e63 --- /dev/null +++ b/packages/ensrainbow-sdk/src/utils.ts @@ -0,0 +1,84 @@ +import type { Labelhash } from "@ensnode/utils/types"; +import { Hex } from "viem"; +import { isHex } from "viem/utils"; + +export type EncodedLabelhash = `[${string}]`; + +/** + * Error thrown when a labelhash cannot be normalized. + */ +export class InvalidLabelhashError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidLabelhashError"; + } +} + +/** + * Parses a labelhash string and normalizes it to the format expected by the ENSRainbow API. + * If the input labelhash is 63 characters long, a leading zero will be added to make it 64 characters. + * + * @param maybeLabelhash - The string to parse as a labelhash + * @returns A normalized labelhash (a 0x-prefixed, lowercased, 64-character hex string) + * @throws {InvalidLabelhashError} If the input cannot be normalized to a valid labelhash + */ +export function parseLabelhash(maybeLabelhash: string): Labelhash { + // Remove 0x prefix if present + let hexPart = maybeLabelhash.startsWith("0x") ? maybeLabelhash.slice(2) : maybeLabelhash; + + // Check if the correct number of bytes (32 bytes = 64 hex chars) + // If length is 63, pad with a leading zero to make it 64 + if (hexPart.length == 63) { + hexPart = `0${hexPart}`; + } else if (hexPart.length !== 64) { + throw new InvalidLabelhashError( + `Invalid labelhash length: expected 32 bytes (64 hex chars), got ${hexPart.length / 2} bytes: ${maybeLabelhash}`, + ); + } + const normalizedHex: Hex = `0x${hexPart}`; + + // Check if all characters are valid hex digits + if (!isHex(normalizedHex, { strict: true })) { + throw new InvalidLabelhashError( + `Invalid labelhash: contains non-hex characters: ${maybeLabelhash}`, + ); + } + + // Ensure lowercase + return normalizedHex.toLowerCase() as Labelhash; +} + +/** + * Parses an encoded labelhash string (surrounded by square brackets) and normalizes it. + * + * @param maybeEncodedLabelhash - The string to parse as an encoded labelhash + * @returns A normalized labelhash (a 0x-prefixed, lowercased, 64-character hex string) + * @throws {InvalidLabelhashError} If the input is not properly encoded or cannot be normalized + */ +export function parseEncodedLabelhash(maybeEncodedLabelhash: EncodedLabelhash): Labelhash { + // Check if the string is enclosed in square brackets + if (!maybeEncodedLabelhash.startsWith("[") || !maybeEncodedLabelhash.endsWith("]")) { + throw new InvalidLabelhashError( + `Invalid encoded labelhash: must be enclosed in square brackets: ${maybeEncodedLabelhash}`, + ); + } + + // Remove the square brackets and parse as a regular labelhash + const innerValue = maybeEncodedLabelhash.slice(1, -1); + return parseLabelhash(innerValue); +} + +/** + * Parses a labelhash or encoded labelhash string and normalizes it to the format expected by the ENSRainbow API. + * + * @param maybeLabelhash - The string to parse as a labelhash + * @returns A normalized labelhash (a 0x-prefixed, lowercased, 64-character hex string) + * @throws {InvalidLabelhashError} If the input cannot be normalized to a valid labelhash + */ +export function parseLabelhashOrEncodedLabelhash(maybeLabelhash: string): Labelhash { + if (maybeLabelhash.startsWith("[") && maybeLabelhash.endsWith("]")) { + return parseEncodedLabelhash(maybeLabelhash as EncodedLabelhash); + } else { + return parseLabelhash(maybeLabelhash); + } +}