Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brave-waves-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@namehash/ens-referrals": minor
"ensapi": minor
---

Added `status` field to referral program API responses (`ReferrerLeaderboardPage`, `ReferrerEditionMetricsRanked`, `ReferrerEditionMetricsUnranked`) indicating whether a program is "Scheduled", "Active", or "Closed" based on the program's timing relative to `accurateAsOf`.
5 changes: 5 additions & 0 deletions .changeset/clever-frogs-detect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

SWRCache `fn` now optionally receives the currently cached result as a parameter, allowing implementations to inspect cached data before deciding whether to return it or fetch fresh data. Fully backward compatible.
5 changes: 5 additions & 0 deletions .changeset/lemon-moose-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

Referral program edition leaderboard caches now check for immutability within the cache builder function. Closed editions past the safety window return cached data without re-fetching.
25 changes: 23 additions & 2 deletions apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
import { minutesToSeconds } from "date-fns";

import {
type CachedResult,
getLatestIndexedBlockRef,
type OmnichainIndexingStatusId,
OmnichainIndexingStatusIds,
SWRCache,
} from "@ensnode/ensnode-sdk";

import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/referrer-leaderboard/closeout";
import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1";
import { makeLogger } from "@/lib/logger";

Expand Down Expand Up @@ -48,15 +50,34 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
/**
* Creates a cache builder function for a specific edition.
*
* The builder function checks if cached data exists and represents an immutably closed edition.
* If so, it returns the cached data without re-fetching. Otherwise, it fetches fresh data.
*
* @param editionConfig - The edition configuration
* @returns A function that builds the leaderboard for the given edition
*/
function createEditionLeaderboardBuilder(
editionConfig: ReferralProgramEditionConfig,
): () => Promise<ReferrerLeaderboard> {
return async (): Promise<ReferrerLeaderboard> => {
): (cachedResult?: CachedResult<ReferrerLeaderboard>) => Promise<ReferrerLeaderboard> {
return async (cachedResult?: CachedResult<ReferrerLeaderboard>): Promise<ReferrerLeaderboard> => {
const editionSlug = editionConfig.slug;

// Check if cached data is immutable and can be returned as-is
if (cachedResult && !(cachedResult.result instanceof Error)) {
const isImmutable = assumeReferralProgramEditionImmutablyClosed(
cachedResult.result.rules,
cachedResult.result.accurateAsOf,
);

if (isImmutable) {
logger.debug(
{ editionSlug },
`Edition is immutably closed, returning cached data without re-fetching`,
);
return cachedResult.result;
}
}

const indexingStatus = await indexingStatusCache.read();
if (indexingStatus instanceof Error) {
logger.error(
Expand Down
7 changes: 7 additions & 0 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
deserializeReferrerMetricsEditionsResponse,
ReferralProgramEditionConfigSetResponseCodes,
type ReferralProgramEditionSlug,
ReferralProgramStatuses,
ReferrerEditionMetricsTypeIds,
type ReferrerLeaderboard,
ReferrerLeaderboardPageResponseCodes,
Expand Down Expand Up @@ -118,6 +119,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...populatedReferrerLeaderboard,
status: ReferralProgramStatuses.Active,
pageContext: {
endIndex: 9,
hasNext: true,
Expand All @@ -139,6 +141,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...populatedReferrerLeaderboard,
status: ReferralProgramStatuses.Active,
pageContext: {
endIndex: 19,
hasNext: true,
Expand All @@ -159,6 +162,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...populatedReferrerLeaderboard,
status: ReferralProgramStatuses.Active,
pageContext: {
endIndex: 28,
hasNext: false,
Expand Down Expand Up @@ -223,6 +227,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...emptyReferralLeaderboard,
status: ReferralProgramStatuses.Active,
pageContext: {
hasNext: false,
hasPrev: false,
Expand Down Expand Up @@ -364,13 +369,15 @@ describe("/v1/ensanalytics", () => {
referrer: expectedMetrics,
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
accurateAsOf: expectedAccurateAsOf,
status: ReferralProgramStatuses.Active,
},
"2026-03": {
type: ReferrerEditionMetricsTypeIds.Ranked,
rules: populatedReferrerLeaderboard.rules,
referrer: expectedMetrics,
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
accurateAsOf: expectedAccurateAsOf,
status: ReferralProgramStatuses.Active,
},
},
} satisfies ReferrerMetricsEditionsResponseOk;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReferralProgramRules } from "@namehash/ens-referrals/v1";
import { minutesToSeconds } from "date-fns";

import { addDuration, type Duration, type UnixTimestamp } from "@ensnode/ensnode-sdk";

/**
* 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

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.

The comment for ASSUMED_CHAIN_REORG_SAFE_DURATION references "typical Ethereum finality", but this helper is keyed off rules.subregistryId.chainId and may apply to non-Ethereum chains/L2s as well. Consider making the wording chain-agnostic (e.g., "typical chain finality") or explicitly scoping this heuristic to the supported chains.

Suggested change
* beyond typical Ethereum finality. It is not a guarantee of immutability.
* beyond typical chain finality assumptions on supported networks. It is not a guarantee
* of immutability.

Copilot uses AI. Check for mistakes.
*/
export const ASSUMED_CHAIN_REORG_SAFE_DURATION: Duration = minutesToSeconds(10);

/**
* Assumes a referral program edition is immutably closed if it ended more than
* ASSUMED_CHAIN_REORG_SAFE_DURATION ago.
*
* This is a practical heuristic for determining when edition data can be cached
* indefinitely, based on the assumption that chain reorgs become extremely unlikely
* after the safety window has passed.
*
* @param rules - The referral program rules containing endTime
* @param referenceTime - The timestamp to check against (typically accurateAsOf from cached leaderboard)
* @returns true if we assume the edition is immutably closed
*/
export function assumeReferralProgramEditionImmutablyClosed(
rules: ReferralProgramRules,
referenceTime: UnixTimestamp,
): boolean {
const immutabilityThreshold = addDuration(rules.endTime, ASSUMED_CHAIN_REORG_SAFE_DURATION);
return referenceTime > immutabilityThreshold;
Comment on lines +28 to +31
Copy link
Contributor

Choose a reason for hiding this comment

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

Closeout check off-by-one
assumeReferralProgramEditionImmutablyClosed uses referenceTime > immutabilityThreshold, so an edition whose accurateAsOf equals the threshold (endTime + safety window) will not be considered immutable. If the intent is “ended at least safety window ago”, this should be >= to avoid skipping upgrades exactly at the threshold.

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ReferralProgramStatuses,
type ReferrerLeaderboard,
ReferrerLeaderboardPageResponseCodes,
type ReferrerLeaderboardPageResponseOk,
Expand Down Expand Up @@ -1093,6 +1094,7 @@ export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseO
startIndex: 0,
endIndex: 28,
},
status: ReferralProgramStatuses.Active,
accurateAsOf: 1735689600,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export type ReferralLeaderboardEditionsCachesMiddlewareVariables = {
* This middleware depends on {@link referralProgramEditionConfigSetMiddleware} to provide
* the edition config set. If the edition config set failed to load, this middleware propagates the error.
* 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.
*/
export const referralLeaderboardEditionsCachesMiddleware = factory.createMiddleware(
async (c, next) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/ens-referrals/src/v1/api/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function serializeReferrerLeaderboardPage(
referrers: page.referrers.map(serializeAwardedReferrerMetrics),
aggregatedMetrics: serializeAggregatedReferrerMetrics(page.aggregatedMetrics),
pageContext: page.pageContext,
status: page.status,
accurateAsOf: page.accurateAsOf,
};
}
Expand All @@ -133,6 +134,7 @@ function serializeReferrerEditionMetricsRanked(
rules: serializeReferralProgramRules(detail.rules),
referrer: serializeAwardedReferrerMetrics(detail.referrer),
aggregatedMetrics: serializeAggregatedReferrerMetrics(detail.aggregatedMetrics),
status: detail.status,
accurateAsOf: detail.accurateAsOf,
};
}
Expand All @@ -148,6 +150,7 @@ function serializeReferrerEditionMetricsUnranked(
rules: serializeReferralProgramRules(detail.rules),
referrer: serializeUnrankedReferrerMetrics(detail.referrer),
aggregatedMetrics: serializeAggregatedReferrerMetrics(detail.aggregatedMetrics),
status: detail.status,
accurateAsOf: detail.accurateAsOf,
};
}
Expand Down
13 changes: 13 additions & 0 deletions packages/ens-referrals/src/v1/api/zod-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ReferrerEditionMetricsTypeIds,
} from "../edition-metrics";
import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page";
import { ReferralProgramStatuses } from "../status";
import {
MAX_EDITIONS_PER_REQUEST,
ReferralProgramEditionConfigSetResponseCodes,
Expand Down Expand Up @@ -140,6 +141,15 @@ export const makeReferrerLeaderboardPageContextSchema = (
endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)),
});

/**
* Schema for referral program status field.
* Validates that the status is one of: "Scheduled", "Active", or "Closed".
*/
export const makeReferralProgramStatusSchema = (valueLabel: string = "status") =>
z.enum(ReferralProgramStatuses, {
message: `${valueLabel} must be "Scheduled", "Active", or "Closed"`,
Comment on lines +149 to +150
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.

});
Comment on lines +147 to +151
Copy link
Contributor

Choose a reason for hiding this comment

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

z.enum passed object
makeReferralProgramStatusSchema calls z.enum(ReferralProgramStatuses, ...), but z.enum expects a tuple/array of string literals (or you should use z.nativeEnum for an enum-like object). Passing an object here will throw at runtime or fail typing depending on Zod version. Fix by using z.nativeEnum(ReferralProgramStatuses) or z.enum(["Scheduled","Active","Closed"] as const).


/**
* Schema for ReferrerLeaderboardPage
*/
Expand All @@ -149,6 +159,7 @@ export const makeReferrerLeaderboardPageSchema = (valueLabel: string = "Referrer
referrers: z.array(makeAwardedReferrerMetricsSchema(`${valueLabel}.referrers[record]`)),
aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`),
pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`),
status: makeReferralProgramStatusSchema(`${valueLabel}.status`),
accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`),
});

Expand Down Expand Up @@ -197,6 +208,7 @@ export const makeReferrerEditionMetricsRankedSchema = (
rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`),
referrer: makeAwardedReferrerMetricsSchema(`${valueLabel}.referrer`),
aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`),
status: makeReferralProgramStatusSchema(`${valueLabel}.status`),
accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`),
});

Expand All @@ -211,6 +223,7 @@ export const makeReferrerEditionMetricsUnrankedSchema = (
rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`),
referrer: makeUnrankedReferrerMetricsSchema(`${valueLabel}.referrer`),
aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`),
status: makeReferralProgramStatusSchema(`${valueLabel}.status`),
accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`),
});

Expand Down
16 changes: 16 additions & 0 deletions packages/ens-referrals/src/v1/edition-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type UnrankedReferrerMetrics,
} from "./referrer-metrics";
import type { ReferralProgramRules } from "./rules";
import { calcReferralProgramStatus, type ReferralProgramStatusId } from "./status";

/**
* The type of referrer edition metrics data.
Expand Down Expand Up @@ -66,6 +67,12 @@ export interface ReferrerEditionMetricsRanked {
*/
aggregatedMetrics: AggregatedReferrerMetrics;

/**
* The status of the referral program ("Scheduled", "Active", or "Closed")
* calculated based on the program's timing relative to {@link accurateAsOf}.
*/
status: ReferralProgramStatusId;

/**
* The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRanked} was accurate as of.
*/
Expand Down Expand Up @@ -105,6 +112,12 @@ export interface ReferrerEditionMetricsUnranked {
*/
aggregatedMetrics: AggregatedReferrerMetrics;

/**
* The status of the referral program ("Scheduled", "Active", or "Closed")
* calculated based on the program's timing relative to {@link accurateAsOf}.
*/
status: ReferralProgramStatusId;

/**
* The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnranked} was accurate as of.
*/
Expand Down Expand Up @@ -134,6 +147,7 @@ export const getReferrerEditionMetrics = (
leaderboard: ReferrerLeaderboard,
): ReferrerEditionMetrics => {
const awardedReferrerMetrics = leaderboard.referrers.get(referrer);
const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf);

// If referrer is on the leaderboard, return their ranked metrics
if (awardedReferrerMetrics) {
Expand All @@ -142,6 +156,7 @@ export const getReferrerEditionMetrics = (
rules: leaderboard.rules,
referrer: awardedReferrerMetrics,
aggregatedMetrics: leaderboard.aggregatedMetrics,
status,
accurateAsOf: leaderboard.accurateAsOf,
};
}
Expand All @@ -152,6 +167,7 @@ export const getReferrerEditionMetrics = (
rules: leaderboard.rules,
referrer: buildUnrankedReferrerMetrics(referrer),
aggregatedMetrics: leaderboard.aggregatedMetrics,
status,
accurateAsOf: leaderboard.accurateAsOf,
};
};
10 changes: 10 additions & 0 deletions packages/ens-referrals/src/v1/leaderboard-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ReferrerLeaderboard } from "./leaderboard";
import { isNonNegativeInteger, isPositiveInteger } from "./number";
import type { AwardedReferrerMetrics } from "./referrer-metrics";
import type { ReferralProgramRules } from "./rules";
import { calcReferralProgramStatus, type ReferralProgramStatusId } from "./status";

/**
* The default number of referrers per leaderboard page.
Expand Down Expand Up @@ -287,6 +288,12 @@ export interface ReferrerLeaderboardPage {
*/
pageContext: ReferrerLeaderboardPageContext;

/**
* The status of the referral program ("Scheduled", "Active", or "Closed")
* calculated based on the program's timing relative to {@link accurateAsOf}.
*/
status: ReferralProgramStatusId;

/**
* The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardPage} was accurate as of.
*/
Expand Down Expand Up @@ -315,11 +322,14 @@ export const getReferrerLeaderboardPage = (
referrers = [];
}

const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf);

return {
rules: leaderboard.rules,
referrers,
aggregatedMetrics: leaderboard.aggregatedMetrics,
pageContext,
accurateAsOf: leaderboard.accurateAsOf,
status,
};
};
Loading