Skip to content

Referral Program Cycles#1603

Merged
Goader merged 18 commits intomainfrom
feat/referral-program-cycles
Feb 9, 2026
Merged

Referral Program Cycles#1603
Goader merged 18 commits intomainfrom
feat/referral-program-cycles

Conversation

@Goader
Copy link
Contributor

@Goader Goader commented Feb 3, 2026

ENS Referrals API v1: Multi-Cycle Support

closes: #1522


Reviewer Focus (Read This First)

What reviewers should focus on
  • Cycle ID typing strategy - ReferralProgramCycleId type allows both predefined IDs (cycle-1, cycle-2) with autocomplete AND custom cycle IDs loaded from JSON (using string & {} trick for extensibility)
  • Configuration loading strategy in apps/ensapi/src/config/config.schema.ts - loadReferralProgramCycleSet() with URL fetching and validation

Problem & Motivation

Why this exists

What Changed (Concrete)

What actually changed

1. New Data Models (packages/ens-referrals/src/v1/)

  1. cycle.ts - Core type definitions:

    • ReferralProgramCycleIds object with Cycle1: "cycle-1" and Cycle2: "cycle-2"
    • ReferralProgramCycleId type (extensible to custom IDs)
    • ReferralProgramCycle interface with id, displayName, rules, rulesUrl
    • ReferralProgramCycleSet type as Map<ReferralProgramCycleId, ReferralProgramCycle>
  2. cycle-defaults.ts - Default cycle definitions:

    • getReferralProgramCycleSet() function returning pre-built Map with cycle-1 and cycle-2
    • Cycle 1: ENS Holiday Awards (Dec 2025)
    • Cycle 2: March 2026
    • Both with proper start/end times, rules, and rules URLs
  3. api/zod-schemas.ts - Comprehensive validation:

    • makeReferralProgramCycleIdSchema() - validates cycle IDs
    • makeReferralProgramCycleSchema() - validates complete cycle objects
    • makeCustomReferralProgramCyclesSchema() - validates JSON array format with duplicate ID checks
    • makeReferralProgramCycleSetSchema() - validates Map structure
  4. api/types.ts - New response types:

    • ReferrerDetailAllCyclesData - Record of cycle ID to referrer details
    • ReferrerDetailAllCyclesResponse - Discriminated union for OK/Error
    • Full serialization/deserialization support

2. Configuration System (apps/ensapi/)

  1. Environment variable (packages/ensnode-sdk/src/shared/config/environments.ts):

    • Added CUSTOM_REFERRAL_PROGRAM_CYCLES?: string to ReferralProgramCyclesEnvironment
    • Optional URL to JSON file with custom cycle definitions
  2. Config schema (apps/ensapi/src/config/config.schema.ts):

    • Added loadReferralProgramCycleSet() async function:
      • Validates URL format
      • Fetches JSON
      • Validates against Zod schema
      • Converts array to Map with duplicate ID checking
      • Comprehensive error messages on failure (crashes ENSApi if invalid)
    • Updated EnsApiConfigSchema to include referralProgramCycleSet field
    • Updated buildConfigFromEnvironment() to call loader
  3. Environment documentation (apps/ensapi/.env.local.example):

    • Added comprehensive documentation for CUSTOM_REFERRAL_PROGRAM_CYCLES
    • Includes example JSON structure
    • Notes about USDC amount encoding (smallest units)
    • Removed ENS_HOLIDAY_AWARDS_START and ENS_HOLIDAY_AWARDS_END (no longer needed)

3. Multi-Cycle Cache Architecture (apps/ensapi/src/cache/)

  1. referral-leaderboard-cycles.cache.ts - New cache implementation:

    • ReferralLeaderboardCyclesCacheMap type: Map<ReferralProgramCycleId, SWRCache<ReferrerLeaderboard>>
    • initializeCyclesCaches() creates independent SWRCache for each configured cycle
    • Followed previous cache implementation pattern
    • Independent failure handling (SWR behavior preserved per cycle)
  2. V1 Middleware (apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts):

    • Provides referralLeaderboardCyclesCaches to downstream handlers
    • Followed previous middleware implementation pattern

4. V1 API Routes (apps/ensapi/src/handlers/ensanalytics-api-v1.ts)

  1. GET /v1/ensanalytics/referral-leaderboard (renamed from /referrers):

    • New required cycle query parameter
    • Returns 404 if cycle not found (with list of valid cycles)
    • Returns 500 if specific cycle's cache failed
    • Returns paginated leaderboard page for requested cycle
  2. GET /v1/ensanalytics/referral-leaderboard/:referrer (renamed from /referrers/:referrer):

    • Returns ReferrerDetailAllCyclesData instead of single ReferrerDetail
    • Aggregates data across ALL configured cycles
    • Returns 500 if any cycle's cache failed
    • Supports custom cycles loaded from JSON

Design & Planning

How this approach was chosen
  • Each cycle needs its own independent leaderboard (different time ranges, rules, award pools)
  • Per-cycle caching enables independent failure semantics (cycle 1 failing doesn't break cycle 2)

Self-Review

What you caught yourself
  • Some things noticed by CodeRabbit and others, like not using proper types in documentation examples (e.g. wrong format of PriceUsdc in custom cycles json)
  • Bugs caught: None
  • Logic simplified: None
  • Naming / terminology improved: None
  • Dead or unnecessary code removed: ENS_HOLIDAY_AWARDS_START/END env vars from ensapi config, now depends on the referral program cycle custom json

Cross-Codebase Alignment

Related code you checked
  • v0 unchanged, except the env variables (replaced by importing constants)
  • Search terms used: ReferralProgram, ENS_HOLIDAY_AWARDS, referrerLeaderboard, SWRCache, cycle, v1, v0
  • Reviewed but unchanged: v0 API implementation
  • Deferred alignment: None

Downstream & Consumer Impact

Who this affects and how

v0 unaffected, v1 is allowed to work with multiple cycles

  • Public APIs affected: v1 routes only (v0 unchanged)
  • Docs updated: Added comprehensive .env.local.example documentation, same with the client

Testing Evidence

How this was validated
  • Normal CI testing
  • Manually tested with running ENSApi instance
  • Tested custom JSON reading with the local server and multiple types of JSONs (duplicate ids, not a json, wrong format, correct format: removing, overriding, adding)
  • Testing performed: Automated CI + comprehensive manual validation
  • Known gaps: None
  • What reviewers have to reason about manually: Handling of custom cycle ids in terms of types

Scope Reductions

What you intentionally didn't do

Did not do partial success response for referrer details (all cycles) - if one cycle fails, return 500.

  • Follow-ups: Since all cycles essentially depend on the same data from the database, we could try to cache it as well, so we wouldn't repeat the same database queries for each cycle.
  • Why they were deferred: This is not a priority for now, and it is not clearly defined yet if the query will remain the same for the next cycles.

Risk Analysis

How this could go wrong

Low risk - v0 affected very little (only env vars, which were not used in production either way and defaulted to constants, which are now the only source of truth).

V1 is not used by any downstream consumer yet.

  • Risk areas:
    • Configuration loading (easy to crash for custom JSON if request fails, but this should be only limited to us testing the ENSApi)
  • Mitigations or rollback options: Revertible through previous ENSApi version
  • Named owner if this causes problems: @Goader

Pre-Review Checklist (Blocking)

  • I reviewed every line of this diff and understand it end-to-end
  • I'm prepared to defend this PR line-by-line in review
  • I'm comfortable being the on-call owner for this change
  • Relevant changesets are included (or explicitly not required)

@Goader Goader self-assigned this Feb 3, 2026
Copilot AI review requested due to automatic review settings February 3, 2026 14:55
@changeset-bot
Copy link

changeset-bot bot commented Feb 3, 2026

🦋 Changeset detected

Latest commit: a1522a2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@namehash/ens-referrals Major
ensapi Major
@ensnode/ensnode-sdk Major
ensadmin Major
ensindexer Major
ensrainbow Major
fallback-ensapi Major
@ensnode/ensnode-react Major
@ensnode/ensrainbow-sdk Major
@namehash/namehash-ui Major
@ensnode/datasources Major
@ensnode/ponder-metadata Major
@ensnode/ensnode-schema Major
@ensnode/ponder-sdk Major
@ensnode/ponder-subgraph Major
@ensnode/shared-configs Major
@docs/ensnode Major
@docs/ensrainbow Major
@docs/mintlify Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Contributor

vercel bot commented Feb 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
admin.ensnode.io Skipped Skipped Feb 9, 2026 5:15pm
ensnode.io Skipped Skipped Feb 9, 2026 5:15pm
ensrainbow.io Skipped Skipped Feb 9, 2026 5:15pm

@coderabbitai
Copy link

coderabbitai bot commented Feb 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces date-based ENS Holiday Awards config with a referral-program-editions model: loads an edition config set (custom URL or defaults), initializes per-edition leaderboard caches, updates v1 APIs and SDK types/serializers to be edition-aware, and adds utilities (parseTimestamp, SWRCache errorTtl).

Changes

Cohort / File(s) Summary
Config & Env
apps/ensapi/.env.local.example, apps/ensapi/src/config/config.schema.ts, apps/ensapi/src/config/config.schema.test.ts, apps/ensapi/src/config/environment.ts, apps/ensapi/src/config/validations.ts
Removed ENS_HOLIDAY_AWARDS_* date fields; added CUSTOM_REFERRAL_PROGRAM_EDITIONS (URL) and customReferralProgramEditionConfigSetUrl in config schema and tests; replaced date invariants with URL validation and error behavior.
Edition Config Cache
apps/ensapi/src/cache/referral-program-edition-set.cache.ts, .changeset/*
New SWR cache referralProgramEditionConfigSetCache that loads custom or default edition config set at startup (indefinite TTL, short errorTtl), with logging and startup failure semantics.
Per-edition Leaderboard Caches
apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts, apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts (deleted), apps/ensapi/src/cache/referrer-leaderboard.cache.ts
Removed old single V1 cache; added per-edition leaderboard cache map (one SWRCache per edition) with singleton init and isolated failures; adjusted remaining cache to use dedicated constants for dates.
Middleware & Factory
apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts, apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts, apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts, apps/ensapi/src/lib/hono-factory.ts
Added middleware to expose edition-config set and per-edition cache map; V1 middleware now attaches cycles cache map; factory types updated to include new middleware variables.
API Handlers & Tests
apps/ensapi/src/handlers/ensanalytics-api-v1.ts, apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
Refactored v1 endpoints to be edition-aware: /referral-leaderboard requires edition, /referrer accepts multiple editions; added /editions; updated validation, serializers, errors, and extensive tests for per-edition flows.
Referrals Library: models & rules
packages/ens-referrals/src/v1/edition.ts, .../edition-defaults.ts, .../edition-metrics.ts, .../rules.ts, .../index.ts
Introduced edition types (slug, config, config set), default editions (2025-12, 2026-03), added rulesUrl to ReferralProgramRules, removed holiday constants, and migrated detail types → edition-metrics.
Referrals Library: API surface
packages/ens-referrals/src/v1/api/types.ts, .../zod-schemas.ts, .../serialize.ts, .../deserialize.ts, .../serialized-types.ts, .../client.ts
Replaced single-cycle API types/schemas with edition-aware requests/responses; added edition-config serialization/deserialization; client methods added (getReferralProgramEditionConfigSet, getEditionConfigSet, getReferrerMetricsEditions) and leaderboard calls now require edition.
SDK: env, datetime, cache changes
packages/ensnode-sdk/src/shared/config/environments.ts, packages/ensnode-sdk/src/shared/datetime.ts, .../datetime.test.ts, .../cache/swr-cache.ts, .../cache/swr-cache.test.ts
Renamed environment subtype to ReferralProgramEditionsEnvironment and added CUSTOM_REFERRAL_PROGRAM_EDITIONS; added parseTimestamp(iso) utility; added errorTtl option to SWRCache and adjusted revalidation/TTL behavior and tests.
Startup & Misc tests/mocks
apps/ensapi/src/index.ts, apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts, apps/ensapi/src/lib/ensanalytics/.../get-referrer-leaderboard-v1.test.ts, packages/ens-referrals/src/v1/leaderboard-page.test.ts
Startup teardown now destroys edition caches and config-set cache; test fixtures updated to include rulesUrl; tests adjusted to new edition-aware shapes.
Changesets
.changeset/clever-laws-count.md, .changeset/proud-eagles-sing.md
Recorded edition support and defaults, plus SDK additions (parseTimestamp, SWRCache errorTtl).

Sequence Diagram

sequenceDiagram
    actor Client
    participant ConfigLoader as Config Loader
    participant EditionCache as Edition Config Cache
    participant PerEditionCaches as Per-Edition Caches
    participant API as ENSAnalytics API

    Client->>ConfigLoader: App startup
    ConfigLoader->>EditionCache: Load edition config set (custom URL or defaults)
    EditionCache-->>ConfigLoader: ReferralProgramEditionConfigSet
    ConfigLoader->>PerEditionCaches: initialize caches per edition
    PerEditionCaches-->>ConfigLoader: caches ready

    Client->>API: GET /v1/ensanalytics/referral-leaderboard?edition=2025-12&page=1
    API->>EditionCache: validate edition exists
    API->>PerEditionCaches: read cache for edition "2025-12"
    PerEditionCaches-->>API: ReferrerLeaderboard | Error
    API-->>Client: paginated leaderboard (per-edition)

    Client->>API: GET /v1/ensanalytics/referrer/0x123?editions=2025-12,2026-03
    API->>PerEditionCaches: read caches for editions
    PerEditionCaches-->>API: per-edition metrics map
    API-->>Client: ReferrerMetricsEditionsResponse (aggregated)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #1371: Adds an ISO→Unix timestamp helper; this PR introduces parseTimestamp, addressing similar needs.

Possibly related PRs

  • #1542: Restructures referral-related modules and public APIs — closely related to the client/types/serialize changes here.
  • #1562: Modifies v1 referral API surface and ENS API handlers/tests — overlaps with the edition-aware handler refactor.

Suggested labels

ensanalytics, ensnode-sdk

Poem

🐰
I hopped from dates to slugs so bright,
Per-edition caches through the night,
Rules linked by URL, defaults in hand,
Leaderboards bloom across the land —
A rabbit cheers for editions planned!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Referral Program Cycles' directly reflects the main objective of the PR, which is to add multi-cycle support to the ENS Referrals API v1 as described in issue #1522.
Description check ✅ Passed The description extensively covers the problem statement, what changed, design rationale, testing evidence, and includes a comprehensive self-review checklist, far exceeding the lightweight template expectations and providing necessary detail for a significant architectural change.
Linked Issues check ✅ Passed The PR implements all major coding requirements from issue #1522: cycle type definitions, cycle-defaults export, Zod validation schemas, config schema loader, environment variable setup, per-cycle cache architecture, and v1 API route updates with cycle parameters and multi-cycle aggregation.
Out of Scope Changes check ✅ Passed All changes align with the stated objectives: cycle support in v1, environment variable replacement, per-cycle caching, and API route updates. No unrelated features, refactoring, or scope creep detected. V0 was intentionally left unchanged per issue requirements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/referral-program-cycles

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request refactors the ENS referral program system from supporting a single program period to supporting multiple concurrent and sequential "cycles". Each cycle represents a distinct referral program period with its own rules, leaderboard, and award distribution.

Changes:

  • Introduces a cycle-based architecture with ReferralProgramCycle type and ReferralProgramCycleSet map structure
  • Updates API endpoints to require cycle ID parameter: /v1/ensanalytics/referral-leaderboard?cycle=<id> and /v1/ensanalytics/referral-leaderboard/<address> (now returns data for all cycles)
  • Replaces single cache with per-cycle independent caches to allow individual cycle failures without affecting others
  • Removes hardcoded ENS Holiday Awards configuration from environment variables, replacing with optional CUSTOM_REFERRAL_PROGRAM_CYCLES URL
  • Adds Cycle 2 (March 2026) as a second default cycle alongside Cycle 1 (ENS Holiday Awards December 2025)

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/ensnode-sdk/src/shared/config/environments.ts Replaces EnsHolidayAwardsEnvironment with ReferralProgramCyclesEnvironment
packages/ens-referrals/src/v1/rules.ts Removes hardcoded ENS Holiday Awards constants
packages/ens-referrals/src/v1/cycle.ts Adds new cycle type definitions and type guards
packages/ens-referrals/src/v1/cycle-defaults.ts Defines default cycles (Cycle 1 and Cycle 2) with configuration
packages/ens-referrals/src/v1/index.ts Exports new cycle types
packages/ens-referrals/src/v1/client.ts Updates SDK client to require cycle parameter for leaderboard queries
packages/ens-referrals/src/v1/api/*.ts Updates API types to support all-cycles responses and cycle-specific requests
apps/ensapi/src/handlers/ensanalytics-api-v1.ts Updates API handlers to support cycle parameter and multi-cycle responses
apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts Updates tests to work with new cycle-based cache structure
apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts New cache implementation with per-cycle independent caches
apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts Deleted - replaced by cycle-based cache
apps/ensapi/src/config/*.ts Updates configuration loading to support custom cycle URLs
apps/ensapi/.env.local.example Updates environment variable documentation
.changeset/clever-laws-count.md Empty changeset file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/ens-referrals/src/v1/api/types.ts (1)

74-124: ⚠️ Potential issue | 🟠 Major

Breaking public type renames—add compatibility aliases or ensure a major version bump.
Downstream TypeScript consumers using the old ReferrerDetailResponse* exports will break. Consider re‑exporting deprecated aliases or explicitly bumping the package major version and documenting the migration.

🧩 Optional compatibility aliases
+/** `@deprecated` Use ReferrerDetailAllCyclesResponseCodes */
+export const ReferrerDetailResponseCodes = ReferrerDetailAllCyclesResponseCodes;
+/** `@deprecated` Use ReferrerDetailAllCyclesResponseCode */
+export type ReferrerDetailResponseCode = ReferrerDetailAllCyclesResponseCode;
+/** `@deprecated` Use ReferrerDetailAllCyclesResponseOk */
+export type ReferrerDetailResponseOk = ReferrerDetailAllCyclesResponseOk;
+/** `@deprecated` Use ReferrerDetailAllCyclesResponseError */
+export type ReferrerDetailResponseError = ReferrerDetailAllCyclesResponseError;
+/** `@deprecated` Use ReferrerDetailAllCyclesResponse */
+export type ReferrerDetailResponse = ReferrerDetailAllCyclesResponse;
🤖 Fix all issues with AI agents
In `@apps/ensapi/.env.local.example`:
- Around line 115-138: Update the env example comment for
CUSTOM_REFERRAL_PROGRAM_CYCLES to state that supplying a URL will completely
replace the default referral cycle set (i.e., it overrides defaults), and
instruct operators to include every cycle they want active in their hosted JSON;
reference the env variable name CUSTOM_REFERRAL_PROGRAM_CYCLES and the example
JSON structure in the comment so readers know to add any default cycles they
still want when providing a custom file.

In `@apps/ensapi/src/handlers/ensanalytics-api-v1.ts`:
- Around line 185-198: The handler currently fail-fast when any cycle
cache.read() returns an Error; to support partial-success change the response to
allow per-cycle results and errors by updating the
ReferrerDetailAllCyclesResponse/ReferrerDetailAllCyclesData types to map cycleId
-> { data?: ReferrerDetail; error?: string }, then modify the loop over
referralLeaderboardCyclesCaches to collect each cycle's result into
allCyclesData[cycleId] using getReferrerDetail(referrer, leaderboard) on success
or an error string on failure (using cycleCache.read()), and finally return a
200 with the aggregated per-cycle data (and optionally an overall status field)
instead of returning 500 on the first failure so clients can consume available
cycles while seeing which cycles failed.

In `@packages/ens-referrals/src/v1/api/deserialize.ts`:
- Around line 235-244: The type error comes from building `data` via
`Object.entries(maybeResponse.data)` which TypeScript widens to `Record<string,
...>`; fix by ensuring the final object is explicitly typed as
`ReferrerDetailAllCyclesResponse` — replace the assignment to `deserialized`
with an explicit cast: set `deserialized = { responseCode: "ok", data } as
ReferrerDetailAllCyclesResponse`; alternatively, build `data` with a typed
reducer (e.g., reduce to `Record<ReferralProgramCycleId, ReferrerDetail>` or
`ReferrerDetailAllCyclesData`) so the resulting `data` has the correct key type
before assigning to `deserialized` (use `deserializeReferrerDetail` inside the
reducer).

In `@packages/ens-referrals/src/v1/api/serialize.ts`:
- Around line 154-160: The switch in serializeReferrerDetail over ReferrerDetail
isn't exhaustive and can return undefined if a new detail.type is added; update
serializeReferrerDetail to include a default branch that asserts exhaustiveness
(e.g., call an assertNever/assertUnreachable helper or assign detail to a
never-typed variable and throw) so the compiler errors on new union members and
at runtime you throw a clear error; keep existing branches that call
serializeReferrerDetailRanked and serializeReferrerDetailUnranked and reference
ReferrerDetail/SerializedReferrerDetail in the assertion.
- Around line 201-218: The switch over response.responseCode
(ReferrerDetailAllCyclesResponseCodes) is not exhaustive and the serializedData
is unsafely asserted with {} as SerializedReferrerDetailAllCyclesData; update
the case for Ok to build the record in a type-safe way (e.g., const
serializedData: Partial<SerializedReferrerDetailAllCyclesData> = {}; for (const
[cycleId, detail] of Object.entries(response.data)) { serializedData[cycleId as
ReferralProgramCycleId] = serializeReferrerDetail(detail); } and return data:
serializedData as SerializedReferrerDetailAllCyclesData), and add a default (or
never) branch after the known cases that throws an error like throw new
Error(`Unhandled ReferrerDetailAllCyclesResponseCodes:
${response.responseCode}`) to enforce exhaustiveness for
ReferrerDetailAllCyclesResponseCodes.

In `@packages/ens-referrals/src/v1/api/zod-schemas.ts`:
- Around line 281-306: In makeReferralProgramCycleSetSchema, the refine
currently only checks that each value parses as a ReferralProgramCycle but
doesn't ensure the Map key equals the cycle's id; update the validate loop in
makeReferralProgramCycleSetSchema to parse each value with
makeReferralProgramCycleSchema(`${valueLabel}[${key}]`), extract the parsed
cycle.id, and return false if typeof key !== "string" or parsedCycle.id !== key
(so keys must match the cycle.id). Also update the refine error message to
indicate keys must equal the nested cycle.id for integrity.

In `@packages/ens-referrals/src/v1/client.ts`:
- Around line 220-229: The example accesses response.data["cycle-1"] without
guarding for undefined which can cause unsafe access; update the example around
the getReferrerDetail response handling (check response.responseCode ===
ReferrerDetailAllCyclesResponseCodes.Ok) to first confirm the key exists (e.g.,
verify response.data["cycle-1"] is truthy) before reading its .type and
.referrer, and adjust the branching that inspects ReferrerDetailTypeIds.Ranked
to handle the case where cycle1Detail is undefined so callers see the safer
pattern.
- Around line 99-104: The JSDoc example in client.ts references a non-existent
rules.cycleId; update the example to use an existing serialized field from
serializeReferralProgramRules (e.g., rules.subregistryId or
rules.startTime/endTime/totalAwardPoolValue) or, if you intended to show the
requested cycle id, reference the request parameter (e.g., the cycleId variable
used to call the client) instead; modify the example console.log lines in the
example block near the response handling to use one of those valid identifiers
so the docs match serializeReferralProgramRules and the request parameters.

In `@packages/ens-referrals/src/v1/cycle-defaults.ts`:
- Around line 45-55: The START_DATE and END_DATE constants in cycle-defaults.ts
are using incorrect Unix timestamps for the documented UTC dates; update
START_DATE (currently 1772524800) to 1772323200 and END_DATE (currently
1775116799) to 1775001599 so the cycle properly covers 2026-03-01T00:00:00Z
through 2026-03-31T23:59:59Z; edit the START_DATE and END_DATE values (the
symbols START_DATE and END_DATE) to the corrected integers and keep the
UnixTimestamp type assertions.

In `@packages/ens-referrals/src/v1/cycle.ts`:
- Around line 42-43: The type guard isPredefinedCycleId currently narrows to
ReferralProgramCycleId which is effectively any string; change its return type
to the concrete union of predefined IDs (e.g. value is typeof
ALL_REFERRAL_PROGRAM_CYCLE_IDS[number] or a named
PredefinedReferralProgramCycleId union) and keep the runtime check using
ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as any) so callers get stronger
compile-time narrowing while preserving the existing runtime behavior; update
the function signature only (isPredefinedCycleId) to reference the more precise
union type (or create a named union) and leave ALL_REFERRAL_PROGRAM_CYCLE_IDS
usage unchanged.

Comment on lines 42 to 43
export const isPredefinedCycleId = (value: string): value is ReferralProgramCycleId =>
ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as ReferralProgramCycleId);
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider a more precise return type for the type guard.

The type guard narrows to ReferralProgramCycleId, but since that type includes string & {}, any string technically satisfies it. For better type safety, consider narrowing to just the predefined union:

♻️ Suggested refinement
-export const isPredefinedCycleId = (value: string): value is ReferralProgramCycleId =>
+export const isPredefinedCycleId = (value: string): value is (typeof ReferralProgramCycleIds)[keyof typeof ReferralProgramCycleIds] =>
   ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as ReferralProgramCycleId);

The current implementation works correctly at runtime, and the JSDoc clearly documents the behavior, so this is optional.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const isPredefinedCycleId = (value: string): value is ReferralProgramCycleId =>
ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as ReferralProgramCycleId);
export const isPredefinedCycleId = (value: string): value is (typeof ReferralProgramCycleIds)[keyof typeof ReferralProgramCycleIds] =>
ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as ReferralProgramCycleId);
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/cycle.ts` around lines 42 - 43, The type guard
isPredefinedCycleId currently narrows to ReferralProgramCycleId which is
effectively any string; change its return type to the concrete union of
predefined IDs (e.g. value is typeof ALL_REFERRAL_PROGRAM_CYCLE_IDS[number] or a
named PredefinedReferralProgramCycleId union) and keep the runtime check using
ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as any) so callers get stronger
compile-time narrowing while preserving the existing runtime behavior; update
the function signature only (isPredefinedCycleId) to reference the more precise
union type (or create a named union) and leave ALL_REFERRAL_PROGRAM_CYCLE_IDS
usage unchanged.

@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 3, 2026 16:00 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 3, 2026 16:00 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 3, 2026 16:00 Inactive
@vercel
Copy link
Contributor

vercel bot commented Feb 3, 2026

Deployment failed with the following error:

Failed to create deployment for team_CWAzsA7nfejZhqJ4NfJ8B5ew in project prj_nKcHTO12hq9kcgascQMq4xokRhwp: FetchError: request to https://76.76.21.112/v13/now/deployments?ownerId=team_CWAzsA7nfejZhqJ4NfJ8B5ew&projectId=prj_nKcHTO12hq9kcgascQMq4xokRhwp&skipAutoDetectionConfirmation=1&teamId=team_CWAzsA7nfejZhqJ4NfJ8B5ew&traceCarrier=%7B%22ot-baggage-webhookAt%22%3A%221770132262052%22%2C%22ot-baggage-senderUsername%22%3A%22gh.Goader%22%2C%22x-datadog-trace-id%22%3A%228401127708701102998%22%2C%22x-datadog-parent-id%22%3A%222369066029094997530%22%2C%22x-datadog-sampling-priority%22%3A%222%22%2C%22x-datadog-tags%22%3A%22_dd.p.tid%3D6982132600000000%2C_dd.p.dm%3D-3%22%2C%22traceparent%22%3A%2200-69821326000000007496cd23d5d21396-20e09d0069a70e1a-01%22%2C%22tracestate%22%3A%22dd%3Dt.tid%3A6982132600000000%3Bt.dm%3A-3%3Bs%3A2%3Bp%3A20e09d0069a70e1a%22%7D failed, reason: socket hang up

@vercel
Copy link
Contributor

vercel bot commented Feb 3, 2026

Deployment failed with the following error:

Failed to create deployment for team_CWAzsA7nfejZhqJ4NfJ8B5ew in project prj_fqPGNyBo1bNMz2uZKsCVNdKsoOJF: FetchError: request to https://76.76.21.112/v13/now/deployments?ownerId=team_CWAzsA7nfejZhqJ4NfJ8B5ew&projectId=prj_fqPGNyBo1bNMz2uZKsCVNdKsoOJF&skipAutoDetectionConfirmation=1&teamId=team_CWAzsA7nfejZhqJ4NfJ8B5ew&traceCarrier=%7B%22ot-baggage-webhookAt%22%3A%221770132262052%22%2C%22ot-baggage-senderUsername%22%3A%22gh.Goader%22%2C%22x-datadog-trace-id%22%3A%228401127708701102998%22%2C%22x-datadog-parent-id%22%3A%22579946873202667545%22%2C%22x-datadog-sampling-priority%22%3A%222%22%2C%22x-datadog-tags%22%3A%22_dd.p.tid%3D6982132600000000%2C_dd.p.dm%3D-3%22%2C%22traceparent%22%3A%2200-69821326000000007496cd23d5d21396-080c629bc5d7f019-01%22%2C%22tracestate%22%3A%22dd%3Dt.tid%3A6982132600000000%3Bt.dm%3A-3%3Bs%3A2%3Bp%3A080c629bc5d7f019%22%7D failed, reason: socket hang up

@vercel
Copy link
Contributor

vercel bot commented Feb 3, 2026

Deployment failed with the following error:

Failed to create deployment for team_CWAzsA7nfejZhqJ4NfJ8B5ew in project prj_IdDwohGX1owW84Tj9u3nFc6vDekK: FetchError: request to https://76.76.21.112/v13/now/deployments?ownerId=team_CWAzsA7nfejZhqJ4NfJ8B5ew&projectId=prj_IdDwohGX1owW84Tj9u3nFc6vDekK&skipAutoDetectionConfirmation=1&teamId=team_CWAzsA7nfejZhqJ4NfJ8B5ew&traceCarrier=%7B%22ot-baggage-webhookAt%22%3A%221770132262052%22%2C%22ot-baggage-senderUsername%22%3A%22gh.Goader%22%2C%22x-datadog-trace-id%22%3A%228401127708701102998%22%2C%22x-datadog-parent-id%22%3A%222369066029094997530%22%2C%22x-datadog-sampling-priority%22%3A%222%22%2C%22x-datadog-tags%22%3A%22_dd.p.tid%3D6982132600000000%2C_dd.p.dm%3D-3%22%2C%22traceparent%22%3A%2200-69821326000000007496cd23d5d21396-20e09d0069a70e1a-01%22%2C%22tracestate%22%3A%22dd%3Dt.tid%3A6982132600000000%3Bt.dm%3A-3%3Bs%3A2%3Bp%3A20e09d0069a70e1a%22%7D failed, reason: socket hang up

Copilot AI review requested due to automatic review settings February 3, 2026 16:20
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 83 to 127
async function loadReferralProgramCycleSet(
customCyclesUrl: string | undefined,
namespace: ENSNamespaceId,
): Promise<ReferralProgramCycleSet> {
const subregistryId = getEthnamesSubregistryId(namespace);

if (!customCyclesUrl) {
logger.info("Using default referral program cycle set");
return getReferralProgramCycleSet(subregistryId.address);
}

// Validate URL format
try {
new URL(customCyclesUrl);
} catch {
throw new Error(`CUSTOM_REFERRAL_PROGRAM_CYCLES is not a valid URL: ${customCyclesUrl}`);
}

// Fetch and validate
logger.info(`Fetching custom referral program cycles from: ${customCyclesUrl}`);
const response = await fetch(customCyclesUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch custom referral program cycles from ${customCyclesUrl}: ${response.status} ${response.statusText}`,
);
}

const json = await response.json();
const schema = makeCustomReferralProgramCyclesSchema("CUSTOM_REFERRAL_PROGRAM_CYCLES");
const validated = schema.parse(json);

// Convert array to Map, check for duplicates
const cycleSet: ReferralProgramCycleSet = new Map();
for (const cycleObj of validated) {
const cycle = cycleObj as ReferralProgramCycle;
const cycleId = cycle.id;
if (cycleSet.has(cycleId)) {
throw new Error(`Duplicate cycle ID in CUSTOM_REFERRAL_PROGRAM_CYCLES: ${cycle.id}`);
}
cycleSet.set(cycleId, cycle);
}

logger.info(`Loaded ${cycleSet.size} custom referral program cycles`);
return cycleSet;
}
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The loadReferralProgramCycleSet function performs a network request during application startup (via buildConfigFromEnvironment). If the custom cycles URL is unreachable, slow to respond, or returns invalid data, this will block application startup.

Consider adding:

  1. A timeout for the fetch operation to prevent hanging
  2. Retry logic with exponential backoff for transient network failures
  3. Better error context in the exception messages to help with debugging
  4. Optional: A fallback mechanism to use default cycles if custom cycles fail to load (with appropriate logging)

This would improve resilience during deployment and prevent startup failures due to temporary network issues.

Copilot uses AI. Check for mistakes.
Comment on lines 184 to 198
// Check all caches and fail immediately if any cache failed
for (const [cycleId, cycleCache] of c.var.referralLeaderboardCyclesCaches) {
const leaderboard = await cycleCache.read();
if (leaderboard instanceof Error) {
return c.json(
serializeReferrerDetailAllCyclesResponse({
responseCode: ReferrerDetailAllCyclesResponseCodes.Error,
error: "Internal Server Error",
errorMessage: `Referrer leaderboard data for cycle ${cycleId} has not been successfully cached yet.`,
} satisfies ReferrerDetailAllCyclesResponse),
500,
);
}
allCyclesData[cycleId] = getReferrerDetail(referrer, leaderboard);
}
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The fail-fast approach means that if any single cycle fails to load, the entire request returns an error. This could be problematic from a user experience perspective - if cycle-1 is working but cycle-2 fails, users cannot access cycle-1 data either.

Consider implementing a more resilient approach where:

  1. Successfully loaded cycles are returned in the response
  2. Failed cycles are indicated with an error status or omitted with a warning
  3. The response includes information about which cycles loaded successfully vs. which failed

This would provide better availability and user experience, especially during partial outages or when one cycle has data issues.

Copilot uses AI. Check for mistakes.
Comment on lines 185 to 198
for (const [cycleId, cycleCache] of c.var.referralLeaderboardCyclesCaches) {
const leaderboard = await cycleCache.read();
if (leaderboard instanceof Error) {
return c.json(
serializeReferrerDetailAllCyclesResponse({
responseCode: ReferrerDetailAllCyclesResponseCodes.Error,
error: "Internal Server Error",
errorMessage: `Referrer leaderboard data for cycle ${cycleId} has not been successfully cached yet.`,
} satisfies ReferrerDetailAllCyclesResponse),
500,
);
}
allCyclesData[cycleId] = getReferrerDetail(referrer, leaderboard);
}
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The code iterates over c.var.referralLeaderboardCyclesCaches using a for-of loop with .entries(). JavaScript Maps preserve insertion order, but the iteration order matters here because the first cycle that fails will determine the error response.

If the goal is to fail fast on any cycle failure, consider:

  1. Documenting the specific iteration order behavior (e.g., "cycles are checked in the order they were configured")
  2. Or explicitly sorting the cycles to ensure a predictable error reporting order
  3. Or collecting all failures and reporting them together

The current implementation may result in non-deterministic error reporting if the Map insertion order isn't guaranteed by the configuration loading code.

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/ens-referrals/src/v1/rules.ts (1)

81-101: 🧹 Nitpick | 🔵 Trivial

Consider an options-object pattern for buildReferralProgramRules.

With 6 positional parameters, call sites are becoming harder to read (easy to swap startTime/endTime or other same-typed args). An options object would improve clarity and reduce positional mistakes.

♻️ Example refactor
-export const buildReferralProgramRules = (
-  totalAwardPoolValue: PriceUsdc,
-  maxQualifiedReferrers: number,
-  startTime: UnixTimestamp,
-  endTime: UnixTimestamp,
-  subregistryId: AccountId,
-  rulesUrl: URL,
-): ReferralProgramRules => {
-  const result = {
-    totalAwardPoolValue,
-    maxQualifiedReferrers,
-    startTime,
-    endTime,
-    subregistryId,
-    rulesUrl,
-  } satisfies ReferralProgramRules;
+export const buildReferralProgramRules = (
+  options: ReferralProgramRules,
+): ReferralProgramRules => {
+  validateReferralProgramRules(options);
+  return options;
+};

This leverages the existing ReferralProgramRules interface directly, eliminating redundancy between the parameter list and the interface definition.

packages/ens-referrals/src/v1/api/serialize.ts (1)

189-202: 🧹 Nitpick | 🔵 Trivial

Missing exhaustiveness check in serializeReferrerLeaderboardPageResponse.

The new serializers (serializeReferrerMetricsEditionsResponse, serializeReferralProgramEditionConfigSetResponse) include default: never exhaustiveness checks, but this existing serializer does not. For consistency and the same compile-time safety guarantees, consider adding one here too.

♻️ Proposed fix
     case ReferrerLeaderboardPageResponseCodes.Error:
       return response;
+
+    default: {
+      const _exhaustiveCheck: never = response;
+      throw new Error(
+        `Unknown response code: ${(_exhaustiveCheck as ReferrerLeaderboardPageResponse).responseCode}`,
+      );
+    }
   }
🤖 Fix all issues with AI agents
In @.changeset/clever-laws-count.md:
- Line 6: The single-line changeset description in
.changeset/clever-laws-count.md is dense; split it into a short header plus 3–4
bullet points for clarity — one bullet for referral program editions support
with example edition names (ENS Holiday Awards December 2025, March 2026), one
for "pre-configured edition definitions", one for the ENSAnalytics API v1 change
(mention edition-based leaderboard queries), and one for adding edition
configuration to the environment schema; keep tense consistent and wrap the
bullets under the existing changeset summary line.

In `@apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts`:
- Around line 54-107: The serialized rules JSON is being logged at info level
inside createEditionLeaderboardBuilder, producing multi-line output every cache
rebuild; change the detailed dump that calls logger.info with
serializeReferralProgramRules to logger.debug while keeping the summary
success/info logs (the initial logger.info describing "Building referrer
leaderboard..." and the final logger.info with leaderboard.referrers.size)
unchanged so only the verbose rules dump is demoted to debug.

In `@apps/ensapi/src/config/config.schema.test.ts`:
- Around line 86-100: Add a negative test in config.schema.test.ts that sets
CUSTOM_REFERRAL_PROGRAM_EDITIONS to an invalid string (e.g., "not-a-url") and
asserts that buildConfigFromEnvironment fails validation; specifically call
buildConfigFromEnvironment with the modified env and verify it either throws a
validation error or triggers the same process.exit(1) behavior used in other
config error-path tests (reuse the existing process.exit mock/assert pattern),
referencing CUSTOM_REFERRAL_PROGRAM_EDITIONS and
config.customReferralProgramEditionConfigSetUrl to ensure the invalid URL path
is covered.

In `@apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts`:
- Around line 882-905: The test mocks
editionSetMiddleware.referralProgramEditionConfigSetMiddleware but not
editionsCachesMiddleware.referralProgramEditionCachesMiddleware, causing
reliance on prior test state; add an explicit mock for
editionsCachesMiddleware.referralProgramEditionCachesMiddleware in this test
that calls next() (and, if necessary, sets any context keys the real middleware
would) so the middleware chain proceeds and the route handler executes reliably
without leaking state from other tests.

In `@packages/ens-referrals/src/v1/api/zod-schemas.ts`:
- Around line 280-289: The default valueLabel for
makeReferrerMetricsEditionsResponseOkSchema and
makeReferrerMetricsEditionsResponseErrorSchema is inconsistent (both use
"ReferrerMetricsEditionsResponse"); update the default valueLabel parameter in
makeReferrerMetricsEditionsResponseOkSchema to
"ReferrerMetricsEditionsResponseOk" and in
makeReferrerMetricsEditionsResponseErrorSchema to
"ReferrerMetricsEditionsResponseError" so error messages match the Ok/Error
schema pair naming convention used elsewhere (locate these functions by name to
make the change).

In `@packages/ens-referrals/src/v1/edition-defaults.ts`:
- Around line 39-51: The inline placeholder rules URL used when constructing
edition2 via buildReferralProgramRules (the new
URL("https://ensawards.org/ens-holiday-awards-rules") argument) must be tracked
and validated: extract that literal into a named constant (e.g.,
PLACEHOLDER_RULES_URL) and replace the inline URL in the
ReferralProgramEditionConfig for edition2, then add a small validation in the
editions loader/initialization code (where editions are assembled or exported)
to detect if any edition.ruleUrl === PLACEHOLDER_RULES_URL and either throw a
build/validation error or emit a clear log/error that blocks release; ensure you
reference edition2 and buildReferralProgramRules so the check runs whenever
editions are registered.

In `@packages/ens-referrals/src/v1/edition.ts`:
- Line 17: ReferralProgramEditionSlug is currently a plain string so arbitrary
strings can be passed; change it to a branded/opaque type (e.g., type
ReferralProgramEditionSlug = string & { readonly __brand: unique symbol }) and
use a single factory/validator function (or the existing Zod parse function) to
produce/return the branded type so callers must explicitly obtain the branded
value; update any functions or parameters that currently accept raw strings
(references to ReferralProgramEditionSlug) to require the branded type so
accidental misuse is caught at compile time.

In `@packages/ensnode-sdk/src/shared/cache/swr-cache.ts`:
- Around line 158-167: Update the stale inline comment above the TTL check to
reference the computed effective TTL instead of just "ttl": change the comment
near the effectiveTtl calculation and the following check in swr-cache.ts
(around this.cache.updatedAt, durationBetween, getUnixTime, effectiveTtl, and
this.revalidate) to say something like "if effective TTL expired, revalidate in
background" so the comment matches the branching logic that uses errorTtl when
this.cache.result is an Error.

Copilot AI review requested due to automatic review settings February 8, 2026 17:37
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 8, 2026 17:37 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 8, 2026 17:37 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts`:
- Around line 664-723: Add a new test (or extend the existing one) that requests
only unknown edition slugs (e.g.,
app.request(`/referrer/${referrer}?editions=foo,bar`)) to ensure the handler
returns 404 and the same error payload shape; reuse the same mocks
(mockEditionsCaches, mockEditionConfigSet) and assertions via
deserializeReferrerMetricsEditionsResponse and
ReferrerMetricsEditionsResponseCodes.Error to check response.error contains "Not
Found", response.errorMessage lists the unknown slugs ("foo,bar") and still
includes the valid editions list ("2025-12, 2026-03").

In `@packages/ens-referrals/src/v1/api/zod-schemas.ts`:
- Around line 248-264: The uniqueness refine in
makeReferrerMetricsEditionsArraySchema currently checks uniqueness by creating a
Set and comparing its size to editions.length; to align with
makeReferralProgramEditionConfigSetArraySchema and be more efficient, replace
that check with an early-return loop that iterates editions, uses a Set.has()
check to detect duplicates and returns false immediately on first duplicate
(otherwise adds to the Set and returns true at end); update the refine predicate
and keep the same error message `${valueLabel} must not contain duplicate
edition slugs`.

Comment on lines +664 to +723
it("returns 404 error when unknown edition slug is requested", async () => {
// Arrange: mock cache map with configured editions
const mockEditionsCaches = new Map<ReferralProgramEditionSlug, SWRCache<ReferrerLeaderboard>>(
[
[
"2025-12",
{
read: async () => populatedReferrerLeaderboard,
} as SWRCache<ReferrerLeaderboard>,
],
[
"2026-03",
{
read: async () => populatedReferrerLeaderboard,
} as SWRCache<ReferrerLeaderboard>,
],
],
);

// Mock edition set middleware to provide a mock edition set
const mockEditionConfigSet = new Map([
["2025-12", { slug: "2025-12", displayName: "Edition 1", rules: {} as any }],
["2026-03", { slug: "2026-03", displayName: "Edition 2", rules: {} as any }],
]);
vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation(
async (c, next) => {
c.set("referralProgramEditionConfigSet", mockEditionConfigSet);
return await next();
},
);

// Mock caches middleware to provide the mock caches
vi.mocked(
editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware,
).mockImplementation(async (c, next) => {
c.set("referralLeaderboardEditionsCaches", mockEditionsCaches);
return await next();
});

// Arrange: use any referrer address
const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e";

// Act: send test request with one valid and one invalid edition
const httpResponse = await app.request(
`/referrer/${referrer}?editions=2025-12,invalid-edition`,
);
const responseData = await httpResponse.json();
const response = deserializeReferrerMetricsEditionsResponse(responseData);

// Assert: response is 404 error with list of valid editions
expect(httpResponse.status).toBe(404);
expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Error);
if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) {
expect(response.error).toBe("Not Found");
expect(response.errorMessage).toContain("invalid-edition");
expect(response.errorMessage).toBe(
"Unknown edition(s): invalid-edition. Valid editions: 2025-12, 2026-03",
);
}
});
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider testing with all unknown editions in addition to a mixed valid/invalid request.

This test sends editions=2025-12,invalid-edition (one valid, one invalid) and asserts 404. It would strengthen coverage to also test the case where all requested editions are unknown (e.g., editions=foo,bar), to confirm the same 404 behavior and that the valid-editions list is still returned correctly.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts` around lines 664 - 723,
Add a new test (or extend the existing one) that requests only unknown edition
slugs (e.g., app.request(`/referrer/${referrer}?editions=foo,bar`)) to ensure
the handler returns 404 and the same error payload shape; reuse the same mocks
(mockEditionsCaches, mockEditionConfigSet) and assertions via
deserializeReferrerMetricsEditionsResponse and
ReferrerMetricsEditionsResponseCodes.Error to check response.error contains "Not
Found", response.errorMessage lists the unknown slugs ("foo,bar") and still
includes the valid editions list ("2025-12, 2026-03").

Comment on lines +248 to +264
export const makeReferrerMetricsEditionsArraySchema = (
valueLabel: string = "ReferrerMetricsEditionsArray",
) =>
z
.array(makeReferralProgramEditionSlugSchema(`${valueLabel}[edition]`))
.min(1, `${valueLabel} must contain at least 1 edition`)
.max(
MAX_EDITIONS_PER_REQUEST,
`${valueLabel} must not contain more than ${MAX_EDITIONS_PER_REQUEST} editions`,
)
.refine(
(editions) => {
const uniqueEditions = new Set(editions);
return uniqueEditions.size === editions.length;
},
{ message: `${valueLabel} must not contain duplicate edition slugs` },
);
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Minor inconsistency in uniqueness check pattern.

This schema uses a Set size comparison for uniqueness (line 260–261), while makeReferralProgramEditionConfigSetArraySchema (lines 336–342) uses an early-return loop with Set.has(). Both are correct, but the early-return pattern is slightly more efficient for large arrays and provides a more consistent codebase.

Consider aligning both to the same pattern:

♻️ Suggested alignment
     .refine(
       (editions) => {
-        const uniqueEditions = new Set(editions);
-        return uniqueEditions.size === editions.length;
+        const seen = new Set<string>();
+        for (const edition of editions) {
+          if (seen.has(edition)) return false;
+          seen.add(edition);
+        }
+        return true;
       },
       { message: `${valueLabel} must not contain duplicate edition slugs` },
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const makeReferrerMetricsEditionsArraySchema = (
valueLabel: string = "ReferrerMetricsEditionsArray",
) =>
z
.array(makeReferralProgramEditionSlugSchema(`${valueLabel}[edition]`))
.min(1, `${valueLabel} must contain at least 1 edition`)
.max(
MAX_EDITIONS_PER_REQUEST,
`${valueLabel} must not contain more than ${MAX_EDITIONS_PER_REQUEST} editions`,
)
.refine(
(editions) => {
const uniqueEditions = new Set(editions);
return uniqueEditions.size === editions.length;
},
{ message: `${valueLabel} must not contain duplicate edition slugs` },
);
export const makeReferrerMetricsEditionsArraySchema = (
valueLabel: string = "ReferrerMetricsEditionsArray",
) =>
z
.array(makeReferralProgramEditionSlugSchema(`${valueLabel}[edition]`))
.min(1, `${valueLabel} must contain at least 1 edition`)
.max(
MAX_EDITIONS_PER_REQUEST,
`${valueLabel} must not contain more than ${MAX_EDITIONS_PER_REQUEST} editions`,
)
.refine(
(editions) => {
const seen = new Set<string>();
for (const edition of editions) {
if (seen.has(edition)) return false;
seen.add(edition);
}
return true;
},
{ message: `${valueLabel} must not contain duplicate edition slugs` },
);
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/api/zod-schemas.ts` around lines 248 - 264, The
uniqueness refine in makeReferrerMetricsEditionsArraySchema currently checks
uniqueness by creating a Set and comparing its size to editions.length; to align
with makeReferralProgramEditionConfigSetArraySchema and be more efficient,
replace that check with an early-return loop that iterates editions, uses a
Set.has() check to detect duplicates and returns false immediately on first
duplicate (otherwise adds to the Set and returns true at end); update the refine
predicate and keep the same error message `${valueLabel} must not contain
duplicate edition slugs`.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

# For the complete schema definition, see makeReferralProgramEditionConfigSetArraySchema in @namehash/ens-referrals/v1
#
# Fetching Behavior:
# - Fetched proactively at ENSApi startup (before accepting requests)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

The env docs claim the edition config set JSON is fetched at startup “before accepting requests”, but SWRCache proactivelyInitialize: true triggers an async revalidate without awaiting it. The server can start serving requests before the fetch completes, so the docs should reflect that (or the bootstrap code should await initialization if that behavior is required).

Suggested change
# - Fetched proactively at ENSApi startup (before accepting requests)
# - Fetched proactively when ENSApi starts up (non-blocking; ENSApi may begin accepting requests before load completes)

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

@Goader Yes this feedback from Copilot matches my understanding. We may proactively initialize an attempt to fetch, but as I understand we don't block while waiting for successful fetching to complete.

Is that fair?

Copy link
Member

@lightwalker-eth lightwalker-eth left a comment

Choose a reason for hiding this comment

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

@Goader Really awesome work here! Very happy 🚀 Looks great! Shared some small suggestions please take the lead to merge when ready 👍

parseTimestamp("2026-03-01T00:00:00Z"),
parseTimestamp("2026-03-31T23:59:59Z"),
subregistryId,
// note: this will be replaced with dedicated March 2026 rules URL once published
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// note: this will be replaced with dedicated March 2026 rules URL once published
// TODO: replace this with the dedicated March 2026 rules URL once published

# For the complete schema definition, see makeReferralProgramEditionConfigSetArraySchema in @namehash/ens-referrals/v1
#
# Fetching Behavior:
# - Fetched proactively at ENSApi startup (before accepting requests)
Copy link
Member

Choose a reason for hiding this comment

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

@Goader Yes this feedback from Copilot matches my understanding. We may proactively initialize an attempt to fetch, but as I understand we don't block while waiting for successful fetching to complete.

Is that fair?

Comment on lines 25 to 27
*
* @invariant Must contain only lowercase letters (a-z), digits (0-9), and hyphens (-).
* Must not start or end with a hyphen. Pattern: `^[a-z0-9]+(-[a-z0-9]+)*$`
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
*
* @invariant Must contain only lowercase letters (a-z), digits (0-9), and hyphens (-).
* Must not start or end with a hyphen. Pattern: `^[a-z0-9]+(-[a-z0-9]+)*$`

You did a great job of documenting this invariant on the definition of ReferralProgramEditionSlug. Therefore no need to duplicate that documentation here.

*
* Used to store and look up all configured referral program editions.
*/
export type ReferralProgramEditionConfigSet = Map<
Copy link
Member

Choose a reason for hiding this comment

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

Let's document / enforce an invariant here that for each key / value pair in this map, key is equal to value.slug.


const editionConfigs = deserializeReferralProgramEditionConfigSetArray(json);

return new Map(editionConfigs.map((editionConfig) => [editionConfig.slug, editionConfig]));
Copy link
Member

Choose a reason for hiding this comment

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

The creation of this Map should move into the deserialize utility function.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ens-referrals/src/v1/edition.ts`:
- Around line 80-85: buildReferralProgramEditionConfigSet silently drops earlier
configs when multiple ReferralProgramEditionConfig items share the same slug;
before converting configs to a Map, scan the incoming configs array in
buildReferralProgramEditionConfigSet to detect duplicate config.slug values, and
if any duplicates are found throw a clear Error (or ValidationError) listing the
duplicate slug(s); only after duplicates are ruled out construct the Map and
call validateReferralProgramEditionConfigSet as currently done.

Copilot AI review requested due to automatic review settings February 9, 2026 17:14
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 9, 2026 17:15 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 9, 2026 17:15 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 9, 2026 17:15 Inactive
@Goader Goader merged commit 8be113b into main Feb 9, 2026
18 of 19 checks passed
@Goader Goader deleted the feat/referral-program-cycles branch February 9, 2026 17:21
@github-actions github-actions bot mentioned this pull request Feb 9, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +30 to +45
const edition1: ReferralProgramEditionConfig = {
slug: "2025-12",
displayName: "ENS Holiday Awards",
rules: buildReferralProgramRules(
parseUsdc("10000"),
10,
parseTimestamp("2025-12-01T00:00:00Z"),
parseTimestamp("2025-12-31T23:59:59Z"),
subregistryId,
new URL("https://ensawards.org/ens-holiday-awards-rules"),
),
};

const edition2: ReferralProgramEditionConfig = {
slug: "2026-03",
displayName: "March 2026",
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The PR description and linked issue describe predefined cycle IDs like cycle-1 / cycle-2, but the default identifiers here are date-based slugs (2025-12, 2026-03). Please confirm the intended public identifier scheme and align defaults/types/docs accordingly to avoid breaking downstream expectations.

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +138
# Custom Referral Program Edition Config Set Definition (optional)
# URL that returns JSON for a custom referral program edition config set.
# If not set, the default edition config set for the namespace is used.
#
# JSON Structure:
# The JSON must be an array of edition config objects (SerializedReferralProgramEditionConfig[]).
# For the complete schema definition, see makeReferralProgramEditionConfigSetArraySchema in @namehash/ens-referrals/v1
#
# Fetching Behavior:
# - Fetched proactively when ENSApi starts up (non-blocking; ENSApi may begin accepting requests before load completes)
# - Once successfully loaded, cached indefinitely (never expires or revalidates)
# - On load failure:
# * Error is logged
# * ENSApi continues running
# * Failed state is cached for 1 minute, then retried on subsequent requests
# * API requests receive error responses until successful load
#
# Configuration Notes:
# - Setting CUSTOM_REFERRAL_PROGRAM_EDITIONS completely replaces the default edition config set
# - Include all edition configs you want active in the JSON
# - Array must contain at least one edition config
# - All edition slugs must be unique
#
# CUSTOM_REFERRAL_PROGRAM_EDITIONS=https://example.com/custom-editions.json
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

This documentation introduces CUSTOM_REFERRAL_PROGRAM_EDITIONS, but the PR description calls the env var CUSTOM_REFERRAL_PROGRAM_CYCLES and describes cycle IDs (cycle-1, cycle-2). Please reconcile the naming (env var + terminology + examples) so operators configure the correct variable and consumers understand the identifier format.

Copilot uses AI. Check for mistakes.
Comment on lines +41 to 43
const referrerLeaderboardPageQuerySchema = z.object({
edition: makeReferralProgramEditionSlugSchema("edition"),
page: z
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The v1 leaderboard route is documented in this PR as using a cycle query parameter, but the handler currently requires edition (and the request/SDK types are named ReferralProgramEdition*). Please standardize the public API contract (choose either cycle or edition) and update the handler/query schema + client/types/docs consistently so consumers don’t send the wrong parameter.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +196
app
.get(
"/referrer/:referrer",
describeRoute({
tags: ["ENSAwards"],
summary: "Get Referrer Detail for Editions (v1)",
description: `Returns detailed information for a specific referrer for the requested editions. Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`,
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

This endpoint is implemented as /referrer/:referrer, but the PR description/issue call out a renamed route like /referral-leaderboard/:referrer (to pair with /referral-leaderboard). Consider aligning the route path naming so the v1 API surface is consistent and matches the documented contract (and update the SDK/client paths accordingly).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Introduce Referral Program Cycles

2 participants