diff --git a/.changeset/clever-laws-count.md b/.changeset/clever-laws-count.md new file mode 100644 index 000000000..4fe9f7237 --- /dev/null +++ b/.changeset/clever-laws-count.md @@ -0,0 +1,6 @@ +--- +"@namehash/ens-referrals": minor +"ensapi": minor +--- + +Introduces referral program editions support with pre-configured edition definitions (ENS Holiday Awards December 2025, March 2026 edition). Updated ENSAnalytics API v1 to support edition-based leaderboard queries and added edition configuration to environment schema. 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 f5a2d230c..6de2ee84b 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 Edition Config Set Definition (optional) +# URL that returns JSON for a custom referral program edition config set. +# If not set, the default edition config set for the namespace is used. +# +# JSON Structure: +# The JSON must be an array of edition config objects (SerializedReferralProgramEditionConfig[]). +# For the complete schema definition, see makeReferralProgramEditionConfigSetArraySchema in @namehash/ens-referrals/v1 +# +# Fetching Behavior: +# - Fetched proactively when ENSApi starts up (non-blocking; ENSApi may begin accepting requests before load completes) +# - Once successfully loaded, cached indefinitely (never expires or revalidates) +# - On load failure: +# * Error is logged +# * ENSApi continues running +# * Failed state is cached for 1 minute, then retried on subsequent requests +# * API requests receive error responses until successful load +# +# Configuration Notes: +# - Setting CUSTOM_REFERRAL_PROGRAM_EDITIONS completely replaces the default edition config set +# - Include all edition configs you want active in the JSON +# - Array must contain at least one edition config +# - All edition slugs must be unique +# +# CUSTOM_REFERRAL_PROGRAM_EDITIONS=https://example.com/custom-editions.json 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-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts new file mode 100644 index 000000000..29a0d1ae7 --- /dev/null +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -0,0 +1,66 @@ +import config from "@/config"; + +import { + ENSReferralsClient, + getDefaultReferralProgramEditionConfigSet, + type ReferralProgramEditionConfigSet, +} from "@namehash/ens-referrals/v1"; +import { minutesToSeconds } from "date-fns"; + +import { SWRCache } from "@ensnode/ensnode-sdk"; + +import { makeLogger } from "@/lib/logger"; + +const logger = makeLogger("referral-program-edition-set-cache"); + +/** + * Loads the referral program edition config set from custom URL or defaults. + */ +async function loadReferralProgramEditionConfigSet(): Promise { + // Check if custom URL is configured + if (config.customReferralProgramEditionConfigSetUrl) { + logger.info( + `Loading custom referral program edition config set from: ${config.customReferralProgramEditionConfigSetUrl.href}`, + ); + try { + const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet( + config.customReferralProgramEditionConfigSetUrl, + ); + logger.info(`Successfully loaded ${editionConfigSet.size} custom referral program editions`); + return editionConfigSet; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(error, "Error occurred while loading referral program edition config set"); + throw new Error( + `Failed to load custom referral program edition config set from ${config.customReferralProgramEditionConfigSetUrl.href}: ${errorMessage}`, + ); + } + } + + // Use default edition config set for the namespace + logger.info( + `Loading default referral program edition config set for namespace: ${config.namespace}`, + ); + const editionConfigSet = getDefaultReferralProgramEditionConfigSet(config.namespace); + logger.info(`Successfully loaded ${editionConfigSet.size} default referral program editions`); + return editionConfigSet; +} + +/** + * SWR Cache for the referral program edition config set. + * + * Once successfully loaded, the edition config set is cached indefinitely and never revalidated. + * This ensures the JSON is only fetched once during the application lifecycle. + * + * Configuration: + * - ttl: Infinity - Never expires once cached + * - proactiveRevalidationInterval: undefined - No proactive revalidation + * - proactivelyInitialize: true - Load immediately on startup + */ +export const referralProgramEditionConfigSetCache = new SWRCache({ + fn: loadReferralProgramEditionConfigSet, + ttl: Number.POSITIVE_INFINITY, + errorTtl: minutesToSeconds(1), + proactiveRevalidationInterval: undefined, + proactivelyInitialize: true, +}); 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..f0905c285 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -1,9 +1,5 @@ import packageJson from "@/../package.json" with { type: "json" }; -import { - ENS_HOLIDAY_AWARDS_END_DATE, - ENS_HOLIDAY_AWARDS_START_DATE, -} from "@namehash/ens-referrals"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -21,6 +17,7 @@ import logger from "@/lib/logger"; vi.mock("@/lib/logger", () => ({ default: { error: vi.fn(), + info: vi.fn(), }, })); @@ -82,11 +79,26 @@ describe("buildConfigFromEnvironment", () => { } satisfies RpcConfig, ], ]), - ensHolidayAwardsStart: ENS_HOLIDAY_AWARDS_START_DATE, - ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, + customReferralProgramEditionConfigSetUrl: undefined, }); }); + 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); @@ -104,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, @@ -164,8 +193,7 @@ describe("buildEnsApiPublicConfig", () => { } satisfies RpcConfig, ], ]), - ensHolidayAwardsStart: ENS_HOLIDAY_AWARDS_START_DATE, - ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, + customReferralProgramEditionConfigSetUrl: undefined, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -189,8 +217,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, + customReferralProgramEditionConfigSetUrl: undefined, }; const result = buildEnsApiPublicConfig(mockConfig); @@ -224,8 +251,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, + 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 acc80a261..af74bd41c 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,10 +1,5 @@ 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"; import pRetry from "p-retry"; import { parse as parseConnectionString } from "pg-connection-string"; import { prettifyError, ZodError, z } from "zod/v4"; @@ -17,7 +12,6 @@ import { ENSNamespaceSchema, EnsIndexerUrlSchema, invariant_rpcConfigsSpecifiedForRootChain, - makeDatetimeSchema, makeENSIndexerPublicConfigSchema, PortSchema, RpcConfigsSchema, @@ -26,10 +20,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,11 +42,23 @@ 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 +/** + * Schema for validating custom referral program edition config set URL. + */ +const CustomReferralProgramEditionConfigSetUrlSchema = z .string() - .pipe(makeDatetimeSchema()) - .transform((date) => getUnixTime(date)); + .transform((val, ctx) => { + try { + return new URL(val); + } catch { + ctx.addIssue({ + code: "custom", + message: `CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL: ${val}`, + }); + return z.NEVER; + } + }) + .optional(); const EnsApiConfigSchema = z .object({ @@ -67,12 +70,10 @@ 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), + customReferralProgramEditionConfigSetUrl: CustomReferralProgramEditionConfigSetUrlSchema, }) .check(invariant_rpcConfigsSpecifiedForRootChain) - .check(invariant_ensIndexerPublicConfigVersionInfo) - .check(invariant_ensHolidayAwardsEndAfterStart); + .check(invariant_ensIndexerPublicConfigVersionInfo); export type EnsApiConfig = z.infer; @@ -106,8 +107,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, + 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 cb968656c..119490fdf 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, + ReferralProgramEditionsEnvironment, RpcEnvironment, TheGraphEnvironment, } from "@ensnode/ensnode-sdk/internal"; @@ -21,4 +21,4 @@ export type EnsApiEnvironment = Omit & PortEnvironment & LogLevelEnvironment & TheGraphEnvironment & - EnsHolidayAwardsEnvironment; + ReferralProgramEditionsEnvironment; 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..aa72ef716 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -5,7 +5,8 @@ import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; -import * as middleware from "../middleware/referrer-leaderboard.middleware-v1"; +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() { @@ -18,20 +19,31 @@ vi.mock("@/config", () => ({ }, })); -vi.mock("../middleware/referrer-leaderboard.middleware-v1", () => ({ - referrerLeaderboardMiddlewareV1: vi.fn(), +vi.mock("../middleware/referral-program-edition-set.middleware", () => ({ + referralProgramEditionConfigSetMiddleware: vi.fn(), +})); + +vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => ({ + referralLeaderboardEditionsCachesMiddleware: vi.fn(), })); import { - deserializeReferrerDetailResponse, + buildReferralProgramRules, + deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, - ReferrerDetailResponseCodes, - type ReferrerDetailResponseOk, - ReferrerDetailTypeIds, + deserializeReferrerMetricsEditionsResponse, + ReferralProgramEditionConfigSetResponseCodes, + type ReferralProgramEditionSlug, + ReferrerEditionMetricsTypeIds, + type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, + ReferrerMetricsEditionsResponseCodes, + type ReferrerMetricsEditionsResponseOk, } from "@namehash/ens-referrals/v1"; +import { parseTimestamp, parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; + import { emptyReferralLeaderboard, populatedReferrerLeaderboard, @@ -40,12 +52,37 @@ import { import app from "./ensanalytics-api-v1"; -describe("/ensanalytics/v1", () => { - describe("/referrers", () => { +describe("/v1/ensanalytics", () => { + describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { - // Arrange: set `referrerLeaderboardV1` context var - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", populatedReferrerLeaderboard); + // Arrange: mock cache map with 2025-12 + const mockEditionsCaches = new Map>( + [ + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -56,22 +93,23 @@ describe("/ensanalytics/v1", () => { // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; + const edition = "2025-12"; // 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: { edition, 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: { edition, 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: { edition, recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); @@ -138,19 +176,45 @@ describe("/ensanalytics/v1", () => { }); it("returns empty cached referrer leaderboard when there are no referrals yet", async () => { - // Arrange: set `referrerLeaderboardV1` context var - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", emptyReferralLeaderboard); + // Arrange: mock cache map with 2025-12 + const mockEditionsCaches = new Map>( + [ + [ + "2025-12", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); // Arrange: create the test client from the app instance const client = testClient(app); const recordsPerPage = 10; + const edition = "2025-12"; // 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: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) .then((r) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); @@ -173,13 +237,108 @@ describe("/ensanalytics/v1", () => { expect(response).toMatchObject(expectedResponse); }); + + 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-edition-a", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "test-edition-b", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ], + ); + + // Mock edition set middleware to provide a mock edition set + const mockEditionConfigSet = new Map([ + ["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) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + return await next(); + }); + + // Arrange: create the test client from the app instance + const client = testClient(app); + const recordsPerPage = 10; + const invalidEdition = "invalid-edition"; + + // Act: send test request with invalid edition slug + const httpResponse = await client["referral-leaderboard"].$get( + { 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 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 edition: invalid-edition. Valid editions: test-edition-a, test-edition-b", + ); + } + }); }); - describe("/referrers/:referrer", () => { - it("returns referrer metrics when referrer exists in leaderboard", async () => { - // Arrange: set `referrerLeaderboard` context var with populated leaderboard - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", populatedReferrerLeaderboard); + describe("/referrer/:referrer", () => { + 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, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -188,30 +347,73 @@ 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 requested editions + const httpResponse = await app.request( + `/referrer/${existingReferrer}?editions=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains the expected referrer metrics + // Assert: response contains the expected referrer metrics for requested editions const expectedResponse = { - responseCode: ReferrerDetailResponseCodes.Ok, + responseCode: ReferrerMetricsEditionsResponseCodes.Ok, data: { - type: ReferrerDetailTypeIds.Ranked, - rules: populatedReferrerLeaderboard.rules, - referrer: expectedMetrics, - aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, - accurateAsOf: expectedAccurateAsOf, + "2025-12": { + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: populatedReferrerLeaderboard.rules, + referrer: expectedMetrics, + aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, + accurateAsOf: expectedAccurateAsOf, + }, + "2026-03": { + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: populatedReferrerLeaderboard.rules, + referrer: expectedMetrics, + aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, + accurateAsOf: expectedAccurateAsOf, + }, }, - } satisfies ReferrerDetailResponseOk; + } satisfies ReferrerMetricsEditionsResponseOk; 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 - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", populatedReferrerLeaderboard); + 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>( + [ + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -219,42 +421,82 @@ 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( + `/referrer/${nonExistingReferrer}?editions=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(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 requested editions 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( - 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.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(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(response.data.accurateAsOf).toBe(expectedAccurateAsOf); + expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); + + // Check 2026-03 + expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); + expect(edition2.referrer.referrer).toBe(nonExistingReferrer); + expect(edition2.referrer.rank).toBe(null); } }); - it("returns zero-score metrics when leaderboard is empty", async () => { - // Arrange: set `referrerLeaderboardV1` context var with empty leaderboard - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", emptyReferralLeaderboard); + 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, + ], + [ + "2026-03", + { + read: async () => emptyReferralLeaderboard, + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -262,40 +504,139 @@ 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(`/referrer/${referrer}?editions=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(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 requested editions 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(ReferrerMetricsEditionsResponseCodes.Ok); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { + const edition1 = response.data["2025-12"]!; + const edition2 = response.data["2026-03"]!; + + // Check 2025-12 + 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(response.data.accurateAsOf).toBe(expectedAccurateAsOf); + expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); + + // Check 2026-03 + expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); + expect(edition2.referrer.referrer).toBe(referrer); + expect(edition2.referrer.rank).toBe(null); } }); - it("returns error response when leaderboard fails to load", async () => { - // Arrange: set `referrerLeaderboard` context var with rejected promise - vi.mocked(middleware.referrerLeaderboardMiddlewareV1).mockImplementation(async (c, next) => { - c.set("referrerLeaderboardV1", new Error("Database connection failed")); + 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 mockEditionsCaches = new Map>( + [ + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + 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 editions + const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); + const responseData = await httpResponse.json(); + const response = deserializeReferrerMetricsEditionsResponse(responseData); + + // Assert: response contains error mentioning the specific edition that failed + expect(httpResponse.status).toBe(503); + 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 edition(s): 2026-03", + ); + } + }); + + 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, + ], + [ + "2026-03", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); @@ -303,18 +644,272 @@ 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(`/referrer/${referrer}?editions=2025-12,2026-03`); const responseData = await httpResponse.json(); - const response = deserializeReferrerDetailResponse(responseData); + const response = deserializeReferrerMetricsEditionsResponse(responseData); - // Assert: response contains error - expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Error); - if (response.responseCode === ReferrerDetailResponseCodes.Error) { + // Assert: response contains error for all failed editions + expect(httpResponse.status).toBe(503); + 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 has not been successfully cached yet.", + "Referrer leaderboard data not cached for edition(s): 2025-12, 2026-03", ); } }); + + it("returns 404 error when unknown edition slug is requested", async () => { + // Arrange: mock cache map with configured editions + const mockEditionsCaches = new Map>( + [ + [ + "2025-12", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + 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 edition + const httpResponse = await app.request( + `/referrer/${referrer}?editions=2025-12,invalid-edition`, + ); + const responseData = await httpResponse.json(); + const response = deserializeReferrerMetricsEditionsResponse(responseData); + + // Assert: response is 404 error with list of valid editions + expect(httpResponse.status).toBe(404); + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Error); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { + expect(response.error).toBe("Not Found"); + expect(response.errorMessage).toContain("invalid-edition"); + expect(response.errorMessage).toBe( + "Unknown edition(s): invalid-edition. Valid editions: 2025-12, 2026-03", + ); + } + }); + + 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, + ], + [ + "2026-03", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + [ + "2026-06", + { + read: async () => populatedReferrerLeaderboard, + } as SWRCache, + ], + ], + ); + + // 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(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // Mock caches middleware to provide the mock caches + vi.mocked( + editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + ).mockImplementation(async (c, next) => { + 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 editions + const httpResponse = await app.request( + `/referrer/${existingReferrer}?editions=2025-12,2026-06`, + ); + const responseData = await httpResponse.json(); + const response = deserializeReferrerMetricsEditionsResponse(responseData); + + // 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(); + } + }); + }); + + 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", + { + slug: "2025-12", + displayName: "December 2025", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 100, + parseTimestamp("2025-12-01T00:00:00Z"), + parseTimestamp("2025-12-31T23:59:59Z"), + { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + new URL("https://example.com/rules"), + ), + }, + ], + [ + "2026-03", + { + slug: "2026-03", + displayName: "March 2026", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 100, + parseTimestamp("2026-03-01T00:00:00Z"), + parseTimestamp("2026-03-31T23:59:59Z"), + { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + new URL("https://example.com/rules"), + ), + }, + ], + [ + "2026-06", + { + slug: "2026-06", + displayName: "June 2026", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 100, + parseTimestamp("2026-06-01T00:00:00Z"), + parseTimestamp("2026-06-30T23:59:59Z"), + { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + new URL("https://example.com/rules"), + ), + }, + ], + ]); + + // Mock edition set middleware + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + + // 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(); + const response = deserializeReferralProgramEditionConfigSetResponse(responseData); + + // Assert: response contains all editions sorted by start timestamp descending + expect(httpResponse.status).toBe(200); + expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Ok); + + if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) { + expect(response.data.editions).toHaveLength(3); + + // Verify sorting: most recent start time first + 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 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("referralProgramEditionConfigSet", loadError); + return await next(); + }, + ); + + // 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(); + const response = deserializeReferralProgramEditionConfigSetResponse(responseData); + + // Assert: response is error + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.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 2b9efaec3..5806befb0 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -1,15 +1,26 @@ import { - getReferrerDetail, + getReferrerEditionMetrics, getReferrerLeaderboardPage, + MAX_EDITIONS_PER_REQUEST, REFERRERS_PER_LEADERBOARD_PAGE_MAX, - type ReferrerDetailResponse, - ReferrerDetailResponseCodes, + type ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionConfigSetResponseCodes, + type ReferralProgramEditionSlug, + type ReferrerLeaderboard, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, - serializeReferrerDetailResponse, + type ReferrerMetricsEditionsData, + type ReferrerMetricsEditionsResponse, + ReferrerMetricsEditionsResponseCodes, + serializeReferralProgramEditionConfigSetResponse, serializeReferrerLeaderboardPageResponse, + serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals/v1"; +import { + makeReferralProgramEditionSlugSchema, + makeReferrerMetricsEditionsArraySchema, +} from "@namehash/ens-referrals/v1/internal"; import { describeRoute } from "hono-openapi"; import { z } from "zod/v4"; @@ -18,12 +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 { referrerLeaderboardMiddlewareV1 } from "@/middleware/referrer-leaderboard.middleware-v1"; +import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; const logger = makeLogger("ensanalytics-api-v1"); -// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest) -const paginationQuerySchema = z.object({ +/** + * Query parameters schema for referrer leaderboard page requests. + * Validates edition slug, page number, and records per page. + */ +const referrerLeaderboardPageQuerySchema = z.object({ + edition: makeReferralProgramEditionSlugSchema("edition"), page: z .optional(z.coerce.number().int().min(1, "Page must be a positive integer")) .describe("Page number for pagination"), @@ -44,49 +60,93 @@ const paginationQuerySchema = z.object({ const app = factory .createApp() - // Apply referrer leaderboard cache middleware to all routes in this handler - .use(referrerLeaderboardMiddlewareV1) + // Apply referral program edition config set middleware + .use(referralProgramEditionConfigSetMiddleware) + + // Apply referrer leaderboard cache middleware (depends on edition config set middleware) + .use(referralLeaderboardEditionsCachesMiddleware) - // Get a page from the referrer leaderboard + // Get a page from the referrer leaderboard for a specific edition .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 edition", responses: { 200: { description: "Successfully retrieved referrer leaderboard page", }, + 404: { + description: "Unknown edition slug", + }, 500: { description: "Internal server error", }, + 503: { + description: "Service unavailable", + }, }, }), - validate("query", paginationQuerySchema), + validate("query", referrerLeaderboardPageQuerySchema), async (c) => { // context must be set by the required middleware - if (c.var.referrerLeaderboardV1 === undefined) { - throw new Error(`Invariant(ensanalytics-api-v1): referrerLeaderboardMiddlewareV1 required`); + if (c.var.referralLeaderboardEditionsCaches === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, + ); } try { - if (c.var.referrerLeaderboardV1 instanceof Error) { + const { edition, page, recordsPerPage } = c.req.valid("query"); + + // Check if edition set failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program edition set failed to load", + ); return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Internal Server Error", - errorMessage: "Failed to load referrer leaderboard data.", + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", } satisfies ReferrerLeaderboardPageResponse), - 500, + 503, ); } - const { page, recordsPerPage } = c.req.valid("query"); - const leaderboardPage = getReferrerLeaderboardPage( - { page, recordsPerPage }, - c.var.referrerLeaderboardV1, - ); + // Get the specific edition's cache + const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition); + + if (!editionCache) { + const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys()); + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Not Found", + errorMessage: `Unknown edition: ${edition}. Valid editions: ${configuredEditions.join(", ")}`, + } satisfies ReferrerLeaderboardPageResponse), + 404, + ); + } + + // Read from the edition's cache + const leaderboard = await editionCache.read(); + + // 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 edition ${edition}.`, + } satisfies ReferrerLeaderboardPageResponse), + 503, + ); + } + + const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard); return c.json( serializeReferrerLeaderboardPageResponse({ @@ -95,7 +155,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,70 +177,236 @@ const referrerAddressSchema = z.object({ referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), }); -// Get referrer detail for a specific address -app.get( - "/referrers/:referrer", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Referrer Detail (v1)", - description: "Returns detailed information for a specific referrer by address", - responses: { - 200: { - description: "Successfully retrieved referrer detail", - }, - 500: { - description: "Internal server error", - }, - 503: { - description: "Service unavailable - referrer leaderboard data not yet cached", +// Editions query parameter schema +const editionsQuerySchema = z.object({ + editions: z + .string() + .describe("Comma-separated list of edition slugs") + .transform((value) => value.split(",").map((s) => s.trim())) + .pipe(makeReferrerMetricsEditionsArraySchema("editions")), +}); + +// Get referrer detail for a specific address for requested editions +app + .get( + "/referrer/:referrer", + describeRoute({ + tags: ["ENSAwards"], + 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 editions", + }, + 400: { + description: "Invalid request", + }, + 404: { + description: "Unknown edition 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.referrerLeaderboardV1 === undefined) { - throw new Error(`Invariant(ensanalytics-api-v1): referrerLeaderboardMiddlewareV1 required`); - } - - try { - // Check if leaderboard failed to load - if (c.var.referrerLeaderboardV1 instanceof Error) { + }), + validate("param", referrerAddressSchema), + validate("query", editionsQuerySchema), + async (c) => { + // context must be set by the required middleware + if (c.var.referralLeaderboardEditionsCaches === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, + ); + } + + try { + const { referrer } = c.req.valid("param"); + const { editions } = c.req.valid("query"); + + // Check if edition set failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program edition set failed to load", + ); + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferrerMetricsEditionsResponse), + 503, + ); + } + + // Type narrowing: at this point we know it's not an Error + const editionsCaches = c.var.referralLeaderboardEditionsCaches; + + // 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 (unrecognizedEditions.length > 0) { + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Not Found", + errorMessage: `Unknown edition(s): ${unrecognizedEditions.join(", ")}. Valid editions: ${configuredEditions.join(", ")}`, + } satisfies ReferrerMetricsEditionsResponse), + 404, + ); + } + + // 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 editionCache.read(); + return { editionSlug, leaderboard }; + }), + ); + + // Validate that all requested editions have cached data (no errors) + const uncachedEditions = editionLeaderboards + .filter(({ leaderboard }) => leaderboard instanceof Error) + .map(({ editionSlug }) => editionSlug); + + if (uncachedEditions.length > 0) { + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Service Unavailable", + 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 validEditionLeaderboards = editionLeaderboards.filter( + ( + item, + ): item is { + editionSlug: ReferralProgramEditionSlug; + leaderboard: ReferrerLeaderboard; + } => !(item.leaderboard instanceof Error), + ); + + // Build response data for the requested editions + const editionsData = Object.fromEntries( + validEditionLeaderboards.map(({ editionSlug, leaderboard }) => [ + editionSlug, + getReferrerEditionMetrics(referrer, leaderboard), + ]), + ) as ReferrerMetricsEditionsData; + return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Error, - error: "Service Unavailable", - errorMessage: "Referrer leaderboard data has not been successfully cached yet.", - } satisfies ReferrerDetailResponse), - 503, + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Ok, + data: editionsData, + } satisfies ReferrerMetricsEditionsResponse), + ); + } 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( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferrerMetricsEditionsResponse), + 500, ); } + }, + ) - const { referrer } = c.req.valid("param"); - const detail = getReferrerDetail(referrer, c.var.referrerLeaderboardV1); - - return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Ok, - data: detail, - } satisfies ReferrerDetailResponse), - ); - } catch (error) { - logger.error({ error }, "Error in /ensanalytics/v1/referrers/:referrer endpoint"); - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred while processing your request"; - return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Error, - error: "Internal server error", - errorMessage, - } satisfies ReferrerDetailResponse), - 500, - ); - } - }, -); + // Get configured edition config set + .get( + "/editions", + describeRoute({ + tags: ["ENSAwards"], + summary: "Get Edition Config Set (v1)", + description: + "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 edition config set", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable", + }, + }, + }), + async (c) => { + // context must be set by the required middleware + if (c.var.referralProgramEditionConfigSet === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`, + ); + } + + try { + // Check if edition config set failed to load + if (c.var.referralProgramEditionConfigSet instanceof Error) { + logger.error( + { error: c.var.referralProgramEditionConfigSet }, + "Referral program edition config set failed to load", + ); + return c.json( + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferralProgramEditionConfigSetResponse), + 503, + ); + } + + // Convert Map to array and sort by start timestamp descending + const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort( + (a, b) => b.rules.startTime - a.rules.startTime, + ); + + return c.json( + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok, + data: { + editions, + }, + } satisfies ReferralProgramEditionConfigSetResponse), + ); + } catch (error) { + 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( + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferralProgramEditionConfigSetResponse), + 500, + ); + } + }, + ); export default app; diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 1389089ec..9483c4f6a 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -8,8 +8,9 @@ import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; import { indexingStatusCache } from "@/cache/indexing-status.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 { 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 +162,18 @@ const gracefulShutdown = async () => { referrerLeaderboardCache.destroy(); logger.info("Destroyed referrerLeaderboardCache"); - referrerLeaderboardCacheV1.destroy(); - logger.info("Destroyed referrerLeaderboardCacheV1"); + // Destroy referral program edition config set cache + referralProgramEditionConfigSetCache.destroy(); + logger.info("Destroyed referralProgramEditionConfigSetCache"); + + // Destroy all edition caches (if initialized) + const editionsCaches = getReferralLeaderboardEditionsCaches(); + if (editionsCaches) { + for (const [editionSlug, cache] of editionsCaches) { + cache.destroy(); + logger.info(`Destroyed referralLeaderboardEditionsCache for ${editionSlug}`); + } + } 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..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,12 +1,8 @@ -import { - buildReferralProgramRules, - ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, - ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, - type ReferrerLeaderboard, -} from "@namehash/ens-referrals/v1"; -import { getUnixTime } from "date-fns"; +import { buildReferralProgramRules, type ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; import { describe, expect, it, vi } from "vitest"; +import { parseTimestamp, parseUsdc } from "@ensnode/ensnode-sdk"; + import * as database from "./database-v1"; import { getReferrerLeaderboard } from "./get-referrer-leaderboard-v1"; import { dbResultsReferrerLeaderboard } from "./mocks-v1"; @@ -17,17 +13,18 @@ vi.mock("./database-v1", () => ({ })); const rules = buildReferralProgramRules( - ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, - ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, - getUnixTime("2025-01-01T00:00:00Z"), - getUnixTime("2025-12-31T23:59:59Z"), + parseUsdc("10000"), + 10, // maxQualifiedReferrers + parseTimestamp("2025-01-01T00:00:00Z"), + parseTimestamp("2025-12-31T23:59:59Z"), { chainId: 1, address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, + 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/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 0d3be68c0..2a4f47914 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -3,14 +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 { 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"; -import type { ReferrerLeaderboardMiddlewareV1Variables } from "@/middleware/referrer-leaderboard.middleware-v1"; export type MiddlewareVariables = IndexingStatusMiddlewareVariables & IsRealtimeMiddlewareVariables & CanAccelerateMiddlewareVariables & ReferrerLeaderboardMiddlewareVariables & - ReferrerLeaderboardMiddlewareV1Variables; + ReferralProgramEditionConfigSetMiddlewareVariables & + ReferralLeaderboardEditionsCachesMiddlewareVariables; export const factory = createFactory<{ Variables: Partial; 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..1546b76e0 --- /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 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. + */ +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-edition-set.middleware.ts b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts new file mode 100644 index 000000000..58410d476 --- /dev/null +++ b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts @@ -0,0 +1,35 @@ +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, 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) => { + const editionConfigSet = await referralProgramEditionConfigSetCache.read(); + c.set("referralProgramEditionConfigSet", editionConfigSet); + await next(); + }, +); diff --git a/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts b/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts deleted file mode 100644 index 468a549fb..000000000 --- a/apps/ensapi/src/middleware/referrer-leaderboard.middleware-v1.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; - -import { referrerLeaderboardCacheV1 } from "@/cache/referrer-leaderboard.cache-v1"; -import { factory } from "@/lib/hono-factory"; - -/** - * Type definition for the referrer leaderboard middleware context passed to downstream middleware and handlers (V1 API). - */ -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. - * - * 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. - * - * If `referrerLeaderboardV1` is a {@link ReferrerLeaderboard}, a referrer leaderboard was successfully - * fetched (and cached) at least once within the lifetime of this middleware. - */ - referrerLeaderboardV1: ReferrerLeaderboard | Error; -}; - -/** - * Middleware that provides {@link 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); - await next(); -}); diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index dd55dd6d5..ce4096a99 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -1,179 +1,74 @@ import { prettifyError } from "zod/v4"; -import { deserializePriceEth, deserializePriceUsdc, type PriceEth } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "../aggregations"; -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 { ReferralProgramEditionConfig } from "../edition"; import type { - SerializedAggregatedReferrerMetrics, - SerializedAwardedReferrerMetrics, - SerializedReferralProgramRules, - SerializedReferrerDetailRanked, - SerializedReferrerDetailResponse, - SerializedReferrerDetailUnranked, - SerializedReferrerLeaderboardPage, + SerializedReferralProgramEditionConfigSetResponse, SerializedReferrerLeaderboardPageResponse, - SerializedUnrankedReferrerMetrics, + SerializedReferrerMetricsEditionsResponse, } from "./serialized-types"; -import type { ReferrerDetailResponse, ReferrerLeaderboardPageResponse } from "./types"; +import type { + ReferralProgramEditionConfigSetResponse, + ReferrerLeaderboardPageResponse, + ReferrerMetricsEditionsResponse, +} from "./types"; import { - makeReferrerDetailResponseSchema, + makeReferralProgramEditionConfigSetArraySchema, + makeReferralProgramEditionConfigSetResponseSchema, makeReferrerLeaderboardPageResponseSchema, + makeReferrerMetricsEditionsResponseSchema, } from "./zod-schemas"; /** - * Deserializes a {@link SerializedReferralProgramRules} object. - */ -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. + * Deserialize a {@link ReferrerLeaderboardPageResponse} 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), - }; -} +export function deserializeReferrerLeaderboardPageResponse( + maybeResponse: SerializedReferrerLeaderboardPageResponse, + valueLabel?: string, +): ReferrerLeaderboardPageResponse { + const schema = makeReferrerLeaderboardPageResponseSchema(valueLabel); + const parsed = schema.safeParse(maybeResponse); -/** - * 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), - }; -} + if (parsed.error) { + throw new Error( + `Cannot deserialize SerializedReferrerLeaderboardPageResponse:\n${prettifyError(parsed.error)}\n`, + ); + } -/** - * Deserializes an {@link SerializedAggregatedReferrerMetrics} object. - */ -function deserializeAggregatedReferrerMetrics( - metrics: SerializedAggregatedReferrerMetrics, -): AggregatedReferrerMetrics { - return { - grandTotalReferrals: metrics.grandTotalReferrals, - grandTotalIncrementalDuration: metrics.grandTotalIncrementalDuration, - grandTotalRevenueContribution: deserializePriceEth(metrics.grandTotalRevenueContribution), - grandTotalQualifiedReferrersFinalScore: metrics.grandTotalQualifiedReferrersFinalScore, - minFinalScoreToQualify: metrics.minFinalScoreToQualify, - }; + return parsed.data; } /** - * Deserializes a {@link SerializedReferrerLeaderboardPage} object. + * Deserialize a {@link ReferrerMetricsEditionsResponse} 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, - }; -} +export function deserializeReferrerMetricsEditionsResponse( + maybeResponse: SerializedReferrerMetricsEditionsResponse, + valueLabel?: string, +): ReferrerMetricsEditionsResponse { + const schema = makeReferrerMetricsEditionsResponseSchema(valueLabel); + const parsed = schema.safeParse(maybeResponse); -/** - * 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, - }; -} + if (parsed.error) { + throw new Error( + `Cannot deserialize ReferrerMetricsEditionsResponse:\n${prettifyError(parsed.error)}\n`, + ); + } -/** - * Deserializes a {@link SerializedReferrerDetailUnranked} 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, - }; + 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 ReferralProgramEditionConfig} objects. */ -export function deserializeReferrerLeaderboardPageResponse( - maybeResponse: SerializedReferrerLeaderboardPageResponse, +export function deserializeReferralProgramEditionConfigSetArray( + 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); +): ReferralProgramEditionConfig[] { + const schema = makeReferralProgramEditionConfigSetArraySchema(valueLabel); + const parsed = schema.safeParse(maybeArray); if (parsed.error) { throw new Error( - `Cannot deserialize SerializedReferrerLeaderboardPageResponse:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferralProgramEditionConfigSetArray:\n${prettifyError(parsed.error)}\n`, ); } @@ -181,48 +76,19 @@ export function deserializeReferrerLeaderboardPageResponse( } /** - * Deserialize a {@link ReferrerDetailResponse} 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 ReferralProgramEditionConfigSetResponse} object. */ -export function deserializeReferrerDetailResponse( - maybeResponse: SerializedReferrerDetailResponse, +export function deserializeReferralProgramEditionConfigSetResponse( + maybeResponse: SerializedReferralProgramEditionConfigSetResponse, valueLabel?: string, -): ReferrerDetailResponse { - let deserialized: ReferrerDetailResponse; - 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; - } - break; - } - - case "error": - deserialized = maybeResponse; - break; - } - - // Then validate the deserialized structure using zod schemas - const schema = makeReferrerDetailResponseSchema(valueLabel); - const parsed = schema.safeParse(deserialized); +): ReferralProgramEditionConfigSetResponse { + const schema = makeReferralProgramEditionConfigSetResponseSchema(valueLabel); + const parsed = schema.safeParse(maybeResponse); if (parsed.error) { - throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`); + throw new Error( + `Cannot deserialize ReferralProgramEditionConfigSetResponse:\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..371c63ba1 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,26 +1,37 @@ import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; +import type { ReferralProgramEditionConfig } from "../edition"; +import type { + ReferrerEditionMetrics, + 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 { SerializedAggregatedReferrerMetrics, SerializedAwardedReferrerMetrics, + SerializedReferralProgramEditionConfig, + SerializedReferralProgramEditionConfigSetResponse, SerializedReferralProgramRules, - SerializedReferrerDetailRanked, - SerializedReferrerDetailResponse, - SerializedReferrerDetailUnranked, + SerializedReferrerEditionMetrics, + SerializedReferrerEditionMetricsRanked, + SerializedReferrerEditionMetricsUnranked, SerializedReferrerLeaderboardPage, SerializedReferrerLeaderboardPageResponse, + SerializedReferrerMetricsEditionsData, + SerializedReferrerMetricsEditionsResponse, SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import { - type ReferrerDetailResponse, - ReferrerDetailResponseCodes, + type ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionConfigSetResponseCodes, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, + type ReferrerMetricsEditionsResponse, + ReferrerMetricsEditionsResponseCodes, } from "./types"; /** @@ -35,6 +46,7 @@ export function serializeReferralProgramRules( startTime: rules.startTime, endTime: rules.endTime, subregistryId: rules.subregistryId, + rulesUrl: rules.rulesUrl.toString(), }; } @@ -111,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), @@ -126,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), @@ -140,6 +152,37 @@ function serializeReferrerDetailUnranked( }; } +/** + * Serializes a {@link ReferrerEditionMetrics} object (ranked or unranked). + */ +function serializeReferrerEditionMetrics( + detail: ReferrerEditionMetrics, +): SerializedReferrerEditionMetrics { + switch (detail.type) { + case "ranked": + return serializeReferrerEditionMetricsRanked(detail); + case "unranked": + return serializeReferrerEditionMetricsUnranked(detail); + default: { + const _exhaustiveCheck: never = detail; + throw new Error(`Unknown detail type: ${(_exhaustiveCheck as ReferrerEditionMetrics).type}`); + } + } +} + +/** + * Serializes a {@link ReferralProgramEditionConfig} object. + */ +export function serializeReferralProgramEditionConfig( + editionConfig: ReferralProgramEditionConfig, +): SerializedReferralProgramEditionConfig { + return { + slug: editionConfig.slug, + displayName: editionConfig.displayName, + rules: serializeReferralProgramRules(editionConfig.rules), + }; +} + /** * Serialize a {@link ReferrerLeaderboardPageResponse} object. */ @@ -159,29 +202,61 @@ export function serializeReferrerLeaderboardPageResponse( } /** - * Serialize a {@link ReferrerDetailResponse} object. + * Serialize a {@link ReferrerMetricsEditionsResponse} object. + */ +export function serializeReferrerMetricsEditionsResponse( + response: ReferrerMetricsEditionsResponse, +): SerializedReferrerMetricsEditionsResponse { + switch (response.responseCode) { + case ReferrerMetricsEditionsResponseCodes.Ok: { + const serializedData = Object.fromEntries( + Object.entries(response.data).map(([editionSlug, detail]) => [ + editionSlug, + serializeReferrerEditionMetrics(detail as ReferrerEditionMetrics), + ]), + ) as SerializedReferrerMetricsEditionsData; + + return { + responseCode: response.responseCode, + data: serializedData, + }; + } + + case ReferrerMetricsEditionsResponseCodes.Error: + return response; + + default: { + const _exhaustiveCheck: never = response; + throw new Error( + `Unknown response code: ${(_exhaustiveCheck as ReferrerMetricsEditionsResponse).responseCode}`, + ); + } + } +} + +/** + * Serialize a {@link ReferralProgramEditionConfigSetResponse} object. */ -export function serializeReferrerDetailResponse( - response: ReferrerDetailResponse, -): SerializedReferrerDetailResponse { +export function serializeReferralProgramEditionConfigSetResponse( + response: ReferralProgramEditionConfigSetResponse, +): SerializedReferralProgramEditionConfigSetResponse { 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), - }; - } - break; - - case ReferrerDetailResponseCodes.Error: + case ReferralProgramEditionConfigSetResponseCodes.Ok: + return { + responseCode: response.responseCode, + data: { + editions: response.data.editions.map(serializeReferralProgramEditionConfig), + }, + }; + + case ReferralProgramEditionConfigSetResponseCodes.Error: return response; + + default: { + const _exhaustiveCheck: never = response; + throw new Error( + `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 149ede0dd..1c049bde6 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,25 +1,34 @@ import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; import type { AggregatedReferrerMetrics } from "../aggregations"; +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 { - ReferrerDetailResponse, - ReferrerDetailResponseError, - ReferrerDetailResponseOk, + ReferralProgramEditionConfigSetData, + ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionConfigSetResponseError, + ReferralProgramEditionConfigSetResponseOk, ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseError, ReferrerLeaderboardPageResponseOk, + ReferrerMetricsEditionsResponse, + ReferrerMetricsEditionsResponseError, + ReferrerMetricsEditionsResponseOk, } from "./types"; /** * Serialized representation of {@link ReferralProgramRules}. */ export interface SerializedReferralProgramRules - extends Omit { + extends Omit { totalAwardPoolValue: SerializedPriceUsdc; + rulesUrl: string; } /** @@ -59,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}. @@ -108,22 +117,72 @@ export type SerializedReferrerLeaderboardPageResponse = | SerializedReferrerLeaderboardPageResponseError; /** - * Serialized representation of {@link ReferrerDetailResponseError}. + * Serialized representation of {@link ReferralProgramEditionConfig}. + */ +export interface SerializedReferralProgramEditionConfig + extends Omit { + rules: SerializedReferralProgramRules; +} + +/** + * 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 SerializedReferrerMetricsEditionsData = Partial< + Record +>; + +/** + * Serialized representation of {@link ReferrerMetricsEditionsResponseOk}. + */ +export interface SerializedReferrerMetricsEditionsResponseOk + extends Omit { + data: SerializedReferrerMetricsEditionsData; +} + +/** + * Serialized representation of {@link ReferrerMetricsEditionsResponseError}. * * Note: All fields are already serializable, so this type is identical to the source type. */ -export type SerializedReferrerDetailResponseError = ReferrerDetailResponseError; +export type SerializedReferrerMetricsEditionsResponseError = ReferrerMetricsEditionsResponseError; /** - * Serialized representation of {@link ReferrerDetailResponseOk}. + * Serialized representation of {@link ReferrerMetricsEditionsResponse}. */ -export interface SerializedReferrerDetailResponseOk extends Omit { - data: SerializedReferrerDetail; +export type SerializedReferrerMetricsEditionsResponse = + | SerializedReferrerMetricsEditionsResponseOk + | SerializedReferrerMetricsEditionsResponseError; + +/** + * Serialized representation of {@link ReferralProgramEditionConfigSetData}. + */ +export interface SerializedReferralProgramEditionConfigSetData + extends Omit { + editions: SerializedReferralProgramEditionConfig[]; +} + +/** + * Serialized representation of {@link ReferralProgramEditionConfigSetResponseOk}. + */ +export interface SerializedReferralProgramEditionConfigSetResponseOk + extends Omit { + data: SerializedReferralProgramEditionConfigSetData; } /** - * Serialized representation of {@link ReferrerDetailResponse}. + * Serialized representation of {@link ReferralProgramEditionConfigSetResponseError}. + * + * Note: All fields are already serializable, so this type is identical to the source type. + */ +export type SerializedReferralProgramEditionConfigSetResponseError = + ReferralProgramEditionConfigSetResponseError; + +/** + * Serialized representation of {@link ReferralProgramEditionConfigSetResponse}. */ -export type SerializedReferrerDetailResponse = - | SerializedReferrerDetailResponseOk - | SerializedReferrerDetailResponseError; +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 51848e2c2..5f7e83bc0 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 { 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 {} +export interface ReferrerLeaderboardPageRequest extends ReferrerLeaderboardPageParams { + /** The referral program edition slug */ + edition: ReferralProgramEditionSlug; +} /** * A status code for a referrer leaderboard page API response. @@ -57,19 +61,26 @@ export type ReferrerLeaderboardPageResponse = | ReferrerLeaderboardPageResponseError; /** - * Request parameters for referrer detail query. + * Maximum number of editions that can be requested in a single {@link ReferrerMetricsEditionsRequest}. + */ +export const MAX_EDITIONS_PER_REQUEST = 20; + +/** + * Request parameters for referrer metrics query. */ -export interface ReferrerDetailRequest { +export interface ReferrerMetricsEditionsRequest { /** The Ethereum address of the referrer to query */ referrer: Address; + /** 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 ReferrerDetailResponseCodes = { +export const ReferrerMetricsEditionsResponseCodes = { /** - * Represents that the referrer detail data is available. + * Represents that the referrer metrics data for the requested editions is available. */ Ok: "ok", @@ -80,32 +91,102 @@ export const ReferrerDetailResponseCodes = { } as const; /** - * The derived string union of possible {@link ReferrerDetailResponseCodes}. + * The derived string union of possible {@link ReferrerMetricsEditionsResponseCodes}. + */ +export type ReferrerMetricsEditionsResponseCode = + (typeof ReferrerMetricsEditionsResponseCodes)[keyof typeof ReferrerMetricsEditionsResponseCodes]; + +/** + * Referrer metrics data for requested editions. + * + * 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 ReferrerMetricsEditionsData = Partial< + Record +>; + +/** + * A successful response containing referrer metrics for the requested editions. + */ +export type ReferrerMetricsEditionsResponseOk = { + responseCode: typeof ReferrerMetricsEditionsResponseCodes.Ok; + data: ReferrerMetricsEditionsData; +}; + +/** + * A referrer metrics editions response when an error occurs. + */ +export type ReferrerMetricsEditionsResponseError = { + responseCode: typeof ReferrerMetricsEditionsResponseCodes.Error; + error: string; + errorMessage: string; +}; + +/** + * A referrer metrics editions API response. + * + * Use the `responseCode` field to determine the specific type interpretation + * at runtime. + */ +export type ReferrerMetricsEditionsResponse = + | ReferrerMetricsEditionsResponseOk + | ReferrerMetricsEditionsResponseError; + +/** + * A status code for referral program edition config set API responses. + */ +export const ReferralProgramEditionConfigSetResponseCodes = { + /** + * Represents that the edition config set is available. + */ + Ok: "ok", + + /** + * Represents that the edition config set is not available. + */ + Error: "error", +} as const; + +/** + * The derived string union of possible {@link ReferralProgramEditionConfigSetResponseCodes}. */ -export type ReferrerDetailResponseCode = - (typeof ReferrerDetailResponseCodes)[keyof typeof ReferrerDetailResponseCodes]; +export type ReferralProgramEditionConfigSetResponseCode = + (typeof ReferralProgramEditionConfigSetResponseCodes)[keyof typeof ReferralProgramEditionConfigSetResponseCodes]; + +/** + * The data payload containing edition configs. + * Editions are sorted in descending order by start timestamp. + */ +export type ReferralProgramEditionConfigSetData = { + editions: ReferralProgramEditionConfig[]; +}; /** - * A referrer detail response when the data is available for a referrer on the leaderboard. + * A successful response containing the configured edition config set. */ -export type ReferrerDetailResponseOk = { - responseCode: typeof ReferrerDetailResponseCodes.Ok; - data: ReferrerDetail; +export type ReferralProgramEditionConfigSetResponseOk = { + responseCode: typeof ReferralProgramEditionConfigSetResponseCodes.Ok; + data: ReferralProgramEditionConfigSetData; }; /** - * A referrer detail response when an error occurs. + * An edition config set response when an error occurs. */ -export type ReferrerDetailResponseError = { - responseCode: typeof ReferrerDetailResponseCodes.Error; +export type ReferralProgramEditionConfigSetResponseError = { + responseCode: typeof ReferralProgramEditionConfigSetResponseCodes.Error; error: string; errorMessage: string; }; /** - * A referrer detail API response. + * A referral program edition config set API response. * * Use the `responseCode` field to determine the specific type interpretation * at runtime. */ -export type ReferrerDetailResponse = ReferrerDetailResponseOk | ReferrerDetailResponseError; +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 994258506..371f3060a 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -19,14 +19,24 @@ import { makePriceEthSchema, makePriceUsdcSchema, makeUnixTimestampSchema, + makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; +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 { ReferrerDetailResponseCodes, ReferrerLeaderboardPageResponseCodes } from "./types"; +import { + MAX_EDITIONS_PER_REQUEST, + ReferralProgramEditionConfigSetResponseCodes, + ReferrerLeaderboardPageResponseCodes, + ReferrerMetricsEditionsResponseCodes, +} from "./types"; /** - * Schema for ReferralProgramRules + * Schema for {@link ReferralProgramRules} */ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => z @@ -36,6 +46,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`, @@ -176,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`), @@ -188,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`), @@ -200,35 +215,175 @@ export const makeReferrerDetailUnrankedSchema = (valueLabel: string = "ReferrerD }); /** - * Schema for {@link ReferrerDetailResponseOk} - * Accepts either ranked or unranked referrer detail data + * Schema for {@link ReferrerEditionMetrics} (discriminated union of ranked and unranked) + */ +export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerEditionMetrics") => + z.discriminatedUnion("type", [ + makeReferrerEditionMetricsRankedSchema(valueLabel), + 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). + */ +export const makeReferrerMetricsEditionsArraySchema = ( + valueLabel: string = "ReferrerMetricsEditionsArray", +) => + z + .array(makeReferralProgramEditionSlugSchema(`${valueLabel}[edition]`)) + .min(1, `${valueLabel} must contain at least 1 edition`) + .max( + MAX_EDITIONS_PER_REQUEST, + `${valueLabel} must not contain more than ${MAX_EDITIONS_PER_REQUEST} editions`, + ) + .refine( + (editions) => { + const uniqueEditions = new Set(editions); + return uniqueEditions.size === editions.length; + }, + { message: `${valueLabel} must not contain duplicate edition slugs` }, + ); + +/** + * Schema for {@link ReferrerMetricsEditionsRequest} + */ +export const makeReferrerMetricsEditionsRequestSchema = ( + valueLabel: string = "ReferrerMetricsEditionsRequest", +) => + z.object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + editions: makeReferrerMetricsEditionsArraySchema(`${valueLabel}.editions`), + }); + +/** + * Schema for {@link ReferrerMetricsEditionsResponseOk} + */ +export const makeReferrerMetricsEditionsResponseOkSchema = ( + valueLabel: string = "ReferrerMetricsEditionsResponseOk", +) => + z.object({ + responseCode: z.literal(ReferrerMetricsEditionsResponseCodes.Ok), + data: z.record( + makeReferralProgramEditionSlugSchema(`${valueLabel}.data[edition]`), + makeReferrerEditionMetricsSchema(`${valueLabel}.data[edition]`), + ), + }); + +/** + * Schema for {@link ReferrerMetricsEditionsResponseError} */ -export const makeReferrerDetailResponseOkSchema = (valueLabel: string = "ReferrerDetailResponse") => +export const makeReferrerMetricsEditionsResponseErrorSchema = ( + _valueLabel: string = "ReferrerMetricsEditionsResponseError", +) => + z.object({ + responseCode: z.literal(ReferrerMetricsEditionsResponseCodes.Error), + error: z.string(), + errorMessage: z.string(), + }); + +/** + * Schema for {@link ReferrerMetricsEditionsResponse} + */ +export const makeReferrerMetricsEditionsResponseSchema = ( + valueLabel: string = "ReferrerMetricsEditionsResponse", +) => + z.discriminatedUnion("responseCode", [ + makeReferrerMetricsEditionsResponseOkSchema(valueLabel), + makeReferrerMetricsEditionsResponseErrorSchema(valueLabel), + ]); + +/** + * Schema for validating a {@link ReferralProgramEditionConfig}. + */ +export const makeReferralProgramEditionConfigSchema = ( + valueLabel: string = "ReferralProgramEditionConfig", +) => + z.object({ + slug: makeReferralProgramEditionSlugSchema(`${valueLabel}.slug`), + displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), + rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), + }); + +/** + * Schema for validating referral program edition config set array. + */ +export const makeReferralProgramEditionConfigSetArraySchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetArray", +) => + z + .array(makeReferralProgramEditionConfigSchema(`${valueLabel}[edition]`)) + .min(1, `${valueLabel} must contain at least one edition`) + .refine( + (editions) => { + const slugs = new Set(); + for (const edition of editions) { + if (slugs.has(edition.slug)) return false; + slugs.add(edition.slug); + } + return true; + }, + { message: `${valueLabel} must not contain duplicate edition slugs` }, + ); + +/** + * Schema for {@link ReferralProgramEditionConfigSetData}. + */ +export const makeReferralProgramEditionConfigSetDataSchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetData", +) => + z.object({ + editions: makeReferralProgramEditionConfigSetArraySchema(`${valueLabel}.editions`), + }); + +/** + * Schema for {@link ReferralProgramEditionConfigSetResponseOk}. + */ +export const makeReferralProgramEditionConfigSetResponseOkSchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetResponseOk", +) => z.object({ - responseCode: z.literal(ReferrerDetailResponseCodes.Ok), - data: z.discriminatedUnion("type", [ - makeReferrerDetailRankedSchema(`${valueLabel}.data`), - makeReferrerDetailUnrankedSchema(`${valueLabel}.data`), - ]), + responseCode: z.literal(ReferralProgramEditionConfigSetResponseCodes.Ok), + data: makeReferralProgramEditionConfigSetDataSchema(`${valueLabel}.data`), }); /** - * Schema for {@link ReferrerDetailResponseError} + * Schema for {@link ReferralProgramEditionConfigSetResponseError}. */ -export const makeReferrerDetailResponseErrorSchema = ( - _valueLabel: string = "ReferrerDetailResponse", +export const makeReferralProgramEditionConfigSetResponseErrorSchema = ( + _valueLabel: string = "ReferralProgramEditionConfigSetResponseError", ) => z.object({ - responseCode: z.literal(ReferrerDetailResponseCodes.Error), + responseCode: z.literal(ReferralProgramEditionConfigSetResponseCodes.Error), error: z.string(), errorMessage: z.string(), }); /** - * Schema for {@link ReferrerDetailResponse} + * Schema for {@link ReferralProgramEditionConfigSetResponse}. */ -export const makeReferrerDetailResponseSchema = (valueLabel: string = "ReferrerDetailResponse") => +export const makeReferralProgramEditionConfigSetResponseSchema = ( + valueLabel: string = "ReferralProgramEditionConfigSetResponse", +) => z.discriminatedUnion("responseCode", [ - makeReferrerDetailResponseOkSchema(valueLabel), - makeReferrerDetailResponseErrorSchema(valueLabel), + makeReferralProgramEditionConfigSetResponseOkSchema(valueLabel), + makeReferralProgramEditionConfigSetResponseErrorSchema(valueLabel), ]); diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 8e21a179d..1086b13fc 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -1,13 +1,21 @@ import { - deserializeReferrerDetailResponse, + deserializeReferralProgramEditionConfigSetArray, + deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, - type ReferrerDetailRequest, - type ReferrerDetailResponse, + deserializeReferrerMetricsEditionsResponse, + type ReferralProgramEditionConfigSetResponse, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, - type SerializedReferrerDetailResponse, + type ReferrerMetricsEditionsRequest, + type ReferrerMetricsEditionsResponse, + type SerializedReferralProgramEditionConfigSetResponse, type SerializedReferrerLeaderboardPageResponse, + type SerializedReferrerMetricsEditionsResponse, } from "./api"; +import { + buildReferralProgramEditionConfigSet, + type ReferralProgramEditionConfigSet, +} from "./edition"; /** * Default ENSNode API endpoint URL @@ -32,8 +40,9 @@ export interface ClientOptions { * // Create client with default options * const client = new ENSReferralsClient(); * - * // Get referrer leaderboard + * // Get referrer leaderboard for December 2025 edition * const leaderboardPage = await client.getReferrerLeaderboardPage({ + * edition: "2025-12", * page: 1, * recordsPerPage: 25 * }); @@ -69,13 +78,53 @@ export class ENSReferralsClient { }); } + /** + * Get Referral Program Edition Config Set + * + * Fetches and deserializes a referral program edition config set from a remote URL. + * + * @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 + * @throws if the data doesn't match the expected schema + * + * @example + * ```typescript + * const url = new URL("https://example.com/editions.json"); + * const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(url); + * console.log(`Loaded ${editionConfigSet.size} editions`); + * ``` + */ + static async getReferralProgramEditionConfigSet( + 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 editionConfigs = deserializeReferralProgramEditionConfigSetArray(json); + + return buildReferralProgramEditionConfigSet(editionConfigs); + } + /** * 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 edition. * - * @param request - Pagination parameters + * @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} @@ -86,34 +135,38 @@ export class ENSReferralsClient { * * @example * ```typescript - * // Get first page with default page size (25 records) - * const response = await client.getReferrerLeaderboardPage(); + * // Get first page of 2025-12 leaderboard with default page size (25 records) + * const editionSlug = "2025-12"; + * const response = await client.getReferrerLeaderboardPage({ edition: editionSlug }); * 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(`Edition: ${editionSlug}`); + * console.log(`Subregistry: ${rules.subregistryId}`); + * 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 2026-03 with 50 records per page + * const response = await client.getReferrerLeaderboardPage({ + * edition: "2026-03", + * 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 edition or data not available) + * const response = await client.getReferrerLeaderboardPage({ edition: "2025-12" }); * * if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { * console.error(response.error); @@ -122,12 +175,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("edition", request.edition); + 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 +195,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,87 +206,105 @@ export class ENSReferralsClient { } /** - * Fetch Referrer Detail + * Fetch Referrer Metrics for Specific Editions * - * Retrieves detailed information about a specific referrer, whether they are on the - * leaderboard or not. + * Retrieves detailed information about a specific referrer for the requested + * referral program editions. Returns a record mapping each requested edition slug + * to the referrer's metrics for that edition. * - * The response data is a discriminated union type with a `type` field: + * 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} - * - `referrer`: The `AwardedReferrerMetrics` from @namehash/ens-referrals - * - `rules`: The referral program rules + * **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 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 + * - `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 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 to query - * @returns {ReferrerDetailResponse} Returns the referrer detail response + * @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 a specific address - * const response = await client.getReferrerDetail({ - * referrer: "0x1234567890123456789012345678901234567890" + * // Get referrer metrics for specific editions + * const response = await client.getReferrerMetricsEditions({ + * referrer: "0x1234567890123456789012345678901234567890", + * editions: ["2025-12", "2026-01"] * }); - * 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 === 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 === ReferrerEditionMetricsTypeIds.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 - * const response = await client.getReferrerDetail({ - * referrer: "0x1234567890123456789012345678901234567890" + * // Access specific edition data directly (edition is guaranteed to exist when OK) + * const response = await client.getReferrerMetricsEditions({ + * referrer: "0x1234567890123456789012345678901234567890", + * editions: ["2025-12"] * }); - * if (response.responseCode === ReferrerDetailResponseCodes.Ok) { - * if (response.data.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}%`); - * } else { - * // TypeScript knows this is ReferrerDetailUnranked - * console.log("Referrer is not on the leaderboard (no referrals yet)"); + * 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"); * } * } * ``` * * @example * ```typescript - * // Handle error response, ie. when Referrer Detail is not currently available. - * const response = await client.getReferrerDetail({ - * referrer: "0x1234567890123456789012345678901234567890" + * // Handle error response (e.g., unknown edition or data not available) + * const response = await client.getReferrerMetricsEditions({ + * referrer: "0x1234567890123456789012345678901234567890", + * editions: ["2025-12", "invalid-edition"] * }); * - * if (response.responseCode === ReferrerDetailResponseCodes.Error) { + * if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { * console.error(response.error); * console.error(response.errorMessage); * } * ``` */ - async getReferrerDetail(request: ReferrerDetailRequest): Promise { + async getReferrerMetricsEditions( + request: ReferrerMetricsEditionsRequest, + ): Promise { const url = new URL( - `/v1/ensanalytics/referrers/${encodeURIComponent(request.referrer)}`, + `/v1/ensanalytics/referrer/${encodeURIComponent(request.referrer)}`, this.options.url, ); + // Add editions as comma-separated query parameter + url.searchParams.set("editions", request.editions.join(",")); + const response = await fetch(url); // ENSNode API should always allow parsing a response as JSON object. @@ -244,11 +316,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 - // ReferrerDetailResponse format with responseCode: 'error' + // The API can return errors with various status codes, but they're still in the + // 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 deserializeReferrerDetailResponse(responseData as SerializedReferrerDetailResponse); + return deserializeReferrerMetricsEditionsResponse( + responseData as SerializedReferrerMetricsEditionsResponse, + ); + } + + /** + * 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 edition config set, or an error response if unavailable. + * + * @example + * ```typescript + * const response = await client.getEditionConfigSet(); + * + * 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}`); + * } + * } + * ``` + * + * @example + * ```typescript + * // Handle error response + * const response = await client.getEditionConfigSet(); + * + * if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) { + * console.error(response.error); + * console.error(response.errorMessage); + * } + * ``` + */ + async getEditionConfigSet(): Promise { + const url = new URL(`/v1/ensanalytics/editions`, 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 + // 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 deserializeReferralProgramEditionConfigSetResponse( + responseData as SerializedReferralProgramEditionConfigSetResponse, + ); } } diff --git a/packages/ens-referrals/src/v1/edition-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts new file mode 100644 index 000000000..df6086a29 --- /dev/null +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -0,0 +1,58 @@ +import { + type ENSNamespaceId, + getEthnamesSubregistryId, + parseTimestamp, + parseUsdc, +} from "@ensnode/ensnode-sdk"; + +import { + buildReferralProgramEditionConfigSet, + type ReferralProgramEditionConfig, + type ReferralProgramEditionConfigSet, +} from "./edition"; +import { buildReferralProgramRules } from "./rules"; + +/** + * 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 editions for that namespace. + * + * @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 getDefaultReferralProgramEditionConfigSet( + ensNamespaceId: ENSNamespaceId, +): ReferralProgramEditionConfigSet { + const subregistryId = getEthnamesSubregistryId(ensNamespaceId); + + const edition1: ReferralProgramEditionConfig = { + slug: "2025-12", + displayName: "ENS Holiday Awards", + rules: buildReferralProgramRules( + 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"), + ), + }; + + const edition2: ReferralProgramEditionConfig = { + slug: "2026-03", + displayName: "March 2026", + rules: buildReferralProgramRules( + parseUsdc("10000"), + 10, + parseTimestamp("2026-03-01T00:00:00Z"), + parseTimestamp("2026-03-31T23:59:59Z"), + subregistryId, + // TODO: replace this with the dedicated March 2026 rules URL once published + new URL("https://ensawards.org/ens-holiday-awards-rules"), + ), + }; + + return buildReferralProgramEditionConfigSet([edition1, 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..b33be2455 --- /dev/null +++ b/packages/ens-referrals/src/v1/edition.ts @@ -0,0 +1,100 @@ +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. + */ + 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. + * + * @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 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; +} diff --git a/packages/ens-referrals/src/v1/index.ts b/packages/ens-referrals/src/v1/index.ts index 9ef2b215a..72af819ce 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -2,12 +2,14 @@ export * from "./address"; export * from "./aggregations"; export * from "./api"; export * from "./client"; +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/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 3e6b52eda..13c7137e8 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. @@ -62,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 these rules. + * @example new URL("https://ensawards.org/ens-holiday-awards-rules") + */ + rulesUrl: URL; } export const validateReferralProgramRules = (rules: ReferralProgramRules): void => { @@ -87,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}.`, @@ -100,6 +84,7 @@ export const buildReferralProgramRules = ( startTime: UnixTimestamp, endTime: UnixTimestamp, subregistryId: AccountId, + rulesUrl: URL, ): ReferralProgramRules => { const result = { totalAwardPoolValue, @@ -107,6 +92,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..39022f336 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 @@ -107,8 +123,8 @@ export class SWRCache { }; }) .catch((error) => { - // on error, only update the cache if this is the first revalidation - if (!this.cache) { + // 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 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"); - // if ttl expired, revalidate in background - if (durationBetween(this.cache.updatedAt, getUnixTime(new Date())) > this.options.ttl) { + // 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 effective TTL expired, revalidate in background + 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 3344bfc42..6f0f9a7bd 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 editions configuration. * - * Dates must be specified in ISO 8601 format (e.g., '2025-12-01T00:00:00Z'). + * 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 EnsHolidayAwardsEnvironment { - ENS_HOLIDAY_AWARDS_START?: string; - ENS_HOLIDAY_AWARDS_END?: string; +export interface ReferralProgramEditionsEnvironment { + /** + * 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_EDITIONS?: string; } diff --git a/packages/ensnode-sdk/src/shared/datetime.test.ts b/packages/ensnode-sdk/src/shared/datetime.test.ts index e2d904397..b1067b157 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,36 @@ describe("datetime", () => { expect(addDuration(1000000, 999999)).toEqual(1999999); }); }); + + describe("parseTimestamp()", () => { + 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 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-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 ad95f3b43..497c70e07 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,32 @@ 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 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 (must include timezone) + * @returns The Unix timestamp (seconds since epoch) + * + * @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 deserializeUnixTimestamp(getUnixTime(date), "UnixTimestamp"); +}