Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/wise-breads-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ensnode/ensrainbow-sdk": minor
"ensindexer": patch
---

Normalize labelhash for ENSRainbow Heal request
4 changes: 1 addition & 3 deletions apps/ensindexer/src/lib/graphnode-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,5 @@ export async function labelByHash(labelhash: Labelhash): Promise<string | null>
return null;
}

throw new Error(
`Error healing labelhash: "${labelhash}". Error (${healResponse.errorCode}): ${healResponse.error}.`,
);
throw new Error(`Error (${healResponse.errorCode}): ${healResponse.error}.`);
}
28 changes: 16 additions & 12 deletions apps/ensindexer/test/graphnode-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
2 changes: 1 addition & 1 deletion packages/ensrainbow-sdk/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
31 changes: 25 additions & 6 deletions packages/ensrainbow-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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;

export interface ApiClient {
count(): Promise<CountResponse>;

heal(labelhash: Labelhash): Promise<HealResponse>;
heal(labelhash: Labelhash | EncodedLabelhash | string): Promise<HealResponse>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or this should be just labelhash: string?


health(): Promise<HealthResponse>;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -226,18 +228,35 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient {
* // }
* ```
*/
async heal(labelhash: Labelhash): Promise<EnsRainbow.HealResponse> {
const cachedResult = this.cache.get(labelhash);
async heal(labelhash: Labelhash | EncodedLabelhash | string): Promise<EnsRainbow.HealResponse> {
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;
Expand Down
1 change: 1 addition & 0 deletions packages/ensrainbow-sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./client";
export * from "./consts";
export * from "./label-utils";
export * from "./utils";
130 changes: 130 additions & 0 deletions packages/ensrainbow-sdk/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
84 changes: 84 additions & 0 deletions packages/ensrainbow-sdk/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo we should refactor this entire file to just:

function parseLabelhashOrEncodedLabelhash(maybeLabelHash: string) {
  // 1. ensure is Hex formatted
  let hexLabelhash: Hex;
  try {
    // attempt to decode label hash with ensjs
    // NOTE: decodeLabelhash will throw InvalidEncodedLabelError or return hex-looking string
    hexLabelhash = decodeLabelhash(maybeLabelHash);
  } catch {
    // if not encoded label hash, prefix with 0x
    hexLabelhash = maybeLabelHash.startsWith("0x")
      ? (maybeLabelHash as `0x${string}`)
      : `0x${maybeLabelHash}`;
  }

  // 2. if not hex, throw
  if (!isHex(hexLabelhash, { strict: true })) throw new InvalidLabelhashError();

  // 3. ensure hex part is correctly sized, padding left
  const normalizedHex = pad(hexLabelhash, { dir: "left", size: 32 });
  if (size(normalizedHex) !== 32) throw new InvalidLabelhashError();

  // 4. return lower case string
  return normalizedHex.toLowerCase() as Labelhash;
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if ensjs isn't already a dependency, we can keep parseEncodedLabelHash in place of ensjs#decodeLabelHash except make sure it returns 0x${maybeEncodedLabelhash.slice(1, -1)} not the raw hex characters

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and the logic can be separated into multiple functions if desired. but we shouldn't be re-implementing hex-related logic that's already available in viem

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have ensjs.
pad(hexLabelhash, { dir: "left", size: 32 }) is not working correctly, e.g. it validates positively 0x00000.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the code to use isHex

// 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);
}
}