Skip to content

Referral Program Statuses#1621

Merged
Goader merged 16 commits intomainfrom
feat/referral-program-statuses
Feb 16, 2026
Merged

Referral Program Statuses#1621
Goader merged 16 commits intomainfrom
feat/referral-program-statuses

Conversation

@Goader
Copy link
Contributor

@Goader Goader commented Feb 10, 2026

Referral Program Statuses

closes: #1523

Summary

  • Added status field ("Scheduled", "Active", "Closed") to referral program API responses, calculated from program timing relative to accurateAsOf
  • Implemented automatic cache upgrade to indefinite storage for immutably closed editions with zero-data-loss initialization and atomic race prevention

Why

  • Clients need program status for UI display (show "Active", "Scheduled", or "Closed" badges)
  • Cache optimization - closed editions don't need continuous revalidation, should use indefinite storage

Testing

  • Automatic CI and manual validation
  • Added a bunch of tests for cache upgrading functionality

Notes for Reviewer (Optional)

  • Most important: Cache upgrade atomicity via IIFE pattern (apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts lines ~190-204), I was not aware of this pattern before, so best if this is confirmed to be correct.

Pre-Review Checklist (Blocking)

  • This PR does not introduce significant changes and is low-risk to review quickly.
  • Relevant changesets are included (or are not required)

@Goader Goader self-assigned this Feb 10, 2026
Copilot AI review requested due to automatic review settings February 10, 2026 02:46
@changeset-bot
Copy link

changeset-bot bot commented Feb 10, 2026

🦋 Changeset detected

Latest commit: 84acb4f

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 10, 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 16, 2026 4:30pm
ensnode.io Skipped Skipped Feb 16, 2026 4:30pm
ensrainbow.io Skipped Skipped Feb 16, 2026 4:30pm

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 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

Adds a program status field to several v1 referral API responses, introduces an immutability heuristic and constant, exports a cache builder and SWRCache introspection, implements non-blocking background cache upgrades to indefinite storage for immutably closed editions, and adds tests for upgrade and cache detection logic.

Changes

Cohort / File(s) Summary
Cache infra & exports
apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts, packages/ensnode-sdk/src/shared/cache/swr-cache.ts
Exported createEditionLeaderboardBuilder and added SWRCache.isIndefinitelyStored() to detect infinite-TTL, non-revalidating caches.
Immutability helper
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/closeout.ts
Added ASSUMED_CHAIN_REORG_SAFE_DURATION constant and assumeReferralProgramEditionImmutablyClosed() to compute immutability threshold from an edition's endTime.
Cache upgrade logic
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/cache-upgrade.ts, apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/cache-upgrade.test.ts
New module orchestrating non-blocking upgrade of per-edition SWR caches to infinite-TTL immutable caches with concurrency tracking, freshness validation, atomic swap/destroy semantics, and tests covering success/failure/edge cases.
Middleware: non-blocking upgrades
apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts
Triggers background call to checkAndUpgradeImmutableCaches after cache initialization, reads indexing status, and logs upgrade errors without blocking request handling.
API schemas & serializers
packages/ens-referrals/src/v1/api/zod-schemas.ts, packages/ens-referrals/src/v1/api/serialize.ts
Added status schema/validation and included status in serialized outputs for leaderboard page and edition metrics.
Business logic: status calc
packages/ens-referrals/src/v1/leaderboard-page.ts, packages/ens-referrals/src/v1/edition-metrics.ts
Compute status via calcReferralProgramStatus(...) and propagate it into ReferrerLeaderboardPage and ReferrerEditionMetrics return shapes.
Tests & mocks
apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts, apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts
Updated tests and mocks to include status: ReferralProgramStatuses.Active in relevant fixtures to match new serialized shape.
Changesets & metadata
.changeset/*
Added changesets documenting the status field, SWRCache change, and cache-upgrade behavior for releases.

Sequence Diagram(s)

sequenceDiagram
    participant Request as Request Handler
    participant Middleware as Referral Leaderboard<br/>Editions Middleware
    participant Indexing as Indexing Status Cache
    participant Closeout as Immutability Helper
    participant Upgrade as Cache Upgrade Manager
    participant EditionCache as Edition Cache Manager

    Request->>Middleware: Incoming HTTP request
    Middleware->>Middleware: Ensure per-edition caches initialized
    Middleware->>Upgrade: checkAndUpgradeImmutableCaches() (async, non-blocking)
    loop per edition
        Upgrade->>Indexing: read indexing status for edition
        Indexing-->>Upgrade: indexing snapshot
        Upgrade->>Closeout: assumeReferralProgramEditionImmutablyClosed(rules, referenceTime)
        Closeout-->>Upgrade: isImmutable (true/false)
        alt immutable
            Upgrade->>EditionCache: create infinite-TTL cache & initialize
            EditionCache-->>Upgrade: new cache initialized
            Upgrade->>EditionCache: atomically swap in new cache
        else not immutable
            Upgrade->>EditionCache: skip upgrade, keep existing cache
        end
    end
    Middleware-->>Request: Continue handling request
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through code and found a new field bright,
Status tucked in pages, now safely in sight.
When programs end and reorgs are past,
Caches settle in, forever to last.
A rabbit applauds this upgrade — swift and light!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly summarizes the primary change: adding referral program statuses to API responses.
Description check ✅ Passed The description follows the template structure with all required sections (Summary, Why, Testing, Notes, Checklist) completed and relevant to the changeset.
Linked Issues check ✅ Passed All objectives from issue #1523 are addressed: status field added to API responses, cache upgrade with immutability checks implemented, and both leaderboard and detail APIs updated.
Out of Scope Changes check ✅ Passed All changes align with the stated objectives: status field additions, cache upgrade infrastructure, and associated tests. No unrelated modifications detected.
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-statuses

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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

Adds referral-program “status” metadata to ENS Referrals v1 responses and introduces logic in ENSAPI to upgrade per-edition leaderboard caches to indefinite storage once an edition is assumed immutably closed.

Changes:

  • Add status (Scheduled / Active / Closed) to leaderboard-page and edition-metrics domain objects, plus serialization and Zod validation.
  • Add SWRCache helper isIndefinitelyStored() and implement ENSAPI middleware that opportunistically upgrades closed-edition caches to infinite TTL/no proactive revalidation.
  • Add closeout heuristic (ASSUMED_CHAIN_REORG_SAFE_DURATION) and update mocks/tests for the new status field.

Reviewed changes

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

Show a summary per file
File Description
packages/ensnode-sdk/src/shared/cache/swr-cache.ts Adds isIndefinitelyStored() helper for identifying “infinite” caches.
packages/ens-referrals/src/v1/leaderboard-page.ts Adds status to ReferrerLeaderboardPage and computes it from rules + accurateAsOf.
packages/ens-referrals/src/v1/edition-metrics.ts Adds status to ranked/unranked edition metrics and computes it from rules + accurateAsOf.
packages/ens-referrals/src/v1/api/zod-schemas.ts Extends response schemas to validate the new status field.
packages/ens-referrals/src/v1/api/serialize.ts Serializes the new status field into API responses.
apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts Exports createEditionLeaderboardBuilder and documents upgrade-to-immutable behavior.
apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts Adds non-blocking per-request check to upgrade caches to immutable storage once closed.
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/closeout.ts Introduces “immutably closed” heuristic based on end time + safety window.
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts Updates mock API response to include status.
apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts Updates tests to expect status in responses.

💡 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: 4

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/closeout.ts`:
- Around line 26-31: The function assumeReferralProgramEditionImmutablyClosed
should guard against negative durations by returning false when the latest
indexed timestamp precedes the edition end time; add a check at the top of
assumeReferralProgramEditionImmutablyClosed that if referenceTime <
rules.endTime then return false (before calling durationBetween), so
durationBetween/deserializeDuration and the makeNonNegativeIntegerSchema
validation are not invoked and no RangeError is thrown; keep the existing
comparison to ASSUMED_CHAIN_REORG_SAFE_DURATION after this guard.

In
`@apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts`:
- Around line 105-115: Replacing the old SWRCache (variable cache) with a new
SWRCache instance (immutableCache) discards existing cached entries and causes
an immediate fetch (proactivelyInitialize: true) which can block reads during
the transition; before calling cache.destroy() or before replacing it via
caches.set(editionSlug, immutableCache), extract the live cached value(s) from
the old cache and seed them into the new SWRCache created by
createEditionLeaderboardBuilder(editionConfig) (or avoid destroying and reuse
the existing cache store), and if the SWRCache API requires, set
proactivelyInitialize to false while seeding then trigger any needed background
revalidation—update the logic around cache, SWRCache,
createEditionLeaderboardBuilder, immutableCache and caches.set to
preserve/transfer existing entries rather than dropping them.
- Around line 53-119: checkAndUpgradeImmutableCaches has a race where two
concurrent invocations can both upgrade the same edition and one can destroy the
other's new cache; fix by adding a per-edition in-progress guard (e.g., a
Set<string> upgradeInProgress) checked at the start of each loop iteration
(using editionSlug) to skip or wait if an upgrade is already running, mark the
slug in upgradeInProgress before calling cache.destroy() and creating the new
SWRCache, and clear the slug from the set after caches.set(editionSlug,
immutableCache) (also ensure any early continues remove the slug if you choose a
lock-then-check pattern); reference checkAndUpgradeImmutableCaches,
ReferralLeaderboardEditionsCacheMap, isIndefinitelyStored, cache.destroy,
caches.set, and the created SWRCache/immutableCache when applying the guard.
- Around line 68-76: Move the call to indexingStatusCache.read() out of the
per-edition loop: call indexingStatusCache.read() once before iterating, check
if the result is an Error and log/handle it (preserving the existing
logger.debug message but without editionSlug since it's not per-edition), then
use the resulting indexingStatus variable inside the loop for the immutability
check; update references to indexingStatus and remove the in-loop await to avoid
repeated reads and redundant logging per edition.

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: 3

🤖 Fix all issues with AI agents
In
`@apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts`:
- Around line 190-213: The background upgrade promise from upgradeEditionCache
is stored in inProgressUpgrades without a catch, causing possible unhandled
promise rejections; fix by attaching a .catch handler to the promise before
storing/returning so all rejections are logged/handled (use logger.error with
context { editionSlug }) and still ensure the existing .finally() removes the
entry from inProgressUpgrades; update the self-invoking block that creates
upgradePromise (and the variable inProgressUpgrades) to use promise.catch(...)
then .finally(...) so errors are consumed and logged while cleanup remains
intact.

In `@packages/ens-referrals/src/v1/api/zod-schemas.ts`:
- Around line 206-210: The z.enum for the status field (using
ReferralProgramStatuses) is missing the consistent custom error message pattern
used elsewhere; update the status schema to pass a valueLabel-derived message to
z.enum (e.g., use valueLabel(ReferralProgramStatuses) or the same helper used by
other fields) so the error text matches other fields, and make the same change
for the other status z.enum instance in this file that also references
ReferralProgramStatuses.
- Around line 153-157: Extract the duplicated enum into a shared Zod schema
(e.g., create a single ReferralProgramStatusSchema using z.enum with
ReferralProgramStatuses) and replace the three inline occurrences of
z.enum([...ReferralProgramStatuses.Scheduled, ...]) with that shared
ReferralProgramStatusSchema; update imports/exports in v1/api/zod-schemas.ts so
all schemas reference the single ReferralProgramStatusSchema (replace the inline
enum in the schemas that currently use "status" so they reuse the new helper).

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 13 out of 13 changed files in this pull request and generated 5 comments.


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

Comment on lines 149 to 150
z.enum(ReferralProgramStatuses, {
message: `${valueLabel} must be "Scheduled", "Active", or "Closed"`,
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

makeReferralProgramStatusSchema is using z.enum(ReferralProgramStatuses, …), but ReferralProgramStatuses is an object map (e.g. { Scheduled: "Scheduled", … }), not a string tuple. This will either fail type-checking or validate incorrectly at runtime. Use z.nativeEnum(ReferralProgramStatuses) (if supported by the zod/v4 build you’re using) or pass an explicit tuple/Object.values(ReferralProgramStatuses) cast to a non-empty string tuple, so the schema actually validates the allowed status strings.

Suggested change
z.enum(ReferralProgramStatuses, {
message: `${valueLabel} must be "Scheduled", "Active", or "Closed"`,
z.nativeEnum(ReferralProgramStatuses, {
errorMap: (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_enum_value) {
return { message: `${valueLabel} must be "Scheduled", "Active", or "Closed"` };
}
return { message: ctx.defaultError };
},

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

CodeRabbit says it's ok, nativeEnum seems to only work with TypeScript's enums.

Copy link
Member

Choose a reason for hiding this comment

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

Suggest reviewing how we define Zod schemas for other enum values. For example: https://github.com/namehash/ensnode/blob/main/packages/ponder-sdk/src/deserialize/indexing-metrics.ts#L77 and then repeating those patterns here.

Copy link
Member

Choose a reason for hiding this comment

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

@Goader I continue to believe the above comment is good for you to review.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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 Great work here 👍 Shared a few small comments. Please take the lead to merge when ready!

* Otherwise, it initializes caches for each edition in the config set.
*
* Each cache's builder function handles immutability internally - when an edition becomes immutably
* closed (past the safety window), the builder returns cached data without re-fetching.
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
* closed (past the safety window), the builder returns cached data without re-fetching.
* closed (past the safety window), the builder returns previously cached data without re-fetching.

editionConfig: ReferralProgramEditionConfig,
): () => Promise<ReferrerLeaderboard> {
return async (): Promise<ReferrerLeaderboard> => {
): (cachedResult?: CachedResult<ReferrerLeaderboard>) => Promise<ReferrerLeaderboard> {
Copy link
Member

Choose a reason for hiding this comment

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

Could you check all the other SWRCache fn implementations? Suggest we add this new param to each of them to nicely keep everything in sync. Of course, appreciate the param would be unused in the other fn implementations, but it seems nice to explicitly identify how it is there.

* Duration after which we assume a closed edition is safe from chain reorganizations.
*
* This is a heuristic value (10 minutes) chosen to provide a reasonable safety margin
* beyond typical Ethereum finality. It is not a guarantee of immutability.
Copy link
Member

Choose a reason for hiding this comment

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

@Goader Suggest to apply this suggestion from Copilot 👍

Comment on lines 149 to 150
z.enum(ReferralProgramStatuses, {
message: `${valueLabel} must be "Scheduled", "Active", or "Closed"`,
Copy link
Member

Choose a reason for hiding this comment

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

@Goader I continue to believe the above comment is good for you to review.

Comment on lines 147 to 151
*/
export const makeReferralProgramStatusSchema = (valueLabel: string = "status") =>
z.enum(ReferralProgramStatuses, {
message: `${valueLabel} must be "Scheduled", "Active", or "Closed"`,
});
Copy link
Member

Choose a reason for hiding this comment

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

@Goader This feedback from Greptile is related to the other related comment thread in this file.

@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 16, 2026 16:07 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 16, 2026 16:07 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 16, 2026 16:07 Inactive
Copilot AI review requested due to automatic review settings February 16, 2026 16:30
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 16, 2026 16:30 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 16, 2026 16:30 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 16, 2026 16:30 Inactive
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 17 out of 17 changed files in this pull request and generated 4 comments.


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

export const indexingStatusCache = new SWRCache({
fn: async () =>
export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

'_cachedResult' is defined but never used.

Suggested change
fn: async (_cachedResult) =>
fn: async () =>

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +21
async function loadReferralProgramEditionConfigSet(
_cachedResult?: CachedResult<ReferralProgramEditionConfigSet>,
): Promise<ReferralProgramEditionConfigSet> {
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

'_cachedResult' is defined but never used.

Suggested change
async function loadReferralProgramEditionConfigSet(
_cachedResult?: CachedResult<ReferralProgramEditionConfigSet>,
): Promise<ReferralProgramEditionConfigSet> {
async function loadReferralProgramEditionConfigSet(): Promise<ReferralProgramEditionConfigSet> {

Copilot uses AI. Check for mistakes.
export const referrerLeaderboardCache = new SWRCache({
fn: async () => {
export const referrerLeaderboardCache = new SWRCache<ReferrerLeaderboard>({
fn: async (_cachedResult) => {
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

'_cachedResult' is defined but never used.

Suggested change
fn: async (_cachedResult) => {
fn: async () => {

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +149
export const makeReferralProgramStatusSchema = (_valueLabel: string = "status") =>
z.enum(ReferralProgramStatuses);
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

'_valueLabel' is assigned a value but never used.

Suggested change
export const makeReferralProgramStatusSchema = (_valueLabel: string = "status") =>
z.enum(ReferralProgramStatuses);
export const makeReferralProgramStatusSchema = (valueLabel: string = "status") =>
z.enum(ReferralProgramStatuses, {
invalid_type_error: `${valueLabel} must be one of: ${ReferralProgramStatuses.join(", ")}`,
});

Copilot uses AI. Check for mistakes.
@Goader Goader merged commit 75c8b01 into main Feb 16, 2026
22 checks passed
@Goader Goader deleted the feat/referral-program-statuses branch February 16, 2026 16:43
@github-actions github-actions bot mentioned this pull request Feb 16, 2026
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.

Calculate Referral Program Cycle Status on Server

2 participants