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/clever-laws-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@namehash/ens-referrals": minor
"ensapi": minor
---

Introduces referral program editions support with pre-configured edition definitions (ENS Holiday Awards December 2025, March 2026 edition). Updated ENSAnalytics API v1 to support edition-based leaderboard queries and added edition configuration to environment schema.
5 changes: 5 additions & 0 deletions .changeset/proud-eagles-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": patch
---

Adds `parseTimestamp` utility to parse ISO 8601 date strings into Unix timestamps. Adds `errorTtl` option to `SWRCache` for configuring separate revalidation intervals for cached errors vs. successful results.
31 changes: 24 additions & 7 deletions apps/ensapi/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,27 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database
# it receives to The Graph's hosted subgraphs using this API key.
# THEGRAPH_API_KEY=

# ENS Holiday Awards Date Range
# Optional. These variables define the date range for ENSAnalytics ENS Holiday Awards campaign.
# If not set, defaults to the hardcoded values from @namehash/ens-referrals package.
# Format: ISO 8601 datetime string (e.g., "2025-12-01T00:00:00Z")
# Note: ENS_HOLIDAY_AWARDS_START date must be before or the same as ENS_HOLIDAY_AWARDS_END
# ENS_HOLIDAY_AWARDS_START="2025-12-01T00:00:00Z"
# ENS_HOLIDAY_AWARDS_END="2025-12-31T23:59:59Z"
# 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
Comment on lines +115 to +138
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.
161 changes: 161 additions & 0 deletions apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {
type ReferralProgramEditionConfig,
type ReferralProgramEditionConfigSet,
type ReferralProgramEditionSlug,
type ReferrerLeaderboard,
serializeReferralProgramRules,
} from "@namehash/ens-referrals/v1";
import { minutesToSeconds } from "date-fns";

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

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

import { indexingStatusCache } from "./indexing-status.cache";

const logger = makeLogger("referral-leaderboard-editions-cache");

/**
* Map from edition slug to its leaderboard cache.
*
* Each edition has its own independent cache. Therefore, each
* edition's cache can be asynchronously loaded / refreshed from
* others, and a failure to load data for one edition doesn't break
* data successfully loaded for other editions.
*/
export type ReferralLeaderboardEditionsCacheMap = Map<
ReferralProgramEditionSlug,
SWRCache<ReferrerLeaderboard>
>;

/**
* The list of {@link OmnichainIndexingStatusId} values that are supported for generating
* referrer leaderboards.
*
* Other values indicate that we are not ready to generate leaderboards yet.
*/
const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
OmnichainIndexingStatusIds.Following,
OmnichainIndexingStatusIds.Completed,
];

/**
* Creates a cache builder function for a specific edition.
*
* @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> => {
const editionSlug = editionConfig.slug;

const indexingStatus = await indexingStatusCache.read();
if (indexingStatus instanceof Error) {
logger.error(
{ error: indexingStatus, editionSlug },
`Failed to read indexing status cache while generating referral leaderboard for ${editionSlug}. Cannot proceed without valid indexing status.`,
);
throw new Error(
`Unable to generate referral leaderboard for ${editionSlug}. indexingStatusCache must have been successfully initialized.`,
);
}

const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
throw new Error(
`Unable to generate referrer leaderboard for ${editionSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`,
);
}

const latestIndexedBlockRef = getLatestIndexedBlockRef(
indexingStatus,
editionConfig.rules.subregistryId.chainId,
);
if (latestIndexedBlockRef === null) {
throw new Error(
`Unable to generate referrer leaderboard for ${editionSlug}. Latest indexed block ref for chain ${editionConfig.rules.subregistryId.chainId} is null.`,
);
}

logger.info(
`Building referrer leaderboard for ${editionSlug} with rules:\n${JSON.stringify(
serializeReferralProgramRules(editionConfig.rules),
null,
2,
)}`,
);

const leaderboard = await getReferrerLeaderboard(
editionConfig.rules,
latestIndexedBlockRef.timestamp,
);

logger.info(
`Successfully built referrer leaderboard for ${editionSlug} with ${leaderboard.referrers.size} referrers`,
);

return leaderboard;
};
}

/**
* Singleton instance of the initialized caches.
* Ensures caches are only initialized once per application lifecycle.
*/
let cachedInstance: ReferralLeaderboardEditionsCacheMap | null = null;

/**
* Initializes caches for all referral program editions in the given edition set.
*
* This function uses a singleton pattern to ensure caches are only initialized once,
* even if called multiple times. Each edition gets its own independent SWRCache,
* ensuring that if one edition fails to refresh, other editions' previously successful
* data remains available.
*
* @param editionConfigSet - The referral program edition config set to initialize caches for
* @returns A map from edition slug to its dedicated SWRCache
*/
export function initializeReferralLeaderboardEditionsCaches(
editionConfigSet: ReferralProgramEditionConfigSet,
): ReferralLeaderboardEditionsCacheMap {
// Return cached instance if already initialized
if (cachedInstance !== null) {
return cachedInstance;
}

const caches: ReferralLeaderboardEditionsCacheMap = new Map();

for (const [editionSlug, editionConfig] of editionConfigSet) {
const cache = new SWRCache({
fn: createEditionLeaderboardBuilder(editionConfig),
ttl: minutesToSeconds(1),
proactiveRevalidationInterval: minutesToSeconds(2),
proactivelyInitialize: true,
});

caches.set(editionSlug, cache);
logger.info(`Initialized leaderboard cache for ${editionSlug}`);
}

// Cache the instance for subsequent calls
cachedInstance = caches;
return caches;
}

/**
* Gets the cached instance of referral leaderboard editions caches.
* Returns null if not yet initialized.
*
* @returns The cached cache map or null
*/
export function getReferralLeaderboardEditionsCaches(): ReferralLeaderboardEditionsCacheMap | null {
return cachedInstance;
}
66 changes: 66 additions & 0 deletions apps/ensapi/src/cache/referral-program-edition-set.cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import config from "@/config";

import {
ENSReferralsClient,
getDefaultReferralProgramEditionConfigSet,
type ReferralProgramEditionConfigSet,
} from "@namehash/ens-referrals/v1";
import { minutesToSeconds } from "date-fns";

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

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

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> {
// Check if custom URL is configured
if (config.customReferralProgramEditionConfigSetUrl) {
logger.info(
`Loading custom referral program edition config set from: ${config.customReferralProgramEditionConfigSetUrl.href}`,
);
try {
const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(
config.customReferralProgramEditionConfigSetUrl,
);
logger.info(`Successfully loaded ${editionConfigSet.size} custom referral program editions`);
return editionConfigSet;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, "Error occurred while loading referral program edition config set");
throw new Error(
`Failed to load custom referral program edition config set from ${config.customReferralProgramEditionConfigSetUrl.href}: ${errorMessage}`,
);
}
}

// Use default edition config set for the namespace
logger.info(
`Loading default referral program edition config set for namespace: ${config.namespace}`,
);
const editionConfigSet = getDefaultReferralProgramEditionConfigSet(config.namespace);
logger.info(`Successfully loaded ${editionConfigSet.size} default referral program editions`);
return editionConfigSet;
}

/**
* SWR Cache for the referral program edition config set.
*
* Once successfully loaded, the edition config set is cached indefinitely and never revalidated.
* This ensures the JSON is only fetched once during the application lifecycle.
*
* Configuration:
* - ttl: Infinity - Never expires once cached
* - proactiveRevalidationInterval: undefined - No proactive revalidation
* - proactivelyInitialize: true - Load immediately on startup
*/
export const referralProgramEditionConfigSetCache = new SWRCache<ReferralProgramEditionConfigSet>({
fn: loadReferralProgramEditionConfigSet,
ttl: Number.POSITIVE_INFINITY,
errorTtl: minutesToSeconds(1),
proactiveRevalidationInterval: undefined,
proactivelyInitialize: true,
});
101 changes: 0 additions & 101 deletions apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts

This file was deleted.

6 changes: 4 additions & 2 deletions apps/ensapi/src/cache/referrer-leaderboard.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import config from "@/config";

import {
buildReferralProgramRules,
ENS_HOLIDAY_AWARDS_END_DATE,
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
ENS_HOLIDAY_AWARDS_START_DATE,
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
} from "@namehash/ens-referrals";
import { minutesToSeconds } from "date-fns";
Expand All @@ -25,8 +27,8 @@ const logger = makeLogger("referrer-leaderboard-cache.cache");
const rules = buildReferralProgramRules(
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
config.ensHolidayAwardsStart,
config.ensHolidayAwardsEnd,
ENS_HOLIDAY_AWARDS_START_DATE,
ENS_HOLIDAY_AWARDS_END_DATE,
getEthnamesSubregistryId(config.namespace),
);

Expand Down
Loading
Loading