Skip to content
7 changes: 7 additions & 0 deletions .changeset/puny-peaches-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@namehash/ens-referrals": minor
"@ensnode/ensnode-sdk": minor
"ensapi": minor
---

Add referrer detail endpoint API. Supports querying individual referrers whether they are ranked on the leaderboard or not.
151 changes: 151 additions & 0 deletions apps/ensapi/src/handlers/ensanalytics-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ vi.mock("../middleware/referrer-leaderboard.middleware", () => ({
referrerLeaderboardMiddleware: vi.fn(),
}));

import { ReferrerDetailTypeIds } from "@namehash/ens-referrals";
import pReflect from "p-reflect";

import {
deserializeReferrerDetailResponse,
deserializeReferrerLeaderboardPageResponse,
ReferrerDetailResponseCodes,
type ReferrerDetailResponseOk,
ReferrerLeaderboardPageResponseCodes,
type ReferrerLeaderboardPageResponseOk,
} from "@ensnode/ensnode-sdk";
Expand Down Expand Up @@ -179,4 +183,151 @@ describe("/ensanalytics", () => {
expect(response).toMatchObject(expectedResponse);
});
});

describe("/referrers/:referrer", () => {
it("returns referrer metrics when referrer exists in leaderboard", async () => {
// Arrange: set `referrerLeaderboard` context var with populated leaderboard
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
const mockedReferrerLeaderboard = await pReflect(
Promise.resolve(populatedReferrerLeaderboard),
);
c.set("referrerLeaderboard", mockedReferrerLeaderboard);
return await next();
});

// Arrange: use a referrer address that exists in the leaderboard (rank 1)
const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e";
const expectedMetrics = populatedReferrerLeaderboard.referrers.get(existingReferrer)!;
const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf;

// Act: send test request to fetch referrer detail
const httpResponse = await app.request(`/referrers/${existingReferrer}`);
const responseData = await httpResponse.json();
const response = deserializeReferrerDetailResponse(responseData);

// Assert: response contains the expected referrer metrics
const expectedResponse = {
responseCode: ReferrerDetailResponseCodes.Ok,
data: {
type: ReferrerDetailTypeIds.Ranked,
rules: populatedReferrerLeaderboard.rules,
referrer: expectedMetrics,
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
accurateAsOf: expectedAccurateAsOf,
},
} satisfies ReferrerDetailResponseOk;

expect(response).toMatchObject(expectedResponse);
});

it("returns zero-score metrics when referrer does not exist in leaderboard", async () => {
// Arrange: set `referrerLeaderboard` context var with populated leaderboard
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
const mockedReferrerLeaderboard = await pReflect(
Promise.resolve(populatedReferrerLeaderboard),
);
c.set("referrerLeaderboard", mockedReferrerLeaderboard);
return await next();
});

// Arrange: use a referrer address that does NOT exist in the leaderboard
const nonExistingReferrer = "0x0000000000000000000000000000000000000099";

// Act: send test request to fetch referrer detail
const httpResponse = await app.request(`/referrers/${nonExistingReferrer}`);
const responseData = await httpResponse.json();
const response = deserializeReferrerDetailResponse(responseData);

// Assert: response contains zero-score metrics for the referrer
// Rank should be null since they're not on the leaderboard
const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf;

expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Ok);
if (response.responseCode === ReferrerDetailResponseCodes.Ok) {
expect(response.data.type).toBe(ReferrerDetailTypeIds.Unranked);
expect(response.data.rules).toEqual(populatedReferrerLeaderboard.rules);
expect(response.data.aggregatedMetrics).toEqual(
populatedReferrerLeaderboard.aggregatedMetrics,
);
expect(response.data.referrer.referrer).toBe(nonExistingReferrer);
expect(response.data.referrer.rank).toBe(null);
expect(response.data.referrer.totalReferrals).toBe(0);
expect(response.data.referrer.totalIncrementalDuration).toBe(0);
expect(response.data.referrer.score).toBe(0);
expect(response.data.referrer.isQualified).toBe(false);
expect(response.data.referrer.finalScoreBoost).toBe(0);
expect(response.data.referrer.finalScore).toBe(0);
expect(response.data.referrer.awardPoolShare).toBe(0);
expect(response.data.referrer.awardPoolApproxValue).toBe(0);
expect(response.data.accurateAsOf).toBe(expectedAccurateAsOf);
}
});

it("returns zero-score metrics when leaderboard is empty", async () => {
// Arrange: set `referrerLeaderboard` context var with empty leaderboard
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
const mockedReferrerLeaderboard = await pReflect(Promise.resolve(emptyReferralLeaderboard));
c.set("referrerLeaderboard", mockedReferrerLeaderboard);
return await next();
});

// Arrange: use any referrer address
const referrer = "0x0000000000000000000000000000000000000001";

// Act: send test request to fetch referrer detail
const httpResponse = await app.request(`/referrers/${referrer}`);
const responseData = await httpResponse.json();
const response = deserializeReferrerDetailResponse(responseData);

// Assert: response contains zero-score metrics for the referrer
// Rank should be null since they're not on the leaderboard
const expectedAccurateAsOf = emptyReferralLeaderboard.accurateAsOf;

expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Ok);
if (response.responseCode === ReferrerDetailResponseCodes.Ok) {
expect(response.data.type).toBe(ReferrerDetailTypeIds.Unranked);
expect(response.data.rules).toEqual(emptyReferralLeaderboard.rules);
expect(response.data.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics);
expect(response.data.referrer.referrer).toBe(referrer);
expect(response.data.referrer.rank).toBe(null);
expect(response.data.referrer.totalReferrals).toBe(0);
expect(response.data.referrer.totalIncrementalDuration).toBe(0);
expect(response.data.referrer.score).toBe(0);
expect(response.data.referrer.isQualified).toBe(false);
expect(response.data.referrer.finalScoreBoost).toBe(0);
expect(response.data.referrer.finalScore).toBe(0);
expect(response.data.referrer.awardPoolShare).toBe(0);
expect(response.data.referrer.awardPoolApproxValue).toBe(0);
expect(response.data.accurateAsOf).toBe(expectedAccurateAsOf);
}
});

it("returns error response when leaderboard fails to load", async () => {
// Arrange: set `referrerLeaderboard` context var with rejected promise
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
const mockedReferrerLeaderboard = await pReflect(
Promise.reject(new Error("Database connection failed")),
);
c.set("referrerLeaderboard", mockedReferrerLeaderboard);
return await next();
});

// Arrange: use any referrer address
const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e";

// Act: send test request to fetch referrer detail
const httpResponse = await app.request(`/referrers/${referrer}`);
const responseData = await httpResponse.json();
const response = deserializeReferrerDetailResponse(responseData);

// Assert: response contains error
expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Error);
if (response.responseCode === ReferrerDetailResponseCodes.Error) {
expect(response.error).toBe("Service Unavailable");
expect(response.errorMessage).toBe(
"Referrer leaderboard data has not been successfully cached yet.",
);
}
});
});
});
64 changes: 61 additions & 3 deletions apps/ensapi/src/handlers/ensanalytics-api.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import {
getReferrerDetail,
getReferrerLeaderboardPage,
REFERRERS_PER_LEADERBOARD_PAGE_MAX,
} from "@namehash/ens-referrals";
import { z } from "zod/v4";

import {
type ReferrerDetailResponse,
ReferrerDetailResponseCodes,
type ReferrerLeaderboardPageRequest,
type ReferrerLeaderboardPageResponse,
ReferrerLeaderboardPageResponseCodes,
type ReferrerLeaderboardPaginationRequest,
serializeReferrerDetailResponse,
serializeReferrerLeaderboardPageResponse,
} from "@ensnode/ensnode-sdk";
import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal";

import { validate } from "@/lib/handlers/validate";
import { factory } from "@/lib/hono-factory";
Expand All @@ -18,7 +23,7 @@ import { referrerLeaderboardMiddleware } from "@/middleware/referrer-leaderboard

const logger = makeLogger("ensanalytics-api");

// Pagination query parameters schema (mirrors PaginatedAggregatedReferrersRequest)
// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest)
const paginationQuerySchema = z.object({
page: z.optional(z.coerce.number().int().min(1, "Page must be a positive integer")),
itemsPerPage: z.optional(
Expand All @@ -31,7 +36,7 @@ const paginationQuerySchema = z.object({
`Items per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
),
),
}) satisfies z.ZodType<ReferrerLeaderboardPaginationRequest>;
}) satisfies z.ZodType<ReferrerLeaderboardPageRequest>;

const app = factory
.createApp()
Expand Down Expand Up @@ -89,4 +94,57 @@ const app = factory
}
});

// Referrer address parameter schema
const referrerAddressSchema = z.object({
referrer: makeLowercaseAddressSchema("Referrer address"),
});

// Get referrer detail for a specific address
app.get("/referrers/:referrer", validate("param", referrerAddressSchema), async (c) => {
// context must be set by the required middleware
if (c.var.referrerLeaderboard === undefined) {
throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`);
}

try {
const referrerLeaderboard = c.var.referrerLeaderboard;

// Check if leaderboard failed to load
if (referrerLeaderboard.isRejected) {
return c.json(
serializeReferrerDetailResponse({
responseCode: ReferrerDetailResponseCodes.Error,
error: "Service Unavailable",
errorMessage: "Referrer leaderboard data has not been successfully cached yet.",
} satisfies ReferrerDetailResponse),
503,
);
}

const { referrer } = c.req.valid("param");
const detail = getReferrerDetail(referrer, referrerLeaderboard.value);

return c.json(
serializeReferrerDetailResponse({
responseCode: ReferrerDetailResponseCodes.Ok,
data: detail,
} satisfies ReferrerDetailResponse),
);
} catch (error) {
logger.error({ error }, "Error in /ensanalytics/referrers/:referrer endpoint");
const errorMessage =
error instanceof Error
? error.message
: "An unexpected error occurred while processing your request";
return c.json(
serializeReferrerDetailResponse({
responseCode: ReferrerDetailResponseCodes.Error,
error: "Internal server error",
errorMessage,
} satisfies ReferrerDetailResponse),
500,
);
}
});

export default app;
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboard = {
},
referrers: new Map([
[
"0x538e35b2888ed5b[c58cf2825d76cf6265aa4e31e",
"0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably a bug from earlier

{
referrer: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e",
totalReferrals: 3,
Expand Down
1 change: 1 addition & 0 deletions packages/ens-referrals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./leaderboard-page";
export * from "./link";
export * from "./number";
export * from "./rank";
export * from "./referrer-detail";
export * from "./referrer-metrics";
export * from "./rules";
export * from "./score";
Expand Down
34 changes: 15 additions & 19 deletions packages/ens-referrals/src/leaderboard-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";

import type { ReferrerLeaderboard } from "./leaderboard.ts";
import {
buildReferrerLeaderboardPaginationContext,
type ReferrerLeaderboardPaginationContext,
type ReferrerLeaderboardPaginationParams,
buildReferrerLeaderboardPageContext,
type ReferrerLeaderboardPageContext,
type ReferrerLeaderboardPageParams,
} from "./leaderboard-page.ts";
import type { AwardedReferrerMetrics } from "./referrer-metrics.ts";

describe("buildReferrerLeaderboardPaginationContext", () => {
const paginationParams: ReferrerLeaderboardPaginationParams = {
describe("buildReferrerLeaderboardPageContext", () => {
const paginationParams: ReferrerLeaderboardPageParams = {
page: 1,
itemsPerPage: 3,
};
Expand Down Expand Up @@ -83,14 +83,12 @@ describe("buildReferrerLeaderboardPaginationContext", () => {
accurateAsOf: 1764580368,
};

const buildReferrerLeaderboardPaginationContextSpy = vi.fn(
buildReferrerLeaderboardPaginationContext,
);
const result = buildReferrerLeaderboardPaginationContextSpy(paginationParams, leaderboard);
const buildReferrerLeaderboardPageContextSpy = vi.fn(buildReferrerLeaderboardPageContext);
const result = buildReferrerLeaderboardPageContextSpy(paginationParams, leaderboard);

expect(
buildReferrerLeaderboardPaginationContextSpy,
"buildReferrerLeaderboardPaginationContext should successfully complete for itemsPerPage=3, leaderboard.referrers.size=3",
buildReferrerLeaderboardPageContextSpy,
"buildReferrerLeaderboardPageContext should successfully complete for itemsPerPage=3, leaderboard.referrers.size=3",
).toHaveReturned();
expect(
result.hasNext,
Expand Down Expand Up @@ -120,7 +118,7 @@ describe("buildReferrerLeaderboardPaginationContext", () => {
accurateAsOf: 1764580368,
};

const expectedResult: ReferrerLeaderboardPaginationContext = {
const expectedResult: ReferrerLeaderboardPageContext = {
totalRecords: 0,
totalPages: 1,
hasNext: false,
Expand All @@ -131,19 +129,17 @@ describe("buildReferrerLeaderboardPaginationContext", () => {
page: 1,
};

const buildReferrerLeaderboardPaginationContextSpy = vi.fn(
buildReferrerLeaderboardPaginationContext,
);
const result = buildReferrerLeaderboardPaginationContextSpy(paginationParams, leaderboard);
const buildReferrerLeaderboardPageContextSpy = vi.fn(buildReferrerLeaderboardPageContext);
const result = buildReferrerLeaderboardPageContextSpy(paginationParams, leaderboard);

expect(
buildReferrerLeaderboardPaginationContextSpy,
"buildReferrerLeaderboardPaginationContext should successfully complete for itemsPerPage=3, leaderboard.referrers.size=0",
buildReferrerLeaderboardPageContextSpy,
"buildReferrerLeaderboardPageContext should successfully complete for itemsPerPage=3, leaderboard.referrers.size=0",
).toHaveReturned();

expect(
result,
`ReferrerLeaderboardPaginationContext result should match all edge-case requirements for leaderboard.referrers.size=0`,
`ReferrerLeaderboardPageContext result should match all edge-case requirements for leaderboard.referrers.size=0`,
).toStrictEqual(expectedResult);
});
});
Loading