Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/swift-trains-move.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 36 additions & 17 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,7 +123,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...populatedReferrerLeaderboard,
status: ReferralProgramStatuses.Active,
status: ReferralProgramEditionStatuses.Active,
pageContext: {
endIndex: 9,
hasNext: true,
Expand All @@ -145,7 +145,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...populatedReferrerLeaderboard,
status: ReferralProgramStatuses.Active,
status: ReferralProgramEditionStatuses.Active,
pageContext: {
endIndex: 19,
hasNext: true,
Expand All @@ -166,7 +166,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...populatedReferrerLeaderboard,
status: ReferralProgramStatuses.Active,
status: ReferralProgramEditionStatuses.Active,
pageContext: {
endIndex: 28,
hasNext: false,
Expand Down Expand Up @@ -229,7 +229,7 @@ describe("/v1/ensanalytics", () => {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
...emptyReferralLeaderboard,
status: ReferralProgramStatuses.Active,
status: ReferralProgramEditionStatuses.Active,
pageContext: {
hasNext: false,
hasPrev: false,
Expand Down Expand Up @@ -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,
Expand All @@ -378,7 +378,7 @@ describe("/v1/ensanalytics", () => {
referrer: expectedMetrics,
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
accurateAsOf: expectedAccurateAsOf,
status: ReferralProgramStatuses.Active,
status: ReferralProgramEditionStatuses.Active,
},
},
} satisfies ReferrerMetricsEditionsResponseOk;
Expand Down Expand Up @@ -824,6 +824,7 @@ describe("/v1/ensanalytics", () => {
parseTimestamp("2025-12-31T23:59:59Z"),
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
new URL("https://example.com/rules"),
false,
),
},
],
Expand All @@ -839,6 +840,7 @@ describe("/v1/ensanalytics", () => {
parseTimestamp("2026-03-31T23:59:59Z"),
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
new URL("https://example.com/rules"),
false,
),
},
],
Expand All @@ -854,6 +856,7 @@ describe("/v1/ensanalytics", () => {
parseTimestamp("2026-06-30T23:59:59Z"),
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
new URL("https://example.com/rules"),
false,
),
},
],
Expand All @@ -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<ReferralProgramEditionSlug, SWRCache<ReferrerLeaderboard>>(
[
[
"2025-12",
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
],
[
"2026-03",
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
],
[
"2026-06",
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
],
],
);
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
Expand Down Expand Up @@ -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");
}
Expand Down
92 changes: 77 additions & 15 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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");
Expand All @@ -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,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const rules = buildReferralProgramRulesPieSplit(
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
new URL("https://example.com/rules"),
false,
);

const accurateAsOf = parseTimestamp("2025-11-30T23:59:59Z");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
ReferralProgramAwardModels,
ReferralProgramStatuses,
ReferralProgramEditionStatuses,
type ReferrerLeaderboardPagePieSplit,
ReferrerLeaderboardPageResponseCodes,
type ReferrerLeaderboardPageResponseOk,
Expand Down Expand Up @@ -188,6 +188,7 @@ export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = {
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
rulesUrl: new URL("https://example.com/rules"),
areAwardsDistributed: false,
},
aggregatedMetrics: {
grandTotalReferrals: 0,
Expand All @@ -213,6 +214,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = {
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
rulesUrl: new URL("https://example.com/rules"),
areAwardsDistributed: false,
},
aggregatedMetrics: {
grandTotalReferrals: 68,
Expand Down Expand Up @@ -705,6 +707,7 @@ export const referrerLeaderboardPageResponseOk = {
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
rulesUrl: new URL("https://example.com/rules"),
areAwardsDistributed: false,
},
referrers: [
{
Expand Down Expand Up @@ -1102,7 +1105,7 @@ export const referrerLeaderboardPageResponseOk = {
startIndex: 0,
endIndex: 28,
},
status: ReferralProgramStatuses.Active,
status: ReferralProgramEditionStatuses.Active,
accurateAsOf: 1735689600,
} satisfies ReferrerLeaderboardPagePieSplit,
} satisfies ReferrerLeaderboardPageResponseOk;
Loading
Loading