From 99d31dcb6886c8f864efad702bda92bd4c92c361 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 06:59:02 +0200 Subject: [PATCH 1/7] Fix registration expiry helpers Align and helpers with the indexed smart contracts' logic. --- .../registration-expiration.test.ts | 67 +++++++++++++++++++ .../src/registrars/registration-expiration.ts | 6 +- 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 packages/ensnode-sdk/src/registrars/registration-expiration.test.ts diff --git a/packages/ensnode-sdk/src/registrars/registration-expiration.test.ts b/packages/ensnode-sdk/src/registrars/registration-expiration.test.ts new file mode 100644 index 0000000000..857f797592 --- /dev/null +++ b/packages/ensnode-sdk/src/registrars/registration-expiration.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + isRegistrationExpired, + isRegistrationFullyExpired, + isRegistrationInGracePeriod, +} from "./registration-expiration"; + +describe("registration expiration", () => { + const expiry = 1000n; + const gracePeriod = 100n; + + describe("isRegistrationExpired", () => { + it.each([ + { now: 999n, expected: false, description: "before expiry" }, + { now: 1000n, expected: true, description: "at expiry" }, + { now: 1001n, expected: true, description: "after expiry" }, + ])("returns $expected when $description", ({ now, expected }) => { + expect(isRegistrationExpired({ expiry, gracePeriod }, now)).toBe(expected); + }); + + it("returns false when expiry is null", () => { + expect(isRegistrationExpired({ expiry: null, gracePeriod }, 2000n)).toBe(false); + }); + }); + + describe("isRegistrationFullyExpired", () => { + it.each([ + { now: 999n, expected: false, description: "before expiry" }, + { now: 1000n, expected: false, description: "at expiry" }, + { now: 1050n, expected: false, description: "during grace period" }, + { now: 1100n, expected: false, description: "at expiry + grace period" }, + { now: 1101n, expected: true, description: "after expiry + grace period" }, + ])("returns $expected when $description", ({ now, expected }) => { + expect(isRegistrationFullyExpired({ expiry, gracePeriod }, now)).toBe(expected); + }); + + it("returns false when expiry is null", () => { + expect(isRegistrationFullyExpired({ expiry: null, gracePeriod }, 2000n)).toBe(false); + }); + + it("treats null grace period as zero", () => { + expect(isRegistrationFullyExpired({ expiry, gracePeriod: null }, 1000n)).toBe(false); + expect(isRegistrationFullyExpired({ expiry, gracePeriod: null }, 1001n)).toBe(true); + }); + }); + + describe("isRegistrationInGracePeriod", () => { + it.each([ + { now: 999n, expected: false, description: "before expiry" }, + { now: 1000n, expected: true, description: "at expiry" }, + { now: 1050n, expected: true, description: "during grace period" }, + { now: 1100n, expected: true, description: "at expiry + grace period" }, + { now: 1101n, expected: false, description: "after expiry + grace period" }, + ])("returns $expected when $description", ({ now, expected }) => { + expect(isRegistrationInGracePeriod({ expiry, gracePeriod }, now)).toBe(expected); + }); + + it("returns false when expiry is null", () => { + expect(isRegistrationInGracePeriod({ expiry: null, gracePeriod }, 1050n)).toBe(false); + }); + + it("returns false when grace period is null", () => { + expect(isRegistrationInGracePeriod({ expiry, gracePeriod: null }, 1050n)).toBe(false); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/registrars/registration-expiration.ts b/packages/ensnode-sdk/src/registrars/registration-expiration.ts index b81e7d696e..a046c08688 100644 --- a/packages/ensnode-sdk/src/registrars/registration-expiration.ts +++ b/packages/ensnode-sdk/src/registrars/registration-expiration.ts @@ -23,8 +23,8 @@ export function isRegistrationFullyExpired(info: RegistrationExpiryInfo, now: bi // no expiry, never expired if (info.expiry == null) return false; - // otherwise it is expired if now >= expiry + grace - return now >= info.expiry + (info.gracePeriod ?? 0n); + // otherwise it is expired if now > expiry + grace + return now > info.expiry + (info.gracePeriod ?? 0n); } /** @@ -34,5 +34,5 @@ export function isRegistrationInGracePeriod(info: RegistrationExpiryInfo, now: b if (info.expiry == null) return false; if (info.gracePeriod == null) return false; - return info.expiry <= now && info.expiry + info.gracePeriod > now; + return info.expiry <= now && info.expiry + info.gracePeriod >= now; } From 404570863e1bded52e7d25560ddbaf4ae1f389dd Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 07:00:01 +0200 Subject: [PATCH 2/7] fix: `handleRegistrationOrReservation` handler Update invariant logic to meet the relevant smart contract implementation which allows reverse name registration overrides. --- .../unigraph/handlers/ensv2/ENSv2Registry.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts index 2aefcd4f87..bf0818664b 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts @@ -103,10 +103,19 @@ export default function () { `Invariant(ENSv2Registry:Label[Registered|Reserved]): Existing unexpired Registration found, expected none or expired.\n${toJson(registration, { pretty: true })}`, ); } - } else { - // Invariant: if this is a Registration, unless it is a Reservation, it should be fully expired + } else if (registration) { + // There's an existing Registration record, so we need to handle it carefully. + // Registrations for reverse names are a special case, since they can + // never expire. Therefore, we need to skip the expiration check for + // reverse name registrations and allow a new Registration record to be + // created for the same label. + // For a reverse name registration, the registrant ID value is + // guaranteed to end with the label value. + const isReverseNameRegistration = registration.registrantId === `0x${label}`; + + // Invariant: if this is a Registration, unless it is a Reservation or a reverse name Registration, it should be fully expired if ( - registration && + !isReverseNameRegistration && registration.type !== "ENSv2RegistryReservation" && !isRegistrationFullyExpired(registration, event.block.timestamp) ) { From b8381bb6368ee33970e60ba638ba9361fba83f28 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 07:17:47 +0200 Subject: [PATCH 3/7] docs(changeset): Aligned `isRegistrationFullyExpired` and `isRegistrationInGracePeriod` helpers with onchain logic. --- .changeset/modern-groups-occur.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/modern-groups-occur.md diff --git a/.changeset/modern-groups-occur.md b/.changeset/modern-groups-occur.md new file mode 100644 index 0000000000..c3be157c48 --- /dev/null +++ b/.changeset/modern-groups-occur.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": patch +--- + +Aligned `isRegistrationFullyExpired` and `isRegistrationInGracePeriod` helpers with onchain logic. From fa263c05c55397b5ff8ce7fc6cdf468e7aee601f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 07:33:05 +0200 Subject: [PATCH 4/7] Apply AI PR feedback --- .../src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts index bf0818664b..6b3443632e 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts @@ -110,7 +110,7 @@ export default function () { // reverse name registrations and allow a new Registration record to be // created for the same label. // For a reverse name registration, the registrant ID value is - // guaranteed to end with the label value. + // guaranteed to match the label value. const isReverseNameRegistration = registration.registrantId === `0x${label}`; // Invariant: if this is a Registration, unless it is a Reservation or a reverse name Registration, it should be fully expired From 18274968473c5de5d5ba0b15c99220ccefc81ee2 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 07:34:29 +0200 Subject: [PATCH 5/7] docs(changeset): Updated ENSv2Registry handling for non-expiring reverse-name registrations. --- .changeset/two-corners-tease.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/two-corners-tease.md diff --git a/.changeset/two-corners-tease.md b/.changeset/two-corners-tease.md new file mode 100644 index 0000000000..8be49f5941 --- /dev/null +++ b/.changeset/two-corners-tease.md @@ -0,0 +1,5 @@ +--- +"ensindexer": patch +--- + +Updated ENSv2Registry handling for non-expiring reverse-name registrations. From 63dc950f5852b47571a573f0eee5767fadf898a8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 07:46:55 +0200 Subject: [PATCH 6/7] Apply AI PR feedback --- .../src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts index 6b3443632e..f28619270d 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts @@ -1,6 +1,7 @@ import { type AccountId, asLiteralLabel, + isNormalizedAddress, type LabelHash, labelhashLiteralLabel, makeENSv2DomainId, @@ -109,9 +110,11 @@ export default function () { // never expire. Therefore, we need to skip the expiration check for // reverse name registrations and allow a new Registration record to be // created for the same label. - // For a reverse name registration, the registrant ID value is - // guaranteed to match the label value. - const isReverseNameRegistration = registration.registrantId === `0x${label}`; + // For a reverse name registration, the label matches the registrant ID. + const maybeReverseNameLabel = `0x${label}`; + const isReverseNameRegistration = + isNormalizedAddress(maybeReverseNameLabel) && + registration.registrantId === maybeReverseNameLabel; // Invariant: if this is a Registration, unless it is a Reservation or a reverse name Registration, it should be fully expired if ( From c3a4db60280b8f8d3db7a57804cb8a92e3a2c5b9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 13 Jul 2026 07:58:09 +0200 Subject: [PATCH 7/7] Apply AI PR feedback --- .../src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts index f28619270d..ab3840b5ee 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts @@ -110,7 +110,7 @@ export default function () { // never expire. Therefore, we need to skip the expiration check for // reverse name registrations and allow a new Registration record to be // created for the same label. - // For a reverse name registration, the label matches the registrant ID. + // For a reverse name registration, the registrant ID is the label with `0x` prefix. const maybeReverseNameLabel = `0x${label}`; const isReverseNameRegistration = isNormalizedAddress(maybeReverseNameLabel) &&