diff --git a/.changeset/swift-trains-move.md b/.changeset/swift-trains-move.md new file mode 100644 index 000000000..115917dc3 --- /dev/null +++ b/.changeset/swift-trains-move.md @@ -0,0 +1,6 @@ +--- +"@namehash/ens-referrals": minor +"ensapi": patch +--- + +Add `Exhausted` and `AwardsReview` referral program statuses; add `areAwardsDistributed` to base rules; enrich `/editions` with runtime `status` and `awardPoolRemaining` per edition. diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts index ceef7fc5e..003682e37 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts @@ -112,12 +112,12 @@ export const getEditionsRoute = createRoute({ path: "/editions", operationId: "getEditions_v1", tags: ["ENSAwards"], - summary: "Get Edition Config Set (v1)", + summary: "Get Edition Summaries (v1)", description: - "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).", + "Returns a summary for each configured referral program edition, including its current status and award-model-specific runtime data. Editions are sorted in descending order by start timestamp (most recent first).", responses: { 200: { - description: "Successfully retrieved edition config set", + description: "Successfully retrieved edition summaries.", }, 500: { description: "Internal server error", diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 916d4ce49..2984d054a 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -28,13 +28,13 @@ vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => ( import { buildReferralProgramRulesPieSplit, - deserializeReferralProgramEditionConfigSetResponse, + deserializeReferralProgramEditionSummariesResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, ReferralProgramAwardModels, - ReferralProgramEditionConfigSetResponseCodes, type ReferralProgramEditionSlug, - ReferralProgramStatuses, + ReferralProgramEditionStatuses, + ReferralProgramEditionSummariesResponseCodes, ReferrerEditionMetricsTypeIds, type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, @@ -123,7 +123,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...populatedReferrerLeaderboard, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, pageContext: { endIndex: 9, hasNext: true, @@ -145,7 +145,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...populatedReferrerLeaderboard, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, pageContext: { endIndex: 19, hasNext: true, @@ -166,7 +166,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...populatedReferrerLeaderboard, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, pageContext: { endIndex: 28, hasNext: false, @@ -229,7 +229,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...emptyReferralLeaderboard, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, pageContext: { hasNext: false, hasPrev: false, @@ -369,7 +369,7 @@ describe("/v1/ensanalytics", () => { referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, }, "2026-03": { awardModel: populatedReferrerLeaderboard.awardModel, @@ -378,7 +378,7 @@ describe("/v1/ensanalytics", () => { referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, }, }, } satisfies ReferrerMetricsEditionsResponseOk; @@ -824,6 +824,7 @@ describe("/v1/ensanalytics", () => { parseTimestamp("2025-12-31T23:59:59Z"), { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, new URL("https://example.com/rules"), + false, ), }, ], @@ -839,6 +840,7 @@ describe("/v1/ensanalytics", () => { parseTimestamp("2026-03-31T23:59:59Z"), { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, new URL("https://example.com/rules"), + false, ), }, ], @@ -854,6 +856,7 @@ describe("/v1/ensanalytics", () => { parseTimestamp("2026-06-30T23:59:59Z"), { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, new URL("https://example.com/rules"), + false, ), }, ], @@ -867,24 +870,40 @@ describe("/v1/ensanalytics", () => { }, ); - // Mock caches middleware (needed by middleware chain) + // Mock caches middleware with a cache for each edition + const mockEditionsCaches = new Map>( + [ + [ + "2025-12", + { read: async () => emptyReferralLeaderboard } as SWRCache, + ], + [ + "2026-03", + { read: async () => emptyReferralLeaderboard } as SWRCache, + ], + [ + "2026-06", + { read: async () => emptyReferralLeaderboard } as SWRCache, + ], + ], + ); vi.mocked( editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", new Map()); + c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); return await next(); }); // Act: send test request const httpResponse = await app.request("/editions"); const responseData = await httpResponse.json(); - const response = deserializeReferralProgramEditionConfigSetResponse(responseData); + const response = deserializeReferralProgramEditionSummariesResponse(responseData); // Assert: response contains all editions sorted by start timestamp descending expect(httpResponse.status).toBe(200); - expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Ok); + expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Ok); - if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) { + if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Ok) { expect(response.data.editions).toHaveLength(3); // Verify sorting: most recent start time first @@ -920,13 +939,13 @@ describe("/v1/ensanalytics", () => { // Act: send test request const httpResponse = await app.request("/editions"); const responseData = await httpResponse.json(); - const response = deserializeReferralProgramEditionConfigSetResponse(responseData); + const response = deserializeReferralProgramEditionSummariesResponse(responseData); // Assert: response is error expect(httpResponse.status).toBe(503); - expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Error); + expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Error); - if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) { + if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.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 320a2e68e..a062e3ed4 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -1,16 +1,17 @@ import { + buildEditionSummary, getReferrerEditionMetrics, getReferrerLeaderboardPage, - type ReferralProgramEditionConfigSetResponse, - ReferralProgramEditionConfigSetResponseCodes, type ReferralProgramEditionSlug, + type ReferralProgramEditionSummariesResponse, + ReferralProgramEditionSummariesResponseCodes, type ReferrerLeaderboard, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, type ReferrerMetricsEditionsData, type ReferrerMetricsEditionsResponse, ReferrerMetricsEditionsResponseCodes, - serializeReferralProgramEditionConfigSetResponse, + serializeReferralProgramEditionSummariesResponse, serializeReferrerLeaderboardPageResponse, serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals/v1"; @@ -235,7 +236,7 @@ app.openapi(getReferrerDetailRoute, async (c) => { } }); -// Get configured edition config set +// Get edition summaries app.openapi(getEditionsRoute, async (c) => { // context must be set by the required middleware if (c.var.referralProgramEditionConfigSet === undefined) { @@ -244,6 +245,12 @@ app.openapi(getEditionsRoute, async (c) => { ); } + if (c.var.referralLeaderboardEditionsCaches === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, + ); + } + try { // Check if edition config set failed to load if (c.var.referralProgramEditionConfigSet instanceof Error) { @@ -252,27 +259,82 @@ app.openapi(getEditionsRoute, async (c) => { "Referral program edition config set failed to load", ); return c.json( - serializeReferralProgramEditionConfigSetResponse({ - responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, + serializeReferralProgramEditionSummariesResponse({ + responseCode: ReferralProgramEditionSummariesResponseCodes.Error, error: "Service Unavailable", errorMessage: "Referral program configuration is currently unavailable.", - } satisfies ReferralProgramEditionConfigSetResponse), + } satisfies ReferralProgramEditionSummariesResponse), 503, ); } - // Convert Map to array and sort by start timestamp descending - const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort( + // Check if leaderboard caches failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program leaderboard caches failed to load", + ); + return c.json( + serializeReferralProgramEditionSummariesResponse({ + responseCode: ReferralProgramEditionSummariesResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program leaderboard data is currently unavailable.", + } satisfies ReferralProgramEditionSummariesResponse), + 503, + ); + } + + // Sort edition configs by start timestamp descending + const editionConfigs = Array.from(c.var.referralProgramEditionConfigSet.values()).sort( (a, b) => b.rules.startTime - a.rules.startTime, ); + // Read all leaderboard caches in parallel, keeping config colocated with its leaderboard + const leaderboardCaches = c.var.referralLeaderboardEditionsCaches; + const results = await Promise.all( + editionConfigs.map(async (config) => { + const cache = leaderboardCaches.get(config.slug); + if (!cache) throw new Error(`Invariant: edition cache for ${config.slug} should exist`); + return { config, leaderboard: await cache.read() }; + }), + ); + + // Partition into failures and successes in one pass + const failedSlugs: ReferralProgramEditionSlug[] = []; + const valid: { + config: (typeof results)[number]["config"]; + leaderboard: ReferrerLeaderboard; + }[] = []; + for (const { config, leaderboard } of results) { + if (leaderboard instanceof Error) { + failedSlugs.push(config.slug); + } else { + valid.push({ config, leaderboard }); + } + } + + if (failedSlugs.length > 0) { + return c.json( + serializeReferralProgramEditionSummariesResponse({ + responseCode: ReferralProgramEditionSummariesResponseCodes.Error, + error: "Service Unavailable", + errorMessage: `Leaderboard data not available for edition(s): ${failedSlugs.join(", ")}`, + } satisfies ReferralProgramEditionSummariesResponse), + 503, + ); + } + + const editions = valid.map(({ config, leaderboard }) => + buildEditionSummary(config, leaderboard), + ); + return c.json( - serializeReferralProgramEditionConfigSetResponse({ - responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok, + serializeReferralProgramEditionSummariesResponse({ + responseCode: ReferralProgramEditionSummariesResponseCodes.Ok, data: { editions, }, - } satisfies ReferralProgramEditionConfigSetResponse), + } satisfies ReferralProgramEditionSummariesResponse), ); } catch (error) { logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint"); @@ -281,11 +343,11 @@ app.openapi(getEditionsRoute, async (c) => { ? error.message : "An unexpected error occurred while processing your request"; return c.json( - serializeReferralProgramEditionConfigSetResponse({ - responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, + serializeReferralProgramEditionSummariesResponse({ + responseCode: ReferralProgramEditionSummariesResponseCodes.Error, error: "Internal server error", errorMessage, - } satisfies ReferralProgramEditionConfigSetResponse), + } satisfies ReferralProgramEditionSummariesResponse), 500, ); } 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 f86d59950..da3a4b627 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 @@ -26,6 +26,7 @@ const rules = buildReferralProgramRulesPieSplit( address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, new URL("https://example.com/rules"), + false, ); const accurateAsOf = parseTimestamp("2025-11-30T23:59:59Z"); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts index 03837ef46..02de03442 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts @@ -1,6 +1,6 @@ import { ReferralProgramAwardModels, - ReferralProgramStatuses, + ReferralProgramEditionStatuses, type ReferrerLeaderboardPagePieSplit, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, @@ -188,6 +188,7 @@ export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = { address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, rulesUrl: new URL("https://example.com/rules"), + areAwardsDistributed: false, }, aggregatedMetrics: { grandTotalReferrals: 0, @@ -213,6 +214,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, rulesUrl: new URL("https://example.com/rules"), + areAwardsDistributed: false, }, aggregatedMetrics: { grandTotalReferrals: 68, @@ -705,6 +707,7 @@ export const referrerLeaderboardPageResponseOk = { address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", }, rulesUrl: new URL("https://example.com/rules"), + areAwardsDistributed: false, }, referrers: [ { @@ -1102,7 +1105,7 @@ export const referrerLeaderboardPageResponseOk = { startIndex: 0, endIndex: 28, }, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1735689600, } satisfies ReferrerLeaderboardPagePieSplit, } satisfies ReferrerLeaderboardPageResponseOk; diff --git a/docs/docs.ensnode.io/ensapi-openapi.json b/docs/docs.ensnode.io/ensapi-openapi.json index 7630b76de..54e2e9423 100644 --- a/docs/docs.ensnode.io/ensapi-openapi.json +++ b/docs/docs.ensnode.io/ensapi-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ENSApi APIs", - "version": "1.5.1", + "version": "1.7.0", "description": "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode" }, "servers": [ @@ -101,6 +101,34 @@ "ensIndexerPublicConfig": { "type": "object", "properties": { + "databaseSchemaName": { "type": "string", "minLength": 1 }, + "ensRainbowPublicConfig": { + "type": "object", + "properties": { + "version": { "type": "string", "minLength": 1 }, + "labelSet": { + "type": "object", + "properties": { + "labelSetId": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-z-]+$" + }, + "highestLabelSetVersion": { "type": ["number", "null"] } + }, + "required": ["labelSetId", "highestLabelSetVersion"] + }, + "recordsCount": { "type": "integer", "minimum": 0 } + }, + "required": ["version", "labelSet", "recordsCount"] + }, + "indexedChainIds": { + "type": "array", + "items": { "type": "integer", "exclusiveMinimum": 0 }, + "minItems": 1 + }, + "isSubgraphCompatible": { "type": "boolean" }, "labelSet": { "type": "object", "properties": { @@ -114,12 +142,6 @@ }, "required": ["labelSetId", "labelSetVersion"] }, - "indexedChainIds": { - "type": "array", - "items": { "type": "integer", "exclusiveMinimum": 0 }, - "minItems": 1 - }, - "isSubgraphCompatible": { "type": "boolean" }, "namespace": { "type": "string", "enum": ["mainnet", "sepolia", "sepolia-v2", "ens-test-env"] @@ -129,7 +151,6 @@ "items": { "type": "string" }, "minItems": 1 }, - "databaseSchemaName": { "type": "string", "minLength": 1 }, "versionInfo": { "type": "object", "properties": { @@ -137,29 +158,19 @@ "ponder": { "type": "string", "minLength": 1 }, "ensDb": { "type": "string", "minLength": 1 }, "ensIndexer": { "type": "string", "minLength": 1 }, - "ensNormalize": { "type": "string", "minLength": 1 }, - "ensRainbow": { "type": "string", "minLength": 1 }, - "ensRainbowSchema": { "type": "integer", "exclusiveMinimum": 0 } + "ensNormalize": { "type": "string", "minLength": 1 } }, - "required": [ - "nodejs", - "ponder", - "ensDb", - "ensIndexer", - "ensNormalize", - "ensRainbow", - "ensRainbowSchema" - ], - "additionalProperties": false + "required": ["nodejs", "ponder", "ensDb", "ensIndexer", "ensNormalize"] } }, "required": [ - "labelSet", + "databaseSchemaName", + "ensRainbowPublicConfig", "indexedChainIds", "isSubgraphCompatible", + "labelSet", "namespace", "plugins", - "databaseSchemaName", "versionInfo" ] } @@ -1025,10 +1036,10 @@ "get": { "operationId": "getEditions_v1", "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).", + "summary": "Get Edition Summaries (v1)", + "description": "Returns a summary for each configured referral program edition, including its current status and award-model-specific runtime data. Editions are sorted in descending order by start timestamp (most recent first).", "responses": { - "200": { "description": "Successfully retrieved edition config set" }, + "200": { "description": "Successfully retrieved edition summaries." }, "500": { "description": "Internal server error" }, "503": { "description": "Service unavailable" } } diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index ce4096a99..abe174962 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -2,18 +2,18 @@ import { prettifyError } from "zod/v4"; import type { ReferralProgramEditionConfig } from "../edition"; import type { - SerializedReferralProgramEditionConfigSetResponse, + SerializedReferralProgramEditionSummariesResponse, SerializedReferrerLeaderboardPageResponse, SerializedReferrerMetricsEditionsResponse, } from "./serialized-types"; import type { - ReferralProgramEditionConfigSetResponse, + ReferralProgramEditionSummariesResponse, ReferrerLeaderboardPageResponse, ReferrerMetricsEditionsResponse, } from "./types"; import { makeReferralProgramEditionConfigSetArraySchema, - makeReferralProgramEditionConfigSetResponseSchema, + makeReferralProgramEditionSummariesResponseSchema, makeReferrerLeaderboardPageResponseSchema, makeReferrerMetricsEditionsResponseSchema, } from "./zod-schemas"; @@ -76,18 +76,18 @@ export function deserializeReferralProgramEditionConfigSetArray( } /** - * Deserialize a {@link ReferralProgramEditionConfigSetResponse} object. + * Deserialize a {@link ReferralProgramEditionSummariesResponse} object. */ -export function deserializeReferralProgramEditionConfigSetResponse( - maybeResponse: SerializedReferralProgramEditionConfigSetResponse, +export function deserializeReferralProgramEditionSummariesResponse( + maybeResponse: SerializedReferralProgramEditionSummariesResponse, valueLabel?: string, -): ReferralProgramEditionConfigSetResponse { - const schema = makeReferralProgramEditionConfigSetResponseSchema(valueLabel); +): ReferralProgramEditionSummariesResponse { + const schema = makeReferralProgramEditionSummariesResponseSchema(valueLabel); const parsed = schema.safeParse(maybeResponse); if (parsed.error) { throw new Error( - `Cannot deserialize ReferralProgramEditionConfigSetResponse:\n${prettifyError(parsed.error)}\n`, + `Cannot deserialize ReferralProgramEditionSummariesResponse:\n${prettifyError(parsed.error)}\n`, ); } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 259cd61a2..2b460a9bf 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,24 +1,27 @@ import { + serializeReferralProgramEditionSummaryPieSplit, serializeReferralProgramRulesPieSplit, serializeReferrerEditionMetricsPieSplit, serializeReferrerLeaderboardPagePieSplit, } from "../award-models/pie-split/api/serialize"; import { + serializeReferralProgramEditionSummaryRevShareLimit, serializeReferralProgramRulesRevShareLimit, serializeReferrerEditionMetricsRevShareLimit, serializeReferrerLeaderboardPageRevShareLimit, } from "../award-models/rev-share-limit/api/serialize"; import type { ReferrerEditionMetricsUnrecognized } from "../award-models/shared/edition-metrics"; +import type { ReferralProgramEditionSummaryUnrecognized } from "../award-models/shared/edition-summary"; import type { ReferrerLeaderboardPageUnrecognized } from "../award-models/shared/leaderboard-page"; import type { ReferralProgramRulesUnrecognized } from "../award-models/shared/rules"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; -import type { ReferralProgramEditionConfig } from "../edition"; import type { ReferrerEditionMetrics } from "../edition-metrics"; +import type { ReferralProgramEditionSummary } from "../edition-summary"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { ReferralProgramRules } from "../rules"; import type { - SerializedReferralProgramEditionConfig, - SerializedReferralProgramEditionConfigSetResponse, + SerializedReferralProgramEditionSummariesResponse, + SerializedReferralProgramEditionSummary, SerializedReferralProgramRules, SerializedReferrerEditionMetrics, SerializedReferrerLeaderboardPage, @@ -27,8 +30,8 @@ import type { SerializedReferrerMetricsEditionsResponse, } from "./serialized-types"; import { - type ReferralProgramEditionConfigSetResponse, - ReferralProgramEditionConfigSetResponseCodes, + type ReferralProgramEditionSummariesResponse, + ReferralProgramEditionSummariesResponseCodes, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, type ReferrerMetricsEditionsResponse, @@ -126,16 +129,35 @@ function serializeReferrerEditionMetrics( } /** - * Serializes a {@link ReferralProgramEditionConfig} object. + * Serializes a {@link ReferralProgramEditionSummary} object. + * + * @throws if called with a {@link ReferralProgramEditionSummaryUnrecognized} — unrecognized + * summaries are client-side forward-compatibility placeholders and must never be serialized. */ -export function serializeReferralProgramEditionConfig( - editionConfig: ReferralProgramEditionConfig, -): SerializedReferralProgramEditionConfig { - return { - slug: editionConfig.slug, - displayName: editionConfig.displayName, - rules: serializeReferralProgramRules(editionConfig.rules), - }; +export function serializeReferralProgramEditionSummary( + summary: ReferralProgramEditionSummary, +): SerializedReferralProgramEditionSummary { + switch (summary.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferralProgramEditionSummaryPieSplit(summary); + + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferralProgramEditionSummaryRevShareLimit(summary); + + case ReferralProgramAwardModels.Unrecognized: { + const unrecognized = summary as ReferralProgramEditionSummaryUnrecognized; + throw new Error( + `ReferralProgramEditionSummaryUnrecognized (originalAwardModel: '${unrecognized.rules.originalAwardModel}') must not be serialized — it is a client-side forward-compatibility placeholder only.`, + ); + } + + default: { + const _exhaustiveCheck: never = summary; + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferralProgramEditionSummary).awardModel}`, + ); + } + } } /** @@ -190,27 +212,27 @@ export function serializeReferrerMetricsEditionsResponse( } /** - * Serialize a {@link ReferralProgramEditionConfigSetResponse} object. + * Serialize a {@link ReferralProgramEditionSummariesResponse} object. */ -export function serializeReferralProgramEditionConfigSetResponse( - response: ReferralProgramEditionConfigSetResponse, -): SerializedReferralProgramEditionConfigSetResponse { +export function serializeReferralProgramEditionSummariesResponse( + response: ReferralProgramEditionSummariesResponse, +): SerializedReferralProgramEditionSummariesResponse { switch (response.responseCode) { - case ReferralProgramEditionConfigSetResponseCodes.Ok: + case ReferralProgramEditionSummariesResponseCodes.Ok: return { responseCode: response.responseCode, data: { - editions: response.data.editions.map(serializeReferralProgramEditionConfig), + editions: response.data.editions.map(serializeReferralProgramEditionSummary), }, }; - case ReferralProgramEditionConfigSetResponseCodes.Error: + case ReferralProgramEditionSummariesResponseCodes.Error: return response; default: { const _exhaustiveCheck: never = response; throw new Error( - `Unknown response code: ${(_exhaustiveCheck as ReferralProgramEditionConfigSetResponse).responseCode}`, + `Unknown response code: ${(_exhaustiveCheck as ReferralProgramEditionSummariesResponse).responseCode}`, ); } } diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 1dad620df..8f51d0160 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,22 +1,25 @@ import type { + SerializedReferralProgramEditionSummaryPieSplit, SerializedReferralProgramRulesPieSplit, SerializedReferrerEditionMetricsPieSplit, SerializedReferrerLeaderboardPagePieSplit, } from "../award-models/pie-split/api/serialized-types"; import type { + SerializedReferralProgramEditionSummaryRevShareLimit, SerializedReferralProgramRulesRevShareLimit, SerializedReferrerEditionMetricsRevShareLimit, SerializedReferrerLeaderboardPageRevShareLimit, } from "../award-models/rev-share-limit/api/serialized-types"; -import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; +import type { ReferralProgramEditionSlug } from "../edition"; import type { ReferrerEditionMetrics } from "../edition-metrics"; +import type { ReferralProgramEditionSummary } from "../edition-summary"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import type { ReferralProgramRules } from "../rules"; import type { - ReferralProgramEditionConfigSetData, - ReferralProgramEditionConfigSetResponse, - ReferralProgramEditionConfigSetResponseError, - ReferralProgramEditionConfigSetResponseOk, + ReferralProgramEditionSummariesData, + ReferralProgramEditionSummariesResponse, + ReferralProgramEditionSummariesResponseError, + ReferralProgramEditionSummariesResponseOk, ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseError, ReferrerLeaderboardPageResponseOk, @@ -69,12 +72,11 @@ export type SerializedReferrerLeaderboardPageResponse = | SerializedReferrerLeaderboardPageResponseError; /** - * Serialized representation of {@link ReferralProgramEditionConfig}. + * Serialized representation of {@link ReferralProgramEditionSummary}. */ -export interface SerializedReferralProgramEditionConfig - extends Omit { - rules: SerializedReferralProgramRules; -} +export type SerializedReferralProgramEditionSummary = + | SerializedReferralProgramEditionSummaryPieSplit + | SerializedReferralProgramEditionSummaryRevShareLimit; /** * Serialized representation of referrer metrics data for requested editions. @@ -109,32 +111,32 @@ export type SerializedReferrerMetricsEditionsResponse = | SerializedReferrerMetricsEditionsResponseError; /** - * Serialized representation of {@link ReferralProgramEditionConfigSetData}. + * Serialized representation of {@link ReferralProgramEditionSummariesData}. */ -export interface SerializedReferralProgramEditionConfigSetData - extends Omit { - editions: SerializedReferralProgramEditionConfig[]; +export interface SerializedReferralProgramEditionSummariesData + extends Omit { + editions: SerializedReferralProgramEditionSummary[]; } /** - * Serialized representation of {@link ReferralProgramEditionConfigSetResponseOk}. + * Serialized representation of {@link ReferralProgramEditionSummariesResponseOk}. */ -export interface SerializedReferralProgramEditionConfigSetResponseOk - extends Omit { - data: SerializedReferralProgramEditionConfigSetData; +export interface SerializedReferralProgramEditionSummariesResponseOk + extends Omit { + data: SerializedReferralProgramEditionSummariesData; } /** - * Serialized representation of {@link ReferralProgramEditionConfigSetResponseError}. + * Serialized representation of {@link ReferralProgramEditionSummariesResponseError}. * * Note: All fields are already serializable, so this type is identical to the source type. */ -export type SerializedReferralProgramEditionConfigSetResponseError = - ReferralProgramEditionConfigSetResponseError; +export type SerializedReferralProgramEditionSummariesResponseError = + ReferralProgramEditionSummariesResponseError; /** - * Serialized representation of {@link ReferralProgramEditionConfigSetResponse}. + * Serialized representation of {@link ReferralProgramEditionSummariesResponse}. */ -export type SerializedReferralProgramEditionConfigSetResponse = - | SerializedReferralProgramEditionConfigSetResponseOk - | SerializedReferralProgramEditionConfigSetResponseError; +export type SerializedReferralProgramEditionSummariesResponse = + | SerializedReferralProgramEditionSummariesResponseOk + | SerializedReferralProgramEditionSummariesResponseError; diff --git a/packages/ens-referrals/src/v1/api/types.ts b/packages/ens-referrals/src/v1/api/types.ts index 3d2e90e75..5e5274097 100644 --- a/packages/ens-referrals/src/v1/api/types.ts +++ b/packages/ens-referrals/src/v1/api/types.ts @@ -1,8 +1,9 @@ import type { Address } from "viem"; import type { ReferrerLeaderboardPageParams } from "../award-models/shared/leaderboard-page"; -import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; +import type { ReferralProgramEditionSlug } from "../edition"; import type { ReferrerEditionMetrics } from "../edition-metrics"; +import type { ReferralProgramEditionSummary } from "../edition-summary"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; /** @@ -137,57 +138,57 @@ export type ReferrerMetricsEditionsResponse = | ReferrerMetricsEditionsResponseError; /** - * A status code for referral program edition config set API responses. + * A status code for referral program edition summaries API responses. */ -export const ReferralProgramEditionConfigSetResponseCodes = { +export const ReferralProgramEditionSummariesResponseCodes = { /** - * Represents that the edition config set is available. + * Represents that the edition summaries are available. */ Ok: "ok", /** - * Represents that the edition config set is not available. + * Represents that the edition summaries are not available. */ Error: "error", } as const; /** - * The derived string union of possible {@link ReferralProgramEditionConfigSetResponseCodes}. + * The derived string union of possible {@link ReferralProgramEditionSummariesResponseCodes}. */ -export type ReferralProgramEditionConfigSetResponseCode = - (typeof ReferralProgramEditionConfigSetResponseCodes)[keyof typeof ReferralProgramEditionConfigSetResponseCodes]; +export type ReferralProgramEditionSummariesResponseCode = + (typeof ReferralProgramEditionSummariesResponseCodes)[keyof typeof ReferralProgramEditionSummariesResponseCodes]; /** - * The data payload containing edition configs. + * The data payload containing edition summaries. * Editions are sorted in descending order by start timestamp. */ -export type ReferralProgramEditionConfigSetData = { - editions: ReferralProgramEditionConfig[]; +export type ReferralProgramEditionSummariesData = { + editions: ReferralProgramEditionSummary[]; }; /** - * A successful response containing the configured edition config set. + * A successful response containing edition summaries. */ -export type ReferralProgramEditionConfigSetResponseOk = { - responseCode: typeof ReferralProgramEditionConfigSetResponseCodes.Ok; - data: ReferralProgramEditionConfigSetData; +export type ReferralProgramEditionSummariesResponseOk = { + responseCode: typeof ReferralProgramEditionSummariesResponseCodes.Ok; + data: ReferralProgramEditionSummariesData; }; /** - * An edition config set response when an error occurs. + * An edition summaries response when an error occurs. */ -export type ReferralProgramEditionConfigSetResponseError = { - responseCode: typeof ReferralProgramEditionConfigSetResponseCodes.Error; +export type ReferralProgramEditionSummariesResponseError = { + responseCode: typeof ReferralProgramEditionSummariesResponseCodes.Error; error: string; errorMessage: string; }; /** - * A referral program edition config set API response. + * A referral program edition summaries API response. * * Use the `responseCode` field to determine the specific type interpretation * at runtime. */ -export type ReferralProgramEditionConfigSetResponse = - | ReferralProgramEditionConfigSetResponseOk - | ReferralProgramEditionConfigSetResponseError; +export type ReferralProgramEditionSummariesResponse = + | ReferralProgramEditionSummariesResponseOk + | ReferralProgramEditionSummariesResponseError; diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.test.ts b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts index 1da92cab3..82bcff610 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts @@ -4,11 +4,13 @@ import { CurrencyIds, parseEth, parseUsdc } from "@ensnode/ensnode-sdk"; import type { ReferrerEditionMetricsUnrecognized } from "../award-models/shared/edition-metrics"; import { ReferrerEditionMetricsTypeIds } from "../award-models/shared/edition-metrics"; +import type { ReferralProgramEditionSummaryUnrecognized } from "../award-models/shared/edition-summary"; import type { ReferrerLeaderboardPageUnrecognized } from "../award-models/shared/leaderboard-page"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; -import { ReferralProgramStatuses } from "../status"; +import { ReferralProgramEditionStatuses } from "../award-models/shared/status"; import { makeReferralProgramEditionConfigSetArraySchema, + makeReferralProgramEditionSummarySchema, makeReferrerEditionMetricsSchema, makeReferrerLeaderboardPageSchema, } from "./zod-schemas"; @@ -32,6 +34,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { endTime: 2000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }, }; @@ -47,6 +50,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { endTime: 2000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }, }; @@ -59,6 +63,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { endTime: 3000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, someNewField: "extra-data", }, }; @@ -75,6 +80,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { expect(pieSplit).toBeDefined(); expect(pieSplit!.rules.awardModel).toBe(ReferralProgramAwardModels.PieSplit); + expect(pieSplit!.rules.areAwardsDistributed).toBe(pieSplitEdition.rules.areAwardsDistributed); }); it("parses the recognized rev-share-limit edition correctly", () => { @@ -94,6 +100,9 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { expect(rules.minQualifiedRevenueContribution).toBeDefined(); expect(typeof rules.qualifiedRevenueShare).toBe("number"); expect(rules.qualifiedRevenueShare).toBe(0.5); + expect(revShareLimit!.rules.areAwardsDistributed).toBe( + revShareLimitEdition.rules.areAwardsDistributed, + ); }); it("wraps the unrecognized edition as ReferralProgramRulesUnrecognized", () => { @@ -115,6 +124,9 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { expect(unrecognized!.rules.endTime).toBe(3000000); expect(unrecognized!.rules.rulesUrl).toBeInstanceOf(URL); expect(unrecognized!.rules.rulesUrl.href).toBe("https://ensawards.org/rules"); + expect(unrecognized!.rules.areAwardsDistributed).toBe( + futureModelEdition.rules.areAwardsDistributed, + ); }); it("fails when an unrecognized edition has malformed base fields", () => { @@ -126,6 +138,7 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { // startTime missing, endTime missing subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }, }; @@ -182,6 +195,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { endTime: 2000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }, referrers: [], aggregatedMetrics: { @@ -192,7 +206,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { minFinalScoreToQualify: 0, }, pageContext: emptyPageContext, - status: ReferralProgramStatuses.Scheduled, + status: ReferralProgramEditionStatuses.Scheduled, accurateAsOf: 500000, }; @@ -207,6 +221,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { endTime: 2000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }, referrers: [], aggregatedMetrics: { @@ -216,7 +231,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { awardPoolRemaining: parseUsdc("2000"), }, pageContext: emptyPageContext, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -224,7 +239,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { const result = schema.parse(pieSplitLeaderboardPage); expect(result.awardModel).toBe(ReferralProgramAwardModels.PieSplit); - expect(result.status).toBe(ReferralProgramStatuses.Scheduled); + expect(result.status).toBe(ReferralProgramEditionStatuses.Scheduled); expect(result.accurateAsOf).toBe(500000); expect(result.pageContext.page).toBe(1); }); @@ -233,7 +248,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { const result = schema.parse(revShareLimitLeaderboardPage); expect(result.awardModel).toBe(ReferralProgramAwardModels.RevShareLimit); - expect(result.status).toBe(ReferralProgramStatuses.Active); + expect(result.status).toBe(ReferralProgramEditionStatuses.Active); expect(result.accurateAsOf).toBe(1500000); }); @@ -241,7 +256,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { const input = { awardModel: "future-model", pageContext: emptyPageContext, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1000000, someNewField: "extra-data", }; @@ -250,7 +265,7 @@ describe("makeReferrerLeaderboardPageSchema", () => { expect(result.awardModel).toBe(ReferralProgramAwardModels.Unrecognized); expect((result as ReferrerLeaderboardPageUnrecognized).originalAwardModel).toBe("future-model"); - expect(result.status).toBe(ReferralProgramStatuses.Active); + expect(result.status).toBe(ReferralProgramEditionStatuses.Active); expect(result.accurateAsOf).toBe(1000000); expect(result.pageContext.page).toBe(1); }); @@ -277,6 +292,127 @@ describe("makeReferrerLeaderboardPageSchema", () => { }); }); +describe("makeReferralProgramEditionSummarySchema", () => { + const schema = makeReferralProgramEditionSummarySchema(); + + const subregistryId = { + chainId: 1, + address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + }; + + const pieSplitSummary = { + awardModel: ReferralProgramAwardModels.PieSplit, + slug: "2025-12", + displayName: "December 2025", + status: ReferralProgramEditionStatuses.Active, + rules: { + awardModel: ReferralProgramAwardModels.PieSplit, + totalAwardPoolValue: parseUsdc("1000"), + maxQualifiedReferrers: 100, + startTime: 1000000, + endTime: 2000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, + }, + }; + + const revShareLimitSummary = { + awardModel: ReferralProgramAwardModels.RevShareLimit, + slug: "2026-01", + displayName: "January 2026", + status: ReferralProgramEditionStatuses.Active, + rules: { + awardModel: ReferralProgramAwardModels.RevShareLimit, + totalAwardPoolValue: parseUsdc("2000"), + minQualifiedRevenueContribution: parseUsdc("10"), + qualifiedRevenueShare: 0.5, + startTime: 1000000, + endTime: 2000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, + }, + awardPoolRemaining: parseUsdc("2000"), + }; + + it("parses a known pie-split edition summary correctly", () => { + const result = schema.parse(pieSplitSummary); + + expect(result.awardModel).toBe(ReferralProgramAwardModels.PieSplit); + expect(result.slug).toBe("2025-12"); + expect(result.status).toBe(ReferralProgramEditionStatuses.Active); + expect(result.rules.awardModel).toBe(ReferralProgramAwardModels.PieSplit); + }); + + it("parses a known rev-share-limit edition summary correctly, including awardPoolRemaining", () => { + const result = schema.parse(revShareLimitSummary); + + expect(result.awardModel).toBe(ReferralProgramAwardModels.RevShareLimit); + if (result.awardModel !== ReferralProgramAwardModels.RevShareLimit) throw new Error(); + expect(result.awardPoolRemaining.amount).toBe(parseUsdc("2000").amount); + expect(result.status).toBe(ReferralProgramEditionStatuses.Active); + }); + + it("parses Exhausted status on a rev-share-limit edition summary", () => { + const result = schema.parse({ + ...revShareLimitSummary, + status: ReferralProgramEditionStatuses.Exhausted, + awardPoolRemaining: parseUsdc("0"), + }); + + expect(result.status).toBe(ReferralProgramEditionStatuses.Exhausted); + }); + + it("wraps an unknown awardModel as ReferralProgramEditionSummaryUnrecognized", () => { + const input = { + awardModel: "future-model", + slug: "2026-03", + displayName: "March 2026", + status: ReferralProgramEditionStatuses.Scheduled, + rules: { + awardModel: "future-model", + startTime: 2000000, + endTime: 3000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, + someNewField: "extra-data", + }, + }; + + const result = schema.parse(input); + + expect(result.awardModel).toBe(ReferralProgramAwardModels.Unrecognized); + expect((result as ReferralProgramEditionSummaryUnrecognized).rules.originalAwardModel).toBe( + "future-model", + ); + expect(result.slug).toBe("2026-03"); + expect(result.status).toBe(ReferralProgramEditionStatuses.Scheduled); + }); + + it("fails when a known awardModel has invalid fields", () => { + const invalid = { + ...pieSplitSummary, + rules: { + ...pieSplitSummary.rules, + endTime: 500000, // endTime < startTime → refine violation + }, + }; + + expect(() => schema.parse(invalid)).toThrow(); + }); + + it("fails when an unknown awardModel is missing required base fields", () => { + const input = { + awardModel: "future-model", + // slug, displayName, status, rules all missing + }; + + expect(() => schema.parse(input)).toThrow(); + }); +}); + describe("makeReferrerEditionMetricsSchema", () => { const schema = makeReferrerEditionMetricsSchema(); @@ -293,6 +429,7 @@ describe("makeReferrerEditionMetricsSchema", () => { endTime: 2000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }; const pieSplitAggregatedMetrics = { @@ -322,7 +459,7 @@ describe("makeReferrerEditionMetricsSchema", () => { awardPoolApproxValue: parseUsdc("500"), }, aggregatedMetrics: pieSplitAggregatedMetrics, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -352,7 +489,7 @@ describe("makeReferrerEditionMetricsSchema", () => { awardPoolApproxValue: parseUsdc("0"), }, aggregatedMetrics: pieSplitAggregatedMetrics, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -376,6 +513,7 @@ describe("makeReferrerEditionMetricsSchema", () => { endTime: 2000000, subregistryId, rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, }, referrer: { referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", @@ -396,7 +534,7 @@ describe("makeReferrerEditionMetricsSchema", () => { grandTotalRevenueContribution: parseEth("300"), awardPoolRemaining: parseUsdc("1800"), }, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -429,7 +567,7 @@ describe("makeReferrerEditionMetricsSchema", () => { awardPoolApproxValue: parseUsdc("500"), }, aggregatedMetrics: pieSplitAggregatedMetrics, - status: ReferralProgramStatuses.Active, + status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 1f6a9d677..83cdb042e 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -12,29 +12,34 @@ import z from "zod/v4"; import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; import { + makeReferralProgramEditionSummaryPieSplitSchema, makeReferralProgramRulesPieSplitSchema, makeReferrerEditionMetricsPieSplitSchema, makeReferrerLeaderboardPagePieSplitSchema, } from "../award-models/pie-split/api/zod-schemas"; import { + makeReferralProgramEditionSummaryRevShareLimitSchema, makeReferralProgramRulesRevShareLimitSchema, makeReferrerEditionMetricsRevShareLimitSchema, makeReferrerLeaderboardPageRevShareLimitSchema, } from "../award-models/rev-share-limit/api/zod-schemas"; import { + makeBaseReferralProgramEditionSummarySchema, makeBaseReferralProgramRulesSchema, makeBaseReferrerLeaderboardPageSchema, } from "../award-models/shared/api/zod-schemas"; import type { ReferrerEditionMetricsUnrecognized } from "../award-models/shared/edition-metrics"; +import type { ReferralProgramEditionSummaryUnrecognized } from "../award-models/shared/edition-summary"; import type { ReferrerLeaderboardPageUnrecognized } from "../award-models/shared/leaderboard-page"; import type { ReferralProgramRulesUnrecognized } from "../award-models/shared/rules"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import type { ReferralProgramEditionConfig } from "../edition"; import type { ReferrerEditionMetrics } from "../edition-metrics"; +import type { ReferralProgramEditionSummary } from "../edition-summary"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; import { MAX_EDITIONS_PER_REQUEST, - ReferralProgramEditionConfigSetResponseCodes, + ReferralProgramEditionSummariesResponseCodes, ReferrerLeaderboardPageResponseCodes, ReferrerMetricsEditionsResponseCodes, } from "./types"; @@ -406,45 +411,111 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( }; /** - * Schema for {@link ReferralProgramEditionConfigSetData}. + * Schema for {@link ReferralProgramEditionSummary}. + * + * Forward-compatible — peeks at `awardModel` before committing to full validation: + * - Known award models are fully validated with the model-specific schema. + * - Unknown award models are wrapped as {@link ReferralProgramEditionSummaryUnrecognized}. + */ +export const makeReferralProgramEditionSummarySchema = ( + valueLabel: string = "ReferralProgramEditionSummary", +) => { + const knownAwardModels = Object.values(ReferralProgramAwardModels).filter( + (m) => m !== ReferralProgramAwardModels.Unrecognized, + ) as string[]; + + // Loose schema used only to peek at awardModel before full validation. + const looseSchema = z.object({ awardModel: z.string() }).passthrough(); + + // Schema for known award models — dispatch handled automatically by discriminatedUnion. + const knownSchema = z.discriminatedUnion("awardModel", [ + makeReferralProgramEditionSummaryPieSplitSchema(valueLabel), + makeReferralProgramEditionSummaryRevShareLimitSchema(valueLabel), + ]); + + // Base schema for fields present on all edition summary variants (used for Unrecognized). + const baseSchema = makeBaseReferralProgramEditionSummarySchema(valueLabel); + + return looseSchema.transform((data, ctx): ReferralProgramEditionSummary => { + if (knownAwardModels.includes(data.awardModel)) { + const parsed = knownSchema.safeParse(data); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: issue.path as PropertyKey[], + message: issue.message, + }); + } + return z.NEVER; + } + return parsed.data; + } + + // Unknown awardModel — preserve as ReferralProgramEditionSummaryUnrecognized using base fields. + const parsed = baseSchema.safeParse(data); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: issue.path as PropertyKey[], + message: issue.message, + }); + } + return z.NEVER; + } + return { + ...parsed.data, + awardModel: ReferralProgramAwardModels.Unrecognized, + rules: { + ...parsed.data.rules, + awardModel: ReferralProgramAwardModels.Unrecognized, + originalAwardModel: data.awardModel, + }, + } satisfies ReferralProgramEditionSummaryUnrecognized; + }); +}; + +/** + * Schema for {@link ReferralProgramEditionSummariesData}. */ -export const makeReferralProgramEditionConfigSetDataSchema = ( - valueLabel: string = "ReferralProgramEditionConfigSetData", +export const makeReferralProgramEditionSummariesDataSchema = ( + valueLabel: string = "ReferralProgramEditionSummariesData", ) => z.object({ - editions: makeReferralProgramEditionConfigSetArraySchema(`${valueLabel}.editions`), + editions: z.array(makeReferralProgramEditionSummarySchema(`${valueLabel}.editions[edition]`)), }); /** - * Schema for {@link ReferralProgramEditionConfigSetResponseOk}. + * Schema for {@link ReferralProgramEditionSummariesResponseOk}. */ -export const makeReferralProgramEditionConfigSetResponseOkSchema = ( - valueLabel: string = "ReferralProgramEditionConfigSetResponseOk", +export const makeReferralProgramEditionSummariesResponseOkSchema = ( + valueLabel: string = "ReferralProgramEditionSummariesResponseOk", ) => z.object({ - responseCode: z.literal(ReferralProgramEditionConfigSetResponseCodes.Ok), - data: makeReferralProgramEditionConfigSetDataSchema(`${valueLabel}.data`), + responseCode: z.literal(ReferralProgramEditionSummariesResponseCodes.Ok), + data: makeReferralProgramEditionSummariesDataSchema(`${valueLabel}.data`), }); /** - * Schema for {@link ReferralProgramEditionConfigSetResponseError}. + * Schema for {@link ReferralProgramEditionSummariesResponseError}. */ -export const makeReferralProgramEditionConfigSetResponseErrorSchema = ( - _valueLabel: string = "ReferralProgramEditionConfigSetResponseError", +export const makeReferralProgramEditionSummariesResponseErrorSchema = ( + _valueLabel: string = "ReferralProgramEditionSummariesResponseError", ) => z.object({ - responseCode: z.literal(ReferralProgramEditionConfigSetResponseCodes.Error), + responseCode: z.literal(ReferralProgramEditionSummariesResponseCodes.Error), error: z.string(), errorMessage: z.string(), }); /** - * Schema for {@link ReferralProgramEditionConfigSetResponse}. + * Schema for {@link ReferralProgramEditionSummariesResponse}. */ -export const makeReferralProgramEditionConfigSetResponseSchema = ( - valueLabel: string = "ReferralProgramEditionConfigSetResponse", +export const makeReferralProgramEditionSummariesResponseSchema = ( + valueLabel: string = "ReferralProgramEditionSummariesResponse", ) => z.discriminatedUnion("responseCode", [ - makeReferralProgramEditionConfigSetResponseOkSchema(valueLabel), - makeReferralProgramEditionConfigSetResponseErrorSchema(valueLabel), + makeReferralProgramEditionSummariesResponseOkSchema(valueLabel), + makeReferralProgramEditionSummariesResponseErrorSchema(valueLabel), ]); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts index 916c9b720..0d53b2811 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts @@ -7,12 +7,14 @@ import type { ReferrerEditionMetricsRankedPieSplit, ReferrerEditionMetricsUnrankedPieSplit, } from "../edition-metrics"; +import type { ReferralProgramEditionSummaryPieSplit } from "../edition-summary"; import type { ReferrerLeaderboardPagePieSplit } from "../leaderboard-page"; import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "../metrics"; import type { ReferralProgramRulesPieSplit } from "../rules"; import type { SerializedAggregatedReferrerMetricsPieSplit, SerializedAwardedReferrerMetricsPieSplit, + SerializedReferralProgramEditionSummaryPieSplit, SerializedReferralProgramRulesPieSplit, SerializedReferrerEditionMetricsPieSplit, SerializedReferrerEditionMetricsRankedPieSplit, @@ -35,6 +37,7 @@ export function serializeReferralProgramRulesPieSplit( endTime: rules.endTime, subregistryId: rules.subregistryId, rulesUrl: rules.rulesUrl.toString(), + areAwardsDistributed: rules.areAwardsDistributed, }; } @@ -163,3 +166,18 @@ export function serializeReferrerLeaderboardPagePieSplit( accurateAsOf: page.accurateAsOf, }; } + +/** + * Serializes a {@link ReferralProgramEditionSummaryPieSplit} object. + */ +export function serializeReferralProgramEditionSummaryPieSplit( + summary: ReferralProgramEditionSummaryPieSplit, +): SerializedReferralProgramEditionSummaryPieSplit { + return { + awardModel: summary.awardModel, + slug: summary.slug, + displayName: summary.displayName, + status: summary.status, + rules: serializeReferralProgramRulesPieSplit(summary.rules), + }; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts index 89c84d83b..4f354b8ae 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts @@ -6,6 +6,7 @@ import type { ReferrerEditionMetricsRankedPieSplit, ReferrerEditionMetricsUnrankedPieSplit, } from "../edition-metrics"; +import type { ReferralProgramEditionSummaryPieSplit } from "../edition-summary"; import type { ReferrerLeaderboardPagePieSplit } from "../leaderboard-page"; import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "../metrics"; import type { ReferralProgramRulesPieSplit } from "../rules"; @@ -87,3 +88,11 @@ export interface SerializedReferrerEditionMetricsUnrankedPieSplit export type SerializedReferrerEditionMetricsPieSplit = | SerializedReferrerEditionMetricsRankedPieSplit | SerializedReferrerEditionMetricsUnrankedPieSplit; + +/** + * Serialized representation of {@link ReferralProgramEditionSummaryPieSplit}. + */ +export interface SerializedReferralProgramEditionSummaryPieSplit + extends Omit { + rules: SerializedReferralProgramRulesPieSplit; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts index 11314d742..2bddc1be7 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts @@ -12,6 +12,7 @@ import { } from "@ensnode/ensnode-sdk/internal"; import { + makeBaseReferralProgramEditionSummarySchema, makeBaseReferralProgramRulesSchema, makeBaseReferrerLeaderboardPageSchema, makeReferralProgramStatusSchema, @@ -163,6 +164,22 @@ export const makeReferrerEditionMetricsPieSplitSchema = ( makeReferrerEditionMetricsUnrankedPieSplitSchema(valueLabel), ]); +/** + * Schema for {@link ReferralProgramEditionSummaryPieSplit}. + */ +export const makeReferralProgramEditionSummaryPieSplitSchema = ( + valueLabel: string = "ReferralProgramEditionSummaryPieSplit", +) => + makeBaseReferralProgramEditionSummarySchema(valueLabel) + .safeExtend({ + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); + /** * Schema for {@link ReferrerLeaderboardPagePieSplit}. */ diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts index f0e2007fb..d984cf3f7 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts @@ -1,8 +1,8 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; -import type { ReferralProgramStatusId } from "../../status"; import type { ReferrerEditionMetricsTypeIds } from "../shared/edition-metrics"; import type { ReferralProgramAwardModels } from "../shared/rules"; +import type { ReferralProgramEditionStatusId } from "../shared/status"; import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "./metrics"; import type { ReferralProgramRulesPieSplit } from "./rules"; @@ -50,10 +50,10 @@ export interface ReferrerEditionMetricsRankedPieSplit { aggregatedMetrics: AggregatedReferrerMetricsPieSplit; /** - * The status of the referral program ("Scheduled", "Active", or "Closed") + * The status of the referral program edition * calculated based on the program's timing relative to {@link accurateAsOf}. */ - status: ReferralProgramStatusId; + status: ReferralProgramEditionStatusId; /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRankedPieSplit} was accurate as of. @@ -103,10 +103,10 @@ export interface ReferrerEditionMetricsUnrankedPieSplit { aggregatedMetrics: AggregatedReferrerMetricsPieSplit; /** - * The status of the referral program ("Scheduled", "Active", or "Closed") + * The status of the referral program edition * calculated based on the program's timing relative to {@link accurateAsOf}. */ - status: ReferralProgramStatusId; + status: ReferralProgramEditionStatusId; /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnrankedPieSplit} was accurate as of. diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/edition-summary.ts b/packages/ens-referrals/src/v1/award-models/pie-split/edition-summary.ts new file mode 100644 index 000000000..e83f0ee97 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/edition-summary.ts @@ -0,0 +1,50 @@ +import type { ReferralProgramEditionSlug } from "../../edition"; +import type { BaseReferralProgramEditionSummary } from "../shared/edition-summary"; +import { validateBaseReferralProgramEditionSummary } from "../shared/edition-summary"; +import type { ReferralProgramAwardModels } from "../shared/rules"; +import type { ReferrerLeaderboardPieSplit } from "./leaderboard"; +import type { ReferralProgramRulesPieSplit } from "./rules"; +import { validateReferralProgramRulesPieSplit } from "./rules"; +import { calcReferralProgramEditionStatusPieSplit } from "./status"; + +/** + * Edition summary for a `pie-split` referral program edition. + */ +export interface ReferralProgramEditionSummaryPieSplit extends BaseReferralProgramEditionSummary { + /** + * Discriminant — always `"pie-split"`. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.PieSplit}). + */ + awardModel: typeof ReferralProgramAwardModels.PieSplit; + + /** + * The pie-split rules for this edition. + */ + rules: ReferralProgramRulesPieSplit; +} + +export const validateEditionSummaryPieSplit = ( + summary: ReferralProgramEditionSummaryPieSplit, +): void => { + validateReferralProgramRulesPieSplit(summary.rules); + validateBaseReferralProgramEditionSummary(summary); +}; + +/** + * Build a {@link ReferralProgramEditionSummaryPieSplit} from a pie-split edition config and the + * edition's leaderboard. + */ +export function buildEditionSummaryPieSplit( + slug: ReferralProgramEditionSlug, + displayName: string, + rules: ReferralProgramRulesPieSplit, + leaderboard: ReferrerLeaderboardPieSplit, +): ReferralProgramEditionSummaryPieSplit { + const status = calcReferralProgramEditionStatusPieSplit(rules, leaderboard.accurateAsOf); + const result = { awardModel: rules.awardModel, slug, displayName, status, rules }; + + validateEditionSummaryPieSplit(result); + + return result; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts index dee11f4eb..c5cbb8b7c 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts @@ -1,4 +1,3 @@ -import { calcReferralProgramStatus } from "../../status"; import { type BaseReferrerLeaderboardPage, type ReferrerLeaderboardPageContext, @@ -9,6 +8,7 @@ import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; import type { ReferrerLeaderboardPieSplit } from "./leaderboard"; import type { AwardedReferrerMetricsPieSplit } from "./metrics"; import type { ReferralProgramRulesPieSplit } from "./rules"; +import { calcReferralProgramEditionStatusPieSplit } from "./status"; /** * A page of referrers from the pie-split referrer leaderboard. @@ -46,7 +46,10 @@ export function buildLeaderboardPagePieSplit( pageContext: ReferrerLeaderboardPageContext, leaderboard: ReferrerLeaderboardPieSplit, ): ReferrerLeaderboardPagePieSplit { - const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); + const status = calcReferralProgramEditionStatusPieSplit( + leaderboard.rules, + leaderboard.accurateAsOf, + ); return { awardModel: leaderboard.awardModel, rules: leaderboard.rules, diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts index b55a3e634..797099bd0 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts @@ -49,6 +49,7 @@ export const buildReferralProgramRulesPieSplit = ( endTime: UnixTimestamp, subregistryId: AccountId, rulesUrl: URL, + areAwardsDistributed: boolean, ): ReferralProgramRulesPieSplit => { const result = { awardModel: ReferralProgramAwardModels.PieSplit, @@ -58,6 +59,7 @@ export const buildReferralProgramRulesPieSplit = ( endTime, subregistryId, rulesUrl, + areAwardsDistributed, } satisfies ReferralProgramRulesPieSplit; validateReferralProgramRulesPieSplit(result); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/status.ts b/packages/ens-referrals/src/v1/award-models/pie-split/status.ts new file mode 100644 index 000000000..aaadd8f37 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/status.ts @@ -0,0 +1,21 @@ +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import { + calcBaseReferralProgramEditionStatus, + type ReferralProgramEditionStatusId, +} from "../shared/status"; +import type { ReferralProgramRulesPieSplit } from "./rules"; + +/** + * Calculate the status of a `pie-split` referral program. + * + * Delegates entirely to {@link calcBaseReferralProgramEditionStatus} — pie-split has no additional + * runtime conditions that affect status beyond the time-based lifecycle. + * + * @param rules - The pie-split rules for the edition. + * @param now - Current date in {@link UnixTimestamp} format. + */ +export const calcReferralProgramEditionStatusPieSplit = ( + rules: ReferralProgramRulesPieSplit, + now: UnixTimestamp, +): ReferralProgramEditionStatusId => calcBaseReferralProgramEditionStatus(rules, now); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts index 61800f7f3..3dfa697e3 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts @@ -7,6 +7,7 @@ import type { ReferrerEditionMetricsRevShareLimit, ReferrerEditionMetricsUnrankedRevShareLimit, } from "../edition-metrics"; +import type { ReferralProgramEditionSummaryRevShareLimit } from "../edition-summary"; import type { ReferrerLeaderboardPageRevShareLimit } from "../leaderboard-page"; import type { AwardedReferrerMetricsRevShareLimit, @@ -16,6 +17,7 @@ import type { ReferralProgramRulesRevShareLimit } from "../rules"; import type { SerializedAggregatedReferrerMetricsRevShareLimit, SerializedAwardedReferrerMetricsRevShareLimit, + SerializedReferralProgramEditionSummaryRevShareLimit, SerializedReferralProgramRulesRevShareLimit, SerializedReferrerEditionMetricsRankedRevShareLimit, SerializedReferrerEditionMetricsRevShareLimit, @@ -39,6 +41,7 @@ export function serializeReferralProgramRulesRevShareLimit( endTime: rules.endTime, subregistryId: rules.subregistryId, rulesUrl: rules.rulesUrl.toString(), + areAwardsDistributed: rules.areAwardsDistributed, disqualifications: rules.disqualifications, }; } @@ -169,3 +172,19 @@ export function serializeReferrerLeaderboardPageRevShareLimit( accurateAsOf: page.accurateAsOf, }; } + +/** + * Serializes a {@link ReferralProgramEditionSummaryRevShareLimit} object. + */ +export function serializeReferralProgramEditionSummaryRevShareLimit( + summary: ReferralProgramEditionSummaryRevShareLimit, +): SerializedReferralProgramEditionSummaryRevShareLimit { + return { + awardModel: summary.awardModel, + slug: summary.slug, + displayName: summary.displayName, + status: summary.status, + rules: serializeReferralProgramRulesRevShareLimit(summary.rules), + awardPoolRemaining: serializePriceUsdc(summary.awardPoolRemaining), + }; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts index 5901dab52..96807091c 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts @@ -6,6 +6,7 @@ import type { ReferrerEditionMetricsRevShareLimit, ReferrerEditionMetricsUnrankedRevShareLimit, } from "../edition-metrics"; +import type { ReferralProgramEditionSummaryRevShareLimit } from "../edition-summary"; import type { ReferrerLeaderboardPageRevShareLimit } from "../leaderboard-page"; import type { AwardedReferrerMetricsRevShareLimit, @@ -114,3 +115,12 @@ export interface SerializedReferrerEditionMetricsUnrankedRevShareLimit export type SerializedReferrerEditionMetricsRevShareLimit = | SerializedReferrerEditionMetricsRankedRevShareLimit | SerializedReferrerEditionMetricsUnrankedRevShareLimit; + +/** + * Serialized representation of {@link ReferralProgramEditionSummaryRevShareLimit}. + */ +export interface SerializedReferralProgramEditionSummaryRevShareLimit + extends Omit { + rules: SerializedReferralProgramRulesRevShareLimit; + awardPoolRemaining: SerializedPriceUsdc; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts index d98acfc12..9578357b9 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -13,6 +13,7 @@ import { import { normalizeAddress } from "../../../address"; import { + makeBaseReferralProgramEditionSummarySchema, makeBaseReferralProgramRulesSchema, makeBaseReferrerLeaderboardPageSchema, makeReferralProgramStatusSchema, @@ -216,6 +217,23 @@ export const makeReferrerEditionMetricsRevShareLimitSchema = ( makeReferrerEditionMetricsUnrankedRevShareLimitSchema(valueLabel), ]); +/** + * Schema for {@link ReferralProgramEditionSummaryRevShareLimit}. + */ +export const makeReferralProgramEditionSummaryRevShareLimitSchema = ( + valueLabel: string = "ReferralProgramEditionSummaryRevShareLimit", +) => + makeBaseReferralProgramEditionSummarySchema(valueLabel) + .safeExtend({ + awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + awardPoolRemaining: makePriceUsdcSchema(`${valueLabel}.awardPoolRemaining`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); + /** * Schema for {@link ReferrerLeaderboardPageRevShareLimit}. */ diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts index f42cb9ff5..0e3d74af7 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts @@ -1,8 +1,8 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; -import type { ReferralProgramStatusId } from "../../status"; import type { ReferrerEditionMetricsTypeIds } from "../shared/edition-metrics"; import type { ReferralProgramAwardModels } from "../shared/rules"; +import type { ReferralProgramEditionStatusId } from "../shared/status"; import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import type { AwardedReferrerMetricsRevShareLimit, @@ -49,10 +49,10 @@ export interface ReferrerEditionMetricsRankedRevShareLimit { aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; /** - * The status of the referral program ("Scheduled", "Active", or "Closed") + * The status of the referral program edition * calculated based on the program's timing relative to {@link accurateAsOf}. */ - status: ReferralProgramStatusId; + status: ReferralProgramEditionStatusId; /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRankedRevShareLimit} was accurate as of. @@ -98,10 +98,10 @@ export interface ReferrerEditionMetricsUnrankedRevShareLimit { aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; /** - * The status of the referral program ("Scheduled", "Active", or "Closed") + * The status of the referral program edition * calculated based on the program's timing relative to {@link accurateAsOf}. */ - status: ReferralProgramStatusId; + status: ReferralProgramEditionStatusId; /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnrankedRevShareLimit} was accurate as of. diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-summary.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-summary.ts new file mode 100644 index 000000000..fd5b9ffd9 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-summary.ts @@ -0,0 +1,81 @@ +import type { PriceUsdc } from "@ensnode/ensnode-sdk"; +import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import type { ReferralProgramEditionSlug } from "../../edition"; +import type { BaseReferralProgramEditionSummary } from "../shared/edition-summary"; +import { validateBaseReferralProgramEditionSummary } from "../shared/edition-summary"; +import type { ReferralProgramAwardModels } from "../shared/rules"; +import type { ReferrerLeaderboardRevShareLimit } from "./leaderboard"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; +import { validateReferralProgramRulesRevShareLimit } from "./rules"; +import { calcReferralProgramEditionStatusRevShareLimit } from "./status"; + +/** + * Edition summary for a `rev-share-limit` referral program edition. + * + * Includes `awardPoolRemaining` so consumers can display pool exhaustion state + * without needing to fetch the full leaderboard. + */ +export interface ReferralProgramEditionSummaryRevShareLimit + extends BaseReferralProgramEditionSummary { + /** + * Discriminant — always `"rev-share-limit"`. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.RevShareLimit}). + */ + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + + /** + * The rev-share-limit rules for this edition. + */ + rules: ReferralProgramRulesRevShareLimit; + + /** + * The remaining award pool after sequential race processing. + * + * When `0n`, the edition's status will be {@link ReferralProgramEditionStatuses.Exhausted} + * if the edition is still within its active window. + */ + awardPoolRemaining: PriceUsdc; +} + +export const validateEditionSummaryRevShareLimit = ( + summary: ReferralProgramEditionSummaryRevShareLimit, +): void => { + validateReferralProgramRulesRevShareLimit(summary.rules); + + makePriceUsdcSchema("ReferralProgramEditionSummaryRevShareLimit.awardPoolRemaining").parse( + summary.awardPoolRemaining, + ); + + validateBaseReferralProgramEditionSummary(summary); +}; + +/** + * Build a {@link ReferralProgramEditionSummaryRevShareLimit} from a rev-share-limit edition + * config and the edition's leaderboard. + */ +export function buildEditionSummaryRevShareLimit( + slug: ReferralProgramEditionSlug, + displayName: string, + rules: ReferralProgramRulesRevShareLimit, + leaderboard: ReferrerLeaderboardRevShareLimit, +): ReferralProgramEditionSummaryRevShareLimit { + const status = calcReferralProgramEditionStatusRevShareLimit( + rules, + leaderboard.accurateAsOf, + leaderboard.aggregatedMetrics, + ); + const result = { + awardModel: rules.awardModel, + slug, + displayName, + status, + rules, + awardPoolRemaining: leaderboard.aggregatedMetrics.awardPoolRemaining, + }; + + validateEditionSummaryRevShareLimit(result); + + return result; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts index 58a86bcb2..9627ce298 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts @@ -1,4 +1,3 @@ -import { calcReferralProgramStatus } from "../../status"; import { type BaseReferrerLeaderboardPage, type ReferrerLeaderboardPageContext, @@ -9,6 +8,7 @@ import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import type { ReferrerLeaderboardRevShareLimit } from "./leaderboard"; import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; import type { ReferralProgramRulesRevShareLimit } from "./rules"; +import { calcReferralProgramEditionStatusRevShareLimit } from "./status"; /** * A page of referrers from the rev-share-limit referrer leaderboard. @@ -46,7 +46,11 @@ export function buildLeaderboardPageRevShareLimit( pageContext: ReferrerLeaderboardPageContext, leaderboard: ReferrerLeaderboardRevShareLimit, ): ReferrerLeaderboardPageRevShareLimit { - const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); + const status = calcReferralProgramEditionStatusRevShareLimit( + leaderboard.rules, + leaderboard.accurateAsOf, + leaderboard.aggregatedMetrics, + ); return { awardModel: leaderboard.awardModel, rules: leaderboard.rules, diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts index 1bc611639..79d92fcd6 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; import { SECONDS_PER_YEAR } from "../../time"; +import { buildReferrerLeaderboardPageContext } from "../shared/leaderboard-page"; +import { ReferralProgramEditionStatuses } from "../shared/status"; import { buildReferrerLeaderboardRevShareLimit } from "./leaderboard"; +import { buildLeaderboardPageRevShareLimit } from "./leaderboard-page"; import type { ReferralEvent } from "./referral-event"; import type { ReferralProgramEditionDisqualification } from "./rules"; import { buildReferralProgramRulesRevShareLimit } from "./rules"; @@ -54,6 +57,7 @@ function buildTestRules( parseTimestamp("2026-12-31T23:59:59Z"), { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, new URL("https://example.com/rules"), + false, disqualifications, ); } @@ -385,6 +389,24 @@ describe("buildReferrerLeaderboardRevShareLimit", () => { }); }); + describe("Edition status via leaderboard page", () => { + it("page status is Exhausted when pool is fully consumed within the active window", () => { + // Pool = $2.50 — just enough for one qualifying referrer + // ADDR_A qualifies at t=1000 (1 year), claims the full pool → awardPoolRemaining = $0 + // accurateAsOf is within the active window (2026-06-01), so status must be Exhausted + const rules = buildTestRules(parseUsdc("2.5")); + const events = [makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR)]; + + const leaderboard = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + expect(leaderboard.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); + + const pageContext = buildReferrerLeaderboardPageContext({ page: 1 }, leaderboard); + const page = buildLeaderboardPageRevShareLimit(pageContext, leaderboard); + + expect(page.status).toBe(ReferralProgramEditionStatuses.Exhausted); + }); + }); + describe("Aggregated metrics", () => { it("correctly sums grandTotalReferrals and grandTotalIncrementalDuration", () => { const rules = buildTestRules(); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts index cafd25603..9f7501488 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts @@ -127,6 +127,7 @@ export const buildReferralProgramRulesRevShareLimit = ( endTime: UnixTimestamp, subregistryId: AccountId, rulesUrl: URL, + areAwardsDistributed: boolean, disqualifications: ReferralProgramEditionDisqualification[] = [], ): ReferralProgramRulesRevShareLimit => { const result = { @@ -138,6 +139,7 @@ export const buildReferralProgramRulesRevShareLimit = ( endTime, subregistryId, rulesUrl, + areAwardsDistributed, disqualifications, } satisfies ReferralProgramRulesRevShareLimit; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/status.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/status.ts new file mode 100644 index 000000000..625582a7f --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/status.ts @@ -0,0 +1,34 @@ +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import { + calcBaseReferralProgramEditionStatus, + ReferralProgramEditionStatuses, + type ReferralProgramEditionStatusId, +} from "../shared/status"; +import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * Calculate the status of a `rev-share-limit` referral program. + * + * Returns `Exhausted` when the program is `Active` but its award pool has been fully consumed + * (`awardPoolRemaining.amount === 0n`). Otherwise delegates to {@link calcBaseReferralProgramEditionStatus}. + * + * @param rules - The rev-share-limit rules for the edition. + * @param now - Current date in {@link UnixTimestamp} format. + * @param aggregatedMetrics - The aggregated leaderboard metrics, used to check `awardPoolRemaining`. + */ +export const calcReferralProgramEditionStatusRevShareLimit = ( + rules: ReferralProgramRulesRevShareLimit, + now: UnixTimestamp, + aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit, +): ReferralProgramEditionStatusId => { + const base = calcBaseReferralProgramEditionStatus(rules, now); + if ( + base === ReferralProgramEditionStatuses.Active && + aggregatedMetrics.awardPoolRemaining.amount === 0n + ) { + return ReferralProgramEditionStatuses.Exhausted; + } + return base; +}; diff --git a/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts index 522a6f861..6fc2b95ad 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts @@ -8,8 +8,8 @@ import { makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; -import { ReferralProgramStatuses } from "../../../status"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; +import { ReferralProgramEditionStatuses } from "../status"; /** * Loose base schema for {@link BaseReferralProgramRules}. @@ -25,6 +25,7 @@ export const makeBaseReferralProgramRulesSchema = (valueLabel: string) => endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), + areAwardsDistributed: z.boolean(), }) .refine((data) => data.endTime >= data.startTime, { message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, @@ -53,10 +54,24 @@ export const makeReferrerLeaderboardPageContextSchema = ( /** * Schema for referral program status field. - * Validates that the status is one of: "Scheduled", "Active", or "Closed". + * Validates that the status is one of the values in {@link ReferralProgramEditionStatuses}. */ export const makeReferralProgramStatusSchema = (_valueLabel: string = "status") => - z.enum(ReferralProgramStatuses); + z.enum(ReferralProgramEditionStatuses); + +/** + * Loose base schema for {@link BaseReferralProgramEditionSummary}. + * + * Accepts any string for `rules.awardModel` to support forward-compatible parsing. + */ +export const makeBaseReferralProgramEditionSummarySchema = (valueLabel: string) => + z.object({ + awardModel: z.string(), + slug: z.string().min(1, `${valueLabel}.slug must not be empty`), + displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + rules: makeBaseReferralProgramRulesSchema(`${valueLabel}.rules`), + }); /** * Loose base schema for {@link BaseReferrerLeaderboardPage}. diff --git a/packages/ens-referrals/src/v1/award-models/shared/edition-summary.ts b/packages/ens-referrals/src/v1/award-models/shared/edition-summary.ts new file mode 100644 index 000000000..873122656 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/edition-summary.ts @@ -0,0 +1,84 @@ +import { + REFERRAL_PROGRAM_EDITION_SLUG_PATTERN, + type ReferralProgramEditionSlug, +} from "../../edition"; +import type { + BaseReferralProgramRules, + ReferralProgramAwardModel, + ReferralProgramAwardModels, + ReferralProgramRulesUnrecognized, +} from "./rules"; +import type { ReferralProgramEditionStatusId } from "./status"; + +/** + * Base fields shared by all edition summary variants. + */ +export interface BaseReferralProgramEditionSummary { + /** + * Discriminant: identifies the award model for this edition. + * + * @invariant Always equals `rules.awardModel`. + */ + awardModel: ReferralProgramAwardModel; + + /** + * Unique slug identifier for the edition. + */ + slug: ReferralProgramEditionSlug; + + /** + * Human-readable display name for the edition. + */ + displayName: string; + + /** + * The current runtime status of the edition. + */ + status: ReferralProgramEditionStatusId; + + /** + * The rules for this edition. Per-model subtypes narrow this to their specific rules type. + */ + rules: BaseReferralProgramRules; +} + +/** + * Edition summary for an edition whose `awardModel` is not recognized by this client version. + * + * @remarks + * This is a **client-side forward-compatibility** type only. It is never serialized or produced + * by the server. When the server sends a new award model, older clients preserve the edition + * summary rather than crashing, and downstream code should handle it gracefully. + */ +export interface ReferralProgramEditionSummaryUnrecognized + extends BaseReferralProgramEditionSummary { + /** + * Discriminant — always `"unrecognized"`. + */ + awardModel: typeof ReferralProgramAwardModels.Unrecognized; + + /** + * The unrecognized rules — preserves `originalAwardModel` for logging/debugging. + */ + rules: ReferralProgramRulesUnrecognized; +} + +export const validateBaseReferralProgramEditionSummary = ( + summary: BaseReferralProgramEditionSummary, +): void => { + if (!REFERRAL_PROGRAM_EDITION_SLUG_PATTERN.test(summary.slug)) { + throw new Error( + `BaseReferralProgramEditionSummary: slug "${summary.slug}" does not match required pattern ${REFERRAL_PROGRAM_EDITION_SLUG_PATTERN}.`, + ); + } + + if (summary.displayName.length === 0) { + throw new Error("BaseReferralProgramEditionSummary: displayName must not be empty."); + } + + if (summary.awardModel !== summary.rules.awardModel) { + throw new Error( + `BaseReferralProgramEditionSummary: awardModel (${summary.awardModel}) must equal rules.awardModel (${summary.rules.awardModel}).`, + ); + } +}; diff --git a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts index f424f0a2f..1166393d7 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts @@ -4,8 +4,8 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; import type { ReferrerLeaderboard } from "../../leaderboard"; import { isNonNegativeInteger, isPositiveInteger } from "../../number"; -import type { ReferralProgramStatusId } from "../../status"; import type { ReferralProgramAwardModel, ReferralProgramAwardModels } from "./rules"; +import type { ReferralProgramEditionStatusId } from "./status"; /** * The default number of referrers per leaderboard page. @@ -273,10 +273,9 @@ export interface BaseReferrerLeaderboardPage { pageContext: ReferrerLeaderboardPageContext; /** - * The status of the referral program ("Scheduled", "Active", or "Closed") - * calculated based on the program's timing relative to {@link accurateAsOf}. + * The status of the referral program edition. */ - status: ReferralProgramStatusId; + status: ReferralProgramEditionStatusId; /** * The {@link UnixTimestamp} of when the data used to build this page was accurate as of. diff --git a/packages/ens-referrals/src/v1/award-models/shared/rules.ts b/packages/ens-referrals/src/v1/award-models/shared/rules.ts index d42412f87..72c2e92fe 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/rules.ts @@ -55,6 +55,13 @@ export interface BaseReferralProgramRules { * @example new URL("https://ensawards.org/ens-holiday-awards-rules") */ rulesUrl: URL; + + /** + * Whether the awards for this edition have been distributed. + * + * When `true` and `now > endTime`, the status transitions from `AwardsReview` to `Closed`. + */ + areAwardsDistributed: boolean; } /** diff --git a/packages/ens-referrals/src/v1/award-models/shared/status.test.ts b/packages/ens-referrals/src/v1/award-models/shared/status.test.ts new file mode 100644 index 000000000..fe2d2d7c5 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/status.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { ReferralProgramAwardModels } from "./rules"; +import { + calcBaseReferralProgramEditionStatus, + ReferralProgramEditionStatuses, + type ReferralProgramEditionStatusId, +} from "./status"; + +const baseRules = { + awardModel: ReferralProgramAwardModels.PieSplit, + startTime: 1000, + endTime: 2000, + subregistryId: { chainId: 1, address: "0x0000000000000000000000000000000000000000" as const }, + rulesUrl: new URL("https://example.com/rules"), +}; + +describe("calcBaseReferralProgramEditionStatus", () => { + it("returns Scheduled when now is before startTime", () => { + const status = calcBaseReferralProgramEditionStatus( + { ...baseRules, areAwardsDistributed: false }, + 999, + ); + expect(status).toBe(ReferralProgramEditionStatuses.Scheduled); + }); + + it("returns Active when now is within the active window", () => { + const status = calcBaseReferralProgramEditionStatus( + { ...baseRules, areAwardsDistributed: false }, + 1500, + ); + expect(status).toBe(ReferralProgramEditionStatuses.Active); + }); + + it("returns AwardsReview when now is after endTime and awards are not yet distributed", () => { + const status = calcBaseReferralProgramEditionStatus( + { ...baseRules, areAwardsDistributed: false }, + 2001, + ); + expect(status).toBe( + ReferralProgramEditionStatuses.AwardsReview, + ); + }); + + it("returns Closed when now is after endTime and awards have been distributed", () => { + const status = calcBaseReferralProgramEditionStatus( + { ...baseRules, areAwardsDistributed: true }, + 2001, + ); + expect(status).toBe(ReferralProgramEditionStatuses.Closed); + }); +}); diff --git a/packages/ens-referrals/src/v1/award-models/shared/status.ts b/packages/ens-referrals/src/v1/award-models/shared/status.ts new file mode 100644 index 000000000..05b1da340 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/status.ts @@ -0,0 +1,70 @@ +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { BaseReferralProgramRules } from "./rules"; + +/** + * The type of referral program edition's status. + */ +export const ReferralProgramEditionStatuses = { + /** + * Represents a referral program edition that has been announced, but hasn't started yet. + */ + Scheduled: "Scheduled", + + /** + * Represents a currently ongoing referral program edition. + */ + Active: "Active", + + /** + * Represents a referral program edition that is still within its active window + * but whose award pool has been fully consumed. + * + * @note Not all award models may support this status. + */ + Exhausted: "Exhausted", + + /** + * Represents a referral program edition that has passed its end time but whose awards have not yet + * been distributed. The edition is in a review window before full closure. + * + * Transitions to {@link ReferralProgramEditionStatuses.Closed} once `areAwardsDistributed` is set to `true`. + */ + AwardsReview: "AwardsReview", + + /** + * Represents a referral program edition that has already ended and whose awards have been distributed. + */ + Closed: "Closed", +} as const; + +/** + * The derived string union of possible {@link ReferralProgramEditionStatuses}. + */ +export type ReferralProgramEditionStatusId = + (typeof ReferralProgramEditionStatuses)[keyof typeof ReferralProgramEditionStatuses]; + +/** + * Calculate the base status of a referral program edition using only its rules and + * the current time (makes no consideration of the awards possibly being exhausted). + * + * @param rules - Related referral program's rules containing program's start/end date and + * `areAwardsDistributed` flag. + * @param now - Current date in {@link UnixTimestamp} format. + */ +export const calcBaseReferralProgramEditionStatus = ( + rules: BaseReferralProgramRules, + now: UnixTimestamp, +): ReferralProgramEditionStatusId => { + // if the program has not started return "Scheduled" + if (now < rules.startTime) return ReferralProgramEditionStatuses.Scheduled; + + // if the program has ended, return "Closed" if awards are distributed, else "AwardsReview" + if (now > rules.endTime) + return rules.areAwardsDistributed + ? ReferralProgramEditionStatuses.Closed + : ReferralProgramEditionStatuses.AwardsReview; + + // otherwise, return "Active" + return ReferralProgramEditionStatuses.Active; +}; diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 72e0ffc5b..3f72ce1a0 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -1,14 +1,14 @@ import { deserializeReferralProgramEditionConfigSetArray, - deserializeReferralProgramEditionConfigSetResponse, + deserializeReferralProgramEditionSummariesResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, - type ReferralProgramEditionConfigSetResponse, + type ReferralProgramEditionSummariesResponse, type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, type ReferrerMetricsEditionsRequest, type ReferrerMetricsEditionsResponse, - type SerializedReferralProgramEditionConfigSetResponse, + type SerializedReferralProgramEditionSummariesResponse, type SerializedReferrerLeaderboardPageResponse, type SerializedReferrerMetricsEditionsResponse, } from "./api"; @@ -340,22 +340,22 @@ export class ENSReferralsClient { } /** - * Get the currently configured referral program edition config set. + * Get the currently configured referral program edition summaries. * 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. + * @returns A response containing edition summaries, or an error response if unavailable. * * @remarks Editions whose `rules.awardModel` is not recognized by this client version are - * preserved as {@link ReferralProgramRulesUnrecognized}. The returned map includes all - * editions — recognized and unrecognized alike. Callers should check `editionConfig.rules.awardModel` + * preserved as {@link ReferralProgramEditionSummaryUnrecognized}. The returned response includes all + * editions — recognized and unrecognized alike. Callers should check `edition.awardModel` * and skip editions with `"unrecognized"` as appropriate. At least one edition of any kind must * be present, otherwise deserialization throws. * * @example * ```typescript - * const response = await client.getEditionConfigSet(); + * const response = await client.getEditionSummaries(); * - * if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) { + * if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Ok) { * console.log(`Found ${response.data.editions.length} editions`); * for (const edition of response.data.editions) { * console.log(`${edition.slug}: ${edition.displayName}`); @@ -366,15 +366,15 @@ export class ENSReferralsClient { * @example * ```typescript * // Handle error response - * const response = await client.getEditionConfigSet(); + * const response = await client.getEditionSummaries(); * - * if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) { + * if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Error) { * console.error(response.error); * console.error(response.errorMessage); * } * ``` */ - async getEditionConfigSet(): Promise { + async getEditionSummaries(): Promise { const url = new URL(`/v1/ensanalytics/editions`, this.options.url); const response = await fetch(url); @@ -389,12 +389,12 @@ export class ENSReferralsClient { } // The API can return errors with various status codes, but they're still in the - // ReferralProgramEditionConfigSetResponse format with responseCode: 'error' + // ReferralProgramEditionSummariesResponse 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, + return deserializeReferralProgramEditionSummariesResponse( + responseData as SerializedReferralProgramEditionSummariesResponse, ); } } diff --git a/packages/ens-referrals/src/v1/edition-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts index e7e11f7ea..24b89bcc3 100644 --- a/packages/ens-referrals/src/v1/edition-defaults.ts +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -38,6 +38,7 @@ export function getDefaultReferralProgramEditionConfigSet( parseTimestamp("2025-12-31T23:59:59Z"), subregistryId, new URL("https://ensawards.org/ens-holiday-awards-rules"), + true, ), }; @@ -53,7 +54,7 @@ export function getDefaultReferralProgramEditionConfigSet( subregistryId, // TODO: replace this with the dedicated March 2026 rules URL once published new URL("https://ensawards.org/ens-holiday-awards-rules"), - [], + false, ), }; diff --git a/packages/ens-referrals/src/v1/edition-metrics.ts b/packages/ens-referrals/src/v1/edition-metrics.ts index 9e101efd3..bba874c07 100644 --- a/packages/ens-referrals/src/v1/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -6,19 +6,20 @@ import type { ReferrerEditionMetricsUnrankedPieSplit, } from "./award-models/pie-split/edition-metrics"; import { buildUnrankedReferrerMetricsPieSplit } from "./award-models/pie-split/metrics"; +import { calcReferralProgramEditionStatusPieSplit } from "./award-models/pie-split/status"; import type { ReferrerEditionMetricsRankedRevShareLimit, ReferrerEditionMetricsRevShareLimit, ReferrerEditionMetricsUnrankedRevShareLimit, } from "./award-models/rev-share-limit/edition-metrics"; import { buildUnrankedReferrerMetricsRevShareLimit } from "./award-models/rev-share-limit/metrics"; +import { calcReferralProgramEditionStatusRevShareLimit } from "./award-models/rev-share-limit/status"; import { ReferrerEditionMetricsTypeIds, type ReferrerEditionMetricsUnrecognized, } from "./award-models/shared/edition-metrics"; import { ReferralProgramAwardModels } from "./award-models/shared/rules"; import type { ReferrerLeaderboard } from "./leaderboard"; -import { calcReferralProgramStatus } from "./status"; /** * Referrer edition metrics data for a specific referrer address. @@ -46,10 +47,12 @@ export const getReferrerEditionMetrics = ( referrer: Address, leaderboard: ReferrerLeaderboard, ): ReferrerEditionMetrics => { - const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); - switch (leaderboard.awardModel) { case ReferralProgramAwardModels.PieSplit: { + const status = calcReferralProgramEditionStatusPieSplit( + leaderboard.rules, + leaderboard.accurateAsOf, + ); const awardedReferrerMetrics = leaderboard.referrers.get(referrer); if (awardedReferrerMetrics) { return { @@ -74,6 +77,11 @@ export const getReferrerEditionMetrics = ( } case ReferralProgramAwardModels.RevShareLimit: { + const status = calcReferralProgramEditionStatusRevShareLimit( + leaderboard.rules, + leaderboard.accurateAsOf, + leaderboard.aggregatedMetrics, + ); const awardedReferrerMetrics = leaderboard.referrers.get(referrer); if (awardedReferrerMetrics) { return { diff --git a/packages/ens-referrals/src/v1/edition-summary.ts b/packages/ens-referrals/src/v1/edition-summary.ts new file mode 100644 index 000000000..603fca2ac --- /dev/null +++ b/packages/ens-referrals/src/v1/edition-summary.ts @@ -0,0 +1,51 @@ +import { + buildEditionSummaryPieSplit, + type ReferralProgramEditionSummaryPieSplit, +} from "./award-models/pie-split/edition-summary"; +import { + buildEditionSummaryRevShareLimit, + type ReferralProgramEditionSummaryRevShareLimit, +} from "./award-models/rev-share-limit/edition-summary"; +import type { ReferralProgramEditionSummaryUnrecognized } from "./award-models/shared/edition-summary"; +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; +import type { ReferralProgramEditionConfig } from "./edition"; +import type { ReferrerLeaderboard } from "./leaderboard"; + +/** + * Runtime summary of a referral program edition, enriched with current status and pool data. + * + * Use `awardModel` to discriminate between variants at runtime. + */ +export type ReferralProgramEditionSummary = + | ReferralProgramEditionSummaryPieSplit + | ReferralProgramEditionSummaryRevShareLimit + | ReferralProgramEditionSummaryUnrecognized; + +/** + * Build a runtime edition summary from an edition config and the edition's leaderboard. + * Dispatches to the appropriate per-model builder based on `leaderboard.awardModel`. + * + * @param config - The edition configuration (provides `slug` and `displayName`). + * @param leaderboard - The resolved leaderboard for this edition. + */ +export function buildEditionSummary( + config: ReferralProgramEditionConfig, + leaderboard: ReferrerLeaderboard, +): ReferralProgramEditionSummary { + const { slug, displayName } = config; + + switch (leaderboard.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return buildEditionSummaryPieSplit(slug, displayName, leaderboard.rules, leaderboard); + + case ReferralProgramAwardModels.RevShareLimit: + return buildEditionSummaryRevShareLimit(slug, displayName, leaderboard.rules, leaderboard); + + default: { + const _exhaustiveCheck: never = leaderboard; + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferrerLeaderboard).awardModel}`, + ); + } + } +} diff --git a/packages/ens-referrals/src/v1/edition.ts b/packages/ens-referrals/src/v1/edition.ts index b33be2455..34a1dcb83 100644 --- a/packages/ens-referrals/src/v1/edition.ts +++ b/packages/ens-referrals/src/v1/edition.ts @@ -16,6 +16,14 @@ import type { ReferralProgramRules } from "./rules"; */ export type ReferralProgramEditionSlug = string; +/** + * Regex pattern that all {@link ReferralProgramEditionSlug} values must match. + * + * Allows lowercase letters (a-z), digits (0-9), and hyphens (-). + * Must not start or end with a hyphen. + */ +export const REFERRAL_PROGRAM_EDITION_SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + /** * Represents a referral program edition configuration. */ diff --git a/packages/ens-referrals/src/v1/index.ts b/packages/ens-referrals/src/v1/index.ts index 82ebce219..fac158ed3 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -3,33 +3,39 @@ export * from "./api"; export * from "./award-models/pie-split/aggregations"; export * from "./award-models/pie-split/api/serialized-types"; export * from "./award-models/pie-split/edition-metrics"; +export * from "./award-models/pie-split/edition-summary"; export * from "./award-models/pie-split/leaderboard"; export * from "./award-models/pie-split/leaderboard-page"; export * from "./award-models/pie-split/metrics"; export * from "./award-models/pie-split/rules"; export * from "./award-models/pie-split/score"; +export * from "./award-models/pie-split/status"; export * from "./award-models/rev-share-limit/aggregations"; export * from "./award-models/rev-share-limit/api/serialized-types"; export * from "./award-models/rev-share-limit/edition-metrics"; +export * from "./award-models/rev-share-limit/edition-summary"; export * from "./award-models/rev-share-limit/leaderboard"; export * from "./award-models/rev-share-limit/leaderboard-page"; export * from "./award-models/rev-share-limit/metrics"; export * from "./award-models/rev-share-limit/referral-event"; export * from "./award-models/rev-share-limit/rules"; +export * from "./award-models/rev-share-limit/status"; export * from "./award-models/shared/edition-metrics"; +export * from "./award-models/shared/edition-summary"; export * from "./award-models/shared/leaderboard-page"; export * from "./award-models/shared/rank"; export * from "./award-models/shared/rules"; export * from "./award-models/shared/score"; +export * from "./award-models/shared/status"; export * from "./client"; export * from "./edition"; export * from "./edition-defaults"; export * from "./edition-metrics"; +export * from "./edition-summary"; export * from "./leaderboard"; export * from "./leaderboard-page"; export * from "./link"; export * from "./number"; export * from "./referrer-metrics"; export * from "./rules"; -export * from "./status"; export * from "./time"; diff --git a/packages/ens-referrals/src/v1/leaderboard-page.test.ts b/packages/ens-referrals/src/v1/leaderboard-page.test.ts index d2a41e8e8..dbb3a7d61 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.test.ts @@ -32,6 +32,7 @@ describe("buildReferrerLeaderboardPageContext", () => { address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", }, rulesUrl: new URL("https://example.com/rules"), + areAwardsDistributed: false, }, aggregatedMetrics: { grandTotalReferrals: 17, @@ -120,6 +121,7 @@ describe("buildReferrerLeaderboardPageContext", () => { address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", }, rulesUrl: new URL("https://example.com/rules"), + areAwardsDistributed: false, }, aggregatedMetrics: { grandTotalReferrals: 17, diff --git a/packages/ens-referrals/src/v1/status.ts b/packages/ens-referrals/src/v1/status.ts deleted file mode 100644 index 93ce0e824..000000000 --- a/packages/ens-referrals/src/v1/status.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; - -import type { ReferralProgramRules } from "./rules.ts"; - -/** - * The type of referral program's status. - */ -export const ReferralProgramStatuses = { - /** - * Represents a referral program that has been announced, but hasn't started yet. - */ - Scheduled: "Scheduled", - - /** - * Represents a currently ongoing referral program. - */ - Active: "Active", - - /** - * Represents a referral program that has already ended. - */ - Closed: "Closed", -} as const; - -/** - * The derived string union of possible {@link ReferralProgramStatuses}. - */ -export type ReferralProgramStatusId = - (typeof ReferralProgramStatuses)[keyof typeof ReferralProgramStatuses]; - -/** - * Calculate the status of the referral program based on the current date - * and program's timeframe available in its rules. - * - * @param referralProgramRules - Related referral program's rules containing - * program's start date and end date. - * - * @param now - Current date in {@link UnixTimestamp} format. - */ -export const calcReferralProgramStatus = ( - referralProgramRules: ReferralProgramRules, - now: UnixTimestamp, -): ReferralProgramStatusId => { - // if the program has not started return "Scheduled" - if (now < referralProgramRules.startTime) return ReferralProgramStatuses.Scheduled; - - // if the program has ended return "Closed" - if (now > referralProgramRules.endTime) return ReferralProgramStatuses.Closed; - - // otherwise, return "Active" - return ReferralProgramStatuses.Active; -};