-
Notifications
You must be signed in to change notification settings - Fork 15
Referral Program Cycles #1603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Referral Program Cycles #1603
Changes from all commits
9a2335f
07d83cf
8d0f65b
87a9f79
ca64874
6791b56
3731d72
3a1954d
4edd41d
2e514aa
06bfd8a
2bd2872
e6dddc7
3d2f890
67f4a28
346dd39
ffc5533
a1522a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@namehash/ens-referrals": minor | ||
| "ensapi": minor | ||
| --- | ||
Goader marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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. | ||
Goader marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@ensnode/ensnode-sdk": patch | ||
| --- | ||
|
|
||
| Adds `parseTimestamp` utility to parse ISO 8601 date strings into Unix timestamps. Adds `errorTtl` option to `SWRCache` for configuring separate revalidation intervals for cached errors vs. successful results. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,10 +112,27 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database | |
| # it receives to The Graph's hosted subgraphs using this API key. | ||
| # THEGRAPH_API_KEY= | ||
|
|
||
| # ENS Holiday Awards Date Range | ||
| # Optional. These variables define the date range for ENSAnalytics ENS Holiday Awards campaign. | ||
| # If not set, defaults to the hardcoded values from @namehash/ens-referrals package. | ||
| # Format: ISO 8601 datetime string (e.g., "2025-12-01T00:00:00Z") | ||
| # Note: ENS_HOLIDAY_AWARDS_START date must be before or the same as ENS_HOLIDAY_AWARDS_END | ||
| # ENS_HOLIDAY_AWARDS_START="2025-12-01T00:00:00Z" | ||
| # ENS_HOLIDAY_AWARDS_END="2025-12-31T23:59:59Z" | ||
| # Custom Referral Program Edition Config Set Definition (optional) | ||
| # URL that returns JSON for a custom referral program edition config set. | ||
| # If not set, the default edition config set for the namespace is used. | ||
| # | ||
| # JSON Structure: | ||
| # The JSON must be an array of edition config objects (SerializedReferralProgramEditionConfig[]). | ||
| # For the complete schema definition, see makeReferralProgramEditionConfigSetArraySchema in @namehash/ens-referrals/v1 | ||
| # | ||
| # Fetching Behavior: | ||
| # - Fetched proactively when ENSApi starts up (non-blocking; ENSApi may begin accepting requests before load completes) | ||
| # - Once successfully loaded, cached indefinitely (never expires or revalidates) | ||
| # - On load failure: | ||
| # * Error is logged | ||
| # * ENSApi continues running | ||
| # * Failed state is cached for 1 minute, then retried on subsequent requests | ||
| # * API requests receive error responses until successful load | ||
| # | ||
| # Configuration Notes: | ||
| # - Setting CUSTOM_REFERRAL_PROGRAM_EDITIONS completely replaces the default edition config set | ||
| # - Include all edition configs you want active in the JSON | ||
| # - Array must contain at least one edition config | ||
| # - All edition slugs must be unique | ||
| # | ||
| # CUSTOM_REFERRAL_PROGRAM_EDITIONS=https://example.com/custom-editions.json | ||
|
Comment on lines
+115
to
+138
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { | ||
| type ReferralProgramEditionConfig, | ||
| type ReferralProgramEditionConfigSet, | ||
| type ReferralProgramEditionSlug, | ||
| type ReferrerLeaderboard, | ||
| serializeReferralProgramRules, | ||
| } from "@namehash/ens-referrals/v1"; | ||
| import { minutesToSeconds } from "date-fns"; | ||
|
|
||
| import { | ||
| getLatestIndexedBlockRef, | ||
| type OmnichainIndexingStatusId, | ||
| OmnichainIndexingStatusIds, | ||
| SWRCache, | ||
| } from "@ensnode/ensnode-sdk"; | ||
|
|
||
| import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1"; | ||
| import { makeLogger } from "@/lib/logger"; | ||
|
|
||
| import { indexingStatusCache } from "./indexing-status.cache"; | ||
|
|
||
| const logger = makeLogger("referral-leaderboard-editions-cache"); | ||
|
|
||
| /** | ||
| * Map from edition slug to its leaderboard cache. | ||
| * | ||
| * Each edition has its own independent cache. Therefore, each | ||
| * edition's cache can be asynchronously loaded / refreshed from | ||
| * others, and a failure to load data for one edition doesn't break | ||
| * data successfully loaded for other editions. | ||
| */ | ||
| export type ReferralLeaderboardEditionsCacheMap = Map< | ||
| ReferralProgramEditionSlug, | ||
| SWRCache<ReferrerLeaderboard> | ||
| >; | ||
|
|
||
| /** | ||
| * The list of {@link OmnichainIndexingStatusId} values that are supported for generating | ||
| * referrer leaderboards. | ||
| * | ||
| * Other values indicate that we are not ready to generate leaderboards yet. | ||
| */ | ||
| const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ | ||
| OmnichainIndexingStatusIds.Following, | ||
| OmnichainIndexingStatusIds.Completed, | ||
| ]; | ||
|
|
||
| /** | ||
| * Creates a cache builder function for a specific edition. | ||
| * | ||
| * @param editionConfig - The edition configuration | ||
| * @returns A function that builds the leaderboard for the given edition | ||
| */ | ||
| function createEditionLeaderboardBuilder( | ||
| editionConfig: ReferralProgramEditionConfig, | ||
| ): () => Promise<ReferrerLeaderboard> { | ||
| return async (): Promise<ReferrerLeaderboard> => { | ||
| const editionSlug = editionConfig.slug; | ||
|
|
||
| const indexingStatus = await indexingStatusCache.read(); | ||
| if (indexingStatus instanceof Error) { | ||
| logger.error( | ||
| { error: indexingStatus, editionSlug }, | ||
| `Failed to read indexing status cache while generating referral leaderboard for ${editionSlug}. Cannot proceed without valid indexing status.`, | ||
| ); | ||
| throw new Error( | ||
| `Unable to generate referral leaderboard for ${editionSlug}. indexingStatusCache must have been successfully initialized.`, | ||
| ); | ||
| } | ||
|
|
||
| const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; | ||
| if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { | ||
| throw new Error( | ||
| `Unable to generate referrer leaderboard for ${editionSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, | ||
| ); | ||
| } | ||
|
|
||
| const latestIndexedBlockRef = getLatestIndexedBlockRef( | ||
| indexingStatus, | ||
| editionConfig.rules.subregistryId.chainId, | ||
| ); | ||
| if (latestIndexedBlockRef === null) { | ||
| throw new Error( | ||
| `Unable to generate referrer leaderboard for ${editionSlug}. Latest indexed block ref for chain ${editionConfig.rules.subregistryId.chainId} is null.`, | ||
| ); | ||
| } | ||
|
|
||
| logger.info( | ||
| `Building referrer leaderboard for ${editionSlug} with rules:\n${JSON.stringify( | ||
| serializeReferralProgramRules(editionConfig.rules), | ||
| null, | ||
| 2, | ||
| )}`, | ||
| ); | ||
|
|
||
| const leaderboard = await getReferrerLeaderboard( | ||
| editionConfig.rules, | ||
| latestIndexedBlockRef.timestamp, | ||
| ); | ||
|
|
||
| logger.info( | ||
| `Successfully built referrer leaderboard for ${editionSlug} with ${leaderboard.referrers.size} referrers`, | ||
| ); | ||
|
|
||
| return leaderboard; | ||
| }; | ||
| } | ||
Goader marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import config from "@/config"; | ||
|
|
||
| import { | ||
| ENSReferralsClient, | ||
| getDefaultReferralProgramEditionConfigSet, | ||
| type ReferralProgramEditionConfigSet, | ||
| } from "@namehash/ens-referrals/v1"; | ||
| import { minutesToSeconds } from "date-fns"; | ||
|
|
||
| import { SWRCache } from "@ensnode/ensnode-sdk"; | ||
|
|
||
| import { makeLogger } from "@/lib/logger"; | ||
|
|
||
| const logger = makeLogger("referral-program-edition-set-cache"); | ||
|
|
||
| /** | ||
| * Loads the referral program edition config set from custom URL or defaults. | ||
| */ | ||
| async function loadReferralProgramEditionConfigSet(): Promise<ReferralProgramEditionConfigSet> { | ||
| // Check if custom URL is configured | ||
| if (config.customReferralProgramEditionConfigSetUrl) { | ||
| logger.info( | ||
| `Loading custom referral program edition config set from: ${config.customReferralProgramEditionConfigSetUrl.href}`, | ||
| ); | ||
| try { | ||
| const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet( | ||
| config.customReferralProgramEditionConfigSetUrl, | ||
| ); | ||
| logger.info(`Successfully loaded ${editionConfigSet.size} custom referral program editions`); | ||
| return editionConfigSet; | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| logger.error(error, "Error occurred while loading referral program edition config set"); | ||
| throw new Error( | ||
| `Failed to load custom referral program edition config set from ${config.customReferralProgramEditionConfigSetUrl.href}: ${errorMessage}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Use default edition config set for the namespace | ||
| logger.info( | ||
| `Loading default referral program edition config set for namespace: ${config.namespace}`, | ||
| ); | ||
| const editionConfigSet = getDefaultReferralProgramEditionConfigSet(config.namespace); | ||
| logger.info(`Successfully loaded ${editionConfigSet.size} default referral program editions`); | ||
| return editionConfigSet; | ||
| } | ||
|
|
||
| /** | ||
| * SWR Cache for the referral program edition config set. | ||
| * | ||
| * Once successfully loaded, the edition config set is cached indefinitely and never revalidated. | ||
| * This ensures the JSON is only fetched once during the application lifecycle. | ||
| * | ||
| * Configuration: | ||
| * - ttl: Infinity - Never expires once cached | ||
| * - proactiveRevalidationInterval: undefined - No proactive revalidation | ||
| * - proactivelyInitialize: true - Load immediately on startup | ||
| */ | ||
| export const referralProgramEditionConfigSetCache = new SWRCache<ReferralProgramEditionConfigSet>({ | ||
| fn: loadReferralProgramEditionConfigSet, | ||
| ttl: Number.POSITIVE_INFINITY, | ||
| errorTtl: minutesToSeconds(1), | ||
| proactiveRevalidationInterval: undefined, | ||
| proactivelyInitialize: true, | ||
Goader marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.