From 9a2335fddfa06392de71036cc5d1e686e0382aab Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 3 Feb 2026 15:54:32 +0100 Subject: [PATCH 01/16] docs(changeset): --- .changeset/clever-laws-count.md | 2 + apps/ensapi/.env.local.example | 31 +- .../referral-leaderboard-cycles.cache.ts | 142 ++++++++ .../cache/referrer-leaderboard.cache-v1.ts | 101 ------ .../src/cache/referrer-leaderboard.cache.ts | 6 +- apps/ensapi/src/config/config.schema.test.ts | 22 +- apps/ensapi/src/config/config.schema.ts | 98 ++++-- apps/ensapi/src/config/environment.ts | 4 +- apps/ensapi/src/config/validations.ts | 23 +- .../src/handlers/ensanalytics-api-v1.test.ts | 321 +++++++++++++----- .../src/handlers/ensanalytics-api-v1.ts | 136 +++++--- apps/ensapi/src/index.ts | 9 +- .../get-referrer-leaderboard-v1.test.ts | 13 +- .../referrer-leaderboard.middleware-v1.ts | 27 +- .../ens-referrals/src/v1/api/deserialize.ts | 87 +++-- .../ens-referrals/src/v1/api/serialize.ts | 78 +++-- .../src/v1/api/serialized-types.ts | 45 ++- packages/ens-referrals/src/v1/api/types.ts | 43 ++- .../ens-referrals/src/v1/api/zod-schemas.ts | 106 +++++- packages/ens-referrals/src/v1/client.ts | 123 ++++--- .../ens-referrals/src/v1/cycle-defaults.ts | 111 ++++++ packages/ens-referrals/src/v1/cycle.ts | 77 +++++ packages/ens-referrals/src/v1/index.ts | 2 + packages/ens-referrals/src/v1/rules.ts | 30 +- .../src/shared/config/environments.ts | 14 +- 25 files changed, 1142 insertions(+), 509 deletions(-) create mode 100644 .changeset/clever-laws-count.md create mode 100644 apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts delete mode 100644 apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts create mode 100644 packages/ens-referrals/src/v1/cycle-defaults.ts create mode 100644 packages/ens-referrals/src/v1/cycle.ts diff --git a/.changeset/clever-laws-count.md b/.changeset/clever-laws-count.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/clever-laws-count.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index f5a2d230c..f04d439fb 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -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 Cycles (optional) +# URL to a JSON file containing custom referral program cycle definitions. +# If not set, the default cycle set is used. +# +# The JSON file should contain an array of cycle objects with the following structure: +# [ +# { +# "id": "cycle-1", +# "displayName": "ENS Holiday Awards", +# "rules": { +# "totalAwardPoolValue": "10000000000", +# "maxQualifiedReferrers": 10, +# "startTime": 1764547200, +# "endTime": 1767225599, +# "subregistryId": { +# "chainId": 1, +# "address": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" +# } +# }, +# "rulesUrl": "https://ensawards.org/ens-holiday-awards-rules" +# } +# ] +# +# CUSTOM_REFERRAL_PROGRAM_CYCLES=https://example.com/cycles.json diff --git a/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts b/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts new file mode 100644 index 000000000..2800111e0 --- /dev/null +++ b/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts @@ -0,0 +1,142 @@ +import config from "@/config"; + +import { + type ReferralProgramCycle, + type ReferralProgramCycleId, + 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-cycles-cache"); + +/** + * Map from cycle ID to its leaderboard cache. + * Each cycle has its own independent cache to preserve successful data + * even when other cycles fail. + */ +export type ReferralLeaderboardCyclesCacheMap = Map< + ReferralProgramCycleId, + SWRCache +>; + +/** + * 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 cycle. + * + * @param cycleId - The ID of the cycle to build a cache for + * @returns A function that builds the leaderboard for the given cycle + */ +function createCycleLeaderboardBuilder( + cycleId: ReferralProgramCycleId, +): () => Promise { + return async (): Promise => { + const cycle = config.referralProgramCycleSet.get(cycleId) as ReferralProgramCycle | undefined; + if (!cycle) { + throw new Error(`Cycle ${cycleId} not found in referralProgramCycleSet`); + } + + const indexingStatus = await indexingStatusCache.read(); + if (indexingStatus instanceof Error) { + logger.error( + { error: indexingStatus, cycleId }, + `Failed to read indexing status cache while generating referral leaderboard for ${cycleId}. Cannot proceed without valid indexing status.`, + ); + throw new Error( + `Unable to generate referral leaderboard for ${cycleId}. indexingStatusCache must have been successfully initialized.`, + ); + } + + const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; + if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { + throw new Error( + `Unable to generate referrer leaderboard for ${cycleId}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, + ); + } + + const latestIndexedBlockRef = getLatestIndexedBlockRef( + indexingStatus, + cycle.rules.subregistryId.chainId, + ); + if (latestIndexedBlockRef === null) { + throw new Error( + `Unable to generate referrer leaderboard for ${cycleId}. Latest indexed block ref for chain ${cycle.rules.subregistryId.chainId} is null.`, + ); + } + + logger.info( + `Building referrer leaderboard for ${cycleId} with rules:\n${JSON.stringify( + serializeReferralProgramRules(cycle.rules), + null, + 2, + )}`, + ); + + const leaderboard = await getReferrerLeaderboard(cycle.rules, latestIndexedBlockRef.timestamp); + + logger.info( + `Successfully built referrer leaderboard for ${cycleId} with ${leaderboard.referrers.size} referrers`, + ); + + return leaderboard; + }; +} + +/** + * Initializes caches for all configured referral program cycles. + * + * Each cycle gets its own independent SWRCache, ensuring that if one cycle + * fails to refresh, other cycles' previously successful data remains available. + * + * @returns A map from cycle ID to its dedicated SWRCache + */ +function initializeCyclesCaches(): ReferralLeaderboardCyclesCacheMap { + const caches: ReferralLeaderboardCyclesCacheMap = new Map(); + + for (const [cycleId] of config.referralProgramCycleSet) { + const typedCycleId = cycleId as ReferralProgramCycleId; + const cache = new SWRCache({ + fn: createCycleLeaderboardBuilder(typedCycleId), + ttl: minutesToSeconds(1), + proactiveRevalidationInterval: minutesToSeconds(2), + proactivelyInitialize: true, + }); + + caches.set(typedCycleId, cache); + logger.info(`Initialized leaderboard cache for ${typedCycleId}`); + } + + return caches; +} + +/** + * Map of independent caches for each referral program cycle. + * + * Each cycle has its own SWRCache to ensure independent failure handling. + * If cycle 1's cache fails to refresh but was previously successful, its old + * data remains available while cycle 2 can independently succeed or fail. + */ +export const referralLeaderboardCyclesCaches: ReferralLeaderboardCyclesCacheMap = + initializeCyclesCaches(); diff --git a/apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts b/apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts deleted file mode 100644 index 5e9b5d7c3..000000000 --- a/apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts +++ /dev/null @@ -1,101 +0,0 @@ -import config from "@/config"; - -import { - buildReferralProgramRules, - ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, - ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, - serializeReferralProgramRules, -} from "@namehash/ens-referrals/v1"; -import { minutesToSeconds } from "date-fns"; - -import { - getEthnamesSubregistryId, - 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("referrer-leaderboard-cache-v1"); - -const rules = buildReferralProgramRules( - ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, - ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, - config.ensHolidayAwardsStart, - config.ensHolidayAwardsEnd, - getEthnamesSubregistryId(config.namespace), -); - -/** - * The list of {@link OmnichainIndexingStatusId} values that are supported for generating - * a referrer leaderboard. - * - * Other values indicate that we are not ready to generate a referrer leaderboard yet. - * - */ -const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ - OmnichainIndexingStatusIds.Following, - OmnichainIndexingStatusIds.Completed, -]; - -/** - * Cache for the V1 referrer leaderboard API. - */ -export const referrerLeaderboardCacheV1 = new SWRCache({ - fn: async () => { - const indexingStatus = await indexingStatusCache.read(); - if (indexingStatus instanceof Error) { - logger.error( - { error: indexingStatus }, - "Failed to read indexing status cache while generating referrer leaderboard (V1). Cannot proceed without valid indexing status.", - ); - throw new Error( - "Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.", - ); - } - - const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; - if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { - throw new Error( - `Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`, - ); - } - - const latestIndexedBlockRef = getLatestIndexedBlockRef( - indexingStatus, - rules.subregistryId.chainId, - ); - if (latestIndexedBlockRef === null) { - throw new Error( - `Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`, - ); - } - - logger.info( - `Building referrer leaderboard (V1) with rules:\n${JSON.stringify( - serializeReferralProgramRules(rules), - null, - 2, - )}`, - ); - - try { - const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp); - logger.info( - `Successfully built referrer leaderboard (V1) with ${result.referrers.size} referrers from indexed data`, - ); - return result; - } catch (error) { - logger.error({ error }, "Failed to build referrer leaderboard (V1)"); - throw error; - } - }, - ttl: minutesToSeconds(1), - proactiveRevalidationInterval: minutesToSeconds(2), - proactivelyInitialize: true, -}); diff --git a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts index 29c7d715e..6a66d1b99 100644 --- a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts +++ b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts @@ -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"; @@ -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), ); diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 3143c52e4..fbf5ace6d 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -1,13 +1,11 @@ import packageJson from "@/../package.json" with { type: "json" }; -import { - ENS_HOLIDAY_AWARDS_END_DATE, - ENS_HOLIDAY_AWARDS_START_DATE, -} from "@namehash/ens-referrals"; +import { getReferralProgramCycleSet } from "@namehash/ens-referrals/v1"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ENSIndexerPublicConfig, + getEthnamesSubregistryId, PluginName, serializeENSIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; @@ -21,6 +19,7 @@ import logger from "@/lib/logger"; vi.mock("@/lib/logger", () => ({ default: { error: vi.fn(), + info: vi.fn(), }, })); @@ -53,6 +52,9 @@ const ENSINDEXER_PUBLIC_CONFIG = { const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); +const subregistryId = getEthnamesSubregistryId("mainnet"); +const defaultCycleSet = getReferralProgramCycleSet(subregistryId.address); + describe("buildConfigFromEnvironment", () => { afterEach(() => { mockFetch.mockReset(); @@ -82,8 +84,7 @@ describe("buildConfigFromEnvironment", () => { } satisfies RpcConfig, ], ]), - ensHolidayAwardsStart: ENS_HOLIDAY_AWARDS_START_DATE, - ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, + referralProgramCycleSet: defaultCycleSet, }); }); @@ -164,8 +165,7 @@ describe("buildEnsApiPublicConfig", () => { } satisfies RpcConfig, ], ]), - ensHolidayAwardsStart: ENS_HOLIDAY_AWARDS_START_DATE, - ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, + referralProgramCycleSet: defaultCycleSet, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -189,8 +189,7 @@ describe("buildEnsApiPublicConfig", () => { namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, rpcConfigs: new Map(), - ensHolidayAwardsStart: ENS_HOLIDAY_AWARDS_START_DATE, - ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, + referralProgramCycleSet: defaultCycleSet, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -224,8 +223,7 @@ describe("buildEnsApiPublicConfig", () => { namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, rpcConfigs: new Map(), - ensHolidayAwardsStart: ENS_HOLIDAY_AWARDS_START_DATE, - ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, + referralProgramCycleSet: defaultCycleSet, theGraphApiKey: "secret-api-key", }; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index acc80a261..85d3f6c97 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,15 +1,24 @@ import packageJson from "@/../package.json" with { type: "json" }; import { - ENS_HOLIDAY_AWARDS_END_DATE, - ENS_HOLIDAY_AWARDS_START_DATE, -} from "@namehash/ens-referrals"; -import { getUnixTime } from "date-fns"; + getReferralProgramCycleSet, + type ReferralProgramCycle, + type ReferralProgramCycleSet, +} from "@namehash/ens-referrals/v1"; +import { + makeCustomReferralProgramCyclesSchema, + makeReferralProgramCycleSetSchema, +} from "@namehash/ens-referrals/v1/internal"; import pRetry from "p-retry"; import { parse as parseConnectionString } from "pg-connection-string"; import { prettifyError, ZodError, z } from "zod/v4"; -import { type ENSApiPublicConfig, serializeENSIndexerPublicConfig } from "@ensnode/ensnode-sdk"; +import { + type ENSApiPublicConfig, + type ENSNamespaceId, + getEthnamesSubregistryId, + serializeENSIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, canFallbackToTheGraph, @@ -17,7 +26,6 @@ import { ENSNamespaceSchema, EnsIndexerUrlSchema, invariant_rpcConfigsSpecifiedForRootChain, - makeDatetimeSchema, makeENSIndexerPublicConfigSchema, PortSchema, RpcConfigsSchema, @@ -26,10 +34,7 @@ import { import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; -import { - invariant_ensHolidayAwardsEndAfterStart, - invariant_ensIndexerPublicConfigVersionInfo, -} from "@/config/validations"; +import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config"; import logger from "@/lib/logger"; @@ -51,12 +56,6 @@ export const DatabaseUrlSchema = z.string().refine( }, ); -// Use ISO 8601 format for defining datetime values (e.g., '2025-12-01T00:00:00Z') -const DateStringToUnixTimestampSchema = z.coerce - .string() - .pipe(makeDatetimeSchema()) - .transform((date) => getUnixTime(date)); - const EnsApiConfigSchema = z .object({ port: PortSchema.default(ENSApi_DEFAULT_PORT), @@ -67,15 +66,66 @@ const EnsApiConfigSchema = z namespace: ENSNamespaceSchema, rpcConfigs: RpcConfigsSchema, ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"), - ensHolidayAwardsStart: DateStringToUnixTimestampSchema.default(ENS_HOLIDAY_AWARDS_START_DATE), - ensHolidayAwardsEnd: DateStringToUnixTimestampSchema.default(ENS_HOLIDAY_AWARDS_END_DATE), + referralProgramCycleSet: makeReferralProgramCycleSetSchema("referralProgramCycleSet"), }) .check(invariant_rpcConfigsSpecifiedForRootChain) - .check(invariant_ensIndexerPublicConfigVersionInfo) - .check(invariant_ensHolidayAwardsEndAfterStart); + .check(invariant_ensIndexerPublicConfigVersionInfo); export type EnsApiConfig = z.infer; +/** + * Loads the referral program cycle set from a custom URL or uses defaults. + * + * @param customCyclesUrl - Optional URL to a JSON file containing custom cycle definitions + * @param namespace - The ENS namespace to get the subregistry address for + * @returns A map of cycle IDs to their cycle configurations + */ +async function loadReferralProgramCycleSet( + customCyclesUrl: string | undefined, + namespace: ENSNamespaceId, +): Promise { + 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; +} + /** * Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig. * @@ -97,6 +147,11 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); + const referralProgramCycleSet = await loadReferralProgramCycleSet( + env.CUSTOM_REFERRAL_PROGRAM_CYCLES, + ensIndexerPublicConfig.namespace, + ); + return EnsApiConfigSchema.parse({ port: env.PORT, databaseUrl: env.DATABASE_URL, @@ -106,8 +161,7 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis namespace: ensIndexerPublicConfig.namespace, databaseSchemaName: ensIndexerPublicConfig.databaseSchemaName, rpcConfigs, - ensHolidayAwardsStart: env.ENS_HOLIDAY_AWARDS_START, - ensHolidayAwardsEnd: env.ENS_HOLIDAY_AWARDS_END, + referralProgramCycleSet, }); } catch (error) { if (error instanceof ZodError) { diff --git a/apps/ensapi/src/config/environment.ts b/apps/ensapi/src/config/environment.ts index cb968656c..f71a7523a 100644 --- a/apps/ensapi/src/config/environment.ts +++ b/apps/ensapi/src/config/environment.ts @@ -1,9 +1,9 @@ import type { DatabaseEnvironment, - EnsHolidayAwardsEnvironment, EnsIndexerUrlEnvironment, LogLevelEnvironment, PortEnvironment, + ReferralProgramCyclesEnvironment, RpcEnvironment, TheGraphEnvironment, } from "@ensnode/ensnode-sdk/internal"; @@ -21,4 +21,4 @@ export type EnsApiEnvironment = Omit & PortEnvironment & LogLevelEnvironment & TheGraphEnvironment & - EnsHolidayAwardsEnvironment; + ReferralProgramCyclesEnvironment; diff --git a/apps/ensapi/src/config/validations.ts b/apps/ensapi/src/config/validations.ts index bddded71a..2b06e1e3e 100644 --- a/apps/ensapi/src/config/validations.ts +++ b/apps/ensapi/src/config/validations.ts @@ -1,6 +1,6 @@ import packageJson from "@/../package.json" with { type: "json" }; -import type { ENSIndexerPublicConfig, UnixTimestamp } from "@ensnode/ensnode-sdk"; +import type { ENSIndexerPublicConfig } from "@ensnode/ensnode-sdk"; import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; // Invariant: ENSIndexerPublicConfig VersionInfo must match ENSApi @@ -43,24 +43,3 @@ export function invariant_ensIndexerPublicConfigVersionInfo( }); } } - -// Invariant: ensHolidayAwardsEnd must be greater than or equal to ensHolidayAwardsStart -export function invariant_ensHolidayAwardsEndAfterStart( - ctx: ZodCheckFnInput<{ - ensHolidayAwardsStart: UnixTimestamp; - ensHolidayAwardsEnd: UnixTimestamp; - }>, -) { - const { - value: { ensHolidayAwardsStart, ensHolidayAwardsEnd }, - } = ctx; - - if (ensHolidayAwardsEnd < ensHolidayAwardsStart) { - ctx.issues.push({ - code: "custom", - path: ["ensHolidayAwardsEnd"], - input: ensHolidayAwardsEnd, - message: `ensHolidayAwardsEnd (${ensHolidayAwardsEnd}) must be greater than or equal to ensHolidayAwardsStart (${ensHolidayAwardsStart})`, - }); - } -} diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 4f66a9323..a20e1bf52 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -23,15 +23,19 @@ vi.mock("../middleware/referrer-leaderboard.middleware-v1", () => ({ })); import { - deserializeReferrerDetailResponse, + deserializeReferrerDetailAllCyclesResponse, deserializeReferrerLeaderboardPageResponse, - ReferrerDetailResponseCodes, - type ReferrerDetailResponseOk, + type ReferralProgramCycleId, + ReferrerDetailAllCyclesResponseCodes, + type ReferrerDetailAllCyclesResponseOk, ReferrerDetailTypeIds, + type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, } from "@namehash/ens-referrals/v1"; +import type { SWRCache } from "@ensnode/ensnode-sdk"; + import { emptyReferralLeaderboard, populatedReferrerLeaderboard, @@ -41,11 +45,20 @@ import { import app from "./ensanalytics-api-v1"; describe("/ensanalytics/v1", () => { - describe("/referrers", () => { + describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { - // Arrange: set `referrerLeaderboardV1` context var + // Arrange: mock cache map with cycle-1 + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ]); + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", populatedReferrerLeaderboard); + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -56,22 +69,23 @@ describe("/ensanalytics/v1", () => { // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; + const cycle = "cycle-1"; // Act: send test request to fetch 1st page - const responsePage1 = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) + const responsePage1 = await client["referral-leaderboard"] + .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Act: send test request to fetch 2nd page - const responsePage2 = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) + const responsePage2 = await client["referral-leaderboard"] + .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Act: send test request to fetch 3rd page - const responsePage3 = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) + const responsePage3 = await client["referral-leaderboard"] + .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); @@ -138,19 +152,29 @@ describe("/ensanalytics/v1", () => { }); it("returns empty cached referrer leaderboard when there are no referrals yet", async () => { - // Arrange: set `referrerLeaderboardV1` context var + // Arrange: mock cache map with cycle-1 + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], + ]); + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", emptyReferralLeaderboard); + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; + const cycle = "cycle-1"; // Act: send test request to fetch 1st page - const response = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) + const response = await client["referral-leaderboard"] + .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); @@ -175,11 +199,26 @@ describe("/ensanalytics/v1", () => { }); }); - describe("/referrers/:referrer", () => { - it("returns referrer metrics when referrer exists in leaderboard", async () => { - // Arrange: set `referrerLeaderboard` context var with populated leaderboard + describe("/referral-leaderboard/:referrer", () => { + it("returns referrer metrics for all cycles when referrer exists", async () => { + // Arrange: mock cache map with multiple cycles + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "cycle-2", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ]); + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", populatedReferrerLeaderboard); + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -188,30 +227,54 @@ describe("/ensanalytics/v1", () => { const expectedMetrics = populatedReferrerLeaderboard.referrers.get(existingReferrer)!; const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; - // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrers/${existingReferrer}`); + // Act: send test request to fetch referrer detail for all cycles + const httpResponse = await app.request(`/referral-leaderboard/${existingReferrer}`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerDetailAllCyclesResponse(responseData); - // Assert: response contains the expected referrer metrics + // Assert: response contains the expected referrer metrics for all cycles const expectedResponse = { - responseCode: ReferrerDetailResponseCodes.Ok, + responseCode: ReferrerDetailAllCyclesResponseCodes.Ok, data: { - type: ReferrerDetailTypeIds.Ranked, - rules: populatedReferrerLeaderboard.rules, - referrer: expectedMetrics, - aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, - accurateAsOf: expectedAccurateAsOf, + "cycle-1": { + type: ReferrerDetailTypeIds.Ranked, + rules: populatedReferrerLeaderboard.rules, + referrer: expectedMetrics, + aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, + accurateAsOf: expectedAccurateAsOf, + }, + "cycle-2": { + type: ReferrerDetailTypeIds.Ranked, + rules: populatedReferrerLeaderboard.rules, + referrer: expectedMetrics, + aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, + accurateAsOf: expectedAccurateAsOf, + }, }, - } satisfies ReferrerDetailResponseOk; + } satisfies ReferrerDetailAllCyclesResponseOk; expect(response).toMatchObject(expectedResponse); }); - it("returns zero-score metrics when referrer does not exist in leaderboard", async () => { - // Arrange: set `referrerLeaderboard` context var with populated leaderboard + it("returns zero-score metrics for all cycles when referrer does not exist", async () => { + // Arrange: mock cache map with multiple cycles + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "cycle-2", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ]); + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", populatedReferrerLeaderboard); + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -219,42 +282,62 @@ describe("/ensanalytics/v1", () => { const nonExistingReferrer = "0x0000000000000000000000000000000000000099"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrers/${nonExistingReferrer}`); + const httpResponse = await app.request(`/referral-leaderboard/${nonExistingReferrer}`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerDetailAllCyclesResponse(responseData); - // Assert: response contains zero-score metrics for the referrer - // Rank should be null since they're not on the leaderboard + // Assert: response contains zero-score metrics for the referrer across all cycles const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; - expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Ok); - if (response.responseCode === ReferrerDetailResponseCodes.Ok) { - expect(response.data.type).toBe(ReferrerDetailTypeIds.Unranked); - expect(response.data.rules).toEqual(populatedReferrerLeaderboard.rules); - expect(response.data.aggregatedMetrics).toEqual( + expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Ok); + if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { + // Check cycle-1 + expect(response.data["cycle-1"].type).toBe(ReferrerDetailTypeIds.Unranked); + expect(response.data["cycle-1"].rules).toEqual(populatedReferrerLeaderboard.rules); + expect(response.data["cycle-1"].aggregatedMetrics).toEqual( populatedReferrerLeaderboard.aggregatedMetrics, ); - expect(response.data.referrer.referrer).toBe(nonExistingReferrer); - expect(response.data.referrer.rank).toBe(null); - expect(response.data.referrer.totalReferrals).toBe(0); - expect(response.data.referrer.totalIncrementalDuration).toBe(0); - expect(response.data.referrer.score).toBe(0); - expect(response.data.referrer.isQualified).toBe(false); - expect(response.data.referrer.finalScoreBoost).toBe(0); - expect(response.data.referrer.finalScore).toBe(0); - expect(response.data.referrer.awardPoolShare).toBe(0); - expect(response.data.referrer.awardPoolApproxValue).toStrictEqual({ + expect(response.data["cycle-1"].referrer.referrer).toBe(nonExistingReferrer); + expect(response.data["cycle-1"].referrer.rank).toBe(null); + expect(response.data["cycle-1"].referrer.totalReferrals).toBe(0); + expect(response.data["cycle-1"].referrer.totalIncrementalDuration).toBe(0); + expect(response.data["cycle-1"].referrer.score).toBe(0); + expect(response.data["cycle-1"].referrer.isQualified).toBe(false); + expect(response.data["cycle-1"].referrer.finalScoreBoost).toBe(0); + expect(response.data["cycle-1"].referrer.finalScore).toBe(0); + expect(response.data["cycle-1"].referrer.awardPoolShare).toBe(0); + expect(response.data["cycle-1"].referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); - expect(response.data.accurateAsOf).toBe(expectedAccurateAsOf); + expect(response.data["cycle-1"].accurateAsOf).toBe(expectedAccurateAsOf); + + // Check cycle-2 + expect(response.data["cycle-2"].type).toBe(ReferrerDetailTypeIds.Unranked); + expect(response.data["cycle-2"].referrer.referrer).toBe(nonExistingReferrer); + expect(response.data["cycle-2"].referrer.rank).toBe(null); } }); - it("returns zero-score metrics when leaderboard is empty", async () => { - // Arrange: set `referrerLeaderboardV1` context var with empty leaderboard + it("returns zero-score metrics for all cycles when leaderboards are empty", async () => { + // Arrange: mock cache map with multiple cycles, all empty + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], + [ + "cycle-2", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], + ]); + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", emptyReferralLeaderboard); + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -262,40 +345,103 @@ describe("/ensanalytics/v1", () => { const referrer = "0x0000000000000000000000000000000000000001"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrers/${referrer}`); + const httpResponse = await app.request(`/referral-leaderboard/${referrer}`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerDetailAllCyclesResponse(responseData); - // Assert: response contains zero-score metrics for the referrer - // Rank should be null since they're not on the leaderboard + // Assert: response contains zero-score metrics for the referrer across all cycles const expectedAccurateAsOf = emptyReferralLeaderboard.accurateAsOf; - expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Ok); - if (response.responseCode === ReferrerDetailResponseCodes.Ok) { - expect(response.data.type).toBe(ReferrerDetailTypeIds.Unranked); - expect(response.data.rules).toEqual(emptyReferralLeaderboard.rules); - expect(response.data.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); - expect(response.data.referrer.referrer).toBe(referrer); - expect(response.data.referrer.rank).toBe(null); - expect(response.data.referrer.totalReferrals).toBe(0); - expect(response.data.referrer.totalIncrementalDuration).toBe(0); - expect(response.data.referrer.score).toBe(0); - expect(response.data.referrer.isQualified).toBe(false); - expect(response.data.referrer.finalScoreBoost).toBe(0); - expect(response.data.referrer.finalScore).toBe(0); - expect(response.data.referrer.awardPoolShare).toBe(0); - expect(response.data.referrer.awardPoolApproxValue).toStrictEqual({ + expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Ok); + if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { + // Check cycle-1 + expect(response.data["cycle-1"].type).toBe(ReferrerDetailTypeIds.Unranked); + expect(response.data["cycle-1"].rules).toEqual(emptyReferralLeaderboard.rules); + expect(response.data["cycle-1"].aggregatedMetrics).toEqual( + emptyReferralLeaderboard.aggregatedMetrics, + ); + expect(response.data["cycle-1"].referrer.referrer).toBe(referrer); + expect(response.data["cycle-1"].referrer.rank).toBe(null); + expect(response.data["cycle-1"].referrer.totalReferrals).toBe(0); + expect(response.data["cycle-1"].referrer.totalIncrementalDuration).toBe(0); + expect(response.data["cycle-1"].referrer.score).toBe(0); + expect(response.data["cycle-1"].referrer.isQualified).toBe(false); + expect(response.data["cycle-1"].referrer.finalScoreBoost).toBe(0); + expect(response.data["cycle-1"].referrer.finalScore).toBe(0); + expect(response.data["cycle-1"].referrer.awardPoolShare).toBe(0); + expect(response.data["cycle-1"].referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); - expect(response.data.accurateAsOf).toBe(expectedAccurateAsOf); + expect(response.data["cycle-1"].accurateAsOf).toBe(expectedAccurateAsOf); + + // Check cycle-2 + expect(response.data["cycle-2"].type).toBe(ReferrerDetailTypeIds.Unranked); + expect(response.data["cycle-2"].referrer.referrer).toBe(referrer); + expect(response.data["cycle-2"].referrer.rank).toBe(null); } }); - it("returns error response when leaderboard fails to load", async () => { - // Arrange: set `referrerLeaderboard` context var with rejected promise + it("returns error response when any cycle cache fails to load", async () => { + // Arrange: mock cache map where cycle-1 succeeds but cycle-2 fails + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "cycle-2", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + ]); + + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + + // Arrange: use any referrer address + const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + + // Act: send test request to fetch referrer detail + const httpResponse = await app.request(`/referral-leaderboard/${referrer}`); + const responseData = await httpResponse.json(); + const response = deserializeReferrerDetailAllCyclesResponse(responseData); + + // Assert: response contains error mentioning the specific cycle that failed + expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Error); + if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Error) { + expect(response.error).toBe("Internal Server Error"); + expect(response.errorMessage).toContain("cycle-2"); + expect(response.errorMessage).toBe( + "Referrer leaderboard data for cycle cycle-2 has not been successfully cached yet.", + ); + } + }); + + it("returns error response when all cycle caches fail to load", async () => { + // Arrange: mock cache map where all cycles fail + const mockCyclesCaches = new Map>([ + [ + "cycle-1", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + [ + "cycle-2", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + ]); + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", new Error("Database connection failed")); + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -303,16 +449,17 @@ describe("/ensanalytics/v1", () => { const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrers/${referrer}`); + const httpResponse = await app.request(`/referral-leaderboard/${referrer}`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerDetailAllCyclesResponse(responseData); - // Assert: response contains error - expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Error); - if (response.responseCode === ReferrerDetailResponseCodes.Error) { - expect(response.error).toBe("Service Unavailable"); + // Assert: response contains error for the first cycle that failed + expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Error); + if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Error) { + expect(response.error).toBe("Internal Server Error"); + expect(response.errorMessage).toContain("cycle-1"); expect(response.errorMessage).toBe( - "Referrer leaderboard data has not been successfully cached yet.", + "Referrer leaderboard data for cycle cycle-1 has not been successfully cached yet.", ); } }); diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index 2b9efaec3..c35b49eab 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -1,15 +1,20 @@ +import config from "@/config"; + import { getReferrerDetail, getReferrerLeaderboardPage, REFERRERS_PER_LEADERBOARD_PAGE_MAX, - type ReferrerDetailResponse, - ReferrerDetailResponseCodes, + type ReferralProgramCycleId, + type ReferrerDetailAllCyclesData, + type ReferrerDetailAllCyclesResponse, + ReferrerDetailAllCyclesResponseCodes, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, - serializeReferrerDetailResponse, + serializeReferrerDetailAllCyclesResponse, serializeReferrerLeaderboardPageResponse, } from "@namehash/ens-referrals/v1"; +import { makeReferralProgramCycleIdSchema } from "@namehash/ens-referrals/v1/internal"; import { describeRoute } from "hono-openapi"; import { z } from "zod/v4"; @@ -22,8 +27,17 @@ import { referrerLeaderboardMiddlewareV1 } from "@/middleware/referrer-leaderboa const logger = makeLogger("ensanalytics-api-v1"); -// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest) -const paginationQuerySchema = z.object({ +// Get list of configured cycle IDs for validation +const getConfiguredCycleIds = (): ReferralProgramCycleId[] => { + return Array.from(config.referralProgramCycleSet.keys()) as ReferralProgramCycleId[]; +}; + +/** + * Query parameters schema for referrer leaderboard page requests. + * Validates cycle ID, page number, and records per page. + */ +const referrerLeaderboardPageQuerySchema = z.object({ + cycle: makeReferralProgramCycleIdSchema("cycle"), page: z .optional(z.coerce.number().int().min(1, "Page must be a positive integer")) .describe("Page number for pagination"), @@ -47,46 +61,68 @@ const app = factory // Apply referrer leaderboard cache middleware to all routes in this handler .use(referrerLeaderboardMiddlewareV1) - // Get a page from the referrer leaderboard + // Get a page from the referrer leaderboard for a specific cycle .get( - "/referrers", + "/referral-leaderboard", describeRoute({ tags: ["ENSAwards"], summary: "Get Referrer Leaderboard (v1)", - description: "Returns a paginated page from the referrer leaderboard", + description: "Returns a paginated page from the referrer leaderboard for a specific cycle", responses: { 200: { description: "Successfully retrieved referrer leaderboard page", }, + 404: { + description: "Unknown cycle ID", + }, 500: { description: "Internal server error", }, }, }), - validate("query", paginationQuerySchema), + validate("query", referrerLeaderboardPageQuerySchema), async (c) => { // context must be set by the required middleware - if (c.var.referrerLeaderboardV1 === undefined) { + if (c.var.referralLeaderboardCyclesCaches === undefined) { throw new Error(`Invariant(ensanalytics-api-v1): referrerLeaderboardMiddlewareV1 required`); } try { - if (c.var.referrerLeaderboardV1 instanceof Error) { + const { cycle, page, recordsPerPage } = c.req.valid("query"); + + // Get the specific cycle's cache + // Note: We validate against the configured cycles, not just predefined IDs, + // to support custom cycles loaded from CUSTOM_REFERRAL_PROGRAM_CYCLES + const cycleCache = c.var.referralLeaderboardCyclesCaches.get(cycle); + + if (!cycleCache) { + const configuredCycles = getConfiguredCycleIds(); + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Not Found", + errorMessage: `Unknown cycle: ${cycle}. Valid cycles: ${configuredCycles.join(", ")}`, + } satisfies ReferrerLeaderboardPageResponse), + 404, + ); + } + + // Read from the cycle's cache + const leaderboard = await cycleCache.read(); + + // Check if this specific cycle failed to build + if (leaderboard instanceof Error) { return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, error: "Internal Server Error", - errorMessage: "Failed to load referrer leaderboard data.", + errorMessage: `Failed to load leaderboard for cycle ${cycle}.`, } satisfies ReferrerLeaderboardPageResponse), 500, ); } - const { page, recordsPerPage } = c.req.valid("query"); - const leaderboardPage = getReferrerLeaderboardPage( - { page, recordsPerPage }, - c.var.referrerLeaderboardV1, - ); + const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard); return c.json( serializeReferrerLeaderboardPageResponse({ @@ -95,7 +131,7 @@ const app = factory } satisfies ReferrerLeaderboardPageResponse), ); } catch (error) { - logger.error({ error }, "Error in /ensanalytics/v1/referrers endpoint"); + logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard endpoint"); const errorMessage = error instanceof Error ? error.message @@ -117,66 +153,68 @@ const referrerAddressSchema = z.object({ referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), }); -// Get referrer detail for a specific address +// Get referrer detail for a specific address across all cycles app.get( - "/referrers/:referrer", + "/referral-leaderboard/:referrer", describeRoute({ tags: ["ENSAwards"], - summary: "Get Referrer Detail (v1)", - description: "Returns detailed information for a specific referrer by address", + summary: "Get Referrer Detail for All Cycles (v1)", + description: + "Returns detailed information for a specific referrer across all referral program cycles", responses: { 200: { - description: "Successfully retrieved referrer detail", + description: "Successfully retrieved referrer detail for all cycles", }, 500: { - description: "Internal server error", - }, - 503: { - description: "Service unavailable - referrer leaderboard data not yet cached", + description: "Internal server error - referrer leaderboard for a cycle failed to load", }, }, }), validate("param", referrerAddressSchema), async (c) => { // context must be set by the required middleware - if (c.var.referrerLeaderboardV1 === undefined) { + if (c.var.referralLeaderboardCyclesCaches === undefined) { throw new Error(`Invariant(ensanalytics-api-v1): referrerLeaderboardMiddlewareV1 required`); } try { - // Check if leaderboard failed to load - if (c.var.referrerLeaderboardV1 instanceof Error) { - return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Error, - error: "Service Unavailable", - errorMessage: "Referrer leaderboard data has not been successfully cached yet.", - } satisfies ReferrerDetailResponse), - 503, - ); - } - const { referrer } = c.req.valid("param"); - const detail = getReferrerDetail(referrer, c.var.referrerLeaderboardV1); + const allCyclesData = {} as ReferrerDetailAllCyclesData; + + // 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); + } return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Ok, - data: detail, - } satisfies ReferrerDetailResponse), + serializeReferrerDetailAllCyclesResponse({ + responseCode: ReferrerDetailAllCyclesResponseCodes.Ok, + data: allCyclesData, + } satisfies ReferrerDetailAllCyclesResponse), ); } catch (error) { - logger.error({ error }, "Error in /ensanalytics/v1/referrers/:referrer endpoint"); + logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard/:referrer endpoint"); const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred while processing your request"; return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Error, + serializeReferrerDetailAllCyclesResponse({ + responseCode: ReferrerDetailAllCyclesResponseCodes.Error, error: "Internal server error", errorMessage, - } satisfies ReferrerDetailResponse), + } satisfies ReferrerDetailAllCyclesResponse), 500, ); } diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 1389089ec..3999d5c10 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -8,8 +8,8 @@ import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; +import { referralLeaderboardCyclesCaches } from "@/cache/referral-leaderboard-cycles.cache"; import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache"; -import { referrerLeaderboardCacheV1 } from "@/cache/referrer-leaderboard.cache-v1"; import { redactEnsApiConfig } from "@/config/redact"; import { errorResponse } from "@/lib/handlers/error-response"; import { factory } from "@/lib/hono-factory"; @@ -161,8 +161,11 @@ const gracefulShutdown = async () => { referrerLeaderboardCache.destroy(); logger.info("Destroyed referrerLeaderboardCache"); - referrerLeaderboardCacheV1.destroy(); - logger.info("Destroyed referrerLeaderboardCacheV1"); + // Destroy all cycle caches + for (const [cycleId, cache] of referralLeaderboardCyclesCaches) { + cache.destroy(); + logger.info(`Destroyed referralLeaderboardCyclesCache for ${cycleId}`); + } indexingStatusCache.destroy(); logger.info("Destroyed indexingStatusCache"); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 1f81309e4..8957e3d17 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -1,12 +1,9 @@ -import { - buildReferralProgramRules, - ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, - ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, - type ReferrerLeaderboard, -} from "@namehash/ens-referrals/v1"; +import { buildReferralProgramRules, type ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; import { getUnixTime } from "date-fns"; import { describe, expect, it, vi } from "vitest"; +import { priceUsdc } from "@ensnode/ensnode-sdk"; + import * as database from "./database-v1"; import { getReferrerLeaderboard } from "./get-referrer-leaderboard-v1"; import { dbResultsReferrerLeaderboard } from "./mocks-v1"; @@ -17,8 +14,8 @@ vi.mock("./database-v1", () => ({ })); const rules = buildReferralProgramRules( - ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, - ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, + priceUsdc(10_000_000_000n), // 10,000 USDC in smallest units + 10, // maxQualifiedReferrers getUnixTime("2025-01-01T00:00:00Z"), getUnixTime("2025-12-31T23:59:59Z"), { diff --git a/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts b/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts index 468a549fb..f029cfc9b 100644 --- a/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts +++ b/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts @@ -1,6 +1,7 @@ -import type { ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; - -import { referrerLeaderboardCacheV1 } from "@/cache/referrer-leaderboard.cache-v1"; +import { + type ReferralLeaderboardCyclesCacheMap, + referralLeaderboardCyclesCaches, +} from "@/cache/referral-leaderboard-cycles.cache"; import { factory } from "@/lib/hono-factory"; /** @@ -8,17 +9,17 @@ import { factory } from "@/lib/hono-factory"; */ export type ReferrerLeaderboardMiddlewareV1Variables = { /** - * A {@link ReferrerLeaderboard} containing metrics and rankings for all referrers - * with at least one referral within the ENS Holiday Awards period, or an {@link Error} - * indicating failure to build the leaderboard. + * A map from cycle ID to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. * - * If `referrerLeaderboardV1` is an {@link Error}, no prior attempts to successfully fetch (and cache) - * a referrer leaderboard within the lifetime of this middleware have been successful. + * Each cycle has its own independent cache to preserve successful data even when other cycles fail. + * When reading from a specific cycle's cache, it will return either: + * - The {@link ReferrerLeaderboard} if successfully cached + * - An {@link Error} if the cache failed to build * - * If `referrerLeaderboardV1` is a {@link ReferrerLeaderboard}, a referrer leaderboard was successfully - * fetched (and cached) at least once within the lifetime of this middleware. + * Individual cycle caches maintain their own stale-while-revalidate behavior, so a previously + * successfully fetched cycle continues serving its data even if a subsequent refresh fails. */ - referrerLeaderboardV1: ReferrerLeaderboard | Error; + referralLeaderboardCyclesCaches: ReferralLeaderboardCyclesCacheMap; }; /** @@ -26,8 +27,6 @@ export type ReferrerLeaderboardMiddlewareV1Variables = { * to downstream middleware and handlers (V1 API). */ export const referrerLeaderboardMiddlewareV1 = factory.createMiddleware(async (c, next) => { - const leaderboard = await referrerLeaderboardCacheV1.read(); - - c.set("referrerLeaderboardV1", leaderboard); + c.set("referralLeaderboardCyclesCaches", referralLeaderboardCyclesCaches); await next(); }); diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index dd55dd6d5..b5e351840 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -3,31 +3,42 @@ import { prettifyError } from "zod/v4"; import { deserializePriceEth, deserializePriceUsdc, type PriceEth } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; +import type { ReferralProgramCycle, ReferralProgramCycleId } from "../cycle"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; -import type { ReferrerDetailRanked, ReferrerDetailUnranked } from "../referrer-detail"; +import type { + ReferrerDetail, + ReferrerDetailRanked, + ReferrerDetailUnranked, +} from "../referrer-detail"; import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { SerializedAggregatedReferrerMetrics, SerializedAwardedReferrerMetrics, + SerializedReferralProgramCycle, SerializedReferralProgramRules, + SerializedReferrerDetail, + SerializedReferrerDetailAllCyclesResponse, SerializedReferrerDetailRanked, - SerializedReferrerDetailResponse, SerializedReferrerDetailUnranked, SerializedReferrerLeaderboardPage, SerializedReferrerLeaderboardPageResponse, SerializedUnrankedReferrerMetrics, } from "./serialized-types"; -import type { ReferrerDetailResponse, ReferrerLeaderboardPageResponse } from "./types"; +import type { + ReferrerDetailAllCyclesData, + ReferrerDetailAllCyclesResponse, + ReferrerLeaderboardPageResponse, +} from "./types"; import { - makeReferrerDetailResponseSchema, + makeReferrerDetailAllCyclesResponseSchema, makeReferrerLeaderboardPageResponseSchema, } from "./zod-schemas"; /** * Deserializes a {@link SerializedReferralProgramRules} object. */ -function deserializeReferralProgramRules( +export function deserializeReferralProgramRules( rules: SerializedReferralProgramRules, ): ReferralProgramRules { return { @@ -141,6 +152,32 @@ function deserializeReferrerDetailUnranked( }; } +/** + * Deserializes a {@link SerializedReferrerDetail} object (ranked or unranked). + */ +function deserializeReferrerDetail(detail: SerializedReferrerDetail): ReferrerDetail { + switch (detail.type) { + case "ranked": + return deserializeReferrerDetailRanked(detail); + case "unranked": + return deserializeReferrerDetailUnranked(detail); + } +} + +/** + * Deserializes a {@link SerializedReferralProgramCycle} object. + */ +export function deserializeReferralProgramCycle( + cycle: SerializedReferralProgramCycle, +): ReferralProgramCycle { + return { + id: cycle.id, + displayName: cycle.displayName, + rules: deserializeReferralProgramRules(cycle.rules), + rulesUrl: cycle.rulesUrl, + }; +} + /** * Deserialize a {@link ReferrerLeaderboardPageResponse} object. * @@ -181,34 +218,30 @@ export function deserializeReferrerLeaderboardPageResponse( } /** - * Deserialize a {@link ReferrerDetailResponse} object. + * Deserialize a {@link ReferrerDetailAllCyclesResponse} object. * * Note: This function explicitly deserializes each subobject to convert string * RevenueContribution values back to {@link PriceEth}, then validates using Zod schemas * to enforce invariants on the data. */ -export function deserializeReferrerDetailResponse( - maybeResponse: SerializedReferrerDetailResponse, +export function deserializeReferrerDetailAllCyclesResponse( + maybeResponse: SerializedReferrerDetailAllCyclesResponse, valueLabel?: string, -): ReferrerDetailResponse { - let deserialized: ReferrerDetailResponse; +): ReferrerDetailAllCyclesResponse { + let deserialized: ReferrerDetailAllCyclesResponse; + switch (maybeResponse.responseCode) { case "ok": { - switch (maybeResponse.data.type) { - case "ranked": - deserialized = { - responseCode: maybeResponse.responseCode, - data: deserializeReferrerDetailRanked(maybeResponse.data), - } as ReferrerDetailResponse; - break; - - case "unranked": - deserialized = { - responseCode: maybeResponse.responseCode, - data: deserializeReferrerDetailUnranked(maybeResponse.data), - } as ReferrerDetailResponse; - break; + const data: ReferrerDetailAllCyclesData = {} as ReferrerDetailAllCyclesData; + + for (const [cycleId, detail] of Object.entries(maybeResponse.data)) { + data[cycleId as ReferralProgramCycleId] = deserializeReferrerDetail(detail); } + + deserialized = { + responseCode: "ok", + data, + }; break; } @@ -218,11 +251,13 @@ export function deserializeReferrerDetailResponse( } // Then validate the deserialized structure using zod schemas - const schema = makeReferrerDetailResponseSchema(valueLabel); + const schema = makeReferrerDetailAllCyclesResponseSchema(valueLabel); const parsed = schema.safeParse(deserialized); if (parsed.error) { - throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`); + throw new Error( + `Cannot deserialize ReferrerDetailAllCyclesResponse:\n${prettifyError(parsed.error)}\n`, + ); } return parsed.data; diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 66906ff39..383e4a9d1 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,24 +1,32 @@ import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; +import type { ReferralProgramCycle, ReferralProgramCycleId } from "../cycle"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; -import type { ReferrerDetailRanked, ReferrerDetailUnranked } from "../referrer-detail"; +import type { + ReferrerDetail, + ReferrerDetailRanked, + ReferrerDetailUnranked, +} from "../referrer-detail"; import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { SerializedAggregatedReferrerMetrics, SerializedAwardedReferrerMetrics, + SerializedReferralProgramCycle, SerializedReferralProgramRules, + SerializedReferrerDetail, + SerializedReferrerDetailAllCyclesData, + SerializedReferrerDetailAllCyclesResponse, SerializedReferrerDetailRanked, - SerializedReferrerDetailResponse, SerializedReferrerDetailUnranked, SerializedReferrerLeaderboardPage, SerializedReferrerLeaderboardPageResponse, SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import { - type ReferrerDetailResponse, - ReferrerDetailResponseCodes, + type ReferrerDetailAllCyclesResponse, + ReferrerDetailAllCyclesResponseCodes, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, } from "./types"; @@ -140,6 +148,32 @@ function serializeReferrerDetailUnranked( }; } +/** + * Serializes a {@link ReferrerDetail} object (ranked or unranked). + */ +function serializeReferrerDetail(detail: ReferrerDetail): SerializedReferrerDetail { + switch (detail.type) { + case "ranked": + return serializeReferrerDetailRanked(detail); + case "unranked": + return serializeReferrerDetailUnranked(detail); + } +} + +/** + * Serializes a {@link ReferralProgramCycle} object. + */ +export function serializeReferralProgramCycle( + cycle: ReferralProgramCycle, +): SerializedReferralProgramCycle { + return { + id: cycle.id, + displayName: cycle.displayName, + rules: serializeReferralProgramRules(cycle.rules), + rulesUrl: cycle.rulesUrl, + }; +} + /** * Serialize a {@link ReferrerLeaderboardPageResponse} object. */ @@ -159,29 +193,27 @@ export function serializeReferrerLeaderboardPageResponse( } /** - * Serialize a {@link ReferrerDetailResponse} object. + * Serialize a {@link ReferrerDetailAllCyclesResponse} object. */ -export function serializeReferrerDetailResponse( - response: ReferrerDetailResponse, -): SerializedReferrerDetailResponse { +export function serializeReferrerDetailAllCyclesResponse( + response: ReferrerDetailAllCyclesResponse, +): SerializedReferrerDetailAllCyclesResponse { switch (response.responseCode) { - case ReferrerDetailResponseCodes.Ok: - switch (response.data.type) { - case "ranked": - return { - responseCode: response.responseCode, - data: serializeReferrerDetailRanked(response.data), - }; - - case "unranked": - return { - responseCode: response.responseCode, - data: serializeReferrerDetailUnranked(response.data), - }; + case ReferrerDetailAllCyclesResponseCodes.Ok: { + const serializedData: SerializedReferrerDetailAllCyclesData = + {} as SerializedReferrerDetailAllCyclesData; + + for (const [cycleId, detail] of Object.entries(response.data)) { + serializedData[cycleId as ReferralProgramCycleId] = serializeReferrerDetail(detail); } - break; - case ReferrerDetailResponseCodes.Error: + return { + responseCode: response.responseCode, + data: serializedData, + }; + } + + case ReferrerDetailAllCyclesResponseCodes.Error: return response; } } diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 149ede0dd..392dbc112 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,14 +1,15 @@ import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; +import type { ReferralProgramCycle, ReferralProgramCycleId } from "../cycle"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { ReferrerDetailRanked, ReferrerDetailUnranked } from "../referrer-detail"; import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { - ReferrerDetailResponse, - ReferrerDetailResponseError, - ReferrerDetailResponseOk, + ReferrerDetailAllCyclesResponse, + ReferrerDetailAllCyclesResponseError, + ReferrerDetailAllCyclesResponseOk, ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseError, ReferrerLeaderboardPageResponseOk, @@ -108,22 +109,38 @@ export type SerializedReferrerLeaderboardPageResponse = | SerializedReferrerLeaderboardPageResponseError; /** - * Serialized representation of {@link ReferrerDetailResponseError}. - * - * Note: All fields are already serializable, so this type is identical to the source type. + * Serialized representation of {@link ReferralProgramCycle}. + */ +export interface SerializedReferralProgramCycle extends Omit { + rules: SerializedReferralProgramRules; +} + +/** + * Serialized representation of referrer detail data across all cycles. */ -export type SerializedReferrerDetailResponseError = ReferrerDetailResponseError; +export type SerializedReferrerDetailAllCyclesData = Record< + ReferralProgramCycleId, + SerializedReferrerDetail +>; /** - * Serialized representation of {@link ReferrerDetailResponseOk}. + * Serialized representation of {@link ReferrerDetailAllCyclesResponseOk}. */ -export interface SerializedReferrerDetailResponseOk extends Omit { - data: SerializedReferrerDetail; +export interface SerializedReferrerDetailAllCyclesResponseOk + extends Omit { + data: SerializedReferrerDetailAllCyclesData; } /** - * Serialized representation of {@link ReferrerDetailResponse}. + * Serialized representation of {@link ReferrerDetailAllCyclesResponseError}. + * + * Note: All fields are already serializable, so this type is identical to the source type. + */ +export type SerializedReferrerDetailAllCyclesResponseError = ReferrerDetailAllCyclesResponseError; + +/** + * Serialized representation of {@link ReferrerDetailAllCyclesResponse}. */ -export type SerializedReferrerDetailResponse = - | SerializedReferrerDetailResponseOk - | SerializedReferrerDetailResponseError; +export type SerializedReferrerDetailAllCyclesResponse = + | SerializedReferrerDetailAllCyclesResponseOk + | SerializedReferrerDetailAllCyclesResponseError; diff --git a/packages/ens-referrals/src/v1/api/types.ts b/packages/ens-referrals/src/v1/api/types.ts index 51848e2c2..593a309dc 100644 --- a/packages/ens-referrals/src/v1/api/types.ts +++ b/packages/ens-referrals/src/v1/api/types.ts @@ -1,12 +1,16 @@ import type { Address } from "viem"; +import type { ReferralProgramCycleId } from "../cycle"; import type { ReferrerLeaderboardPage, ReferrerLeaderboardPageParams } from "../leaderboard-page"; import type { ReferrerDetail } from "../referrer-detail"; /** * Request parameters for a referrer leaderboard page query. */ -export interface ReferrerLeaderboardPageRequest extends ReferrerLeaderboardPageParams {} +export interface ReferrerLeaderboardPageRequest extends ReferrerLeaderboardPageParams { + /** The referral program cycle ID */ + cycle: ReferralProgramCycleId; +} /** * A status code for a referrer leaderboard page API response. @@ -67,9 +71,9 @@ export interface ReferrerDetailRequest { /** * A status code for referrer detail API responses. */ -export const ReferrerDetailResponseCodes = { +export const ReferrerDetailAllCyclesResponseCodes = { /** - * Represents that the referrer detail data is available. + * Represents that the referrer detail data across all cycles is available. */ Ok: "ok", @@ -80,32 +84,41 @@ export const ReferrerDetailResponseCodes = { } as const; /** - * The derived string union of possible {@link ReferrerDetailResponseCodes}. + * The derived string union of possible {@link ReferrerDetailAllCyclesResponseCodes}. + */ +export type ReferrerDetailAllCyclesResponseCode = + (typeof ReferrerDetailAllCyclesResponseCodes)[keyof typeof ReferrerDetailAllCyclesResponseCodes]; + +/** + * Referrer detail data across all cycles. + * + * Maps each cycle ID to the referrer's detail for that cycle. */ -export type ReferrerDetailResponseCode = - (typeof ReferrerDetailResponseCodes)[keyof typeof ReferrerDetailResponseCodes]; +export type ReferrerDetailAllCyclesData = Record; /** - * A referrer detail response when the data is available for a referrer on the leaderboard. + * A successful response containing referrer detail for all cycles. */ -export type ReferrerDetailResponseOk = { - responseCode: typeof ReferrerDetailResponseCodes.Ok; - data: ReferrerDetail; +export type ReferrerDetailAllCyclesResponseOk = { + responseCode: typeof ReferrerDetailAllCyclesResponseCodes.Ok; + data: ReferrerDetailAllCyclesData; }; /** - * A referrer detail response when an error occurs. + * A referrer detail across all cycles response when an error occurs. */ -export type ReferrerDetailResponseError = { - responseCode: typeof ReferrerDetailResponseCodes.Error; +export type ReferrerDetailAllCyclesResponseError = { + responseCode: typeof ReferrerDetailAllCyclesResponseCodes.Error; error: string; errorMessage: string; }; /** - * A referrer detail API response. + * A referrer detail across all cycles API response. * * Use the `responseCode` field to determine the specific type interpretation * at runtime. */ -export type ReferrerDetailResponse = ReferrerDetailResponseOk | ReferrerDetailResponseError; +export type ReferrerDetailAllCyclesResponse = + | ReferrerDetailAllCyclesResponseOk + | ReferrerDetailAllCyclesResponseError; diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 994258506..951a5fc10 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -21,9 +21,13 @@ import { makeUnixTimestampSchema, } from "@ensnode/ensnode-sdk/internal"; +import type { ReferralProgramCycleId } from "../cycle"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; import { type ReferrerDetailRanked, ReferrerDetailTypeIds } from "../referrer-detail"; -import { ReferrerDetailResponseCodes, ReferrerLeaderboardPageResponseCodes } from "./types"; +import { + ReferrerDetailAllCyclesResponseCodes, + ReferrerLeaderboardPageResponseCodes, +} from "./types"; /** * Schema for ReferralProgramRules @@ -200,35 +204,103 @@ export const makeReferrerDetailUnrankedSchema = (valueLabel: string = "ReferrerD }); /** - * Schema for {@link ReferrerDetailResponseOk} - * Accepts either ranked or unranked referrer detail data + * Schema for {@link ReferrerDetailAllCyclesResponseOk} + * Accepts a record of cycle IDs to referrer details */ -export const makeReferrerDetailResponseOkSchema = (valueLabel: string = "ReferrerDetailResponse") => +export const makeReferrerDetailAllCyclesResponseOkSchema = ( + valueLabel: string = "ReferrerDetailAllCyclesResponse", +) => z.object({ - responseCode: z.literal(ReferrerDetailResponseCodes.Ok), - data: z.discriminatedUnion("type", [ - makeReferrerDetailRankedSchema(`${valueLabel}.data`), - makeReferrerDetailUnrankedSchema(`${valueLabel}.data`), - ]), + responseCode: z.literal(ReferrerDetailAllCyclesResponseCodes.Ok), + data: z.record( + makeReferralProgramCycleIdSchema(`${valueLabel}.data[cycle]`), + z.discriminatedUnion("type", [ + makeReferrerDetailRankedSchema(`${valueLabel}.data[cycle]`), + makeReferrerDetailUnrankedSchema(`${valueLabel}.data[cycle]`), + ]), + ), }); /** - * Schema for {@link ReferrerDetailResponseError} + * Schema for {@link ReferrerDetailAllCyclesResponseError} */ -export const makeReferrerDetailResponseErrorSchema = ( - _valueLabel: string = "ReferrerDetailResponse", +export const makeReferrerDetailAllCyclesResponseErrorSchema = ( + _valueLabel: string = "ReferrerDetailAllCyclesResponse", ) => z.object({ - responseCode: z.literal(ReferrerDetailResponseCodes.Error), + responseCode: z.literal(ReferrerDetailAllCyclesResponseCodes.Error), error: z.string(), errorMessage: z.string(), }); /** - * Schema for {@link ReferrerDetailResponse} + * Schema for {@link ReferrerDetailAllCyclesResponse} */ -export const makeReferrerDetailResponseSchema = (valueLabel: string = "ReferrerDetailResponse") => +export const makeReferrerDetailAllCyclesResponseSchema = ( + valueLabel: string = "ReferrerDetailAllCyclesResponse", +) => z.discriminatedUnion("responseCode", [ - makeReferrerDetailResponseOkSchema(valueLabel), - makeReferrerDetailResponseErrorSchema(valueLabel), + makeReferrerDetailAllCyclesResponseOkSchema(valueLabel), + makeReferrerDetailAllCyclesResponseErrorSchema(valueLabel), ]); + +/** + * Schema for validating a {@link ReferralProgramCycleId}. + * + * Note: This accepts any non-empty string to support custom cycle IDs loaded from + * CUSTOM_REFERRAL_PROGRAM_CYCLES. Runtime validation against configured cycles + * happens at the business logic level. + */ +export const makeReferralProgramCycleIdSchema = (valueLabel: string = "ReferralProgramCycleId") => + z.string().min(1, `${valueLabel} must not be empty`); + +/** + * Schema for validating a {@link ReferralProgramCycle}. + */ +export const makeReferralProgramCycleSchema = (valueLabel: string = "ReferralProgramCycle") => + z.object({ + id: makeReferralProgramCycleIdSchema(`${valueLabel}.id`), + displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), + rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), + rulesUrl: z.url(`${valueLabel}.rulesUrl must be a valid URL`), + }); + +/** + * Schema for validating custom referral program cycles (array format from JSON). + */ +export const makeCustomReferralProgramCyclesSchema = ( + valueLabel: string = "CustomReferralProgramCycles", +) => + z + .array(makeReferralProgramCycleSchema(`${valueLabel}[cycle]`)) + .min(1, `${valueLabel} must contain at least one cycle`); + +/** + * Schema for validating a {@link ReferralProgramCycleSet} (Map structure). + */ +export const makeReferralProgramCycleSetSchema = (valueLabel: string = "ReferralProgramCycleSet") => + z + .instanceof(Map, { + message: `${valueLabel} must be a Map`, + }) + .refine( + (map): map is Map => { + // Validate each entry in the map + for (const [key, value] of map.entries()) { + // Validate key is a string + if (typeof key !== "string") { + return false; + } + // Validate value structure using the cycle schema + try { + makeReferralProgramCycleSchema(`${valueLabel}[${key}]`).parse(value); + } catch { + return false; + } + } + return true; + }, + { + message: `${valueLabel} must be a Map`, + }, + ); diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 8e21a179d..053050dc6 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -1,11 +1,11 @@ import { - deserializeReferrerDetailResponse, + deserializeReferrerDetailAllCyclesResponse, deserializeReferrerLeaderboardPageResponse, + type ReferrerDetailAllCyclesResponse, type ReferrerDetailRequest, - type ReferrerDetailResponse, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, - type SerializedReferrerDetailResponse, + type SerializedReferrerDetailAllCyclesResponse, type SerializedReferrerLeaderboardPageResponse, } from "./api"; @@ -72,10 +72,12 @@ export class ENSReferralsClient { /** * Fetch Referrer Leaderboard Page * - * Retrieves a paginated list of referrer leaderboard metrics with contribution percentages. - * Each referrer's contribution is calculated as a percentage of the grand totals across all referrers. + * Retrieves a paginated list of referrer leaderboard metrics for a specific referral program cycle. + * Each referrer's contribution is calculated as a percentage of the grand totals across all referrers + * within that cycle. * - * @param request - Pagination parameters + * @param request - Request parameters including cycle and pagination + * @param request.cycle - The referral program cycle ID (e.g., "cycle-1", "cycle-2", or custom cycle ID) * @param request.page - The page number to retrieve (1-indexed, default: 1) * @param request.recordsPerPage - Number of records per page (default: 25, max: 100) * @returns {ReferrerLeaderboardPageResponse} @@ -86,34 +88,36 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Get first page with default page size (25 records) - * const response = await client.getReferrerLeaderboardPage(); + * // Get first page of cycle-1 leaderboard with default page size (25 records) + * const response = await client.getReferrerLeaderboardPage({ cycle: "cycle-1" }); * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Ok) { * const { * aggregatedMetrics, * referrers, * rules, * pageContext, - * updatedAt + * accurateAsOf * } = response.data; - * console.log(aggregatedMetrics); - * console.log(referrers); - * console.log(rules); - * console.log(updatedAt); + * console.log(`Cycle: ${rules.cycleId}`); + * console.log(`Total Referrers: ${pageContext.totalRecords}`); * console.log(`Page ${pageContext.page} of ${pageContext.totalPages}`); * } * ``` * * @example * ```typescript - * // Get second page with 50 records per page - * const response = await client.getReferrerLeaderboardPage({ page: 2, recordsPerPage: 50 }); + * // Get second page of cycle-2 with 50 records per page + * const response = await client.getReferrerLeaderboardPage({ + * cycle: "cycle-2", + * page: 2, + * recordsPerPage: 50 + * }); * ``` * * @example * ```typescript - * // Handle error response, ie. when Referrer Leaderboard is not currently available. - * const response = await client.getReferrerLeaderboardPage(); + * // Handle error response (e.g., unknown cycle or data not available) + * const response = await client.getReferrerLeaderboardPage({ cycle: "cycle-1" }); * * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { * console.error(response.error); @@ -122,12 +126,13 @@ export class ENSReferralsClient { * ``` */ async getReferrerLeaderboardPage( - request?: ReferrerLeaderboardPageRequest, + request: ReferrerLeaderboardPageRequest, ): Promise { - const url = new URL(`/v1/ensanalytics/referrers`, this.options.url); + const url = new URL(`/v1/ensanalytics/referral-leaderboard`, this.options.url); - if (request?.page) url.searchParams.set("page", request.page.toString()); - if (request?.recordsPerPage) + url.searchParams.set("cycle", request.cycle); + if (request.page) url.searchParams.set("page", request.page.toString()); + if (request.recordsPerPage) url.searchParams.set("recordsPerPage", request.recordsPerPage.toString()); const response = await fetch(url); @@ -141,8 +146,8 @@ export class ENSReferralsClient { throw new Error("Malformed response data: invalid JSON"); } - // The API can return errors with 500 status, but they're still in the - // PaginatedAggregatedReferrersResponse format with responseCode: 'error' + // The API can return errors with various status codes, but they're still in the + // ReferrerLeaderboardPageResponse format with responseCode: 'error' // So we don't need to check response.ok here, just deserialize and let // the caller handle the responseCode @@ -152,84 +157,98 @@ export class ENSReferralsClient { } /** - * Fetch Referrer Detail + * Fetch Referrer Detail Across All Cycles * - * Retrieves detailed information about a specific referrer, whether they are on the - * leaderboard or not. + * Retrieves detailed information about a specific referrer across all configured + * referral program cycles. Returns a record mapping each cycle ID to the referrer's + * detail for that cycle. * - * The response data is a discriminated union type with a `type` field: + * The response data maps cycle IDs to referrer details. Each cycle's data is a + * discriminated union type with a `type` field: * * **For referrers on the leaderboard** (`ReferrerDetailRanked`): * - `type`: {@link ReferrerDetailTypeIds.Ranked} - * - `referrer`: The `AwardedReferrerMetrics` from @namehash/ens-referrals - * - `rules`: The referral program rules + * - `referrer`: The `AwardedReferrerMetrics` with rank, qualification status, and award share + * - `rules`: The referral program rules for this cycle * - `aggregatedMetrics`: Aggregated metrics for all referrers on the leaderboard * - `accurateAsOf`: Unix timestamp indicating when the data was last updated * * **For referrers NOT on the leaderboard** (`ReferrerDetailUnranked`): * - `type`: {@link ReferrerDetailTypeIds.Unranked} * - `referrer`: The `UnrankedReferrerMetrics` from @namehash/ens-referrals - * - `rules`: The referral program rules + * - `rules`: The referral program rules for this cycle * - `aggregatedMetrics`: Aggregated metrics for all referrers on the leaderboard * - `accurateAsOf`: Unix timestamp indicating when the data was last updated * + * **Note:** The API uses a fail-fast approach. If ANY cycle fails to load, the entire request + * returns an error. When `responseCode === Ok`, ALL configured cycles are guaranteed to be + * present in the response data. + * * @see {@link https://www.npmjs.com/package/@namehash/ens-referrals|@namehash/ens-referrals} for calculation details * * @param request The referrer address to query - * @returns {ReferrerDetailResponse} Returns the referrer detail response + * @returns {ReferrerDetailAllCyclesResponse} Returns the referrer detail for all cycles * * @throws if the ENSNode request fails * @throws if the response data is malformed * * @example * ```typescript - * // Get referrer detail for a specific address + * // Get referrer detail across all cycles * const response = await client.getReferrerDetail({ * referrer: "0x1234567890123456789012345678901234567890" * }); - * if (response.responseCode === ReferrerDetailResponseCodes.Ok) { - * const { type, referrer, rules, aggregatedMetrics, accurateAsOf } = response.data; - * console.log(type); // ReferrerDetailTypeIds.Ranked or ReferrerDetailTypeIds.Unranked - * console.log(referrer); - * console.log(accurateAsOf); + * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { + * // All configured cycles are present in response.data + * for (const [cycleId, detail] of Object.entries(response.data)) { + * console.log(`Cycle: ${cycleId}`); + * console.log(`Type: ${detail.type}`); + * if (detail.type === ReferrerDetailTypeIds.Ranked) { + * console.log(`Rank: ${detail.referrer.rank}`); + * console.log(`Award Share: ${detail.referrer.awardPoolShare * 100}%`); + * } + * } * } * ``` * * @example * ```typescript - * // Use discriminated union to check if referrer is ranked + * // Access specific cycle data directly (cycle is guaranteed to exist when OK) * const response = await client.getReferrerDetail({ * referrer: "0x1234567890123456789012345678901234567890" * }); - * if (response.responseCode === ReferrerDetailResponseCodes.Ok) { - * if (response.data.type === ReferrerDetailTypeIds.Ranked) { + * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { + * // If "cycle-1" is configured, it will be in response.data + * const cycle1Detail = response.data["cycle-1"]; + * if (cycle1Detail.type === ReferrerDetailTypeIds.Ranked) { * // TypeScript knows this is ReferrerDetailRanked - * console.log(`Rank: ${response.data.referrer.rank}`); - * console.log(`Qualified: ${response.data.referrer.isQualified}`); - * console.log(`Award Pool Share: ${response.data.referrer.awardPoolShare * 100}%`); + * console.log(`Cycle 1 Rank: ${cycle1Detail.referrer.rank}`); * } else { * // TypeScript knows this is ReferrerDetailUnranked - * console.log("Referrer is not on the leaderboard (no referrals yet)"); + * console.log("Referrer is not on the leaderboard for cycle-1"); * } * } * ``` * * @example * ```typescript - * // Handle error response, ie. when Referrer Detail is not currently available. + * // Handle error response (e.g., a cycle failed to load) * const response = await client.getReferrerDetail({ * referrer: "0x1234567890123456789012345678901234567890" * }); * - * if (response.responseCode === ReferrerDetailResponseCodes.Error) { + * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Error) { * console.error(response.error); + * // Error message includes which cycle failed * console.error(response.errorMessage); * } * ``` */ - async getReferrerDetail(request: ReferrerDetailRequest): Promise { + async getReferrerDetail( + request: ReferrerDetailRequest, + ): Promise { const url = new URL( - `/v1/ensanalytics/referrers/${encodeURIComponent(request.referrer)}`, + `/v1/ensanalytics/referral-leaderboard/${encodeURIComponent(request.referrer)}`, this.options.url, ); @@ -244,11 +263,13 @@ export class ENSReferralsClient { throw new Error("Malformed response data: invalid JSON"); } - // The API can return errors with 500 status, but they're still in the - // ReferrerDetailResponse format with responseCode: 'error' + // The API can return errors with various status codes, but they're still in the + // ReferrerDetailAllCyclesResponse format with responseCode: 'error' // So we don't need to check response.ok here, just deserialize and let // the caller handle the responseCode - return deserializeReferrerDetailResponse(responseData as SerializedReferrerDetailResponse); + return deserializeReferrerDetailAllCyclesResponse( + responseData as SerializedReferrerDetailAllCyclesResponse, + ); } } diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/cycle-defaults.ts new file mode 100644 index 000000000..9409f2662 --- /dev/null +++ b/packages/ens-referrals/src/v1/cycle-defaults.ts @@ -0,0 +1,111 @@ +import { mainnet } from "viem/chains"; + +import { priceUsdc, type UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import { + type ReferralProgramCycle, + ReferralProgramCycleIds, + type ReferralProgramCycleSet, +} from "./cycle"; +import { buildReferralProgramRules } from "./rules"; + +/** + * Configuration for Cycle 1: ENS Holiday Awards (December 2025) + */ +const CYCLE_1_CONFIG = { + /** + * Start date for the ENS Holiday Awards referral program. + * 2025-12-01T00:00:00Z (December 1, 2025 at 00:00:00 UTC) + */ + START_DATE: 1764547200 as UnixTimestamp, + + /** + * End date for the ENS Holiday Awards referral program. + * 2025-12-31T23:59:59Z (December 31, 2025 at 23:59:59 UTC) + */ + END_DATE: 1767225599 as UnixTimestamp, + + /** + * The maximum number of qualified referrers. + */ + MAX_QUALIFIED_REFERRERS: 10, + + /** + * The total value of the award pool in USDC. + * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) + */ + TOTAL_AWARD_POOL_VALUE: priceUsdc(10_000_000_000n), +} as const; + +/** + * Configuration for Cycle 2: March 2026 + */ +const CYCLE_2_CONFIG = { + /** + * Start date for the March 2026 referral program. + * 2026-03-01T00:00:00Z (March 1, 2026 at 00:00:00 UTC) + */ + START_DATE: 1772524800 as UnixTimestamp, + + /** + * End date for the March 2026 referral program. + * 2026-03-31T23:59:59Z (March 31, 2026 at 23:59:59 UTC) + */ + END_DATE: 1775116799 as UnixTimestamp, + + /** + * The maximum number of qualified referrers. + */ + MAX_QUALIFIED_REFERRERS: 10, + + /** + * The total value of the award pool in USDC. + * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) + */ + TOTAL_AWARD_POOL_VALUE: priceUsdc(10_000_000_000n), +} as const; + +/** + * Returns the default referral program cycle set with pre-built cycle definitions. + * + * @param subregistryAddress - The subregistry address for rule validation (e.g., BaseRegistrar address) + * @returns A map of cycle IDs to their pre-built cycle configurations + */ +export function getReferralProgramCycleSet( + subregistryAddress: `0x${string}`, +): ReferralProgramCycleSet { + const subregistryId = { chainId: mainnet.id, address: subregistryAddress }; + + // Pre-built cycle-1 object (ENS Holiday Awards Dec 2025) + const cycle1: ReferralProgramCycle = { + id: ReferralProgramCycleIds.Cycle1, + displayName: "ENS Holiday Awards", + rules: buildReferralProgramRules( + CYCLE_1_CONFIG.TOTAL_AWARD_POOL_VALUE, + CYCLE_1_CONFIG.MAX_QUALIFIED_REFERRERS, + CYCLE_1_CONFIG.START_DATE, + CYCLE_1_CONFIG.END_DATE, + subregistryId, + ), + rulesUrl: "https://ensawards.org/ens-holiday-awards-rules", + }; + + // Pre-built cycle-2 object (March 2026) + const cycle2: ReferralProgramCycle = { + id: ReferralProgramCycleIds.Cycle2, + displayName: "March 2026", + rules: buildReferralProgramRules( + CYCLE_2_CONFIG.TOTAL_AWARD_POOL_VALUE, + CYCLE_2_CONFIG.MAX_QUALIFIED_REFERRERS, + CYCLE_2_CONFIG.START_DATE, + CYCLE_2_CONFIG.END_DATE, + subregistryId, + ), + rulesUrl: "https://ensawards.org/march-2026-rules", + }; + + return new Map([ + [ReferralProgramCycleIds.Cycle1, cycle1], + [ReferralProgramCycleIds.Cycle2, cycle2], + ]); +} diff --git a/packages/ens-referrals/src/v1/cycle.ts b/packages/ens-referrals/src/v1/cycle.ts new file mode 100644 index 000000000..7b4ba28ff --- /dev/null +++ b/packages/ens-referrals/src/v1/cycle.ts @@ -0,0 +1,77 @@ +import type { ReferralProgramRules } from "./rules"; + +/** + * Referral program cycle identifiers. + * + * Each cycle represents a distinct referral program period with its own + * rules, leaderboard, and award distribution. + */ +export const ReferralProgramCycleIds = { + /** ENS Holiday Awards December 2025 */ + Cycle1: "cycle-1", + /** March 2026 */ + Cycle2: "cycle-2", +} as const; + +/** + * Referral program cycle identifier. + * + * Can be either a predefined cycle ID (e.g., "cycle-1", "cycle-2") or a custom cycle ID. + * The type provides autocomplete for known cycle IDs while accepting any string for custom cycles. + */ +export type ReferralProgramCycleId = + | (typeof ReferralProgramCycleIds)[keyof typeof ReferralProgramCycleIds] + | (string & {}); + +/** + * Array of all valid referral program cycle IDs. + */ +export const ALL_REFERRAL_PROGRAM_CYCLE_IDS: ReferralProgramCycleId[] = + Object.values(ReferralProgramCycleIds); + +/** + * Type guard to check if a string is a predefined {@link ReferralProgramCycleId}. + * + * Note: This only checks for predefined cycle IDs (e.g., "cycle-1", "cycle-2"). + * Custom cycle IDs loaded from CUSTOM_REFERRAL_PROGRAM_CYCLES are valid + * ReferralProgramCycleIds but won't pass this check. + * + * @param value - The string value to check + * @returns true if the value is a predefined cycle ID + */ +export const isPredefinedCycleId = (value: string): value is ReferralProgramCycleId => + ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as ReferralProgramCycleId); + +/** + * Represents a referral program cycle with its configuration and rules. + */ +export interface ReferralProgramCycle { + /** + * Unique identifier for the cycle. + */ + id: ReferralProgramCycleId; + + /** + * Human-readable display name for the cycle. + * @example "ENS Holiday Awards" + */ + displayName: string; + + /** + * The rules that govern this referral program cycle. + */ + rules: ReferralProgramRules; + + /** + * URL to the full rules document for this cycle. + * @example "https://ensawards.org/ens-holiday-awards-rules" + */ + rulesUrl: string; +} + +/** + * A map from cycle ID to cycle definition. + * + * Used to store and look up all configured referral program cycles. + */ +export type ReferralProgramCycleSet = Map; diff --git a/packages/ens-referrals/src/v1/index.ts b/packages/ens-referrals/src/v1/index.ts index 9ef2b215a..ec911a690 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -2,6 +2,8 @@ export * from "./address"; export * from "./aggregations"; export * from "./api"; export * from "./client"; +export * from "./cycle"; +export * from "./cycle-defaults"; export * from "./leaderboard"; export * from "./leaderboard-page"; export * from "./link"; diff --git a/packages/ens-referrals/src/v1/rules.ts b/packages/ens-referrals/src/v1/rules.ts index 3e6b52eda..cad1fae05 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -1,37 +1,9 @@ -import { - type AccountId, - type PriceUsdc, - priceUsdc, - type UnixTimestamp, -} from "@ensnode/ensnode-sdk"; +import type { AccountId, PriceUsdc, UnixTimestamp } from "@ensnode/ensnode-sdk"; import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; import { validateNonNegativeInteger } from "./number"; import { validateUnixTimestamp } from "./time"; -/** - * Start date for the ENS Holiday Awards referral program. - * 2025-12-01T00:00:00Z (December 1, 2025 at 00:00:00 UTC) - */ -export const ENS_HOLIDAY_AWARDS_START_DATE: UnixTimestamp = 1764547200; - -/** - * End date for the ENS Holiday Awards referral program. - * 2025-12-31T23:59:59Z (December 31, 2025 at 23:59:59 UTC) - */ -export const ENS_HOLIDAY_AWARDS_END_DATE: UnixTimestamp = 1767225599; - -/** - * The maximum number of qualified referrers for ENS Holiday Awards. - */ -export const ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS = 10; - -/** - * The total value of the award pool in USDC. - * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) - */ -export const ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE: PriceUsdc = priceUsdc(10_000_000_000n); - export interface ReferralProgramRules { /** * The total value of the award pool in USDC. diff --git a/packages/ensnode-sdk/src/shared/config/environments.ts b/packages/ensnode-sdk/src/shared/config/environments.ts index 3344bfc42..52777baeb 100644 --- a/packages/ensnode-sdk/src/shared/config/environments.ts +++ b/packages/ensnode-sdk/src/shared/config/environments.ts @@ -53,11 +53,15 @@ export type TheGraphEnvironment = { }; /** - * Environment variables for ENS Holiday Awards date range configuration. + * Environment variables for referral program cycles configuration. * - * Dates must be specified in ISO 8601 format (e.g., '2025-12-01T00:00:00Z'). + * If CUSTOM_REFERRAL_PROGRAM_CYCLES is set, it should be a URL pointing to + * a JSON file containing custom cycle definitions. */ -export interface EnsHolidayAwardsEnvironment { - ENS_HOLIDAY_AWARDS_START?: string; - ENS_HOLIDAY_AWARDS_END?: string; +export interface ReferralProgramCyclesEnvironment { + /** + * Optional URL to a JSON file containing custom referral program cycle definitions. + * If not set, the default cycle set will be used. + */ + CUSTOM_REFERRAL_PROGRAM_CYCLES?: string; } From 8d0f65b5c6c48a3f221e9fb7b8d72e48f70d5f61 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 3 Feb 2026 16:14:08 +0100 Subject: [PATCH 02/16] typecheck fixed --- .../src/handlers/ensanalytics-api-v1.test.ts | 78 ++++++++++--------- .../ens-referrals/src/v1/api/deserialize.ts | 5 +- .../ens-referrals/src/v1/api/serialize.ts | 5 +- .../src/v1/api/serialized-types.ts | 12 ++- packages/ens-referrals/src/v1/api/types.ts | 7 +- 5 files changed, 62 insertions(+), 45 deletions(-) diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index a20e1bf52..b16e6b267 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -291,31 +291,32 @@ describe("/ensanalytics/v1", () => { expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Ok); if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { + const cycle1 = response.data["cycle-1"]!; + const cycle2 = response.data["cycle-2"]!; + // Check cycle-1 - expect(response.data["cycle-1"].type).toBe(ReferrerDetailTypeIds.Unranked); - expect(response.data["cycle-1"].rules).toEqual(populatedReferrerLeaderboard.rules); - expect(response.data["cycle-1"].aggregatedMetrics).toEqual( - populatedReferrerLeaderboard.aggregatedMetrics, - ); - expect(response.data["cycle-1"].referrer.referrer).toBe(nonExistingReferrer); - expect(response.data["cycle-1"].referrer.rank).toBe(null); - expect(response.data["cycle-1"].referrer.totalReferrals).toBe(0); - expect(response.data["cycle-1"].referrer.totalIncrementalDuration).toBe(0); - expect(response.data["cycle-1"].referrer.score).toBe(0); - expect(response.data["cycle-1"].referrer.isQualified).toBe(false); - expect(response.data["cycle-1"].referrer.finalScoreBoost).toBe(0); - expect(response.data["cycle-1"].referrer.finalScore).toBe(0); - expect(response.data["cycle-1"].referrer.awardPoolShare).toBe(0); - expect(response.data["cycle-1"].referrer.awardPoolApproxValue).toStrictEqual({ + expect(cycle1.type).toBe(ReferrerDetailTypeIds.Unranked); + expect(cycle1.rules).toEqual(populatedReferrerLeaderboard.rules); + expect(cycle1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); + expect(cycle1.referrer.referrer).toBe(nonExistingReferrer); + expect(cycle1.referrer.rank).toBe(null); + expect(cycle1.referrer.totalReferrals).toBe(0); + expect(cycle1.referrer.totalIncrementalDuration).toBe(0); + expect(cycle1.referrer.score).toBe(0); + expect(cycle1.referrer.isQualified).toBe(false); + expect(cycle1.referrer.finalScoreBoost).toBe(0); + expect(cycle1.referrer.finalScore).toBe(0); + expect(cycle1.referrer.awardPoolShare).toBe(0); + expect(cycle1.referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); - expect(response.data["cycle-1"].accurateAsOf).toBe(expectedAccurateAsOf); + expect(cycle1.accurateAsOf).toBe(expectedAccurateAsOf); // Check cycle-2 - expect(response.data["cycle-2"].type).toBe(ReferrerDetailTypeIds.Unranked); - expect(response.data["cycle-2"].referrer.referrer).toBe(nonExistingReferrer); - expect(response.data["cycle-2"].referrer.rank).toBe(null); + expect(cycle2.type).toBe(ReferrerDetailTypeIds.Unranked); + expect(cycle2.referrer.referrer).toBe(nonExistingReferrer); + expect(cycle2.referrer.rank).toBe(null); } }); @@ -354,31 +355,32 @@ describe("/ensanalytics/v1", () => { expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Ok); if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { + const cycle1 = response.data["cycle-1"]!; + const cycle2 = response.data["cycle-2"]!; + // Check cycle-1 - expect(response.data["cycle-1"].type).toBe(ReferrerDetailTypeIds.Unranked); - expect(response.data["cycle-1"].rules).toEqual(emptyReferralLeaderboard.rules); - expect(response.data["cycle-1"].aggregatedMetrics).toEqual( - emptyReferralLeaderboard.aggregatedMetrics, - ); - expect(response.data["cycle-1"].referrer.referrer).toBe(referrer); - expect(response.data["cycle-1"].referrer.rank).toBe(null); - expect(response.data["cycle-1"].referrer.totalReferrals).toBe(0); - expect(response.data["cycle-1"].referrer.totalIncrementalDuration).toBe(0); - expect(response.data["cycle-1"].referrer.score).toBe(0); - expect(response.data["cycle-1"].referrer.isQualified).toBe(false); - expect(response.data["cycle-1"].referrer.finalScoreBoost).toBe(0); - expect(response.data["cycle-1"].referrer.finalScore).toBe(0); - expect(response.data["cycle-1"].referrer.awardPoolShare).toBe(0); - expect(response.data["cycle-1"].referrer.awardPoolApproxValue).toStrictEqual({ + expect(cycle1.type).toBe(ReferrerDetailTypeIds.Unranked); + expect(cycle1.rules).toEqual(emptyReferralLeaderboard.rules); + expect(cycle1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); + expect(cycle1.referrer.referrer).toBe(referrer); + expect(cycle1.referrer.rank).toBe(null); + expect(cycle1.referrer.totalReferrals).toBe(0); + expect(cycle1.referrer.totalIncrementalDuration).toBe(0); + expect(cycle1.referrer.score).toBe(0); + expect(cycle1.referrer.isQualified).toBe(false); + expect(cycle1.referrer.finalScoreBoost).toBe(0); + expect(cycle1.referrer.finalScore).toBe(0); + expect(cycle1.referrer.awardPoolShare).toBe(0); + expect(cycle1.referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); - expect(response.data["cycle-1"].accurateAsOf).toBe(expectedAccurateAsOf); + expect(cycle1.accurateAsOf).toBe(expectedAccurateAsOf); // Check cycle-2 - expect(response.data["cycle-2"].type).toBe(ReferrerDetailTypeIds.Unranked); - expect(response.data["cycle-2"].referrer.referrer).toBe(referrer); - expect(response.data["cycle-2"].referrer.rank).toBe(null); + expect(cycle2.type).toBe(ReferrerDetailTypeIds.Unranked); + expect(cycle2.referrer.referrer).toBe(referrer); + expect(cycle2.referrer.rank).toBe(null); } }); diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index b5e351840..4823816bc 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -235,7 +235,10 @@ export function deserializeReferrerDetailAllCyclesResponse( const data: ReferrerDetailAllCyclesData = {} as ReferrerDetailAllCyclesData; for (const [cycleId, detail] of Object.entries(maybeResponse.data)) { - data[cycleId as ReferralProgramCycleId] = deserializeReferrerDetail(detail); + // Object.entries only returns existing entries, so detail is never undefined at runtime + data[cycleId as ReferralProgramCycleId] = deserializeReferrerDetail( + detail as SerializedReferrerDetail, + ); } deserialized = { diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 383e4a9d1..a4c448b41 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -204,7 +204,10 @@ export function serializeReferrerDetailAllCyclesResponse( {} as SerializedReferrerDetailAllCyclesData; for (const [cycleId, detail] of Object.entries(response.data)) { - serializedData[cycleId as ReferralProgramCycleId] = serializeReferrerDetail(detail); + // Object.entries only returns existing entries, so detail is never undefined at runtime + serializedData[cycleId as ReferralProgramCycleId] = serializeReferrerDetail( + detail as ReferrerDetail, + ); } return { diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 392dbc112..d6997abbb 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -117,10 +117,14 @@ export interface SerializedReferralProgramCycle extends Omit >; /** diff --git a/packages/ens-referrals/src/v1/api/types.ts b/packages/ens-referrals/src/v1/api/types.ts index 593a309dc..403cbece9 100644 --- a/packages/ens-referrals/src/v1/api/types.ts +++ b/packages/ens-referrals/src/v1/api/types.ts @@ -93,8 +93,13 @@ export type ReferrerDetailAllCyclesResponseCode = * Referrer detail data across all cycles. * * Maps each cycle ID to the referrer's detail for that cycle. + * Uses Partial because the set of cycles includes both predefined cycles + * (e.g., "cycle-1", "cycle-2") and any custom cycles loaded from configuration. + * All configured cycles will have entries in the response (even if empty for + * referrers who haven't participated), but TypeScript cannot know at compile + * time which specific cycles are configured. */ -export type ReferrerDetailAllCyclesData = Record; +export type ReferrerDetailAllCyclesData = Partial>; /** * A successful response containing referrer detail for all cycles. From 87a9f79d270dccfaa2b500657a525aa0837bb309 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 3 Feb 2026 17:20:17 +0100 Subject: [PATCH 03/16] fixes + changesets --- .changeset/clever-laws-count.md | 5 ++++ apps/ensapi/.env.local.example | 3 ++ .../ens-referrals/src/v1/api/deserialize.ts | 4 +++ .../ens-referrals/src/v1/api/serialize.ts | 28 ++++++++++++------- .../ens-referrals/src/v1/api/zod-schemas.ts | 5 +++- packages/ens-referrals/src/v1/client.ts | 13 +++++---- .../ens-referrals/src/v1/cycle-defaults.ts | 4 +-- 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/.changeset/clever-laws-count.md b/.changeset/clever-laws-count.md index a845151cc..dfbee2542 100644 --- a/.changeset/clever-laws-count.md +++ b/.changeset/clever-laws-count.md @@ -1,2 +1,7 @@ --- +"@namehash/ens-referrals": minor +"ensapi": minor +"@ensnode/ensnode-sdk": patch --- + +Introduces referral program cycles support with pre-configured cycle definitions (ENS Holiday Awards December 2025, March 2026 cycle). Updated ENSAnalytics API v1 to support cycle-based leaderboard queries and added cycle configuration to environment schema. diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index f04d439fb..43aa2957e 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -116,6 +116,9 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # URL to a JSON file containing custom referral program cycle definitions. # If not set, the default cycle set is used. # +# Note: Setting CUSTOM_REFERRAL_PROGRAM_CYCLES replaces the default cycle set. +# Include all cycles you want active in the JSON file. +# # The JSON file should contain an array of cycle objects with the following structure: # [ # { diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index 4823816bc..2ac4960e7 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -161,6 +161,10 @@ function deserializeReferrerDetail(detail: SerializedReferrerDetail): ReferrerDe return deserializeReferrerDetailRanked(detail); case "unranked": return deserializeReferrerDetailUnranked(detail); + default: { + const _exhaustiveCheck: never = detail; + throw new Error(`Unknown detail type: ${(_exhaustiveCheck as ReferrerDetail).type}`); + } } } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index a4c448b41..d7788f987 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,7 +1,7 @@ import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramCycle, ReferralProgramCycleId } from "../cycle"; +import type { ReferralProgramCycle } from "../cycle"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { ReferrerDetail, @@ -157,6 +157,10 @@ function serializeReferrerDetail(detail: ReferrerDetail): SerializedReferrerDeta return serializeReferrerDetailRanked(detail); case "unranked": return serializeReferrerDetailUnranked(detail); + default: { + const _exhaustiveCheck: never = detail; + throw new Error(`Unknown detail type: ${(_exhaustiveCheck as ReferrerDetail).type}`); + } } } @@ -200,15 +204,12 @@ export function serializeReferrerDetailAllCyclesResponse( ): SerializedReferrerDetailAllCyclesResponse { switch (response.responseCode) { case ReferrerDetailAllCyclesResponseCodes.Ok: { - const serializedData: SerializedReferrerDetailAllCyclesData = - {} as SerializedReferrerDetailAllCyclesData; - - for (const [cycleId, detail] of Object.entries(response.data)) { - // Object.entries only returns existing entries, so detail is never undefined at runtime - serializedData[cycleId as ReferralProgramCycleId] = serializeReferrerDetail( - detail as ReferrerDetail, - ); - } + const serializedData = Object.fromEntries( + Object.entries(response.data).map(([cycleId, detail]) => [ + cycleId, + serializeReferrerDetail(detail as ReferrerDetail), + ]), + ) as SerializedReferrerDetailAllCyclesData; return { responseCode: response.responseCode, @@ -218,5 +219,12 @@ export function serializeReferrerDetailAllCyclesResponse( case ReferrerDetailAllCyclesResponseCodes.Error: return response; + + default: { + const _exhaustiveCheck: never = response; + throw new Error( + `Unknown response code: ${(_exhaustiveCheck as ReferrerDetailAllCyclesResponse).responseCode}`, + ); + } } } diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 951a5fc10..a9b2be6f0 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -293,7 +293,10 @@ export const makeReferralProgramCycleSetSchema = (valueLabel: string = "Referral } // Validate value structure using the cycle schema try { - makeReferralProgramCycleSchema(`${valueLabel}[${key}]`).parse(value); + const parsed = makeReferralProgramCycleSchema(`${valueLabel}[${key}]`).parse(value); + if (parsed.id !== key) { + return false; + } } catch { return false; } diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 053050dc6..aebddd2eb 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -32,8 +32,9 @@ export interface ClientOptions { * // Create client with default options * const client = new ENSReferralsClient(); * - * // Get referrer leaderboard + * // Get referrer leaderboard for cycle-1 * const leaderboardPage = await client.getReferrerLeaderboardPage({ + * cycle: "cycle-1", * page: 1, * recordsPerPage: 25 * }); @@ -89,7 +90,8 @@ export class ENSReferralsClient { * @example * ```typescript * // Get first page of cycle-1 leaderboard with default page size (25 records) - * const response = await client.getReferrerLeaderboardPage({ cycle: "cycle-1" }); + * const cycleId = "cycle-1"; + * const response = await client.getReferrerLeaderboardPage({ cycle: cycleId }); * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Ok) { * const { * aggregatedMetrics, @@ -98,7 +100,8 @@ export class ENSReferralsClient { * pageContext, * accurateAsOf * } = response.data; - * console.log(`Cycle: ${rules.cycleId}`); + * console.log(`Cycle: ${cycleId}`); + * console.log(`Subregistry: ${rules.subregistryId}`); * console.log(`Total Referrers: ${pageContext.totalRecords}`); * console.log(`Page ${pageContext.page} of ${pageContext.totalPages}`); * } @@ -220,10 +223,10 @@ export class ENSReferralsClient { * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { * // If "cycle-1" is configured, it will be in response.data * const cycle1Detail = response.data["cycle-1"]; - * if (cycle1Detail.type === ReferrerDetailTypeIds.Ranked) { + * if (cycle1Detail && cycle1Detail.type === ReferrerDetailTypeIds.Ranked) { * // TypeScript knows this is ReferrerDetailRanked * console.log(`Cycle 1 Rank: ${cycle1Detail.referrer.rank}`); - * } else { + * } else if (cycle1Detail) { * // TypeScript knows this is ReferrerDetailUnranked * console.log("Referrer is not on the leaderboard for cycle-1"); * } diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/cycle-defaults.ts index 9409f2662..27c16eb9c 100644 --- a/packages/ens-referrals/src/v1/cycle-defaults.ts +++ b/packages/ens-referrals/src/v1/cycle-defaults.ts @@ -45,13 +45,13 @@ const CYCLE_2_CONFIG = { * Start date for the March 2026 referral program. * 2026-03-01T00:00:00Z (March 1, 2026 at 00:00:00 UTC) */ - START_DATE: 1772524800 as UnixTimestamp, + START_DATE: 1772323200 as UnixTimestamp, /** * End date for the March 2026 referral program. * 2026-03-31T23:59:59Z (March 31, 2026 at 23:59:59 UTC) */ - END_DATE: 1775116799 as UnixTimestamp, + END_DATE: 1775001599 as UnixTimestamp, /** * The maximum number of qualified referrers. From ca648749d4ea79c7ba2826c3e5b4afe23a9847d0 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 3 Feb 2026 17:52:09 +0100 Subject: [PATCH 04/16] review fixes --- apps/ensapi/.env.local.example | 9 ++++++++- packages/ens-referrals/src/v1/api/zod-schemas.ts | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 43aa2957e..d9d44be30 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -125,7 +125,10 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # "id": "cycle-1", # "displayName": "ENS Holiday Awards", # "rules": { -# "totalAwardPoolValue": "10000000000", +# "totalAwardPoolValue": { +# "amount": "10000000000", +# "currency": "USDC" +# }, # "maxQualifiedReferrers": 10, # "startTime": 1764547200, # "endTime": 1767225599, @@ -138,4 +141,8 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # } # ] # +# Note: totalAwardPoolValue.amount is specified in the smallest units of USDC, +# where 1 USDC = 1,000,000 smallest units (6 decimals). +# Example: "10000000000" = 10,000 USDC +# # CUSTOM_REFERRAL_PROGRAM_CYCLES=https://example.com/cycles.json diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index a9b2be6f0..6be41167a 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -283,6 +283,9 @@ export const makeReferralProgramCycleSetSchema = (valueLabel: string = "Referral .instanceof(Map, { message: `${valueLabel} must be a Map`, }) + .refine((map) => map.size >= 1, { + message: `${valueLabel} must contain at least one cycle`, + }) .refine( (map): map is Map => { // Validate each entry in the map From 6791b56966825b8999697a84de4423e3d345302f Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 3 Feb 2026 18:01:59 +0100 Subject: [PATCH 05/16] uniqueness check --- packages/ens-referrals/src/v1/api/zod-schemas.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 6be41167a..8269dff65 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -273,7 +273,18 @@ export const makeCustomReferralProgramCyclesSchema = ( ) => z .array(makeReferralProgramCycleSchema(`${valueLabel}[cycle]`)) - .min(1, `${valueLabel} must contain at least one cycle`); + .min(1, `${valueLabel} must contain at least one cycle`) + .refine( + (cycles) => { + const ids = new Set(); + for (const cycle of cycles) { + if (ids.has(cycle.id)) return false; + ids.add(cycle.id); + } + return true; + }, + { message: `${valueLabel} must not contain duplicate cycle ids` }, + ); /** * Schema for validating a {@link ReferralProgramCycleSet} (Map structure). From 3731d72cf1b1e7f69150f69165fff591068545a3 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 3 Feb 2026 19:23:46 +0100 Subject: [PATCH 06/16] microfix --- apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index b16e6b267..3734d7660 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -44,7 +44,7 @@ import { import app from "./ensanalytics-api-v1"; -describe("/ensanalytics/v1", () => { +describe("/v1/ensanalytics", () => { describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { // Arrange: mock cache map with cycle-1 From 3a1954d934dc1108d8e89ab199960ad901ee3e37 Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 4 Feb 2026 01:22:18 +0100 Subject: [PATCH 07/16] fix + better logging --- apps/ensapi/src/config/config.schema.ts | 25 ++++++++++++++++--- apps/ensapi/src/config/redact.ts | 9 +++++++ .../ens-referrals/src/v1/cycle-defaults.ts | 9 +++---- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 85d3f6c97..56068c2df 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -88,7 +88,7 @@ async function loadReferralProgramCycleSet( if (!customCyclesUrl) { logger.info("Using default referral program cycle set"); - return getReferralProgramCycleSet(subregistryId.address); + return getReferralProgramCycleSet(subregistryId); } // Validate URL format @@ -100,14 +100,33 @@ async function loadReferralProgramCycleSet( // Fetch and validate logger.info(`Fetching custom referral program cycles from: ${customCyclesUrl}`); - const response = await fetch(customCyclesUrl); + + let response: Response; + try { + response = await fetch(customCyclesUrl); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to fetch custom referral program cycles from ${customCyclesUrl}: ${errorMessage}. ` + + `Please verify the URL is accessible and the server is running.`, + ); + } + if (!response.ok) { throw new Error( `Failed to fetch custom referral program cycles from ${customCyclesUrl}: ${response.status} ${response.statusText}`, ); } - const json = await response.json(); + let json: unknown; + try { + json = await response.json(); + } catch (error) { + throw new Error( + `Failed to parse JSON from ${customCyclesUrl}: The response is not valid JSON. ` + + `Please verify the file contains valid JSON.`, + ); + } const schema = makeCustomReferralProgramCyclesSchema("CUSTOM_REFERRAL_PROGRAM_CYCLES"); const validated = schema.parse(json); diff --git a/apps/ensapi/src/config/redact.ts b/apps/ensapi/src/config/redact.ts index c26730cac..ab8a6d27e 100644 --- a/apps/ensapi/src/config/redact.ts +++ b/apps/ensapi/src/config/redact.ts @@ -1,3 +1,5 @@ +import { serializeReferralProgramCycle } from "@namehash/ens-referrals/v1"; + import { redactRpcConfigs, redactString } from "@ensnode/ensnode-sdk/internal"; import type { EnsApiConfig } from "@/config/config.schema"; @@ -10,5 +12,12 @@ export function redactEnsApiConfig(config: EnsApiConfig) { ...config, databaseUrl: redactString(config.databaseUrl), rpcConfigs: redactRpcConfigs(config.rpcConfigs), + // Convert Map to object for proper logging (Maps serialize to {} in JSON) + referralProgramCycleSet: Object.fromEntries( + Array.from(config.referralProgramCycleSet.entries()).map(([cycleId, cycle]) => [ + cycleId, + serializeReferralProgramCycle(cycle), + ]), + ), }; } diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/cycle-defaults.ts index 27c16eb9c..d42c3e8b4 100644 --- a/packages/ens-referrals/src/v1/cycle-defaults.ts +++ b/packages/ens-referrals/src/v1/cycle-defaults.ts @@ -1,6 +1,4 @@ -import { mainnet } from "viem/chains"; - -import { priceUsdc, type UnixTimestamp } from "@ensnode/ensnode-sdk"; +import { type AccountId, priceUsdc, type UnixTimestamp } from "@ensnode/ensnode-sdk"; import { type ReferralProgramCycle, @@ -68,13 +66,12 @@ const CYCLE_2_CONFIG = { /** * Returns the default referral program cycle set with pre-built cycle definitions. * - * @param subregistryAddress - The subregistry address for rule validation (e.g., BaseRegistrar address) + * @param subregistryId - The subregistry account ID for rule validation (e.g., BaseRegistrar on the namespace chain) * @returns A map of cycle IDs to their pre-built cycle configurations */ export function getReferralProgramCycleSet( - subregistryAddress: `0x${string}`, + subregistryId: AccountId, ): ReferralProgramCycleSet { - const subregistryId = { chainId: mainnet.id, address: subregistryAddress }; // Pre-built cycle-1 object (ENS Holiday Awards Dec 2025) const cycle1: ReferralProgramCycle = { From 4edd41d736a4fe67e72e86622bc85e36f70f9d91 Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 4 Feb 2026 02:17:46 +0100 Subject: [PATCH 08/16] linter issues + tests for 404 --- apps/ensapi/src/config/config.schema.test.ts | 2 +- apps/ensapi/src/config/config.schema.ts | 10 +-- apps/ensapi/src/config/redact.ts | 7 +- .../src/handlers/ensanalytics-api-v1.test.ts | 70 ++++++++++++++++++- .../ens-referrals/src/v1/cycle-defaults.ts | 5 +- 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index fbf5ace6d..900d32962 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -53,7 +53,7 @@ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); const subregistryId = getEthnamesSubregistryId("mainnet"); -const defaultCycleSet = getReferralProgramCycleSet(subregistryId.address); +const defaultCycleSet = getReferralProgramCycleSet(subregistryId); describe("buildConfigFromEnvironment", () => { afterEach(() => { diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 56068c2df..01bc4df80 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -108,7 +108,7 @@ async function loadReferralProgramCycleSet( const errorMessage = error instanceof Error ? error.message : String(error); throw new Error( `Failed to fetch custom referral program cycles from ${customCyclesUrl}: ${errorMessage}. ` + - `Please verify the URL is accessible and the server is running.`, + `Please verify the URL is accessible and the server is running.`, ); } @@ -121,23 +121,19 @@ async function loadReferralProgramCycleSet( let json: unknown; try { json = await response.json(); - } catch (error) { + } catch (_error) { throw new Error( `Failed to parse JSON from ${customCyclesUrl}: The response is not valid JSON. ` + - `Please verify the file contains valid JSON.`, + `Please verify the file contains valid 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); } diff --git a/apps/ensapi/src/config/redact.ts b/apps/ensapi/src/config/redact.ts index ab8a6d27e..b2352c8ca 100644 --- a/apps/ensapi/src/config/redact.ts +++ b/apps/ensapi/src/config/redact.ts @@ -1,4 +1,7 @@ -import { serializeReferralProgramCycle } from "@namehash/ens-referrals/v1"; +import { + type ReferralProgramCycle, + serializeReferralProgramCycle, +} from "@namehash/ens-referrals/v1"; import { redactRpcConfigs, redactString } from "@ensnode/ensnode-sdk/internal"; @@ -16,7 +19,7 @@ export function redactEnsApiConfig(config: EnsApiConfig) { referralProgramCycleSet: Object.fromEntries( Array.from(config.referralProgramCycleSet.entries()).map(([cycleId, cycle]) => [ cycleId, - serializeReferralProgramCycle(cycle), + serializeReferralProgramCycle(cycle as ReferralProgramCycle), ]), ), }; diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 3734d7660..439cd82ea 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -9,9 +9,30 @@ import * as middleware from "../middleware/referrer-leaderboard.middleware-v1"; vi.mock("@/config", () => ({ get default() { - const mockedConfig: Pick = { + const mockCycleA: ReferralProgramCycle = { + id: "test-cycle-a", + displayName: "Test Cycle A", + rules: {} as any, + rulesUrl: "https://example.com/rules", + }; + + const mockCycleB: ReferralProgramCycle = { + id: "test-cycle-b", + displayName: "Test Cycle B", + rules: {} as any, + rulesUrl: "https://example.com/rules", + }; + + const mockedConfig: Pick< + EnsApiConfig, + "ensIndexerUrl" | "namespace" | "referralProgramCycleSet" + > = { ensIndexerUrl: new URL("https://ensnode.example.com"), namespace: ENSNamespaceIds.Mainnet, + referralProgramCycleSet: new Map([ + ["test-cycle-a", mockCycleA], + ["test-cycle-b", mockCycleB], + ]), }; return mockedConfig; @@ -25,6 +46,7 @@ vi.mock("../middleware/referrer-leaderboard.middleware-v1", () => ({ import { deserializeReferrerDetailAllCyclesResponse, deserializeReferrerLeaderboardPageResponse, + type ReferralProgramCycle, type ReferralProgramCycleId, ReferrerDetailAllCyclesResponseCodes, type ReferrerDetailAllCyclesResponseOk, @@ -197,6 +219,52 @@ describe("/v1/ensanalytics", () => { expect(response).toMatchObject(expectedResponse); }); + + it("returns 404 error when unknown cycle ID is requested", async () => { + // Arrange: mock cache map with test-cycle-a and test-cycle-b + const mockCyclesCaches = new Map>([ + [ + "test-cycle-a", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "test-cycle-b", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ]); + + vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + + // Arrange: create the test client from the app instance + const client = testClient(app); + const recordsPerPage = 10; + const invalidCycle = "invalid-cycle" as ReferralProgramCycleId; + + // Act: send test request with invalid cycle ID + const httpResponse = await client["referral-leaderboard"].$get( + { query: { cycle: invalidCycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, + {}, + ); + const responseData = await httpResponse.json(); + const response = deserializeReferrerLeaderboardPageResponse(responseData); + + // Assert: response is 404 error with list of valid cycles from config + expect(httpResponse.status).toBe(404); + expect(response.responseCode).toBe(ReferrerLeaderboardPageResponseCodes.Error); + if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { + expect(response.error).toBe("Not Found"); + expect(response.errorMessage).toBe( + "Unknown cycle: invalid-cycle. Valid cycles: test-cycle-a, test-cycle-b", + ); + } + }); }); describe("/referral-leaderboard/:referrer", () => { diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/cycle-defaults.ts index d42c3e8b4..f9a3dc891 100644 --- a/packages/ens-referrals/src/v1/cycle-defaults.ts +++ b/packages/ens-referrals/src/v1/cycle-defaults.ts @@ -69,10 +69,7 @@ const CYCLE_2_CONFIG = { * @param subregistryId - The subregistry account ID for rule validation (e.g., BaseRegistrar on the namespace chain) * @returns A map of cycle IDs to their pre-built cycle configurations */ -export function getReferralProgramCycleSet( - subregistryId: AccountId, -): ReferralProgramCycleSet { - +export function getReferralProgramCycleSet(subregistryId: AccountId): ReferralProgramCycleSet { // Pre-built cycle-1 object (ENS Holiday Awards Dec 2025) const cycle1: ReferralProgramCycle = { id: ReferralProgramCycleIds.Cycle1, From 2e514aa3e1395a240ccc5fe5a63cea2f9ebe999f Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 4 Feb 2026 03:22:52 +0100 Subject: [PATCH 09/16] middleware refactor, more detailed logs --- apps/ensapi/src/config/config.schema.ts | 12 +++++++- .../src/handlers/ensanalytics-api-v1.test.ts | 22 +++++++-------- .../src/handlers/ensanalytics-api-v1.ts | 8 +++--- apps/ensapi/src/lib/hono-factory.ts | 4 +-- ...l-leaderboard-cycles-caches.middleware.ts} | 18 ++++++------ packages/ens-referrals/src/v1/client.ts | 2 +- .../ens-referrals/src/v1/cycle-defaults.ts | 28 ++++++++++++++++--- 7 files changed, 63 insertions(+), 31 deletions(-) rename apps/ensapi/src/middleware/{referrer-leaderboard.middleware-v1.ts => referral-leaderboard-cycles-caches.middleware.ts} (62%) diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 01bc4df80..fe611143e 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -127,8 +127,18 @@ async function loadReferralProgramCycleSet( `Please verify the file contains valid JSON.`, ); } + const schema = makeCustomReferralProgramCyclesSchema("CUSTOM_REFERRAL_PROGRAM_CYCLES"); - const validated = schema.parse(json); + const result = schema.safeParse(json); + + if (result.error) { + throw new Error( + `Failed to validate custom referral program cycles from ${customCyclesUrl}:\n${prettifyError(result.error)}\n` + + `Please verify the JSON structure matches the expected schema.`, + ); + } + + const validated = result.data; const cycleSet: ReferralProgramCycleSet = new Map(); for (const cycleObj of validated) { diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 439cd82ea..5a49de5fb 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -5,7 +5,7 @@ import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; -import * as middleware from "../middleware/referrer-leaderboard.middleware-v1"; +import * as middleware from "../middleware/referral-leaderboard-cycles-caches.middleware"; vi.mock("@/config", () => ({ get default() { @@ -39,8 +39,8 @@ vi.mock("@/config", () => ({ }, })); -vi.mock("../middleware/referrer-leaderboard.middleware-v1", () => ({ - referrerLeaderboardMiddlewareV1: vi.fn(), +vi.mock("../middleware/referral-leaderboard-cycles-caches.middleware", () => ({ + referralLeaderboardCyclesCachesMiddleware: vi.fn(), })); import { @@ -79,7 +79,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -184,7 +184,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -237,7 +237,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -285,7 +285,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -341,7 +341,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -405,7 +405,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -469,7 +469,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); @@ -510,7 +510,7 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); return await next(); }); diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index c35b49eab..bcb35b92c 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -23,7 +23,7 @@ import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; -import { referrerLeaderboardMiddlewareV1 } from "@/middleware/referrer-leaderboard.middleware-v1"; +import { referralLeaderboardCyclesCachesMiddleware } from "@/middleware/referral-leaderboard-cycles-caches.middleware"; const logger = makeLogger("ensanalytics-api-v1"); @@ -59,7 +59,7 @@ const app = factory .createApp() // Apply referrer leaderboard cache middleware to all routes in this handler - .use(referrerLeaderboardMiddlewareV1) + .use(referralLeaderboardCyclesCachesMiddleware) // Get a page from the referrer leaderboard for a specific cycle .get( @@ -84,7 +84,7 @@ const app = factory async (c) => { // context must be set by the required middleware if (c.var.referralLeaderboardCyclesCaches === undefined) { - throw new Error(`Invariant(ensanalytics-api-v1): referrerLeaderboardMiddlewareV1 required`); + throw new Error(`Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`); } try { @@ -174,7 +174,7 @@ app.get( async (c) => { // context must be set by the required middleware if (c.var.referralLeaderboardCyclesCaches === undefined) { - throw new Error(`Invariant(ensanalytics-api-v1): referrerLeaderboardMiddlewareV1 required`); + throw new Error(`Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`); } try { diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 0d3be68c0..51bd9c8f2 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -3,14 +3,14 @@ import { createFactory } from "hono/factory"; import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware"; +import type { ReferralLeaderboardCyclesCachesMiddlewareVariables } from "@/middleware/referral-leaderboard-cycles-caches.middleware"; import type { ReferrerLeaderboardMiddlewareVariables } from "@/middleware/referrer-leaderboard.middleware"; -import type { ReferrerLeaderboardMiddlewareV1Variables } from "@/middleware/referrer-leaderboard.middleware-v1"; export type MiddlewareVariables = IndexingStatusMiddlewareVariables & IsRealtimeMiddlewareVariables & CanAccelerateMiddlewareVariables & ReferrerLeaderboardMiddlewareVariables & - ReferrerLeaderboardMiddlewareV1Variables; + ReferralLeaderboardCyclesCachesMiddlewareVariables; export const factory = createFactory<{ Variables: Partial; diff --git a/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts b/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts similarity index 62% rename from apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts rename to apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts index f029cfc9b..0431ff161 100644 --- a/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts +++ b/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts @@ -5,9 +5,9 @@ import { import { factory } from "@/lib/hono-factory"; /** - * Type definition for the referrer leaderboard middleware context passed to downstream middleware and handlers (V1 API). + * Type definition for the referral leaderboard cycles caches middleware context passed to downstream middleware and handlers. */ -export type ReferrerLeaderboardMiddlewareV1Variables = { +export type ReferralLeaderboardCyclesCachesMiddlewareVariables = { /** * A map from cycle ID to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. * @@ -23,10 +23,12 @@ export type ReferrerLeaderboardMiddlewareV1Variables = { }; /** - * Middleware that provides {@link ReferrerLeaderboardMiddlewareV1Variables} - * to downstream middleware and handlers (V1 API). + * Middleware that provides {@link ReferralLeaderboardCyclesCachesMiddlewareVariables} + * to downstream middleware and handlers. */ -export const referrerLeaderboardMiddlewareV1 = factory.createMiddleware(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", referralLeaderboardCyclesCaches); - await next(); -}); +export const referralLeaderboardCyclesCachesMiddleware = factory.createMiddleware( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", referralLeaderboardCyclesCaches); + await next(); + }, +); diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index aebddd2eb..2fad992b9 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -266,7 +266,7 @@ export class ENSReferralsClient { throw new Error("Malformed response data: invalid JSON"); } - // The API can return errors with various status codes, but they're still in the + // The API can return errors with 500 status, but they're still in the // ReferrerDetailAllCyclesResponse format with responseCode: 'error' // So we don't need to check response.ok here, just deserialize and let // the caller handle the responseCode diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/cycle-defaults.ts index f9a3dc891..761db572f 100644 --- a/packages/ens-referrals/src/v1/cycle-defaults.ts +++ b/packages/ens-referrals/src/v1/cycle-defaults.ts @@ -33,6 +33,16 @@ const CYCLE_1_CONFIG = { * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) */ TOTAL_AWARD_POOL_VALUE: priceUsdc(10_000_000_000n), + + /** + * Display name for the cycle. + */ + DISPLAY_NAME: "ENS Holiday Awards", + + /** + * URL to the rules for this cycle. + */ + RULES_URL: "https://ensawards.org/ens-holiday-awards-rules", } as const; /** @@ -61,6 +71,16 @@ const CYCLE_2_CONFIG = { * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) */ TOTAL_AWARD_POOL_VALUE: priceUsdc(10_000_000_000n), + + /** + * Display name for the cycle. + */ + DISPLAY_NAME: "March 2026", + + /** + * URL to the rules for this cycle. + */ + RULES_URL: "https://ensawards.org/ens-holiday-awards-rules", } as const; /** @@ -73,7 +93,7 @@ export function getReferralProgramCycleSet(subregistryId: AccountId): ReferralPr // Pre-built cycle-1 object (ENS Holiday Awards Dec 2025) const cycle1: ReferralProgramCycle = { id: ReferralProgramCycleIds.Cycle1, - displayName: "ENS Holiday Awards", + displayName: CYCLE_1_CONFIG.DISPLAY_NAME, rules: buildReferralProgramRules( CYCLE_1_CONFIG.TOTAL_AWARD_POOL_VALUE, CYCLE_1_CONFIG.MAX_QUALIFIED_REFERRERS, @@ -81,13 +101,13 @@ export function getReferralProgramCycleSet(subregistryId: AccountId): ReferralPr CYCLE_1_CONFIG.END_DATE, subregistryId, ), - rulesUrl: "https://ensawards.org/ens-holiday-awards-rules", + rulesUrl: CYCLE_1_CONFIG.RULES_URL, }; // Pre-built cycle-2 object (March 2026) const cycle2: ReferralProgramCycle = { id: ReferralProgramCycleIds.Cycle2, - displayName: "March 2026", + displayName: CYCLE_2_CONFIG.DISPLAY_NAME, rules: buildReferralProgramRules( CYCLE_2_CONFIG.TOTAL_AWARD_POOL_VALUE, CYCLE_2_CONFIG.MAX_QUALIFIED_REFERRERS, @@ -95,7 +115,7 @@ export function getReferralProgramCycleSet(subregistryId: AccountId): ReferralPr CYCLE_2_CONFIG.END_DATE, subregistryId, ), - rulesUrl: "https://ensawards.org/march-2026-rules", + rulesUrl: CYCLE_2_CONFIG.RULES_URL, }; return new Map([ From 06bfd8ab4565c7ba8b8cf493e3c0d227c03d45a8 Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 4 Feb 2026 03:26:00 +0100 Subject: [PATCH 10/16] linter fixed --- .../src/handlers/ensanalytics-api-v1.test.ts | 80 +++++++++++-------- .../src/handlers/ensanalytics-api-v1.ts | 8 +- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 5a49de5fb..63e44bc7a 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -79,10 +79,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: all possible referrers on a single page response const allPossibleReferrers = referrerLeaderboardPageResponseOk.data.referrers; @@ -184,10 +186,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: create the test client from the app instance const client = testClient(app); @@ -237,10 +241,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: create the test client from the app instance const client = testClient(app); @@ -285,10 +291,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: use a referrer address that exists in the leaderboard (rank 1) const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; @@ -341,10 +349,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: use a referrer address that does NOT exist in the leaderboard const nonExistingReferrer = "0x0000000000000000000000000000000000000099"; @@ -405,10 +415,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: use any referrer address const referrer = "0x0000000000000000000000000000000000000001"; @@ -469,10 +481,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: use any referrer address const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; @@ -510,10 +524,12 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); - return await next(); - }); + vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }, + ); // Arrange: use any referrer address const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index bcb35b92c..ad3e87352 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -84,7 +84,9 @@ const app = factory async (c) => { // context must be set by the required middleware if (c.var.referralLeaderboardCyclesCaches === undefined) { - throw new Error(`Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`); + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`, + ); } try { @@ -174,7 +176,9 @@ app.get( async (c) => { // context must be set by the required middleware if (c.var.referralLeaderboardCyclesCaches === undefined) { - throw new Error(`Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`); + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`, + ); } try { From 2bd2872494509f9ba97a55594c5fd9503c6e8c87 Mon Sep 17 00:00:00 2001 From: Goader Date: Sat, 7 Feb 2026 21:09:12 +0100 Subject: [PATCH 11/16] review applied --- apps/ensapi/.env.local.example | 60 +- .../referral-leaderboard-cycles.cache.ts | 99 +-- .../cache/referral-program-cycle-set.cache.ts | 77 +++ apps/ensapi/src/config/config.schema.test.ts | 13 +- apps/ensapi/src/config/config.schema.ts | 121 +--- apps/ensapi/src/config/redact.ts | 12 - .../src/handlers/ensanalytics-api-v1.test.ts | 581 ++++++++++++++---- .../src/handlers/ensanalytics-api-v1.ts | 352 ++++++++--- apps/ensapi/src/index.ts | 18 +- .../get-referrer-leaderboard-v1.test.ts | 5 +- .../referrer-leaderboard/mocks-v1.ts | 3 + apps/ensapi/src/lib/hono-factory.ts | 2 + ...al-leaderboard-cycles-caches.middleware.ts | 39 +- .../referral-program-cycle-set.middleware.ts | 31 + .../ens-referrals/src/v1/api/deserialize.ts | 270 ++------ .../ens-referrals/src/v1/api/serialize.ts | 78 ++- .../src/v1/api/serialized-types.ts | 85 ++- packages/ens-referrals/src/v1/api/types.ts | 117 +++- .../ens-referrals/src/v1/api/zod-schemas.ts | 205 +++--- packages/ens-referrals/src/v1/client.ts | 208 +++++-- .../ens-referrals/src/v1/cycle-defaults.ts | 143 ++--- packages/ens-referrals/src/v1/cycle.ts | 70 +-- .../src/v1/leaderboard-page.test.ts | 2 + packages/ens-referrals/src/v1/rules.ts | 8 + .../src/shared/cache/swr-cache.test.ts | 190 +++++- .../ensnode-sdk/src/shared/cache/swr-cache.ts | 26 +- .../src/shared/config/environments.ts | 6 +- .../ensnode-sdk/src/shared/datetime.test.ts | 22 +- packages/ensnode-sdk/src/shared/datetime.ts | 27 + 29 files changed, 1867 insertions(+), 1003 deletions(-) create mode 100644 apps/ensapi/src/cache/referral-program-cycle-set.cache.ts create mode 100644 apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index d9d44be30..7b97a12d2 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -112,37 +112,29 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # it receives to The Graph's hosted subgraphs using this API key. # THEGRAPH_API_KEY= -# Custom Referral Program Cycles (optional) -# URL to a JSON file containing custom referral program cycle definitions. -# If not set, the default cycle set is used. -# -# Note: Setting CUSTOM_REFERRAL_PROGRAM_CYCLES replaces the default cycle set. -# Include all cycles you want active in the JSON file. -# -# The JSON file should contain an array of cycle objects with the following structure: -# [ -# { -# "id": "cycle-1", -# "displayName": "ENS Holiday Awards", -# "rules": { -# "totalAwardPoolValue": { -# "amount": "10000000000", -# "currency": "USDC" -# }, -# "maxQualifiedReferrers": 10, -# "startTime": 1764547200, -# "endTime": 1767225599, -# "subregistryId": { -# "chainId": 1, -# "address": "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" -# } -# }, -# "rulesUrl": "https://ensawards.org/ens-holiday-awards-rules" -# } -# ] -# -# Note: totalAwardPoolValue.amount is specified in the smallest units of USDC, -# where 1 USDC = 1,000,000 smallest units (6 decimals). -# Example: "10000000000" = 10,000 USDC -# -# CUSTOM_REFERRAL_PROGRAM_CYCLES=https://example.com/cycles.json +# Custom Referral Program Cycle Config Set Definition (optional) +# URL that returns JSON for a custom referral program cycle config set. +# If not set, the default cycle config set for the namespace is used. +# +# JSON Structure: +# The JSON must be an array of cycle config objects (SerializedReferralProgramCycleConfig[]). +# For the complete schema definition, see makeReferralProgramCycleConfigSetArraySchema in @namehash/ens-referrals/v1 +# +# +# Fetching Behavior: +# - Fetched proactively at ENSApi startup (before accepting requests) +# - 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 +# - Requests received before initial load completes will receive error responses +# +# Configuration Notes: +# - Setting CUSTOM_REFERRAL_PROGRAM_CYCLES completely replaces the default cycle config set +# - Include all cycle configs you want active in the JSON +# - Array must contain at least one cycle config +# - All cycle IDs must be unique +# +# CUSTOM_REFERRAL_PROGRAM_CYCLES=https://example.com/custom-cycles.json diff --git a/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts b/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts index 2800111e0..59f458609 100644 --- a/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts +++ b/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts @@ -1,8 +1,7 @@ -import config from "@/config"; - import { - type ReferralProgramCycle, - type ReferralProgramCycleId, + type ReferralProgramCycleConfig, + type ReferralProgramCycleConfigSet, + type ReferralProgramCycleSlug, type ReferrerLeaderboard, serializeReferralProgramRules, } from "@namehash/ens-referrals/v1"; @@ -23,12 +22,15 @@ import { indexingStatusCache } from "./indexing-status.cache"; const logger = makeLogger("referral-leaderboard-cycles-cache"); /** - * Map from cycle ID to its leaderboard cache. - * Each cycle has its own independent cache to preserve successful data - * even when other cycles fail. + * Map from cycle slug to its leaderboard cache. + * + * Each cycle has its own independent cache. Therefore, each + * cycle's cache can be asynchronously loaded / refreshed from + * others, and a failure to load data for one cycle doesn't break + * data successfully loaded for other cycles. */ export type ReferralLeaderboardCyclesCacheMap = Map< - ReferralProgramCycleId, + ReferralProgramCycleSlug, SWRCache >; @@ -46,58 +48,58 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ /** * Creates a cache builder function for a specific cycle. * - * @param cycleId - The ID of the cycle to build a cache for + * @param cycleConfig - The cycle configuration * @returns A function that builds the leaderboard for the given cycle */ function createCycleLeaderboardBuilder( - cycleId: ReferralProgramCycleId, + cycleConfig: ReferralProgramCycleConfig, ): () => Promise { return async (): Promise => { - const cycle = config.referralProgramCycleSet.get(cycleId) as ReferralProgramCycle | undefined; - if (!cycle) { - throw new Error(`Cycle ${cycleId} not found in referralProgramCycleSet`); - } + const cycleSlug = cycleConfig.slug; const indexingStatus = await indexingStatusCache.read(); if (indexingStatus instanceof Error) { logger.error( - { error: indexingStatus, cycleId }, - `Failed to read indexing status cache while generating referral leaderboard for ${cycleId}. Cannot proceed without valid indexing status.`, + { error: indexingStatus, cycleSlug }, + `Failed to read indexing status cache while generating referral leaderboard for ${cycleSlug}. Cannot proceed without valid indexing status.`, ); throw new Error( - `Unable to generate referral leaderboard for ${cycleId}. indexingStatusCache must have been successfully initialized.`, + `Unable to generate referral leaderboard for ${cycleSlug}. indexingStatusCache must have been successfully initialized.`, ); } const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { throw new Error( - `Unable to generate referrer leaderboard for ${cycleId}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, + `Unable to generate referrer leaderboard for ${cycleSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, ); } const latestIndexedBlockRef = getLatestIndexedBlockRef( indexingStatus, - cycle.rules.subregistryId.chainId, + cycleConfig.rules.subregistryId.chainId, ); if (latestIndexedBlockRef === null) { throw new Error( - `Unable to generate referrer leaderboard for ${cycleId}. Latest indexed block ref for chain ${cycle.rules.subregistryId.chainId} is null.`, + `Unable to generate referrer leaderboard for ${cycleSlug}. Latest indexed block ref for chain ${cycleConfig.rules.subregistryId.chainId} is null.`, ); } logger.info( - `Building referrer leaderboard for ${cycleId} with rules:\n${JSON.stringify( - serializeReferralProgramRules(cycle.rules), + `Building referrer leaderboard for ${cycleSlug} with rules:\n${JSON.stringify( + serializeReferralProgramRules(cycleConfig.rules), null, 2, )}`, ); - const leaderboard = await getReferrerLeaderboard(cycle.rules, latestIndexedBlockRef.timestamp); + const leaderboard = await getReferrerLeaderboard( + cycleConfig.rules, + latestIndexedBlockRef.timestamp, + ); logger.info( - `Successfully built referrer leaderboard for ${cycleId} with ${leaderboard.referrers.size} referrers`, + `Successfully built referrer leaderboard for ${cycleSlug} with ${leaderboard.referrers.size} referrers`, ); return leaderboard; @@ -105,38 +107,55 @@ function createCycleLeaderboardBuilder( } /** - * Initializes caches for all configured referral program cycles. + * Singleton instance of the initialized caches. + * Ensures caches are only initialized once per application lifecycle. + */ +let cachedInstance: ReferralLeaderboardCyclesCacheMap | null = null; + +/** + * Initializes caches for all referral program cycles in the given cycle set. * - * Each cycle gets its own independent SWRCache, ensuring that if one cycle - * fails to refresh, other cycles' previously successful data remains available. + * This function uses a singleton pattern to ensure caches are only initialized once, + * even if called multiple times. Each cycle gets its own independent SWRCache, + * ensuring that if one cycle fails to refresh, other cycles' previously successful + * data remains available. * - * @returns A map from cycle ID to its dedicated SWRCache + * @param cycleConfigSet - The referral program cycle config set to initialize caches for + * @returns A map from cycle slug to its dedicated SWRCache */ -function initializeCyclesCaches(): ReferralLeaderboardCyclesCacheMap { +export function initializeReferralLeaderboardCyclesCaches( + cycleConfigSet: ReferralProgramCycleConfigSet, +): ReferralLeaderboardCyclesCacheMap { + // Return cached instance if already initialized + if (cachedInstance !== null) { + return cachedInstance; + } + const caches: ReferralLeaderboardCyclesCacheMap = new Map(); - for (const [cycleId] of config.referralProgramCycleSet) { - const typedCycleId = cycleId as ReferralProgramCycleId; + for (const [cycleSlug, cycleConfig] of cycleConfigSet) { const cache = new SWRCache({ - fn: createCycleLeaderboardBuilder(typedCycleId), + fn: createCycleLeaderboardBuilder(cycleConfig), ttl: minutesToSeconds(1), proactiveRevalidationInterval: minutesToSeconds(2), proactivelyInitialize: true, }); - caches.set(typedCycleId, cache); - logger.info(`Initialized leaderboard cache for ${typedCycleId}`); + caches.set(cycleSlug, cache); + logger.info(`Initialized leaderboard cache for ${cycleSlug}`); } + // Cache the instance for subsequent calls + cachedInstance = caches; return caches; } /** - * Map of independent caches for each referral program cycle. + * Gets the cached instance of referral leaderboard cycles caches. + * Returns null if not yet initialized. * - * Each cycle has its own SWRCache to ensure independent failure handling. - * If cycle 1's cache fails to refresh but was previously successful, its old - * data remains available while cycle 2 can independently succeed or fail. + * @returns The cached cache map or null */ -export const referralLeaderboardCyclesCaches: ReferralLeaderboardCyclesCacheMap = - initializeCyclesCaches(); +export function getReferralLeaderboardCyclesCaches(): ReferralLeaderboardCyclesCacheMap | null { + return cachedInstance; +} diff --git a/apps/ensapi/src/cache/referral-program-cycle-set.cache.ts b/apps/ensapi/src/cache/referral-program-cycle-set.cache.ts new file mode 100644 index 000000000..9a413d168 --- /dev/null +++ b/apps/ensapi/src/cache/referral-program-cycle-set.cache.ts @@ -0,0 +1,77 @@ +import config from "@/config"; + +import { + ENSReferralsClient, + getDefaultReferralProgramCycleConfigSet, + type ReferralProgramCycleConfigSet, +} 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-cycle-set-cache"); + +/** + * Loads the referral program cycle config set from custom URL or defaults. + */ +async function loadReferralProgramCycleConfigSet(): Promise { + // Check if custom URL is configured + if (config.customReferralProgramCycleConfigSetUrl) { + logger.info( + `Loading custom referral program cycle config set from: ${config.customReferralProgramCycleConfigSetUrl.href}`, + ); + try { + const cycleConfigSet = await ENSReferralsClient.getReferralProgramCycleConfigSet( + config.customReferralProgramCycleConfigSetUrl, + ); + logger.info(`Successfully loaded ${cycleConfigSet.size} custom referral program cycles`); + return cycleConfigSet; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to load custom referral program cycle config set from ${config.customReferralProgramCycleConfigSetUrl.href}: ${errorMessage}`, + ); + } + } + + // Use default cycle config set for the namespace + logger.info( + `Loading default referral program cycle config set for namespace: ${config.namespace}`, + ); + const cycleConfigSet = getDefaultReferralProgramCycleConfigSet(config.namespace); + logger.info(`Successfully loaded ${cycleConfigSet.size} default referral program cycles`); + return cycleConfigSet; +} + +/** + * SWR Cache for the referral program cycle config set. + * + * Once successfully loaded, the cycle 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 referralProgramCycleConfigSetCache = new SWRCache({ + fn: async () => { + try { + const cycleConfigSet = await loadReferralProgramCycleConfigSet(); + logger.info("Referral program cycle config set cached successfully"); + return cycleConfigSet; + } catch (error) { + logger.error( + error, + "Error occurred while loading referral program cycle config set. The cache will remain empty.", + ); + throw error; + } + }, + ttl: Number.POSITIVE_INFINITY, + errorTtl: minutesToSeconds(1), + proactiveRevalidationInterval: undefined, + proactivelyInitialize: true, +}); diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 900d32962..4a2561fab 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -1,11 +1,9 @@ import packageJson from "@/../package.json" with { type: "json" }; -import { getReferralProgramCycleSet } from "@namehash/ens-referrals/v1"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ENSIndexerPublicConfig, - getEthnamesSubregistryId, PluginName, serializeENSIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; @@ -52,9 +50,6 @@ const ENSINDEXER_PUBLIC_CONFIG = { const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); -const subregistryId = getEthnamesSubregistryId("mainnet"); -const defaultCycleSet = getReferralProgramCycleSet(subregistryId); - describe("buildConfigFromEnvironment", () => { afterEach(() => { mockFetch.mockReset(); @@ -84,7 +79,7 @@ describe("buildConfigFromEnvironment", () => { } satisfies RpcConfig, ], ]), - referralProgramCycleSet: defaultCycleSet, + customReferralProgramCycleConfigSetUrl: undefined, }); }); @@ -165,7 +160,7 @@ describe("buildEnsApiPublicConfig", () => { } satisfies RpcConfig, ], ]), - referralProgramCycleSet: defaultCycleSet, + customReferralProgramCycleConfigSetUrl: undefined, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -189,7 +184,7 @@ describe("buildEnsApiPublicConfig", () => { namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, rpcConfigs: new Map(), - referralProgramCycleSet: defaultCycleSet, + customReferralProgramCycleConfigSetUrl: undefined, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -223,7 +218,7 @@ describe("buildEnsApiPublicConfig", () => { namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, rpcConfigs: new Map(), - referralProgramCycleSet: defaultCycleSet, + customReferralProgramCycleConfigSetUrl: undefined, theGraphApiKey: "secret-api-key", }; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index fe611143e..a6caf665d 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,24 +1,10 @@ import packageJson from "@/../package.json" with { type: "json" }; -import { - getReferralProgramCycleSet, - type ReferralProgramCycle, - type ReferralProgramCycleSet, -} from "@namehash/ens-referrals/v1"; -import { - makeCustomReferralProgramCyclesSchema, - makeReferralProgramCycleSetSchema, -} from "@namehash/ens-referrals/v1/internal"; import pRetry from "p-retry"; import { parse as parseConnectionString } from "pg-connection-string"; import { prettifyError, ZodError, z } from "zod/v4"; -import { - type ENSApiPublicConfig, - type ENSNamespaceId, - getEthnamesSubregistryId, - serializeENSIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; +import { type ENSApiPublicConfig, serializeENSIndexerPublicConfig } from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, canFallbackToTheGraph, @@ -56,6 +42,24 @@ export const DatabaseUrlSchema = z.string().refine( }, ); +/** + * Schema for validating custom referral program cycle config set URL. + */ +const CustomReferralProgramCycleConfigSetUrlSchema = z + .string() + .transform((val, ctx) => { + try { + return new URL(val); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `CUSTOM_REFERRAL_PROGRAM_CYCLES is not a valid URL: ${val}`, + }); + return z.NEVER; + } + }) + .optional(); + const EnsApiConfigSchema = z .object({ port: PortSchema.default(ENSApi_DEFAULT_PORT), @@ -66,91 +70,13 @@ const EnsApiConfigSchema = z namespace: ENSNamespaceSchema, rpcConfigs: RpcConfigsSchema, ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"), - referralProgramCycleSet: makeReferralProgramCycleSetSchema("referralProgramCycleSet"), + customReferralProgramCycleConfigSetUrl: CustomReferralProgramCycleConfigSetUrlSchema, }) .check(invariant_rpcConfigsSpecifiedForRootChain) .check(invariant_ensIndexerPublicConfigVersionInfo); export type EnsApiConfig = z.infer; -/** - * Loads the referral program cycle set from a custom URL or uses defaults. - * - * @param customCyclesUrl - Optional URL to a JSON file containing custom cycle definitions - * @param namespace - The ENS namespace to get the subregistry address for - * @returns A map of cycle IDs to their cycle configurations - */ -async function loadReferralProgramCycleSet( - customCyclesUrl: string | undefined, - namespace: ENSNamespaceId, -): Promise { - const subregistryId = getEthnamesSubregistryId(namespace); - - if (!customCyclesUrl) { - logger.info("Using default referral program cycle set"); - return getReferralProgramCycleSet(subregistryId); - } - - // 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}`); - - let response: Response; - try { - response = await fetch(customCyclesUrl); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to fetch custom referral program cycles from ${customCyclesUrl}: ${errorMessage}. ` + - `Please verify the URL is accessible and the server is running.`, - ); - } - - if (!response.ok) { - throw new Error( - `Failed to fetch custom referral program cycles from ${customCyclesUrl}: ${response.status} ${response.statusText}`, - ); - } - - let json: unknown; - try { - json = await response.json(); - } catch (_error) { - throw new Error( - `Failed to parse JSON from ${customCyclesUrl}: The response is not valid JSON. ` + - `Please verify the file contains valid JSON.`, - ); - } - - const schema = makeCustomReferralProgramCyclesSchema("CUSTOM_REFERRAL_PROGRAM_CYCLES"); - const result = schema.safeParse(json); - - if (result.error) { - throw new Error( - `Failed to validate custom referral program cycles from ${customCyclesUrl}:\n${prettifyError(result.error)}\n` + - `Please verify the JSON structure matches the expected schema.`, - ); - } - - const validated = result.data; - - const cycleSet: ReferralProgramCycleSet = new Map(); - for (const cycleObj of validated) { - const cycle = cycleObj as ReferralProgramCycle; - const cycleId = cycle.id; - cycleSet.set(cycleId, cycle); - } - - logger.info(`Loaded ${cycleSet.size} custom referral program cycles`); - return cycleSet; -} - /** * Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig. * @@ -172,11 +98,6 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); - const referralProgramCycleSet = await loadReferralProgramCycleSet( - env.CUSTOM_REFERRAL_PROGRAM_CYCLES, - ensIndexerPublicConfig.namespace, - ); - return EnsApiConfigSchema.parse({ port: env.PORT, databaseUrl: env.DATABASE_URL, @@ -186,7 +107,7 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis namespace: ensIndexerPublicConfig.namespace, databaseSchemaName: ensIndexerPublicConfig.databaseSchemaName, rpcConfigs, - referralProgramCycleSet, + customReferralProgramCycleConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_CYCLES, }); } catch (error) { if (error instanceof ZodError) { diff --git a/apps/ensapi/src/config/redact.ts b/apps/ensapi/src/config/redact.ts index b2352c8ca..c26730cac 100644 --- a/apps/ensapi/src/config/redact.ts +++ b/apps/ensapi/src/config/redact.ts @@ -1,8 +1,3 @@ -import { - type ReferralProgramCycle, - serializeReferralProgramCycle, -} from "@namehash/ens-referrals/v1"; - import { redactRpcConfigs, redactString } from "@ensnode/ensnode-sdk/internal"; import type { EnsApiConfig } from "@/config/config.schema"; @@ -15,12 +10,5 @@ export function redactEnsApiConfig(config: EnsApiConfig) { ...config, databaseUrl: redactString(config.databaseUrl), rpcConfigs: redactRpcConfigs(config.rpcConfigs), - // Convert Map to object for proper logging (Maps serialize to {} in JSON) - referralProgramCycleSet: Object.fromEntries( - Array.from(config.referralProgramCycleSet.entries()).map(([cycleId, cycle]) => [ - cycleId, - serializeReferralProgramCycle(cycle as ReferralProgramCycle), - ]), - ), }; } diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 63e44bc7a..53fe3f1b9 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -5,58 +5,44 @@ import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; -import * as middleware from "../middleware/referral-leaderboard-cycles-caches.middleware"; +import * as cyclesCachesMiddleware from "../middleware/referral-leaderboard-cycles-caches.middleware"; +import * as cycleSetMiddleware from "../middleware/referral-program-cycle-set.middleware"; vi.mock("@/config", () => ({ get default() { - const mockCycleA: ReferralProgramCycle = { - id: "test-cycle-a", - displayName: "Test Cycle A", - rules: {} as any, - rulesUrl: "https://example.com/rules", - }; - - const mockCycleB: ReferralProgramCycle = { - id: "test-cycle-b", - displayName: "Test Cycle B", - rules: {} as any, - rulesUrl: "https://example.com/rules", - }; - - const mockedConfig: Pick< - EnsApiConfig, - "ensIndexerUrl" | "namespace" | "referralProgramCycleSet" - > = { + const mockedConfig: Pick = { ensIndexerUrl: new URL("https://ensnode.example.com"), namespace: ENSNamespaceIds.Mainnet, - referralProgramCycleSet: new Map([ - ["test-cycle-a", mockCycleA], - ["test-cycle-b", mockCycleB], - ]), }; return mockedConfig; }, })); +vi.mock("../middleware/referral-program-cycle-set.middleware", () => ({ + referralProgramCycleConfigSetMiddleware: vi.fn(), +})); + vi.mock("../middleware/referral-leaderboard-cycles-caches.middleware", () => ({ referralLeaderboardCyclesCachesMiddleware: vi.fn(), })); import { - deserializeReferrerDetailAllCyclesResponse, + buildReferralProgramRules, + deserializeReferralProgramCycleConfigSetResponse, + deserializeReferrerDetailCyclesResponse, deserializeReferrerLeaderboardPageResponse, - type ReferralProgramCycle, - type ReferralProgramCycleId, - ReferrerDetailAllCyclesResponseCodes, - type ReferrerDetailAllCyclesResponseOk, + ReferralProgramCycleConfigSetResponseCodes, + type ReferralProgramCycleSlug, + ReferrerDetailCyclesResponseCodes, + type ReferrerDetailCyclesResponseOk, ReferrerDetailTypeIds, type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, } from "@namehash/ens-referrals/v1"; -import type { SWRCache } from "@ensnode/ensnode-sdk"; +import { parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; import { emptyReferralLeaderboard, @@ -69,23 +55,35 @@ import app from "./ensanalytics-api-v1"; describe("/v1/ensanalytics", () => { describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { - // Arrange: mock cache map with cycle-1 - const mockCyclesCaches = new Map>([ + // Arrange: mock cache map with 2025-12 + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => populatedReferrerLeaderboard, } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: all possible referrers on a single page response const allPossibleReferrers = referrerLeaderboardPageResponseOk.data.referrers; const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator](); @@ -93,7 +91,7 @@ describe("/v1/ensanalytics", () => { // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; - const cycle = "cycle-1"; + const cycle = "2025-12"; // Act: send test request to fetch 1st page const responsePage1 = await client["referral-leaderboard"] @@ -176,27 +174,39 @@ describe("/v1/ensanalytics", () => { }); it("returns empty cached referrer leaderboard when there are no referrals yet", async () => { - // Arrange: mock cache map with cycle-1 - const mockCyclesCaches = new Map>([ + // Arrange: mock cache map with 2025-12 + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => emptyReferralLeaderboard, } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; - const cycle = "cycle-1"; + const cycle = "2025-12"; // Act: send test request to fetch 1st page const response = await client["referral-leaderboard"] @@ -224,9 +234,9 @@ describe("/v1/ensanalytics", () => { expect(response).toMatchObject(expectedResponse); }); - it("returns 404 error when unknown cycle ID is requested", async () => { + it("returns 404 error when unknown cycle slug is requested", async () => { // Arrange: mock cache map with test-cycle-a and test-cycle-b - const mockCyclesCaches = new Map>([ + const mockCyclesCaches = new Map>([ [ "test-cycle-a", { @@ -241,19 +251,31 @@ describe("/v1/ensanalytics", () => { ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; - const invalidCycle = "invalid-cycle" as ReferralProgramCycleId; + const invalidCycle = "invalid-cycle"; - // Act: send test request with invalid cycle ID + // Act: send test request with invalid cycle slug const httpResponse = await client["referral-leaderboard"].$get( { query: { cycle: invalidCycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}, @@ -273,53 +295,68 @@ describe("/v1/ensanalytics", () => { }); }); - describe("/referral-leaderboard/:referrer", () => { - it("returns referrer metrics for all cycles when referrer exists", async () => { + describe("/referrer/:referrer", () => { + it("returns referrer metrics for requested cycles when referrer exists", async () => { // Arrange: mock cache map with multiple cycles - const mockCyclesCaches = new Map>([ + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => populatedReferrerLeaderboard, } as SWRCache, ], [ - "cycle-2", + "2026-03", { read: async () => populatedReferrerLeaderboard, } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: use a referrer address that exists in the leaderboard (rank 1) const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; const expectedMetrics = populatedReferrerLeaderboard.referrers.get(existingReferrer)!; const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; - // Act: send test request to fetch referrer detail for all cycles - const httpResponse = await app.request(`/referral-leaderboard/${existingReferrer}`); + // Act: send test request to fetch referrer detail for requested cycles + const httpResponse = await app.request( + `/referrer/${existingReferrer}?cycles=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailAllCyclesResponse(responseData); + const response = deserializeReferrerDetailCyclesResponse(responseData); - // Assert: response contains the expected referrer metrics for all cycles + // Assert: response contains the expected referrer metrics for requested cycles const expectedResponse = { - responseCode: ReferrerDetailAllCyclesResponseCodes.Ok, + responseCode: ReferrerDetailCyclesResponseCodes.Ok, data: { - "cycle-1": { + "2025-12": { type: ReferrerDetailTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, }, - "cycle-2": { + "2026-03": { type: ReferrerDetailTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, @@ -327,52 +364,67 @@ describe("/v1/ensanalytics", () => { accurateAsOf: expectedAccurateAsOf, }, }, - } satisfies ReferrerDetailAllCyclesResponseOk; + } satisfies ReferrerDetailCyclesResponseOk; expect(response).toMatchObject(expectedResponse); }); - it("returns zero-score metrics for all cycles when referrer does not exist", async () => { + it("returns zero-score metrics for requested cycles when referrer does not exist", async () => { // Arrange: mock cache map with multiple cycles - const mockCyclesCaches = new Map>([ + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => populatedReferrerLeaderboard, } as SWRCache, ], [ - "cycle-2", + "2026-03", { read: async () => populatedReferrerLeaderboard, } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: use a referrer address that does NOT exist in the leaderboard const nonExistingReferrer = "0x0000000000000000000000000000000000000099"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referral-leaderboard/${nonExistingReferrer}`); + const httpResponse = await app.request( + `/referrer/${nonExistingReferrer}?cycles=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailAllCyclesResponse(responseData); + const response = deserializeReferrerDetailCyclesResponse(responseData); - // Assert: response contains zero-score metrics for the referrer across all cycles + // Assert: response contains zero-score metrics for the referrer across requested cycles const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; - expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Ok); - if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { - const cycle1 = response.data["cycle-1"]!; - const cycle2 = response.data["cycle-2"]!; + expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Ok); + if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { + const cycle1 = response.data["2025-12"]!; + const cycle2 = response.data["2026-03"]!; - // Check cycle-1 + // Check 2025-12 expect(cycle1.type).toBe(ReferrerDetailTypeIds.Unranked); expect(cycle1.rules).toEqual(populatedReferrerLeaderboard.rules); expect(cycle1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); @@ -391,54 +443,67 @@ describe("/v1/ensanalytics", () => { }); expect(cycle1.accurateAsOf).toBe(expectedAccurateAsOf); - // Check cycle-2 + // Check 2026-03 expect(cycle2.type).toBe(ReferrerDetailTypeIds.Unranked); expect(cycle2.referrer.referrer).toBe(nonExistingReferrer); expect(cycle2.referrer.rank).toBe(null); } }); - it("returns zero-score metrics for all cycles when leaderboards are empty", async () => { + it("returns zero-score metrics for requested cycles when leaderboards are empty", async () => { // Arrange: mock cache map with multiple cycles, all empty - const mockCyclesCaches = new Map>([ + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => emptyReferralLeaderboard, } as SWRCache, ], [ - "cycle-2", + "2026-03", { read: async () => emptyReferralLeaderboard, } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: use any referrer address const referrer = "0x0000000000000000000000000000000000000001"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referral-leaderboard/${referrer}`); + const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailAllCyclesResponse(responseData); + const response = deserializeReferrerDetailCyclesResponse(responseData); - // Assert: response contains zero-score metrics for the referrer across all cycles + // Assert: response contains zero-score metrics for the referrer across requested cycles const expectedAccurateAsOf = emptyReferralLeaderboard.accurateAsOf; - expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Ok); - if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { - const cycle1 = response.data["cycle-1"]!; - const cycle2 = response.data["cycle-2"]!; + expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Ok); + if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { + const cycle1 = response.data["2025-12"]!; + const cycle2 = response.data["2026-03"]!; - // Check cycle-1 + // Check 2025-12 expect(cycle1.type).toBe(ReferrerDetailTypeIds.Unranked); expect(cycle1.rules).toEqual(emptyReferralLeaderboard.rules); expect(cycle1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); @@ -457,97 +522,371 @@ describe("/v1/ensanalytics", () => { }); expect(cycle1.accurateAsOf).toBe(expectedAccurateAsOf); - // Check cycle-2 + // Check 2026-03 expect(cycle2.type).toBe(ReferrerDetailTypeIds.Unranked); expect(cycle2.referrer.referrer).toBe(referrer); expect(cycle2.referrer.rank).toBe(null); } }); - it("returns error response when any cycle cache fails to load", async () => { - // Arrange: mock cache map where cycle-1 succeeds but cycle-2 fails - const mockCyclesCaches = new Map>([ + it("returns error response when any requested cycle cache fails to load", async () => { + // Arrange: mock cache map where 2025-12 succeeds but 2026-03 fails + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => populatedReferrerLeaderboard, } as SWRCache, ], [ - "cycle-2", + "2026-03", { read: async () => new Error("Database connection failed"), } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: use any referrer address const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; - // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referral-leaderboard/${referrer}`); + // Act: send test request to fetch referrer detail for both cycles + const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailAllCyclesResponse(responseData); + const response = deserializeReferrerDetailCyclesResponse(responseData); // Assert: response contains error mentioning the specific cycle that failed - expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Error); - if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Error) { - expect(response.error).toBe("Internal Server Error"); - expect(response.errorMessage).toContain("cycle-2"); + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Error); + if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + expect(response.error).toBe("Service Unavailable"); + expect(response.errorMessage).toContain("2026-03"); expect(response.errorMessage).toBe( - "Referrer leaderboard data for cycle cycle-2 has not been successfully cached yet.", + "Referrer leaderboard data not cached for cycle(s): 2026-03", ); } }); - it("returns error response when all cycle caches fail to load", async () => { + it("returns error response when all requested cycle caches fail to load", async () => { // Arrange: mock cache map where all cycles fail - const mockCyclesCaches = new Map>([ + const mockCyclesCaches = new Map>([ [ - "cycle-1", + "2025-12", { read: async () => new Error("Database connection failed"), } as SWRCache, ], [ - "cycle-2", + "2026-03", { read: async () => new Error("Database connection failed"), } as SWRCache, ], ]); - vi.mocked(middleware.referralLeaderboardCyclesCachesMiddleware).mockImplementation( + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); return await next(); }, ); + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + // Arrange: use any referrer address const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referral-leaderboard/${referrer}`); + const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,2026-03`); + const responseData = await httpResponse.json(); + const response = deserializeReferrerDetailCyclesResponse(responseData); + + // Assert: response contains error for all failed cycles + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Error); + if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + expect(response.error).toBe("Service Unavailable"); + expect(response.errorMessage).toContain("2025-12"); + expect(response.errorMessage).toContain("2026-03"); + expect(response.errorMessage).toBe( + "Referrer leaderboard data not cached for cycle(s): 2025-12, 2026-03", + ); + } + }); + + it("returns 404 error when unknown cycle slug is requested", async () => { + // Arrange: mock cache map with configured cycles + const mockCyclesCaches = new Map>([ + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ]); + + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + + // Arrange: use any referrer address + const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + + // Act: send test request with one valid and one invalid cycle + const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,invalid-cycle`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailAllCyclesResponse(responseData); + const response = deserializeReferrerDetailCyclesResponse(responseData); - // Assert: response contains error for the first cycle that failed - expect(response.responseCode).toBe(ReferrerDetailAllCyclesResponseCodes.Error); - if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Error) { - expect(response.error).toBe("Internal Server Error"); - expect(response.errorMessage).toContain("cycle-1"); + // Assert: response is 404 error with list of valid cycles + expect(httpResponse.status).toBe(404); + expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Error); + if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + expect(response.error).toBe("Not Found"); + expect(response.errorMessage).toContain("invalid-cycle"); expect(response.errorMessage).toBe( - "Referrer leaderboard data for cycle cycle-1 has not been successfully cached yet.", + "Unknown cycle(s): invalid-cycle. Valid cycles: 2025-12, 2026-03", ); } }); + + it("returns only requested cycle data when subset is requested", async () => { + // Arrange: mock cache map with multiple cycles + const mockCyclesCaches = new Map>([ + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-06", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ]); + + // Mock cycle set middleware to provide a mock cycle set + const mockCycleConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + ["2026-06", { slug: "2026-06", displayName: "Cycle 3", rules: {} as any }], + ]); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + return await next(); + }); + + // Arrange: use a referrer address that exists in the leaderboard + const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + + // Act: send test request requesting only 2 out of 3 cycles + const httpResponse = await app.request( + `/referrer/${existingReferrer}?cycles=2025-12,2026-06`, + ); + const responseData = await httpResponse.json(); + const response = deserializeReferrerDetailCyclesResponse(responseData); + + // Assert: response contains only the requested cycles + expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Ok); + if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { + expect(response.data["2025-12"]).toBeDefined(); + expect(response.data["2026-06"]).toBeDefined(); + expect(response.data["2026-03"]).toBeUndefined(); + } + }); + }); + + describe("/cycles", () => { + it("returns configured cycle config set sorted by start timestamp descending", async () => { + // Arrange: mock cycle config set with multiple cycles + const mockCycleConfigSet = new Map([ + [ + "2025-12", + { + slug: "2025-12", + displayName: "December 2025", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 100, + 1733011200, // 2024-12-01 + 1735603200, // 2024-12-31 + { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + new URL("https://example.com/rules"), + ), + }, + ], + [ + "2026-03", + { + slug: "2026-03", + displayName: "March 2026", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 100, + 1740787200, // 2025-03-01 + 1743465600, // 2025-03-31 + { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + new URL("https://example.com/rules"), + ), + }, + ], + [ + "2026-06", + { + slug: "2026-06", + displayName: "June 2026", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 100, + 1748736000, // 2025-06-01 + 1751328000, // 2025-06-30 + { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + new URL("https://example.com/rules"), + ), + }, + ], + ]); + + // Mock cycle set middleware + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + return await next(); + }, + ); + + // Mock caches middleware (needed by middleware chain) + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", new Map()); + return await next(); + }); + + // Act: send test request + const httpResponse = await app.request("/cycles"); + const responseData = await httpResponse.json(); + const response = deserializeReferralProgramCycleConfigSetResponse(responseData); + + // Assert: response contains all cycles sorted by start timestamp descending + expect(httpResponse.status).toBe(200); + expect(response.responseCode).toBe(ReferralProgramCycleConfigSetResponseCodes.Ok); + + if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Ok) { + expect(response.data.cycles).toHaveLength(3); + + // Verify sorting: most recent start time first + expect(response.data.cycles[0].slug).toBe("2026-06"); + expect(response.data.cycles[1].slug).toBe("2026-03"); + expect(response.data.cycles[2].slug).toBe("2025-12"); + + // Verify all cycle data is present + expect(response.data.cycles[0].displayName).toBe("June 2026"); + expect(response.data.cycles[1].displayName).toBe("March 2026"); + expect(response.data.cycles[2].displayName).toBe("December 2025"); + } + }); + + it("returns 503 error when cycle config set fails to load", async () => { + // Arrange: mock cycle set middleware to return Error + const loadError = new Error("Failed to fetch cycle config set"); + vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramCycleConfigSet", loadError); + return await next(); + }, + ); + + // Mock caches middleware (needed by middleware chain) + vi.mocked( + cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardCyclesCaches", new Map()); + return await next(); + }); + + // Act: send test request + const httpResponse = await app.request("/cycles"); + const responseData = await httpResponse.json(); + const response = deserializeReferralProgramCycleConfigSetResponse(responseData); + + // Assert: response is error + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferralProgramCycleConfigSetResponseCodes.Error); + + if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Error) { + expect(response.error).toBe("Service Unavailable"); + expect(response.errorMessage).toContain("currently unavailable"); + } + }); }); }); diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index ad3e87352..d7cd5a75a 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -1,20 +1,26 @@ -import config from "@/config"; - import { getReferrerDetail, getReferrerLeaderboardPage, + MAX_CYCLES_PER_REQUEST, REFERRERS_PER_LEADERBOARD_PAGE_MAX, - type ReferralProgramCycleId, - type ReferrerDetailAllCyclesData, - type ReferrerDetailAllCyclesResponse, - ReferrerDetailAllCyclesResponseCodes, + type ReferralProgramCycleConfigSetResponse, + ReferralProgramCycleConfigSetResponseCodes, + type ReferralProgramCycleSlug, + type ReferrerDetailCyclesData, + type ReferrerDetailCyclesResponse, + ReferrerDetailCyclesResponseCodes, + type ReferrerLeaderboard, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, - serializeReferrerDetailAllCyclesResponse, + serializeReferralProgramCycleConfigSetResponse, + serializeReferrerDetailCyclesResponse, serializeReferrerLeaderboardPageResponse, } from "@namehash/ens-referrals/v1"; -import { makeReferralProgramCycleIdSchema } from "@namehash/ens-referrals/v1/internal"; +import { + makeReferralProgramCycleSlugSchema, + makeReferrerDetailCyclesArraySchema, +} from "@namehash/ens-referrals/v1/internal"; import { describeRoute } from "hono-openapi"; import { z } from "zod/v4"; @@ -24,20 +30,16 @@ import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { referralLeaderboardCyclesCachesMiddleware } from "@/middleware/referral-leaderboard-cycles-caches.middleware"; +import { referralProgramCycleConfigSetMiddleware } from "@/middleware/referral-program-cycle-set.middleware"; const logger = makeLogger("ensanalytics-api-v1"); -// Get list of configured cycle IDs for validation -const getConfiguredCycleIds = (): ReferralProgramCycleId[] => { - return Array.from(config.referralProgramCycleSet.keys()) as ReferralProgramCycleId[]; -}; - /** * Query parameters schema for referrer leaderboard page requests. - * Validates cycle ID, page number, and records per page. + * Validates cycle slug, page number, and records per page. */ const referrerLeaderboardPageQuerySchema = z.object({ - cycle: makeReferralProgramCycleIdSchema("cycle"), + cycle: makeReferralProgramCycleSlugSchema("cycle"), page: z .optional(z.coerce.number().int().min(1, "Page must be a positive integer")) .describe("Page number for pagination"), @@ -58,7 +60,10 @@ const referrerLeaderboardPageQuerySchema = z.object({ const app = factory .createApp() - // Apply referrer leaderboard cache middleware to all routes in this handler + // Apply referral program cycle config set middleware + .use(referralProgramCycleConfigSetMiddleware) + + // Apply referrer leaderboard cache middleware (depends on cycle config set middleware) .use(referralLeaderboardCyclesCachesMiddleware) // Get a page from the referrer leaderboard for a specific cycle @@ -73,11 +78,14 @@ const app = factory description: "Successfully retrieved referrer leaderboard page", }, 404: { - description: "Unknown cycle ID", + description: "Unknown cycle slug", }, 500: { description: "Internal server error", }, + 503: { + description: "Service unavailable", + }, }, }), validate("query", referrerLeaderboardPageQuerySchema), @@ -92,13 +100,27 @@ const app = factory try { const { cycle, page, recordsPerPage } = c.req.valid("query"); + // Check if cycle set failed to load + if (c.var.referralLeaderboardCyclesCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardCyclesCaches }, + "Referral program cycle set failed to load", + ); + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferrerLeaderboardPageResponse), + 503, + ); + } + // Get the specific cycle's cache - // Note: We validate against the configured cycles, not just predefined IDs, - // to support custom cycles loaded from CUSTOM_REFERRAL_PROGRAM_CYCLES const cycleCache = c.var.referralLeaderboardCyclesCaches.get(cycle); if (!cycleCache) { - const configuredCycles = getConfiguredCycleIds(); + const configuredCycles = Array.from(c.var.referralLeaderboardCyclesCaches.keys()); return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, @@ -117,10 +139,10 @@ const app = factory return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Internal Server Error", + error: "Service Unavailable", errorMessage: `Failed to load leaderboard for cycle ${cycle}.`, } satisfies ReferrerLeaderboardPageResponse), - 500, + 503, ); } @@ -155,74 +177,234 @@ const referrerAddressSchema = z.object({ referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), }); -// Get referrer detail for a specific address across all cycles -app.get( - "/referral-leaderboard/:referrer", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Referrer Detail for All Cycles (v1)", - description: - "Returns detailed information for a specific referrer across all referral program cycles", - responses: { - 200: { - description: "Successfully retrieved referrer detail for all cycles", - }, - 500: { - description: "Internal server error - referrer leaderboard for a cycle failed to load", +// Cycles query parameter schema +const cyclesQuerySchema = z.object({ + cycles: z + .string() + .describe("Comma-separated list of cycle slugs") + .transform((value) => value.split(",").map((s) => s.trim())) + .pipe(makeReferrerDetailCyclesArraySchema("cycles")), +}); + +// Get referrer detail for a specific address for requested cycles +app + .get( + "/referrer/:referrer", + describeRoute({ + tags: ["ENSAwards"], + summary: "Get Referrer Detail for Cycles (v1)", + description: `Returns detailed information for a specific referrer for the requested cycles. Requires 1-${MAX_CYCLES_PER_REQUEST} distinct cycle slugs. All requested cycles must be recognized and have cached data, or the request fails.`, + responses: { + 200: { + description: "Successfully retrieved referrer detail for requested cycles", + }, + 400: { + description: "Invalid request", + }, + 404: { + description: "Unknown cycle slug", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable", + }, }, - }, - }), - validate("param", referrerAddressSchema), - async (c) => { - // context must be set by the required middleware - if (c.var.referralLeaderboardCyclesCaches === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`, - ); - } - - try { - const { referrer } = c.req.valid("param"); - const allCyclesData = {} as ReferrerDetailAllCyclesData; - - // 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) { + }), + validate("param", referrerAddressSchema), + validate("query", cyclesQuerySchema), + async (c) => { + // context must be set by the required middleware + if (c.var.referralLeaderboardCyclesCaches === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`, + ); + } + + try { + const { referrer } = c.req.valid("param"); + const { cycles } = c.req.valid("query"); + + // Check if cycle set failed to load + if (c.var.referralLeaderboardCyclesCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardCyclesCaches }, + "Referral program cycle set failed to load", + ); + return c.json( + serializeReferrerDetailCyclesResponse({ + responseCode: ReferrerDetailCyclesResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferrerDetailCyclesResponse), + 503, + ); + } + + // Type narrowing: at this point we know it's not an Error + const cyclesCaches = c.var.referralLeaderboardCyclesCaches; + + // Validate that all requested cycles are recognized (exist in the cache map) + const configuredCycles = Array.from(cyclesCaches.keys()); + const unrecognizedCycles = cycles.filter((cycle) => !cyclesCaches.has(cycle)); + + if (unrecognizedCycles.length > 0) { + return c.json( + serializeReferrerDetailCyclesResponse({ + responseCode: ReferrerDetailCyclesResponseCodes.Error, + error: "Not Found", + errorMessage: `Unknown cycle(s): ${unrecognizedCycles.join(", ")}. Valid cycles: ${configuredCycles.join(", ")}`, + } satisfies ReferrerDetailCyclesResponse), + 404, + ); + } + + // Read all requested cycle caches + const cycleLeaderboards = await Promise.all( + cycles.map(async (cycleSlug) => { + const cycleCache = cyclesCaches.get(cycleSlug); + if (!cycleCache) { + throw new Error(`Invariant: cycle cache for ${cycleSlug} should exist`); + } + const leaderboard = await cycleCache.read(); + return { cycleSlug, leaderboard }; + }), + ); + + // Validate that all requested cycles have cached data (no errors) + const uncachedCycles = cycleLeaderboards + .filter(({ leaderboard }) => leaderboard instanceof Error) + .map(({ cycleSlug }) => cycleSlug); + + if (uncachedCycles.length > 0) { 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, + serializeReferrerDetailCyclesResponse({ + responseCode: ReferrerDetailCyclesResponseCodes.Error, + error: "Service Unavailable", + errorMessage: `Referrer leaderboard data not cached for cycle(s): ${uncachedCycles.join(", ")}`, + } satisfies ReferrerDetailCyclesResponse), + 503, ); } - allCyclesData[cycleId] = getReferrerDetail(referrer, leaderboard); + + // Type narrowing: at this point all leaderboards are guaranteed to be non-Error + const validCycleLeaderboards = cycleLeaderboards.filter( + ( + item, + ): item is { cycleSlug: ReferralProgramCycleSlug; leaderboard: ReferrerLeaderboard } => + !(item.leaderboard instanceof Error), + ); + + // Build response data for the requested cycles + const cyclesData = Object.fromEntries( + validCycleLeaderboards.map(({ cycleSlug, leaderboard }) => [ + cycleSlug, + getReferrerDetail(referrer, leaderboard), + ]), + ) as ReferrerDetailCyclesData; + + return c.json( + serializeReferrerDetailCyclesResponse({ + responseCode: ReferrerDetailCyclesResponseCodes.Ok, + data: cyclesData, + } satisfies ReferrerDetailCyclesResponse), + ); + } catch (error) { + logger.error( + { error }, + "Error in /v1/ensanalytics/referral-leaderboard/:referrer endpoint", + ); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.json( + serializeReferrerDetailCyclesResponse({ + responseCode: ReferrerDetailCyclesResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferrerDetailCyclesResponse), + 500, + ); } + }, + ) - return c.json( - serializeReferrerDetailAllCyclesResponse({ - responseCode: ReferrerDetailAllCyclesResponseCodes.Ok, - data: allCyclesData, - } satisfies ReferrerDetailAllCyclesResponse), - ); - } catch (error) { - logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard/:referrer endpoint"); - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred while processing your request"; - return c.json( - serializeReferrerDetailAllCyclesResponse({ - responseCode: ReferrerDetailAllCyclesResponseCodes.Error, - error: "Internal server error", - errorMessage, - } satisfies ReferrerDetailAllCyclesResponse), - 500, - ); - } - }, -); + // Get configured cycle config set + .get( + "/cycles", + describeRoute({ + tags: ["ENSAwards"], + summary: "Get Cycle Config Set (v1)", + description: + "Returns the currently configured referral program cycle config set. Cycles are sorted in descending order by start timestamp (most recent first).", + responses: { + 200: { + description: "Successfully retrieved cycle config set", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable", + }, + }, + }), + async (c) => { + // context must be set by the required middleware + if (c.var.referralProgramCycleConfigSet === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralProgramCycleConfigSetMiddleware required`, + ); + } + + try { + // Check if cycle config set failed to load + if (c.var.referralProgramCycleConfigSet instanceof Error) { + logger.error( + { error: c.var.referralProgramCycleConfigSet }, + "Referral program cycle config set failed to load", + ); + return c.json( + serializeReferralProgramCycleConfigSetResponse({ + responseCode: ReferralProgramCycleConfigSetResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferralProgramCycleConfigSetResponse), + 503, + ); + } + + // Convert Map to array and sort by start timestamp descending + const cycles = Array.from(c.var.referralProgramCycleConfigSet.values()).sort( + (a, b) => b.rules.startTime - a.rules.startTime, + ); + + return c.json( + serializeReferralProgramCycleConfigSetResponse({ + responseCode: ReferralProgramCycleConfigSetResponseCodes.Ok, + data: { + cycles, + }, + } satisfies ReferralProgramCycleConfigSetResponse), + ); + } catch (error) { + logger.error({ error }, "Error in /v1/ensanalytics/cycles endpoint"); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.json( + serializeReferralProgramCycleConfigSetResponse({ + responseCode: ReferralProgramCycleConfigSetResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferralProgramCycleConfigSetResponse), + 500, + ); + } + }, + ); export default app; diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 3999d5c10..8802675e6 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -8,7 +8,8 @@ import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; -import { referralLeaderboardCyclesCaches } from "@/cache/referral-leaderboard-cycles.cache"; +import { getReferralLeaderboardCyclesCaches } from "@/cache/referral-leaderboard-cycles.cache"; +import { referralProgramCycleConfigSetCache } from "@/cache/referral-program-cycle-set.cache"; import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache"; import { redactEnsApiConfig } from "@/config/redact"; import { errorResponse } from "@/lib/handlers/error-response"; @@ -161,10 +162,17 @@ const gracefulShutdown = async () => { referrerLeaderboardCache.destroy(); logger.info("Destroyed referrerLeaderboardCache"); - // Destroy all cycle caches - for (const [cycleId, cache] of referralLeaderboardCyclesCaches) { - cache.destroy(); - logger.info(`Destroyed referralLeaderboardCyclesCache for ${cycleId}`); + // Destroy referral program cycle config set cache + referralProgramCycleConfigSetCache.destroy(); + logger.info("Destroyed referralProgramCycleConfigSetCache"); + + // Destroy all cycle caches (if initialized) + const cyclesCaches = getReferralLeaderboardCyclesCaches(); + if (cyclesCaches) { + for (const [cycleSlug, cache] of cyclesCaches) { + cache.destroy(); + logger.info(`Destroyed referralLeaderboardCyclesCache for ${cycleSlug}`); + } } indexingStatusCache.destroy(); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 8957e3d17..152111754 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -2,7 +2,7 @@ import { buildReferralProgramRules, type ReferrerLeaderboard } from "@namehash/e import { getUnixTime } from "date-fns"; import { describe, expect, it, vi } from "vitest"; -import { priceUsdc } from "@ensnode/ensnode-sdk"; +import { parseUsdc } from "@ensnode/ensnode-sdk"; import * as database from "./database-v1"; import { getReferrerLeaderboard } from "./get-referrer-leaderboard-v1"; @@ -14,7 +14,7 @@ vi.mock("./database-v1", () => ({ })); const rules = buildReferralProgramRules( - priceUsdc(10_000_000_000n), // 10,000 USDC in smallest units + parseUsdc("10000"), 10, // maxQualifiedReferrers getUnixTime("2025-01-01T00:00:00Z"), getUnixTime("2025-12-31T23:59:59Z"), @@ -22,6 +22,7 @@ const rules = buildReferralProgramRules( chainId: 1, address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, + new URL("https://example.com/rules"), ); const accurateAsOf = getUnixTime("2025-11-30T23:59:59Z"); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts index eb0be6417..7ea8bf34c 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts @@ -182,6 +182,7 @@ export const emptyReferralLeaderboard: ReferrerLeaderboard = { chainId: 1, address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, + rulesUrl: new URL("https://example.com/rules"), }, aggregatedMetrics: { grandTotalReferrals: 0, @@ -204,6 +205,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboard = { chainId: 1, address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, + rulesUrl: new URL("https://example.com/rules"), }, aggregatedMetrics: { grandTotalReferrals: 68, @@ -693,6 +695,7 @@ export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseO chainId: 1, address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, + rulesUrl: new URL("https://example.com/rules"), }, referrers: [ { diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 51bd9c8f2..8cbbfff5d 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -4,12 +4,14 @@ import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelera import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware"; import type { ReferralLeaderboardCyclesCachesMiddlewareVariables } from "@/middleware/referral-leaderboard-cycles-caches.middleware"; +import type { ReferralProgramCycleConfigSetMiddlewareVariables } from "@/middleware/referral-program-cycle-set.middleware"; import type { ReferrerLeaderboardMiddlewareVariables } from "@/middleware/referrer-leaderboard.middleware"; export type MiddlewareVariables = IndexingStatusMiddlewareVariables & IsRealtimeMiddlewareVariables & CanAccelerateMiddlewareVariables & ReferrerLeaderboardMiddlewareVariables & + ReferralProgramCycleConfigSetMiddlewareVariables & ReferralLeaderboardCyclesCachesMiddlewareVariables; export const factory = createFactory<{ diff --git a/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts b/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts index 0431ff161..d352537eb 100644 --- a/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts +++ b/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts @@ -1,17 +1,24 @@ import { + initializeReferralLeaderboardCyclesCaches, type ReferralLeaderboardCyclesCacheMap, - referralLeaderboardCyclesCaches, } from "@/cache/referral-leaderboard-cycles.cache"; import { factory } from "@/lib/hono-factory"; +import { referralProgramCycleConfigSetMiddleware } from "@/middleware/referral-program-cycle-set.middleware"; /** * Type definition for the referral leaderboard cycles caches middleware context passed to downstream middleware and handlers. */ export type ReferralLeaderboardCyclesCachesMiddlewareVariables = { /** - * A map from cycle ID to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. + * A map from cycle slug to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. + * + * Returns an {@link Error} if the referral program cycle config set failed to load. + * + * When the map is available, each cycle has its own independent cache. Therefore, each cycle's cache + * can be asynchronously loaded / refreshed from others, and a failure to + * load data for one cycle doesn't break data successfully loaded + * for other cycles. * - * Each cycle has its own independent cache to preserve successful data even when other cycles fail. * When reading from a specific cycle's cache, it will return either: * - The {@link ReferrerLeaderboard} if successfully cached * - An {@link Error} if the cache failed to build @@ -19,16 +26,38 @@ export type ReferralLeaderboardCyclesCachesMiddlewareVariables = { * Individual cycle caches maintain their own stale-while-revalidate behavior, so a previously * successfully fetched cycle continues serving its data even if a subsequent refresh fails. */ - referralLeaderboardCyclesCaches: ReferralLeaderboardCyclesCacheMap; + referralLeaderboardCyclesCaches: ReferralLeaderboardCyclesCacheMap | Error; }; /** * Middleware that provides {@link ReferralLeaderboardCyclesCachesMiddlewareVariables} * to downstream middleware and handlers. + * + * This middleware depends on {@link referralProgramCycleConfigSetMiddleware} to provide + * the cycle config set. If the cycle config set failed to load, this middleware propagates the error. + * Otherwise, it initializes caches for each cycle in the config set. */ export const referralLeaderboardCyclesCachesMiddleware = factory.createMiddleware( async (c, next) => { - c.set("referralLeaderboardCyclesCaches", referralLeaderboardCyclesCaches); + const cycleConfigSet = c.get("referralProgramCycleConfigSet"); + + // Invariant: referralProgramCycleConfigSetMiddleware must be applied before this middleware + if (cycleConfigSet === undefined) { + throw new Error( + "Invariant(referralLeaderboardCyclesCachesMiddleware): referralProgramCycleConfigSetMiddleware required", + ); + } + + // If cycle config set loading failed, propagate the error + if (cycleConfigSet instanceof Error) { + c.set("referralLeaderboardCyclesCaches", cycleConfigSet); + await next(); + return; + } + + // Initialize caches for the cycle config set + const caches = initializeReferralLeaderboardCyclesCaches(cycleConfigSet); + c.set("referralLeaderboardCyclesCaches", caches); await next(); }, ); diff --git a/apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts b/apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts new file mode 100644 index 000000000..0f99467b0 --- /dev/null +++ b/apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts @@ -0,0 +1,31 @@ +import type { ReferralProgramCycleConfigSet } from "@namehash/ens-referrals/v1"; + +import { referralProgramCycleConfigSetCache } from "@/cache/referral-program-cycle-set.cache"; +import { factory } from "@/lib/hono-factory"; + +/** + * Type definition for the referral program cycle config set middleware context. + */ +export type ReferralProgramCycleConfigSetMiddlewareVariables = { + /** + * The referral program cycle config set loaded either from a custom URL or defaults. + * + * - On success: {@link ReferralProgramCycleConfigSet} - A Map of cycle slugs to cycle configurations + * - On failure: {@link Error} - An error that occurred during loading + */ + referralProgramCycleConfigSet: ReferralProgramCycleConfigSet | Error; +}; + +/** + * Middleware that provides {@link ReferralProgramCycleConfigSetMiddlewareVariables} + * to downstream middleware and handlers. + * + * This middleware reads the referral program cycle config set from the SWR cache. + * The cache is initialized once at startup and never revalidated, ensuring + * the cycle config set JSON is only fetched once during the application lifecycle. + */ +export const referralProgramCycleConfigSetMiddleware = factory.createMiddleware(async (c, next) => { + const cycleConfigSet = await referralProgramCycleConfigSetCache.read(); + c.set("referralProgramCycleConfigSet", cycleConfigSet); + await next(); +}); diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index 2ac4960e7..af72e8424 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -1,220 +1,74 @@ import { prettifyError } from "zod/v4"; -import { deserializePriceEth, deserializePriceUsdc, type PriceEth } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramCycle, ReferralProgramCycleId } from "../cycle"; -import type { ReferrerLeaderboardPage } from "../leaderboard-page"; -import type { - ReferrerDetail, - ReferrerDetailRanked, - ReferrerDetailUnranked, -} from "../referrer-detail"; -import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; -import type { ReferralProgramRules } from "../rules"; +import type { ReferralProgramCycleConfig } from "../cycle"; import type { - SerializedAggregatedReferrerMetrics, - SerializedAwardedReferrerMetrics, - SerializedReferralProgramCycle, - SerializedReferralProgramRules, - SerializedReferrerDetail, - SerializedReferrerDetailAllCyclesResponse, - SerializedReferrerDetailRanked, - SerializedReferrerDetailUnranked, - SerializedReferrerLeaderboardPage, + SerializedReferralProgramCycleConfigSetResponse, + SerializedReferrerDetailCyclesResponse, SerializedReferrerLeaderboardPageResponse, - SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import type { - ReferrerDetailAllCyclesData, - ReferrerDetailAllCyclesResponse, + ReferralProgramCycleConfigSetResponse, + ReferrerDetailCyclesResponse, ReferrerLeaderboardPageResponse, } from "./types"; import { - makeReferrerDetailAllCyclesResponseSchema, + makeReferralProgramCycleConfigSetArraySchema, + makeReferralProgramCycleConfigSetResponseSchema, + makeReferrerDetailCyclesResponseSchema, makeReferrerLeaderboardPageResponseSchema, } from "./zod-schemas"; /** - * Deserializes a {@link SerializedReferralProgramRules} object. - */ -export function deserializeReferralProgramRules( - rules: SerializedReferralProgramRules, -): ReferralProgramRules { - return { - totalAwardPoolValue: deserializePriceUsdc(rules.totalAwardPoolValue), - maxQualifiedReferrers: rules.maxQualifiedReferrers, - startTime: rules.startTime, - endTime: rules.endTime, - subregistryId: rules.subregistryId, - }; -} - -/** - * Deserializes an {@link SerializedAwardedReferrerMetrics} object. - */ -function deserializeAwardedReferrerMetrics( - metrics: SerializedAwardedReferrerMetrics, -): AwardedReferrerMetrics { - return { - referrer: metrics.referrer, - totalReferrals: metrics.totalReferrals, - totalIncrementalDuration: metrics.totalIncrementalDuration, - totalRevenueContribution: deserializePriceEth(metrics.totalRevenueContribution), - score: metrics.score, - rank: metrics.rank, - isQualified: metrics.isQualified, - finalScoreBoost: metrics.finalScoreBoost, - finalScore: metrics.finalScore, - awardPoolShare: metrics.awardPoolShare, - awardPoolApproxValue: deserializePriceUsdc(metrics.awardPoolApproxValue), - }; -} - -/** - * Deserializes an {@link SerializedUnrankedReferrerMetrics} object. - */ -function deserializeUnrankedReferrerMetrics( - metrics: SerializedUnrankedReferrerMetrics, -): UnrankedReferrerMetrics { - return { - referrer: metrics.referrer, - totalReferrals: metrics.totalReferrals, - totalIncrementalDuration: metrics.totalIncrementalDuration, - totalRevenueContribution: deserializePriceEth(metrics.totalRevenueContribution), - score: metrics.score, - rank: metrics.rank, - isQualified: metrics.isQualified, - finalScoreBoost: metrics.finalScoreBoost, - finalScore: metrics.finalScore, - awardPoolShare: metrics.awardPoolShare, - awardPoolApproxValue: deserializePriceUsdc(metrics.awardPoolApproxValue), - }; -} - -/** - * Deserializes an {@link SerializedAggregatedReferrerMetrics} object. + * Deserialize a {@link ReferrerLeaderboardPageResponse} object. */ -function deserializeAggregatedReferrerMetrics( - metrics: SerializedAggregatedReferrerMetrics, -): AggregatedReferrerMetrics { - return { - grandTotalReferrals: metrics.grandTotalReferrals, - grandTotalIncrementalDuration: metrics.grandTotalIncrementalDuration, - grandTotalRevenueContribution: deserializePriceEth(metrics.grandTotalRevenueContribution), - grandTotalQualifiedReferrersFinalScore: metrics.grandTotalQualifiedReferrersFinalScore, - minFinalScoreToQualify: metrics.minFinalScoreToQualify, - }; -} +export function deserializeReferrerLeaderboardPageResponse( + maybeResponse: SerializedReferrerLeaderboardPageResponse, + valueLabel?: string, +): ReferrerLeaderboardPageResponse { + const schema = makeReferrerLeaderboardPageResponseSchema(valueLabel); + const parsed = schema.safeParse(maybeResponse); -/** - * Deserializes a {@link SerializedReferrerLeaderboardPage} object. - */ -function deserializeReferrerLeaderboardPage( - page: SerializedReferrerLeaderboardPage, -): ReferrerLeaderboardPage { - return { - rules: deserializeReferralProgramRules(page.rules), - referrers: page.referrers.map(deserializeAwardedReferrerMetrics), - aggregatedMetrics: deserializeAggregatedReferrerMetrics(page.aggregatedMetrics), - pageContext: page.pageContext, - accurateAsOf: page.accurateAsOf, - }; -} + if (parsed.error) { + throw new Error( + `Cannot deserialize SerializedReferrerLeaderboardPageResponse:\n${prettifyError(parsed.error)}\n`, + ); + } -/** - * Deserializes a {@link SerializedReferrerDetailRanked} object. - */ -function deserializeReferrerDetailRanked( - detail: SerializedReferrerDetailRanked, -): ReferrerDetailRanked { - return { - type: detail.type, - rules: deserializeReferralProgramRules(detail.rules), - referrer: deserializeAwardedReferrerMetrics(detail.referrer), - aggregatedMetrics: deserializeAggregatedReferrerMetrics(detail.aggregatedMetrics), - accurateAsOf: detail.accurateAsOf, - }; + return parsed.data; } /** - * Deserializes a {@link SerializedReferrerDetailUnranked} object. + * Deserialize a {@link ReferrerDetailCyclesResponse} object. */ -function deserializeReferrerDetailUnranked( - detail: SerializedReferrerDetailUnranked, -): ReferrerDetailUnranked { - return { - type: detail.type, - rules: deserializeReferralProgramRules(detail.rules), - referrer: deserializeUnrankedReferrerMetrics(detail.referrer), - aggregatedMetrics: deserializeAggregatedReferrerMetrics(detail.aggregatedMetrics), - accurateAsOf: detail.accurateAsOf, - }; -} +export function deserializeReferrerDetailCyclesResponse( + maybeResponse: SerializedReferrerDetailCyclesResponse, + valueLabel?: string, +): ReferrerDetailCyclesResponse { + const schema = makeReferrerDetailCyclesResponseSchema(valueLabel); + const parsed = schema.safeParse(maybeResponse); -/** - * Deserializes a {@link SerializedReferrerDetail} object (ranked or unranked). - */ -function deserializeReferrerDetail(detail: SerializedReferrerDetail): ReferrerDetail { - switch (detail.type) { - case "ranked": - return deserializeReferrerDetailRanked(detail); - case "unranked": - return deserializeReferrerDetailUnranked(detail); - default: { - const _exhaustiveCheck: never = detail; - throw new Error(`Unknown detail type: ${(_exhaustiveCheck as ReferrerDetail).type}`); - } + if (parsed.error) { + throw new Error( + `Cannot deserialize ReferrerDetailCyclesResponse:\n${prettifyError(parsed.error)}\n`, + ); } -} -/** - * Deserializes a {@link SerializedReferralProgramCycle} object. - */ -export function deserializeReferralProgramCycle( - cycle: SerializedReferralProgramCycle, -): ReferralProgramCycle { - return { - id: cycle.id, - displayName: cycle.displayName, - rules: deserializeReferralProgramRules(cycle.rules), - rulesUrl: cycle.rulesUrl, - }; + return parsed.data; } /** - * Deserialize a {@link ReferrerLeaderboardPageResponse} object. - * - * Note: This function explicitly deserializes each subobject to convert string - * RevenueContribution values back to {@link PriceEth}, then validates using Zod schemas - * to enforce invariants on the data. + * Deserializes an array of {@link ReferralProgramCycleConfig} objects. */ -export function deserializeReferrerLeaderboardPageResponse( - maybeResponse: SerializedReferrerLeaderboardPageResponse, +export function deserializeReferralProgramCycleConfigSetArray( + maybeArray: unknown, valueLabel?: string, -): ReferrerLeaderboardPageResponse { - let deserialized: ReferrerLeaderboardPageResponse; - switch (maybeResponse.responseCode) { - case "ok": { - deserialized = { - responseCode: maybeResponse.responseCode, - data: deserializeReferrerLeaderboardPage(maybeResponse.data), - } as ReferrerLeaderboardPageResponse; - break; - } - - case "error": - deserialized = maybeResponse; - break; - } - - // Then validate the deserialized structure using zod schemas - const schema = makeReferrerLeaderboardPageResponseSchema(valueLabel); - const parsed = schema.safeParse(deserialized); +): ReferralProgramCycleConfig[] { + const schema = makeReferralProgramCycleConfigSetArraySchema(valueLabel); + const parsed = schema.safeParse(maybeArray); if (parsed.error) { throw new Error( - `Cannot deserialize SerializedReferrerLeaderboardPageResponse:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferralProgramCycleConfigSetArray:\n${prettifyError(parsed.error)}\n`, ); } @@ -222,48 +76,18 @@ export function deserializeReferrerLeaderboardPageResponse( } /** - * Deserialize a {@link ReferrerDetailAllCyclesResponse} object. - * - * Note: This function explicitly deserializes each subobject to convert string - * RevenueContribution values back to {@link PriceEth}, then validates using Zod schemas - * to enforce invariants on the data. + * Deserialize a {@link ReferralProgramCycleConfigSetResponse} object. */ -export function deserializeReferrerDetailAllCyclesResponse( - maybeResponse: SerializedReferrerDetailAllCyclesResponse, +export function deserializeReferralProgramCycleConfigSetResponse( + maybeResponse: SerializedReferralProgramCycleConfigSetResponse, valueLabel?: string, -): ReferrerDetailAllCyclesResponse { - let deserialized: ReferrerDetailAllCyclesResponse; - - switch (maybeResponse.responseCode) { - case "ok": { - const data: ReferrerDetailAllCyclesData = {} as ReferrerDetailAllCyclesData; - - for (const [cycleId, detail] of Object.entries(maybeResponse.data)) { - // Object.entries only returns existing entries, so detail is never undefined at runtime - data[cycleId as ReferralProgramCycleId] = deserializeReferrerDetail( - detail as SerializedReferrerDetail, - ); - } - - deserialized = { - responseCode: "ok", - data, - }; - break; - } - - case "error": - deserialized = maybeResponse; - break; - } - - // Then validate the deserialized structure using zod schemas - const schema = makeReferrerDetailAllCyclesResponseSchema(valueLabel); - const parsed = schema.safeParse(deserialized); +): ReferralProgramCycleConfigSetResponse { + const schema = makeReferralProgramCycleConfigSetResponseSchema(valueLabel); + const parsed = schema.safeParse(maybeResponse); if (parsed.error) { throw new Error( - `Cannot deserialize ReferrerDetailAllCyclesResponse:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferralProgramCycleConfigSetResponse:\n${prettifyError(parsed.error)}\n`, ); } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index d7788f987..f41a6a16d 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,7 +1,7 @@ import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramCycle } from "../cycle"; +import type { ReferralProgramCycleConfig } from "../cycle"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { ReferrerDetail, @@ -13,11 +13,12 @@ import type { ReferralProgramRules } from "../rules"; import type { SerializedAggregatedReferrerMetrics, SerializedAwardedReferrerMetrics, - SerializedReferralProgramCycle, + SerializedReferralProgramCycleConfig, + SerializedReferralProgramCycleConfigSetResponse, SerializedReferralProgramRules, SerializedReferrerDetail, - SerializedReferrerDetailAllCyclesData, - SerializedReferrerDetailAllCyclesResponse, + SerializedReferrerDetailCyclesData, + SerializedReferrerDetailCyclesResponse, SerializedReferrerDetailRanked, SerializedReferrerDetailUnranked, SerializedReferrerLeaderboardPage, @@ -25,8 +26,10 @@ import type { SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import { - type ReferrerDetailAllCyclesResponse, - ReferrerDetailAllCyclesResponseCodes, + type ReferralProgramCycleConfigSetResponse, + ReferralProgramCycleConfigSetResponseCodes, + type ReferrerDetailCyclesResponse, + ReferrerDetailCyclesResponseCodes, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, } from "./types"; @@ -43,6 +46,7 @@ export function serializeReferralProgramRules( startTime: rules.startTime, endTime: rules.endTime, subregistryId: rules.subregistryId, + rulesUrl: rules.rulesUrl.toString(), }; } @@ -165,16 +169,15 @@ function serializeReferrerDetail(detail: ReferrerDetail): SerializedReferrerDeta } /** - * Serializes a {@link ReferralProgramCycle} object. + * Serializes a {@link ReferralProgramCycleConfig} object. */ -export function serializeReferralProgramCycle( - cycle: ReferralProgramCycle, -): SerializedReferralProgramCycle { +export function serializeReferralProgramCycleConfig( + cycleConfig: ReferralProgramCycleConfig, +): SerializedReferralProgramCycleConfig { return { - id: cycle.id, - displayName: cycle.displayName, - rules: serializeReferralProgramRules(cycle.rules), - rulesUrl: cycle.rulesUrl, + slug: cycleConfig.slug, + displayName: cycleConfig.displayName, + rules: serializeReferralProgramRules(cycleConfig.rules), }; } @@ -197,19 +200,19 @@ export function serializeReferrerLeaderboardPageResponse( } /** - * Serialize a {@link ReferrerDetailAllCyclesResponse} object. + * Serialize a {@link ReferrerDetailCyclesResponse} object. */ -export function serializeReferrerDetailAllCyclesResponse( - response: ReferrerDetailAllCyclesResponse, -): SerializedReferrerDetailAllCyclesResponse { +export function serializeReferrerDetailCyclesResponse( + response: ReferrerDetailCyclesResponse, +): SerializedReferrerDetailCyclesResponse { switch (response.responseCode) { - case ReferrerDetailAllCyclesResponseCodes.Ok: { + case ReferrerDetailCyclesResponseCodes.Ok: { const serializedData = Object.fromEntries( - Object.entries(response.data).map(([cycleId, detail]) => [ - cycleId, + Object.entries(response.data).map(([cycleSlug, detail]) => [ + cycleSlug, serializeReferrerDetail(detail as ReferrerDetail), ]), - ) as SerializedReferrerDetailAllCyclesData; + ) as SerializedReferrerDetailCyclesData; return { responseCode: response.responseCode, @@ -217,13 +220,40 @@ export function serializeReferrerDetailAllCyclesResponse( }; } - case ReferrerDetailAllCyclesResponseCodes.Error: + case ReferrerDetailCyclesResponseCodes.Error: return response; default: { const _exhaustiveCheck: never = response; throw new Error( - `Unknown response code: ${(_exhaustiveCheck as ReferrerDetailAllCyclesResponse).responseCode}`, + `Unknown response code: ${(_exhaustiveCheck as ReferrerDetailCyclesResponse).responseCode}`, + ); + } + } +} + +/** + * Serialize a {@link ReferralProgramCycleConfigSetResponse} object. + */ +export function serializeReferralProgramCycleConfigSetResponse( + response: ReferralProgramCycleConfigSetResponse, +): SerializedReferralProgramCycleConfigSetResponse { + switch (response.responseCode) { + case ReferralProgramCycleConfigSetResponseCodes.Ok: + return { + responseCode: response.responseCode, + data: { + cycles: response.data.cycles.map(serializeReferralProgramCycleConfig), + }, + }; + + case ReferralProgramCycleConfigSetResponseCodes.Error: + return response; + + default: { + const _exhaustiveCheck: never = response; + throw new Error( + `Unknown response code: ${(_exhaustiveCheck as ReferralProgramCycleConfigSetResponse).responseCode}`, ); } } diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index d6997abbb..d0e1f9f1b 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,15 +1,19 @@ import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramCycle, ReferralProgramCycleId } from "../cycle"; +import type { ReferralProgramCycleConfig, ReferralProgramCycleSlug } from "../cycle"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { ReferrerDetailRanked, ReferrerDetailUnranked } from "../referrer-detail"; import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { - ReferrerDetailAllCyclesResponse, - ReferrerDetailAllCyclesResponseError, - ReferrerDetailAllCyclesResponseOk, + ReferralProgramCycleConfigSetData, + ReferralProgramCycleConfigSetResponse, + ReferralProgramCycleConfigSetResponseError, + ReferralProgramCycleConfigSetResponseOk, + ReferrerDetailCyclesResponse, + ReferrerDetailCyclesResponseError, + ReferrerDetailCyclesResponseOk, ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseError, ReferrerLeaderboardPageResponseOk, @@ -19,8 +23,9 @@ import type { * Serialized representation of {@link ReferralProgramRules}. */ export interface SerializedReferralProgramRules - extends Omit { + extends Omit { totalAwardPoolValue: SerializedPriceUsdc; + rulesUrl: string; } /** @@ -109,42 +114,72 @@ export type SerializedReferrerLeaderboardPageResponse = | SerializedReferrerLeaderboardPageResponseError; /** - * Serialized representation of {@link ReferralProgramCycle}. + * Serialized representation of {@link ReferralProgramCycleConfig}. */ -export interface SerializedReferralProgramCycle extends Omit { +export interface SerializedReferralProgramCycleConfig + extends Omit { rules: SerializedReferralProgramRules; } /** - * Serialized representation of referrer detail data across all cycles. - * Uses Partial because the set of cycles includes both predefined cycles - * (e.g., "cycle-1", "cycle-2") and any custom cycles loaded from configuration. - * All configured cycles will have entries in the response (even if empty for - * referrers who haven't participated), but TypeScript cannot know at compile - * time which specific cycles are configured. + * Serialized representation of referrer detail data for requested cycles. + * Uses Partial because TypeScript cannot know at compile time which specific cycle + * slugs are requested. At runtime, when responseCode is Ok, all requested cycle slugs + * are guaranteed to be present in this record. */ -export type SerializedReferrerDetailAllCyclesData = Partial< - Record +export type SerializedReferrerDetailCyclesData = Partial< + Record >; /** - * Serialized representation of {@link ReferrerDetailAllCyclesResponseOk}. + * Serialized representation of {@link ReferrerDetailCyclesResponseOk}. */ -export interface SerializedReferrerDetailAllCyclesResponseOk - extends Omit { - data: SerializedReferrerDetailAllCyclesData; +export interface SerializedReferrerDetailCyclesResponseOk + extends Omit { + data: SerializedReferrerDetailCyclesData; } /** - * Serialized representation of {@link ReferrerDetailAllCyclesResponseError}. + * Serialized representation of {@link ReferrerDetailCyclesResponseError}. * * Note: All fields are already serializable, so this type is identical to the source type. */ -export type SerializedReferrerDetailAllCyclesResponseError = ReferrerDetailAllCyclesResponseError; +export type SerializedReferrerDetailCyclesResponseError = ReferrerDetailCyclesResponseError; /** - * Serialized representation of {@link ReferrerDetailAllCyclesResponse}. + * Serialized representation of {@link ReferrerDetailCyclesResponse}. */ -export type SerializedReferrerDetailAllCyclesResponse = - | SerializedReferrerDetailAllCyclesResponseOk - | SerializedReferrerDetailAllCyclesResponseError; +export type SerializedReferrerDetailCyclesResponse = + | SerializedReferrerDetailCyclesResponseOk + | SerializedReferrerDetailCyclesResponseError; + +/** + * Serialized representation of {@link ReferralProgramCycleConfigSetData}. + */ +export interface SerializedReferralProgramCycleConfigSetData + extends Omit { + cycles: SerializedReferralProgramCycleConfig[]; +} + +/** + * Serialized representation of {@link ReferralProgramCycleConfigSetResponseOk}. + */ +export interface SerializedReferralProgramCycleConfigSetResponseOk + extends Omit { + data: SerializedReferralProgramCycleConfigSetData; +} + +/** + * Serialized representation of {@link ReferralProgramCycleConfigSetResponseError}. + * + * Note: All fields are already serializable, so this type is identical to the source type. + */ +export type SerializedReferralProgramCycleConfigSetResponseError = + ReferralProgramCycleConfigSetResponseError; + +/** + * Serialized representation of {@link ReferralProgramCycleConfigSetResponse}. + */ +export type SerializedReferralProgramCycleConfigSetResponse = + | SerializedReferralProgramCycleConfigSetResponseOk + | SerializedReferralProgramCycleConfigSetResponseError; diff --git a/packages/ens-referrals/src/v1/api/types.ts b/packages/ens-referrals/src/v1/api/types.ts index 403cbece9..3cb1e7b9e 100644 --- a/packages/ens-referrals/src/v1/api/types.ts +++ b/packages/ens-referrals/src/v1/api/types.ts @@ -1,6 +1,6 @@ import type { Address } from "viem"; -import type { ReferralProgramCycleId } from "../cycle"; +import type { ReferralProgramCycleConfig, ReferralProgramCycleSlug } from "../cycle"; import type { ReferrerLeaderboardPage, ReferrerLeaderboardPageParams } from "../leaderboard-page"; import type { ReferrerDetail } from "../referrer-detail"; @@ -8,8 +8,8 @@ import type { ReferrerDetail } from "../referrer-detail"; * Request parameters for a referrer leaderboard page query. */ export interface ReferrerLeaderboardPageRequest extends ReferrerLeaderboardPageParams { - /** The referral program cycle ID */ - cycle: ReferralProgramCycleId; + /** The referral program cycle slug */ + cycle: ReferralProgramCycleSlug; } /** @@ -60,20 +60,27 @@ export type ReferrerLeaderboardPageResponse = | ReferrerLeaderboardPageResponseOk | ReferrerLeaderboardPageResponseError; +/** + * Maximum number of cycles that can be requested in a single {@link ReferrerDetailCyclesRequest}. + */ +export const MAX_CYCLES_PER_REQUEST = 20; + /** * Request parameters for referrer detail query. */ -export interface ReferrerDetailRequest { +export interface ReferrerDetailCyclesRequest { /** The Ethereum address of the referrer to query */ referrer: Address; + /** Array of cycle slugs to query (min 1, max {@link MAX_CYCLES_PER_REQUEST}, must be distinct) */ + cycles: ReferralProgramCycleSlug[]; } /** * A status code for referrer detail API responses. */ -export const ReferrerDetailAllCyclesResponseCodes = { +export const ReferrerDetailCyclesResponseCodes = { /** - * Represents that the referrer detail data across all cycles is available. + * Represents that the referrer detail data for the requested cycles is available. */ Ok: "ok", @@ -84,46 +91,100 @@ export const ReferrerDetailAllCyclesResponseCodes = { } as const; /** - * The derived string union of possible {@link ReferrerDetailAllCyclesResponseCodes}. + * The derived string union of possible {@link ReferrerDetailCyclesResponseCodes}. */ -export type ReferrerDetailAllCyclesResponseCode = - (typeof ReferrerDetailAllCyclesResponseCodes)[keyof typeof ReferrerDetailAllCyclesResponseCodes]; +export type ReferrerDetailCyclesResponseCode = + (typeof ReferrerDetailCyclesResponseCodes)[keyof typeof ReferrerDetailCyclesResponseCodes]; /** - * Referrer detail data across all cycles. + * Referrer detail data for requested cycles. * - * Maps each cycle ID to the referrer's detail for that cycle. - * Uses Partial because the set of cycles includes both predefined cycles - * (e.g., "cycle-1", "cycle-2") and any custom cycles loaded from configuration. - * All configured cycles will have entries in the response (even if empty for - * referrers who haven't participated), but TypeScript cannot know at compile - * time which specific cycles are configured. + * Maps each requested cycle slug to the referrer's detail for that cycle. + * Uses Partial because TypeScript cannot know at compile time which specific cycle + * slugs are requested. At runtime, when responseCode is Ok, all requested cycle slugs + * are guaranteed to be present in this record. + */ +export type ReferrerDetailCyclesData = Partial>; + +/** + * A successful response containing referrer detail for the requested cycles. + */ +export type ReferrerDetailCyclesResponseOk = { + responseCode: typeof ReferrerDetailCyclesResponseCodes.Ok; + data: ReferrerDetailCyclesData; +}; + +/** + * A referrer detail cycles response when an error occurs. + */ +export type ReferrerDetailCyclesResponseError = { + responseCode: typeof ReferrerDetailCyclesResponseCodes.Error; + error: string; + errorMessage: string; +}; + +/** + * A referrer detail cycles API response. + * + * Use the `responseCode` field to determine the specific type interpretation + * at runtime. */ -export type ReferrerDetailAllCyclesData = Partial>; +export type ReferrerDetailCyclesResponse = + | ReferrerDetailCyclesResponseOk + | ReferrerDetailCyclesResponseError; + +/** + * A status code for referral program cycle config set API responses. + */ +export const ReferralProgramCycleConfigSetResponseCodes = { + /** + * Represents that the cycle config set is available. + */ + Ok: "ok", + + /** + * Represents that the cycle config set is not available. + */ + Error: "error", +} as const; + +/** + * The derived string union of possible {@link ReferralProgramCycleConfigSetResponseCodes}. + */ +export type ReferralProgramCycleConfigSetResponseCode = + (typeof ReferralProgramCycleConfigSetResponseCodes)[keyof typeof ReferralProgramCycleConfigSetResponseCodes]; + +/** + * The data payload containing cycle configs. + * Cycles are sorted in descending order by start timestamp. + */ +export type ReferralProgramCycleConfigSetData = { + cycles: ReferralProgramCycleConfig[]; +}; /** - * A successful response containing referrer detail for all cycles. + * A successful response containing the configured cycle config set. */ -export type ReferrerDetailAllCyclesResponseOk = { - responseCode: typeof ReferrerDetailAllCyclesResponseCodes.Ok; - data: ReferrerDetailAllCyclesData; +export type ReferralProgramCycleConfigSetResponseOk = { + responseCode: typeof ReferralProgramCycleConfigSetResponseCodes.Ok; + data: ReferralProgramCycleConfigSetData; }; /** - * A referrer detail across all cycles response when an error occurs. + * A cycle config set response when an error occurs. */ -export type ReferrerDetailAllCyclesResponseError = { - responseCode: typeof ReferrerDetailAllCyclesResponseCodes.Error; +export type ReferralProgramCycleConfigSetResponseError = { + responseCode: typeof ReferralProgramCycleConfigSetResponseCodes.Error; error: string; errorMessage: string; }; /** - * A referrer detail across all cycles API response. + * A referral program cycle config set API response. * * Use the `responseCode` field to determine the specific type interpretation * at runtime. */ -export type ReferrerDetailAllCyclesResponse = - | ReferrerDetailAllCyclesResponseOk - | ReferrerDetailAllCyclesResponseError; +export type ReferralProgramCycleConfigSetResponse = + | ReferralProgramCycleConfigSetResponseOk + | ReferralProgramCycleConfigSetResponseError; diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 8269dff65..c07d656fc 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -19,18 +19,21 @@ import { makePriceEthSchema, makePriceUsdcSchema, makeUnixTimestampSchema, + makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; -import type { ReferralProgramCycleId } from "../cycle"; +import type { ReferralProgramCycleSlug } from "../cycle"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; import { type ReferrerDetailRanked, ReferrerDetailTypeIds } from "../referrer-detail"; import { - ReferrerDetailAllCyclesResponseCodes, + MAX_CYCLES_PER_REQUEST, + ReferralProgramCycleConfigSetResponseCodes, + ReferrerDetailCyclesResponseCodes, ReferrerLeaderboardPageResponseCodes, } from "./types"; /** - * Schema for ReferralProgramRules + * Schema for {@link ReferralProgramRules} */ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => z @@ -40,6 +43,7 @@ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralPro startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), + rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), }) .refine((data) => data.endTime >= data.startTime, { message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, @@ -204,120 +208,175 @@ export const makeReferrerDetailUnrankedSchema = (valueLabel: string = "ReferrerD }); /** - * Schema for {@link ReferrerDetailAllCyclesResponseOk} - * Accepts a record of cycle IDs to referrer details + * Schema for {@link ReferrerDetail} (discriminated union of ranked and unranked) */ -export const makeReferrerDetailAllCyclesResponseOkSchema = ( - valueLabel: string = "ReferrerDetailAllCyclesResponse", +export const makeReferrerDetailSchema = (valueLabel: string = "ReferrerDetail") => + z.discriminatedUnion("type", [ + makeReferrerDetailRankedSchema(valueLabel), + makeReferrerDetailUnrankedSchema(valueLabel), + ]); + +/** + * Schema for validating cycles array (min 1, max {@link MAX_CYCLES_PER_REQUEST}, distinct values). + */ +export const makeReferrerDetailCyclesArraySchema = ( + valueLabel: string = "ReferrerDetailCyclesArray", +) => + z + .array(makeReferralProgramCycleSlugSchema(`${valueLabel}[cycle]`)) + .min(1, `${valueLabel} must contain at least 1 cycle`) + .max( + MAX_CYCLES_PER_REQUEST, + `${valueLabel} must not contain more than ${MAX_CYCLES_PER_REQUEST} cycles`, + ) + .refine( + (cycles) => { + const uniqueCycles = new Set(cycles); + return uniqueCycles.size === cycles.length; + }, + { message: `${valueLabel} must not contain duplicate cycle slugs` }, + ); + +/** + * Schema for {@link ReferrerDetailCyclesRequest} + */ +export const makeReferrerDetailCyclesRequestSchema = ( + valueLabel: string = "ReferrerDetailCyclesRequest", ) => z.object({ - responseCode: z.literal(ReferrerDetailAllCyclesResponseCodes.Ok), + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + cycles: makeReferrerDetailCyclesArraySchema(`${valueLabel}.cycles`), + }); + +/** + * Schema for {@link ReferrerDetailCyclesResponseOk} + */ +export const makeReferrerDetailCyclesResponseOkSchema = ( + valueLabel: string = "ReferrerDetailCyclesResponse", +) => + z.object({ + responseCode: z.literal(ReferrerDetailCyclesResponseCodes.Ok), data: z.record( - makeReferralProgramCycleIdSchema(`${valueLabel}.data[cycle]`), - z.discriminatedUnion("type", [ - makeReferrerDetailRankedSchema(`${valueLabel}.data[cycle]`), - makeReferrerDetailUnrankedSchema(`${valueLabel}.data[cycle]`), - ]), + makeReferralProgramCycleSlugSchema(`${valueLabel}.data[cycle]`), + makeReferrerDetailSchema(`${valueLabel}.data[cycle]`), ), }); /** - * Schema for {@link ReferrerDetailAllCyclesResponseError} + * Schema for {@link ReferrerDetailCyclesResponseError} */ -export const makeReferrerDetailAllCyclesResponseErrorSchema = ( - _valueLabel: string = "ReferrerDetailAllCyclesResponse", +export const makeReferrerDetailCyclesResponseErrorSchema = ( + _valueLabel: string = "ReferrerDetailCyclesResponse", ) => z.object({ - responseCode: z.literal(ReferrerDetailAllCyclesResponseCodes.Error), + responseCode: z.literal(ReferrerDetailCyclesResponseCodes.Error), error: z.string(), errorMessage: z.string(), }); /** - * Schema for {@link ReferrerDetailAllCyclesResponse} + * Schema for {@link ReferrerDetailCyclesResponse} */ -export const makeReferrerDetailAllCyclesResponseSchema = ( - valueLabel: string = "ReferrerDetailAllCyclesResponse", +export const makeReferrerDetailCyclesResponseSchema = ( + valueLabel: string = "ReferrerDetailCyclesResponse", ) => z.discriminatedUnion("responseCode", [ - makeReferrerDetailAllCyclesResponseOkSchema(valueLabel), - makeReferrerDetailAllCyclesResponseErrorSchema(valueLabel), + makeReferrerDetailCyclesResponseOkSchema(valueLabel), + makeReferrerDetailCyclesResponseErrorSchema(valueLabel), ]); /** - * Schema for validating a {@link ReferralProgramCycleId}. + * Schema for validating a {@link ReferralProgramCycleSlug}. + * + * Enforces the slug format invariant: lowercase letters (a-z), digits (0-9), + * and hyphens (-) only. Must not start or end with a hyphen. * - * Note: This accepts any non-empty string to support custom cycle IDs loaded from - * CUSTOM_REFERRAL_PROGRAM_CYCLES. Runtime validation against configured cycles - * happens at the business logic level. + * Runtime validation against configured cycles happens at the business logic level. */ -export const makeReferralProgramCycleIdSchema = (valueLabel: string = "ReferralProgramCycleId") => - z.string().min(1, `${valueLabel} must not be empty`); +export const makeReferralProgramCycleSlugSchema = ( + valueLabel: string = "ReferralProgramCycleSlug", +) => + z + .string() + .min(1, `${valueLabel} must not be empty`) + .regex( + /^[a-z0-9]+(-[a-z0-9]+)*$/, + `${valueLabel} must contain only lowercase letters, digits, and hyphens. Must not start or end with a hyphen.`, + ); /** - * Schema for validating a {@link ReferralProgramCycle}. + * Schema for validating a {@link ReferralProgramCycleConfig}. */ -export const makeReferralProgramCycleSchema = (valueLabel: string = "ReferralProgramCycle") => +export const makeReferralProgramCycleConfigSchema = ( + valueLabel: string = "ReferralProgramCycleConfig", +) => z.object({ - id: makeReferralProgramCycleIdSchema(`${valueLabel}.id`), + slug: makeReferralProgramCycleSlugSchema(`${valueLabel}.slug`), displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), - rulesUrl: z.url(`${valueLabel}.rulesUrl must be a valid URL`), }); /** - * Schema for validating custom referral program cycles (array format from JSON). + * Schema for validating referral program cycle config set array. */ -export const makeCustomReferralProgramCyclesSchema = ( - valueLabel: string = "CustomReferralProgramCycles", +export const makeReferralProgramCycleConfigSetArraySchema = ( + valueLabel: string = "ReferralProgramCycleConfigSetArray", ) => z - .array(makeReferralProgramCycleSchema(`${valueLabel}[cycle]`)) + .array(makeReferralProgramCycleConfigSchema(`${valueLabel}[cycle]`)) .min(1, `${valueLabel} must contain at least one cycle`) .refine( (cycles) => { - const ids = new Set(); + const slugs = new Set(); for (const cycle of cycles) { - if (ids.has(cycle.id)) return false; - ids.add(cycle.id); + if (slugs.has(cycle.slug)) return false; + slugs.add(cycle.slug); } return true; }, - { message: `${valueLabel} must not contain duplicate cycle ids` }, + { message: `${valueLabel} must not contain duplicate cycle slugs` }, ); /** - * Schema for validating a {@link ReferralProgramCycleSet} (Map structure). + * Schema for {@link ReferralProgramCycleConfigSetData}. */ -export const makeReferralProgramCycleSetSchema = (valueLabel: string = "ReferralProgramCycleSet") => - z - .instanceof(Map, { - message: `${valueLabel} must be a Map`, - }) - .refine((map) => map.size >= 1, { - message: `${valueLabel} must contain at least one cycle`, - }) - .refine( - (map): map is Map => { - // Validate each entry in the map - for (const [key, value] of map.entries()) { - // Validate key is a string - if (typeof key !== "string") { - return false; - } - // Validate value structure using the cycle schema - try { - const parsed = makeReferralProgramCycleSchema(`${valueLabel}[${key}]`).parse(value); - if (parsed.id !== key) { - return false; - } - } catch { - return false; - } - } - return true; - }, - { - message: `${valueLabel} must be a Map`, - }, - ); +export const makeReferralProgramCycleConfigSetDataSchema = ( + valueLabel: string = "ReferralProgramCycleConfigSetData", +) => + z.object({ + cycles: z.array(makeReferralProgramCycleConfigSchema(`${valueLabel}.cycles[cycle]`)), + }); + +/** + * Schema for {@link ReferralProgramCycleConfigSetResponseOk}. + */ +export const makeReferralProgramCycleConfigSetResponseOkSchema = ( + valueLabel: string = "ReferralProgramCycleConfigSetResponseOk", +) => + z.object({ + responseCode: z.literal(ReferralProgramCycleConfigSetResponseCodes.Ok), + data: makeReferralProgramCycleConfigSetDataSchema(`${valueLabel}.data`), + }); + +/** + * Schema for {@link ReferralProgramCycleConfigSetResponseError}. + */ +export const makeReferralProgramCycleConfigSetResponseErrorSchema = ( + _valueLabel: string = "ReferralProgramCycleConfigSetResponseError", +) => + z.object({ + responseCode: z.literal(ReferralProgramCycleConfigSetResponseCodes.Error), + error: z.string(), + errorMessage: z.string(), + }); + +/** + * Schema for {@link ReferralProgramCycleConfigSetResponse}. + */ +export const makeReferralProgramCycleConfigSetResponseSchema = ( + valueLabel: string = "ReferralProgramCycleConfigSetResponse", +) => + z.discriminatedUnion("responseCode", [ + makeReferralProgramCycleConfigSetResponseOkSchema(valueLabel), + makeReferralProgramCycleConfigSetResponseErrorSchema(valueLabel), + ]); diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 2fad992b9..5e8dbbef0 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -1,13 +1,18 @@ import { - deserializeReferrerDetailAllCyclesResponse, + deserializeReferralProgramCycleConfigSetArray, + deserializeReferralProgramCycleConfigSetResponse, + deserializeReferrerDetailCyclesResponse, deserializeReferrerLeaderboardPageResponse, - type ReferrerDetailAllCyclesResponse, - type ReferrerDetailRequest, + type ReferralProgramCycleConfigSetResponse, + type ReferrerDetailCyclesRequest, + type ReferrerDetailCyclesResponse, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, - type SerializedReferrerDetailAllCyclesResponse, + type SerializedReferralProgramCycleConfigSetResponse, + type SerializedReferrerDetailCyclesResponse, type SerializedReferrerLeaderboardPageResponse, } from "./api"; +import type { ReferralProgramCycleConfigSet } from "./cycle"; /** * Default ENSNode API endpoint URL @@ -32,9 +37,9 @@ export interface ClientOptions { * // Create client with default options * const client = new ENSReferralsClient(); * - * // Get referrer leaderboard for cycle-1 + * // Get referrer leaderboard for December 2025 cycle * const leaderboardPage = await client.getReferrerLeaderboardPage({ - * cycle: "cycle-1", + * cycle: "2025-12", * page: 1, * recordsPerPage: 25 * }); @@ -70,15 +75,51 @@ export class ENSReferralsClient { }); } + /** + * Get Referral Program Cycle Config Set + * + * Fetches and deserializes a referral program cycle config set from a remote URL. + * + * @param url - The URL to fetch the cycle config set from + * @returns A ReferralProgramCycleConfigSet (Map of cycle slugs to cycle configurations) + * + * @throws if the fetch fails + * @throws if the response is not valid JSON + * @throws if the data doesn't match the expected schema + * + * @example + * ```typescript + * const url = new URL("https://example.com/cycles.json"); + * const cycleConfigSet = await ENSReferralsClient.getReferralProgramCycleConfigSet(url); + * console.log(`Loaded ${cycleConfigSet.size} cycles`); + * ``` + */ + static async getReferralProgramCycleConfigSet(url: URL): Promise { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + let json: unknown; + try { + json = await response.json(); + } catch { + throw new Error("Malformed response data: invalid JSON"); + } + + const cycleConfigs = deserializeReferralProgramCycleConfigSetArray(json); + + return new Map(cycleConfigs.map((cycleConfig) => [cycleConfig.slug, cycleConfig])); + } + /** * Fetch Referrer Leaderboard Page * * Retrieves a paginated list of referrer leaderboard metrics for a specific referral program cycle. - * Each referrer's contribution is calculated as a percentage of the grand totals across all referrers - * within that cycle. * * @param request - Request parameters including cycle and pagination - * @param request.cycle - The referral program cycle ID (e.g., "cycle-1", "cycle-2", or custom cycle ID) + * @param request.cycle - The referral program cycle slug (e.g., "2025-12", "2026-03", or custom cycle slug) * @param request.page - The page number to retrieve (1-indexed, default: 1) * @param request.recordsPerPage - Number of records per page (default: 25, max: 100) * @returns {ReferrerLeaderboardPageResponse} @@ -89,9 +130,9 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Get first page of cycle-1 leaderboard with default page size (25 records) - * const cycleId = "cycle-1"; - * const response = await client.getReferrerLeaderboardPage({ cycle: cycleId }); + * // Get first page of 2025-12 leaderboard with default page size (25 records) + * const cycleSlug = "2025-12"; + * const response = await client.getReferrerLeaderboardPage({ cycle: cycleSlug }); * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Ok) { * const { * aggregatedMetrics, @@ -100,7 +141,7 @@ export class ENSReferralsClient { * pageContext, * accurateAsOf * } = response.data; - * console.log(`Cycle: ${cycleId}`); + * console.log(`Cycle: ${cycleSlug}`); * console.log(`Subregistry: ${rules.subregistryId}`); * console.log(`Total Referrers: ${pageContext.totalRecords}`); * console.log(`Page ${pageContext.page} of ${pageContext.totalPages}`); @@ -109,9 +150,9 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Get second page of cycle-2 with 50 records per page + * // Get second page of 2026-03 with 50 records per page * const response = await client.getReferrerLeaderboardPage({ - * cycle: "cycle-2", + * cycle: "2026-03", * page: 2, * recordsPerPage: 50 * }); @@ -120,7 +161,7 @@ export class ENSReferralsClient { * @example * ```typescript * // Handle error response (e.g., unknown cycle or data not available) - * const response = await client.getReferrerLeaderboardPage({ cycle: "cycle-1" }); + * const response = await client.getReferrerLeaderboardPage({ cycle: "2025-12" }); * * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { * console.error(response.error); @@ -160,13 +201,13 @@ export class ENSReferralsClient { } /** - * Fetch Referrer Detail Across All Cycles + * Fetch Referrer Detail for Specific Cycles * - * Retrieves detailed information about a specific referrer across all configured - * referral program cycles. Returns a record mapping each cycle ID to the referrer's - * detail for that cycle. + * Retrieves detailed information about a specific referrer for the requested + * referral program cycles. Returns a record mapping each requested cycle slug + * to the referrer's detail for that cycle. * - * The response data maps cycle IDs to referrer details. Each cycle's data is a + * The response data maps cycle slugs to referrer details. Each cycle's data is a * discriminated union type with a `type` field: * * **For referrers on the leaderboard** (`ReferrerDetailRanked`): @@ -183,28 +224,29 @@ export class ENSReferralsClient { * - `aggregatedMetrics`: Aggregated metrics for all referrers on the leaderboard * - `accurateAsOf`: Unix timestamp indicating when the data was last updated * - * **Note:** The API uses a fail-fast approach. If ANY cycle fails to load, the entire request - * returns an error. When `responseCode === Ok`, ALL configured cycles are guaranteed to be - * present in the response data. + * **Note:** This endpoint does not allow partial success. When `responseCode === Ok`, + * all requested cycles are guaranteed to be present in the response data. If any + * requested cycle cannot be returned, the entire request fails with an error. * * @see {@link https://www.npmjs.com/package/@namehash/ens-referrals|@namehash/ens-referrals} for calculation details * - * @param request The referrer address to query - * @returns {ReferrerDetailAllCyclesResponse} Returns the referrer detail for all cycles + * @param request The referrer address and cycle slugs to query + * @returns {ReferrerDetailCyclesResponse} Returns the referrer detail for requested cycles * * @throws if the ENSNode request fails * @throws if the response data is malformed * * @example * ```typescript - * // Get referrer detail across all cycles - * const response = await client.getReferrerDetail({ - * referrer: "0x1234567890123456789012345678901234567890" + * // Get referrer detail for specific cycles + * const response = await client.getReferrerDetailForCycles({ + * referrer: "0x1234567890123456789012345678901234567890", + * cycles: ["2025-12", "2026-01"] * }); - * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { - * // All configured cycles are present in response.data - * for (const [cycleId, detail] of Object.entries(response.data)) { - * console.log(`Cycle: ${cycleId}`); + * if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { + * // All requested cycles are present in response.data + * for (const [cycleSlug, detail] of Object.entries(response.data)) { + * console.log(`Cycle: ${cycleSlug}`); * console.log(`Type: ${detail.type}`); * if (detail.type === ReferrerDetailTypeIds.Ranked) { * console.log(`Rank: ${detail.referrer.rank}`); @@ -217,44 +259,47 @@ export class ENSReferralsClient { * @example * ```typescript * // Access specific cycle data directly (cycle is guaranteed to exist when OK) - * const response = await client.getReferrerDetail({ - * referrer: "0x1234567890123456789012345678901234567890" + * const response = await client.getReferrerDetailForCycles({ + * referrer: "0x1234567890123456789012345678901234567890", + * cycles: ["2025-12"] * }); - * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Ok) { - * // If "cycle-1" is configured, it will be in response.data - * const cycle1Detail = response.data["cycle-1"]; - * if (cycle1Detail && cycle1Detail.type === ReferrerDetailTypeIds.Ranked) { + * if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { + * const cycle202512Detail = response.data["2025-12"]; + * if (cycle202512Detail && cycle202512Detail.type === ReferrerDetailTypeIds.Ranked) { * // TypeScript knows this is ReferrerDetailRanked - * console.log(`Cycle 1 Rank: ${cycle1Detail.referrer.rank}`); - * } else if (cycle1Detail) { + * console.log(`Cycle 2025-12 Rank: ${cycle202512Detail.referrer.rank}`); + * } else if (cycle202512Detail) { * // TypeScript knows this is ReferrerDetailUnranked - * console.log("Referrer is not on the leaderboard for cycle-1"); + * console.log("Referrer is not on the leaderboard for 2025-12"); * } * } * ``` * * @example * ```typescript - * // Handle error response (e.g., a cycle failed to load) - * const response = await client.getReferrerDetail({ - * referrer: "0x1234567890123456789012345678901234567890" + * // Handle error response (e.g., unknown cycle or data not available) + * const response = await client.getReferrerDetailForCycles({ + * referrer: "0x1234567890123456789012345678901234567890", + * cycles: ["2025-12", "invalid-cycle"] * }); * - * if (response.responseCode === ReferrerDetailAllCyclesResponseCodes.Error) { + * if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { * console.error(response.error); - * // Error message includes which cycle failed * console.error(response.errorMessage); * } * ``` */ - async getReferrerDetail( - request: ReferrerDetailRequest, - ): Promise { + async getReferrerDetailForCycles( + request: ReferrerDetailCyclesRequest, + ): Promise { const url = new URL( - `/v1/ensanalytics/referral-leaderboard/${encodeURIComponent(request.referrer)}`, + `/v1/ensanalytics/referrer/${encodeURIComponent(request.referrer)}`, this.options.url, ); + // Add cycles as comma-separated query parameter + url.searchParams.set("cycles", request.cycles.join(",")); + const response = await fetch(url); // ENSNode API should always allow parsing a response as JSON object. @@ -266,13 +311,66 @@ export class ENSReferralsClient { throw new Error("Malformed response data: invalid JSON"); } - // The API can return errors with 500 status, but they're still in the - // ReferrerDetailAllCyclesResponse format with responseCode: 'error' + // The API can return errors with various status codes, but they're still in the + // ReferrerDetailCyclesResponse format with responseCode: 'error' + // So we don't need to check response.ok here, just deserialize and let + // the caller handle the responseCode + + return deserializeReferrerDetailCyclesResponse( + responseData as SerializedReferrerDetailCyclesResponse, + ); + } + + /** + * Get the currently configured referral program cycle config set. + * Cycles are sorted in descending order by start timestamp (most recent first). + * + * @returns A response containing the cycle config set, or an error response if unavailable. + * + * @example + * ```typescript + * const response = await client.getCycleConfigSet(); + * + * if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Ok) { + * console.log(`Found ${response.data.cycles.length} cycles`); + * for (const cycle of response.data.cycles) { + * console.log(`${cycle.slug}: ${cycle.displayName}`); + * } + * } + * ``` + * + * @example + * ```typescript + * // Handle error response + * const response = await client.getCycleConfigSet(); + * + * if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Error) { + * console.error(response.error); + * console.error(response.errorMessage); + * } + * ``` + */ + async getCycleConfigSet(): Promise { + const url = new URL(`/v1/ensanalytics/cycles`, this.options.url); + + const response = await fetch(url); + + // ENSNode API should always allow parsing a response as JSON object. + // If for some reason it's not the case, throw an error. + let responseData: unknown; + try { + responseData = await response.json(); + } catch { + throw new Error("Malformed response data: invalid JSON"); + } + + // The API can return errors with various status codes, but they're still in the + // ReferralProgramCycleConfigSetResponse format with responseCode: 'error' // So we don't need to check response.ok here, just deserialize and let // the caller handle the responseCode - return deserializeReferrerDetailAllCyclesResponse( - responseData as SerializedReferrerDetailAllCyclesResponse, + return deserializeReferralProgramCycleConfigSetResponse( + responseData as SerializedReferralProgramCycleConfigSetResponse, ); } } diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/cycle-defaults.ts index 761db572f..0102a95af 100644 --- a/packages/ens-referrals/src/v1/cycle-defaults.ts +++ b/packages/ens-referrals/src/v1/cycle-defaults.ts @@ -1,125 +1,56 @@ -import { type AccountId, priceUsdc, type UnixTimestamp } from "@ensnode/ensnode-sdk"; - import { - type ReferralProgramCycle, - ReferralProgramCycleIds, - type ReferralProgramCycleSet, -} from "./cycle"; -import { buildReferralProgramRules } from "./rules"; - -/** - * Configuration for Cycle 1: ENS Holiday Awards (December 2025) - */ -const CYCLE_1_CONFIG = { - /** - * Start date for the ENS Holiday Awards referral program. - * 2025-12-01T00:00:00Z (December 1, 2025 at 00:00:00 UTC) - */ - START_DATE: 1764547200 as UnixTimestamp, - - /** - * End date for the ENS Holiday Awards referral program. - * 2025-12-31T23:59:59Z (December 31, 2025 at 23:59:59 UTC) - */ - END_DATE: 1767225599 as UnixTimestamp, - - /** - * The maximum number of qualified referrers. - */ - MAX_QUALIFIED_REFERRERS: 10, - - /** - * The total value of the award pool in USDC. - * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) - */ - TOTAL_AWARD_POOL_VALUE: priceUsdc(10_000_000_000n), - - /** - * Display name for the cycle. - */ - DISPLAY_NAME: "ENS Holiday Awards", - - /** - * URL to the rules for this cycle. - */ - RULES_URL: "https://ensawards.org/ens-holiday-awards-rules", -} as const; + type ENSNamespaceId, + getEthnamesSubregistryId, + parseTimestamp, + parseUsdc, +} from "@ensnode/ensnode-sdk"; -/** - * Configuration for Cycle 2: March 2026 - */ -const CYCLE_2_CONFIG = { - /** - * Start date for the March 2026 referral program. - * 2026-03-01T00:00:00Z (March 1, 2026 at 00:00:00 UTC) - */ - START_DATE: 1772323200 as UnixTimestamp, - - /** - * End date for the March 2026 referral program. - * 2026-03-31T23:59:59Z (March 31, 2026 at 23:59:59 UTC) - */ - END_DATE: 1775001599 as UnixTimestamp, - - /** - * The maximum number of qualified referrers. - */ - MAX_QUALIFIED_REFERRERS: 10, - - /** - * The total value of the award pool in USDC. - * 10,000 USDC = 10,000,000,000 (10_000 * 10^6 smallest units) - */ - TOTAL_AWARD_POOL_VALUE: priceUsdc(10_000_000_000n), - - /** - * Display name for the cycle. - */ - DISPLAY_NAME: "March 2026", - - /** - * URL to the rules for this cycle. - */ - RULES_URL: "https://ensawards.org/ens-holiday-awards-rules", -} as const; +import type { ReferralProgramCycleConfig, ReferralProgramCycleConfigSet } from "./cycle"; +import { buildReferralProgramRules } from "./rules"; /** - * Returns the default referral program cycle set with pre-built cycle definitions. + * Returns the default referral program cycle set with pre-built cycle configurations. + * + * This function maps from an ENS namespace to the appropriate subregistry (BaseRegistrar) + * and builds the default referral program cycles for that namespace. * - * @param subregistryId - The subregistry account ID for rule validation (e.g., BaseRegistrar on the namespace chain) - * @returns A map of cycle IDs to their pre-built cycle configurations + * @param ensNamespaceId - The ENS namespace slug to get the default cycles for + * @returns A map of cycle slugs to their pre-built cycle configurations + * @throws Error if the subregistry contract is not found for the given namespace */ -export function getReferralProgramCycleSet(subregistryId: AccountId): ReferralProgramCycleSet { - // Pre-built cycle-1 object (ENS Holiday Awards Dec 2025) - const cycle1: ReferralProgramCycle = { - id: ReferralProgramCycleIds.Cycle1, - displayName: CYCLE_1_CONFIG.DISPLAY_NAME, +export function getDefaultReferralProgramCycleConfigSet( + ensNamespaceId: ENSNamespaceId, +): ReferralProgramCycleConfigSet { + const subregistryId = getEthnamesSubregistryId(ensNamespaceId); + + const cycle1: ReferralProgramCycleConfig = { + slug: "2025-12", + displayName: "ENS Holiday Awards", rules: buildReferralProgramRules( - CYCLE_1_CONFIG.TOTAL_AWARD_POOL_VALUE, - CYCLE_1_CONFIG.MAX_QUALIFIED_REFERRERS, - CYCLE_1_CONFIG.START_DATE, - CYCLE_1_CONFIG.END_DATE, + 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"), ), - rulesUrl: CYCLE_1_CONFIG.RULES_URL, }; - // Pre-built cycle-2 object (March 2026) - const cycle2: ReferralProgramCycle = { - id: ReferralProgramCycleIds.Cycle2, - displayName: CYCLE_2_CONFIG.DISPLAY_NAME, + const cycle2: ReferralProgramCycleConfig = { + slug: "2026-03", + displayName: "March 2026", rules: buildReferralProgramRules( - CYCLE_2_CONFIG.TOTAL_AWARD_POOL_VALUE, - CYCLE_2_CONFIG.MAX_QUALIFIED_REFERRERS, - CYCLE_2_CONFIG.START_DATE, - CYCLE_2_CONFIG.END_DATE, + parseUsdc("10000"), + 10, + parseTimestamp("2026-03-01T00:00:00Z"), + parseTimestamp("2026-03-31T23:59:59Z"), subregistryId, + new URL("https://ensawards.org/ens-holiday-awards-rules"), ), - rulesUrl: CYCLE_2_CONFIG.RULES_URL, }; return new Map([ - [ReferralProgramCycleIds.Cycle1, cycle1], - [ReferralProgramCycleIds.Cycle2, cycle2], + ["2025-12", cycle1], + ["2026-03", cycle2], ]); } diff --git a/packages/ens-referrals/src/v1/cycle.ts b/packages/ens-referrals/src/v1/cycle.ts index 7b4ba28ff..5436012b9 100644 --- a/packages/ens-referrals/src/v1/cycle.ts +++ b/packages/ens-referrals/src/v1/cycle.ts @@ -1,55 +1,32 @@ import type { ReferralProgramRules } from "./rules"; /** - * Referral program cycle identifiers. + * Referral program cycle slug. * - * Each cycle represents a distinct referral program period with its own - * rules, leaderboard, and award distribution. - */ -export const ReferralProgramCycleIds = { - /** ENS Holiday Awards December 2025 */ - Cycle1: "cycle-1", - /** March 2026 */ - Cycle2: "cycle-2", -} as const; - -/** - * Referral program cycle identifier. + * A URL-safe identifier for a referral program cycle. Each cycle represents + * a distinct referral program period with its own rules, leaderboard, and + * award distribution. * - * Can be either a predefined cycle ID (e.g., "cycle-1", "cycle-2") or a custom cycle ID. - * The type provides autocomplete for known cycle IDs while accepting any string for custom cycles. - */ -export type ReferralProgramCycleId = - | (typeof ReferralProgramCycleIds)[keyof typeof ReferralProgramCycleIds] - | (string & {}); - -/** - * Array of all valid referral program cycle IDs. - */ -export const ALL_REFERRAL_PROGRAM_CYCLE_IDS: ReferralProgramCycleId[] = - Object.values(ReferralProgramCycleIds); - -/** - * Type guard to check if a string is a predefined {@link ReferralProgramCycleId}. - * - * Note: This only checks for predefined cycle IDs (e.g., "cycle-1", "cycle-2"). - * Custom cycle IDs loaded from CUSTOM_REFERRAL_PROGRAM_CYCLES are valid - * ReferralProgramCycleIds but won't pass this check. + * @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]+)*$` * - * @param value - The string value to check - * @returns true if the value is a predefined cycle ID + * @example "2025-12" // December 2025 cycle + * @example "2026-03" // March 2026 cycle + * @example "holiday-special" // Custom named cycle */ -export const isPredefinedCycleId = (value: string): value is ReferralProgramCycleId => - ALL_REFERRAL_PROGRAM_CYCLE_IDS.includes(value as ReferralProgramCycleId); +export type ReferralProgramCycleSlug = string; /** - * Represents a referral program cycle with its configuration and rules. + * Represents a referral program cycle configuration. */ -export interface ReferralProgramCycle { +export interface ReferralProgramCycleConfig { /** - * Unique identifier for the cycle. + * Unique slug identifier for the cycle. + * + * @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]+)*$` */ - id: ReferralProgramCycleId; + slug: ReferralProgramCycleSlug; /** * Human-readable display name for the cycle. @@ -61,17 +38,14 @@ export interface ReferralProgramCycle { * The rules that govern this referral program cycle. */ rules: ReferralProgramRules; - - /** - * URL to the full rules document for this cycle. - * @example "https://ensawards.org/ens-holiday-awards-rules" - */ - rulesUrl: string; } /** - * A map from cycle ID to cycle definition. + * A map from cycle slug to cycle configuration. * * Used to store and look up all configured referral program cycles. */ -export type ReferralProgramCycleSet = Map; +export type ReferralProgramCycleConfigSet = Map< + ReferralProgramCycleSlug, + ReferralProgramCycleConfig +>; diff --git a/packages/ens-referrals/src/v1/leaderboard-page.test.ts b/packages/ens-referrals/src/v1/leaderboard-page.test.ts index 79f141cf9..a5ea43826 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.test.ts @@ -28,6 +28,7 @@ describe("buildReferrerLeaderboardPageContext", () => { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", }, + rulesUrl: new URL("https://example.com/rules"), }, aggregatedMetrics: { grandTotalReferrals: 17, @@ -113,6 +114,7 @@ describe("buildReferrerLeaderboardPageContext", () => { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", }, + rulesUrl: new URL("https://example.com/rules"), }, aggregatedMetrics: { grandTotalReferrals: 17, diff --git a/packages/ens-referrals/src/v1/rules.ts b/packages/ens-referrals/src/v1/rules.ts index cad1fae05..2334abb3c 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -34,6 +34,12 @@ export interface ReferralProgramRules { * The account ID of the subregistry for the referral program. */ subregistryId: AccountId; + + /** + * URL to the full rules document for this cycle. + * @example new URL("https://ensawards.org/ens-holiday-awards-rules") + */ + rulesUrl: URL; } export const validateReferralProgramRules = (rules: ReferralProgramRules): void => { @@ -72,6 +78,7 @@ export const buildReferralProgramRules = ( startTime: UnixTimestamp, endTime: UnixTimestamp, subregistryId: AccountId, + rulesUrl: URL, ): ReferralProgramRules => { const result = { totalAwardPoolValue, @@ -79,6 +86,7 @@ export const buildReferralProgramRules = ( startTime, endTime, subregistryId, + rulesUrl, } satisfies ReferralProgramRules; validateReferralProgramRules(result); diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts index 9fedb710a..0e28a1b9e 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts @@ -501,9 +501,9 @@ describe("SWRCache", () => { await vi.runAllTimersAsync(); expect(fn).toHaveBeenCalledTimes(2); - // After failed revalidation, should still return initial error + // After failed revalidation, should still return revalidation error const result3 = await cache.read(); - expect(result3).toBe(initialError); + expect(result3).toBe(revalidationError); // Advance time again to make error stale for next revalidation vi.advanceTimersByTime(2000); @@ -554,4 +554,190 @@ describe("SWRCache", () => { expect(fn).toHaveBeenCalledTimes(1); }); }); + + describe("errorTtl", () => { + it("uses errorTtl for cached errors instead of ttl", async () => { + let shouldError = true; + const fn = vi.fn(async () => { + if (shouldError) throw new Error("Error"); + return "success"; + }); + + const cache = new SWRCache({ + fn, + ttl: 10, // 10 seconds for success + errorTtl: 2, // 2 seconds for errors + }); + + // Initial error + const result1 = await cache.read(); + expect(result1).toBeInstanceOf(Error); + expect(fn).toHaveBeenCalledTimes(1); + + // Advance by 1 second (less than errorTtl) - should not revalidate + vi.advanceTimersByTime(1000); + const result2 = await cache.read(); + expect(result2).toBeInstanceOf(Error); + expect(fn).toHaveBeenCalledTimes(1); // No new call + + // Advance by 2 more seconds (total 3, exceeds errorTtl of 2) - should revalidate + vi.advanceTimersByTime(2000); + shouldError = false; + + const result3 = await cache.read(); + expect(result3).toBeInstanceOf(Error); // Still stale error + expect(fn).toHaveBeenCalledTimes(2); // Revalidation triggered + + // Wait for revalidation to complete + await vi.runAllTimersAsync(); + + // Now should have success + const result4 = await cache.read(); + expect(result4).toBe("success"); + }); + + it("switches to normal ttl after successful revalidation", async () => { + let shouldError = true; + const fn = vi.fn(async () => { + if (shouldError) throw new Error("Error"); + return "success"; + }); + + const cache = new SWRCache({ + fn, + ttl: 10, // 10 seconds for success + errorTtl: 2, // 2 seconds for errors + }); + + // Initial error + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + // Advance past errorTtl + vi.advanceTimersByTime(3000); + shouldError = false; + + // Trigger revalidation (will succeed) + await cache.read(); + await vi.runAllTimersAsync(); + expect(fn).toHaveBeenCalledTimes(2); + + // Now have success - advance by 3 seconds (less than ttl of 10) + vi.advanceTimersByTime(3000); + await cache.read(); + expect(fn).toHaveBeenCalledTimes(2); // Should NOT revalidate (ttl not exceeded) + + // Advance by 8 more seconds (total 11, exceeds ttl of 10) + vi.advanceTimersByTime(8000); + await cache.read(); + expect(fn).toHaveBeenCalledTimes(3); // Should revalidate (ttl exceeded) + }); + + it("retries errors indefinitely when ttl is infinite but errorTtl is finite", async () => { + let callCount = 0; + const fn = vi.fn(async () => { + callCount++; + if (callCount <= 2) throw new Error(`Error ${callCount}`); + return "finally success"; + }); + + const cache = new SWRCache({ + fn, + ttl: Number.POSITIVE_INFINITY, // Never revalidate success + errorTtl: 2, // Retry errors every 2 seconds + }); + + // First error + const result1 = await cache.read(); + expect(result1).toBeInstanceOf(Error); + expect((result1 as Error).message).toBe("Error 1"); + expect(fn).toHaveBeenCalledTimes(1); + + // Advance past errorTtl - should retry + vi.advanceTimersByTime(3000); + await cache.read(); + await vi.runAllTimersAsync(); + expect(fn).toHaveBeenCalledTimes(2); + + const result2 = await cache.read(); + expect(result2).toBeInstanceOf(Error); + expect((result2 as Error).message).toBe("Error 2"); + + // Advance past errorTtl again - should retry and succeed + vi.advanceTimersByTime(3000); + await cache.read(); + await vi.runAllTimersAsync(); + expect(fn).toHaveBeenCalledTimes(3); + + const result3 = await cache.read(); + expect(result3).toBe("finally success"); + + // Advance by a very long time - should NOT revalidate (infinite ttl) + vi.advanceTimersByTime(1000000); + await cache.read(); + expect(fn).toHaveBeenCalledTimes(3); // Still 3, no new calls + }); + + it("uses normal ttl when errorTtl is not specified (backward compatibility)", async () => { + const shouldError = true; + const fn = vi.fn(async () => { + if (shouldError) throw new Error("Error"); + return "success"; + }); + + const cache = new SWRCache({ + fn, + ttl: 5, // 5 seconds for both success and errors + // errorTtl not specified + }); + + // Initial error + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + // Advance by 3 seconds (less than ttl) - should not revalidate + vi.advanceTimersByTime(3000); + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + // Advance by 3 more seconds (total 6, exceeds ttl of 5) - should revalidate + vi.advanceTimersByTime(3000); + await cache.read(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("works with proactive initialization and errorTtl", async () => { + let shouldError = true; + const fn = vi.fn(async () => { + if (shouldError) throw new Error("Error"); + return "success"; + }); + + const cache = new SWRCache({ + fn, + ttl: 10, + errorTtl: 2, + proactivelyInitialize: true, + }); + + // Should have called fn immediately + expect(fn).toHaveBeenCalledTimes(1); + + // Wait for initialization to complete + await vi.runAllTimersAsync(); + + const result1 = await cache.read(); + expect(result1).toBeInstanceOf(Error); + + // Advance past errorTtl + vi.advanceTimersByTime(3000); + shouldError = false; + + await cache.read(); + await vi.runAllTimersAsync(); + + const result2 = await cache.read(); + expect(result2).toBe("success"); + }); + }); }); diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts index ba113723a..789ffa0fc 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts @@ -33,9 +33,25 @@ export interface SWRCacheOptions { * - Each time the cache is read, if the cached result is "stale" and no background * revalidation attempt is already in progress, a new background revalidation * attempt will be made. + * + * This TTL applies to successfully cached values. For error results, see `errorTtl`. */ ttl: Duration; + /** + * Optional time-to-live duration for cached errors in seconds. + * + * If specified, this TTL is used instead of `ttl` when the cached result is an Error. + * This allows different revalidation strategies for errors vs successful results. + * + * Common use case: Set `ttl` to `Number.POSITIVE_INFINITY` (never revalidate successful results) + * and `errorTtl` to a shorter duration (e.g., 60 seconds) to retry failed fetches periodically + * until they succeed. + * + * If not specified, errors use the same `ttl` as successful results. + */ + errorTtl?: Duration; + /** * Optional time-to-proactively-revalidate duration in seconds. After a cached result is * initialized, and this duration has passed, attempts to asynchronously revalidate @@ -108,7 +124,7 @@ export class SWRCache { }) .catch((error) => { // on error, only update the cache if this is the first revalidation - if (!this.cache) { + if (!this.cache || this.cache.result instanceof Error) { this.cache = { // ensure thrown value is always an Error instance result: error instanceof Error ? error : new Error(String(error)), @@ -139,8 +155,14 @@ export class SWRCache { // NOTE: not documenting read() as throwable because this is just for typechecking if (!this.cache) throw new Error("never"); + // Determine which TTL to use: errorTtl for errors (if specified), otherwise ttl + const effectiveTtl = + this.cache.result instanceof Error && this.options.errorTtl !== undefined + ? this.options.errorTtl + : this.options.ttl; + // if ttl expired, revalidate in background - if (durationBetween(this.cache.updatedAt, getUnixTime(new Date())) > this.options.ttl) { + if (durationBetween(this.cache.updatedAt, getUnixTime(new Date())) > effectiveTtl) { this.revalidate(); } diff --git a/packages/ensnode-sdk/src/shared/config/environments.ts b/packages/ensnode-sdk/src/shared/config/environments.ts index 52777baeb..23708c272 100644 --- a/packages/ensnode-sdk/src/shared/config/environments.ts +++ b/packages/ensnode-sdk/src/shared/config/environments.ts @@ -55,12 +55,12 @@ export type TheGraphEnvironment = { /** * Environment variables for referral program cycles configuration. * - * If CUSTOM_REFERRAL_PROGRAM_CYCLES is set, it should be a URL pointing to - * a JSON file containing custom cycle definitions. + * If CUSTOM_REFERRAL_PROGRAM_CYCLES is set, it should be a URL that returns + * the JSON for a valid serialized custom referral program cycles definition. */ export interface ReferralProgramCyclesEnvironment { /** - * Optional URL to a JSON file containing custom referral program cycle definitions. + * Optional URL that returns the JSON for a valid serialized custom referral program cycles definition. * If not set, the default cycle set will be used. */ CUSTOM_REFERRAL_PROGRAM_CYCLES?: string; diff --git a/packages/ensnode-sdk/src/shared/datetime.test.ts b/packages/ensnode-sdk/src/shared/datetime.test.ts index e2d904397..c57f9925a 100644 --- a/packages/ensnode-sdk/src/shared/datetime.test.ts +++ b/packages/ensnode-sdk/src/shared/datetime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { addDuration, durationBetween } from "./datetime"; +import { addDuration, durationBetween, parseTimestamp } from "./datetime"; describe("datetime", () => { describe("durationBetween()", () => { @@ -27,4 +27,24 @@ describe("datetime", () => { expect(addDuration(1000000, 999999)).toEqual(1999999); }); }); + + describe("parseTimestamp()", () => { + it("parses ISO 8601 date strings correctly", () => { + expect(parseTimestamp("2025-12-01T00:00:00Z")).toEqual(1764547200); + expect(parseTimestamp("2026-03-31T23:59:59Z")).toEqual(1775001599); + expect(parseTimestamp("2026-03-01T00:00:00Z")).toEqual(1772323200); + expect(parseTimestamp("2025-12-31T23:59:59Z")).toEqual(1767225599); + }); + + it("parses date strings without timezone", () => { + // The exact value depends on the system timezone, but it should not throw + expect(() => parseTimestamp("2025-01-01T00:00:00")).not.toThrow(); + }); + + it("throws an error for invalid date strings", () => { + expect(() => parseTimestamp("invalid-date")).toThrowError(/Invalid date string/); + expect(() => parseTimestamp("")).toThrowError(/Invalid date string/); + expect(() => parseTimestamp("2025-13-01T00:00:00Z")).toThrowError(/Invalid date string/); + }); + }); }); diff --git a/packages/ensnode-sdk/src/shared/datetime.ts b/packages/ensnode-sdk/src/shared/datetime.ts index ad95f3b43..0a81cb56e 100644 --- a/packages/ensnode-sdk/src/shared/datetime.ts +++ b/packages/ensnode-sdk/src/shared/datetime.ts @@ -1,3 +1,5 @@ +import { getUnixTime } from "date-fns/getUnixTime"; + import { deserializeDuration, deserializeUnixTimestamp } from "./deserialize"; import type { Duration, UnixTimestamp } from "./types"; @@ -14,3 +16,28 @@ export function durationBetween(start: UnixTimestamp, end: UnixTimestamp): Durat export function addDuration(timestamp: UnixTimestamp, duration: Duration): UnixTimestamp { return deserializeUnixTimestamp(timestamp + duration, "UnixTimestamp"); } + +/** + * Parses an ISO 8601 date string into a {@link UnixTimestamp}. + * + * Accepts date strings in ISO 8601 format (e.g., "2025-12-01T00:00:00Z"). + * The string must be parseable by JavaScript's Date constructor. + * + * @param isoDateString - The ISO 8601 date string to parse + * @returns The Unix timestamp (seconds since epoch) + * + * @throws {Error} If the date string is invalid or cannot be parsed + * + * @example + * parseTimestamp("2025-12-01T00:00:00Z") // returns 1764547200 + * parseTimestamp("2026-03-31T23:59:59Z") // returns 1775001599 + */ +export function parseTimestamp(isoDateString: string): UnixTimestamp { + const date = new Date(isoDateString); + + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date string: ${isoDateString}`); + } + + return getUnixTime(date) as UnixTimestamp; +} From 3d2f89068252389562e221960ed5cf1718a11e13 Mon Sep 17 00:00:00 2001 From: Goader Date: Sun, 8 Feb 2026 15:36:02 +0100 Subject: [PATCH 12/16] renamed cycles to editions, and referrer detail to referrer edition metrics --- apps/ensapi/.env.local.example | 22 +- .../referral-leaderboard-cycles.cache.ts | 161 ---- .../referral-leaderboard-editions.cache.ts | 161 ++++ .../cache/referral-program-cycle-set.cache.ts | 77 -- .../referral-program-edition-set.cache.ts | 77 ++ apps/ensapi/src/config/config.schema.test.ts | 8 +- apps/ensapi/src/config/config.schema.ts | 12 +- apps/ensapi/src/config/environment.ts | 4 +- .../src/handlers/ensanalytics-api-v1.test.ts | 718 +++++++++--------- .../src/handlers/ensanalytics-api-v1.ts | 250 +++--- apps/ensapi/src/index.ts | 20 +- apps/ensapi/src/lib/hono-factory.ts | 8 +- ...al-leaderboard-cycles-caches.middleware.ts | 63 -- ...-leaderboard-editions-caches.middleware.ts | 63 ++ .../referral-program-cycle-set.middleware.ts | 31 - ...referral-program-edition-set.middleware.ts | 33 + .../ens-referrals/src/v1/api/deserialize.ts | 50 +- .../ens-referrals/src/v1/api/serialize.ts | 114 +-- .../src/v1/api/serialized-types.ts | 107 +-- packages/ens-referrals/src/v1/api/types.ts | 110 +-- .../ens-referrals/src/v1/api/zod-schemas.ts | 169 +++-- packages/ens-referrals/src/v1/client.ts | 182 ++--- packages/ens-referrals/src/v1/cycle.ts | 51 -- ...{cycle-defaults.ts => edition-defaults.ts} | 22 +- ...{referrer-detail.ts => edition-metrics.ts} | 54 +- packages/ens-referrals/src/v1/edition.ts | 51 ++ packages/ens-referrals/src/v1/index.ts | 6 +- packages/ens-referrals/src/v1/rules.ts | 2 +- .../src/shared/config/environments.ts | 14 +- 29 files changed, 1341 insertions(+), 1299 deletions(-) delete mode 100644 apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts create mode 100644 apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts delete mode 100644 apps/ensapi/src/cache/referral-program-cycle-set.cache.ts create mode 100644 apps/ensapi/src/cache/referral-program-edition-set.cache.ts delete mode 100644 apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts create mode 100644 apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts delete mode 100644 apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts create mode 100644 apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts delete mode 100644 packages/ens-referrals/src/v1/cycle.ts rename packages/ens-referrals/src/v1/{cycle-defaults.ts => edition-defaults.ts} (66%) rename packages/ens-referrals/src/v1/{referrer-detail.ts => edition-metrics.ts} (64%) create mode 100644 packages/ens-referrals/src/v1/edition.ts diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 7b97a12d2..4c5704e08 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -112,14 +112,14 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # it receives to The Graph's hosted subgraphs using this API key. # THEGRAPH_API_KEY= -# Custom Referral Program Cycle Config Set Definition (optional) -# URL that returns JSON for a custom referral program cycle config set. -# If not set, the default cycle config set for the namespace is used. +# 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 cycle config objects (SerializedReferralProgramCycleConfig[]). -# For the complete schema definition, see makeReferralProgramCycleConfigSetArraySchema in @namehash/ens-referrals/v1 -# +# 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 at ENSApi startup (before accepting requests) @@ -132,9 +132,9 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # - Requests received before initial load completes will receive error responses # # Configuration Notes: -# - Setting CUSTOM_REFERRAL_PROGRAM_CYCLES completely replaces the default cycle config set -# - Include all cycle configs you want active in the JSON -# - Array must contain at least one cycle config -# - All cycle IDs must be unique +# - 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_CYCLES=https://example.com/custom-cycles.json +# CUSTOM_REFERRAL_PROGRAM_EDITIONS=https://example.com/custom-editions.json diff --git a/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts b/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts deleted file mode 100644 index 59f458609..000000000 --- a/apps/ensapi/src/cache/referral-leaderboard-cycles.cache.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - type ReferralProgramCycleConfig, - type ReferralProgramCycleConfigSet, - type ReferralProgramCycleSlug, - 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-cycles-cache"); - -/** - * Map from cycle slug to its leaderboard cache. - * - * Each cycle has its own independent cache. Therefore, each - * cycle's cache can be asynchronously loaded / refreshed from - * others, and a failure to load data for one cycle doesn't break - * data successfully loaded for other cycles. - */ -export type ReferralLeaderboardCyclesCacheMap = Map< - ReferralProgramCycleSlug, - SWRCache ->; - -/** - * 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 cycle. - * - * @param cycleConfig - The cycle configuration - * @returns A function that builds the leaderboard for the given cycle - */ -function createCycleLeaderboardBuilder( - cycleConfig: ReferralProgramCycleConfig, -): () => Promise { - return async (): Promise => { - const cycleSlug = cycleConfig.slug; - - const indexingStatus = await indexingStatusCache.read(); - if (indexingStatus instanceof Error) { - logger.error( - { error: indexingStatus, cycleSlug }, - `Failed to read indexing status cache while generating referral leaderboard for ${cycleSlug}. Cannot proceed without valid indexing status.`, - ); - throw new Error( - `Unable to generate referral leaderboard for ${cycleSlug}. indexingStatusCache must have been successfully initialized.`, - ); - } - - const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; - if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { - throw new Error( - `Unable to generate referrer leaderboard for ${cycleSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, - ); - } - - const latestIndexedBlockRef = getLatestIndexedBlockRef( - indexingStatus, - cycleConfig.rules.subregistryId.chainId, - ); - if (latestIndexedBlockRef === null) { - throw new Error( - `Unable to generate referrer leaderboard for ${cycleSlug}. Latest indexed block ref for chain ${cycleConfig.rules.subregistryId.chainId} is null.`, - ); - } - - logger.info( - `Building referrer leaderboard for ${cycleSlug} with rules:\n${JSON.stringify( - serializeReferralProgramRules(cycleConfig.rules), - null, - 2, - )}`, - ); - - const leaderboard = await getReferrerLeaderboard( - cycleConfig.rules, - latestIndexedBlockRef.timestamp, - ); - - logger.info( - `Successfully built referrer leaderboard for ${cycleSlug} with ${leaderboard.referrers.size} referrers`, - ); - - return leaderboard; - }; -} - -/** - * Singleton instance of the initialized caches. - * Ensures caches are only initialized once per application lifecycle. - */ -let cachedInstance: ReferralLeaderboardCyclesCacheMap | null = null; - -/** - * Initializes caches for all referral program cycles in the given cycle set. - * - * This function uses a singleton pattern to ensure caches are only initialized once, - * even if called multiple times. Each cycle gets its own independent SWRCache, - * ensuring that if one cycle fails to refresh, other cycles' previously successful - * data remains available. - * - * @param cycleConfigSet - The referral program cycle config set to initialize caches for - * @returns A map from cycle slug to its dedicated SWRCache - */ -export function initializeReferralLeaderboardCyclesCaches( - cycleConfigSet: ReferralProgramCycleConfigSet, -): ReferralLeaderboardCyclesCacheMap { - // Return cached instance if already initialized - if (cachedInstance !== null) { - return cachedInstance; - } - - const caches: ReferralLeaderboardCyclesCacheMap = new Map(); - - for (const [cycleSlug, cycleConfig] of cycleConfigSet) { - const cache = new SWRCache({ - fn: createCycleLeaderboardBuilder(cycleConfig), - ttl: minutesToSeconds(1), - proactiveRevalidationInterval: minutesToSeconds(2), - proactivelyInitialize: true, - }); - - caches.set(cycleSlug, cache); - logger.info(`Initialized leaderboard cache for ${cycleSlug}`); - } - - // Cache the instance for subsequent calls - cachedInstance = caches; - return caches; -} - -/** - * Gets the cached instance of referral leaderboard cycles caches. - * Returns null if not yet initialized. - * - * @returns The cached cache map or null - */ -export function getReferralLeaderboardCyclesCaches(): ReferralLeaderboardCyclesCacheMap | null { - return cachedInstance; -} diff --git a/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts b/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts new file mode 100644 index 000000000..2672356a2 --- /dev/null +++ b/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts @@ -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 +>; + +/** + * 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 { + return async (): Promise => { + 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; +} diff --git a/apps/ensapi/src/cache/referral-program-cycle-set.cache.ts b/apps/ensapi/src/cache/referral-program-cycle-set.cache.ts deleted file mode 100644 index 9a413d168..000000000 --- a/apps/ensapi/src/cache/referral-program-cycle-set.cache.ts +++ /dev/null @@ -1,77 +0,0 @@ -import config from "@/config"; - -import { - ENSReferralsClient, - getDefaultReferralProgramCycleConfigSet, - type ReferralProgramCycleConfigSet, -} 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-cycle-set-cache"); - -/** - * Loads the referral program cycle config set from custom URL or defaults. - */ -async function loadReferralProgramCycleConfigSet(): Promise { - // Check if custom URL is configured - if (config.customReferralProgramCycleConfigSetUrl) { - logger.info( - `Loading custom referral program cycle config set from: ${config.customReferralProgramCycleConfigSetUrl.href}`, - ); - try { - const cycleConfigSet = await ENSReferralsClient.getReferralProgramCycleConfigSet( - config.customReferralProgramCycleConfigSetUrl, - ); - logger.info(`Successfully loaded ${cycleConfigSet.size} custom referral program cycles`); - return cycleConfigSet; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to load custom referral program cycle config set from ${config.customReferralProgramCycleConfigSetUrl.href}: ${errorMessage}`, - ); - } - } - - // Use default cycle config set for the namespace - logger.info( - `Loading default referral program cycle config set for namespace: ${config.namespace}`, - ); - const cycleConfigSet = getDefaultReferralProgramCycleConfigSet(config.namespace); - logger.info(`Successfully loaded ${cycleConfigSet.size} default referral program cycles`); - return cycleConfigSet; -} - -/** - * SWR Cache for the referral program cycle config set. - * - * Once successfully loaded, the cycle 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 referralProgramCycleConfigSetCache = new SWRCache({ - fn: async () => { - try { - const cycleConfigSet = await loadReferralProgramCycleConfigSet(); - logger.info("Referral program cycle config set cached successfully"); - return cycleConfigSet; - } catch (error) { - logger.error( - error, - "Error occurred while loading referral program cycle config set. The cache will remain empty.", - ); - throw error; - } - }, - ttl: Number.POSITIVE_INFINITY, - errorTtl: minutesToSeconds(1), - proactiveRevalidationInterval: undefined, - proactivelyInitialize: true, -}); diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts new file mode 100644 index 000000000..0ffb99659 --- /dev/null +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -0,0 +1,77 @@ +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 { + // 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); + 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({ + fn: async () => { + try { + const editionConfigSet = await loadReferralProgramEditionConfigSet(); + logger.info("Referral program edition config set cached successfully"); + return editionConfigSet; + } catch (error) { + logger.error( + error, + "Error occurred while loading referral program edition config set. The cache will remain empty.", + ); + throw error; + } + }, + ttl: Number.POSITIVE_INFINITY, + errorTtl: minutesToSeconds(1), + proactiveRevalidationInterval: undefined, + proactivelyInitialize: true, +}); diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 4a2561fab..32dbfb03c 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -79,7 +79,7 @@ describe("buildConfigFromEnvironment", () => { } satisfies RpcConfig, ], ]), - customReferralProgramCycleConfigSetUrl: undefined, + customReferralProgramEditionConfigSetUrl: undefined, }); }); @@ -160,7 +160,7 @@ describe("buildEnsApiPublicConfig", () => { } satisfies RpcConfig, ], ]), - customReferralProgramCycleConfigSetUrl: undefined, + customReferralProgramEditionConfigSetUrl: undefined, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -184,7 +184,7 @@ describe("buildEnsApiPublicConfig", () => { namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, rpcConfigs: new Map(), - customReferralProgramCycleConfigSetUrl: undefined, + customReferralProgramEditionConfigSetUrl: undefined, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -218,7 +218,7 @@ describe("buildEnsApiPublicConfig", () => { namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, rpcConfigs: new Map(), - customReferralProgramCycleConfigSetUrl: undefined, + customReferralProgramEditionConfigSetUrl: undefined, theGraphApiKey: "secret-api-key", }; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index a6caf665d..af74bd41c 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -43,17 +43,17 @@ export const DatabaseUrlSchema = z.string().refine( ); /** - * Schema for validating custom referral program cycle config set URL. + * Schema for validating custom referral program edition config set URL. */ -const CustomReferralProgramCycleConfigSetUrlSchema = z +const CustomReferralProgramEditionConfigSetUrlSchema = z .string() .transform((val, ctx) => { try { return new URL(val); } catch { ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `CUSTOM_REFERRAL_PROGRAM_CYCLES is not a valid URL: ${val}`, + code: "custom", + message: `CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL: ${val}`, }); return z.NEVER; } @@ -70,7 +70,7 @@ const EnsApiConfigSchema = z namespace: ENSNamespaceSchema, rpcConfigs: RpcConfigsSchema, ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"), - customReferralProgramCycleConfigSetUrl: CustomReferralProgramCycleConfigSetUrlSchema, + customReferralProgramEditionConfigSetUrl: CustomReferralProgramEditionConfigSetUrlSchema, }) .check(invariant_rpcConfigsSpecifiedForRootChain) .check(invariant_ensIndexerPublicConfigVersionInfo); @@ -107,7 +107,7 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis namespace: ensIndexerPublicConfig.namespace, databaseSchemaName: ensIndexerPublicConfig.databaseSchemaName, rpcConfigs, - customReferralProgramCycleConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_CYCLES, + customReferralProgramEditionConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_EDITIONS, }); } catch (error) { if (error instanceof ZodError) { diff --git a/apps/ensapi/src/config/environment.ts b/apps/ensapi/src/config/environment.ts index f71a7523a..119490fdf 100644 --- a/apps/ensapi/src/config/environment.ts +++ b/apps/ensapi/src/config/environment.ts @@ -3,7 +3,7 @@ import type { EnsIndexerUrlEnvironment, LogLevelEnvironment, PortEnvironment, - ReferralProgramCyclesEnvironment, + ReferralProgramEditionsEnvironment, RpcEnvironment, TheGraphEnvironment, } from "@ensnode/ensnode-sdk/internal"; @@ -21,4 +21,4 @@ export type EnsApiEnvironment = Omit & PortEnvironment & LogLevelEnvironment & TheGraphEnvironment & - ReferralProgramCyclesEnvironment; + ReferralProgramEditionsEnvironment; diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 53fe3f1b9..a79f9669c 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -5,8 +5,8 @@ import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; -import * as cyclesCachesMiddleware from "../middleware/referral-leaderboard-cycles-caches.middleware"; -import * as cycleSetMiddleware from "../middleware/referral-program-cycle-set.middleware"; +import * as editionsCachesMiddleware from "../middleware/referral-leaderboard-editions-caches.middleware"; +import * as editionSetMiddleware from "../middleware/referral-program-edition-set.middleware"; vi.mock("@/config", () => ({ get default() { @@ -19,27 +19,27 @@ vi.mock("@/config", () => ({ }, })); -vi.mock("../middleware/referral-program-cycle-set.middleware", () => ({ - referralProgramCycleConfigSetMiddleware: vi.fn(), +vi.mock("../middleware/referral-program-edition-set.middleware", () => ({ + referralProgramEditionConfigSetMiddleware: vi.fn(), })); -vi.mock("../middleware/referral-leaderboard-cycles-caches.middleware", () => ({ - referralLeaderboardCyclesCachesMiddleware: vi.fn(), +vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => ({ + referralLeaderboardEditionsCachesMiddleware: vi.fn(), })); import { buildReferralProgramRules, - deserializeReferralProgramCycleConfigSetResponse, - deserializeReferrerDetailCyclesResponse, + deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, - ReferralProgramCycleConfigSetResponseCodes, - type ReferralProgramCycleSlug, - ReferrerDetailCyclesResponseCodes, - type ReferrerDetailCyclesResponseOk, - ReferrerDetailTypeIds, + deserializeReferrerMetricsEditionsResponse, + ReferralProgramEditionConfigSetResponseCodes, + type ReferralProgramEditionSlug, + ReferrerEditionMetricsTypeIds, type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, + ReferrerMetricsEditionsResponseCodes, + type ReferrerMetricsEditionsResponseOk, } from "@namehash/ens-referrals/v1"; import { parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; @@ -56,31 +56,33 @@ describe("/v1/ensanalytics", () => { describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { // Arrange: mock cache map with 2025-12 - const mockCyclesCaches = new Map>([ + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + // 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 }], ]); - vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -91,23 +93,23 @@ describe("/v1/ensanalytics", () => { // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; - const cycle = "2025-12"; + const edition = "2025-12"; // Act: send test request to fetch 1st page const responsePage1 = await client["referral-leaderboard"] - .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) + .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Act: send test request to fetch 2nd page const responsePage2 = await client["referral-leaderboard"] - .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) + .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Act: send test request to fetch 3rd page const responsePage3 = await client["referral-leaderboard"] - .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) + .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); @@ -175,42 +177,44 @@ describe("/v1/ensanalytics", () => { it("returns empty cached referrer leaderboard when there are no referrals yet", async () => { // Arrange: mock cache map with 2025-12 - const mockCyclesCaches = new Map>([ + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => emptyReferralLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + // 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 }], ]); - vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; - const cycle = "2025-12"; + const edition = "2025-12"; // Act: send test request to fetch 1st page const response = await client["referral-leaderboard"] - .$get({ query: { cycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) + .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); @@ -234,102 +238,106 @@ describe("/v1/ensanalytics", () => { expect(response).toMatchObject(expectedResponse); }); - it("returns 404 error when unknown cycle slug is requested", async () => { - // Arrange: mock cache map with test-cycle-a and test-cycle-b - const mockCyclesCaches = new Map>([ + it("returns 404 error when unknown edition slug is requested", async () => { + // Arrange: mock cache map with test-edition-a and test-edition-b + const mockEditionsCaches = new Map>( [ - "test-cycle-a", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, + [ + "test-edition-a", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "test-edition-b", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], ], - [ - "test-cycle-b", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], + // 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 }], ]); - vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; - const invalidCycle = "invalid-cycle"; + const invalidEdition = "invalid-edition"; - // Act: send test request with invalid cycle slug + // Act: send test request with invalid edition slug const httpResponse = await client["referral-leaderboard"].$get( - { query: { cycle: invalidCycle, recordsPerPage: `${recordsPerPage}`, page: "1" } }, + { query: { edition: invalidEdition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}, ); const responseData = await httpResponse.json(); const response = deserializeReferrerLeaderboardPageResponse(responseData); - // Assert: response is 404 error with list of valid cycles from config + // Assert: response is 404 error with list of valid editions from config expect(httpResponse.status).toBe(404); expect(response.responseCode).toBe(ReferrerLeaderboardPageResponseCodes.Error); if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { expect(response.error).toBe("Not Found"); expect(response.errorMessage).toBe( - "Unknown cycle: invalid-cycle. Valid cycles: test-cycle-a, test-cycle-b", + "Unknown edition: invalid-edition. Valid editions: test-edition-a, test-edition-b", ); } }); }); describe("/referrer/:referrer", () => { - it("returns referrer metrics for requested cycles when referrer exists", async () => { - // Arrange: mock cache map with multiple cycles - const mockCyclesCaches = new Map>([ + it("returns referrer metrics for requested editions when referrer exists", async () => { + // Arrange: mock cache map with multiple editions + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + // 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(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -338,71 +346,73 @@ describe("/v1/ensanalytics", () => { const expectedMetrics = populatedReferrerLeaderboard.referrers.get(existingReferrer)!; const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; - // Act: send test request to fetch referrer detail for requested cycles + // Act: send test request to fetch referrer detail for requested editions const httpResponse = await app.request( - `/referrer/${existingReferrer}?cycles=2025-12,2026-03`, + `/referrer/${existingReferrer}?editions=2025-12,2026-03`, ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains the expected referrer metrics for requested cycles + // Assert: response contains the expected referrer metrics for requested editions const expectedResponse = { - responseCode: ReferrerDetailCyclesResponseCodes.Ok, + responseCode: ReferrerMetricsEditionsResponseCodes.Ok, data: { "2025-12": { - type: ReferrerDetailTypeIds.Ranked, + type: ReferrerEditionMetricsTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, }, "2026-03": { - type: ReferrerDetailTypeIds.Ranked, + type: ReferrerEditionMetricsTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, }, }, - } satisfies ReferrerDetailCyclesResponseOk; + } satisfies ReferrerMetricsEditionsResponseOk; expect(response).toMatchObject(expectedResponse); }); - it("returns zero-score metrics for requested cycles when referrer does not exist", async () => { - // Arrange: mock cache map with multiple cycles - const mockCyclesCaches = new Map>([ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + it("returns zero-score metrics for requested editions when referrer does not exist", async () => { + // Arrange: mock cache map with multiple editions + const mockEditionsCaches = new Map>( [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + // 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(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -411,79 +421,81 @@ describe("/v1/ensanalytics", () => { // Act: send test request to fetch referrer detail const httpResponse = await app.request( - `/referrer/${nonExistingReferrer}?cycles=2025-12,2026-03`, + `/referrer/${nonExistingReferrer}?editions=2025-12,2026-03`, ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains zero-score metrics for the referrer across requested cycles + // Assert: response contains zero-score metrics for the referrer across requested editions const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; - expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Ok); - if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { - const cycle1 = response.data["2025-12"]!; - const cycle2 = response.data["2026-03"]!; + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Ok); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { + const edition1 = response.data["2025-12"]!; + const edition2 = response.data["2026-03"]!; // Check 2025-12 - expect(cycle1.type).toBe(ReferrerDetailTypeIds.Unranked); - expect(cycle1.rules).toEqual(populatedReferrerLeaderboard.rules); - expect(cycle1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); - expect(cycle1.referrer.referrer).toBe(nonExistingReferrer); - expect(cycle1.referrer.rank).toBe(null); - expect(cycle1.referrer.totalReferrals).toBe(0); - expect(cycle1.referrer.totalIncrementalDuration).toBe(0); - expect(cycle1.referrer.score).toBe(0); - expect(cycle1.referrer.isQualified).toBe(false); - expect(cycle1.referrer.finalScoreBoost).toBe(0); - expect(cycle1.referrer.finalScore).toBe(0); - expect(cycle1.referrer.awardPoolShare).toBe(0); - expect(cycle1.referrer.awardPoolApproxValue).toStrictEqual({ + expect(edition1.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); + expect(edition1.rules).toEqual(populatedReferrerLeaderboard.rules); + expect(edition1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); + expect(edition1.referrer.referrer).toBe(nonExistingReferrer); + expect(edition1.referrer.rank).toBe(null); + expect(edition1.referrer.totalReferrals).toBe(0); + expect(edition1.referrer.totalIncrementalDuration).toBe(0); + expect(edition1.referrer.score).toBe(0); + expect(edition1.referrer.isQualified).toBe(false); + expect(edition1.referrer.finalScoreBoost).toBe(0); + expect(edition1.referrer.finalScore).toBe(0); + expect(edition1.referrer.awardPoolShare).toBe(0); + expect(edition1.referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); - expect(cycle1.accurateAsOf).toBe(expectedAccurateAsOf); + expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); // Check 2026-03 - expect(cycle2.type).toBe(ReferrerDetailTypeIds.Unranked); - expect(cycle2.referrer.referrer).toBe(nonExistingReferrer); - expect(cycle2.referrer.rank).toBe(null); + expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); + expect(edition2.referrer.referrer).toBe(nonExistingReferrer); + expect(edition2.referrer.rank).toBe(null); } }); - it("returns zero-score metrics for requested cycles when leaderboards are empty", async () => { - // Arrange: mock cache map with multiple cycles, all empty - const mockCyclesCaches = new Map>([ + it("returns zero-score metrics for requested editions when leaderboards are empty", async () => { + // Arrange: mock cache map with multiple editions, all empty + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => emptyReferralLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], ], - [ - "2026-03", - { - read: async () => emptyReferralLeaderboard, - } as SWRCache, - ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + // 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(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -491,135 +503,139 @@ describe("/v1/ensanalytics", () => { const referrer = "0x0000000000000000000000000000000000000001"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,2026-03`); + const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains zero-score metrics for the referrer across requested cycles + // Assert: response contains zero-score metrics for the referrer across requested editions const expectedAccurateAsOf = emptyReferralLeaderboard.accurateAsOf; - expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Ok); - if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { - const cycle1 = response.data["2025-12"]!; - const cycle2 = response.data["2026-03"]!; + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Ok); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { + const edition1 = response.data["2025-12"]!; + const edition2 = response.data["2026-03"]!; // Check 2025-12 - expect(cycle1.type).toBe(ReferrerDetailTypeIds.Unranked); - expect(cycle1.rules).toEqual(emptyReferralLeaderboard.rules); - expect(cycle1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); - expect(cycle1.referrer.referrer).toBe(referrer); - expect(cycle1.referrer.rank).toBe(null); - expect(cycle1.referrer.totalReferrals).toBe(0); - expect(cycle1.referrer.totalIncrementalDuration).toBe(0); - expect(cycle1.referrer.score).toBe(0); - expect(cycle1.referrer.isQualified).toBe(false); - expect(cycle1.referrer.finalScoreBoost).toBe(0); - expect(cycle1.referrer.finalScore).toBe(0); - expect(cycle1.referrer.awardPoolShare).toBe(0); - expect(cycle1.referrer.awardPoolApproxValue).toStrictEqual({ + expect(edition1.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); + expect(edition1.rules).toEqual(emptyReferralLeaderboard.rules); + expect(edition1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); + expect(edition1.referrer.referrer).toBe(referrer); + expect(edition1.referrer.rank).toBe(null); + expect(edition1.referrer.totalReferrals).toBe(0); + expect(edition1.referrer.totalIncrementalDuration).toBe(0); + expect(edition1.referrer.score).toBe(0); + expect(edition1.referrer.isQualified).toBe(false); + expect(edition1.referrer.finalScoreBoost).toBe(0); + expect(edition1.referrer.finalScore).toBe(0); + expect(edition1.referrer.awardPoolShare).toBe(0); + expect(edition1.referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); - expect(cycle1.accurateAsOf).toBe(expectedAccurateAsOf); + expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); // Check 2026-03 - expect(cycle2.type).toBe(ReferrerDetailTypeIds.Unranked); - expect(cycle2.referrer.referrer).toBe(referrer); - expect(cycle2.referrer.rank).toBe(null); + expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); + expect(edition2.referrer.referrer).toBe(referrer); + expect(edition2.referrer.rank).toBe(null); } }); - it("returns error response when any requested cycle cache fails to load", async () => { + it("returns error response when any requested edition cache fails to load", async () => { // Arrange: mock cache map where 2025-12 succeeds but 2026-03 fails - const mockCyclesCaches = new Map>([ + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => new Error("Database connection failed"), - } as SWRCache, + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + // 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(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); // Arrange: use any referrer address const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; - // Act: send test request to fetch referrer detail for both cycles - const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,2026-03`); + // Act: send test request to fetch referrer detail for both editions + const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains error mentioning the specific cycle that failed + // Assert: response contains error mentioning the specific edition that failed expect(httpResponse.status).toBe(503); - expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Error); - if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Error); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { expect(response.error).toBe("Service Unavailable"); expect(response.errorMessage).toContain("2026-03"); expect(response.errorMessage).toBe( - "Referrer leaderboard data not cached for cycle(s): 2026-03", + "Referrer leaderboard data not cached for edition(s): 2026-03", ); } }); - it("returns error response when all requested cycle caches fail to load", async () => { - // Arrange: mock cache map where all cycles fail - const mockCyclesCaches = new Map>([ + it("returns error response when all requested edition caches fail to load", async () => { + // Arrange: mock cache map where all editions fail + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => new Error("Database connection failed"), - } as SWRCache, + [ + "2025-12", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], ], - [ - "2026-03", - { - read: async () => new Error("Database connection failed"), - } as SWRCache, - ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + // 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(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -627,137 +643,143 @@ describe("/v1/ensanalytics", () => { const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,2026-03`); + const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains error for all failed cycles + // Assert: response contains error for all failed editions expect(httpResponse.status).toBe(503); - expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Error); - if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Error); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { expect(response.error).toBe("Service Unavailable"); expect(response.errorMessage).toContain("2025-12"); expect(response.errorMessage).toContain("2026-03"); expect(response.errorMessage).toBe( - "Referrer leaderboard data not cached for cycle(s): 2025-12, 2026-03", + "Referrer leaderboard data not cached for edition(s): 2025-12, 2026-03", ); } }); - it("returns 404 error when unknown cycle slug is requested", async () => { - // Arrange: mock cache map with configured cycles - const mockCyclesCaches = new Map>([ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + it("returns 404 error when unknown edition slug is requested", async () => { + // Arrange: mock cache map with configured editions + const mockEditionsCaches = new Map>( [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], + // 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(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + 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 cycle - const httpResponse = await app.request(`/referrer/${referrer}?cycles=2025-12,invalid-cycle`); + // 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 = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response is 404 error with list of valid cycles + // Assert: response is 404 error with list of valid editions expect(httpResponse.status).toBe(404); - expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Error); - if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Error); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { expect(response.error).toBe("Not Found"); - expect(response.errorMessage).toContain("invalid-cycle"); + expect(response.errorMessage).toContain("invalid-edition"); expect(response.errorMessage).toBe( - "Unknown cycle(s): invalid-cycle. Valid cycles: 2025-12, 2026-03", + "Unknown edition(s): invalid-edition. Valid editions: 2025-12, 2026-03", ); } }); - it("returns only requested cycle data when subset is requested", async () => { - // Arrange: mock cache map with multiple cycles - const mockCyclesCaches = new Map>([ + it("returns only requested edition data when subset is requested", async () => { + // Arrange: mock cache map with multiple editions + const mockEditionsCaches = new Map>( [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-06", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], ], - [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-06", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - ]); + ); - // Mock cycle set middleware to provide a mock cycle set - const mockCycleConfigSet = new Map([ - ["2025-12", { slug: "2025-12", displayName: "Cycle 1", rules: {} as any }], - ["2026-03", { slug: "2026-03", displayName: "Cycle 2", rules: {} as any }], - ["2026-06", { slug: "2026-06", displayName: "Cycle 3", rules: {} as any }], + // 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 }], + ["2026-06", { slug: "2026-06", displayName: "Edition 3", rules: {} as any }], ]); - vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware to provide the mock caches vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", mockCyclesCaches); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); // Arrange: use a referrer address that exists in the leaderboard const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; - // Act: send test request requesting only 2 out of 3 cycles + // Act: send test request requesting only 2 out of 3 editions const httpResponse = await app.request( - `/referrer/${existingReferrer}?cycles=2025-12,2026-06`, + `/referrer/${existingReferrer}?editions=2025-12,2026-06`, ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailCyclesResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains only the requested cycles - expect(response.responseCode).toBe(ReferrerDetailCyclesResponseCodes.Ok); - if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { + // Assert: response contains only the requested editions + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Ok); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { expect(response.data["2025-12"]).toBeDefined(); expect(response.data["2026-06"]).toBeDefined(); expect(response.data["2026-03"]).toBeUndefined(); @@ -765,10 +787,10 @@ describe("/v1/ensanalytics", () => { }); }); - describe("/cycles", () => { - it("returns configured cycle config set sorted by start timestamp descending", async () => { - // Arrange: mock cycle config set with multiple cycles - const mockCycleConfigSet = new Map([ + describe("/editions", () => { + it("returns configured edition config set sorted by start timestamp descending", async () => { + // Arrange: mock edition config set with multiple editions + const mockEditionConfigSet = new Map([ [ "2025-12", { @@ -816,74 +838,74 @@ describe("/v1/ensanalytics", () => { ], ]); - // Mock cycle set middleware - vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + // Mock edition set middleware + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", mockCycleConfigSet); + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); return await next(); }, ); // Mock caches middleware (needed by middleware chain) vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", new Map()); + c.set("referralLeaderboardEditionsCaches", new Map()); return await next(); }); // Act: send test request - const httpResponse = await app.request("/cycles"); + const httpResponse = await app.request("/editions"); const responseData = await httpResponse.json(); - const response = deserializeReferralProgramCycleConfigSetResponse(responseData); + const response = deserializeReferralProgramEditionConfigSetResponse(responseData); - // Assert: response contains all cycles sorted by start timestamp descending + // Assert: response contains all editions sorted by start timestamp descending expect(httpResponse.status).toBe(200); - expect(response.responseCode).toBe(ReferralProgramCycleConfigSetResponseCodes.Ok); + expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Ok); - if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Ok) { - expect(response.data.cycles).toHaveLength(3); + if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) { + expect(response.data.editions).toHaveLength(3); // Verify sorting: most recent start time first - expect(response.data.cycles[0].slug).toBe("2026-06"); - expect(response.data.cycles[1].slug).toBe("2026-03"); - expect(response.data.cycles[2].slug).toBe("2025-12"); - - // Verify all cycle data is present - expect(response.data.cycles[0].displayName).toBe("June 2026"); - expect(response.data.cycles[1].displayName).toBe("March 2026"); - expect(response.data.cycles[2].displayName).toBe("December 2025"); + expect(response.data.editions[0].slug).toBe("2026-06"); + expect(response.data.editions[1].slug).toBe("2026-03"); + expect(response.data.editions[2].slug).toBe("2025-12"); + + // Verify all edition data is present + expect(response.data.editions[0].displayName).toBe("June 2026"); + expect(response.data.editions[1].displayName).toBe("March 2026"); + expect(response.data.editions[2].displayName).toBe("December 2025"); } }); - it("returns 503 error when cycle config set fails to load", async () => { - // Arrange: mock cycle set middleware to return Error - const loadError = new Error("Failed to fetch cycle config set"); - vi.mocked(cycleSetMiddleware.referralProgramCycleConfigSetMiddleware).mockImplementation( + it("returns 503 error when edition config set fails to load", async () => { + // Arrange: mock edition set middleware to return Error + const loadError = new Error("Failed to fetch edition config set"); + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { - c.set("referralProgramCycleConfigSet", loadError); + c.set("referralProgramEditionConfigSet", loadError); return await next(); }, ); // Mock caches middleware (needed by middleware chain) vi.mocked( - cyclesCachesMiddleware.referralLeaderboardCyclesCachesMiddleware, + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardCyclesCaches", new Map()); + c.set("referralLeaderboardEditionsCaches", new Map()); return await next(); }); // Act: send test request - const httpResponse = await app.request("/cycles"); + const httpResponse = await app.request("/editions"); const responseData = await httpResponse.json(); - const response = deserializeReferralProgramCycleConfigSetResponse(responseData); + const response = deserializeReferralProgramEditionConfigSetResponse(responseData); // Assert: response is error expect(httpResponse.status).toBe(503); - expect(response.responseCode).toBe(ReferralProgramCycleConfigSetResponseCodes.Error); + expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Error); - if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Error) { + if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) { expect(response.error).toBe("Service Unavailable"); expect(response.errorMessage).toContain("currently unavailable"); } diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index d7cd5a75a..5806befb0 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -1,25 +1,25 @@ import { - getReferrerDetail, + getReferrerEditionMetrics, getReferrerLeaderboardPage, - MAX_CYCLES_PER_REQUEST, + MAX_EDITIONS_PER_REQUEST, REFERRERS_PER_LEADERBOARD_PAGE_MAX, - type ReferralProgramCycleConfigSetResponse, - ReferralProgramCycleConfigSetResponseCodes, - type ReferralProgramCycleSlug, - type ReferrerDetailCyclesData, - type ReferrerDetailCyclesResponse, - ReferrerDetailCyclesResponseCodes, + type ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionConfigSetResponseCodes, + type ReferralProgramEditionSlug, type ReferrerLeaderboard, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, - serializeReferralProgramCycleConfigSetResponse, - serializeReferrerDetailCyclesResponse, + type ReferrerMetricsEditionsData, + type ReferrerMetricsEditionsResponse, + ReferrerMetricsEditionsResponseCodes, + serializeReferralProgramEditionConfigSetResponse, serializeReferrerLeaderboardPageResponse, + serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals/v1"; import { - makeReferralProgramCycleSlugSchema, - makeReferrerDetailCyclesArraySchema, + makeReferralProgramEditionSlugSchema, + makeReferrerMetricsEditionsArraySchema, } from "@namehash/ens-referrals/v1/internal"; import { describeRoute } from "hono-openapi"; import { z } from "zod/v4"; @@ -29,17 +29,17 @@ import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; -import { referralLeaderboardCyclesCachesMiddleware } from "@/middleware/referral-leaderboard-cycles-caches.middleware"; -import { referralProgramCycleConfigSetMiddleware } from "@/middleware/referral-program-cycle-set.middleware"; +import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; const logger = makeLogger("ensanalytics-api-v1"); /** * Query parameters schema for referrer leaderboard page requests. - * Validates cycle slug, page number, and records per page. + * Validates edition slug, page number, and records per page. */ const referrerLeaderboardPageQuerySchema = z.object({ - cycle: makeReferralProgramCycleSlugSchema("cycle"), + edition: makeReferralProgramEditionSlugSchema("edition"), page: z .optional(z.coerce.number().int().min(1, "Page must be a positive integer")) .describe("Page number for pagination"), @@ -60,25 +60,25 @@ const referrerLeaderboardPageQuerySchema = z.object({ const app = factory .createApp() - // Apply referral program cycle config set middleware - .use(referralProgramCycleConfigSetMiddleware) + // Apply referral program edition config set middleware + .use(referralProgramEditionConfigSetMiddleware) - // Apply referrer leaderboard cache middleware (depends on cycle config set middleware) - .use(referralLeaderboardCyclesCachesMiddleware) + // Apply referrer leaderboard cache middleware (depends on edition config set middleware) + .use(referralLeaderboardEditionsCachesMiddleware) - // Get a page from the referrer leaderboard for a specific cycle + // Get a page from the referrer leaderboard for a specific edition .get( "/referral-leaderboard", describeRoute({ tags: ["ENSAwards"], summary: "Get Referrer Leaderboard (v1)", - description: "Returns a paginated page from the referrer leaderboard for a specific cycle", + description: "Returns a paginated page from the referrer leaderboard for a specific edition", responses: { 200: { description: "Successfully retrieved referrer leaderboard page", }, 404: { - description: "Unknown cycle slug", + description: "Unknown edition slug", }, 500: { description: "Internal server error", @@ -91,20 +91,20 @@ const app = factory validate("query", referrerLeaderboardPageQuerySchema), async (c) => { // context must be set by the required middleware - if (c.var.referralLeaderboardCyclesCaches === undefined) { + if (c.var.referralLeaderboardEditionsCaches === undefined) { throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`, + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, ); } try { - const { cycle, page, recordsPerPage } = c.req.valid("query"); + const { edition, page, recordsPerPage } = c.req.valid("query"); - // Check if cycle set failed to load - if (c.var.referralLeaderboardCyclesCaches instanceof Error) { + // Check if edition set failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { logger.error( - { error: c.var.referralLeaderboardCyclesCaches }, - "Referral program cycle set failed to load", + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program edition set failed to load", ); return c.json( serializeReferrerLeaderboardPageResponse({ @@ -116,31 +116,31 @@ const app = factory ); } - // Get the specific cycle's cache - const cycleCache = c.var.referralLeaderboardCyclesCaches.get(cycle); + // Get the specific edition's cache + const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition); - if (!cycleCache) { - const configuredCycles = Array.from(c.var.referralLeaderboardCyclesCaches.keys()); + if (!editionCache) { + const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys()); return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, error: "Not Found", - errorMessage: `Unknown cycle: ${cycle}. Valid cycles: ${configuredCycles.join(", ")}`, + errorMessage: `Unknown edition: ${edition}. Valid editions: ${configuredEditions.join(", ")}`, } satisfies ReferrerLeaderboardPageResponse), 404, ); } - // Read from the cycle's cache - const leaderboard = await cycleCache.read(); + // Read from the edition's cache + const leaderboard = await editionCache.read(); - // Check if this specific cycle failed to build + // Check if this specific edition failed to build if (leaderboard instanceof Error) { return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, error: "Service Unavailable", - errorMessage: `Failed to load leaderboard for cycle ${cycle}.`, + errorMessage: `Failed to load leaderboard for edition ${edition}.`, } satisfies ReferrerLeaderboardPageResponse), 503, ); @@ -177,32 +177,32 @@ const referrerAddressSchema = z.object({ referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), }); -// Cycles query parameter schema -const cyclesQuerySchema = z.object({ - cycles: z +// Editions query parameter schema +const editionsQuerySchema = z.object({ + editions: z .string() - .describe("Comma-separated list of cycle slugs") + .describe("Comma-separated list of edition slugs") .transform((value) => value.split(",").map((s) => s.trim())) - .pipe(makeReferrerDetailCyclesArraySchema("cycles")), + .pipe(makeReferrerMetricsEditionsArraySchema("editions")), }); -// Get referrer detail for a specific address for requested cycles +// Get referrer detail for a specific address for requested editions app .get( "/referrer/:referrer", describeRoute({ tags: ["ENSAwards"], - summary: "Get Referrer Detail for Cycles (v1)", - description: `Returns detailed information for a specific referrer for the requested cycles. Requires 1-${MAX_CYCLES_PER_REQUEST} distinct cycle slugs. All requested cycles must be recognized and have cached data, or the request fails.`, + 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.`, responses: { 200: { - description: "Successfully retrieved referrer detail for requested cycles", + description: "Successfully retrieved referrer detail for requested editions", }, 400: { description: "Invalid request", }, 404: { - description: "Unknown cycle slug", + description: "Unknown edition slug", }, 500: { description: "Internal server error", @@ -213,102 +213,104 @@ app }, }), validate("param", referrerAddressSchema), - validate("query", cyclesQuerySchema), + validate("query", editionsQuerySchema), async (c) => { // context must be set by the required middleware - if (c.var.referralLeaderboardCyclesCaches === undefined) { + if (c.var.referralLeaderboardEditionsCaches === undefined) { throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardCyclesCachesMiddleware required`, + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, ); } try { const { referrer } = c.req.valid("param"); - const { cycles } = c.req.valid("query"); + const { editions } = c.req.valid("query"); - // Check if cycle set failed to load - if (c.var.referralLeaderboardCyclesCaches instanceof Error) { + // Check if edition set failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { logger.error( - { error: c.var.referralLeaderboardCyclesCaches }, - "Referral program cycle set failed to load", + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program edition set failed to load", ); return c.json( - serializeReferrerDetailCyclesResponse({ - responseCode: ReferrerDetailCyclesResponseCodes.Error, + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, error: "Service Unavailable", errorMessage: "Referral program configuration is currently unavailable.", - } satisfies ReferrerDetailCyclesResponse), + } satisfies ReferrerMetricsEditionsResponse), 503, ); } // Type narrowing: at this point we know it's not an Error - const cyclesCaches = c.var.referralLeaderboardCyclesCaches; + const editionsCaches = c.var.referralLeaderboardEditionsCaches; - // Validate that all requested cycles are recognized (exist in the cache map) - const configuredCycles = Array.from(cyclesCaches.keys()); - const unrecognizedCycles = cycles.filter((cycle) => !cyclesCaches.has(cycle)); + // Validate that all requested editions are recognized (exist in the cache map) + const configuredEditions = Array.from(editionsCaches.keys()); + const unrecognizedEditions = editions.filter((edition) => !editionsCaches.has(edition)); - if (unrecognizedCycles.length > 0) { + if (unrecognizedEditions.length > 0) { return c.json( - serializeReferrerDetailCyclesResponse({ - responseCode: ReferrerDetailCyclesResponseCodes.Error, + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, error: "Not Found", - errorMessage: `Unknown cycle(s): ${unrecognizedCycles.join(", ")}. Valid cycles: ${configuredCycles.join(", ")}`, - } satisfies ReferrerDetailCyclesResponse), + errorMessage: `Unknown edition(s): ${unrecognizedEditions.join(", ")}. Valid editions: ${configuredEditions.join(", ")}`, + } satisfies ReferrerMetricsEditionsResponse), 404, ); } - // Read all requested cycle caches - const cycleLeaderboards = await Promise.all( - cycles.map(async (cycleSlug) => { - const cycleCache = cyclesCaches.get(cycleSlug); - if (!cycleCache) { - throw new Error(`Invariant: cycle cache for ${cycleSlug} should exist`); + // Read all requested edition caches + const editionLeaderboards = await Promise.all( + editions.map(async (editionSlug) => { + const editionCache = editionsCaches.get(editionSlug); + if (!editionCache) { + throw new Error(`Invariant: edition cache for ${editionSlug} should exist`); } - const leaderboard = await cycleCache.read(); - return { cycleSlug, leaderboard }; + const leaderboard = await editionCache.read(); + return { editionSlug, leaderboard }; }), ); - // Validate that all requested cycles have cached data (no errors) - const uncachedCycles = cycleLeaderboards + // Validate that all requested editions have cached data (no errors) + const uncachedEditions = editionLeaderboards .filter(({ leaderboard }) => leaderboard instanceof Error) - .map(({ cycleSlug }) => cycleSlug); + .map(({ editionSlug }) => editionSlug); - if (uncachedCycles.length > 0) { + if (uncachedEditions.length > 0) { return c.json( - serializeReferrerDetailCyclesResponse({ - responseCode: ReferrerDetailCyclesResponseCodes.Error, + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, error: "Service Unavailable", - errorMessage: `Referrer leaderboard data not cached for cycle(s): ${uncachedCycles.join(", ")}`, - } satisfies ReferrerDetailCyclesResponse), + errorMessage: `Referrer leaderboard data not cached for edition(s): ${uncachedEditions.join(", ")}`, + } satisfies ReferrerMetricsEditionsResponse), 503, ); } // Type narrowing: at this point all leaderboards are guaranteed to be non-Error - const validCycleLeaderboards = cycleLeaderboards.filter( + const validEditionLeaderboards = editionLeaderboards.filter( ( item, - ): item is { cycleSlug: ReferralProgramCycleSlug; leaderboard: ReferrerLeaderboard } => - !(item.leaderboard instanceof Error), + ): item is { + editionSlug: ReferralProgramEditionSlug; + leaderboard: ReferrerLeaderboard; + } => !(item.leaderboard instanceof Error), ); - // Build response data for the requested cycles - const cyclesData = Object.fromEntries( - validCycleLeaderboards.map(({ cycleSlug, leaderboard }) => [ - cycleSlug, - getReferrerDetail(referrer, leaderboard), + // Build response data for the requested editions + const editionsData = Object.fromEntries( + validEditionLeaderboards.map(({ editionSlug, leaderboard }) => [ + editionSlug, + getReferrerEditionMetrics(referrer, leaderboard), ]), - ) as ReferrerDetailCyclesData; + ) as ReferrerMetricsEditionsData; return c.json( - serializeReferrerDetailCyclesResponse({ - responseCode: ReferrerDetailCyclesResponseCodes.Ok, - data: cyclesData, - } satisfies ReferrerDetailCyclesResponse), + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Ok, + data: editionsData, + } satisfies ReferrerMetricsEditionsResponse), ); } catch (error) { logger.error( @@ -320,28 +322,28 @@ app ? error.message : "An unexpected error occurred while processing your request"; return c.json( - serializeReferrerDetailCyclesResponse({ - responseCode: ReferrerDetailCyclesResponseCodes.Error, + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, error: "Internal server error", errorMessage, - } satisfies ReferrerDetailCyclesResponse), + } satisfies ReferrerMetricsEditionsResponse), 500, ); } }, ) - // Get configured cycle config set + // Get configured edition config set .get( - "/cycles", + "/editions", describeRoute({ tags: ["ENSAwards"], - summary: "Get Cycle Config Set (v1)", + summary: "Get Edition Config Set (v1)", description: - "Returns the currently configured referral program cycle config set. Cycles are sorted in descending order by start timestamp (most recent first).", + "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).", responses: { 200: { - description: "Successfully retrieved cycle config set", + description: "Successfully retrieved edition config set", }, 500: { description: "Internal server error", @@ -353,54 +355,54 @@ app }), async (c) => { // context must be set by the required middleware - if (c.var.referralProgramCycleConfigSet === undefined) { + if (c.var.referralProgramEditionConfigSet === undefined) { throw new Error( - `Invariant(ensanalytics-api-v1): referralProgramCycleConfigSetMiddleware required`, + `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`, ); } try { - // Check if cycle config set failed to load - if (c.var.referralProgramCycleConfigSet instanceof Error) { + // Check if edition config set failed to load + if (c.var.referralProgramEditionConfigSet instanceof Error) { logger.error( - { error: c.var.referralProgramCycleConfigSet }, - "Referral program cycle config set failed to load", + { error: c.var.referralProgramEditionConfigSet }, + "Referral program edition config set failed to load", ); return c.json( - serializeReferralProgramCycleConfigSetResponse({ - responseCode: ReferralProgramCycleConfigSetResponseCodes.Error, + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, error: "Service Unavailable", errorMessage: "Referral program configuration is currently unavailable.", - } satisfies ReferralProgramCycleConfigSetResponse), + } satisfies ReferralProgramEditionConfigSetResponse), 503, ); } // Convert Map to array and sort by start timestamp descending - const cycles = Array.from(c.var.referralProgramCycleConfigSet.values()).sort( + const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort( (a, b) => b.rules.startTime - a.rules.startTime, ); return c.json( - serializeReferralProgramCycleConfigSetResponse({ - responseCode: ReferralProgramCycleConfigSetResponseCodes.Ok, + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok, data: { - cycles, + editions, }, - } satisfies ReferralProgramCycleConfigSetResponse), + } satisfies ReferralProgramEditionConfigSetResponse), ); } catch (error) { - logger.error({ error }, "Error in /v1/ensanalytics/cycles endpoint"); + logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint"); const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred while processing your request"; return c.json( - serializeReferralProgramCycleConfigSetResponse({ - responseCode: ReferralProgramCycleConfigSetResponseCodes.Error, + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, error: "Internal server error", errorMessage, - } satisfies ReferralProgramCycleConfigSetResponse), + } satisfies ReferralProgramEditionConfigSetResponse), 500, ); } diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 8802675e6..9483c4f6a 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -8,8 +8,8 @@ import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; -import { getReferralLeaderboardCyclesCaches } from "@/cache/referral-leaderboard-cycles.cache"; -import { referralProgramCycleConfigSetCache } from "@/cache/referral-program-cycle-set.cache"; +import { getReferralLeaderboardEditionsCaches } from "@/cache/referral-leaderboard-editions.cache"; +import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache"; import { redactEnsApiConfig } from "@/config/redact"; import { errorResponse } from "@/lib/handlers/error-response"; @@ -162,16 +162,16 @@ const gracefulShutdown = async () => { referrerLeaderboardCache.destroy(); logger.info("Destroyed referrerLeaderboardCache"); - // Destroy referral program cycle config set cache - referralProgramCycleConfigSetCache.destroy(); - logger.info("Destroyed referralProgramCycleConfigSetCache"); + // Destroy referral program edition config set cache + referralProgramEditionConfigSetCache.destroy(); + logger.info("Destroyed referralProgramEditionConfigSetCache"); - // Destroy all cycle caches (if initialized) - const cyclesCaches = getReferralLeaderboardCyclesCaches(); - if (cyclesCaches) { - for (const [cycleSlug, cache] of cyclesCaches) { + // Destroy all edition caches (if initialized) + const editionsCaches = getReferralLeaderboardEditionsCaches(); + if (editionsCaches) { + for (const [editionSlug, cache] of editionsCaches) { cache.destroy(); - logger.info(`Destroyed referralLeaderboardCyclesCache for ${cycleSlug}`); + logger.info(`Destroyed referralLeaderboardEditionsCache for ${editionSlug}`); } } diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 8cbbfff5d..2a4f47914 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -3,16 +3,16 @@ import { createFactory } from "hono/factory"; import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware"; -import type { ReferralLeaderboardCyclesCachesMiddlewareVariables } from "@/middleware/referral-leaderboard-cycles-caches.middleware"; -import type { ReferralProgramCycleConfigSetMiddlewareVariables } from "@/middleware/referral-program-cycle-set.middleware"; +import type { ReferralLeaderboardEditionsCachesMiddlewareVariables } from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import type { ReferralProgramEditionConfigSetMiddlewareVariables } from "@/middleware/referral-program-edition-set.middleware"; import type { ReferrerLeaderboardMiddlewareVariables } from "@/middleware/referrer-leaderboard.middleware"; export type MiddlewareVariables = IndexingStatusMiddlewareVariables & IsRealtimeMiddlewareVariables & CanAccelerateMiddlewareVariables & ReferrerLeaderboardMiddlewareVariables & - ReferralProgramCycleConfigSetMiddlewareVariables & - ReferralLeaderboardCyclesCachesMiddlewareVariables; + ReferralProgramEditionConfigSetMiddlewareVariables & + ReferralLeaderboardEditionsCachesMiddlewareVariables; export const factory = createFactory<{ Variables: Partial; diff --git a/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts b/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts deleted file mode 100644 index d352537eb..000000000 --- a/apps/ensapi/src/middleware/referral-leaderboard-cycles-caches.middleware.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - initializeReferralLeaderboardCyclesCaches, - type ReferralLeaderboardCyclesCacheMap, -} from "@/cache/referral-leaderboard-cycles.cache"; -import { factory } from "@/lib/hono-factory"; -import { referralProgramCycleConfigSetMiddleware } from "@/middleware/referral-program-cycle-set.middleware"; - -/** - * Type definition for the referral leaderboard cycles caches middleware context passed to downstream middleware and handlers. - */ -export type ReferralLeaderboardCyclesCachesMiddlewareVariables = { - /** - * A map from cycle slug to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. - * - * Returns an {@link Error} if the referral program cycle config set failed to load. - * - * When the map is available, each cycle has its own independent cache. Therefore, each cycle's cache - * can be asynchronously loaded / refreshed from others, and a failure to - * load data for one cycle doesn't break data successfully loaded - * for other cycles. - * - * When reading from a specific cycle's cache, it will return either: - * - The {@link ReferrerLeaderboard} if successfully cached - * - An {@link Error} if the cache failed to build - * - * Individual cycle caches maintain their own stale-while-revalidate behavior, so a previously - * successfully fetched cycle continues serving its data even if a subsequent refresh fails. - */ - referralLeaderboardCyclesCaches: ReferralLeaderboardCyclesCacheMap | Error; -}; - -/** - * Middleware that provides {@link ReferralLeaderboardCyclesCachesMiddlewareVariables} - * to downstream middleware and handlers. - * - * This middleware depends on {@link referralProgramCycleConfigSetMiddleware} to provide - * the cycle config set. If the cycle config set failed to load, this middleware propagates the error. - * Otherwise, it initializes caches for each cycle in the config set. - */ -export const referralLeaderboardCyclesCachesMiddleware = factory.createMiddleware( - async (c, next) => { - const cycleConfigSet = c.get("referralProgramCycleConfigSet"); - - // Invariant: referralProgramCycleConfigSetMiddleware must be applied before this middleware - if (cycleConfigSet === undefined) { - throw new Error( - "Invariant(referralLeaderboardCyclesCachesMiddleware): referralProgramCycleConfigSetMiddleware required", - ); - } - - // If cycle config set loading failed, propagate the error - if (cycleConfigSet instanceof Error) { - c.set("referralLeaderboardCyclesCaches", cycleConfigSet); - await next(); - return; - } - - // Initialize caches for the cycle config set - const caches = initializeReferralLeaderboardCyclesCaches(cycleConfigSet); - c.set("referralLeaderboardCyclesCaches", caches); - await next(); - }, -); diff --git a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts new file mode 100644 index 000000000..4b77084bf --- /dev/null +++ b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts @@ -0,0 +1,63 @@ +import { + initializeReferralLeaderboardEditionsCaches, + type ReferralLeaderboardEditionsCacheMap, +} from "@/cache/referral-leaderboard-editions.cache"; +import { factory } from "@/lib/hono-factory"; +import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; + +/** + * Type definition for the referral leaderboard editions caches middleware context passed to downstream middleware and handlers. + */ +export type ReferralLeaderboardEditionsCachesMiddlewareVariables = { + /** + * A map from edition slug to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. + * + * Returns an {@link Error} if the referral program edition config set failed to load. + * + * When the map is available, 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. + * + * When reading from a specific edition's cache, it will return either: + * - The {@link ReferrerLeaderboard} if successfully cached + * - An {@link Error} if the cache failed to build + * + * Individual edition caches maintain their own stale-while-revalidate behavior, so a previously + * successfully fetched edition continues serving its data even if a subsequent refresh fails. + */ + referralLeaderboardEditionsCaches: ReferralLeaderboardEditionsCacheMap | Error; +}; + +/** + * Middleware that provides {@link ReferralLeaderboardEditionsCachesMiddlewareVariables} + * to downstream middleware and handlers. + * + * 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. + */ +export const referralLeaderboardEditionsCachesMiddleware = factory.createMiddleware( + async (c, next) => { + const editionConfigSet = c.get("referralProgramEditionConfigSet"); + + // Invariant: referralProgramEditionConfigSetMiddleware must be applied before this middleware + if (editionConfigSet === undefined) { + throw new Error( + "Invariant(referralLeaderboardEditionsCachesMiddleware): referralProgramEditionConfigSetMiddleware required", + ); + } + + // If edition config set loading failed, propagate the error + if (editionConfigSet instanceof Error) { + c.set("referralLeaderboardEditionsCaches", editionConfigSet); + await next(); + return; + } + + // Initialize caches for the edition config set + const caches = initializeReferralLeaderboardEditionsCaches(editionConfigSet); + c.set("referralLeaderboardEditionsCaches", caches); + await next(); + }, +); diff --git a/apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts b/apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts deleted file mode 100644 index 0f99467b0..000000000 --- a/apps/ensapi/src/middleware/referral-program-cycle-set.middleware.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ReferralProgramCycleConfigSet } from "@namehash/ens-referrals/v1"; - -import { referralProgramCycleConfigSetCache } from "@/cache/referral-program-cycle-set.cache"; -import { factory } from "@/lib/hono-factory"; - -/** - * Type definition for the referral program cycle config set middleware context. - */ -export type ReferralProgramCycleConfigSetMiddlewareVariables = { - /** - * The referral program cycle config set loaded either from a custom URL or defaults. - * - * - On success: {@link ReferralProgramCycleConfigSet} - A Map of cycle slugs to cycle configurations - * - On failure: {@link Error} - An error that occurred during loading - */ - referralProgramCycleConfigSet: ReferralProgramCycleConfigSet | Error; -}; - -/** - * Middleware that provides {@link ReferralProgramCycleConfigSetMiddlewareVariables} - * to downstream middleware and handlers. - * - * This middleware reads the referral program cycle config set from the SWR cache. - * The cache is initialized once at startup and never revalidated, ensuring - * the cycle config set JSON is only fetched once during the application lifecycle. - */ -export const referralProgramCycleConfigSetMiddleware = factory.createMiddleware(async (c, next) => { - const cycleConfigSet = await referralProgramCycleConfigSetCache.read(); - c.set("referralProgramCycleConfigSet", cycleConfigSet); - await next(); -}); diff --git a/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts new file mode 100644 index 000000000..ca6f788a4 --- /dev/null +++ b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts @@ -0,0 +1,33 @@ +import type { ReferralProgramEditionConfigSet } from "@namehash/ens-referrals/v1"; + +import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; +import { factory } from "@/lib/hono-factory"; + +/** + * Type definition for the referral program edition config set middleware context. + */ +export type ReferralProgramEditionConfigSetMiddlewareVariables = { + /** + * The referral program edition config set loaded either from a custom URL or defaults. + * + * - On success: {@link ReferralProgramEditionConfigSet} - A Map of edition slugs to edition configurations + * - On failure: {@link Error} - An error that occurred during loading + */ + referralProgramEditionConfigSet: ReferralProgramEditionConfigSet | Error; +}; + +/** + * Middleware that provides {@link ReferralProgramEditionConfigSetMiddlewareVariables} + * to downstream middleware and handlers. + * + * This middleware reads the referral program edition config set from the SWR cache. + * The cache is initialized once at startup and never revalidated, ensuring + * the edition config set JSON is only fetched once during the application lifecycle. + */ +export const referralProgramEditionConfigSetMiddleware = factory.createMiddleware( + async (c, next) => { + const editionConfigSet = await referralProgramEditionConfigSetCache.read(); + c.set("referralProgramEditionConfigSet", editionConfigSet); + await next(); + }, +); diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index af72e8424..ce4096a99 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -1,21 +1,21 @@ import { prettifyError } from "zod/v4"; -import type { ReferralProgramCycleConfig } from "../cycle"; +import type { ReferralProgramEditionConfig } from "../edition"; import type { - SerializedReferralProgramCycleConfigSetResponse, - SerializedReferrerDetailCyclesResponse, + SerializedReferralProgramEditionConfigSetResponse, SerializedReferrerLeaderboardPageResponse, + SerializedReferrerMetricsEditionsResponse, } from "./serialized-types"; import type { - ReferralProgramCycleConfigSetResponse, - ReferrerDetailCyclesResponse, + ReferralProgramEditionConfigSetResponse, ReferrerLeaderboardPageResponse, + ReferrerMetricsEditionsResponse, } from "./types"; import { - makeReferralProgramCycleConfigSetArraySchema, - makeReferralProgramCycleConfigSetResponseSchema, - makeReferrerDetailCyclesResponseSchema, + makeReferralProgramEditionConfigSetArraySchema, + makeReferralProgramEditionConfigSetResponseSchema, makeReferrerLeaderboardPageResponseSchema, + makeReferrerMetricsEditionsResponseSchema, } from "./zod-schemas"; /** @@ -38,18 +38,18 @@ export function deserializeReferrerLeaderboardPageResponse( } /** - * Deserialize a {@link ReferrerDetailCyclesResponse} object. + * Deserialize a {@link ReferrerMetricsEditionsResponse} object. */ -export function deserializeReferrerDetailCyclesResponse( - maybeResponse: SerializedReferrerDetailCyclesResponse, +export function deserializeReferrerMetricsEditionsResponse( + maybeResponse: SerializedReferrerMetricsEditionsResponse, valueLabel?: string, -): ReferrerDetailCyclesResponse { - const schema = makeReferrerDetailCyclesResponseSchema(valueLabel); +): ReferrerMetricsEditionsResponse { + const schema = makeReferrerMetricsEditionsResponseSchema(valueLabel); const parsed = schema.safeParse(maybeResponse); if (parsed.error) { throw new Error( - `Cannot deserialize ReferrerDetailCyclesResponse:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferrerMetricsEditionsResponse:\n${prettifyError(parsed.error)}\n`, ); } @@ -57,18 +57,18 @@ export function deserializeReferrerDetailCyclesResponse( } /** - * Deserializes an array of {@link ReferralProgramCycleConfig} objects. + * Deserializes an array of {@link ReferralProgramEditionConfig} objects. */ -export function deserializeReferralProgramCycleConfigSetArray( +export function deserializeReferralProgramEditionConfigSetArray( maybeArray: unknown, valueLabel?: string, -): ReferralProgramCycleConfig[] { - const schema = makeReferralProgramCycleConfigSetArraySchema(valueLabel); +): ReferralProgramEditionConfig[] { + const schema = makeReferralProgramEditionConfigSetArraySchema(valueLabel); const parsed = schema.safeParse(maybeArray); if (parsed.error) { throw new Error( - `Cannot deserialize ReferralProgramCycleConfigSetArray:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferralProgramEditionConfigSetArray:\n${prettifyError(parsed.error)}\n`, ); } @@ -76,18 +76,18 @@ export function deserializeReferralProgramCycleConfigSetArray( } /** - * Deserialize a {@link ReferralProgramCycleConfigSetResponse} object. + * Deserialize a {@link ReferralProgramEditionConfigSetResponse} object. */ -export function deserializeReferralProgramCycleConfigSetResponse( - maybeResponse: SerializedReferralProgramCycleConfigSetResponse, +export function deserializeReferralProgramEditionConfigSetResponse( + maybeResponse: SerializedReferralProgramEditionConfigSetResponse, valueLabel?: string, -): ReferralProgramCycleConfigSetResponse { - const schema = makeReferralProgramCycleConfigSetResponseSchema(valueLabel); +): ReferralProgramEditionConfigSetResponse { + const schema = makeReferralProgramEditionConfigSetResponseSchema(valueLabel); const parsed = schema.safeParse(maybeResponse); if (parsed.error) { throw new Error( - `Cannot deserialize ReferralProgramCycleConfigSetResponse:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferralProgramEditionConfigSetResponse:\n${prettifyError(parsed.error)}\n`, ); } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index f41a6a16d..371c63ba1 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,37 +1,37 @@ import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramCycleConfig } from "../cycle"; -import type { ReferrerLeaderboardPage } from "../leaderboard-page"; +import type { ReferralProgramEditionConfig } from "../edition"; import type { - ReferrerDetail, - ReferrerDetailRanked, - ReferrerDetailUnranked, -} from "../referrer-detail"; + ReferrerEditionMetrics, + ReferrerEditionMetricsRanked, + ReferrerEditionMetricsUnranked, +} from "../edition-metrics"; +import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { SerializedAggregatedReferrerMetrics, SerializedAwardedReferrerMetrics, - SerializedReferralProgramCycleConfig, - SerializedReferralProgramCycleConfigSetResponse, + SerializedReferralProgramEditionConfig, + SerializedReferralProgramEditionConfigSetResponse, SerializedReferralProgramRules, - SerializedReferrerDetail, - SerializedReferrerDetailCyclesData, - SerializedReferrerDetailCyclesResponse, - SerializedReferrerDetailRanked, - SerializedReferrerDetailUnranked, + SerializedReferrerEditionMetrics, + SerializedReferrerEditionMetricsRanked, + SerializedReferrerEditionMetricsUnranked, SerializedReferrerLeaderboardPage, SerializedReferrerLeaderboardPageResponse, + SerializedReferrerMetricsEditionsData, + SerializedReferrerMetricsEditionsResponse, SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import { - type ReferralProgramCycleConfigSetResponse, - ReferralProgramCycleConfigSetResponseCodes, - type ReferrerDetailCyclesResponse, - ReferrerDetailCyclesResponseCodes, + type ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionConfigSetResponseCodes, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, + type ReferrerMetricsEditionsResponse, + ReferrerMetricsEditionsResponseCodes, } from "./types"; /** @@ -123,11 +123,11 @@ function serializeReferrerLeaderboardPage( } /** - * Serializes a {@link ReferrerDetailRanked} object. + * Serializes a {@link ReferrerEditionMetricsRanked} object. */ -function serializeReferrerDetailRanked( - detail: ReferrerDetailRanked, -): SerializedReferrerDetailRanked { +function serializeReferrerEditionMetricsRanked( + detail: ReferrerEditionMetricsRanked, +): SerializedReferrerEditionMetricsRanked { return { type: detail.type, rules: serializeReferralProgramRules(detail.rules), @@ -138,11 +138,11 @@ function serializeReferrerDetailRanked( } /** - * Serializes a {@link ReferrerDetailUnranked} object. + * Serializes a {@link ReferrerEditionMetricsUnranked} object. */ -function serializeReferrerDetailUnranked( - detail: ReferrerDetailUnranked, -): SerializedReferrerDetailUnranked { +function serializeReferrerEditionMetricsUnranked( + detail: ReferrerEditionMetricsUnranked, +): SerializedReferrerEditionMetricsUnranked { return { type: detail.type, rules: serializeReferralProgramRules(detail.rules), @@ -153,31 +153,33 @@ function serializeReferrerDetailUnranked( } /** - * Serializes a {@link ReferrerDetail} object (ranked or unranked). + * Serializes a {@link ReferrerEditionMetrics} object (ranked or unranked). */ -function serializeReferrerDetail(detail: ReferrerDetail): SerializedReferrerDetail { +function serializeReferrerEditionMetrics( + detail: ReferrerEditionMetrics, +): SerializedReferrerEditionMetrics { switch (detail.type) { case "ranked": - return serializeReferrerDetailRanked(detail); + return serializeReferrerEditionMetricsRanked(detail); case "unranked": - return serializeReferrerDetailUnranked(detail); + return serializeReferrerEditionMetricsUnranked(detail); default: { const _exhaustiveCheck: never = detail; - throw new Error(`Unknown detail type: ${(_exhaustiveCheck as ReferrerDetail).type}`); + throw new Error(`Unknown detail type: ${(_exhaustiveCheck as ReferrerEditionMetrics).type}`); } } } /** - * Serializes a {@link ReferralProgramCycleConfig} object. + * Serializes a {@link ReferralProgramEditionConfig} object. */ -export function serializeReferralProgramCycleConfig( - cycleConfig: ReferralProgramCycleConfig, -): SerializedReferralProgramCycleConfig { +export function serializeReferralProgramEditionConfig( + editionConfig: ReferralProgramEditionConfig, +): SerializedReferralProgramEditionConfig { return { - slug: cycleConfig.slug, - displayName: cycleConfig.displayName, - rules: serializeReferralProgramRules(cycleConfig.rules), + slug: editionConfig.slug, + displayName: editionConfig.displayName, + rules: serializeReferralProgramRules(editionConfig.rules), }; } @@ -200,19 +202,19 @@ export function serializeReferrerLeaderboardPageResponse( } /** - * Serialize a {@link ReferrerDetailCyclesResponse} object. + * Serialize a {@link ReferrerMetricsEditionsResponse} object. */ -export function serializeReferrerDetailCyclesResponse( - response: ReferrerDetailCyclesResponse, -): SerializedReferrerDetailCyclesResponse { +export function serializeReferrerMetricsEditionsResponse( + response: ReferrerMetricsEditionsResponse, +): SerializedReferrerMetricsEditionsResponse { switch (response.responseCode) { - case ReferrerDetailCyclesResponseCodes.Ok: { + case ReferrerMetricsEditionsResponseCodes.Ok: { const serializedData = Object.fromEntries( - Object.entries(response.data).map(([cycleSlug, detail]) => [ - cycleSlug, - serializeReferrerDetail(detail as ReferrerDetail), + Object.entries(response.data).map(([editionSlug, detail]) => [ + editionSlug, + serializeReferrerEditionMetrics(detail as ReferrerEditionMetrics), ]), - ) as SerializedReferrerDetailCyclesData; + ) as SerializedReferrerMetricsEditionsData; return { responseCode: response.responseCode, @@ -220,40 +222,40 @@ export function serializeReferrerDetailCyclesResponse( }; } - case ReferrerDetailCyclesResponseCodes.Error: + case ReferrerMetricsEditionsResponseCodes.Error: return response; default: { const _exhaustiveCheck: never = response; throw new Error( - `Unknown response code: ${(_exhaustiveCheck as ReferrerDetailCyclesResponse).responseCode}`, + `Unknown response code: ${(_exhaustiveCheck as ReferrerMetricsEditionsResponse).responseCode}`, ); } } } /** - * Serialize a {@link ReferralProgramCycleConfigSetResponse} object. + * Serialize a {@link ReferralProgramEditionConfigSetResponse} object. */ -export function serializeReferralProgramCycleConfigSetResponse( - response: ReferralProgramCycleConfigSetResponse, -): SerializedReferralProgramCycleConfigSetResponse { +export function serializeReferralProgramEditionConfigSetResponse( + response: ReferralProgramEditionConfigSetResponse, +): SerializedReferralProgramEditionConfigSetResponse { switch (response.responseCode) { - case ReferralProgramCycleConfigSetResponseCodes.Ok: + case ReferralProgramEditionConfigSetResponseCodes.Ok: return { responseCode: response.responseCode, data: { - cycles: response.data.cycles.map(serializeReferralProgramCycleConfig), + editions: response.data.editions.map(serializeReferralProgramEditionConfig), }, }; - case ReferralProgramCycleConfigSetResponseCodes.Error: + case ReferralProgramEditionConfigSetResponseCodes.Error: return response; default: { const _exhaustiveCheck: never = response; throw new Error( - `Unknown response code: ${(_exhaustiveCheck as ReferralProgramCycleConfigSetResponse).responseCode}`, + `Unknown response code: ${(_exhaustiveCheck as ReferralProgramEditionConfigSetResponse).responseCode}`, ); } } diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index d0e1f9f1b..1c049bde6 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,22 +1,25 @@ import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramCycleConfig, ReferralProgramCycleSlug } from "../cycle"; +import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; +import type { + ReferrerEditionMetricsRanked, + ReferrerEditionMetricsUnranked, +} from "../edition-metrics"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; -import type { ReferrerDetailRanked, ReferrerDetailUnranked } from "../referrer-detail"; import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { - ReferralProgramCycleConfigSetData, - ReferralProgramCycleConfigSetResponse, - ReferralProgramCycleConfigSetResponseError, - ReferralProgramCycleConfigSetResponseOk, - ReferrerDetailCyclesResponse, - ReferrerDetailCyclesResponseError, - ReferrerDetailCyclesResponseOk, + ReferralProgramEditionConfigSetData, + ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionConfigSetResponseError, + ReferralProgramEditionConfigSetResponseOk, ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseError, ReferrerLeaderboardPageResponseOk, + ReferrerMetricsEditionsResponse, + ReferrerMetricsEditionsResponseError, + ReferrerMetricsEditionsResponseOk, } from "./types"; /** @@ -65,31 +68,31 @@ export interface SerializedReferrerLeaderboardPage } /** - * Serialized representation of {@link ReferrerDetailRanked}. + * Serialized representation of {@link ReferrerEditionMetricsRanked}. */ -export interface SerializedReferrerDetailRanked - extends Omit { +export interface SerializedReferrerEditionMetricsRanked + extends Omit { rules: SerializedReferralProgramRules; referrer: SerializedAwardedReferrerMetrics; aggregatedMetrics: SerializedAggregatedReferrerMetrics; } /** - * Serialized representation of {@link ReferrerDetailUnranked}. + * Serialized representation of {@link ReferrerEditionMetricsUnranked}. */ -export interface SerializedReferrerDetailUnranked - extends Omit { +export interface SerializedReferrerEditionMetricsUnranked + extends Omit { rules: SerializedReferralProgramRules; referrer: SerializedUnrankedReferrerMetrics; aggregatedMetrics: SerializedAggregatedReferrerMetrics; } /** - * Serialized representation of {@link ReferrerDetail} (union of ranked and unranked). + * Serialized representation of {@link ReferrerEditionMetrics} (union of ranked and unranked). */ -export type SerializedReferrerDetail = - | SerializedReferrerDetailRanked - | SerializedReferrerDetailUnranked; +export type SerializedReferrerEditionMetrics = + | SerializedReferrerEditionMetricsRanked + | SerializedReferrerEditionMetricsUnranked; /** * Serialized representation of {@link ReferrerLeaderboardPageResponseError}. @@ -114,72 +117,72 @@ export type SerializedReferrerLeaderboardPageResponse = | SerializedReferrerLeaderboardPageResponseError; /** - * Serialized representation of {@link ReferralProgramCycleConfig}. + * Serialized representation of {@link ReferralProgramEditionConfig}. */ -export interface SerializedReferralProgramCycleConfig - extends Omit { +export interface SerializedReferralProgramEditionConfig + extends Omit { rules: SerializedReferralProgramRules; } /** - * Serialized representation of referrer detail data for requested cycles. - * Uses Partial because TypeScript cannot know at compile time which specific cycle - * slugs are requested. At runtime, when responseCode is Ok, all requested cycle slugs + * Serialized representation of referrer metrics data for requested editions. + * Uses Partial because TypeScript cannot know at compile time which specific edition + * slugs are requested. At runtime, when responseCode is Ok, all requested edition slugs * are guaranteed to be present in this record. */ -export type SerializedReferrerDetailCyclesData = Partial< - Record +export type SerializedReferrerMetricsEditionsData = Partial< + Record >; /** - * Serialized representation of {@link ReferrerDetailCyclesResponseOk}. + * Serialized representation of {@link ReferrerMetricsEditionsResponseOk}. */ -export interface SerializedReferrerDetailCyclesResponseOk - extends Omit { - data: SerializedReferrerDetailCyclesData; +export interface SerializedReferrerMetricsEditionsResponseOk + extends Omit { + data: SerializedReferrerMetricsEditionsData; } /** - * Serialized representation of {@link ReferrerDetailCyclesResponseError}. + * Serialized representation of {@link ReferrerMetricsEditionsResponseError}. * * Note: All fields are already serializable, so this type is identical to the source type. */ -export type SerializedReferrerDetailCyclesResponseError = ReferrerDetailCyclesResponseError; +export type SerializedReferrerMetricsEditionsResponseError = ReferrerMetricsEditionsResponseError; /** - * Serialized representation of {@link ReferrerDetailCyclesResponse}. + * Serialized representation of {@link ReferrerMetricsEditionsResponse}. */ -export type SerializedReferrerDetailCyclesResponse = - | SerializedReferrerDetailCyclesResponseOk - | SerializedReferrerDetailCyclesResponseError; +export type SerializedReferrerMetricsEditionsResponse = + | SerializedReferrerMetricsEditionsResponseOk + | SerializedReferrerMetricsEditionsResponseError; /** - * Serialized representation of {@link ReferralProgramCycleConfigSetData}. + * Serialized representation of {@link ReferralProgramEditionConfigSetData}. */ -export interface SerializedReferralProgramCycleConfigSetData - extends Omit { - cycles: SerializedReferralProgramCycleConfig[]; +export interface SerializedReferralProgramEditionConfigSetData + extends Omit { + editions: SerializedReferralProgramEditionConfig[]; } /** - * Serialized representation of {@link ReferralProgramCycleConfigSetResponseOk}. + * Serialized representation of {@link ReferralProgramEditionConfigSetResponseOk}. */ -export interface SerializedReferralProgramCycleConfigSetResponseOk - extends Omit { - data: SerializedReferralProgramCycleConfigSetData; +export interface SerializedReferralProgramEditionConfigSetResponseOk + extends Omit { + data: SerializedReferralProgramEditionConfigSetData; } /** - * Serialized representation of {@link ReferralProgramCycleConfigSetResponseError}. + * Serialized representation of {@link ReferralProgramEditionConfigSetResponseError}. * * Note: All fields are already serializable, so this type is identical to the source type. */ -export type SerializedReferralProgramCycleConfigSetResponseError = - ReferralProgramCycleConfigSetResponseError; +export type SerializedReferralProgramEditionConfigSetResponseError = + ReferralProgramEditionConfigSetResponseError; /** - * Serialized representation of {@link ReferralProgramCycleConfigSetResponse}. + * Serialized representation of {@link ReferralProgramEditionConfigSetResponse}. */ -export type SerializedReferralProgramCycleConfigSetResponse = - | SerializedReferralProgramCycleConfigSetResponseOk - | SerializedReferralProgramCycleConfigSetResponseError; +export type SerializedReferralProgramEditionConfigSetResponse = + | SerializedReferralProgramEditionConfigSetResponseOk + | SerializedReferralProgramEditionConfigSetResponseError; diff --git a/packages/ens-referrals/src/v1/api/types.ts b/packages/ens-referrals/src/v1/api/types.ts index 3cb1e7b9e..5f7e83bc0 100644 --- a/packages/ens-referrals/src/v1/api/types.ts +++ b/packages/ens-referrals/src/v1/api/types.ts @@ -1,15 +1,15 @@ import type { Address } from "viem"; -import type { ReferralProgramCycleConfig, ReferralProgramCycleSlug } from "../cycle"; +import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; +import type { ReferrerEditionMetrics } from "../edition-metrics"; import type { ReferrerLeaderboardPage, ReferrerLeaderboardPageParams } from "../leaderboard-page"; -import type { ReferrerDetail } from "../referrer-detail"; /** * Request parameters for a referrer leaderboard page query. */ export interface ReferrerLeaderboardPageRequest extends ReferrerLeaderboardPageParams { - /** The referral program cycle slug */ - cycle: ReferralProgramCycleSlug; + /** The referral program edition slug */ + edition: ReferralProgramEditionSlug; } /** @@ -61,26 +61,26 @@ export type ReferrerLeaderboardPageResponse = | ReferrerLeaderboardPageResponseError; /** - * Maximum number of cycles that can be requested in a single {@link ReferrerDetailCyclesRequest}. + * Maximum number of editions that can be requested in a single {@link ReferrerMetricsEditionsRequest}. */ -export const MAX_CYCLES_PER_REQUEST = 20; +export const MAX_EDITIONS_PER_REQUEST = 20; /** - * Request parameters for referrer detail query. + * Request parameters for referrer metrics query. */ -export interface ReferrerDetailCyclesRequest { +export interface ReferrerMetricsEditionsRequest { /** The Ethereum address of the referrer to query */ referrer: Address; - /** Array of cycle slugs to query (min 1, max {@link MAX_CYCLES_PER_REQUEST}, must be distinct) */ - cycles: ReferralProgramCycleSlug[]; + /** Array of edition slugs to query (min 1, max {@link MAX_EDITIONS_PER_REQUEST}, must be distinct) */ + editions: ReferralProgramEditionSlug[]; } /** - * A status code for referrer detail API responses. + * A status code for referrer metrics API responses. */ -export const ReferrerDetailCyclesResponseCodes = { +export const ReferrerMetricsEditionsResponseCodes = { /** - * Represents that the referrer detail data for the requested cycles is available. + * Represents that the referrer metrics data for the requested editions is available. */ Ok: "ok", @@ -91,100 +91,102 @@ export const ReferrerDetailCyclesResponseCodes = { } as const; /** - * The derived string union of possible {@link ReferrerDetailCyclesResponseCodes}. + * The derived string union of possible {@link ReferrerMetricsEditionsResponseCodes}. */ -export type ReferrerDetailCyclesResponseCode = - (typeof ReferrerDetailCyclesResponseCodes)[keyof typeof ReferrerDetailCyclesResponseCodes]; +export type ReferrerMetricsEditionsResponseCode = + (typeof ReferrerMetricsEditionsResponseCodes)[keyof typeof ReferrerMetricsEditionsResponseCodes]; /** - * Referrer detail data for requested cycles. + * Referrer metrics data for requested editions. * - * Maps each requested cycle slug to the referrer's detail for that cycle. - * Uses Partial because TypeScript cannot know at compile time which specific cycle - * slugs are requested. At runtime, when responseCode is Ok, all requested cycle slugs + * Maps each requested edition slug to the referrer's metrics for that edition. + * Uses Partial because TypeScript cannot know at compile time which specific edition + * slugs are requested. At runtime, when responseCode is Ok, all requested edition slugs * are guaranteed to be present in this record. */ -export type ReferrerDetailCyclesData = Partial>; +export type ReferrerMetricsEditionsData = Partial< + Record +>; /** - * A successful response containing referrer detail for the requested cycles. + * A successful response containing referrer metrics for the requested editions. */ -export type ReferrerDetailCyclesResponseOk = { - responseCode: typeof ReferrerDetailCyclesResponseCodes.Ok; - data: ReferrerDetailCyclesData; +export type ReferrerMetricsEditionsResponseOk = { + responseCode: typeof ReferrerMetricsEditionsResponseCodes.Ok; + data: ReferrerMetricsEditionsData; }; /** - * A referrer detail cycles response when an error occurs. + * A referrer metrics editions response when an error occurs. */ -export type ReferrerDetailCyclesResponseError = { - responseCode: typeof ReferrerDetailCyclesResponseCodes.Error; +export type ReferrerMetricsEditionsResponseError = { + responseCode: typeof ReferrerMetricsEditionsResponseCodes.Error; error: string; errorMessage: string; }; /** - * A referrer detail cycles API response. + * A referrer metrics editions API response. * * Use the `responseCode` field to determine the specific type interpretation * at runtime. */ -export type ReferrerDetailCyclesResponse = - | ReferrerDetailCyclesResponseOk - | ReferrerDetailCyclesResponseError; +export type ReferrerMetricsEditionsResponse = + | ReferrerMetricsEditionsResponseOk + | ReferrerMetricsEditionsResponseError; /** - * A status code for referral program cycle config set API responses. + * A status code for referral program edition config set API responses. */ -export const ReferralProgramCycleConfigSetResponseCodes = { +export const ReferralProgramEditionConfigSetResponseCodes = { /** - * Represents that the cycle config set is available. + * Represents that the edition config set is available. */ Ok: "ok", /** - * Represents that the cycle config set is not available. + * Represents that the edition config set is not available. */ Error: "error", } as const; /** - * The derived string union of possible {@link ReferralProgramCycleConfigSetResponseCodes}. + * The derived string union of possible {@link ReferralProgramEditionConfigSetResponseCodes}. */ -export type ReferralProgramCycleConfigSetResponseCode = - (typeof ReferralProgramCycleConfigSetResponseCodes)[keyof typeof ReferralProgramCycleConfigSetResponseCodes]; +export type ReferralProgramEditionConfigSetResponseCode = + (typeof ReferralProgramEditionConfigSetResponseCodes)[keyof typeof ReferralProgramEditionConfigSetResponseCodes]; /** - * The data payload containing cycle configs. - * Cycles are sorted in descending order by start timestamp. + * The data payload containing edition configs. + * Editions are sorted in descending order by start timestamp. */ -export type ReferralProgramCycleConfigSetData = { - cycles: ReferralProgramCycleConfig[]; +export type ReferralProgramEditionConfigSetData = { + editions: ReferralProgramEditionConfig[]; }; /** - * A successful response containing the configured cycle config set. + * A successful response containing the configured edition config set. */ -export type ReferralProgramCycleConfigSetResponseOk = { - responseCode: typeof ReferralProgramCycleConfigSetResponseCodes.Ok; - data: ReferralProgramCycleConfigSetData; +export type ReferralProgramEditionConfigSetResponseOk = { + responseCode: typeof ReferralProgramEditionConfigSetResponseCodes.Ok; + data: ReferralProgramEditionConfigSetData; }; /** - * A cycle config set response when an error occurs. + * An edition config set response when an error occurs. */ -export type ReferralProgramCycleConfigSetResponseError = { - responseCode: typeof ReferralProgramCycleConfigSetResponseCodes.Error; +export type ReferralProgramEditionConfigSetResponseError = { + responseCode: typeof ReferralProgramEditionConfigSetResponseCodes.Error; error: string; errorMessage: string; }; /** - * A referral program cycle config set API response. + * A referral program edition config set API response. * * Use the `responseCode` field to determine the specific type interpretation * at runtime. */ -export type ReferralProgramCycleConfigSetResponse = - | ReferralProgramCycleConfigSetResponseOk - | ReferralProgramCycleConfigSetResponseError; +export type ReferralProgramEditionConfigSetResponse = + | ReferralProgramEditionConfigSetResponseOk + | ReferralProgramEditionConfigSetResponseError; diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index c07d656fc..0b5affb48 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -22,14 +22,17 @@ import { makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; -import type { ReferralProgramCycleSlug } from "../cycle"; +import type { ReferralProgramEditionSlug } from "../edition"; +import { + type ReferrerEditionMetricsRanked, + ReferrerEditionMetricsTypeIds, +} from "../edition-metrics"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; -import { type ReferrerDetailRanked, ReferrerDetailTypeIds } from "../referrer-detail"; import { - MAX_CYCLES_PER_REQUEST, - ReferralProgramCycleConfigSetResponseCodes, - ReferrerDetailCyclesResponseCodes, + MAX_EDITIONS_PER_REQUEST, + ReferralProgramEditionConfigSetResponseCodes, ReferrerLeaderboardPageResponseCodes, + ReferrerMetricsEditionsResponseCodes, } from "./types"; /** @@ -184,11 +187,13 @@ export const makeReferrerLeaderboardPageResponseSchema = ( ]); /** - * Schema for {@link ReferrerDetailRanked} (with ranked metrics) + * Schema for {@link ReferrerEditionMetricsRanked} (with ranked metrics) */ -export const makeReferrerDetailRankedSchema = (valueLabel: string = "ReferrerDetailRanked") => +export const makeReferrerEditionMetricsRankedSchema = ( + valueLabel: string = "ReferrerEditionMetricsRanked", +) => z.object({ - type: z.literal(ReferrerDetailTypeIds.Ranked), + type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), referrer: makeAwardedReferrerMetricsSchema(`${valueLabel}.referrer`), aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), @@ -196,11 +201,13 @@ export const makeReferrerDetailRankedSchema = (valueLabel: string = "ReferrerDet }); /** - * Schema for {@link ReferrerDetailUnranked} (with unranked metrics) + * Schema for {@link ReferrerEditionMetricsUnranked} (with unranked metrics) */ -export const makeReferrerDetailUnrankedSchema = (valueLabel: string = "ReferrerDetailUnranked") => +export const makeReferrerEditionMetricsUnrankedSchema = ( + valueLabel: string = "ReferrerEditionMetricsUnranked", +) => z.object({ - type: z.literal(ReferrerDetailTypeIds.Unranked), + type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), referrer: makeUnrankedReferrerMetricsSchema(`${valueLabel}.referrer`), aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), @@ -208,93 +215,93 @@ export const makeReferrerDetailUnrankedSchema = (valueLabel: string = "ReferrerD }); /** - * Schema for {@link ReferrerDetail} (discriminated union of ranked and unranked) + * Schema for {@link ReferrerEditionMetrics} (discriminated union of ranked and unranked) */ -export const makeReferrerDetailSchema = (valueLabel: string = "ReferrerDetail") => +export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerEditionMetrics") => z.discriminatedUnion("type", [ - makeReferrerDetailRankedSchema(valueLabel), - makeReferrerDetailUnrankedSchema(valueLabel), + makeReferrerEditionMetricsRankedSchema(valueLabel), + makeReferrerEditionMetricsUnrankedSchema(valueLabel), ]); /** - * Schema for validating cycles array (min 1, max {@link MAX_CYCLES_PER_REQUEST}, distinct values). + * Schema for validating editions array (min 1, max {@link MAX_EDITIONS_PER_REQUEST}, distinct values). */ -export const makeReferrerDetailCyclesArraySchema = ( - valueLabel: string = "ReferrerDetailCyclesArray", +export const makeReferrerMetricsEditionsArraySchema = ( + valueLabel: string = "ReferrerMetricsEditionsArray", ) => z - .array(makeReferralProgramCycleSlugSchema(`${valueLabel}[cycle]`)) - .min(1, `${valueLabel} must contain at least 1 cycle`) + .array(makeReferralProgramEditionSlugSchema(`${valueLabel}[edition]`)) + .min(1, `${valueLabel} must contain at least 1 edition`) .max( - MAX_CYCLES_PER_REQUEST, - `${valueLabel} must not contain more than ${MAX_CYCLES_PER_REQUEST} cycles`, + MAX_EDITIONS_PER_REQUEST, + `${valueLabel} must not contain more than ${MAX_EDITIONS_PER_REQUEST} editions`, ) .refine( - (cycles) => { - const uniqueCycles = new Set(cycles); - return uniqueCycles.size === cycles.length; + (editions) => { + const uniqueEditions = new Set(editions); + return uniqueEditions.size === editions.length; }, - { message: `${valueLabel} must not contain duplicate cycle slugs` }, + { message: `${valueLabel} must not contain duplicate edition slugs` }, ); /** - * Schema for {@link ReferrerDetailCyclesRequest} + * Schema for {@link ReferrerMetricsEditionsRequest} */ -export const makeReferrerDetailCyclesRequestSchema = ( - valueLabel: string = "ReferrerDetailCyclesRequest", +export const makeReferrerMetricsEditionsRequestSchema = ( + valueLabel: string = "ReferrerMetricsEditionsRequest", ) => z.object({ referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), - cycles: makeReferrerDetailCyclesArraySchema(`${valueLabel}.cycles`), + editions: makeReferrerMetricsEditionsArraySchema(`${valueLabel}.editions`), }); /** - * Schema for {@link ReferrerDetailCyclesResponseOk} + * Schema for {@link ReferrerMetricsEditionsResponseOk} */ -export const makeReferrerDetailCyclesResponseOkSchema = ( - valueLabel: string = "ReferrerDetailCyclesResponse", +export const makeReferrerMetricsEditionsResponseOkSchema = ( + valueLabel: string = "ReferrerMetricsEditionsResponse", ) => z.object({ - responseCode: z.literal(ReferrerDetailCyclesResponseCodes.Ok), + responseCode: z.literal(ReferrerMetricsEditionsResponseCodes.Ok), data: z.record( - makeReferralProgramCycleSlugSchema(`${valueLabel}.data[cycle]`), - makeReferrerDetailSchema(`${valueLabel}.data[cycle]`), + makeReferralProgramEditionSlugSchema(`${valueLabel}.data[edition]`), + makeReferrerEditionMetricsSchema(`${valueLabel}.data[edition]`), ), }); /** - * Schema for {@link ReferrerDetailCyclesResponseError} + * Schema for {@link ReferrerMetricsEditionsResponseError} */ -export const makeReferrerDetailCyclesResponseErrorSchema = ( - _valueLabel: string = "ReferrerDetailCyclesResponse", +export const makeReferrerMetricsEditionsResponseErrorSchema = ( + _valueLabel: string = "ReferrerMetricsEditionsResponse", ) => z.object({ - responseCode: z.literal(ReferrerDetailCyclesResponseCodes.Error), + responseCode: z.literal(ReferrerMetricsEditionsResponseCodes.Error), error: z.string(), errorMessage: z.string(), }); /** - * Schema for {@link ReferrerDetailCyclesResponse} + * Schema for {@link ReferrerMetricsEditionsResponse} */ -export const makeReferrerDetailCyclesResponseSchema = ( - valueLabel: string = "ReferrerDetailCyclesResponse", +export const makeReferrerMetricsEditionsResponseSchema = ( + valueLabel: string = "ReferrerMetricsEditionsResponse", ) => z.discriminatedUnion("responseCode", [ - makeReferrerDetailCyclesResponseOkSchema(valueLabel), - makeReferrerDetailCyclesResponseErrorSchema(valueLabel), + makeReferrerMetricsEditionsResponseOkSchema(valueLabel), + makeReferrerMetricsEditionsResponseErrorSchema(valueLabel), ]); /** - * Schema for validating a {@link ReferralProgramCycleSlug}. + * Schema for validating a {@link ReferralProgramEditionSlug}. * * Enforces the slug format invariant: lowercase letters (a-z), digits (0-9), * and hyphens (-) only. Must not start or end with a hyphen. * - * Runtime validation against configured cycles happens at the business logic level. + * Runtime validation against configured editions happens at the business logic level. */ -export const makeReferralProgramCycleSlugSchema = ( - valueLabel: string = "ReferralProgramCycleSlug", +export const makeReferralProgramEditionSlugSchema = ( + valueLabel: string = "ReferralProgramEditionSlug", ) => z .string() @@ -305,78 +312,78 @@ export const makeReferralProgramCycleSlugSchema = ( ); /** - * Schema for validating a {@link ReferralProgramCycleConfig}. + * Schema for validating a {@link ReferralProgramEditionConfig}. */ -export const makeReferralProgramCycleConfigSchema = ( - valueLabel: string = "ReferralProgramCycleConfig", +export const makeReferralProgramEditionConfigSchema = ( + valueLabel: string = "ReferralProgramEditionConfig", ) => z.object({ - slug: makeReferralProgramCycleSlugSchema(`${valueLabel}.slug`), + slug: makeReferralProgramEditionSlugSchema(`${valueLabel}.slug`), displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), }); /** - * Schema for validating referral program cycle config set array. + * Schema for validating referral program edition config set array. */ -export const makeReferralProgramCycleConfigSetArraySchema = ( - valueLabel: string = "ReferralProgramCycleConfigSetArray", +export const makeReferralProgramEditionConfigSetArraySchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetArray", ) => z - .array(makeReferralProgramCycleConfigSchema(`${valueLabel}[cycle]`)) - .min(1, `${valueLabel} must contain at least one cycle`) + .array(makeReferralProgramEditionConfigSchema(`${valueLabel}[edition]`)) + .min(1, `${valueLabel} must contain at least one edition`) .refine( - (cycles) => { + (editions) => { const slugs = new Set(); - for (const cycle of cycles) { - if (slugs.has(cycle.slug)) return false; - slugs.add(cycle.slug); + for (const edition of editions) { + if (slugs.has(edition.slug)) return false; + slugs.add(edition.slug); } return true; }, - { message: `${valueLabel} must not contain duplicate cycle slugs` }, + { message: `${valueLabel} must not contain duplicate edition slugs` }, ); /** - * Schema for {@link ReferralProgramCycleConfigSetData}. + * Schema for {@link ReferralProgramEditionConfigSetData}. */ -export const makeReferralProgramCycleConfigSetDataSchema = ( - valueLabel: string = "ReferralProgramCycleConfigSetData", +export const makeReferralProgramEditionConfigSetDataSchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetData", ) => z.object({ - cycles: z.array(makeReferralProgramCycleConfigSchema(`${valueLabel}.cycles[cycle]`)), + editions: z.array(makeReferralProgramEditionConfigSchema(`${valueLabel}.editions[edition]`)), }); /** - * Schema for {@link ReferralProgramCycleConfigSetResponseOk}. + * Schema for {@link ReferralProgramEditionConfigSetResponseOk}. */ -export const makeReferralProgramCycleConfigSetResponseOkSchema = ( - valueLabel: string = "ReferralProgramCycleConfigSetResponseOk", +export const makeReferralProgramEditionConfigSetResponseOkSchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetResponseOk", ) => z.object({ - responseCode: z.literal(ReferralProgramCycleConfigSetResponseCodes.Ok), - data: makeReferralProgramCycleConfigSetDataSchema(`${valueLabel}.data`), + responseCode: z.literal(ReferralProgramEditionConfigSetResponseCodes.Ok), + data: makeReferralProgramEditionConfigSetDataSchema(`${valueLabel}.data`), }); /** - * Schema for {@link ReferralProgramCycleConfigSetResponseError}. + * Schema for {@link ReferralProgramEditionConfigSetResponseError}. */ -export const makeReferralProgramCycleConfigSetResponseErrorSchema = ( - _valueLabel: string = "ReferralProgramCycleConfigSetResponseError", +export const makeReferralProgramEditionConfigSetResponseErrorSchema = ( + _valueLabel: string = "ReferralProgramEditionConfigSetResponseError", ) => z.object({ - responseCode: z.literal(ReferralProgramCycleConfigSetResponseCodes.Error), + responseCode: z.literal(ReferralProgramEditionConfigSetResponseCodes.Error), error: z.string(), errorMessage: z.string(), }); /** - * Schema for {@link ReferralProgramCycleConfigSetResponse}. + * Schema for {@link ReferralProgramEditionConfigSetResponse}. */ -export const makeReferralProgramCycleConfigSetResponseSchema = ( - valueLabel: string = "ReferralProgramCycleConfigSetResponse", +export const makeReferralProgramEditionConfigSetResponseSchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetResponse", ) => z.discriminatedUnion("responseCode", [ - makeReferralProgramCycleConfigSetResponseOkSchema(valueLabel), - makeReferralProgramCycleConfigSetResponseErrorSchema(valueLabel), + makeReferralProgramEditionConfigSetResponseOkSchema(valueLabel), + makeReferralProgramEditionConfigSetResponseErrorSchema(valueLabel), ]); diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 5e8dbbef0..7c19a7fe5 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -1,18 +1,18 @@ import { - deserializeReferralProgramCycleConfigSetArray, - deserializeReferralProgramCycleConfigSetResponse, - deserializeReferrerDetailCyclesResponse, + deserializeReferralProgramEditionConfigSetArray, + deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, - type ReferralProgramCycleConfigSetResponse, - type ReferrerDetailCyclesRequest, - type ReferrerDetailCyclesResponse, + deserializeReferrerMetricsEditionsResponse, + type ReferralProgramEditionConfigSetResponse, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, - type SerializedReferralProgramCycleConfigSetResponse, - type SerializedReferrerDetailCyclesResponse, + type ReferrerMetricsEditionsRequest, + type ReferrerMetricsEditionsResponse, + type SerializedReferralProgramEditionConfigSetResponse, type SerializedReferrerLeaderboardPageResponse, + type SerializedReferrerMetricsEditionsResponse, } from "./api"; -import type { ReferralProgramCycleConfigSet } from "./cycle"; +import type { ReferralProgramEditionConfigSet } from "./edition"; /** * Default ENSNode API endpoint URL @@ -37,9 +37,9 @@ export interface ClientOptions { * // Create client with default options * const client = new ENSReferralsClient(); * - * // Get referrer leaderboard for December 2025 cycle + * // Get referrer leaderboard for December 2025 edition * const leaderboardPage = await client.getReferrerLeaderboardPage({ - * cycle: "2025-12", + * edition: "2025-12", * page: 1, * recordsPerPage: 25 * }); @@ -76,12 +76,12 @@ export class ENSReferralsClient { } /** - * Get Referral Program Cycle Config Set + * Get Referral Program Edition Config Set * - * Fetches and deserializes a referral program cycle config set from a remote URL. + * Fetches and deserializes a referral program edition config set from a remote URL. * - * @param url - The URL to fetch the cycle config set from - * @returns A ReferralProgramCycleConfigSet (Map of cycle slugs to cycle configurations) + * @param url - The URL to fetch the edition config set from + * @returns A ReferralProgramEditionConfigSet (Map of edition slugs to edition configurations) * * @throws if the fetch fails * @throws if the response is not valid JSON @@ -89,12 +89,14 @@ export class ENSReferralsClient { * * @example * ```typescript - * const url = new URL("https://example.com/cycles.json"); - * const cycleConfigSet = await ENSReferralsClient.getReferralProgramCycleConfigSet(url); - * console.log(`Loaded ${cycleConfigSet.size} cycles`); + * const url = new URL("https://example.com/editions.json"); + * const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(url); + * console.log(`Loaded ${editionConfigSet.size} editions`); * ``` */ - static async getReferralProgramCycleConfigSet(url: URL): Promise { + static async getReferralProgramEditionConfigSet( + url: URL, + ): Promise { const response = await fetch(url); if (!response.ok) { @@ -108,18 +110,18 @@ export class ENSReferralsClient { throw new Error("Malformed response data: invalid JSON"); } - const cycleConfigs = deserializeReferralProgramCycleConfigSetArray(json); + const editionConfigs = deserializeReferralProgramEditionConfigSetArray(json); - return new Map(cycleConfigs.map((cycleConfig) => [cycleConfig.slug, cycleConfig])); + return new Map(editionConfigs.map((editionConfig) => [editionConfig.slug, editionConfig])); } /** * Fetch Referrer Leaderboard Page * - * Retrieves a paginated list of referrer leaderboard metrics for a specific referral program cycle. + * Retrieves a paginated list of referrer leaderboard metrics for a specific referral program edition. * - * @param request - Request parameters including cycle and pagination - * @param request.cycle - The referral program cycle slug (e.g., "2025-12", "2026-03", or custom cycle slug) + * @param request - Request parameters including edition and pagination + * @param request.edition - The referral program edition slug (e.g., "2025-12", "2026-03", or any other configured edition slug) * @param request.page - The page number to retrieve (1-indexed, default: 1) * @param request.recordsPerPage - Number of records per page (default: 25, max: 100) * @returns {ReferrerLeaderboardPageResponse} @@ -131,8 +133,8 @@ export class ENSReferralsClient { * @example * ```typescript * // Get first page of 2025-12 leaderboard with default page size (25 records) - * const cycleSlug = "2025-12"; - * const response = await client.getReferrerLeaderboardPage({ cycle: cycleSlug }); + * const editionSlug = "2025-12"; + * const response = await client.getReferrerLeaderboardPage({ edition: editionSlug }); * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Ok) { * const { * aggregatedMetrics, @@ -141,7 +143,7 @@ export class ENSReferralsClient { * pageContext, * accurateAsOf * } = response.data; - * console.log(`Cycle: ${cycleSlug}`); + * console.log(`Edition: ${editionSlug}`); * console.log(`Subregistry: ${rules.subregistryId}`); * console.log(`Total Referrers: ${pageContext.totalRecords}`); * console.log(`Page ${pageContext.page} of ${pageContext.totalPages}`); @@ -152,7 +154,7 @@ export class ENSReferralsClient { * ```typescript * // Get second page of 2026-03 with 50 records per page * const response = await client.getReferrerLeaderboardPage({ - * cycle: "2026-03", + * edition: "2026-03", * page: 2, * recordsPerPage: 50 * }); @@ -160,8 +162,8 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Handle error response (e.g., unknown cycle or data not available) - * const response = await client.getReferrerLeaderboardPage({ cycle: "2025-12" }); + * // Handle error response (e.g., unknown edition or data not available) + * const response = await client.getReferrerLeaderboardPage({ edition: "2025-12" }); * * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { * console.error(response.error); @@ -174,7 +176,7 @@ export class ENSReferralsClient { ): Promise { const url = new URL(`/v1/ensanalytics/referral-leaderboard`, this.options.url); - url.searchParams.set("cycle", request.cycle); + url.searchParams.set("edition", request.edition); if (request.page) url.searchParams.set("page", request.page.toString()); if (request.recordsPerPage) url.searchParams.set("recordsPerPage", request.recordsPerPage.toString()); @@ -201,54 +203,54 @@ export class ENSReferralsClient { } /** - * Fetch Referrer Detail for Specific Cycles + * Fetch Referrer Metrics for Specific Editions * * Retrieves detailed information about a specific referrer for the requested - * referral program cycles. Returns a record mapping each requested cycle slug - * to the referrer's detail for that cycle. + * referral program editions. Returns a record mapping each requested edition slug + * to the referrer's metrics for that edition. * - * The response data maps cycle slugs to referrer details. Each cycle's data is a + * The response data maps edition slugs to referrer metrics. Each edition's data is a * discriminated union type with a `type` field: * - * **For referrers on the leaderboard** (`ReferrerDetailRanked`): - * - `type`: {@link ReferrerDetailTypeIds.Ranked} + * **For referrers on the leaderboard** (`ReferrerEditionMetricsRanked`): + * - `type`: {@link ReferrerEditionMetricsTypeIds.Ranked} * - `referrer`: The `AwardedReferrerMetrics` with rank, qualification status, and award share - * - `rules`: The referral program rules for this cycle + * - `rules`: The referral program rules for this edition * - `aggregatedMetrics`: Aggregated metrics for all referrers on the leaderboard * - `accurateAsOf`: Unix timestamp indicating when the data was last updated * - * **For referrers NOT on the leaderboard** (`ReferrerDetailUnranked`): - * - `type`: {@link ReferrerDetailTypeIds.Unranked} + * **For referrers NOT on the leaderboard** (`ReferrerEditionMetricsUnranked`): + * - `type`: {@link ReferrerEditionMetricsTypeIds.Unranked} * - `referrer`: The `UnrankedReferrerMetrics` from @namehash/ens-referrals - * - `rules`: The referral program rules for this cycle + * - `rules`: The referral program rules for this edition * - `aggregatedMetrics`: Aggregated metrics for all referrers on the leaderboard * - `accurateAsOf`: Unix timestamp indicating when the data was last updated * * **Note:** This endpoint does not allow partial success. When `responseCode === Ok`, - * all requested cycles are guaranteed to be present in the response data. If any - * requested cycle cannot be returned, the entire request fails with an error. + * all requested editions are guaranteed to be present in the response data. If any + * requested edition cannot be returned, the entire request fails with an error. * * @see {@link https://www.npmjs.com/package/@namehash/ens-referrals|@namehash/ens-referrals} for calculation details * - * @param request The referrer address and cycle slugs to query - * @returns {ReferrerDetailCyclesResponse} Returns the referrer detail for requested cycles + * @param request The referrer address and edition slugs to query + * @returns {ReferrerMetricsEditionsResponse} Returns the referrer metrics for requested editions * * @throws if the ENSNode request fails * @throws if the response data is malformed * * @example * ```typescript - * // Get referrer detail for specific cycles - * const response = await client.getReferrerDetailForCycles({ + * // Get referrer metrics for specific editions + * const response = await client.getReferrerMetricsEditions({ * referrer: "0x1234567890123456789012345678901234567890", - * cycles: ["2025-12", "2026-01"] + * editions: ["2025-12", "2026-01"] * }); - * if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { - * // All requested cycles are present in response.data - * for (const [cycleSlug, detail] of Object.entries(response.data)) { - * console.log(`Cycle: ${cycleSlug}`); + * if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { + * // All requested editions are present in response.data + * for (const [editionSlug, detail] of Object.entries(response.data)) { + * console.log(`Edition: ${editionSlug}`); * console.log(`Type: ${detail.type}`); - * if (detail.type === ReferrerDetailTypeIds.Ranked) { + * if (detail.type === ReferrerEditionMetricsTypeIds.Ranked) { * console.log(`Rank: ${detail.referrer.rank}`); * console.log(`Award Share: ${detail.referrer.awardPoolShare * 100}%`); * } @@ -258,18 +260,18 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Access specific cycle data directly (cycle is guaranteed to exist when OK) - * const response = await client.getReferrerDetailForCycles({ + * // Access specific edition data directly (edition is guaranteed to exist when OK) + * const response = await client.getReferrerMetricsEditions({ * referrer: "0x1234567890123456789012345678901234567890", - * cycles: ["2025-12"] + * editions: ["2025-12"] * }); - * if (response.responseCode === ReferrerDetailCyclesResponseCodes.Ok) { - * const cycle202512Detail = response.data["2025-12"]; - * if (cycle202512Detail && cycle202512Detail.type === ReferrerDetailTypeIds.Ranked) { - * // TypeScript knows this is ReferrerDetailRanked - * console.log(`Cycle 2025-12 Rank: ${cycle202512Detail.referrer.rank}`); - * } else if (cycle202512Detail) { - * // TypeScript knows this is ReferrerDetailUnranked + * if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { + * const edition202512Detail = response.data["2025-12"]; + * if (edition202512Detail && edition202512Detail.type === ReferrerEditionMetricsTypeIds.Ranked) { + * // TypeScript knows this is ReferrerEditionMetricsRanked + * console.log(`Edition 2025-12 Rank: ${edition202512Detail.referrer.rank}`); + * } else if (edition202512Detail) { + * // TypeScript knows this is ReferrerEditionMetricsUnranked * console.log("Referrer is not on the leaderboard for 2025-12"); * } * } @@ -277,28 +279,28 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Handle error response (e.g., unknown cycle or data not available) - * const response = await client.getReferrerDetailForCycles({ + * // Handle error response (e.g., unknown edition or data not available) + * const response = await client.getReferrerMetricsEditions({ * referrer: "0x1234567890123456789012345678901234567890", - * cycles: ["2025-12", "invalid-cycle"] + * editions: ["2025-12", "invalid-edition"] * }); * - * if (response.responseCode === ReferrerDetailCyclesResponseCodes.Error) { + * if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { * console.error(response.error); * console.error(response.errorMessage); * } * ``` */ - async getReferrerDetailForCycles( - request: ReferrerDetailCyclesRequest, - ): Promise { + async getReferrerMetricsEditions( + request: ReferrerMetricsEditionsRequest, + ): Promise { const url = new URL( `/v1/ensanalytics/referrer/${encodeURIComponent(request.referrer)}`, this.options.url, ); - // Add cycles as comma-separated query parameter - url.searchParams.set("cycles", request.cycles.join(",")); + // Add editions as comma-separated query parameter + url.searchParams.set("editions", request.editions.join(",")); const response = await fetch(url); @@ -312,29 +314,29 @@ export class ENSReferralsClient { } // The API can return errors with various status codes, but they're still in the - // ReferrerDetailCyclesResponse format with responseCode: 'error' + // ReferrerMetricsEditionsResponse format with responseCode: 'error' // So we don't need to check response.ok here, just deserialize and let // the caller handle the responseCode - return deserializeReferrerDetailCyclesResponse( - responseData as SerializedReferrerDetailCyclesResponse, + return deserializeReferrerMetricsEditionsResponse( + responseData as SerializedReferrerMetricsEditionsResponse, ); } /** - * Get the currently configured referral program cycle config set. - * Cycles are sorted in descending order by start timestamp (most recent first). + * Get the currently configured referral program edition config set. + * Editions are sorted in descending order by start timestamp (most recent first). * - * @returns A response containing the cycle config set, or an error response if unavailable. + * @returns A response containing the edition config set, or an error response if unavailable. * * @example * ```typescript - * const response = await client.getCycleConfigSet(); + * const response = await client.getEditionConfigSet(); * - * if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Ok) { - * console.log(`Found ${response.data.cycles.length} cycles`); - * for (const cycle of response.data.cycles) { - * console.log(`${cycle.slug}: ${cycle.displayName}`); + * if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) { + * console.log(`Found ${response.data.editions.length} editions`); + * for (const edition of response.data.editions) { + * console.log(`${edition.slug}: ${edition.displayName}`); * } * } * ``` @@ -342,16 +344,16 @@ export class ENSReferralsClient { * @example * ```typescript * // Handle error response - * const response = await client.getCycleConfigSet(); + * const response = await client.getEditionConfigSet(); * - * if (response.responseCode === ReferralProgramCycleConfigSetResponseCodes.Error) { + * if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) { * console.error(response.error); * console.error(response.errorMessage); * } * ``` */ - async getCycleConfigSet(): Promise { - const url = new URL(`/v1/ensanalytics/cycles`, this.options.url); + async getEditionConfigSet(): Promise { + const url = new URL(`/v1/ensanalytics/editions`, this.options.url); const response = await fetch(url); @@ -365,12 +367,12 @@ export class ENSReferralsClient { } // The API can return errors with various status codes, but they're still in the - // ReferralProgramCycleConfigSetResponse format with responseCode: 'error' + // ReferralProgramEditionConfigSetResponse format with responseCode: 'error' // So we don't need to check response.ok here, just deserialize and let // the caller handle the responseCode - return deserializeReferralProgramCycleConfigSetResponse( - responseData as SerializedReferralProgramCycleConfigSetResponse, + return deserializeReferralProgramEditionConfigSetResponse( + responseData as SerializedReferralProgramEditionConfigSetResponse, ); } } diff --git a/packages/ens-referrals/src/v1/cycle.ts b/packages/ens-referrals/src/v1/cycle.ts deleted file mode 100644 index 5436012b9..000000000 --- a/packages/ens-referrals/src/v1/cycle.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ReferralProgramRules } from "./rules"; - -/** - * Referral program cycle slug. - * - * A URL-safe identifier for a referral program cycle. Each cycle represents - * a distinct referral program period with its own rules, leaderboard, and - * award distribution. - * - * @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]+)*$` - * - * @example "2025-12" // December 2025 cycle - * @example "2026-03" // March 2026 cycle - * @example "holiday-special" // Custom named cycle - */ -export type ReferralProgramCycleSlug = string; - -/** - * Represents a referral program cycle configuration. - */ -export interface ReferralProgramCycleConfig { - /** - * Unique slug identifier for the cycle. - * - * @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]+)*$` - */ - slug: ReferralProgramCycleSlug; - - /** - * Human-readable display name for the cycle. - * @example "ENS Holiday Awards" - */ - displayName: string; - - /** - * The rules that govern this referral program cycle. - */ - rules: ReferralProgramRules; -} - -/** - * A map from cycle slug to cycle configuration. - * - * Used to store and look up all configured referral program cycles. - */ -export type ReferralProgramCycleConfigSet = Map< - ReferralProgramCycleSlug, - ReferralProgramCycleConfig ->; diff --git a/packages/ens-referrals/src/v1/cycle-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts similarity index 66% rename from packages/ens-referrals/src/v1/cycle-defaults.ts rename to packages/ens-referrals/src/v1/edition-defaults.ts index 0102a95af..ee645f95b 100644 --- a/packages/ens-referrals/src/v1/cycle-defaults.ts +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -5,25 +5,25 @@ import { parseUsdc, } from "@ensnode/ensnode-sdk"; -import type { ReferralProgramCycleConfig, ReferralProgramCycleConfigSet } from "./cycle"; +import type { ReferralProgramEditionConfig, ReferralProgramEditionConfigSet } from "./edition"; import { buildReferralProgramRules } from "./rules"; /** - * Returns the default referral program cycle set with pre-built cycle configurations. + * Returns the default referral program edition set with pre-built edition configurations. * * This function maps from an ENS namespace to the appropriate subregistry (BaseRegistrar) - * and builds the default referral program cycles for that namespace. + * and builds the default referral program editions for that namespace. * - * @param ensNamespaceId - The ENS namespace slug to get the default cycles for - * @returns A map of cycle slugs to their pre-built cycle configurations + * @param ensNamespaceId - The ENS namespace slug to get the default editions for + * @returns A map of edition slugs to their pre-built edition configurations * @throws Error if the subregistry contract is not found for the given namespace */ -export function getDefaultReferralProgramCycleConfigSet( +export function getDefaultReferralProgramEditionConfigSet( ensNamespaceId: ENSNamespaceId, -): ReferralProgramCycleConfigSet { +): ReferralProgramEditionConfigSet { const subregistryId = getEthnamesSubregistryId(ensNamespaceId); - const cycle1: ReferralProgramCycleConfig = { + const edition1: ReferralProgramEditionConfig = { slug: "2025-12", displayName: "ENS Holiday Awards", rules: buildReferralProgramRules( @@ -36,7 +36,7 @@ export function getDefaultReferralProgramCycleConfigSet( ), }; - const cycle2: ReferralProgramCycleConfig = { + const edition2: ReferralProgramEditionConfig = { slug: "2026-03", displayName: "March 2026", rules: buildReferralProgramRules( @@ -50,7 +50,7 @@ export function getDefaultReferralProgramCycleConfigSet( }; return new Map([ - ["2025-12", cycle1], - ["2026-03", cycle2], + [edition1.slug, edition1], + [edition2.slug, edition2], ]); } diff --git a/packages/ens-referrals/src/v1/referrer-detail.ts b/packages/ens-referrals/src/v1/edition-metrics.ts similarity index 64% rename from packages/ens-referrals/src/v1/referrer-detail.ts rename to packages/ens-referrals/src/v1/edition-metrics.ts index 68ddde88f..1c46f9a37 100644 --- a/packages/ens-referrals/src/v1/referrer-detail.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -12,9 +12,9 @@ import { import type { ReferralProgramRules } from "./rules"; /** - * The type of referrer detail data. + * The type of referrer edition metrics data. */ -export const ReferrerDetailTypeIds = { +export const ReferrerEditionMetricsTypeIds = { /** * Represents a referrer who is ranked on the leaderboard. */ @@ -27,26 +27,26 @@ export const ReferrerDetailTypeIds = { } as const; /** - * The derived string union of possible {@link ReferrerDetailTypeIds}. + * The derived string union of possible {@link ReferrerEditionMetricsTypeIds}. */ -export type ReferrerDetailTypeId = - (typeof ReferrerDetailTypeIds)[keyof typeof ReferrerDetailTypeIds]; +export type ReferrerEditionMetricsTypeId = + (typeof ReferrerEditionMetricsTypeIds)[keyof typeof ReferrerEditionMetricsTypeIds]; /** - * Referrer detail data for a specific referrer address on the leaderboard. + * Referrer edition metrics data for a specific referrer address on the leaderboard. * * Includes the referrer's awarded metrics from the leaderboard plus timestamp. * * Invariants: - * - `type` is always {@link ReferrerDetailTypeIds.Ranked}. + * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. * * @see {@link AwardedReferrerMetrics} */ -export interface ReferrerDetailRanked { +export interface ReferrerEditionMetricsRanked { /** - * The type of referrer detail data. + * The type of referrer edition metrics data. */ - type: typeof ReferrerDetailTypeIds.Ranked; + type: typeof ReferrerEditionMetricsTypeIds.Ranked; /** * The {@link ReferralProgramRules} used to calculate the {@link AwardedReferrerMetrics}. @@ -67,26 +67,26 @@ export interface ReferrerDetailRanked { aggregatedMetrics: AggregatedReferrerMetrics; /** - * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerDetailData} was accurate as of. + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRanked} was accurate as of. */ accurateAsOf: UnixTimestamp; } /** - * Referrer detail data for a specific referrer address NOT on the leaderboard. + * Referrer edition metrics data for a specific referrer address NOT on the leaderboard. * * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. * * Invariants: - * - `type` is always {@link ReferrerDetailTypeIds.Unranked}. + * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. * * @see {@link UnrankedReferrerMetrics} */ -export interface ReferrerDetailUnranked { +export interface ReferrerEditionMetricsUnranked { /** - * The type of referrer detail data. + * The type of referrer edition metrics data. */ - type: typeof ReferrerDetailTypeIds.Unranked; + type: typeof ReferrerEditionMetricsTypeIds.Unranked; /** * The {@link ReferralProgramRules} used to calculate the {@link UnrankedReferrerMetrics}. @@ -106,39 +106,39 @@ export interface ReferrerDetailUnranked { aggregatedMetrics: AggregatedReferrerMetrics; /** - * The {@link UnixTimestamp} of when the data used to build the {@link UnrankedReferrerDetailData} was accurate as of. + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnranked} was accurate as of. */ accurateAsOf: UnixTimestamp; } /** - * Referrer detail data for a specific referrer address. + * Referrer edition metrics data for a specific referrer address. * * Use the `type` field to determine the specific type interpretation * at runtime. */ -export type ReferrerDetail = ReferrerDetailRanked | ReferrerDetailUnranked; +export type ReferrerEditionMetrics = ReferrerEditionMetricsRanked | ReferrerEditionMetricsUnranked; /** - * Get the detail for a specific referrer from the leaderboard. + * Get the edition metrics for a specific referrer from the leaderboard. * - * Returns a {@link ReferrerDetailRanked} if the referrer is on the leaderboard, - * or a {@link ReferrerDetailUnranked} if the referrer has no referrals. + * Returns a {@link ReferrerEditionMetricsRanked} if the referrer is on the leaderboard, + * or a {@link ReferrerEditionMetricsUnranked} if the referrer has no referrals. * * @param referrer - The referrer address to look up * @param leaderboard - The referrer leaderboard to query - * @returns The appropriate {@link ReferrerDetail} (ranked or unranked) + * @returns The appropriate {@link ReferrerEditionMetrics} (ranked or unranked) */ -export const getReferrerDetail = ( +export const getReferrerEditionMetrics = ( referrer: Address, leaderboard: ReferrerLeaderboard, -): ReferrerDetail => { +): ReferrerEditionMetrics => { const awardedReferrerMetrics = leaderboard.referrers.get(referrer); // If referrer is on the leaderboard, return their ranked metrics if (awardedReferrerMetrics) { return { - type: ReferrerDetailTypeIds.Ranked, + type: ReferrerEditionMetricsTypeIds.Ranked, rules: leaderboard.rules, referrer: awardedReferrerMetrics, aggregatedMetrics: leaderboard.aggregatedMetrics, @@ -148,7 +148,7 @@ export const getReferrerDetail = ( // If referrer not found, return an unranked referrer record return { - type: ReferrerDetailTypeIds.Unranked, + type: ReferrerEditionMetricsTypeIds.Unranked, rules: leaderboard.rules, referrer: buildUnrankedReferrerMetrics(referrer), aggregatedMetrics: leaderboard.aggregatedMetrics, diff --git a/packages/ens-referrals/src/v1/edition.ts b/packages/ens-referrals/src/v1/edition.ts new file mode 100644 index 000000000..dd5be4d70 --- /dev/null +++ b/packages/ens-referrals/src/v1/edition.ts @@ -0,0 +1,51 @@ +import type { ReferralProgramRules } from "./rules"; + +/** + * Referral program edition slug. + * + * A URL-safe identifier for a referral program edition. Each edition represents + * a distinct referral program period with its own rules, leaderboard, and + * award distribution. + * + * @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]+)*$` + * + * @example "2025-12" // December 2025 edition + * @example "2026-03" // March 2026 edition + * @example "holiday-special" // Custom named edition + */ +export type ReferralProgramEditionSlug = string; + +/** + * Represents a referral program edition configuration. + */ +export interface ReferralProgramEditionConfig { + /** + * Unique slug identifier for the edition. + * + * @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]+)*$` + */ + slug: ReferralProgramEditionSlug; + + /** + * Human-readable display name for the edition. + * @example "ENS Holiday Awards" + */ + displayName: string; + + /** + * The rules that govern this referral program edition. + */ + rules: ReferralProgramRules; +} + +/** + * A map from edition slug to edition configuration. + * + * Used to store and look up all configured referral program editions. + */ +export type ReferralProgramEditionConfigSet = Map< + ReferralProgramEditionSlug, + ReferralProgramEditionConfig +>; diff --git a/packages/ens-referrals/src/v1/index.ts b/packages/ens-referrals/src/v1/index.ts index ec911a690..72af819ce 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -2,14 +2,14 @@ export * from "./address"; export * from "./aggregations"; export * from "./api"; export * from "./client"; -export * from "./cycle"; -export * from "./cycle-defaults"; +export * from "./edition"; +export * from "./edition-defaults"; +export * from "./edition-metrics"; export * from "./leaderboard"; export * from "./leaderboard-page"; export * from "./link"; export * from "./number"; export * from "./rank"; -export * from "./referrer-detail"; export * from "./referrer-metrics"; export * from "./rules"; export * from "./score"; diff --git a/packages/ens-referrals/src/v1/rules.ts b/packages/ens-referrals/src/v1/rules.ts index 2334abb3c..9babd2acb 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -36,7 +36,7 @@ export interface ReferralProgramRules { subregistryId: AccountId; /** - * URL to the full rules document for this cycle. + * URL to the full rules document for these rules. * @example new URL("https://ensawards.org/ens-holiday-awards-rules") */ rulesUrl: URL; diff --git a/packages/ensnode-sdk/src/shared/config/environments.ts b/packages/ensnode-sdk/src/shared/config/environments.ts index 23708c272..6f0f9a7bd 100644 --- a/packages/ensnode-sdk/src/shared/config/environments.ts +++ b/packages/ensnode-sdk/src/shared/config/environments.ts @@ -53,15 +53,15 @@ export type TheGraphEnvironment = { }; /** - * Environment variables for referral program cycles configuration. + * Environment variables for referral program editions configuration. * - * If CUSTOM_REFERRAL_PROGRAM_CYCLES is set, it should be a URL that returns - * the JSON for a valid serialized custom referral program cycles definition. + * If CUSTOM_REFERRAL_PROGRAM_EDITIONS is set, it should be a URL that returns + * the JSON for a valid serialized custom referral program editions definition. */ -export interface ReferralProgramCyclesEnvironment { +export interface ReferralProgramEditionsEnvironment { /** - * Optional URL that returns the JSON for a valid serialized custom referral program cycles definition. - * If not set, the default cycle set will be used. + * Optional URL that returns the JSON for a valid serialized custom referral program editions definition. + * If not set, the default edition set will be used. */ - CUSTOM_REFERRAL_PROGRAM_CYCLES?: string; + CUSTOM_REFERRAL_PROGRAM_EDITIONS?: string; } From 67f4a2879852374dcce00791c3dd8be1a0ac67c1 Mon Sep 17 00:00:00 2001 From: Goader Date: Sun, 8 Feb 2026 17:53:47 +0100 Subject: [PATCH 13/16] review applied --- .changeset/clever-laws-count.md | 3 +- .changeset/proud-eagles-sing.md | 5 +++ apps/ensapi/.env.local.example | 2 - .../referral-program-edition-set.cache.ts | 15 +------ apps/ensapi/src/config/config.schema.test.ts | 16 ++++++++ .../src/handlers/ensanalytics-api-v1.test.ts | 25 +++++------- .../get-referrer-leaderboard-v1.test.ts | 9 ++--- ...-leaderboard-editions-caches.middleware.ts | 2 +- ...referral-program-edition-set.middleware.ts | 4 +- .../ens-referrals/src/v1/api/zod-schemas.ts | 40 +++++++++---------- .../ens-referrals/src/v1/edition-defaults.ts | 1 + packages/ens-referrals/src/v1/rules.ts | 6 +++ .../ensnode-sdk/src/shared/cache/swr-cache.ts | 2 +- .../ensnode-sdk/src/shared/datetime.test.ts | 24 ++++++++--- packages/ensnode-sdk/src/shared/datetime.ts | 14 ++++--- 15 files changed, 96 insertions(+), 72 deletions(-) create mode 100644 .changeset/proud-eagles-sing.md diff --git a/.changeset/clever-laws-count.md b/.changeset/clever-laws-count.md index dfbee2542..4fe9f7237 100644 --- a/.changeset/clever-laws-count.md +++ b/.changeset/clever-laws-count.md @@ -1,7 +1,6 @@ --- "@namehash/ens-referrals": minor "ensapi": minor -"@ensnode/ensnode-sdk": patch --- -Introduces referral program cycles support with pre-configured cycle definitions (ENS Holiday Awards December 2025, March 2026 cycle). Updated ENSAnalytics API v1 to support cycle-based leaderboard queries and added cycle configuration to environment schema. +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. diff --git a/.changeset/proud-eagles-sing.md b/.changeset/proud-eagles-sing.md new file mode 100644 index 000000000..d6a4094ed --- /dev/null +++ b/.changeset/proud-eagles-sing.md @@ -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. diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 4c5704e08..bac2f29a4 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -120,7 +120,6 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # 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 at ENSApi startup (before accepting requests) # - Once successfully loaded, cached indefinitely (never expires or revalidates) @@ -129,7 +128,6 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # * ENSApi continues running # * Failed state is cached for 1 minute, then retried on subsequent requests # * API requests receive error responses until successful load -# - Requests received before initial load completes will receive error responses # # Configuration Notes: # - Setting CUSTOM_REFERRAL_PROGRAM_EDITIONS completely replaces the default edition config set diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts index 0ffb99659..29a0d1ae7 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -30,6 +30,7 @@ async function loadReferralProgramEditionConfigSet(): Promise({ - fn: async () => { - try { - const editionConfigSet = await loadReferralProgramEditionConfigSet(); - logger.info("Referral program edition config set cached successfully"); - return editionConfigSet; - } catch (error) { - logger.error( - error, - "Error occurred while loading referral program edition config set. The cache will remain empty.", - ); - throw error; - } - }, + fn: loadReferralProgramEditionConfigSet, ttl: Number.POSITIVE_INFINITY, errorTtl: minutesToSeconds(1), proactiveRevalidationInterval: undefined, diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 32dbfb03c..d13a7462e 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -83,6 +83,22 @@ describe("buildConfigFromEnvironment", () => { }); }); + it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", async () => { + const customUrl = "https://example.com/editions.json"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), + }); + + const config = await buildConfigFromEnvironment({ + ...BASE_ENV, + CUSTOM_REFERRAL_PROGRAM_EDITIONS: customUrl, + }); + + expect(config.customReferralProgramEditionConfigSetUrl).toEqual(new URL(customUrl)); + }); + describe("Useful error messages", () => { // Mock process.exit to prevent actual exit const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index a79f9669c..f24c5a51b 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -42,7 +42,7 @@ import { type ReferrerMetricsEditionsResponseOk, } from "@namehash/ens-referrals/v1"; -import { parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; +import { parseTimestamp, parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; import { emptyReferralLeaderboard, @@ -259,7 +259,8 @@ describe("/v1/ensanalytics", () => { // 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 }], + ["test-edition-a", { slug: "test-edition-a", displayName: "Edition A", rules: {} as any }], + ["test-edition-b", { slug: "test-edition-b", displayName: "Edition B", rules: {} as any }], ]); vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { @@ -799,8 +800,8 @@ describe("/v1/ensanalytics", () => { rules: buildReferralProgramRules( parseUsdc("10000"), 100, - 1733011200, // 2024-12-01 - 1735603200, // 2024-12-31 + parseTimestamp("2025-12-01T00:00:00Z"), + parseTimestamp("2025-12-31T23:59:59Z"), { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, new URL("https://example.com/rules"), ), @@ -814,8 +815,8 @@ describe("/v1/ensanalytics", () => { rules: buildReferralProgramRules( parseUsdc("10000"), 100, - 1740787200, // 2025-03-01 - 1743465600, // 2025-03-31 + parseTimestamp("2026-03-01T00:00:00Z"), + parseTimestamp("2026-03-31T23:59:59Z"), { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, new URL("https://example.com/rules"), ), @@ -829,8 +830,8 @@ describe("/v1/ensanalytics", () => { rules: buildReferralProgramRules( parseUsdc("10000"), 100, - 1748736000, // 2025-06-01 - 1751328000, // 2025-06-30 + parseTimestamp("2026-06-01T00:00:00Z"), + parseTimestamp("2026-06-30T23:59:59Z"), { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, new URL("https://example.com/rules"), ), @@ -888,14 +889,6 @@ describe("/v1/ensanalytics", () => { }, ); - // Mock caches middleware (needed by middleware chain) - vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, - ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", new Map()); - return await next(); - }); - // Act: send test request const httpResponse = await app.request("/editions"); const responseData = await httpResponse.json(); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 152111754..5903ad331 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -1,8 +1,7 @@ import { buildReferralProgramRules, type ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; -import { getUnixTime } from "date-fns"; import { describe, expect, it, vi } from "vitest"; -import { parseUsdc } from "@ensnode/ensnode-sdk"; +import { parseTimestamp, parseUsdc } from "@ensnode/ensnode-sdk"; import * as database from "./database-v1"; import { getReferrerLeaderboard } from "./get-referrer-leaderboard-v1"; @@ -16,8 +15,8 @@ vi.mock("./database-v1", () => ({ const rules = buildReferralProgramRules( parseUsdc("10000"), 10, // maxQualifiedReferrers - getUnixTime("2025-01-01T00:00:00Z"), - getUnixTime("2025-12-31T23:59:59Z"), + parseTimestamp("2025-01-01T00:00:00Z"), + parseTimestamp("2025-12-31T23:59:59Z"), { chainId: 1, address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", @@ -25,7 +24,7 @@ const rules = buildReferralProgramRules( new URL("https://example.com/rules"), ); -const accurateAsOf = getUnixTime("2025-11-30T23:59:59Z"); +const accurateAsOf = parseTimestamp("2025-11-30T23:59:59Z"); describe("ENSAnalytics Referrer Leaderboard", () => { describe("getReferrerLeaderboard", () => { diff --git a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts index 4b77084bf..1546b76e0 100644 --- a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts +++ b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts @@ -3,7 +3,7 @@ import { type ReferralLeaderboardEditionsCacheMap, } from "@/cache/referral-leaderboard-editions.cache"; import { factory } from "@/lib/hono-factory"; -import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; +import type { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; /** * Type definition for the referral leaderboard editions caches middleware context passed to downstream middleware and handlers. diff --git a/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts index ca6f788a4..58410d476 100644 --- a/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts +++ b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts @@ -21,8 +21,10 @@ export type ReferralProgramEditionConfigSetMiddlewareVariables = { * to downstream middleware and handlers. * * This middleware reads the referral program edition config set from the SWR cache. - * The cache is initialized once at startup and never revalidated, ensuring + * The cache is initialized once at startup and, if successful, never revalidated, ensuring * the edition config set JSON is only fetched once during the application lifecycle. + * + * If the cache fails to load, the JSON fetching will be retried on subsequent requests. */ export const referralProgramEditionConfigSetMiddleware = factory.createMiddleware( async (c, next) => { diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 0b5affb48..bd61e6179 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -223,6 +223,25 @@ export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerE makeReferrerEditionMetricsUnrankedSchema(valueLabel), ]); +/** + * Schema for validating a {@link ReferralProgramEditionSlug}. + * + * Enforces the slug format invariant: lowercase letters (a-z), digits (0-9), + * and hyphens (-) only. Must not start or end with a hyphen. + * + * Runtime validation against configured editions happens at the business logic level. + */ +export const makeReferralProgramEditionSlugSchema = ( + valueLabel: string = "ReferralProgramEditionSlug", +) => + z + .string() + .min(1, `${valueLabel} must not be empty`) + .regex( + /^[a-z0-9]+(-[a-z0-9]+)*$/, + `${valueLabel} must contain only lowercase letters, digits, and hyphens. Must not start or end with a hyphen.`, + ); + /** * Schema for validating editions array (min 1, max {@link MAX_EDITIONS_PER_REQUEST}, distinct values). */ @@ -292,25 +311,6 @@ export const makeReferrerMetricsEditionsResponseSchema = ( makeReferrerMetricsEditionsResponseErrorSchema(valueLabel), ]); -/** - * Schema for validating a {@link ReferralProgramEditionSlug}. - * - * Enforces the slug format invariant: lowercase letters (a-z), digits (0-9), - * and hyphens (-) only. Must not start or end with a hyphen. - * - * Runtime validation against configured editions happens at the business logic level. - */ -export const makeReferralProgramEditionSlugSchema = ( - valueLabel: string = "ReferralProgramEditionSlug", -) => - z - .string() - .min(1, `${valueLabel} must not be empty`) - .regex( - /^[a-z0-9]+(-[a-z0-9]+)*$/, - `${valueLabel} must contain only lowercase letters, digits, and hyphens. Must not start or end with a hyphen.`, - ); - /** * Schema for validating a {@link ReferralProgramEditionConfig}. */ @@ -351,7 +351,7 @@ export const makeReferralProgramEditionConfigSetDataSchema = ( valueLabel: string = "ReferralProgramEditionConfigSetData", ) => z.object({ - editions: z.array(makeReferralProgramEditionConfigSchema(`${valueLabel}.editions[edition]`)), + editions: makeReferralProgramEditionConfigSetArraySchema(`${valueLabel}.editions`), }); /** diff --git a/packages/ens-referrals/src/v1/edition-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts index ee645f95b..c30036194 100644 --- a/packages/ens-referrals/src/v1/edition-defaults.ts +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -45,6 +45,7 @@ export function getDefaultReferralProgramEditionConfigSet( 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 new URL("https://ensawards.org/ens-holiday-awards-rules"), ), }; diff --git a/packages/ens-referrals/src/v1/rules.ts b/packages/ens-referrals/src/v1/rules.ts index 9babd2acb..13c7137e8 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -65,6 +65,12 @@ export const validateReferralProgramRules = (rules: ReferralProgramRules): void validateUnixTimestamp(rules.startTime); validateUnixTimestamp(rules.endTime); + if (!(rules.rulesUrl instanceof URL)) { + throw new Error( + `ReferralProgramRules: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, + ); + } + if (rules.endTime < rules.startTime) { throw new Error( `ReferralProgramRules: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts index 789ffa0fc..358982f72 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts @@ -123,7 +123,7 @@ export class SWRCache { }; }) .catch((error) => { - // on error, only update the cache if this is the first revalidation + // on error, only update the cache if there has been no successful revalidation yet if (!this.cache || this.cache.result instanceof Error) { this.cache = { // ensure thrown value is always an Error instance diff --git a/packages/ensnode-sdk/src/shared/datetime.test.ts b/packages/ensnode-sdk/src/shared/datetime.test.ts index c57f9925a..b1067b157 100644 --- a/packages/ensnode-sdk/src/shared/datetime.test.ts +++ b/packages/ensnode-sdk/src/shared/datetime.test.ts @@ -29,22 +29,34 @@ describe("datetime", () => { }); describe("parseTimestamp()", () => { - it("parses ISO 8601 date strings correctly", () => { + it("parses ISO 8601 date strings with Z suffix correctly", () => { expect(parseTimestamp("2025-12-01T00:00:00Z")).toEqual(1764547200); expect(parseTimestamp("2026-03-31T23:59:59Z")).toEqual(1775001599); expect(parseTimestamp("2026-03-01T00:00:00Z")).toEqual(1772323200); expect(parseTimestamp("2025-12-31T23:59:59Z")).toEqual(1767225599); }); - it("parses date strings without timezone", () => { - // The exact value depends on the system timezone, but it should not throw - expect(() => parseTimestamp("2025-01-01T00:00:00")).not.toThrow(); + it("parses ISO 8601 date strings with UTC offset correctly", () => { + expect(parseTimestamp("2025-12-01T01:00:00+01:00")).toEqual(1764547200); + expect(parseTimestamp("2025-12-01T00:00:00-05:00")).toEqual(1764565200); + }); + + it("throws for date strings missing a timezone designator", () => { + expect(() => parseTimestamp("2025-01-01T00:00:00")).toThrowError( + /Timezone required: provide Z or offset/, + ); + expect(() => parseTimestamp("2025-01-01")).toThrowError( + /Timezone required: provide Z or offset/, + ); }); it("throws an error for invalid date strings", () => { - expect(() => parseTimestamp("invalid-date")).toThrowError(/Invalid date string/); - expect(() => parseTimestamp("")).toThrowError(/Invalid date string/); + expect(() => parseTimestamp("invalid-dateZ")).toThrowError(/Invalid date string/); expect(() => parseTimestamp("2025-13-01T00:00:00Z")).toThrowError(/Invalid date string/); }); + + it("throws for empty string", () => { + expect(() => parseTimestamp("")).toThrowError(/Timezone required: provide Z or offset/); + }); }); }); diff --git a/packages/ensnode-sdk/src/shared/datetime.ts b/packages/ensnode-sdk/src/shared/datetime.ts index 0a81cb56e..497c70e07 100644 --- a/packages/ensnode-sdk/src/shared/datetime.ts +++ b/packages/ensnode-sdk/src/shared/datetime.ts @@ -20,24 +20,28 @@ export function addDuration(timestamp: UnixTimestamp, duration: Duration): UnixT /** * Parses an ISO 8601 date string into a {@link UnixTimestamp}. * - * Accepts date strings in ISO 8601 format (e.g., "2025-12-01T00:00:00Z"). - * The string must be parseable by JavaScript's Date constructor. + * Accepts date strings in ISO 8601 format with an explicit timezone designator + * (trailing 'Z' or offset like +HH:MM/-HH:MM), e.g., "2025-12-01T00:00:00Z". * - * @param isoDateString - The ISO 8601 date string to parse + * @param isoDateString - The ISO 8601 date string to parse (must include timezone) * @returns The Unix timestamp (seconds since epoch) * - * @throws {Error} If the date string is invalid or cannot be parsed + * @throws {Error} If the date string is missing a timezone designator or cannot be parsed * * @example * parseTimestamp("2025-12-01T00:00:00Z") // returns 1764547200 * parseTimestamp("2026-03-31T23:59:59Z") // returns 1775001599 */ export function parseTimestamp(isoDateString: string): UnixTimestamp { + if (!/Z$|[+-]\d{2}:\d{2}$/.test(isoDateString)) { + throw new Error(`Timezone required: provide Z or offset`); + } + const date = new Date(isoDateString); if (Number.isNaN(date.getTime())) { throw new Error(`Invalid date string: ${isoDateString}`); } - return getUnixTime(date) as UnixTimestamp; + return deserializeUnixTimestamp(getUnixTime(date), "UnixTimestamp"); } From 346dd39886e28ddc64b3590b9e62a51c5fbd8f06 Mon Sep 17 00:00:00 2001 From: Goader Date: Sun, 8 Feb 2026 18:37:16 +0100 Subject: [PATCH 14/16] review applied --- apps/ensapi/src/config/config.schema.test.ts | 17 +++++++++++++++++ .../src/handlers/ensanalytics-api-v1.test.ts | 8 ++++++++ .../ens-referrals/src/v1/api/zod-schemas.ts | 4 ++-- .../ensnode-sdk/src/shared/cache/swr-cache.ts | 2 +- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index d13a7462e..f0905c285 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -116,6 +116,23 @@ describe("buildConfigFromEnvironment", () => { ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL, }; + it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), + }); + + await buildConfigFromEnvironment({ + ...TEST_ENV, + CUSTOM_REFERRAL_PROGRAM_EDITIONS: "not-a-url", + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL: not-a-url"), + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index f24c5a51b..aa72ef716 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -889,6 +889,14 @@ describe("/v1/ensanalytics", () => { }, ); + // Mock caches middleware (needed by middleware chain even though /editions doesn't use it) + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", new Map()); + return await next(); + }); + // Act: send test request const httpResponse = await app.request("/editions"); const responseData = await httpResponse.json(); diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index bd61e6179..371f3060a 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -278,7 +278,7 @@ export const makeReferrerMetricsEditionsRequestSchema = ( * Schema for {@link ReferrerMetricsEditionsResponseOk} */ export const makeReferrerMetricsEditionsResponseOkSchema = ( - valueLabel: string = "ReferrerMetricsEditionsResponse", + valueLabel: string = "ReferrerMetricsEditionsResponseOk", ) => z.object({ responseCode: z.literal(ReferrerMetricsEditionsResponseCodes.Ok), @@ -292,7 +292,7 @@ export const makeReferrerMetricsEditionsResponseOkSchema = ( * Schema for {@link ReferrerMetricsEditionsResponseError} */ export const makeReferrerMetricsEditionsResponseErrorSchema = ( - _valueLabel: string = "ReferrerMetricsEditionsResponse", + _valueLabel: string = "ReferrerMetricsEditionsResponseError", ) => z.object({ responseCode: z.literal(ReferrerMetricsEditionsResponseCodes.Error), diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts index 358982f72..39022f336 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts @@ -161,7 +161,7 @@ export class SWRCache { ? this.options.errorTtl : this.options.ttl; - // if ttl expired, revalidate in background + // if effective TTL expired, revalidate in background if (durationBetween(this.cache.updatedAt, getUnixTime(new Date())) > effectiveTtl) { this.revalidate(); } From ffc55333049924f1f8bfae354267f9c42fa5a993 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 9 Feb 2026 17:48:38 +0100 Subject: [PATCH 15/16] small tweaks from review --- apps/ensapi/.env.local.example | 2 +- packages/ens-referrals/src/v1/client.ts | 7 +++- .../ens-referrals/src/v1/edition-defaults.ts | 13 +++--- packages/ens-referrals/src/v1/edition.ts | 41 +++++++++++++++++-- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index bac2f29a4..6de2ee84b 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -121,7 +121,7 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # For the complete schema definition, see makeReferralProgramEditionConfigSetArraySchema in @namehash/ens-referrals/v1 # # Fetching Behavior: -# - Fetched proactively at ENSApi startup (before accepting requests) +# - 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 diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 7c19a7fe5..1086b13fc 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -12,7 +12,10 @@ import { type SerializedReferrerLeaderboardPageResponse, type SerializedReferrerMetricsEditionsResponse, } from "./api"; -import type { ReferralProgramEditionConfigSet } from "./edition"; +import { + buildReferralProgramEditionConfigSet, + type ReferralProgramEditionConfigSet, +} from "./edition"; /** * Default ENSNode API endpoint URL @@ -112,7 +115,7 @@ export class ENSReferralsClient { const editionConfigs = deserializeReferralProgramEditionConfigSetArray(json); - return new Map(editionConfigs.map((editionConfig) => [editionConfig.slug, editionConfig])); + return buildReferralProgramEditionConfigSet(editionConfigs); } /** diff --git a/packages/ens-referrals/src/v1/edition-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts index c30036194..df6086a29 100644 --- a/packages/ens-referrals/src/v1/edition-defaults.ts +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -5,7 +5,11 @@ import { parseUsdc, } from "@ensnode/ensnode-sdk"; -import type { ReferralProgramEditionConfig, ReferralProgramEditionConfigSet } from "./edition"; +import { + buildReferralProgramEditionConfigSet, + type ReferralProgramEditionConfig, + type ReferralProgramEditionConfigSet, +} from "./edition"; import { buildReferralProgramRules } from "./rules"; /** @@ -45,13 +49,10 @@ export function getDefaultReferralProgramEditionConfigSet( 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 + // TODO: replace this with the dedicated March 2026 rules URL once published new URL("https://ensawards.org/ens-holiday-awards-rules"), ), }; - return new Map([ - [edition1.slug, edition1], - [edition2.slug, edition2], - ]); + return buildReferralProgramEditionConfigSet([edition1, edition2]); } diff --git a/packages/ens-referrals/src/v1/edition.ts b/packages/ens-referrals/src/v1/edition.ts index dd5be4d70..62afb97f1 100644 --- a/packages/ens-referrals/src/v1/edition.ts +++ b/packages/ens-referrals/src/v1/edition.ts @@ -22,9 +22,6 @@ export type ReferralProgramEditionSlug = string; export interface ReferralProgramEditionConfig { /** * Unique slug identifier for the edition. - * - * @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]+)*$` */ slug: ReferralProgramEditionSlug; @@ -44,8 +41,46 @@ export interface ReferralProgramEditionConfig { * A map from edition slug to edition configuration. * * Used to store and look up all configured referral program editions. + * + * @invariant For each key-value pair in the map, the key must equal the value's slug property. + * That is, for all entries: `map.get(key)?.slug === key` */ export type ReferralProgramEditionConfigSet = Map< ReferralProgramEditionSlug, ReferralProgramEditionConfig >; + +/** + * Validates that a ReferralProgramEditionConfigSet maintains the invariant + * that each map key equals the corresponding config's slug. + * + * @param configSet - The edition config set to validate + * @throws {Error} If any entry violates the invariant (key !== value.slug) + */ +export function validateReferralProgramEditionConfigSet( + configSet: ReferralProgramEditionConfigSet, +): void { + const violation = Array.from(configSet.entries()).find(([key, config]) => key !== config.slug); + + if (violation) { + const [key, config] = violation; + throw new Error( + `Edition config set invariant violation: map key "${key}" does not match config.slug "${config.slug}"`, + ); + } +} + +/** + * Builds a new ReferralProgramEditionConfigSet from an array of configs and validates the invariant. + * + * @param configs - Array of edition configurations to add to the set + * @returns A validated edition config set + * @throws {Error} If any config would violate the invariant + */ +export function buildReferralProgramEditionConfigSet( + configs: ReferralProgramEditionConfig[], +): ReferralProgramEditionConfigSet { + const configSet = new Map(configs.map((config) => [config.slug, config])); + validateReferralProgramEditionConfigSet(configSet); + return configSet; +} From a1522a21fc9e0bf162c56f2d7f33720a6dae54d5 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 9 Feb 2026 18:14:47 +0100 Subject: [PATCH 16/16] additional validation --- packages/ens-referrals/src/v1/edition.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ens-referrals/src/v1/edition.ts b/packages/ens-referrals/src/v1/edition.ts index 62afb97f1..b33be2455 100644 --- a/packages/ens-referrals/src/v1/edition.ts +++ b/packages/ens-referrals/src/v1/edition.ts @@ -75,11 +75,25 @@ export function validateReferralProgramEditionConfigSet( * * @param configs - Array of edition configurations to add to the set * @returns A validated edition config set - * @throws {Error} If any config would violate the invariant + * @throws {Error} If duplicate slugs are detected or if any config would violate the invariant */ export function buildReferralProgramEditionConfigSet( configs: ReferralProgramEditionConfig[], ): ReferralProgramEditionConfigSet { + // Check for duplicate slugs before creating the Map + const slugCounts = configs.reduce((counts, config) => { + counts.set(config.slug, (counts.get(config.slug) || 0) + 1); + return counts; + }, new Map()); + + const duplicates = Array.from(slugCounts.entries()) + .filter(([_, count]) => count > 1) + .map(([slug, count]) => `"${slug}" (${count} occurrences)`); + + if (duplicates.length > 0) { + throw new Error(`Duplicate edition config slugs detected: ${duplicates.join(", ")}`); + } + const configSet = new Map(configs.map((config) => [config.slug, config])); validateReferralProgramEditionConfigSet(configSet); return configSet;