Skip to content
Merged
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.
11 changes: 8 additions & 3 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import config from "@/config";

import { ENSNodeClient, IndexingStatusResponseCodes, SWRCache } from "@ensnode/ensnode-sdk";
import {
type CrossChainIndexingStatusSnapshot,
ENSNodeClient,
IndexingStatusResponseCodes,
SWRCache,
} from "@ensnode/ensnode-sdk";

import { makeLogger } from "@/lib/logger";

const logger = makeLogger("indexing-status.cache");
const client = new ENSNodeClient({ url: config.ensIndexerUrl });

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.
client
.indexingStatus() // fetch a new indexing status snapshot
.then((response) => {
Expand Down
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> {
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.

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
6 changes: 4 additions & 2 deletions apps/ensapi/src/cache/referral-program-edition-set.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "@namehash/ens-referrals/v1";
import { minutesToSeconds } from "date-fns";

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

import { makeLogger } from "@/lib/logger";

Expand All @@ -16,7 +16,9 @@ const logger = makeLogger("referral-program-edition-set-cache");
/**
* Loads the referral program edition config set from custom URL or defaults.
*/
async function loadReferralProgramEditionConfigSet(): Promise<ReferralProgramEditionConfigSet> {
async function loadReferralProgramEditionConfigSet(
_cachedResult?: CachedResult<ReferralProgramEditionConfigSet>,
): Promise<ReferralProgramEditionConfigSet> {
Comment on lines +19 to +21
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.
// Check if custom URL is configured
if (config.customReferralProgramEditionConfigSetUrl) {
logger.info(
Expand Down
5 changes: 3 additions & 2 deletions apps/ensapi/src/cache/referrer-leaderboard.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
ENS_HOLIDAY_AWARDS_START_DATE,
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
type ReferrerLeaderboard,
} from "@namehash/ens-referrals";
import { minutesToSeconds } from "date-fns";

Expand Down Expand Up @@ -44,8 +45,8 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
OmnichainIndexingStatusIds.Completed,
];

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.
const indexingStatus = await indexingStatusCache.read();
if (indexingStatus instanceof Error) {
throw new 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,33 @@
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 chain finality assumptions on supported networks. It is not a guarantee
* of immutability.
*/
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;
}
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 previously 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
11 changes: 11 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,13 @@ 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);
Comment on lines +148 to +149
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.

/**
* Schema for ReferrerLeaderboardPage
*/
Expand All @@ -149,6 +157,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 +206,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 +221,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,
};
};
Loading