From d992400fe07f93863f73e13a6b946dca304b6ffb Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 18:10:22 +0100 Subject: [PATCH 01/13] Normalize labelhash for ENSRainbow Heal request --- packages/ensrainbow-sdk/src/client.spec.ts | 2 +- packages/ensrainbow-sdk/src/client.test.ts | 118 +++++++++++++++++++++ packages/ensrainbow-sdk/src/client.ts | 52 +++++++-- packages/ensrainbow-sdk/src/index.ts | 1 + packages/ensrainbow-sdk/src/utils.test.ts | 103 ++++++++++++++++++ packages/ensrainbow-sdk/src/utils.ts | 66 ++++++++++++ 6 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 packages/ensrainbow-sdk/src/client.test.ts create mode 100644 packages/ensrainbow-sdk/src/utils.test.ts create mode 100644 packages/ensrainbow-sdk/src/utils.ts diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 4c841c702..5b646e915 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -63,7 +63,7 @@ describe("EnsRainbowApiClient", () => { expect(response).toEqual({ status: StatusCode.Error, - error: "Invalid labelhash length 9 characters (expected 66)", + error: "Invalid labelhash format: Invalid labelhash: contains non-hex characters: 0xinvalid", errorCode: ErrorCode.BadRequest, } satisfies EnsRainbow.HealBadRequestError); }); diff --git a/packages/ensrainbow-sdk/src/client.test.ts b/packages/ensrainbow-sdk/src/client.test.ts new file mode 100644 index 000000000..32a0645b3 --- /dev/null +++ b/packages/ensrainbow-sdk/src/client.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnsRainbowApiClient, ErrorCode, StatusCode } from "./index"; +import { InvalidLabelhashError } from "./utils"; + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("EnsRainbowApiClient", () => { + let client: EnsRainbowApiClient; + + beforeEach(() => { + client = new EnsRainbowApiClient({ + endpointUrl: new URL("https://api.ensrainbow.io"), + cacheCapacity: 10, + }); + + // Reset mock + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("heal", () => { + it("should normalize and heal a valid labelhash", async () => { + // Mock successful response + mockFetch.mockResolvedValueOnce({ + json: async () => ({ + status: "success", + label: "vitalik", + }), + }); + + const response = await client.heal( + "AF2CAA1C2CA1D027F1AC823B529D0A67CD144264B2789FA2EA4D63A67C7103CC", + ); + + // Check that the labelhash was normalized (lowercase) + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: expect.stringContaining( + "/v1/heal/0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", + ), + }), + ); + + expect(response).toEqual({ + status: "success", + label: "vitalik", + }); + }); + + it("should normalize and heal a valid encoded labelhash", async () => { + // Mock successful response + mockFetch.mockResolvedValueOnce({ + json: async () => ({ + status: "success", + label: "vitalik", + }), + }); + + const response = await client.heal( + "[AF2CAA1C2CA1D027F1AC823B529D0A67CD144264B2789FA2EA4D63A67C7103CC]", + ); + + // Check that the labelhash was normalized (lowercase and without brackets) + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: expect.stringContaining( + "/v1/heal/0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", + ), + }), + ); + + expect(response).toEqual({ + status: "success", + label: "vitalik", + }); + }); + + it("should return error response for invalid labelhash", async () => { + const response = await client.heal("invalid-labelhash"); + + expect(response).toEqual({ + status: "error", + error: + "Invalid labelhash format: Invalid labelhash: contains non-hex characters: invalid-labelhash", + errorCode: 400, + }); + }); + + it("should use cache for repeated requests", async () => { + // Mock successful response for first request + mockFetch.mockResolvedValueOnce({ + json: async () => ({ + status: "success", + label: "vitalik", + }), + }); + + // First request should call fetch + const response1 = await client.heal( + "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second request with same labelhash should use cache + const response2 = await client.heal( + "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1, not 2 + + expect(response1).toEqual(response2); + }); + }); +}); diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 1baeef165..eac4ca5d8 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 { InvalidLabelhashError, parseEncodedLabelhash, parseLabelhash } 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 | string): Promise; health(): Promise; @@ -169,8 +170,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 normalized Labelhash or as a string that can be normalized * @returns a `HealResponse` indicating the result of the request and the healed label if successful + * @throws {InvalidLabelhashError} If the provided labelhash cannot be normalized to a valid format * @throws if the request fails due to network failures, DNS lookup failures, request timeouts, CORS violations, or Invalid URLs * * @example @@ -201,18 +203,56 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * // } * ``` */ - async heal(labelhash: Labelhash): Promise { - const cachedResult = this.cache.get(labelhash); + async heal(labelhash: Labelhash | string): Promise { + // Normalize the labelhash if it's a string + let normalizedLabelhash: Labelhash; + + if (typeof labelhash === "string") { + // Check if it's an encoded labelhash + if (labelhash.startsWith("[") && labelhash.endsWith("]")) { + try { + normalizedLabelhash = parseEncodedLabelhash(labelhash); + } catch (error) { + if (error instanceof InvalidLabelhashError) { + return { + status: StatusCode.Error, + error: `Invalid encoded labelhash format: ${error.message}`, + errorCode: ErrorCode.BadRequest, + } as EnsRainbow.HealBadRequestError; + } + throw error; // Re-throw unexpected errors + } + } else { + try { + normalizedLabelhash = parseLabelhash(labelhash); + } catch (error) { + if (error instanceof InvalidLabelhashError) { + return { + status: StatusCode.Error, + error: `Invalid labelhash format: ${error.message}`, + errorCode: ErrorCode.BadRequest, + } as EnsRainbow.HealBadRequestError; + } + throw error; // Re-throw unexpected errors + } + } + } else { + normalizedLabelhash = labelhash; + } + + 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..a4e462360 --- /dev/null +++ b/packages/ensrainbow-sdk/src/utils.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { InvalidLabelhashError, parseEncodedLabelhash, parseLabelhash } from "./utils"; + +describe("parseLabelhash", () => { + it("should normalize a valid labelhash", () => { + expect(parseLabelhash("0000000000000000000000000000000000000000000000000000000000000000")).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + + expect( + parseLabelhash("0x0000000000000000000000000000000000000000000000000000000000000000"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + expect(parseLabelhash("000000000000000000000000000000000000000000000000000000000000000")).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + + expect( + parseLabelhash("0x000000000000000000000000000000000000000000000000000000000000000"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + expect(parseLabelhash("A000000000000000000000000000000000000000000000000000000000000000")).toBe( + "0xa000000000000000000000000000000000000000000000000000000000000000", + ); + + 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", () => { + expect( + parseEncodedLabelhash("[0000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + expect( + parseEncodedLabelhash("[0x0000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + expect( + parseEncodedLabelhash("[000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + expect( + parseEncodedLabelhash("[0x000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + + expect( + parseEncodedLabelhash("[A000000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0xa000000000000000000000000000000000000000000000000000000000000000"); + + expect( + parseEncodedLabelhash("[A00000000000000000000000000000000000000000000000000000000000000]"), + ).toBe("0x0a00000000000000000000000000000000000000000000000000000000000000"); + }); + + it("should throw for invalid encoded labelhash", () => { + // Not enclosed in brackets + expect(() => + parseEncodedLabelhash("0000000000000000000000000000000000000000000000000000000000000000"), + ).toThrow(InvalidLabelhashError); + expect(() => + parseEncodedLabelhash("[0000000000000000000000000000000000000000000000000000000000000000"), + ).toThrow(InvalidLabelhashError); + expect(() => + parseEncodedLabelhash("0000000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + + expect(() => + parseEncodedLabelhash("[00000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + expect(() => + parseEncodedLabelhash("[00000000000000000000000000000000000000000000000000000000000000000]"), + ).toThrow(InvalidLabelhashError); + + 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..7e86ad17d --- /dev/null +++ b/packages/ensrainbow-sdk/src/utils.ts @@ -0,0 +1,66 @@ +import type { Labelhash } from "@ensnode/utils/types"; + +/** + * 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. + * + * @param maybeLabelhash - The string to parse as a labelhash + * @returns A normalized labelhash (Hex string) + * @throws {InvalidLabelhashError} If the input cannot be normalized to a valid labelhash + */ +export function parseLabelhash(maybeLabelhash: string): Labelhash { + // Add 0x prefix if missing + let normalized = maybeLabelhash.startsWith("0x") ? maybeLabelhash : `0x${maybeLabelhash}`; + + // Remove 0x prefix for validation, will add back later + const hexPart = normalized.slice(2); + + // Check if all characters are valid hex digits + if (!/^[0-9a-fA-F]*$/.test(hexPart)) { + throw new InvalidLabelhashError( + `Invalid labelhash: contains non-hex characters: ${maybeLabelhash}`, + ); + } + + // If odd number of hex digits, add a leading 0 + const normalizedHexPart = hexPart.length % 2 === 1 ? `0${hexPart}` : hexPart; + + // Check if the correct number of bytes (32 bytes = 64 hex chars) + if (normalizedHexPart.length !== 64) { + throw new InvalidLabelhashError( + `Invalid labelhash length: expected 32 bytes (64 hex chars), got ${normalizedHexPart.length / 2} bytes: ${maybeLabelhash}`, + ); + } + + // Ensure lowercase + return `0x${normalizedHexPart.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 (Hex string) + * @throws {InvalidLabelhashError} If the input is not properly encoded or cannot be normalized + */ +export function parseEncodedLabelhash(maybeEncodedLabelhash: string): 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); +} From f280f06146cb2d98a28b84eefbf66ceec97790bc Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 18:17:48 +0100 Subject: [PATCH 02/13] move tests into one place --- packages/ensrainbow-sdk/src/client.spec.ts | 71 ++++++++++++- packages/ensrainbow-sdk/src/client.test.ts | 118 --------------------- 2 files changed, 70 insertions(+), 119 deletions(-) delete mode 100644 packages/ensrainbow-sdk/src/client.test.ts diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 5b646e915..caae8ffce 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { type EnsRainbow, EnsRainbowApiClient, @@ -84,6 +84,75 @@ describe("EnsRainbowApiClient", () => { status: "ok", } satisfies EnsRainbow.HealthResponse); }); + + it("should normalize and heal a valid labelhash", async () => { + // Using a known labelhash for "vitalik" + const response = await client.heal( + "AF2CAA1C2CA1D027F1AC823B529D0A67CD144264B2789FA2EA4D63A67C7103CC", + ); + + expect(response.status).toBe(StatusCode.Success); + if (response.status === StatusCode.Success) { + expect(response.label).toBe("vitalik"); + } + }); + + it("should normalize and heal a valid encoded labelhash", async () => { + // Using a known labelhash for "vitalik" in encoded format + const response = await client.heal( + "[AF2CAA1C2CA1D027F1AC823B529D0A67CD144264B2789FA2EA4D63A67C7103CC]", + ); + + expect(response.status).toBe(StatusCode.Success); + if (response.status === StatusCode.Success) { + expect(response.label).toBe("vitalik"); + } + }); + + it("should return error response for invalid labelhash", async () => { + const response = await client.heal("invalid-labelhash"); + + expect(response).toEqual({ + status: "error", + error: + "Invalid labelhash format: Invalid labelhash: contains non-hex characters: invalid-labelhash", + errorCode: 400, + }); + }); + + it("should use cache for repeated requests", async () => { + // First request to the real API + const response1 = await client.heal( + "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", + ); + + // Create a spy to track if the fetch is called again + const fetchSpy = vi.spyOn(global, "fetch"); + const fetchCallCount = fetchSpy.mock.calls.length; + + // Second request with same labelhash should use cache + const response2 = await client.heal( + "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", + ); + + // Verify fetch wasn't called again + expect(fetchSpy.mock.calls.length).toBe(fetchCallCount); + + // Both responses should be identical + expect(response1).toEqual(response2); + }); + + it("should return not found for unknown labelhash", async () => { + // Using a random labelhash that's unlikely to be in the database + const response = await client.heal( + "0x1234567890123456789012345678901234567890123456789012345678901234", + ); + + expect(response.status).toBe(StatusCode.Error); + if (response.status === StatusCode.Error) { + expect(response.errorCode).toBe(ErrorCode.NotFound); + } + }); }); describe("HealResponse error detection", () => { diff --git a/packages/ensrainbow-sdk/src/client.test.ts b/packages/ensrainbow-sdk/src/client.test.ts deleted file mode 100644 index 32a0645b3..000000000 --- a/packages/ensrainbow-sdk/src/client.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { EnsRainbowApiClient, ErrorCode, StatusCode } from "./index"; -import { InvalidLabelhashError } from "./utils"; - -// Mock fetch -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe("EnsRainbowApiClient", () => { - let client: EnsRainbowApiClient; - - beforeEach(() => { - client = new EnsRainbowApiClient({ - endpointUrl: new URL("https://api.ensrainbow.io"), - cacheCapacity: 10, - }); - - // Reset mock - mockFetch.mockReset(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe("heal", () => { - it("should normalize and heal a valid labelhash", async () => { - // Mock successful response - mockFetch.mockResolvedValueOnce({ - json: async () => ({ - status: "success", - label: "vitalik", - }), - }); - - const response = await client.heal( - "AF2CAA1C2CA1D027F1AC823B529D0A67CD144264B2789FA2EA4D63A67C7103CC", - ); - - // Check that the labelhash was normalized (lowercase) - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: expect.stringContaining( - "/v1/heal/0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", - ), - }), - ); - - expect(response).toEqual({ - status: "success", - label: "vitalik", - }); - }); - - it("should normalize and heal a valid encoded labelhash", async () => { - // Mock successful response - mockFetch.mockResolvedValueOnce({ - json: async () => ({ - status: "success", - label: "vitalik", - }), - }); - - const response = await client.heal( - "[AF2CAA1C2CA1D027F1AC823B529D0A67CD144264B2789FA2EA4D63A67C7103CC]", - ); - - // Check that the labelhash was normalized (lowercase and without brackets) - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: expect.stringContaining( - "/v1/heal/0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", - ), - }), - ); - - expect(response).toEqual({ - status: "success", - label: "vitalik", - }); - }); - - it("should return error response for invalid labelhash", async () => { - const response = await client.heal("invalid-labelhash"); - - expect(response).toEqual({ - status: "error", - error: - "Invalid labelhash format: Invalid labelhash: contains non-hex characters: invalid-labelhash", - errorCode: 400, - }); - }); - - it("should use cache for repeated requests", async () => { - // Mock successful response for first request - mockFetch.mockResolvedValueOnce({ - json: async () => ({ - status: "success", - label: "vitalik", - }), - }); - - // First request should call fetch - const response1 = await client.heal( - "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", - ); - expect(mockFetch).toHaveBeenCalledTimes(1); - - // Second request with same labelhash should use cache - const response2 = await client.heal( - "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc", - ); - expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1, not 2 - - expect(response1).toEqual(response2); - }); - }); -}); From c1c372aeca262011a337d331869b0fa5cff8fb80 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 18:25:19 +0100 Subject: [PATCH 03/13] shorten errors --- packages/ensrainbow-sdk/src/client.spec.ts | 4 ++-- packages/ensrainbow-sdk/src/client.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index caae8ffce..9553dc168 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -63,7 +63,7 @@ describe("EnsRainbowApiClient", () => { expect(response).toEqual({ status: StatusCode.Error, - error: "Invalid labelhash format: Invalid labelhash: contains non-hex characters: 0xinvalid", + error: "Invalid labelhash: contains non-hex characters: 0xinvalid", errorCode: ErrorCode.BadRequest, } satisfies EnsRainbow.HealBadRequestError); }); @@ -115,7 +115,7 @@ describe("EnsRainbowApiClient", () => { expect(response).toEqual({ status: "error", error: - "Invalid labelhash format: Invalid labelhash: contains non-hex characters: invalid-labelhash", + "Invalid labelhash: contains non-hex characters: invalid-labelhash", errorCode: 400, }); }); diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index eac4ca5d8..a1bb0392a 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -216,7 +216,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { if (error instanceof InvalidLabelhashError) { return { status: StatusCode.Error, - error: `Invalid encoded labelhash format: ${error.message}`, + error: error.message, errorCode: ErrorCode.BadRequest, } as EnsRainbow.HealBadRequestError; } @@ -229,7 +229,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { if (error instanceof InvalidLabelhashError) { return { status: StatusCode.Error, - error: `Invalid labelhash format: ${error.message}`, + error: error.message, errorCode: ErrorCode.BadRequest, } as EnsRainbow.HealBadRequestError; } From b9edb4df1427d0c1117326c67f3c1c663e58e2bc Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 19:01:16 +0100 Subject: [PATCH 04/13] test(ensindexer): update labelhash validation tests with healing behavior --- .../ensindexer/test/graphnode-helpers.spec.ts | 28 +++++++++++-------- packages/ensrainbow-sdk/src/client.spec.ts | 3 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/ensindexer/test/graphnode-helpers.spec.ts b/apps/ensindexer/test/graphnode-helpers.spec.ts index 7937a53a3..1ee690e3e 100644 --- a/apps/ensindexer/test/graphnode-helpers.spec.ts +++ b/apps/ensindexer/test/graphnode-helpers.spec.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 healing labelhash: "0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0". 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 healing labelhash: "0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067". Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 33 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.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 9553dc168..4c5700c7d 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -114,8 +114,7 @@ describe("EnsRainbowApiClient", () => { expect(response).toEqual({ status: "error", - error: - "Invalid labelhash: contains non-hex characters: invalid-labelhash", + error: "Invalid labelhash: contains non-hex characters: invalid-labelhash", errorCode: 400, }); }); From baf003f17a7cf407b4c291acadb57046872acaff Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 10 Mar 2025 13:09:51 +0100 Subject: [PATCH 05/13] feat(ensrainbow-sdk): improve labelhash parsing and type handling --- apps/ensindexer/src/lib/graphnode-helpers.ts | 2 +- .../ensindexer/test/graphnode-helpers.spec.ts | 4 +- packages/ensrainbow-sdk/src/client.spec.ts | 12 +++++ packages/ensrainbow-sdk/src/client.ts | 51 ++++++------------- packages/ensrainbow-sdk/src/utils.test.ts | 19 +++++-- packages/ensrainbow-sdk/src/utils.ts | 30 ++++++++--- 6 files changed, 67 insertions(+), 51 deletions(-) diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index 66d657128..8cc494275 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -38,6 +38,6 @@ export async function labelByHash(labelhash: Labelhash): Promise } throw new Error( - `Error healing labelhash: "${labelhash}". Error (${healResponse.errorCode}): ${healResponse.error}.`, + `Error (${healResponse.errorCode}): ${healResponse.error}.`, ); } diff --git a/apps/ensindexer/test/graphnode-helpers.spec.ts b/apps/ensindexer/test/graphnode-helpers.spec.ts index 1ee690e3e..7d0d3e3da 100644 --- a/apps/ensindexer/test/graphnode-helpers.spec.ts +++ b/apps/ensindexer/test/graphnode-helpers.spec.ts @@ -22,7 +22,7 @@ describe("labelByHash", () => { await expect( labelByHash("0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0"), ).rejects.toThrow( - 'Error healing labelhash: "0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0". Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 31 bytes: 0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0.', + 'Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 31 bytes: 0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0.', ); }); @@ -30,7 +30,7 @@ describe("labelByHash", () => { await expect( labelByHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067"), ).rejects.toThrow( - 'Error healing labelhash: "0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067". Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 33 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.', + 'Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 33 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.', ); }); diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 4c5700c7d..123125430 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -1,3 +1,4 @@ +import { Labelhash } from "@ensnode/utils/types"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { type EnsRainbow, @@ -46,6 +47,17 @@ describe("EnsRainbowApiClient", () => { } satisfies EnsRainbow.HealSuccess); }); + it("should heal a known labelhash passed as Labelhash", async () => { + const response = await client.heal( + "0xAf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc" as Labelhash, + ); + + expect(response).toEqual({ + status: StatusCode.Success, + label: "vitalik", + } satisfies EnsRainbow.HealSuccess); + }); + it("should return a not found error for an unknown labelhash", async () => { const response = await client.heal( "0xf64dc17ae2e2b9b16dbcb8cb05f35a2e6080a5ff1dc53ac0bc48f0e79111f264", diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index a1bb0392a..52916e581 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -2,7 +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 { InvalidLabelhashError, parseEncodedLabelhash, parseLabelhash } from "./utils"; +import { EncodedLabelhash, InvalidLabelhashError, parseLabelhashOrEncodedLabelhash } from "./utils"; export namespace EnsRainbow { export type ApiClientOptions = EnsRainbowApiClientOptions; @@ -10,7 +10,7 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; - heal(labelhash: Labelhash | string): Promise; + heal(labelhash: Labelhash | EncodedLabelhash | string): Promise; health(): Promise; @@ -170,9 +170,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 - A labelhash to heal, either as a normalized Labelhash or as a string that can be normalized + * @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 cannot be normalized to a valid format + * @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 @@ -203,41 +203,20 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * // } * ``` */ - async heal(labelhash: Labelhash | string): Promise { - // Normalize the labelhash if it's a string + async heal(labelhash: Labelhash | EncodedLabelhash | string): Promise { let normalizedLabelhash: Labelhash; - if (typeof labelhash === "string") { - // Check if it's an encoded labelhash - if (labelhash.startsWith("[") && labelhash.endsWith("]")) { - try { - normalizedLabelhash = parseEncodedLabelhash(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 - } - } else { - try { - normalizedLabelhash = parseLabelhash(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 - } + try { + normalizedLabelhash = parseLabelhashOrEncodedLabelhash(labelhash); + } catch (error) { + if (error instanceof InvalidLabelhashError) { + return { + status: StatusCode.Error, + error: error.message, + errorCode: ErrorCode.BadRequest, + } as EnsRainbow.HealBadRequestError; } - } else { - normalizedLabelhash = labelhash; + throw error; // Re-throw unexpected errors } const cachedResult = this.cache.get(normalizedLabelhash); diff --git a/packages/ensrainbow-sdk/src/utils.test.ts b/packages/ensrainbow-sdk/src/utils.test.ts index a4e462360..c13ec2b9d 100644 --- a/packages/ensrainbow-sdk/src/utils.test.ts +++ b/packages/ensrainbow-sdk/src/utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { InvalidLabelhashError, parseEncodedLabelhash, parseLabelhash } from "./utils"; +import { + EncodedLabelhash, + InvalidLabelhashError, + parseEncodedLabelhash, + parseLabelhash, +} from "./utils"; describe("parseLabelhash", () => { it("should normalize a valid labelhash", () => { @@ -74,13 +79,19 @@ describe("parseEncodedLabelhash", () => { it("should throw for invalid encoded labelhash", () => { // Not enclosed in brackets expect(() => - parseEncodedLabelhash("0000000000000000000000000000000000000000000000000000000000000000"), + parseEncodedLabelhash( + "0000000000000000000000000000000000000000000000000000000000000000" as EncodedLabelhash, + ), ).toThrow(InvalidLabelhashError); expect(() => - parseEncodedLabelhash("[0000000000000000000000000000000000000000000000000000000000000000"), + parseEncodedLabelhash( + "[0000000000000000000000000000000000000000000000000000000000000000" as EncodedLabelhash, + ), ).toThrow(InvalidLabelhashError); expect(() => - parseEncodedLabelhash("0000000000000000000000000000000000000000000000000000000000000000]"), + parseEncodedLabelhash( + "0000000000000000000000000000000000000000000000000000000000000000]" as EncodedLabelhash, + ), ).toThrow(InvalidLabelhashError); expect(() => diff --git a/packages/ensrainbow-sdk/src/utils.ts b/packages/ensrainbow-sdk/src/utils.ts index 7e86ad17d..9dd654b4b 100644 --- a/packages/ensrainbow-sdk/src/utils.ts +++ b/packages/ensrainbow-sdk/src/utils.ts @@ -1,5 +1,7 @@ import type { Labelhash } from "@ensnode/utils/types"; +export type EncodedLabelhash = `[${string}]`; + /** * Error thrown when a labelhash cannot be normalized. */ @@ -14,15 +16,12 @@ export class InvalidLabelhashError extends Error { * Parses a 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 (Hex string) + * @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 { - // Add 0x prefix if missing - let normalized = maybeLabelhash.startsWith("0x") ? maybeLabelhash : `0x${maybeLabelhash}`; - - // Remove 0x prefix for validation, will add back later - const hexPart = normalized.slice(2); + // Remove 0x prefix if present + const hexPart = maybeLabelhash.startsWith("0x") ? maybeLabelhash.slice(2) : maybeLabelhash; // Check if all characters are valid hex digits if (!/^[0-9a-fA-F]*$/.test(hexPart)) { @@ -49,10 +48,10 @@ export function parseLabelhash(maybeLabelhash: string): 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 (Hex string) + * @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: string): Labelhash { +export function parseEncodedLabelhash(maybeEncodedLabelhash: EncodedLabelhash): Labelhash { // Check if the string is enclosed in square brackets if (!maybeEncodedLabelhash.startsWith("[") || !maybeEncodedLabelhash.endsWith("]")) { throw new InvalidLabelhashError( @@ -64,3 +63,18 @@ export function parseEncodedLabelhash(maybeEncodedLabelhash: string): Labelhash const innerValue = maybeEncodedLabelhash.slice(1, -1); return parseLabelhash(innerValue); } + +/** + * Parses a 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); + } +} From 5a4074eb67b6b204b88deec8a3b99990f58be015 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 10 Mar 2025 13:14:25 +0100 Subject: [PATCH 06/13] docs(ensrainbow-sdk): update labelhash parsing function comment --- packages/ensrainbow-sdk/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensrainbow-sdk/src/utils.ts b/packages/ensrainbow-sdk/src/utils.ts index 9dd654b4b..5bf0664e0 100644 --- a/packages/ensrainbow-sdk/src/utils.ts +++ b/packages/ensrainbow-sdk/src/utils.ts @@ -65,7 +65,7 @@ export function parseEncodedLabelhash(maybeEncodedLabelhash: EncodedLabelhash): } /** - * Parses a labelhash string and normalizes it to the format expected by the ENSRainbow API. + * 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) From f0c1dbfb77c97ae5e30e0a531d831bdc83896d3e Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 10 Mar 2025 13:16:14 +0100 Subject: [PATCH 07/13] fix lint --- apps/ensindexer/src/lib/graphnode-helpers.ts | 4 +--- apps/ensindexer/test/graphnode-helpers.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index 8cc494275..698fb09ca 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -37,7 +37,5 @@ export async function labelByHash(labelhash: Labelhash): Promise return null; } - throw new Error( - `Error (${healResponse.errorCode}): ${healResponse.error}.`, - ); + throw new Error(`Error (${healResponse.errorCode}): ${healResponse.error}.`); } diff --git a/apps/ensindexer/test/graphnode-helpers.spec.ts b/apps/ensindexer/test/graphnode-helpers.spec.ts index 7d0d3e3da..a0a9b3344 100644 --- a/apps/ensindexer/test/graphnode-helpers.spec.ts +++ b/apps/ensindexer/test/graphnode-helpers.spec.ts @@ -22,7 +22,7 @@ describe("labelByHash", () => { await expect( labelByHash("0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0"), ).rejects.toThrow( - 'Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 31 bytes: 0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0.', + "Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 31 bytes: 0x0ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0.", ); }); @@ -30,7 +30,7 @@ describe("labelByHash", () => { await expect( labelByHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067"), ).rejects.toThrow( - 'Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 33 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.', + "Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 33 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.", ); }); From daadcc8a98e0577c35cf6b2294767f2854c01fff Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 10 Mar 2025 14:46:27 +0100 Subject: [PATCH 08/13] test(ensrainbow-sdk): add detailed test cases for labelhash parsing --- packages/ensrainbow-sdk/src/utils.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ensrainbow-sdk/src/utils.test.ts b/packages/ensrainbow-sdk/src/utils.test.ts index c13ec2b9d..9bfb70865 100644 --- a/packages/ensrainbow-sdk/src/utils.test.ts +++ b/packages/ensrainbow-sdk/src/utils.test.ts @@ -8,26 +8,32 @@ import { 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", ); + // 64 characters with 0x prefix expect(parseLabelhash("A00000000000000000000000000000000000000000000000000000000000000")).toBe( "0x0a00000000000000000000000000000000000000000000000000000000000000", ); @@ -51,26 +57,32 @@ describe("parseLabelhash", () => { 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"); @@ -94,13 +106,17 @@ describe("parseEncodedLabelhash", () => { ), ).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); From ce49fc44f5fac3ba0c1d90a575d03e0edd94375a Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 10 Mar 2025 14:58:00 +0100 Subject: [PATCH 09/13] fix(ensrainbow-sdk): handle 63-character labelhash parsing --- packages/ensrainbow-sdk/src/utils.test.ts | 2 +- packages/ensrainbow-sdk/src/utils.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ensrainbow-sdk/src/utils.test.ts b/packages/ensrainbow-sdk/src/utils.test.ts index 9bfb70865..968682a9d 100644 --- a/packages/ensrainbow-sdk/src/utils.test.ts +++ b/packages/ensrainbow-sdk/src/utils.test.ts @@ -33,7 +33,7 @@ describe("parseLabelhash", () => { "0xa000000000000000000000000000000000000000000000000000000000000000", ); - // 64 characters with 0x prefix + // 63 characters expect(parseLabelhash("A00000000000000000000000000000000000000000000000000000000000000")).toBe( "0x0a00000000000000000000000000000000000000000000000000000000000000", ); diff --git a/packages/ensrainbow-sdk/src/utils.ts b/packages/ensrainbow-sdk/src/utils.ts index 5bf0664e0..a0eb61f31 100644 --- a/packages/ensrainbow-sdk/src/utils.ts +++ b/packages/ensrainbow-sdk/src/utils.ts @@ -14,6 +14,7 @@ export class InvalidLabelhashError extends Error { /** * 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) From be4806fa6cf84e18429086efd9bb1b514c2ea65c Mon Sep 17 00:00:00 2001 From: djstrong Date: Sat, 5 Apr 2025 17:37:48 +0200 Subject: [PATCH 10/13] fix(ensrainbow-sdk): enhance error handling for invalid labelhash length and content --- packages/ensrainbow-sdk/src/client.spec.ts | 18 ++++++++++------ packages/ensrainbow-sdk/src/utils.ts | 25 ++++++++++++---------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 123125430..eb1872ee4 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -71,11 +71,14 @@ describe("EnsRainbowApiClient", () => { }); it("should return a bad request error for an invalid labelhash", async () => { - const response = await client.heal("0xinvalid"); + const response = await client.heal( + "0xinvalid1invalid1invalid1invalid1invalid1invalid1invalid1invalid1", + ); expect(response).toEqual({ status: StatusCode.Error, - error: "Invalid labelhash: contains non-hex characters: 0xinvalid", + error: + "Invalid labelhash: contains non-hex characters: 0xinvalid1invalid1invalid1invalid1invalid1invalid1invalid1invalid1", errorCode: ErrorCode.BadRequest, } satisfies EnsRainbow.HealBadRequestError); }); @@ -121,14 +124,15 @@ describe("EnsRainbowApiClient", () => { } }); - it("should return error response for invalid labelhash", async () => { + it("should return error response for invalid labelhash length", async () => { const response = await client.heal("invalid-labelhash"); expect(response).toEqual({ - status: "error", - error: "Invalid labelhash: contains non-hex characters: invalid-labelhash", - errorCode: 400, - }); + status: StatusCode.Error, + error: + "Invalid labelhash length: expected 32 bytes (64 hex chars), got 8.5 bytes: invalid-labelhash", + errorCode: ErrorCode.BadRequest, + } satisfies EnsRainbow.HealBadRequestError); }); it("should use cache for repeated requests", async () => { diff --git a/packages/ensrainbow-sdk/src/utils.ts b/packages/ensrainbow-sdk/src/utils.ts index a0eb61f31..b2a0f5e63 100644 --- a/packages/ensrainbow-sdk/src/utils.ts +++ b/packages/ensrainbow-sdk/src/utils.ts @@ -1,4 +1,6 @@ import type { Labelhash } from "@ensnode/utils/types"; +import { Hex } from "viem"; +import { isHex } from "viem/utils"; export type EncodedLabelhash = `[${string}]`; @@ -22,27 +24,28 @@ export class InvalidLabelhashError extends Error { */ export function parseLabelhash(maybeLabelhash: string): Labelhash { // Remove 0x prefix if present - const hexPart = maybeLabelhash.startsWith("0x") ? maybeLabelhash.slice(2) : maybeLabelhash; + let hexPart = maybeLabelhash.startsWith("0x") ? maybeLabelhash.slice(2) : maybeLabelhash; - // Check if all characters are valid hex digits - if (!/^[0-9a-fA-F]*$/.test(hexPart)) { + // 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: contains non-hex characters: ${maybeLabelhash}`, + `Invalid labelhash length: expected 32 bytes (64 hex chars), got ${hexPart.length / 2} bytes: ${maybeLabelhash}`, ); } + const normalizedHex: Hex = `0x${hexPart}`; - // If odd number of hex digits, add a leading 0 - const normalizedHexPart = hexPart.length % 2 === 1 ? `0${hexPart}` : hexPart; - - // Check if the correct number of bytes (32 bytes = 64 hex chars) - if (normalizedHexPart.length !== 64) { + // Check if all characters are valid hex digits + if (!isHex(normalizedHex, { strict: true })) { throw new InvalidLabelhashError( - `Invalid labelhash length: expected 32 bytes (64 hex chars), got ${normalizedHexPart.length / 2} bytes: ${maybeLabelhash}`, + `Invalid labelhash: contains non-hex characters: ${maybeLabelhash}`, ); } // Ensure lowercase - return `0x${normalizedHexPart.toLowerCase()}` as Labelhash; + return normalizedHex.toLowerCase() as Labelhash; } /** From f7bb4bff6845dbaa4ef3dbc6d6fde1034f41e638 Mon Sep 17 00:00:00 2001 From: djstrong Date: Sat, 5 Apr 2025 17:45:28 +0200 Subject: [PATCH 11/13] fix(ensrainbow-sdk): update error message for invalid labelhash length to provide clearer feedback --- packages/ensrainbow-sdk/src/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); }); From 0a8d099e0f036099e0014b8cee70a2d49fbd2685 Mon Sep 17 00:00:00 2001 From: djstrong Date: Sat, 5 Apr 2025 17:53:28 +0200 Subject: [PATCH 12/13] fix(ensrainbow-sdk): refine error message for invalid labelhash length to accurately reflect byte count --- apps/ensindexer/test/graphnode-helpers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensindexer/test/graphnode-helpers.test.ts b/apps/ensindexer/test/graphnode-helpers.test.ts index 76b815f88..381524191 100644 --- a/apps/ensindexer/test/graphnode-helpers.test.ts +++ b/apps/ensindexer/test/graphnode-helpers.test.ts @@ -30,7 +30,7 @@ describe("labelByHash", () => { await expect( labelByHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067"), ).rejects.toThrow( - "Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 33 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.", + "Error (400): Invalid labelhash length: expected 32 bytes (64 hex chars), got 32.5 bytes: 0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067.", ); }); From 628e8ffcf50059264d98aa031cf1b46652c50a1a Mon Sep 17 00:00:00 2001 From: djstrong Date: Sat, 5 Apr 2025 18:16:47 +0200 Subject: [PATCH 13/13] add changeset --- .changeset/wise-breads-stop.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/wise-breads-stop.md 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