From 8c594e2dcc74872656bf0663bc4122d556fcb4fe Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 23 Apr 2026 02:48:57 +0200 Subject: [PATCH 1/2] preventing edition overlap --- .changeset/bright-rivers-flow.md | 5 + .changeset/quiet-mirrors-shine.md | 5 + .changeset/shiny-tigers-wander.md | 5 + .../ensanalytics/ensanalytics-api.test.ts | 22 +- .../ens-referrals/src/api/zod-schemas.test.ts | 202 +++++++++++++++++- packages/ens-referrals/src/api/zod-schemas.ts | 74 ++++--- .../award-models/shared/api/zod-schemas.ts | 36 +++- .../award-models/shared/edition-summary.ts | 20 +- packages/ens-referrals/src/edition.test.ts | 112 ++++++++++ packages/ens-referrals/src/edition.ts | 79 ++++++- packages/ens-referrals/src/internal.ts | 3 + 11 files changed, 498 insertions(+), 65 deletions(-) create mode 100644 .changeset/bright-rivers-flow.md create mode 100644 .changeset/quiet-mirrors-shine.md create mode 100644 .changeset/shiny-tigers-wander.md create mode 100644 packages/ens-referrals/src/edition.test.ts diff --git a/.changeset/bright-rivers-flow.md b/.changeset/bright-rivers-flow.md new file mode 100644 index 0000000000..a75097f69b --- /dev/null +++ b/.changeset/bright-rivers-flow.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-referrals": minor +--- + +Add `BaseReferralProgramEditionConfig` as the shared parent of `ReferralProgramEditionConfig` and `BaseReferralProgramEditionSummary`. diff --git a/.changeset/quiet-mirrors-shine.md b/.changeset/quiet-mirrors-shine.md new file mode 100644 index 0000000000..89d661d997 --- /dev/null +++ b/.changeset/quiet-mirrors-shine.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-referrals": minor +--- + +Expose the per-award-model (`pie-split`, `rev-share-cap`) Zod schemas via `@namehash/ens-referrals/internal`. diff --git a/.changeset/shiny-tigers-wander.md b/.changeset/shiny-tigers-wander.md new file mode 100644 index 0000000000..e1cf652297 --- /dev/null +++ b/.changeset/shiny-tigers-wander.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-referrals": minor +--- + +Reject overlapping referral program editions: for a given `subregistryId`, no two editions may share any point in time. diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index 5df6b40a29..b29d5a8c73 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -33,6 +33,7 @@ import { type ReferralProgramEditionSlug, ReferralProgramEditionStatuses, ReferralProgramEditionSummariesResponseCodes, + type ReferralProgramRulesPieSplit, ReferrerEditionMetricsTypeIds, type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, @@ -868,20 +869,33 @@ describe("/v1/ensanalytics", () => { }, ); - // Mock caches middleware with a cache for each edition + // Each leaderboard carries its edition's own rules so the per-edition summaries don't + // collapse onto a single time window and trip the non-overlap invariant on the response. + const leaderboardFor = (config: { + rules: ReferralProgramRulesPieSplit; + }): ReferrerLeaderboard => ({ + ...emptyReferralLeaderboard, + rules: config.rules, + }); const mockEditionsCaches = new Map>( [ [ "2025-12", - { read: async () => emptyReferralLeaderboard } as SWRCache, + { + read: async () => leaderboardFor(mockEditionConfigSet.get("2025-12")!), + } as SWRCache, ], [ "2026-03", - { read: async () => emptyReferralLeaderboard } as SWRCache, + { + read: async () => leaderboardFor(mockEditionConfigSet.get("2026-03")!), + } as SWRCache, ], [ "2026-06", - { read: async () => emptyReferralLeaderboard } as SWRCache, + { + read: async () => leaderboardFor(mockEditionConfigSet.get("2026-06")!), + } as SWRCache, ], ], ); diff --git a/packages/ens-referrals/src/api/zod-schemas.test.ts b/packages/ens-referrals/src/api/zod-schemas.test.ts index 26d51c616f..a2197379c3 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -11,6 +11,7 @@ import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import { ReferralProgramEditionStatuses } from "../award-models/shared/status"; import { makeReferralProgramEditionConfigSetArraySchema, + makeReferralProgramEditionSummariesDataSchema, makeReferralProgramEditionSummarySchema, makeReferrerEditionMetricsSchema, makeReferrerLeaderboardPageSchema, @@ -24,6 +25,8 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", }; + // Fixtures share a subregistryId, so their time ranges are chosen to be disjoint + // (startTime and endTime are inclusive — abutting ranges count as overlapping). const pieSplitEdition = { slug: "2025-12", displayName: "December 2025", @@ -32,7 +35,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { awardPool: parseUsdc("1000"), maxQualifiedReferrers: 100, startTime: 1000000, - endTime: 2000000, + endTime: 1999999, subregistryId, rulesUrl: "https://ensawards.org/rules", areAwardsDistributed: false, @@ -48,8 +51,8 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { minBaseRevenueContribution: parseUsdc("10"), baseAnnualRevenueContribution: parseUsdc("5"), maxBaseRevenueShare: 0.5, - startTime: 1000000, - endTime: 2000000, + startTime: 2000000, + endTime: 2500000, subregistryId, rulesUrl: "https://ensawards.org/rules", areAwardsDistributed: false, @@ -61,7 +64,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { displayName: "March 2026", rules: { awardModel: "future-model", - startTime: 2000000, + startTime: 2500001, endTime: 3000000, subregistryId, rulesUrl: "https://ensawards.org/rules", @@ -124,7 +127,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { const result = schema.parse([pieSplitEdition, futureModelEdition]); const unrecognized = result.find((e) => e.slug === "2026-03"); - expect(unrecognized!.rules.startTime).toBe(2000000); + expect(unrecognized!.rules.startTime).toBe(2500001); expect(unrecognized!.rules.endTime).toBe(3000000); expect(unrecognized!.rules.rulesUrl).toBeInstanceOf(URL); expect(unrecognized!.rules.rulesUrl.href).toBe("https://ensawards.org/rules"); @@ -176,6 +179,127 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { expect(() => schema.parse([pieSplitEdition, duplicateUnrecognized])).toThrow(); }); + + describe("non-overlapping time invariant (per subregistryId)", () => { + it("accepts editions for the same subregistry with disjoint time ranges", () => { + const earlier = { + ...pieSplitEdition, + slug: "2025-12", + rules: { ...pieSplitEdition.rules, startTime: 1000, endTime: 1999 }, + }; + const later = { + ...revShareCapEdition, + slug: "2026-01", + rules: { ...revShareCapEdition.rules, startTime: 2000, endTime: 3000 }, + }; + + const result = schema.parse([earlier, later]); + expect(result).toHaveLength(2); + }); + + it("rejects editions for the same subregistry whose ranges interior-overlap", () => { + const a = { + ...pieSplitEdition, + slug: "a", + rules: { ...pieSplitEdition.rules, startTime: 1000, endTime: 2500 }, + }; + const b = { + ...revShareCapEdition, + slug: "b", + rules: { ...revShareCapEdition.rules, startTime: 2000, endTime: 3000 }, + }; + + expect(() => schema.parse([a, b])).toThrow(/overlapping time ranges/i); + }); + + it("rejects editions that only touch at a single boundary (endTime === startTime)", () => { + const a = { + ...pieSplitEdition, + slug: "a", + rules: { ...pieSplitEdition.rules, startTime: 1000, endTime: 2000 }, + }; + const b = { + ...revShareCapEdition, + slug: "b", + rules: { ...revShareCapEdition.rules, startTime: 2000, endTime: 3000 }, + }; + + expect(() => schema.parse([a, b])).toThrow(/overlapping time ranges/i); + }); + + it("accepts overlapping editions when subregistries differ by chainId", () => { + const a = { + ...pieSplitEdition, + slug: "a", + rules: { + ...pieSplitEdition.rules, + startTime: 1000, + endTime: 2000, + subregistryId: { ...subregistryId, chainId: 1 }, + }, + }; + const b = { + ...revShareCapEdition, + slug: "b", + rules: { + ...revShareCapEdition.rules, + startTime: 1000, + endTime: 2000, + subregistryId: { ...subregistryId, chainId: 8453 }, + }, + }; + + const result = schema.parse([a, b]); + expect(result).toHaveLength(2); + }); + + it("accepts overlapping editions when subregistries differ by address", () => { + const a = { + ...pieSplitEdition, + slug: "a", + rules: { + ...pieSplitEdition.rules, + startTime: 1000, + endTime: 2000, + subregistryId: { + chainId: 1, + address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + }, + }, + }; + const b = { + ...revShareCapEdition, + slug: "b", + rules: { + ...revShareCapEdition.rules, + startTime: 1000, + endTime: 2000, + subregistryId: { + chainId: 1, + address: "0x0635513f179d50a207757e05759cbd106d7dfce8", + }, + }, + }; + + const result = schema.parse([a, b]); + expect(result).toHaveLength(2); + }); + + it("rejects an unrecognized edition that overlaps a recognized edition on the same subregistry", () => { + const recognized = { + ...pieSplitEdition, + slug: "recognized", + rules: { ...pieSplitEdition.rules, startTime: 1000, endTime: 2000 }, + }; + const unrecognized = { + ...futureModelEdition, + slug: "unrecognized", + rules: { ...futureModelEdition.rules, startTime: 1500, endTime: 2500 }, + }; + + expect(() => schema.parse([recognized, unrecognized])).toThrow(/overlapping time ranges/i); + }); + }); }); describe("makeReferrerLeaderboardPageSchema", () => { @@ -425,6 +549,74 @@ describe("makeReferralProgramEditionSummarySchema", () => { }); }); +describe("makeReferralProgramEditionSummariesDataSchema — non-overlapping time invariant", () => { + const schema = makeReferralProgramEditionSummariesDataSchema(); + + const subregistryId = { + chainId: 1, + address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + }; + + const makePieSplitSummary = ( + slug: string, + startTime: number, + endTime: number, + registry = subregistryId, + ) => ({ + awardModel: ReferralProgramAwardModels.PieSplit, + slug, + displayName: slug, + status: ReferralProgramEditionStatuses.Active, + rules: { + awardModel: ReferralProgramAwardModels.PieSplit, + awardPool: parseUsdc("1000"), + maxQualifiedReferrers: 100, + startTime, + endTime, + subregistryId: registry, + rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, + }, + }); + + it("accepts summaries for the same subregistry with disjoint time ranges", () => { + const result = schema.parse({ + editions: [makePieSplitSummary("a", 1000, 1999), makePieSplitSummary("b", 2000, 3000)], + }); + expect(result.editions).toHaveLength(2); + }); + + it("rejects summaries for the same subregistry whose ranges interior-overlap", () => { + expect(() => + schema.parse({ + editions: [makePieSplitSummary("a", 1000, 2500), makePieSplitSummary("b", 2000, 3000)], + }), + ).toThrow(/overlapping time ranges/i); + }); + + it("rejects summaries that only touch at a single boundary (endTime === startTime)", () => { + expect(() => + schema.parse({ + editions: [makePieSplitSummary("a", 1000, 2000), makePieSplitSummary("b", 2000, 3000)], + }), + ).toThrow(/overlapping time ranges/i); + }); + + it("accepts overlapping summaries when subregistries differ", () => { + const result = schema.parse({ + editions: [ + makePieSplitSummary("a", 1000, 2000, { ...subregistryId, chainId: 1 }), + makePieSplitSummary("b", 1000, 2000, { ...subregistryId, chainId: 8453 }), + ], + }); + expect(result.editions).toHaveLength(2); + }); + + it("accepts an empty editions array", () => { + expect(schema.parse({ editions: [] })).toEqual({ editions: [] }); + }); +}); + describe("makeReferrerEditionMetricsSchema", () => { const schema = makeReferrerEditionMetricsSchema(); diff --git a/packages/ens-referrals/src/api/zod-schemas.ts b/packages/ens-referrals/src/api/zod-schemas.ts index 3ec30990ac..416cf9868e 100644 --- a/packages/ens-referrals/src/api/zod-schemas.ts +++ b/packages/ens-referrals/src/api/zod-schemas.ts @@ -24,9 +24,11 @@ import { makeReferrerLeaderboardPageRevShareCapSchema, } from "../award-models/rev-share-cap/api/zod-schemas"; import { + makeBaseReferralProgramEditionConfigSchema, makeBaseReferralProgramEditionSummarySchema, makeBaseReferralProgramRulesSchema, makeBaseReferrerLeaderboardPageSchema, + makeReferralProgramEditionSlugSchema, } from "../award-models/shared/api/zod-schemas"; import type { ReferrerEditionMetricsUnrecognized } from "../award-models/shared/edition-metrics"; import type { ReferralProgramEditionSummaryUnrecognized } from "../award-models/shared/edition-summary"; @@ -34,6 +36,7 @@ import type { ReferrerLeaderboardPageUnrecognized } from "../award-models/shared import type { ReferralProgramRulesUnrecognized } from "../award-models/shared/rules"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import type { ReferralProgramEditionConfig } from "../edition"; +import { findOverlappingEditionPair } from "../edition"; import type { ReferrerEditionMetrics } from "../edition-metrics"; import type { ReferralProgramEditionSummary } from "../edition-summary"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; @@ -196,22 +199,6 @@ export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerE }); }; -/** - * Schema for validating a {@link ReferralProgramEditionSlug}. - * - * Runtime validation against configured editions happens at the business logic level. - */ -export const makeReferralProgramEditionSlugSchema = ( - valueLabel: string = "ReferralProgramEditionSlug", -) => - z - .string() - .min(1, `${valueLabel} must not be empty`) - .regex( - /^[a-z0-9]+(-[a-z0-9]+)*$/, - `${valueLabel} must contain only lowercase letters, digits, and hyphens. Must not start or end with a hyphen.`, - ); - /** * Schema for validating editions array (min 1, max {@link MAX_EDITIONS_PER_REQUEST}, distinct values). */ @@ -281,23 +268,13 @@ export const makeReferrerMetricsEditionsResponseSchema = ( makeReferrerMetricsEditionsResponseErrorSchema(valueLabel), ]); -/** - * Schema for the shared base fields of a {@link ReferralProgramEditionConfig}. - */ -const makeReferralProgramEditionConfigBaseSchema = (valueLabel: string) => - z.object({ - slug: makeReferralProgramEditionSlugSchema(`${valueLabel}.slug`), - displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), - rules: makeBaseReferralProgramRulesSchema(`${valueLabel}.rules`), - }); - /** * Schema for validating a {@link ReferralProgramEditionConfig}. */ export const makeReferralProgramEditionConfigSchema = ( valueLabel: string = "ReferralProgramEditionConfig", ) => - makeReferralProgramEditionConfigBaseSchema(valueLabel).safeExtend({ + makeBaseReferralProgramEditionConfigSchema(valueLabel).safeExtend({ rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), }); @@ -332,7 +309,7 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( }); // Schema for extracting base fields from an unrecognized edition. - const unrecognizedBaseSchema = makeReferralProgramEditionConfigBaseSchema( + const unrecognizedBaseSchema = makeBaseReferralProgramEditionConfigSchema( `${valueLabel}[edition]`, ); @@ -382,6 +359,23 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( slugs.add(edition.slug); } + // For each subregistryId, editions must not overlap in time. startTime and endTime + // are inclusive bounds, so two editions sharing a single instant (A.endTime == B.startTime) + // also count as overlapping. Unrecognized editions participate so the "0 or 1 edition per + // referral" invariant holds even for forward-compatible models. + const overlap = findOverlappingEditionPair(result); + if (overlap) { + const [a, b] = overlap; + ctx.addIssue({ + code: "custom", + message: + `${valueLabel}: editions "${a.slug}" and "${b.slug}" have overlapping time ranges ` + + `for subregistryId ${a.rules.subregistryId.chainId}:${a.rules.subregistryId.address} ` + + `(startTime and endTime are inclusive)`, + }); + return []; + } + return result; }); }; @@ -442,13 +436,31 @@ export const makeReferralProgramEditionSummarySchema = ( /** * Schema for {@link ReferralProgramEditionSummariesData}. + * + * Enforces the per-subregistryId non-overlap invariant across the editions array + * (see {@link findOverlappingEditionPair}). */ export const makeReferralProgramEditionSummariesDataSchema = ( valueLabel: string = "ReferralProgramEditionSummariesData", ) => - z.object({ - editions: z.array(makeReferralProgramEditionSummarySchema(`${valueLabel}.editions[edition]`)), - }); + z + .object({ + editions: z.array(makeReferralProgramEditionSummarySchema(`${valueLabel}.editions[edition]`)), + }) + .superRefine((data, ctx) => { + const overlap = findOverlappingEditionPair(data.editions); + if (!overlap) return; + + const [a, b] = overlap; + ctx.addIssue({ + code: "custom", + path: ["editions"], + message: + `${valueLabel}: editions "${a.slug}" and "${b.slug}" have overlapping time ranges ` + + `for subregistryId ${a.rules.subregistryId.chainId}:${a.rules.subregistryId.address} ` + + `(startTime and endTime are inclusive)`, + }); + }); /** * Schema for {@link ReferralProgramEditionSummariesResponseOk}. diff --git a/packages/ens-referrals/src/award-models/shared/api/zod-schemas.ts b/packages/ens-referrals/src/award-models/shared/api/zod-schemas.ts index 6fc2b95ad2..d0586f8d79 100644 --- a/packages/ens-referrals/src/award-models/shared/api/zod-schemas.ts +++ b/packages/ens-referrals/src/award-models/shared/api/zod-schemas.ts @@ -8,9 +8,38 @@ import { makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; +import { REFERRAL_PROGRAM_EDITION_SLUG_PATTERN } from "../../../edition"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; import { ReferralProgramEditionStatuses } from "../status"; +/** + * Schema for validating a {@link ReferralProgramEditionSlug}. + * + * Runtime validation against configured editions happens at the business logic level. + */ +export const makeReferralProgramEditionSlugSchema = ( + valueLabel: string = "ReferralProgramEditionSlug", +) => + z + .string() + .min(1, `${valueLabel} must not be empty`) + .regex( + REFERRAL_PROGRAM_EDITION_SLUG_PATTERN, + `${valueLabel} must contain only lowercase letters, digits, and hyphens. Must not start or end with a hyphen.`, + ); + +/** + * Loose base schema for {@link BaseReferralProgramEditionConfig}. + */ +export const makeBaseReferralProgramEditionConfigSchema = ( + valueLabel: string = "BaseReferralProgramEditionConfig", +) => + z.object({ + slug: makeReferralProgramEditionSlugSchema(`${valueLabel}.slug`), + displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), + rules: makeBaseReferralProgramRulesSchema(`${valueLabel}.rules`), + }); + /** * Loose base schema for {@link BaseReferralProgramRules}. * @@ -62,15 +91,12 @@ export const makeReferralProgramStatusSchema = (_valueLabel: string = "status") /** * Loose base schema for {@link BaseReferralProgramEditionSummary}. * - * Accepts any string for `rules.awardModel` to support forward-compatible parsing. + * Accepts any string for `awardModel` to support forward-compatible parsing. */ export const makeBaseReferralProgramEditionSummarySchema = (valueLabel: string) => - z.object({ + makeBaseReferralProgramEditionConfigSchema(valueLabel).safeExtend({ awardModel: z.string(), - slug: z.string().min(1, `${valueLabel}.slug must not be empty`), - displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - rules: makeBaseReferralProgramRulesSchema(`${valueLabel}.rules`), }); /** diff --git a/packages/ens-referrals/src/award-models/shared/edition-summary.ts b/packages/ens-referrals/src/award-models/shared/edition-summary.ts index 8731226567..fb16c9fbd6 100644 --- a/packages/ens-referrals/src/award-models/shared/edition-summary.ts +++ b/packages/ens-referrals/src/award-models/shared/edition-summary.ts @@ -1,9 +1,8 @@ import { + type BaseReferralProgramEditionConfig, REFERRAL_PROGRAM_EDITION_SLUG_PATTERN, - type ReferralProgramEditionSlug, } from "../../edition"; import type { - BaseReferralProgramRules, ReferralProgramAwardModel, ReferralProgramAwardModels, ReferralProgramRulesUnrecognized, @@ -13,7 +12,7 @@ import type { ReferralProgramEditionStatusId } from "./status"; /** * Base fields shared by all edition summary variants. */ -export interface BaseReferralProgramEditionSummary { +export interface BaseReferralProgramEditionSummary extends BaseReferralProgramEditionConfig { /** * Discriminant: identifies the award model for this edition. * @@ -21,25 +20,10 @@ export interface BaseReferralProgramEditionSummary { */ awardModel: ReferralProgramAwardModel; - /** - * Unique slug identifier for the edition. - */ - slug: ReferralProgramEditionSlug; - - /** - * Human-readable display name for the edition. - */ - displayName: string; - /** * The current runtime status of the edition. */ status: ReferralProgramEditionStatusId; - - /** - * The rules for this edition. Per-model subtypes narrow this to their specific rules type. - */ - rules: BaseReferralProgramRules; } /** diff --git a/packages/ens-referrals/src/edition.test.ts b/packages/ens-referrals/src/edition.test.ts new file mode 100644 index 0000000000..6d68184bca --- /dev/null +++ b/packages/ens-referrals/src/edition.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { parseUsdc } from "@ensnode/ensnode-sdk"; + +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; +import { + buildReferralProgramEditionConfigSet, + findOverlappingEditionPair, + type ReferralProgramEditionConfig, + validateNonOverlappingEditionTimes, +} from "./edition"; + +const subregistry = { + chainId: 1, + address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" as const, +}; + +const makePieSplitEdition = ( + slug: string, + startTime: number, + endTime: number, + subregistryId = subregistry, +): ReferralProgramEditionConfig => ({ + slug, + displayName: slug, + rules: { + awardModel: ReferralProgramAwardModels.PieSplit, + awardPool: parseUsdc("1000"), + maxQualifiedReferrers: 100, + startTime, + endTime, + subregistryId, + rulesUrl: new URL("https://ensawards.org/rules"), + areAwardsDistributed: false, + }, +}); + +describe("findOverlappingEditionPair", () => { + it("returns null when editions are disjoint for the same subregistry", () => { + const a = makePieSplitEdition("a", 1000, 1999); + const b = makePieSplitEdition("b", 2000, 3000); + + expect(findOverlappingEditionPair([a, b])).toBeNull(); + }); + + it("returns the overlapping pair when ranges interior-overlap", () => { + const a = makePieSplitEdition("a", 1000, 2500); + const b = makePieSplitEdition("b", 2000, 3000); + + const result = findOverlappingEditionPair([a, b]); + expect(result).not.toBeNull(); + expect(result![0].slug).toBe("a"); + expect(result![1].slug).toBe("b"); + }); + + it("treats touching edges as overlap (bounds are inclusive)", () => { + const a = makePieSplitEdition("a", 1000, 2000); + const b = makePieSplitEdition("b", 2000, 3000); + + expect(findOverlappingEditionPair([a, b])).not.toBeNull(); + }); + + it("returns null when time ranges overlap but subregistries differ", () => { + const a = makePieSplitEdition("a", 1000, 2000, { ...subregistry, chainId: 1 }); + const b = makePieSplitEdition("b", 1000, 2000, { ...subregistry, chainId: 8453 }); + + expect(findOverlappingEditionPair([a, b])).toBeNull(); + }); +}); + +describe("validateNonOverlappingEditionTimes", () => { + it("is a no-op when no overlap exists", () => { + const a = makePieSplitEdition("a", 1000, 1999); + const b = makePieSplitEdition("b", 2000, 3000); + + expect(() => validateNonOverlappingEditionTimes([a, b])).not.toThrow(); + }); + + it("throws naming both slugs and the subregistry when editions overlap", () => { + const a = makePieSplitEdition("a", 1000, 2000); + const b = makePieSplitEdition("b", 1500, 2500); + + expect(() => validateNonOverlappingEditionTimes([a, b])).toThrow( + /"a".*"b".*subregistryId 1:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/s, + ); + }); +}); + +describe("buildReferralProgramEditionConfigSet — overlap invariant", () => { + it("builds a set from non-overlapping editions", () => { + const a = makePieSplitEdition("a", 1000, 1999); + const b = makePieSplitEdition("b", 2000, 3000); + + const set = buildReferralProgramEditionConfigSet([a, b]); + expect(set.size).toBe(2); + }); + + it("throws when two editions share a subregistry and overlap in time", () => { + const a = makePieSplitEdition("a", 1000, 2000); + const b = makePieSplitEdition("b", 2000, 3000); + + expect(() => buildReferralProgramEditionConfigSet([a, b])).toThrow(/overlapping time ranges/i); + }); + + it("builds a set when overlapping editions target different subregistries", () => { + const a = makePieSplitEdition("a", 1000, 2000, { ...subregistry, chainId: 1 }); + const b = makePieSplitEdition("b", 1000, 2000, { ...subregistry, chainId: 8453 }); + + const set = buildReferralProgramEditionConfigSet([a, b]); + expect(set.size).toBe(2); + }); +}); diff --git a/packages/ens-referrals/src/edition.ts b/packages/ens-referrals/src/edition.ts index 34a1dcb838..c4d3b95e3f 100644 --- a/packages/ens-referrals/src/edition.ts +++ b/packages/ens-referrals/src/edition.ts @@ -1,3 +1,4 @@ +import type { BaseReferralProgramRules } from "./award-models/shared/rules"; import type { ReferralProgramRules } from "./rules"; /** @@ -25,9 +26,9 @@ export type ReferralProgramEditionSlug = string; export const REFERRAL_PROGRAM_EDITION_SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; /** - * Represents a referral program edition configuration. + * Base fields shared by all referral program edition configs. */ -export interface ReferralProgramEditionConfig { +export interface BaseReferralProgramEditionConfig { /** * Unique slug identifier for the edition. */ @@ -39,6 +40,16 @@ export interface ReferralProgramEditionConfig { */ displayName: string; + /** + * The base rules that govern this referral program edition. + */ + rules: BaseReferralProgramRules; +} + +/** + * Represents a referral program edition configuration. + */ +export interface ReferralProgramEditionConfig extends BaseReferralProgramEditionConfig { /** * The rules that govern this referral program edition. */ @@ -78,6 +89,68 @@ export function validateReferralProgramEditionConfigSet( } } +/** + * Returns the first pair of editions sharing a `subregistryId` whose time ranges overlap, + * or `null` if none do. + * + * `startTime` and `endTime` are inclusive, so ranges sharing a single instant + * (`A.endTime === B.startTime`) count as overlapping. + * + * @param editions - Array of editions to check + * @returns A `[a, b]` tuple of the first offending pair, or `null` if none + */ +export function findOverlappingEditionPair( + editions: readonly T[], +): readonly [T, T] | null { + const byRegistry = new Map(); + for (const edition of editions) { + const key = `${edition.rules.subregistryId.chainId}:${edition.rules.subregistryId.address}`; + const group = byRegistry.get(key); + if (group) { + group.push(edition); + } else { + byRegistry.set(key, [edition]); + } + } + + // Within each subregistry group, sort by startTime so any overlapping pair is also an + // overlap between adjacent editions in this order — one linear pass after the sort suffices. + for (const group of byRegistry.values()) { + if (group.length < 2) continue; + group.sort((a, b) => a.rules.startTime - b.rules.startTime); + for (let i = 1; i < group.length; i++) { + const prev = group[i - 1]; + const curr = group[i]; + if (curr.rules.startTime <= prev.rules.endTime) { + return [prev, curr] as const; + } + } + } + + return null; +} + +/** + * Validates that no two editions sharing a `subregistryId` overlap in time. + * + * @param editions - Array of editions to validate + * @throws {Error} If any pair of editions overlap (start/end are inclusive) + */ +export function validateNonOverlappingEditionTimes( + editions: readonly T[], +): void { + const overlap = findOverlappingEditionPair(editions); + if (!overlap) return; + + const [a, b] = overlap; + throw new Error( + `Edition config set invariant violation: editions "${a.slug}" and "${b.slug}" ` + + `have overlapping time ranges for subregistryId ` + + `${a.rules.subregistryId.chainId}:${a.rules.subregistryId.address} ` + + `(startTime and endTime are inclusive)`, + ); +} + /** * Builds a new ReferralProgramEditionConfigSet from an array of configs and validates the invariant. * @@ -102,6 +175,8 @@ export function buildReferralProgramEditionConfigSet( throw new Error(`Duplicate edition config slugs detected: ${duplicates.join(", ")}`); } + validateNonOverlappingEditionTimes(configs); + const configSet = new Map(configs.map((config) => [config.slug, config])); validateReferralProgramEditionConfigSet(configSet); return configSet; diff --git a/packages/ens-referrals/src/internal.ts b/packages/ens-referrals/src/internal.ts index bff48fdebc..c4d12ac814 100644 --- a/packages/ens-referrals/src/internal.ts +++ b/packages/ens-referrals/src/internal.ts @@ -13,3 +13,6 @@ */ export * from "./api/zod-schemas"; +export * from "./award-models/pie-split/api/zod-schemas"; +export * from "./award-models/rev-share-cap/api/zod-schemas"; +export * from "./award-models/shared/api/zod-schemas"; From 4244d617e2cc8824b71ae321458b5d008ec8f97c Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 23 Apr 2026 14:35:29 +0200 Subject: [PATCH 2/2] openapi spec --- docs/ensnode.io/ensapi-openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index 2e27b8e27a..9676b3c7a6 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ENSApi APIs", - "version": "1.10.0", + "version": "1.10.1", "description": "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode" }, "servers": [