Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/normalize-labelhash-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@ensnode/ensnode-sdk": patch
"@ensnode/ensrainbow-sdk": patch
---

`EnsRainbowApiClient.heal()` now accepts labelhashes in any common format — with or without a `0x` prefix, uppercase hex characters, bracket-enclosed encoded labelhashes, or odd-length hex strings — and normalizes them automatically. Invalid inputs return a `HealBadRequestError` rather than throwing.

The underlying normalization utilities (`parseLabelHash`, `parseEncodedLabelHash`, `parseLabelHashOrEncodedLabelHash`) are also exported from `@ensnode/ensnode-sdk` for use in other contexts.
72 changes: 44 additions & 28 deletions apps/ensindexer/src/lib/graphnode-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,32 @@ describe("labelByLabelHash", () => {
).toBeNull();
});

it("throws an error for an invalid too short labelHash", async () => {
it("normalizes a 63-char hex labelHash by prepending '0' and heals it", async () => {
(fetch as any).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
status: "error",
error: "Invalid labelhash - must be a valid hex string",
errorCode: 400,
status: "success",
label: "vitalik",
}),
});

await expect(
labelByLabelHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0"),
).rejects.toThrow(/Invalid labelhash - must be a valid hex string/i);
expect(
await labelByLabelHash(
"0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103c" as LabelHash, // 63 hex chars
),
).toEqual("vitalik");

const [[calledUrl]] = (fetch as any).mock.calls;
// Verify the client prepended a '0' — the normalized 64-char hash is used in the request
expect(calledUrl.toString()).toContain(
"0x0af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103c",
);
});

it("throws an error for an invalid too long labelHash", async () => {
it("propagates a server 400 error as a thrown exception", async () => {
// The 63-char hash is normalized client-side (leading '0' prepended), so fetch IS called.
// This test verifies that a 400 response from the server is propagated as a thrown error.
(fetch as any).mockResolvedValue({
ok: true,
json: () =>
Expand All @@ -79,41 +88,48 @@ describe("labelByLabelHash", () => {
});

await expect(
labelByLabelHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067"),
labelByLabelHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0"), // 63 hex chars, normalized before sending
).rejects.toThrow(/Invalid labelhash - must be a valid hex string/i);
});

it("throws an error for an invalid labelHash not in lower-case", async () => {
(fetch as any).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
status: "error",
error: "Invalid labelhash - must be a valid hex string",
errorCode: 400,
}),
});

it("throws an error for an invalid too long labelHash", async () => {
// Validation happens client-side; fetch is never called
await expect(
labelByLabelHash("0x00Ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da06"),
).rejects.toThrow(/Invalid labelhash - must be a valid hex string/i);
labelByLabelHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da067"), // 65 hex chars
).rejects.toThrow(/Invalid labelHash length/i);
expect(fetch).not.toHaveBeenCalled();
});

it("throws an error for an invalid labelHash missing 0x prefix", async () => {
it("normalizes a labelHash with uppercase chars and heals it", async () => {
(fetch as any).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
status: "error",
error: "Invalid labelhash - must be a valid hex string",
errorCode: 400,
status: "success",
label: "nick",
}),
});

// Use a hash distinct from other tests to avoid LRU cache hits suppressing the fetch call
expect(
await labelByLabelHash(
"0x5D5727cb0fb76e4944eafb88ec9a3cf0b3c9025a4b2f947729137c5d7f84f68f" as LabelHash,
),
).toEqual("nick");

const [[calledUrl]] = (fetch as any).mock.calls;
expect(calledUrl.toString()).toContain(
"0x5d5727cb0fb76e4944eafb88ec9a3cf0b3c9025a4b2f947729137c5d7f84f68f",
);
});

it("throws an error for an invalid labelHash missing 0x prefix and too long", async () => {
// Validation happens client-side; fetch is never called
await expect(
labelByLabelHash(
"12ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0600" as LabelHash,
),
).rejects.toThrow(/Invalid labelhash - must be a valid hex string/i);
), // 66 hex chars
).rejects.toThrow(/Invalid labelHash length/i);
expect(fetch).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions packages/ensnode-sdk/src/ens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from "./fuses";
export * from "./is-normalized";
export * from "./labelhash";
export * from "./names";
export * from "./parse-labelhash";
export * from "./parse-reverse-name";
export * from "./reverse-name";
export * from "./subname-helpers";
Expand Down
222 changes: 222 additions & 0 deletions packages/ensnode-sdk/src/ens/parse-labelhash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";

import {
parseEncodedLabelHash,
parseLabelHash,
parseLabelHashOrEncodedLabelHash,
} from "./parse-labelhash";

describe("parseLabelHash", () => {
it("normalizes a valid 64-char labelHash with 0x prefix", () => {
expect(
parseLabelHash("0x0000000000000000000000000000000000000000000000000000000000000000"),
).toBe("0x0000000000000000000000000000000000000000000000000000000000000000");
});

it("adds 0x prefix when missing (64 hex chars)", () => {
expect(parseLabelHash("0000000000000000000000000000000000000000000000000000000000000000")).toBe(
"0x0000000000000000000000000000000000000000000000000000000000000000",
);
});

it("pads a 63-char hex string to 64 chars (0x prefix present)", () => {
expect(
parseLabelHash("0x000000000000000000000000000000000000000000000000000000000000000"),
).toBe("0x0000000000000000000000000000000000000000000000000000000000000000");
});

it("pads a 63-char hex string to 64 chars (no 0x prefix)", () => {
expect(parseLabelHash("000000000000000000000000000000000000000000000000000000000000000")).toBe(
"0x0000000000000000000000000000000000000000000000000000000000000000",
);
});

it("normalizes uppercase hex to lowercase (64 chars)", () => {
expect(parseLabelHash("A000000000000000000000000000000000000000000000000000000000000000")).toBe(
"0xa000000000000000000000000000000000000000000000000000000000000000",
);
});

it("pads and normalizes uppercase hex (63 chars)", () => {
expect(parseLabelHash("A00000000000000000000000000000000000000000000000000000000000000")).toBe(
"0x0a00000000000000000000000000000000000000000000000000000000000000",
);
});

it("normalizes a known labelhash (vitalik, uppercase input)", () => {
expect(
parseLabelHash("0xAf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc"),
).toBe("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc");
});

it("normalizes a known labelhash (vitalik, no 0x prefix)", () => {
expect(parseLabelHash("af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc")).toBe(
"0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc",
);
});

it("throws for non-hex characters", () => {
expect(() =>
parseLabelHash("0xG000000000000000000000000000000000000000000000000000000000000000"),
).toThrow(Error);
});

it("throws for too short input (e.g. 5 hex chars)", () => {
expect(() => parseLabelHash("0x00000")).toThrow(Error);
});

it("throws for too long input (65 hex chars)", () => {
expect(() =>
parseLabelHash("0x00000000000000000000000000000000000000000000000000000000000000000"),
).toThrow(Error);
});

it("throws for 62-char hex (even, but wrong length)", () => {
expect(() =>
parseLabelHash("0x00000000000000000000000000000000000000000000000000000000000000"),
).toThrow(Error);
});

it("throws for uppercase 0X prefix", () => {
expect(() =>
parseLabelHash("0X0000000000000000000000000000000000000000000000000000000000000000"),
).toThrow(Error);
});

it("throws for empty string", () => {
expect(() => parseLabelHash("")).toThrow(Error);
});
});

describe("parseEncodedLabelHash", () => {
it("normalizes a valid encoded labelHash with 64 hex chars", () => {
expect(
parseEncodedLabelHash("[0000000000000000000000000000000000000000000000000000000000000000]"),
).toBe("0x0000000000000000000000000000000000000000000000000000000000000000");
});

it("normalizes an encoded labelHash with 0x prefix inside brackets", () => {
expect(
parseEncodedLabelHash("[0x0000000000000000000000000000000000000000000000000000000000000000]"),
).toBe("0x0000000000000000000000000000000000000000000000000000000000000000");
});

it("pads a 63-char encoded labelHash (no 0x prefix inside)", () => {
expect(
parseEncodedLabelHash("[000000000000000000000000000000000000000000000000000000000000000]"),
).toBe("0x0000000000000000000000000000000000000000000000000000000000000000");
});

it("pads a 63-char encoded labelHash (0x prefix inside)", () => {
expect(
parseEncodedLabelHash("[0x000000000000000000000000000000000000000000000000000000000000000]"),
).toBe("0x0000000000000000000000000000000000000000000000000000000000000000");
});

it("normalizes uppercase encoded labelHash (64 chars)", () => {
expect(
parseEncodedLabelHash("[A000000000000000000000000000000000000000000000000000000000000000]"),
).toBe("0xa000000000000000000000000000000000000000000000000000000000000000");
});

it("pads and normalizes uppercase encoded labelHash (63 chars)", () => {
expect(
parseEncodedLabelHash("[A00000000000000000000000000000000000000000000000000000000000000]"),
).toBe("0x0a00000000000000000000000000000000000000000000000000000000000000");
});

it("normalizes a known encoded labelhash (vitalik)", () => {
expect(
parseEncodedLabelHash("[af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc]"),
).toBe("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc");
});

it("throws when missing opening bracket", () => {
expect(() =>
parseEncodedLabelHash("0000000000000000000000000000000000000000000000000000000000000000]"),
).toThrow(Error);
});

it("throws when missing closing bracket", () => {
expect(() =>
parseEncodedLabelHash("[0000000000000000000000000000000000000000000000000000000000000000"),
).toThrow(Error);
});

it("throws when missing both brackets", () => {
expect(() =>
parseEncodedLabelHash("0000000000000000000000000000000000000000000000000000000000000000"),
).toThrow(Error);
});

it("throws for 62 hex chars inside brackets (too short, even length)", () => {
expect(() =>
parseEncodedLabelHash("[00000000000000000000000000000000000000000000000000000000000000]"),
).toThrow(Error);
});

it("throws for 65 hex chars inside brackets (too long)", () => {
expect(() =>
parseEncodedLabelHash("[00000000000000000000000000000000000000000000000000000000000000000]"),
).toThrow(Error);
});

it("throws for uppercase 0X prefix inside brackets", () => {
expect(() =>
parseEncodedLabelHash("[0X0000000000000000000000000000000000000000000000000000000000000000]"),
).toThrow(Error);
});

it("throws for invalid content inside brackets", () => {
expect(() => parseEncodedLabelHash("[00000]")).toThrow(Error);
expect(() =>
parseEncodedLabelHash("[0xG000000000000000000000000000000000000000000000000000000000000000]"),
).toThrow(Error);
});

it("throws for empty string", () => {
expect(() => parseEncodedLabelHash("")).toThrow(Error);
});

it("throws for empty brackets", () => {
expect(() => parseEncodedLabelHash("[]")).toThrow(Error);
});
});

describe("parseLabelHashOrEncodedLabelHash", () => {
it("parses a plain labelHash", () => {
expect(
parseLabelHashOrEncodedLabelHash(
"0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc",
),
).toBe("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc");
});

it("parses an encoded labelHash", () => {
expect(
parseLabelHashOrEncodedLabelHash(
"[af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc]",
),
).toBe("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc");
});

it("normalizes a plain labelHash missing 0x prefix", () => {
expect(
parseLabelHashOrEncodedLabelHash(
"af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc",
),
).toBe("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc");
});

it("normalizes a plain labelHash with uppercase chars", () => {
expect(
parseLabelHashOrEncodedLabelHash(
"0xAf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc",
),
).toBe("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc");
});

it("throws for invalid input", () => {
expect(() => parseLabelHashOrEncodedLabelHash("0xinvalid")).toThrow(Error);
});
});
Loading