From 477b4eef7fc1a5b8cb69bd6748b024eee4a1762a Mon Sep 17 00:00:00 2001 From: Goader Date: Sun, 26 Apr 2026 23:58:41 +0200 Subject: [PATCH 01/12] award accounting --- .changeset/purple-frogs-dream.md | 5 + .changeset/shiny-pandas-account.md | 5 + ...ts => referral-edition-snapshots.cache.ts} | 42 +- .../ensanalytics/ensanalytics-api.routes.ts | 41 +- .../ensanalytics/ensanalytics-api.test.ts | 420 ++++++++++-------- .../handlers/ensanalytics/ensanalytics-api.ts | 95 +++- apps/ensapi/src/index.ts | 4 +- .../referrer-leaderboard/database.ts | 78 +++- .../format-accounting-csv.ts | 82 ++++ ... => get-referral-edition-snapshot.test.ts} | 8 +- ...rd.ts => get-referral-edition-snapshot.ts} | 22 +- apps/ensapi/src/lib/hono-factory.ts | 4 +- ...al-edition-snapshots-caches.middleware.ts} | 30 +- .../src/award-models/pie-split/leaderboard.ts | 22 +- .../rev-share-cap/accounting.test.ts | 316 +++++++++++++ .../award-models/rev-share-cap/accounting.ts | 131 ++++++ .../rev-share-cap/leaderboard.test.ts | 151 +++++-- .../award-models/rev-share-cap/leaderboard.ts | 130 ++++-- .../rev-share-cap/referral-event.ts | 25 +- packages/ens-referrals/src/index.ts | 1 + packages/ens-referrals/src/leaderboard.ts | 17 +- 21 files changed, 1300 insertions(+), 329 deletions(-) create mode 100644 .changeset/purple-frogs-dream.md create mode 100644 .changeset/shiny-pandas-account.md rename apps/ensapi/src/cache/{referral-leaderboard-editions.cache.ts => referral-edition-snapshots.cache.ts} (81%) create mode 100644 apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts rename apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/{get-referrer-leaderboard.test.ts => get-referral-edition-snapshot.test.ts} (93%) rename apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/{get-referrer-leaderboard.ts => get-referral-edition-snapshot.ts} (60%) rename apps/ensapi/src/middleware/{referral-leaderboard-editions-caches.middleware.ts => referral-edition-snapshots-caches.middleware.ts} (66%) create mode 100644 packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts create mode 100644 packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts diff --git a/.changeset/purple-frogs-dream.md b/.changeset/purple-frogs-dream.md new file mode 100644 index 0000000000..2075e4e70e --- /dev/null +++ b/.changeset/purple-frogs-dream.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-referrals": minor +--- + +Add per-event accounting trace for rev-share-cap editions. The new `ReferralEditionSnapshot` (returned by `buildReferralEditionSnapshot*`) bundles the leaderboard with a chronological array of `ReferralAccountingRecordRevShareCap`. diff --git a/.changeset/shiny-pandas-account.md b/.changeset/shiny-pandas-account.md new file mode 100644 index 0000000000..317e712baf --- /dev/null +++ b/.changeset/shiny-pandas-account.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Add `GET /v1/ensanalytics/accounting?edition={slug}` for rev-share-cap editions: returns a CSV dump of the per-event accounting trace, ordered chronologically. diff --git a/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts similarity index 81% rename from apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts rename to apps/ensapi/src/cache/referral-edition-snapshots.cache.ts index f99c0c9d5e..de821c7791 100644 --- a/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts +++ b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts @@ -1,8 +1,8 @@ import { + type ReferralEditionSnapshot, type ReferralProgramEditionConfig, type ReferralProgramEditionConfigSet, type ReferralProgramEditionSlug, - type ReferrerLeaderboard, serializeReferralProgramRules, } from "@namehash/ens-referrals"; import { minutesToSeconds } from "date-fns"; @@ -16,24 +16,24 @@ import { } from "@ensnode/ensnode-sdk"; import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/referrer-leaderboard/closeout"; -import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard"; +import { getReferralEditionSnapshot } from "@/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot"; import { makeLogger } from "@/lib/logger"; import { indexingStatusCache } from "./indexing-status.cache"; -const logger = makeLogger("referral-leaderboard-editions-cache"); +const logger = makeLogger("referral-edition-snapshots-cache"); /** - * Map from edition slug to its leaderboard cache. + * Map from edition slug to its snapshot cache. * * Each edition has its own independent cache. Therefore, each * edition's cache can be asynchronously loaded / refreshed from * others, and a failure to load data for one edition doesn't break * data successfully loaded for other editions. */ -export type ReferralLeaderboardEditionsCacheMap = Map< +export type ReferralEditionSnapshotsCacheMap = Map< ReferralProgramEditionSlug, - SWRCache + SWRCache >; /** @@ -56,17 +56,19 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ * @param editionConfig - The edition configuration * @returns A function that builds the leaderboard for the given edition */ -function createEditionLeaderboardBuilder( +function createEditionSnapshotBuilder( editionConfig: ReferralProgramEditionConfig, -): (cachedResult?: CachedResult) => Promise { - return async (cachedResult?: CachedResult): Promise => { +): (cachedResult?: CachedResult) => Promise { + return async ( + cachedResult?: CachedResult, + ): Promise => { const editionSlug = editionConfig.slug; // Check if cached data is immutable and can be returned as-is if (cachedResult && !(cachedResult.result instanceof Error)) { const isImmutable = assumeReferralProgramEditionImmutablyClosed( - cachedResult.result.rules, - cachedResult.result.accurateAsOf, + cachedResult.result.leaderboard.rules, + cachedResult.result.leaderboard.accurateAsOf, ); if (isImmutable) { @@ -114,16 +116,16 @@ function createEditionLeaderboardBuilder( )}`, ); - const leaderboard = await getReferrerLeaderboard( + const snapshot = await getReferralEditionSnapshot( editionConfig.rules, latestIndexedBlockRef.timestamp, ); logger.info( - `Successfully built referrer leaderboard for ${editionSlug} with ${leaderboard.referrers.size} referrers`, + `Successfully built referrer leaderboard for ${editionSlug} with ${snapshot.leaderboard.referrers.size} referrers`, ); - return leaderboard; + return snapshot; }; } @@ -131,7 +133,7 @@ function createEditionLeaderboardBuilder( * Singleton instance of the initialized caches. * Ensures caches are only initialized once per application lifecycle. */ -let cachedInstance: ReferralLeaderboardEditionsCacheMap | null = null; +let cachedInstance: ReferralEditionSnapshotsCacheMap | null = null; /** * Initializes caches for all referral program editions in the given edition set. @@ -144,19 +146,19 @@ let cachedInstance: ReferralLeaderboardEditionsCacheMap | null = null; * @param editionConfigSet - The referral program edition config set to initialize caches for * @returns A map from edition slug to its dedicated SWRCache */ -export function initializeReferralLeaderboardEditionsCaches( +export function initializeReferralEditionSnapshotsCaches( editionConfigSet: ReferralProgramEditionConfigSet, -): ReferralLeaderboardEditionsCacheMap { +): ReferralEditionSnapshotsCacheMap { // Return cached instance if already initialized if (cachedInstance !== null) { return cachedInstance; } - const caches: ReferralLeaderboardEditionsCacheMap = new Map(); + const caches: ReferralEditionSnapshotsCacheMap = new Map(); for (const [editionSlug, editionConfig] of editionConfigSet) { const cache = new SWRCache({ - fn: createEditionLeaderboardBuilder(editionConfig), + fn: createEditionSnapshotBuilder(editionConfig), ttl: minutesToSeconds(1), proactiveRevalidationInterval: minutesToSeconds(2), proactivelyInitialize: true, @@ -177,6 +179,6 @@ export function initializeReferralLeaderboardEditionsCaches( * * @returns The cached cache map or null */ -export function getReferralLeaderboardEditionsCaches(): ReferralLeaderboardEditionsCacheMap | null { +export function getReferralEditionSnapshotsCaches(): ReferralEditionSnapshotsCacheMap | null { return cachedInstance; } diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts index 3e96c8c1da..e5560fb916 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts @@ -131,4 +131,43 @@ export const getEditionsRoute = createRoute({ }, }); -export const routes = [getReferralLeaderboardRoute, getReferrerDetailRoute, getEditionsRoute]; +/** + * Query parameters schema for accounting CSV requests. + */ +const accountingQuerySchema = z.object({ + edition: makeReferralProgramEditionSlugSchema("edition"), +}); + +export const getAccountingCsvRoute = createRoute({ + method: "get", + path: "/accounting", + operationId: "getAccountingCsv", + tags: ["ENSAwards"], + summary: "Get Accounting Dump (CSV)", + description: + "Returns a full per-event accounting dump for a rev-share-cap edition as a CSV file, ordered chronologically.", + request: { + query: accountingQuerySchema, + }, + responses: { + 200: { + description: "Successfully retrieved per-event accounting CSV", + content: { + "text/csv": { + schema: z.string(), + }, + }, + }, + 400: { description: "Invalid request" }, + 404: { description: "Unknown edition slug" }, + 500: { description: "Internal server error" }, + 503: { description: "Service unavailable" }, + }, +}); + +export const routes = [ + getReferralLeaderboardRoute, + getReferrerDetailRoute, + getEditionsRoute, + getAccountingCsvRoute, +]; diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index 5df6b40a29..936557a3fa 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; -import * as editionsCachesMiddleware from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import * as editionsCachesMiddleware from "@/middleware/referral-edition-snapshots-caches.middleware"; import * as editionSetMiddleware from "@/middleware/referral-program-edition-set.middleware"; vi.mock("@/config", () => ({ @@ -20,8 +20,8 @@ vi.mock("@/middleware/referral-program-edition-set.middleware", () => ({ referralProgramEditionConfigSetMiddleware: vi.fn(), })); -vi.mock("@/middleware/referral-leaderboard-editions-caches.middleware", () => ({ - referralLeaderboardEditionsCachesMiddleware: vi.fn(), +vi.mock("@/middleware/referral-edition-snapshots-caches.middleware", () => ({ + referralEditionSnapshotsCachesMiddleware: vi.fn(), })); import { @@ -29,12 +29,13 @@ import { deserializeReferralProgramEditionSummariesResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, + type ReferralEditionSnapshot, + type ReferralProgramAwardModel, ReferralProgramAwardModels, type ReferralProgramEditionSlug, ReferralProgramEditionStatuses, ReferralProgramEditionSummariesResponseCodes, ReferrerEditionMetricsTypeIds, - type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, ReferrerMetricsEditionsResponseCodes, @@ -55,16 +56,17 @@ describe("/v1/ensanalytics", () => { describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { // Arrange: mock cache map with 2025-12 - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, ], - ); + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -79,9 +81,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -183,16 +185,17 @@ describe("/v1/ensanalytics", () => { it("returns empty cached referrer leaderboard when there are no referrals yet", async () => { // Arrange: mock cache map with 2025-12 - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => emptyReferralLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: emptyReferralLeaderboard }), + } as SWRCache, ], - ); + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -207,9 +210,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -245,22 +248,23 @@ describe("/v1/ensanalytics", () => { it("returns 404 error when unknown edition slug is requested", async () => { // Arrange: mock cache map with test-edition-a and test-edition-b - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "test-edition-a", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "test-edition-b", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + "test-edition-a", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, ], - ); + [ + "test-edition-b", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -276,9 +280,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -307,22 +311,23 @@ describe("/v1/ensanalytics", () => { describe("/referrer/:referrer", () => { it("returns referrer metrics for requested editions when referrer exists", async () => { // Arrange: mock cache map with multiple editions - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -338,9 +343,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -386,22 +391,23 @@ describe("/v1/ensanalytics", () => { it("returns zero-score metrics for requested editions when referrer does not exist", async () => { // Arrange: mock cache map with multiple editions - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -417,9 +423,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -475,22 +481,23 @@ describe("/v1/ensanalytics", () => { it("returns zero-score metrics for requested editions when leaderboards are empty", async () => { // Arrange: mock cache map with multiple editions, all empty - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => emptyReferralLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => emptyReferralLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: emptyReferralLeaderboard }), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => ({ leaderboard: emptyReferralLeaderboard }), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -506,9 +513,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -562,22 +569,23 @@ describe("/v1/ensanalytics", () => { it("returns error response when any requested edition cache fails to load", async () => { // Arrange: mock cache map where 2025-12 succeeds but 2026-03 fails - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ + [ + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], [ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => new Error("Database connection failed"), - } as SWRCache, - ], + "2026-03", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, ], - ); + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -593,9 +601,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -621,22 +629,23 @@ describe("/v1/ensanalytics", () => { it("returns error response when all requested edition caches fail to load", async () => { // Arrange: mock cache map where all editions fail - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => new Error("Database connection failed"), - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => new Error("Database connection failed"), - } as SWRCache, - ], + "2025-12", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => new Error("Database connection failed"), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -652,9 +661,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -681,22 +690,23 @@ describe("/v1/ensanalytics", () => { it("returns 404 error when unknown edition slug is requested", async () => { // Arrange: mock cache map with configured editions - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -712,9 +722,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -742,28 +752,29 @@ describe("/v1/ensanalytics", () => { it("returns only requested edition data when subset is requested", async () => { // Arrange: mock cache map with multiple editions - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-03", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], - [ - "2026-06", - { - read: async () => populatedReferrerLeaderboard, - } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + [ + "2026-06", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + ]); // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ @@ -780,9 +791,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware to provide the mock caches vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -869,26 +880,33 @@ describe("/v1/ensanalytics", () => { ); // Mock caches middleware with a cache for each edition - const mockEditionsCaches = new Map>( + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ [ - [ - "2025-12", - { read: async () => emptyReferralLeaderboard } as SWRCache, - ], - [ - "2026-03", - { read: async () => emptyReferralLeaderboard } as SWRCache, - ], - [ - "2026-06", - { read: async () => emptyReferralLeaderboard } as SWRCache, - ], + "2025-12", + { + read: async () => ({ leaderboard: emptyReferralLeaderboard }), + } as SWRCache, ], - ); + [ + "2026-03", + { + read: async () => ({ leaderboard: emptyReferralLeaderboard }), + } as SWRCache, + ], + [ + "2026-06", + { + read: async () => ({ leaderboard: emptyReferralLeaderboard }), + } as SWRCache, + ], + ]); vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", mockEditionsCaches); + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); return await next(); }); @@ -928,9 +946,9 @@ describe("/v1/ensanalytics", () => { // Mock caches middleware (needed by middleware chain even though /editions doesn't use it) vi.mocked( - editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware, + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralLeaderboardEditionsCaches", new Map()); + c.set("referralEditionSnapshotsCaches", new Map()); return await next(); }); @@ -949,4 +967,54 @@ describe("/v1/ensanalytics", () => { } }); }); + + describe("/accounting", () => { + function mockSnapshotCache( + slug: ReferralProgramEditionSlug, + awardModel: ReferralProgramAwardModel, + ): [ReferralProgramEditionSlug, SWRCache] { + return [ + slug, + { + read: async () => ({ awardModel }) as ReferralEditionSnapshot, + } as SWRCache, + ]; + } + + function setupAccountingMocks( + caches: Map>, + ): void { + vi.mocked( + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralEditionSnapshotsCaches", caches); + return await next(); + }); + } + + it("returns 404 when the edition slug is unknown", async () => { + setupAccountingMocks( + new Map([mockSnapshotCache("known-edition", ReferralProgramAwardModels.PieSplit)]), + ); + + const httpResponse = await app.request("/accounting?edition=unknown"); + + expect(httpResponse.status).toBe(404); + const body = await httpResponse.text(); + expect(body).toContain("unknown"); + expect(body).toContain("known-edition"); + }); + + it("returns 400 when the edition is pie-split (not rev-share-cap)", async () => { + setupAccountingMocks( + new Map([mockSnapshotCache("pie-edition", ReferralProgramAwardModels.PieSplit)]), + ); + + const httpResponse = await app.request("/accounting?edition=pie-edition"); + + expect(httpResponse.status).toBe(400); + const body = await httpResponse.text(); + expect(body).toContain("rev-share-cap"); + }); + }); }); diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index 814f093813..ffe2ea5a2b 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -2,6 +2,7 @@ import { buildEditionSummary, getReferrerEditionMetrics, getReferrerLeaderboardPage, + ReferralProgramAwardModels, type ReferralProgramEditionSlug, type ReferralProgramEditionSummariesResponse, ReferralProgramEditionSummariesResponseCodes, @@ -16,12 +17,14 @@ import { serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals"; +import { formatAccountingCsv } from "@/lib/ensanalytics/referrer-leaderboard/format-accounting-csv"; import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; -import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import { referralEditionSnapshotsCachesMiddleware } from "@/middleware/referral-edition-snapshots-caches.middleware"; import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; import { + getAccountingCsvRoute, getEditionsRoute, getReferralLeaderboardRoute, getReferrerDetailRoute, @@ -32,7 +35,7 @@ const logger = makeLogger("ensanalytics-api"); const app = createApp({ middlewares: [ referralProgramEditionConfigSetMiddleware, - referralLeaderboardEditionsCachesMiddleware, + referralEditionSnapshotsCachesMiddleware, ], }); @@ -42,9 +45,9 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { const { edition, page, recordsPerPage } = c.req.valid("query"); // Check if edition set failed to load - if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + if (c.var.referralEditionSnapshotsCaches instanceof Error) { logger.error( - { error: c.var.referralLeaderboardEditionsCaches }, + { error: c.var.referralEditionSnapshotsCaches }, "Referral program edition set failed to load", ); return c.json( @@ -58,10 +61,10 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { } // Get the specific edition's cache - const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition); + const editionCache = c.var.referralEditionSnapshotsCaches.get(edition); if (!editionCache) { - const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys()); + const configuredEditions = Array.from(c.var.referralEditionSnapshotsCaches.keys()); return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, @@ -73,10 +76,10 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { } // Read from the edition's cache - const leaderboard = await editionCache.read(); + const cached = await editionCache.read(); // Check if this specific edition failed to build - if (leaderboard instanceof Error) { + if (cached instanceof Error) { return c.json( serializeReferrerLeaderboardPageResponse({ responseCode: ReferrerLeaderboardPageResponseCodes.Error, @@ -87,7 +90,10 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { ); } - const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard); + const leaderboardPage = getReferrerLeaderboardPage( + { page, recordsPerPage }, + cached.leaderboard, + ); return c.json( serializeReferrerLeaderboardPageResponse({ @@ -119,9 +125,9 @@ app.openapi(getReferrerDetailRoute, async (c) => { const { editions } = c.req.valid("query"); // Check if edition set failed to load - if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + if (c.var.referralEditionSnapshotsCaches instanceof Error) { logger.error( - { error: c.var.referralLeaderboardEditionsCaches }, + { error: c.var.referralEditionSnapshotsCaches }, "Referral program edition set failed to load", ); return c.json( @@ -135,7 +141,7 @@ app.openapi(getReferrerDetailRoute, async (c) => { } // Type narrowing: at this point we know it's not an Error - const editionsCaches = c.var.referralLeaderboardEditionsCaches; + const editionsCaches = c.var.referralEditionSnapshotsCaches; // Validate that all requested editions are recognized (exist in the cache map) const configuredEditions = Array.from(editionsCaches.keys()); @@ -159,7 +165,8 @@ app.openapi(getReferrerDetailRoute, async (c) => { if (!editionCache) { throw new Error(`Invariant: edition cache for ${editionSlug} should exist`); } - const leaderboard = await editionCache.read(); + const result = await editionCache.read(); + const leaderboard = result instanceof Error ? result : result.leaderboard; return { editionSlug, leaderboard }; }), ); @@ -180,7 +187,7 @@ app.openapi(getReferrerDetailRoute, async (c) => { ); } - // Type narrowing: at this point all leaderboards are guaranteed to be non-Error + // Type narrowing: at this point all cached values are guaranteed to be non-Error const validEditionLeaderboards = editionLeaderboards.filter( ( item, @@ -241,9 +248,9 @@ app.openapi(getEditionsRoute, async (c) => { } // Check if leaderboard caches failed to load - if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + if (c.var.referralEditionSnapshotsCaches instanceof Error) { logger.error( - { error: c.var.referralLeaderboardEditionsCaches }, + { error: c.var.referralEditionSnapshotsCaches }, "Referral program leaderboard caches failed to load", ); return c.json( @@ -262,12 +269,14 @@ app.openapi(getEditionsRoute, async (c) => { ); // Read all leaderboard caches in parallel, keeping config colocated with its leaderboard - const leaderboardCaches = c.var.referralLeaderboardEditionsCaches; + const snapshotCaches = c.var.referralEditionSnapshotsCaches; const results = await Promise.all( editionConfigs.map(async (config) => { - const cache = leaderboardCaches.get(config.slug); + const cache = snapshotCaches.get(config.slug); if (!cache) throw new Error(`Invariant: edition cache for ${config.slug} should exist`); - return { config, leaderboard: await cache.read() }; + const result = await cache.read(); + const leaderboard = result instanceof Error ? result : result.leaderboard; + return { config, leaderboard }; }), ); @@ -325,4 +334,52 @@ app.openapi(getEditionsRoute, async (c) => { } }); +// Get a CSV dump of per-event accounting for a specific edition +app.openapi(getAccountingCsvRoute, async (c) => { + try { + const { edition } = c.req.valid("query"); + + if (c.var.referralEditionSnapshotsCaches instanceof Error) { + logger.error( + { error: c.var.referralEditionSnapshotsCaches }, + "Referral program edition set failed to load", + ); + return c.text("Service Unavailable: referral program configuration is unavailable", 503); + } + + const editionCache = c.var.referralEditionSnapshotsCaches.get(edition); + if (!editionCache) { + const configuredEditions = Array.from(c.var.referralEditionSnapshotsCaches.keys()); + return c.text( + `Not Found: unknown edition "${edition}". Known editions: ${configuredEditions.join(", ")}`, + 404, + ); + } + + const cached = await editionCache.read(); + if (cached instanceof Error) { + logger.error({ error: cached, edition }, "Leaderboard cache failed to build"); + return c.text("Service Unavailable: failed to build accounting trace for this edition", 503); + } + + if (cached.awardModel !== ReferralProgramAwardModels.RevShareCap) { + return c.text( + `Bad Request: edition "${edition}" is not a rev-share-cap edition (awardModel="${cached.awardModel}"). The accounting endpoint only supports rev-share-cap editions.`, + 400, + ); + } + + c.header("Content-Type", "text/csv; charset=utf-8"); + c.header("Content-Disposition", `attachment; filename="accounting-${edition}.csv"`); + return c.body(formatAccountingCsv(cached.accountingRecords), 200); + } catch (error) { + logger.error({ error }, "Error in /v1/ensanalytics/accounting endpoint"); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.text(`Internal server error: ${errorMessage}`, 500); + } +}); + export default app; diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index bf2bcde575..41e2959eb5 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -3,7 +3,7 @@ import config, { initEnvConfig } from "@/config"; import { serve } from "@hono/node-server"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; -import { getReferralLeaderboardEditionsCaches } from "@/cache/referral-leaderboard-editions.cache"; +import { getReferralEditionSnapshotsCaches } from "@/cache/referral-edition-snapshots.cache"; import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; import { redactEnsApiConfig } from "@/config/redact"; import { sdk } from "@/lib/instrumentation"; @@ -54,7 +54,7 @@ const gracefulShutdown = async () => { logger.info("Destroyed referralProgramEditionConfigSetCache"); // Destroy all edition caches (if initialized) - const editionsCaches = getReferralLeaderboardEditionsCaches(); + const editionsCaches = getReferralEditionSnapshotsCaches(); if (editionsCaches) { for (const [editionSlug, cache] of editionsCaches) { cache.destroy(); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts index 1a11fb7f11..1ba7f05fd0 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts @@ -5,10 +5,16 @@ import { type ReferrerMetrics, } from "@namehash/ens-referrals"; import { and, asc, count, desc, eq, gte, isNotNull, lte, ne, sql, sum } from "drizzle-orm"; -import { type NormalizedAddress, stringifyAccountId } from "enssdk"; +import { + type Address, + type InterpretedName, + type NormalizedAddress, + stringifyAccountId, +} from "enssdk"; +import type { Hash } from "viem"; import { zeroAddress } from "viem"; -import { deserializeDuration, priceEth } from "@ensnode/ensnode-sdk"; +import { deserializeDuration, priceEth, RegistrarActionTypes } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import logger from "@/lib/logger"; @@ -118,8 +124,22 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise`COALESCE(${ensIndexerSchema.registrarActions.total}, 0)`.as("total"), + // Audit fields (pass-through; the rev-share-cap race carries them on the per-event + // accounting record so consumers like the CSV endpoint don't need a second query). + actionType: ensIndexerSchema.registrarActions.type, + transactionHash: ensIndexerSchema.registrarActions.transactionHash, + registrant: ensIndexerSchema.registrarActions.registrant, + domainName: ensIndexerSchema.subgraph_domain.name, }) .from(ensIndexerSchema.registrarActions) + .innerJoin( + ensIndexerSchema.registrationLifecycles, + eq(ensIndexerSchema.registrarActions.node, ensIndexerSchema.registrationLifecycles.node), + ) + .innerJoin( + ensIndexerSchema.subgraph_domain, + eq(ensIndexerSchema.registrationLifecycles.node, ensIndexerSchema.subgraph_domain.id), + ) .where( and( // Filter by timestamp range @@ -138,25 +158,43 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise { + // referrer/timestamp/incrementalDuration are guaranteed non-null by the WHERE clause + // and NOT NULL schema constraints; total is guaranteed non-null by COALESCE. + if (record.referrer === null) { + throw new Error( + `getReferralEvents: decodedReferrer must be non-null for registrar action '${record.id}'`, + ); + } + if (record.domainName === null) { + throw new Error( + `getReferralEvents: domain.name must exist for registrar action '${record.id}'`, + ); + } + switch (record.actionType) { + case RegistrarActionTypes.Registration: + case RegistrarActionTypes.Renewal: + break; + default: { + const _exhaustive: never = record.actionType; + throw new Error( + `getReferralEvents: unrecognized action type '${String(_exhaustive)}' for registrar action '${record.id}'`, + ); + } + } - return (records as NonNullRecord[]).map((record) => ({ - id: record.id, - referrer: record.referrer, - timestamp: Number(record.timestamp), - incrementalDuration: Number(record.incrementalDuration), - incrementalRevenueContribution: priceEth(BigInt(record.total)), - })); + return { + id: record.id, + referrer: record.referrer as NormalizedAddress, + timestamp: Number(record.timestamp), + incrementalDuration: Number(record.incrementalDuration), + incrementalRevenueContribution: priceEth(BigInt(record.total)), + name: record.domainName as InterpretedName, + actionType: record.actionType, + transactionHash: record.transactionHash as Hash, + registrant: record.registrant as Address, + } satisfies ReferralEvent; + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error({ error }, "Failed to fetch referral events from database"); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts new file mode 100644 index 0000000000..5f15fe9569 --- /dev/null +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts @@ -0,0 +1,82 @@ +import type { ReferralAccountingRecordRevShareCap } from "@namehash/ens-referrals"; + +/** + * Escape a CSV string cell. Only wraps the value in quotes when it contains a comma, quote, + * or newline (per RFC 4180). ENS names are guaranteed not to contain commas (see issue #1797), + * but the `disqualificationReason` is free-form admin input so needs escaping. + */ +function csvCell(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** + * Column definitions for `GET /v1/ensanalytics/accounting?edition={slug}`. + * + * Each column owns both its header text and the cell renderer. Single source of truth for + * column order — adding/removing/reordering happens in one place. + */ +const CSV_COLUMNS: ReadonlyArray<{ + header: string; + value: (r: ReferralAccountingRecordRevShareCap) => string; +}> = [ + { header: "timestamp", value: (r) => new Date(r.timestamp * 1000).toISOString() }, + { header: "name", value: (r) => csvCell(r.name) }, + { header: "action", value: (r) => r.actionType }, + { header: "transactionHash", value: (r) => r.transactionHash }, + { header: "incrementalDuration", value: (r) => r.incrementalDuration.toString() }, + { header: "registrant", value: (r) => r.registrant }, + { header: "referrer", value: (r) => r.referrer }, + { + header: "incrementalRevenueContributionWei", + value: (r) => r.tentativeAward.incrementalRevenueContribution.amount.toString(), + }, + { + header: "accumulatedRevenueContributionWei", + value: (r) => r.tentativeAward.accumulatedRevenueContribution.amount.toString(), + }, + { + header: "incrementalBaseRevenueContributionUsdc", + value: (r) => r.tentativeAward.incrementalBaseRevenueContribution.amount.toString(), + }, + { + header: "accumulatedBaseRevenueContributionUsdc", + value: (r) => r.tentativeAward.accumulatedBaseRevenueContribution.amount.toString(), + }, + { + header: "awardPoolRemainingUsdc", + value: (r) => r.tentativeAward.awardPoolRemaining.amount.toString(), + }, + { header: "disqualified", value: (r) => (r.tentativeAward.disqualified ? "true" : "false") }, + { + header: "disqualificationReason", + value: (r) => + r.tentativeAward.disqualificationReason + ? csvCell(r.tentativeAward.disqualificationReason) + : "", + }, + { header: "maxRevShare", value: (r) => r.tentativeAward.maxRevShare.toString() }, + { + header: "effectiveBaseRevShare", + value: (r) => r.tentativeAward.effectiveBaseRevShare.toString(), + }, + { + header: "incrementalTentativeAwardUsdc", + value: (r) => r.tentativeAward.incrementalTentativeAward.amount.toString(), + }, +]; + +/** + * Formats per-event accounting records as RFC-4180 CSV (CRLF line endings, header row first). + */ +export function formatAccountingCsv( + records: ReadonlyArray, +): string { + const lines = [ + CSV_COLUMNS.map((c) => c.header).join(","), + ...records.map((r) => CSV_COLUMNS.map((c) => c.value(r)).join(",")), + ]; + return `${lines.join("\r\n")}\r\n`; +} diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot.test.ts similarity index 93% rename from apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts rename to apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot.test.ts index 74730b8448..22feee6be5 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import { parseEth, parseTimestamp, parseUsdc } from "@ensnode/ensnode-sdk"; import * as database from "./database"; -import { getReferrerLeaderboard } from "./get-referrer-leaderboard"; +import { getReferralEditionSnapshot } from "./get-referral-edition-snapshot"; import { dbResultsReferrerLeaderboard } from "./mocks"; // Mock the database module @@ -32,11 +32,11 @@ const rules = buildReferralProgramRulesPieSplit( const accurateAsOf = parseTimestamp("2025-11-30T23:59:59Z"); describe("ENSAnalytics Referrer Leaderboard", () => { - describe("getReferrerLeaderboard", () => { + describe("getReferralEditionSnapshot", () => { it("returns a leaderboard of referrers in the requested time period", async () => { vi.mocked(database.getReferrerMetrics).mockResolvedValue(dbResultsReferrerLeaderboard); - const result = await getReferrerLeaderboard(rules, accurateAsOf); + const { leaderboard: result } = await getReferralEditionSnapshot(rules, accurateAsOf); expect(result.awardModel).toBe(ReferralProgramAwardModels.PieSplit); if (result.awardModel !== ReferralProgramAwardModels.PieSplit) { @@ -107,7 +107,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { it("returns an empty list if no referrer leaderboard records were found in database", async () => { vi.mocked(database.getReferrerMetrics).mockResolvedValue([]); - const result = await getReferrerLeaderboard(rules, accurateAsOf); + const { leaderboard: result } = await getReferralEditionSnapshot(rules, accurateAsOf); expect(result).toMatchObject({ awardModel: rules.awardModel, diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot.ts similarity index 60% rename from apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.ts rename to apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot.ts index 05c0b50e7b..2aa7d83eac 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot.ts @@ -1,49 +1,49 @@ import { - buildReferrerLeaderboardPieSplit, - buildReferrerLeaderboardRevShareCap, + buildReferralEditionSnapshotPieSplit, + buildReferralEditionSnapshotRevShareCap, + type ReferralEditionSnapshot, ReferralProgramAwardModels, type ReferralProgramRules, - type ReferrerLeaderboard, } from "@namehash/ens-referrals"; import type { UnixTimestamp } from "enssdk"; import { getReferralEvents, getReferrerMetrics } from "./database"; /** - * Builds a {@link ReferrerLeaderboard} from the database using the provided referral program rules. + * Builds a {@link ReferralEditionSnapshot} from the database using the provided referral program rules. * * Dispatches to the appropriate model-specific builder based on `rules.awardModel`: * - PieSplit: uses aggregated referrer metrics (GROUP BY query). * - RevShareCap: uses raw referral events (no GROUP BY) for the sequential race algorithm. * * @param rules - The referral program rules for filtering registrar actions - * @param accurateAsOf - The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboard} was accurate as of. + * @param accurateAsOf - The {@link UnixTimestamp} of when the data used to build the {@link ReferralEditionSnapshot} was accurate as of. * @throws Error if the database query fails */ -export async function getReferrerLeaderboard( +export async function getReferralEditionSnapshot( rules: ReferralProgramRules, accurateAsOf: UnixTimestamp, -): Promise { +): Promise { switch (rules.awardModel) { case ReferralProgramAwardModels.PieSplit: { const allReferrers = await getReferrerMetrics(rules); - return buildReferrerLeaderboardPieSplit(allReferrers, rules, accurateAsOf); + return buildReferralEditionSnapshotPieSplit(allReferrers, rules, accurateAsOf); } case ReferralProgramAwardModels.RevShareCap: { const events = await getReferralEvents(rules); - return buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + return buildReferralEditionSnapshotRevShareCap(events, rules, accurateAsOf); } case ReferralProgramAwardModels.Unrecognized: // ReferralProgramRulesUnrecognized editions are filtered at cache-init time // and should never reach this function. throw new Error( - `getReferrerLeaderboard called with unrecognized award model '${rules.originalAwardModel}' — edition should have been filtered before reaching this point.`, + `getReferralEditionSnapshot called with unrecognized award model '${rules.originalAwardModel}' — edition should have been filtered before reaching this point.`, ); default: { const _exhaustiveCheck: never = rules; throw new Error( - `Unexpected award model in getReferrerLeaderboard: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`, + `Unexpected award model in getReferralEditionSnapshot: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`, ); } } diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index c7d4f0012d..cde2c2daaa 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -6,7 +6,7 @@ import { errorResponse } from "@/lib/handlers/error-response"; import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware"; -import type { ReferralLeaderboardEditionsCachesMiddlewareVariables } from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import type { ReferralEditionSnapshotsCachesMiddlewareVariables } from "@/middleware/referral-edition-snapshots-caches.middleware"; import type { ReferralProgramEditionConfigSetMiddlewareVariables } from "@/middleware/referral-program-edition-set.middleware"; import type { StackInfoMiddlewareVariables } from "@/middleware/stack-info.middleware"; @@ -14,7 +14,7 @@ export type MiddlewareVariables = IndexingStatusMiddlewareVariables & IsRealtimeMiddlewareVariables & CanAccelerateMiddlewareVariables & ReferralProgramEditionConfigSetMiddlewareVariables & - ReferralLeaderboardEditionsCachesMiddlewareVariables & + ReferralEditionSnapshotsCachesMiddlewareVariables & StackInfoMiddlewareVariables; type AppEnv = { Variables: Partial }; diff --git a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts b/apps/ensapi/src/middleware/referral-edition-snapshots-caches.middleware.ts similarity index 66% rename from apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts rename to apps/ensapi/src/middleware/referral-edition-snapshots-caches.middleware.ts index 791995ddb5..33b0c0f43c 100644 --- a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts +++ b/apps/ensapi/src/middleware/referral-edition-snapshots-caches.middleware.ts @@ -1,16 +1,16 @@ import { - initializeReferralLeaderboardEditionsCaches, - type ReferralLeaderboardEditionsCacheMap, -} from "@/cache/referral-leaderboard-editions.cache"; + initializeReferralEditionSnapshotsCaches, + type ReferralEditionSnapshotsCacheMap, +} from "@/cache/referral-edition-snapshots.cache"; import { factory, producing } from "@/lib/hono-factory"; import type { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; /** - * Type definition for the referral leaderboard editions caches middleware context passed to downstream middleware and handlers. + * Type definition for the referral edition snapshots caches middleware context passed to downstream middleware and handlers. */ -export type ReferralLeaderboardEditionsCachesMiddlewareVariables = { +export type ReferralEditionSnapshotsCachesMiddlewareVariables = { /** - * A map from edition slug to its dedicated {@link SWRCache} containing {@link ReferrerLeaderboard}. + * A map from edition slug to its dedicated {@link SWRCache} containing a {@link ReferralEditionSnapshot}. * * Returns an {@link Error} if the referral program edition config set failed to load. * @@ -20,17 +20,17 @@ export type ReferralLeaderboardEditionsCachesMiddlewareVariables = { * for other editions. * * When reading from a specific edition's cache, it will return either: - * - The {@link ReferrerLeaderboard} if successfully cached + * - The {@link ReferralEditionSnapshot} if successfully cached * - An {@link Error} if the cache failed to build * * Individual edition caches maintain their own stale-while-revalidate behavior, so a previously * successfully fetched edition continues serving its data even if a subsequent refresh fails. */ - referralLeaderboardEditionsCaches: ReferralLeaderboardEditionsCacheMap | Error; + referralEditionSnapshotsCaches: ReferralEditionSnapshotsCacheMap | Error; }; /** - * Middleware that provides {@link ReferralLeaderboardEditionsCachesMiddlewareVariables} + * Middleware that provides {@link ReferralEditionSnapshotsCachesMiddlewareVariables} * to downstream middleware and handlers. * * This middleware depends on {@link referralProgramEditionConfigSetMiddleware} to provide @@ -40,28 +40,28 @@ export type ReferralLeaderboardEditionsCachesMiddlewareVariables = { * Each cache's builder function handles immutability internally - when an edition becomes immutably * closed (past the safety window), the builder returns previously cached data without re-fetching. */ -export const referralLeaderboardEditionsCachesMiddleware = producing( - ["referralLeaderboardEditionsCaches"], +export const referralEditionSnapshotsCachesMiddleware = producing( + ["referralEditionSnapshotsCaches"], factory.createMiddleware(async (c, next) => { const editionConfigSet = c.get("referralProgramEditionConfigSet"); // Invariant: referralProgramEditionConfigSetMiddleware must be applied before this middleware if (editionConfigSet === undefined) { throw new Error( - "Invariant(referralLeaderboardEditionsCachesMiddleware): referralProgramEditionConfigSetMiddleware required", + "Invariant(referralEditionSnapshotsCachesMiddleware): referralProgramEditionConfigSetMiddleware required", ); } // If edition config set loading failed, propagate the error if (editionConfigSet instanceof Error) { - c.set("referralLeaderboardEditionsCaches", editionConfigSet); + c.set("referralEditionSnapshotsCaches", editionConfigSet); await next(); return; } // Initialize caches for the edition config set - const caches = initializeReferralLeaderboardEditionsCaches(editionConfigSet); - c.set("referralLeaderboardEditionsCaches", caches); + const caches = initializeReferralEditionSnapshotsCaches(editionConfigSet); + c.set("referralEditionSnapshotsCaches", caches); await next(); }), ); diff --git a/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts b/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts index d1cfb40f64..badd078401 100644 --- a/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts +++ b/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts @@ -56,11 +56,19 @@ export interface ReferrerLeaderboardPieSplit { accurateAsOf: UnixTimestamp; } -export const buildReferrerLeaderboardPieSplit = ( +/** + * A point-in-time snapshot of everything computed for a `pie-split` referral program edition. + */ +export interface ReferralEditionSnapshotPieSplit { + awardModel: typeof ReferralProgramAwardModels.PieSplit; + leaderboard: ReferrerLeaderboardPieSplit; +} + +export const buildReferralEditionSnapshotPieSplit = ( allReferrers: ReferrerMetrics[], rules: ReferralProgramRulesPieSplit, accurateAsOf: UnixTimestamp, -): ReferrerLeaderboardPieSplit => { +): ReferralEditionSnapshotPieSplit => { assertLeaderboardInputs(allReferrers, rules, accurateAsOf); const sortedReferrers = sortReferrerMetrics(allReferrers); @@ -79,5 +87,13 @@ export const buildReferrerLeaderboardPieSplit = ( const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); - return { awardModel: rules.awardModel, rules, aggregatedMetrics, referrers, accurateAsOf }; + const leaderboard: ReferrerLeaderboardPieSplit = { + awardModel: rules.awardModel, + rules, + aggregatedMetrics, + referrers, + accurateAsOf, + }; + + return { awardModel: rules.awardModel, leaderboard }; }; diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts new file mode 100644 index 0000000000..36c5c150ea --- /dev/null +++ b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts @@ -0,0 +1,316 @@ +import type { Address, InterpretedName } from "enssdk"; +import type { Hash } from "viem"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { parseEth, parseTimestamp, parseUsdc, priceEth } from "@ensnode/ensnode-sdk"; + +import { SECONDS_PER_YEAR } from "../../time"; +import { buildReferralEditionSnapshotRevShareCap } from "./leaderboard"; +import type { ReferralEvent } from "./referral-event"; +import { type AdminAction, AdminActionTypes, buildReferralProgramRulesRevShareCap } from "./rules"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as const; +const ADDR_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as const; + +const CHECKPOINT_PREFIX = + "0000000000" + "0000000000000001" + "0000000000000001" + "0000000000000000" + "0"; + +function buildTestRules( + awardPool = parseUsdc("1000"), + minBaseRevenueContribution = parseUsdc("5"), + adminActions: AdminAction[] = [], +) { + return buildReferralProgramRulesRevShareCap( + awardPool, + minBaseRevenueContribution, + parseUsdc("5"), + 0.5, + parseTimestamp("2026-01-01T00:00:00Z"), + parseTimestamp("2026-12-31T23:59:59Z"), + { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + new URL("https://example.com/rules"), + false, + adminActions, + ); +} + +let eventIdCounter = 0; +function makeEvent( + referrer: `0x${string}`, + timestamp: number, + incrementalDuration: number, +): ReferralEvent { + const counter = ++eventIdCounter; + return { + id: `${CHECKPOINT_PREFIX}${String(counter).padStart(16, "0")}`, + referrer, + timestamp, + incrementalDuration, + incrementalRevenueContribution: priceEth(0n), + name: "test.eth" as InterpretedName, + actionType: "registration", + transactionHash: "0x0000000000000000000000000000000000000000000000000000000000000000" as Hash, + registrant: "0xdddddddddddddddddddddddddddddddddddddddd" as Address, + }; +} + +const accurateAsOf = parseTimestamp("2026-06-01T00:00:00Z"); + +beforeEach(() => { + eventIdCounter = 0; +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("buildReferralEditionSnapshotRevShareCap — per-event trace", () => { + it("emits one trace entry per input event in chronological order", () => { + const rules = buildTestRules(); + const events = [ + makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2)), + makeEvent(ADDR_B, 1500, SECONDS_PER_YEAR), + makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2)), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + expect(accountingRecords).toHaveLength(3); + expect(accountingRecords.map((e) => e.registrarActionId)).toEqual(events.map((e) => e.id)); + expect(accountingRecords.map((e) => e.referrer)).toEqual([ADDR_A, ADDR_B, ADDR_A]); + }); + + it("not-yet-qualified: incrementalTentativeAward = 0, effectiveBaseRevShare = 0", () => { + // Half a year → base revenue = $2.50 < threshold ($5) + const rules = buildTestRules(); + const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + expect(accountingRecords).toHaveLength(1); + const record = accountingRecords[0]; + expect(record.tentativeAward.incrementalTentativeAward.amount).toBe(0n); + expect(record.tentativeAward.effectiveBaseRevShare).toBe(0); + expect(record.tentativeAward.disqualified).toBe(false); + expect(record.tentativeAward.disqualificationReason).toBeUndefined(); + expect(record.tentativeAward.awardPoolRemaining.amount).toBe(rules.awardPool.amount); + expect(record.tentativeAward.maxRevShare).toBe(rules.maxBaseRevenueShare); + }); + + it("newly-qualifying with full pool: claims accumulated uncapped award in one lump", () => { + // Event 1: half year (not qualified) — award = 0, poolBefore = 10000 + // Event 2: half year (crosses threshold) — accumulated uncapped = $2.50, poolBefore = 10000 + const rules = buildTestRules(parseUsdc("10000")); + const events = [ + makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2)), + makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2)), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + expect(accountingRecords).toHaveLength(2); + + const first = accountingRecords[0]; + expect(first.tentativeAward.incrementalTentativeAward.amount).toBe(0n); + + const second = accountingRecords[1]; + // Claims the accumulated uncapped award: $2.50 (not just this event's half-year) + expect(second.tentativeAward.incrementalTentativeAward.amount).toBe(parseUsdc("2.5").amount); + // awardPoolRemaining is captured BEFORE the event — still the full pool + expect(second.tentativeAward.awardPoolRemaining.amount).toBe(parseUsdc("10000").amount); + // On first-time qualification, effectiveBaseRevShare can exceed maxRevShare (claims past events' value). + expect(second.tentativeAward.effectiveBaseRevShare).toBeGreaterThan(rules.maxBaseRevenueShare); + }); + + it("newly-qualifying with tight pool: tentativeAward truncated to remaining pool", () => { + // Pool = $1.50, accumulated uncapped = $2.50 → capped at $1.50 + const rules = buildTestRules(parseUsdc("1.5")); + const events = [ + makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2)), + makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2)), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + const second = accountingRecords[1]; + expect(second.tentativeAward.incrementalTentativeAward.amount).toBe(parseUsdc("1.5").amount); + expect(second.tentativeAward.awardPoolRemaining.amount).toBe(parseUsdc("1.5").amount); + }); + + it("already-qualified: claims incremental uncapped award per event, effectiveBaseRevShare ≈ maxRevShare", () => { + // Event 1: 1 year → qualifies, claims $2.50, pool = 10000 → 9997.5 + // Event 2: 1 year → incremental $2.50, claims $2.50, pool = 9997.5 → 9995 + const rules = buildTestRules(parseUsdc("10000")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + // Event 2 is the already-qualified claim. + const second = accountingRecords[1]; + expect(second.tentativeAward.incrementalTentativeAward.amount).toBe(parseUsdc("2.5").amount); + // incrementalBase = $5, incrementalTentative = $2.50 → effective = 0.5 = maxRevShare + expect(second.tentativeAward.effectiveBaseRevShare).toBeCloseTo(rules.maxBaseRevenueShare, 10); + }); + + it("already-qualified with pool-truncation: effectiveBaseRevShare < maxRevShare", () => { + // Pool = $3.00 + // Event 1: 1 year → qualifies, claims min($2.50, $3) = $2.50, pool = $0.50 + // Event 2: 1 year → incremental $2.50, claims min($2.50, $0.50) = $0.50 (truncated), effective = 0.10 + const rules = buildTestRules(parseUsdc("3")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + const second = accountingRecords[1]; + expect(second.tentativeAward.incrementalTentativeAward.amount).toBe(parseUsdc("0.5").amount); + // awardPoolRemaining captured BEFORE this event = $0.50 + expect(second.tentativeAward.awardPoolRemaining.amount).toBe(parseUsdc("0.5").amount); + expect(second.tentativeAward.effectiveBaseRevShare).toBeLessThan(rules.maxBaseRevenueShare); + }); + + it("pool-exhausted: subsequent events claim 0 and awardPoolRemaining is 0", () => { + // Pool exactly $2.50 → A qualifies with $2.50 on event 1, then pool is empty + const rules = buildTestRules(parseUsdc("2.5")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + expect(accountingRecords[0].tentativeAward.incrementalTentativeAward.amount).toBe( + parseUsdc("2.5").amount, + ); + expect(accountingRecords[1].tentativeAward.incrementalTentativeAward.amount).toBe(0n); + expect(accountingRecords[1].tentativeAward.awardPoolRemaining.amount).toBe(0n); + expect(accountingRecords[1].tentativeAward.effectiveBaseRevShare).toBe(0); + }); + + it("admin-disqualified: every trace entry flags disqualified and tentativeAward is 0", () => { + const rules = buildTestRules(parseUsdc("10000"), parseUsdc("5"), [ + { actionType: AdminActionTypes.Disqualification, referrer: ADDR_A, reason: "sybil ring" }, + ]); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR), + ]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + for (const record of accountingRecords) { + expect(record.tentativeAward.disqualified).toBe(true); + expect(record.tentativeAward.disqualificationReason).toBe("sybil ring"); + expect(record.tentativeAward.incrementalTentativeAward.amount).toBe(0n); + expect(record.tentativeAward.effectiveBaseRevShare).toBe(0); + } + }); + + it("warning (non-disqualifying admin action) still allows awards, disqualified = false", () => { + const rules = buildTestRules(parseUsdc("10000"), parseUsdc("5"), [ + { actionType: AdminActionTypes.Warning, referrer: ADDR_A, reason: "be careful" }, + ]); + const events = [makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR)]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + expect(accountingRecords[0].tentativeAward.disqualified).toBe(false); + expect(accountingRecords[0].tentativeAward.disqualificationReason).toBeUndefined(); + expect(accountingRecords[0].tentativeAward.incrementalTentativeAward.amount).toBe( + parseUsdc("2.5").amount, + ); + }); + + it("leaderboard returned alongside trace matches buildReferralEditionSnapshotRevShareCap exactly", () => { + // Verify that the wrapper and the with-accounting function produce the same leaderboard. + const rules = buildTestRules(parseUsdc("10000")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_B, 1500, Math.floor(SECONDS_PER_YEAR / 2)), + makeEvent(ADDR_A, 2500, Math.floor(SECONDS_PER_YEAR / 2)), + ]; + + const leaderboard = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; + + // Deep spot-checks + expect(leaderboard.awardModel).toBe(rules.awardModel); + expect(leaderboard.rules).toBe(rules); + expect(leaderboard.accurateAsOf).toBe(accurateAsOf); + const a = leaderboard.referrers.get(ADDR_A)!; + expect(a.isQualified).toBe(true); + // A: 1.5 years → base $7.50 → uncapped $3.75 + expect(a.uncappedAward.amount).toBe(parseUsdc("3.75").amount); + }); + + it("totalRevenueContribution accumulates across events in the trace entries", () => { + const rules = buildTestRules(parseUsdc("10000")); + const e1 = makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2)); + e1.incrementalRevenueContribution = parseEth("1"); + const e2 = makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2)); + e2.incrementalRevenueContribution = parseEth("0.5"); + const events = [e1, e2]; + + const accountingRecords = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).accountingRecords; + + expect(accountingRecords[0].tentativeAward.accumulatedRevenueContribution.amount).toBe( + parseEth("1").amount, + ); + expect(accountingRecords[1].tentativeAward.accumulatedRevenueContribution.amount).toBe( + parseEth("1.5").amount, + ); + expect(accountingRecords[0].tentativeAward.incrementalRevenueContribution.amount).toBe( + parseEth("1").amount, + ); + expect(accountingRecords[1].tentativeAward.incrementalRevenueContribution.amount).toBe( + parseEth("0.5").amount, + ); + }); +}); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts new file mode 100644 index 0000000000..3f52e0e0a0 --- /dev/null +++ b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts @@ -0,0 +1,131 @@ +import type { Address, Duration, InterpretedName, NormalizedAddress, UnixTimestamp } from "enssdk"; +import type { Hash } from "viem"; + +import type { PriceEth, PriceUsdc, RegistrarActionType } from "@ensnode/ensnode-sdk"; + +/** + * Per-event accounting math for a single referral under a rev-share-cap edition. + */ +export interface TentativeReferralAwardRevShareCap { + /** + * Revenue contribution in ETH from this single referral event. + */ + incrementalRevenueContribution: PriceEth; + + /** + * Running total of the referrer's revenue contribution in ETH, through and including this event. + */ + accumulatedRevenueContribution: PriceEth; + + /** + * Base revenue contribution in USDC attributable to this single referral event's duration. + */ + incrementalBaseRevenueContribution: PriceUsdc; + + /** + * The referrer's accumulated base revenue contribution in USDC, through and including this event. + */ + accumulatedBaseRevenueContribution: PriceUsdc; + + /** + * The award pool in USDC remaining. + * + * @invariant Reflects the pool state *before* this event was processed. + */ + awardPoolRemaining: PriceUsdc; + + /** + * `true` if and only if the referrer has an admin-disqualification action for this edition. + * + * @invariant Reflects admin disqualification only; NOT set when the referrer has simply not + * yet met the minimum-base-revenue threshold. + */ + disqualified: boolean; + + /** + * The admin-disqualification reason string, verbatim from the admin action. + * + * @invariant Defined if and only if `disqualified === true`. + */ + disqualificationReason?: string; + + /** + * A copy of `rules.maxBaseRevenueShare` at the time of this event. + */ + maxRevShare: number; + + /** + * The effective fraction of base revenue that was awarded for this event. Derived as + * `incrementalTentativeAward.amount / incrementalBaseRevenueContribution.amount`. + * Useful for diagnosing partial-pool truncation and first-time-qualification catch-up awards. + * + * @invariant Equals `0` when `incrementalBaseRevenueContribution.amount === 0n`. + * @invariant `<= maxRevShare` for the already-qualified branch. May exceed `maxRevShare` on + * the event that triggers first-time qualification, because accumulated uncapped + * award from prior events is claimed against this event's base revenue. + */ + effectiveBaseRevShare: number; + + /** + * The tentative USDC award attributed to this single referral event. + * + * On the event that triggers first-time qualification, this includes the accumulated uncapped + * award from prior events in which the referrer had not yet qualified (capped by the remaining + * award pool). On subsequent events, it is the per-event uncapped award capped by the pool. + * + * @invariant `amount === 0n` when the referrer is admin-disqualified OR has not yet qualified + * as of this event. Also zero when the pool is empty. + * @invariant `amount <= awardPoolRemaining.amount`. + */ + incrementalTentativeAward: PriceUsdc; +} + +/** + * A single per-event accounting record for a referral under a rev-share-cap edition. + */ +export interface ReferralAccountingRecordRevShareCap { + /** + * The registrar-action identifier. + */ + registrarActionId: string; + + /** + * Block timestamp of the referral event. + */ + timestamp: UnixTimestamp; + + /** + * FQDN of the name the referral applies to. + */ + name: InterpretedName; + + /** + * Type of registrar action. + */ + actionType: RegistrarActionType; + + /** + * Transaction hash of the referral. + */ + transactionHash: Hash; + + /** + * Registrant that paid for / performed the action. + */ + registrant: Address; + + /** + * Referrer that received credit. + */ + referrer: NormalizedAddress; + + /** + * Incremental duration (seconds) contributed by this referral. + */ + incrementalDuration: Duration; + + /** + * Per-event accounting math from the rev-share-cap race. + */ + tentativeAward: TentativeReferralAwardRevShareCap; +} diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts index 8915aa5a63..900cea5330 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts @@ -1,4 +1,5 @@ -import type { NormalizedAddress } from "enssdk"; +import type { Address, InterpretedName, NormalizedAddress } from "enssdk"; +import type { Hash } from "viem"; import { beforeEach, describe, expect, it } from "vitest"; import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; @@ -6,7 +7,7 @@ import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode import { SECONDS_PER_YEAR } from "../../time"; import { buildReferrerLeaderboardPageContext } from "../shared/leaderboard-page"; import { ReferralProgramEditionStatuses } from "../shared/status"; -import { buildReferrerLeaderboardRevShareCap } from "./leaderboard"; +import { buildReferralEditionSnapshotRevShareCap } from "./leaderboard"; import { buildLeaderboardPageRevShareCap } from "./leaderboard-page"; import type { ReferralEvent } from "./referral-event"; import { type AdminAction, AdminActionTypes, buildReferralProgramRulesRevShareCap } from "./rules"; @@ -84,6 +85,10 @@ function makeEvent( timestamp, incrementalDuration, incrementalRevenueContribution: ZERO_ETH, + name: "test.eth" as InterpretedName, + actionType: "registration", + transactionHash: "0x0000000000000000000000000000000000000000000000000000000000000000" as Hash, + registrant: "0xdddddddddddddddddddddddddddddddddddddddd" as Address, }; } @@ -104,14 +109,14 @@ function warning(referrer: NormalizedAddress, reason: string): AdminAction { // ─── Tests ──────────────────────────────────────────────────────────────────── -describe("buildReferrerLeaderboardRevShareCap", () => { +describe("buildReferralEditionSnapshotRevShareCap", () => { beforeEach(() => { eventIdCounter = 0; }); it("returns empty leaderboard when events list is empty", () => { const rules = buildTestRules(); - const result = buildReferrerLeaderboardRevShareCap([], rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap([], rules, accurateAsOf).leaderboard; expect(result.awardModel).toBe(rules.awardModel); expect(result.rules).toBe(rules); @@ -131,7 +136,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; const rules = buildTestRules(); - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrer = result.referrers.get(ADDR_A)!; expect(referrer).toBeDefined(); @@ -156,7 +165,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2)), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrer = result.referrers.get(ADDR_A)!; expect(referrer.isQualified).toBe(true); @@ -176,7 +189,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2)), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrer = result.referrers.get(ADDR_A)!; expect(referrer.isQualified).toBe(true); @@ -200,7 +217,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrer = result.referrers.get(ADDR_A)!; expect(referrer.isQualified).toBe(true); @@ -222,7 +243,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrer = result.referrers.get(ADDR_A)!; expect(referrer.isQualified).toBe(true); @@ -241,7 +266,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const rules = buildTestRules(priceUsdc(0n)); const events = [makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR)]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrer = result.referrers.get(ADDR_A)!; expect(referrer.isQualified).toBe(true); @@ -261,7 +290,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; @@ -285,7 +318,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; @@ -306,7 +343,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_C, 3000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; const referrerC = result.referrers.get(ADDR_C)!; @@ -336,7 +377,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { }), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; // ADDR_A has the lower (earlier) id, should claim the pool first expect(result.referrers.get(ADDR_A)!.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); @@ -357,7 +402,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_C, 3000, Math.floor(SECONDS_PER_YEAR / 2)), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; // ADDR_B: cappedAward $5.00 → rank 1 (highest pool claim) // ADDR_A: cappedAward $2.50 → rank 2 @@ -377,7 +426,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; // Both have $0 cappedAward; ADDR_A has higher uncappedAward (longer duration) → rank 1 expect(result.referrers.get(ADDR_A)!.rank).toBe(1); @@ -391,7 +444,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR * 2), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const ranks = [...result.referrers.values()].map((r) => r.rank); expect(ranks).toEqual([1, 2]); }); @@ -405,7 +462,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const rules = buildTestRules(parseUsdc("2.5")); const events = [makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR)]; - const leaderboard = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const leaderboard = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; expect(leaderboard.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); const pageContext = buildReferrerLeaderboardPageContext({ page: 1 }, leaderboard); @@ -433,7 +494,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, Math.floor(SECONDS_PER_YEAR / 2)), // below threshold: $5 base ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; @@ -462,7 +527,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 3000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; expect(result.aggregatedMetrics.grandTotalReferrals).toBe(3); expect(result.aggregatedMetrics.grandTotalIncrementalDuration).toBe(3 * SECONDS_PER_YEAR); @@ -477,7 +546,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; @@ -500,7 +573,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), // qualifies normally ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; @@ -521,7 +598,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { ]); const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); @@ -550,7 +631,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_C, 3000, Math.floor(SECONDS_PER_YEAR / 2)), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; const referrerC = result.referrers.get(ADDR_C)!; @@ -583,7 +668,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_C, 3000, SECONDS_PER_YEAR), // only C qualifies and claims ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; const referrerC = result.referrers.get(ADDR_C)!; @@ -623,7 +712,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), ]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; @@ -642,7 +735,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { ]); const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; - const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const result = buildReferralEditionSnapshotRevShareCap( + events, + rules, + accurateAsOf, + ).leaderboard; const referrerA = result.referrers.get(ADDR_A)!; expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Warning); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts index 0c475fc643..09349aa6ba 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts @@ -13,6 +13,10 @@ import { import { buildReferrerMetrics } from "../../referrer-metrics"; import type { ReferralProgramAwardModels } from "../shared/rules"; +import type { + ReferralAccountingRecordRevShareCap, + TentativeReferralAwardRevShareCap, +} from "./accounting"; import type { AggregatedReferrerMetricsRevShareCap } from "./aggregations"; import { buildAggregatedReferrerMetricsRevShareCap } from "./aggregations"; import type { AwardedReferrerMetricsRevShareCap } from "./metrics"; @@ -23,6 +27,8 @@ import { } from "./metrics"; import type { ReferralEvent } from "./referral-event"; import { + type AdminAction, + AdminActionTypes, calcBaseRevenueContribution, isReferrerQualifiedRevShareCap, type ReferralProgramRulesRevShareCap, @@ -71,6 +77,17 @@ export interface ReferrerLeaderboardRevShareCap { accurateAsOf: UnixTimestamp; } +/** + * A point-in-time snapshot of everything computed for a `rev-share-cap` referral program edition. + * + * @invariant `accountingRecords` are in chronological onchain order (one per processed event). + */ +export interface ReferralEditionSnapshotRevShareCap { + awardModel: typeof ReferralProgramAwardModels.RevShareCap; + leaderboard: ReferrerLeaderboardRevShareCap; + accountingRecords: ReferralAccountingRecordRevShareCap[]; +} + /** * Per-referrer mutable state used during sequential race processing. */ @@ -89,7 +106,7 @@ interface ReferrerRaceState { } /** - * Builds a {@link ReferrerLeaderboardRevShareCap} using a sequential "first-come, first-served" + * Builds a {@link ReferralEditionSnapshotRevShareCap} using a sequential "first-come, first-served" * race algorithm over individual referral events. * * Events are processed in chronological order. When a referrer first crosses the qualification @@ -101,16 +118,23 @@ interface ReferrerRaceState { * @param rules - The {@link ReferralProgramRulesRevShareCap} defining the program parameters. * @param accurateAsOf - Timestamp indicating data freshness. */ -export const buildReferrerLeaderboardRevShareCap = ( +export const buildReferralEditionSnapshotRevShareCap = ( events: ReferralEvent[], rules: ReferralProgramRulesRevShareCap, accurateAsOf: UnixTimestamp, -): ReferrerLeaderboardRevShareCap => { +): ReferralEditionSnapshotRevShareCap => { // 1. Sort events into chronological order by onchain execution order. const sortedEvents = sortReferralEvents(events); + // Precompute admin-action lookup (O(1) per event). + const adminActionByReferrer = new Map(); + for (const action of rules.adminActions) { + adminActionByReferrer.set(action.referrer, action); + } + // 2. Process events sequentially to run the race. const referrerStates = new Map(); + const accountingRecords: ReferralAccountingRecordRevShareCap[] = []; let awardPoolRemaining: PriceUsdc = rules.awardPool; for (const event of sortedEvents) { @@ -128,7 +152,8 @@ export const buildReferrerLeaderboardRevShareCap = ( referrerStates.set(referrerId, referrerState); } - // Update raw totals. + // Update raw totals BEFORE computing the accounting record. + // (The per-event accounting uses the post-event accumulated totals.) referrerState.totalReferrals += 1; referrerState.totalIncrementalDuration += event.incrementalDuration; referrerState.totalRevenueContribution = addPrices( @@ -136,36 +161,81 @@ export const buildReferrerLeaderboardRevShareCap = ( event.incrementalRevenueContribution, ); - // Compute totalBaseRevenue from aggregated duration (single division — avoids per-event - // truncation that would compound into a sum lower than the correct aggregated value). - const totalBaseRevenue = calcBaseRevenueContribution( + const hasQualifiedBefore = referrerState.hasQualified; + const awardPoolRemainingBefore = awardPoolRemaining; + const adminAction = adminActionByReferrer.get(referrerId); + const adminDisqualification = + adminAction?.actionType === AdminActionTypes.Disqualification ? adminAction : null; + + const accumulatedBaseRevenueContribution = calcBaseRevenueContribution( rules, referrerState.totalIncrementalDuration, ); + const incrementalBaseRevenueContribution = calcBaseRevenueContribution( + rules, + event.incrementalDuration, + ); - // Determine if newly qualifying or already qualified. - const isNowQualified = isReferrerQualifiedRevShareCap(referrerId, totalBaseRevenue, rules); + const isNowQualified = isReferrerQualifiedRevShareCap( + referrerId, + accumulatedBaseRevenueContribution, + rules, + ); - if (isNowQualified && !referrerState.hasQualified) { - // First time crossing the qualification threshold: claim all accumulated uncapped award. - // Compute from aggregated totals to match the single-division used in final output. - const accumulatedUncappedAward = scalePrice(totalBaseRevenue, rules.maxBaseRevenueShare); - const incrementalCappedAward = minPrice(accumulatedUncappedAward, awardPoolRemaining); - referrerState.cappedAward = addPrices(referrerState.cappedAward, incrementalCappedAward); - awardPoolRemaining = subtractPrice(awardPoolRemaining, incrementalCappedAward); + // admin-disqualified → 0 + // newly-qualifying → claim accumulated uncapped award (catch-up), capped by pool + // already-qualified → claim this event's incremental uncapped award, capped by pool + // not yet qualified → 0 + let incrementalTentativeAward: PriceUsdc = priceUsdc(0n); + if (isNowQualified && !hasQualifiedBefore) { + const accumulatedUncappedAward = scalePrice( + accumulatedBaseRevenueContribution, + rules.maxBaseRevenueShare, + ); + incrementalTentativeAward = minPrice(accumulatedUncappedAward, awardPoolRemainingBefore); referrerState.hasQualified = true; - } else if (referrerState.hasQualified) { - // Already qualified: claim this event's incremental uncapped award. - const incrementalBaseRevenue = calcBaseRevenueContribution(rules, event.incrementalDuration); + } else if (hasQualifiedBefore) { const incrementalUncappedAward = scalePrice( - incrementalBaseRevenue, + incrementalBaseRevenueContribution, rules.maxBaseRevenueShare, ); - const incrementalCappedAward = minPrice(incrementalUncappedAward, awardPoolRemaining); - referrerState.cappedAward = addPrices(referrerState.cappedAward, incrementalCappedAward); - awardPoolRemaining = subtractPrice(awardPoolRemaining, incrementalCappedAward); + incrementalTentativeAward = minPrice(incrementalUncappedAward, awardPoolRemainingBefore); } - // If not yet qualified, nothing is claimed from the pool. + + // Apply the claim to referrer state + pool (zero-amount claim is a no-op via bigint math). + referrerState.cappedAward = addPrices(referrerState.cappedAward, incrementalTentativeAward); + awardPoolRemaining = subtractPrice(awardPoolRemaining, incrementalTentativeAward); + + const tentativeAward: TentativeReferralAwardRevShareCap = { + incrementalRevenueContribution: event.incrementalRevenueContribution, + accumulatedRevenueContribution: referrerState.totalRevenueContribution, + incrementalBaseRevenueContribution, + accumulatedBaseRevenueContribution, + awardPoolRemaining: awardPoolRemainingBefore, + disqualified: adminDisqualification !== null, + ...(adminDisqualification !== null && { + disqualificationReason: adminDisqualification.reason, + }), + maxRevShare: rules.maxBaseRevenueShare, + effectiveBaseRevShare: + incrementalBaseRevenueContribution.amount === 0n + ? 0 + : Number(incrementalTentativeAward.amount) / + Number(incrementalBaseRevenueContribution.amount), + incrementalTentativeAward, + }; + + accountingRecords.push({ + registrarActionId: event.id, + timestamp: event.timestamp, + name: event.name, + actionType: event.actionType, + transactionHash: event.transactionHash, + registrant: event.registrant, + referrer: referrerId, + incrementalDuration: event.incrementalDuration, + tentativeAward, + }); } // 3. Sort referrers to assign ranks: @@ -230,5 +300,15 @@ export const buildReferrerLeaderboardRevShareCap = ( const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); - return { awardModel: rules.awardModel, rules, aggregatedMetrics, referrers, accurateAsOf }; + return { + awardModel: rules.awardModel, + leaderboard: { + awardModel: rules.awardModel, + rules, + aggregatedMetrics, + referrers, + accurateAsOf, + }, + accountingRecords, + }; }; diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts b/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts index af8b7e4621..c258b32c98 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts @@ -1,6 +1,7 @@ -import type { Duration, NormalizedAddress, UnixTimestamp } from "enssdk"; +import type { Address, Duration, InterpretedName, NormalizedAddress, UnixTimestamp } from "enssdk"; +import type { Hash } from "viem"; -import type { PriceEth } from "@ensnode/ensnode-sdk"; +import type { PriceEth, RegistrarActionType } from "@ensnode/ensnode-sdk"; /** * Represents a single raw referral event. @@ -38,4 +39,24 @@ export interface ReferralEvent { * Revenue contribution in ETH from this single referral event. */ incrementalRevenueContribution: PriceEth; + + /** + * FQDN of the name the referral applies to. + */ + name: InterpretedName; + + /** + * Type of registrar action. + */ + actionType: RegistrarActionType; + + /** + * Transaction hash of the onchain registrar action that produced this referral. + */ + transactionHash: Hash; + + /** + * Ethereum address of the registrant that paid for / performed the action. + */ + registrant: Address; } diff --git a/packages/ens-referrals/src/index.ts b/packages/ens-referrals/src/index.ts index bb09aced5f..fc161023a5 100644 --- a/packages/ens-referrals/src/index.ts +++ b/packages/ens-referrals/src/index.ts @@ -10,6 +10,7 @@ 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-cap/accounting"; export * from "./award-models/rev-share-cap/aggregations"; export * from "./award-models/rev-share-cap/api/serialized-types"; export * from "./award-models/rev-share-cap/edition-metrics"; diff --git a/packages/ens-referrals/src/leaderboard.ts b/packages/ens-referrals/src/leaderboard.ts index 60dc9cbdc8..949fec1d41 100644 --- a/packages/ens-referrals/src/leaderboard.ts +++ b/packages/ens-referrals/src/leaderboard.ts @@ -1,5 +1,11 @@ -import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; -import type { ReferrerLeaderboardRevShareCap } from "./award-models/rev-share-cap/leaderboard"; +import type { + ReferralEditionSnapshotPieSplit, + ReferrerLeaderboardPieSplit, +} from "./award-models/pie-split/leaderboard"; +import type { + ReferralEditionSnapshotRevShareCap, + ReferrerLeaderboardRevShareCap, +} from "./award-models/rev-share-cap/leaderboard"; /** * Represents a leaderboard for any number of referrers. @@ -7,3 +13,10 @@ import type { ReferrerLeaderboardRevShareCap } from "./award-models/rev-share-ca * Use `awardModel` to narrow the specific variant at runtime. */ export type ReferrerLeaderboard = ReferrerLeaderboardPieSplit | ReferrerLeaderboardRevShareCap; + +/** + * A point-in-time snapshot of everything computed for a referral program edition. + */ +export type ReferralEditionSnapshot = + | ReferralEditionSnapshotPieSplit + | ReferralEditionSnapshotRevShareCap; From b2af0db230c5d4e7690343b9c09d3db65a14d55e Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 27 Apr 2026 20:07:14 +0200 Subject: [PATCH 02/12] review --- .../cache/referral-edition-snapshots.cache.ts | 22 +-- .../ensanalytics/ensanalytics-api.test.ts | 130 +++++++++++++++++- apps/ensapi/src/index.ts | 2 +- .../format-accounting-csv.ts | 16 +-- .../src/award-models/pie-split/leaderboard.ts | 9 ++ .../rev-share-cap/accounting.test.ts | 7 +- .../rev-share-cap/leaderboard.test.ts | 4 +- .../award-models/rev-share-cap/leaderboard.ts | 19 ++- packages/ens-referrals/src/leaderboard.ts | 2 + 9 files changed, 176 insertions(+), 35 deletions(-) diff --git a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts index de821c7791..692b24ec79 100644 --- a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts +++ b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts @@ -38,9 +38,9 @@ export type ReferralEditionSnapshotsCacheMap = Map< /** * The list of {@link OmnichainIndexingStatusId} values that are supported for generating - * referrer leaderboards. + * edition snapshots. * - * Other values indicate that we are not ready to generate leaderboards yet. + * Other values indicate that we are not ready to generate snapshots yet. */ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ OmnichainIndexingStatusIds.Following, @@ -54,7 +54,7 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ * If so, it returns the cached data without re-fetching. Otherwise, it fetches fresh data. * * @param editionConfig - The edition configuration - * @returns A function that builds the leaderboard for the given edition + * @returns A function that builds the edition snapshot for the given edition */ function createEditionSnapshotBuilder( editionConfig: ReferralProgramEditionConfig, @@ -84,17 +84,17 @@ function createEditionSnapshotBuilder( if (indexingStatus instanceof Error) { logger.error( { error: indexingStatus, editionSlug }, - `Failed to read indexing status cache while generating referral leaderboard for ${editionSlug}. Cannot proceed without valid indexing status.`, + `Failed to read indexing status cache while generating edition snapshot for ${editionSlug}. Cannot proceed without valid indexing status.`, ); throw new Error( - `Unable to generate referral leaderboard for ${editionSlug}. indexingStatusCache must have been successfully initialized.`, + `Unable to generate edition snapshot for ${editionSlug}. indexingStatusCache must have been successfully initialized.`, ); } const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { throw new Error( - `Unable to generate referrer leaderboard for ${editionSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, + `Unable to generate edition snapshot for ${editionSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, ); } @@ -104,12 +104,12 @@ function createEditionSnapshotBuilder( ); if (latestIndexedBlockRef === null) { throw new Error( - `Unable to generate referrer leaderboard for ${editionSlug}. Latest indexed block ref for chain ${editionConfig.rules.subregistryId.chainId} is null.`, + `Unable to generate edition snapshot for ${editionSlug}. Latest indexed block ref for chain ${editionConfig.rules.subregistryId.chainId} is null.`, ); } logger.info( - `Building referrer leaderboard for ${editionSlug} with rules:\n${JSON.stringify( + `Building edition snapshot for ${editionSlug} with rules:\n${JSON.stringify( serializeReferralProgramRules(editionConfig.rules), null, 2, @@ -122,7 +122,7 @@ function createEditionSnapshotBuilder( ); logger.info( - `Successfully built referrer leaderboard for ${editionSlug} with ${snapshot.leaderboard.referrers.size} referrers`, + `Successfully built edition snapshot for ${editionSlug} with ${snapshot.leaderboard.referrers.size} referrers`, ); return snapshot; @@ -165,7 +165,7 @@ export function initializeReferralEditionSnapshotsCaches( }); caches.set(editionSlug, cache); - logger.info(`Initialized leaderboard cache for ${editionSlug}`); + logger.info(`Initialized edition snapshot cache for ${editionSlug}`); } // Cache the instance for subsequent calls @@ -174,7 +174,7 @@ export function initializeReferralEditionSnapshotsCaches( } /** - * Gets the cached instance of referral leaderboard editions caches. + * Gets the cached instance of referral edition snapshots caches. * Returns null if not yet initialized. * * @returns The cached cache map or null diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index 53e0a2f81c..f9b7698c13 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -29,6 +29,7 @@ import { deserializeReferralProgramEditionSummariesResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, + type ReferralAccountingRecordRevShareCap, type ReferralEditionSnapshot, type ReferralProgramAwardModel, ReferralProgramAwardModels, @@ -36,15 +37,22 @@ import { ReferralProgramEditionStatuses, ReferralProgramEditionSummariesResponseCodes, type ReferralProgramRulesPieSplit, - type ReferrerLeaderboard, ReferrerEditionMetricsTypeIds, + type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, ReferrerMetricsEditionsResponseCodes, type ReferrerMetricsEditionsResponseOk, } from "@namehash/ens-referrals"; +import { asInterpretedName, toNormalizedAddress } from "enssdk"; -import { parseTimestamp, parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; +import { + parseEth, + parseTimestamp, + parseUsdc, + RegistrarActionTypes, + type SWRCache, +} from "@ensnode/ensnode-sdk"; import { emptyReferralLeaderboard, @@ -984,6 +992,8 @@ describe("/v1/ensanalytics", () => { }); describe("/accounting", () => { + // Minimal snapshot — only `awardModel` is set. Use only for tests that short-circuit + // on the awardModel check (404, 400); use mockRevShareCapAccountingCache otherwise. function mockSnapshotCache( slug: ReferralProgramEditionSlug, awardModel: ReferralProgramAwardModel, @@ -996,17 +1006,69 @@ describe("/v1/ensanalytics", () => { ]; } + function mockRevShareCapAccountingCache( + slug: ReferralProgramEditionSlug, + accountingRecords: ReferralAccountingRecordRevShareCap[], + ): [ReferralProgramEditionSlug, SWRCache] { + return [ + slug, + { + read: async () => + ({ + awardModel: ReferralProgramAwardModels.RevShareCap, + accountingRecords, + }) as ReferralEditionSnapshot, + } as SWRCache, + ]; + } + + function mockReadErrorCache( + slug: ReferralProgramEditionSlug, + error: Error, + ): [ReferralProgramEditionSlug, SWRCache] { + return [ + slug, + { + read: async () => error, + } as unknown as SWRCache, + ]; + } + function setupAccountingMocks( - caches: Map>, + cachesOrError: Map> | Error, ): void { vi.mocked( editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, ).mockImplementation(async (c, next) => { - c.set("referralEditionSnapshotsCaches", caches); + c.set("referralEditionSnapshotsCaches", cachesOrError); return await next(); }); } + function buildAccountingRecord(): ReferralAccountingRecordRevShareCap { + return { + registrarActionId: "registration:1:0xabc:0", + timestamp: 1735689600, + name: asInterpretedName("alice.eth"), + actionType: RegistrarActionTypes.Registration, + transactionHash: "0xabc", + registrant: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e", + referrer: toNormalizedAddress("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"), + incrementalDuration: 31536000, + tentativeAward: { + incrementalRevenueContribution: parseEth("0.01"), + accumulatedRevenueContribution: parseEth("0.01"), + incrementalBaseRevenueContribution: parseUsdc("100"), + accumulatedBaseRevenueContribution: parseUsdc("100"), + awardPoolRemaining: parseUsdc("9900"), + disqualified: false, + maxRevShare: 0.5, + effectiveBaseRevShare: 0.5, + incrementalTentativeAward: parseUsdc("50"), + }, + } satisfies ReferralAccountingRecordRevShareCap; + } + it("returns 404 when the edition slug is unknown", async () => { setupAccountingMocks( new Map([mockSnapshotCache("known-edition", ReferralProgramAwardModels.PieSplit)]), @@ -1031,5 +1093,65 @@ describe("/v1/ensanalytics", () => { const body = await httpResponse.text(); expect(body).toContain("rev-share-cap"); }); + + it("returns 200 with a CSV body for a rev-share-cap edition", async () => { + setupAccountingMocks( + new Map([mockRevShareCapAccountingCache("rsc-edition", [buildAccountingRecord()])]), + ); + + const httpResponse = await app.request("/accounting?edition=rsc-edition"); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.headers.get("content-type")).toBe("text/csv; charset=utf-8"); + expect(httpResponse.headers.get("content-disposition")).toContain( + 'filename="accounting-rsc-edition.csv"', + ); + + // Wiring assertions only: header row present + 1 record row in correct CRLF format. + // The exact CSV cell values are exercised by `formatAccountingCsv`'s own unit coverage. + const body = await httpResponse.text(); + const expectedHeaderRow = + "timestamp,name,action,transactionHash,incrementalDuration,registrant,referrer," + + "incrementalRevenueContributionWei,accumulatedRevenueContributionWei," + + "incrementalBaseRevenueContributionUsdc,accumulatedBaseRevenueContributionUsdc," + + "awardPoolRemainingUsdc,disqualified,disqualificationReason,maxRevShare," + + "effectiveBaseRevShare,incrementalTentativeAwardUsdc"; + expect(body.startsWith(`${expectedHeaderRow}\r\n`)).toBe(true); + expect(body.endsWith("\r\n")).toBe(true); + + // Header row + one record row + trailing CRLF → 3 segments after split on CRLF + // (last segment is empty due to the trailing terminator). + const segments = body.split("\r\n"); + expect(segments).toHaveLength(3); + expect(segments[2]).toBe(""); + + // The record row should have the same column count as the header. + const headerColumns = expectedHeaderRow.split(",").length; + expect(segments[1].split(",")).toHaveLength(headerColumns); + }); + + it("returns 503 when the edition snapshots caches map failed to load", async () => { + setupAccountingMocks(new Error("Failed to load referral edition snapshots caches")); + + const httpResponse = await app.request("/accounting?edition=any-edition"); + + expect(httpResponse.status).toBe(503); + const body = await httpResponse.text(); + expect(body).toContain("Service Unavailable"); + expect(body).toContain("configuration is unavailable"); + }); + + it("returns 503 when the edition snapshot cache read() resolves to an Error", async () => { + setupAccountingMocks( + new Map([mockReadErrorCache("rsc-edition", new Error("Failed to build accounting trace"))]), + ); + + const httpResponse = await app.request("/accounting?edition=rsc-edition"); + + expect(httpResponse.status).toBe(503); + const body = await httpResponse.text(); + expect(body).toContain("Service Unavailable"); + expect(body).toContain("failed to build accounting trace"); + }); }); }); diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 41e2959eb5..ea8791ef28 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -58,7 +58,7 @@ const gracefulShutdown = async () => { if (editionsCaches) { for (const [editionSlug, cache] of editionsCaches) { cache.destroy(); - logger.info(`Destroyed referralLeaderboardEditionsCache for ${editionSlug}`); + logger.info(`Destroyed referralEditionSnapshotsCache for ${editionSlug}`); } } diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts index 5f15fe9569..48e5702fd1 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts @@ -1,9 +1,8 @@ import type { ReferralAccountingRecordRevShareCap } from "@namehash/ens-referrals"; /** - * Escape a CSV string cell. Only wraps the value in quotes when it contains a comma, quote, - * or newline (per RFC 4180). ENS names are guaranteed not to contain commas (see issue #1797), - * but the `disqualificationReason` is free-form admin input so needs escaping. + * Escape a CSV cell per RFC 4180: wrap in quotes when the value contains a comma, quote, + * or newline; double up internal quotes. */ function csvCell(value: string): string { if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) { @@ -23,7 +22,7 @@ const CSV_COLUMNS: ReadonlyArray<{ value: (r: ReferralAccountingRecordRevShareCap) => string; }> = [ { header: "timestamp", value: (r) => new Date(r.timestamp * 1000).toISOString() }, - { header: "name", value: (r) => csvCell(r.name) }, + { header: "name", value: (r) => r.name }, { header: "action", value: (r) => r.actionType }, { header: "transactionHash", value: (r) => r.transactionHash }, { header: "incrementalDuration", value: (r) => r.incrementalDuration.toString() }, @@ -52,10 +51,7 @@ const CSV_COLUMNS: ReadonlyArray<{ { header: "disqualified", value: (r) => (r.tentativeAward.disqualified ? "true" : "false") }, { header: "disqualificationReason", - value: (r) => - r.tentativeAward.disqualificationReason - ? csvCell(r.tentativeAward.disqualificationReason) - : "", + value: (r) => r.tentativeAward.disqualificationReason ?? "", }, { header: "maxRevShare", value: (r) => r.tentativeAward.maxRevShare.toString() }, { @@ -75,8 +71,8 @@ export function formatAccountingCsv( records: ReadonlyArray, ): string { const lines = [ - CSV_COLUMNS.map((c) => c.header).join(","), - ...records.map((r) => CSV_COLUMNS.map((c) => c.value(r)).join(",")), + CSV_COLUMNS.map((c) => csvCell(c.header)).join(","), + ...records.map((r) => CSV_COLUMNS.map((c) => csvCell(c.value(r))).join(",")), ]; return `${lines.join("\r\n")}\r\n`; } diff --git a/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts b/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts index badd078401..9806de5004 100644 --- a/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts +++ b/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts @@ -60,7 +60,16 @@ export interface ReferrerLeaderboardPieSplit { * A point-in-time snapshot of everything computed for a `pie-split` referral program edition. */ export interface ReferralEditionSnapshotPieSplit { + /** + * Discriminant identifying this as a pie-split snapshot. + * + * @invariant Equals `leaderboard.awardModel` and `leaderboard.rules.awardModel`. + */ awardModel: typeof ReferralProgramAwardModels.PieSplit; + + /** + * The {@link ReferrerLeaderboardPieSplit} computed for this edition. + */ leaderboard: ReferrerLeaderboardPieSplit; } diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts index 36c5c150ea..4ac3bdcf1a 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts @@ -1,4 +1,4 @@ -import type { Address, InterpretedName } from "enssdk"; +import { type Address, asInterpretedName } from "enssdk"; import type { Hash } from "viem"; import { beforeEach, describe, expect, it } from "vitest"; @@ -49,7 +49,7 @@ function makeEvent( timestamp, incrementalDuration, incrementalRevenueContribution: priceEth(0n), - name: "test.eth" as InterpretedName, + name: asInterpretedName("test.eth"), actionType: "registration", transactionHash: "0x0000000000000000000000000000000000000000000000000000000000000000" as Hash, registrant: "0xdddddddddddddddddddddddddddddddddddddddd" as Address, @@ -261,8 +261,7 @@ describe("buildReferralEditionSnapshotRevShareCap — per-event trace", () => { ); }); - it("leaderboard returned alongside trace matches buildReferralEditionSnapshotRevShareCap exactly", () => { - // Verify that the wrapper and the with-accounting function produce the same leaderboard. + it("returns a leaderboard alongside the accounting records that reflects the rules and per-referrer awards", () => { const rules = buildTestRules(parseUsdc("10000")); const events = [ makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts index 900cea5330..1035a0c2cb 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts @@ -1,4 +1,4 @@ -import type { Address, InterpretedName, NormalizedAddress } from "enssdk"; +import { type Address, asInterpretedName, type NormalizedAddress } from "enssdk"; import type { Hash } from "viem"; import { beforeEach, describe, expect, it } from "vitest"; @@ -85,7 +85,7 @@ function makeEvent( timestamp, incrementalDuration, incrementalRevenueContribution: ZERO_ETH, - name: "test.eth" as InterpretedName, + name: asInterpretedName("test.eth"), actionType: "registration", transactionHash: "0x0000000000000000000000000000000000000000000000000000000000000000" as Hash, registrant: "0xdddddddddddddddddddddddddddddddddddddddd" as Address, diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts index 09349aa6ba..b4c8485d59 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts @@ -79,12 +79,25 @@ export interface ReferrerLeaderboardRevShareCap { /** * A point-in-time snapshot of everything computed for a `rev-share-cap` referral program edition. - * - * @invariant `accountingRecords` are in chronological onchain order (one per processed event). */ export interface ReferralEditionSnapshotRevShareCap { + /** + * Discriminant identifying this as a rev-share-cap snapshot. + * + * @invariant Equals `leaderboard.awardModel` and `leaderboard.rules.awardModel`. + */ awardModel: typeof ReferralProgramAwardModels.RevShareCap; + + /** + * The {@link ReferrerLeaderboardRevShareCap} computed from `accountingRecords`. + */ leaderboard: ReferrerLeaderboardRevShareCap; + + /** + * Per-event accounting trace. + * + * @invariant One entry per processed onchain event, in chronological order. + */ accountingRecords: ReferralAccountingRecordRevShareCap[]; } @@ -126,7 +139,7 @@ export const buildReferralEditionSnapshotRevShareCap = ( // 1. Sort events into chronological order by onchain execution order. const sortedEvents = sortReferralEvents(events); - // Precompute admin-action lookup (O(1) per event). + // Precompute admin-action object lookup for the accounting record. const adminActionByReferrer = new Map(); for (const action of rules.adminActions) { adminActionByReferrer.set(action.referrer, action); diff --git a/packages/ens-referrals/src/leaderboard.ts b/packages/ens-referrals/src/leaderboard.ts index 949fec1d41..a86e8884f5 100644 --- a/packages/ens-referrals/src/leaderboard.ts +++ b/packages/ens-referrals/src/leaderboard.ts @@ -16,6 +16,8 @@ export type ReferrerLeaderboard = ReferrerLeaderboardPieSplit | ReferrerLeaderbo /** * A point-in-time snapshot of everything computed for a referral program edition. + * + * Use `awardModel` to narrow the specific variant at runtime. */ export type ReferralEditionSnapshot = | ReferralEditionSnapshotPieSplit From 6db315d9e76f9f601deda109ec6513e26610e016 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 27 Apr 2026 20:20:50 +0200 Subject: [PATCH 03/12] openapi --- docs/ensnode.io/ensapi-openapi.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index 487ad34b48..5efabc512f 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -2972,6 +2972,32 @@ "503": { "description": "Service unavailable" } } } + }, + "/v1/ensanalytics/accounting": { + "get": { + "operationId": "getAccountingCsv", + "tags": ["ENSAwards"], + "summary": "Get Accounting Dump (CSV)", + "description": "Returns a full per-event accounting dump for a rev-share-cap edition as a CSV file, ordered chronologically.", + "parameters": [ + { + "schema": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "required": true, + "name": "edition", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved per-event accounting CSV", + "content": { "text/csv": { "schema": { "type": "string" } } } + }, + "400": { "description": "Invalid request" }, + "404": { "description": "Unknown edition slug" }, + "500": { "description": "Internal server error" }, + "503": { "description": "Service unavailable" } + } + } } }, "webhooks": {} From 658505db87ae90773410a2c4d79476b9916c8bb9 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 27 Apr 2026 20:58:21 +0200 Subject: [PATCH 04/12] more defensive checks --- .../lib/ensanalytics/referrer-leaderboard/database.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts index 1ba7f05fd0..069aa44998 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts @@ -171,6 +171,16 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise Date: Tue, 28 Apr 2026 16:20:03 +0200 Subject: [PATCH 05/12] adding prerequisites to ensanalytics api --- .../cache/referral-edition-snapshots.cache.ts | 33 ++--- .../ensanalytics/ensanalytics-api.test.ts | 119 +++++++++++++++++- .../handlers/ensanalytics/ensanalytics-api.ts | 45 +++++++ .../format-accounting-csv.ts | 1 + apps/ensapi/src/lib/hono-factory.ts | 2 + .../src/middleware/ensanalytics.middleware.ts | 80 ++++++++++++ packages/ens-referrals/src/api/index.ts | 1 + .../ens-referrals/src/api/prerequisites.ts | 72 +++++++++++ 8 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 apps/ensapi/src/middleware/ensanalytics.middleware.ts create mode 100644 packages/ens-referrals/src/api/prerequisites.ts diff --git a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts index 692b24ec79..4c459031ae 100644 --- a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts +++ b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts @@ -1,4 +1,5 @@ import { + hasEnsAnalyticsIndexingStatusSupport, type ReferralEditionSnapshot, type ReferralProgramEditionConfig, type ReferralProgramEditionConfigSet, @@ -7,13 +8,7 @@ import { } from "@namehash/ens-referrals"; import { minutesToSeconds } from "date-fns"; -import { - type CachedResult, - getLatestIndexedBlockRef, - type OmnichainIndexingStatusId, - OmnichainIndexingStatusIds, - SWRCache, -} from "@ensnode/ensnode-sdk"; +import { type CachedResult, getLatestIndexedBlockRef, SWRCache } from "@ensnode/ensnode-sdk"; import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/referrer-leaderboard/closeout"; import { getReferralEditionSnapshot } from "@/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot"; @@ -36,17 +31,6 @@ export type ReferralEditionSnapshotsCacheMap = Map< SWRCache >; -/** - * The list of {@link OmnichainIndexingStatusId} values that are supported for generating - * edition snapshots. - * - * Other values indicate that we are not ready to generate snapshots yet. - */ -const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ - OmnichainIndexingStatusIds.Following, - OmnichainIndexingStatusIds.Completed, -]; - /** * Creates a cache builder function for a specific edition. * @@ -80,6 +64,11 @@ function createEditionSnapshotBuilder( } } + // This check duplicates `ensanalyticsApiMiddleware`'s indexing-status gate, but is required + // here because `proactivelyInitialize: true` runs the cache builder at startup — before any + // request — so the middleware can't gate it. Without this, the cache could capture a snapshot + // derived from a not-yet-final indexer state and serve it for the rest of its (effectively + // infinite, for closed editions) TTL. const indexingStatus = await indexingStatusCache.read(); if (indexingStatus instanceof Error) { logger.error( @@ -91,10 +80,12 @@ function createEditionSnapshotBuilder( ); } - const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus; - if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) { + const indexingStatusSupport = hasEnsAnalyticsIndexingStatusSupport( + indexingStatus.omnichainSnapshot.omnichainStatus, + ); + if (!indexingStatusSupport.supported) { throw new Error( - `Unable to generate edition snapshot for ${editionSlug}. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")}.`, + `Unable to generate edition snapshot for ${editionSlug}. ${indexingStatusSupport.reason}`, ); } diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index f9b7698c13..cda9b7f7c0 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; +import * as ensanalyticsMiddleware from "@/middleware/ensanalytics.middleware"; import * as editionsCachesMiddleware from "@/middleware/referral-edition-snapshots-caches.middleware"; import * as editionSetMiddleware from "@/middleware/referral-program-edition-set.middleware"; @@ -24,6 +25,22 @@ vi.mock("@/middleware/referral-edition-snapshots-caches.middleware", () => ({ referralEditionSnapshotsCachesMiddleware: vi.fn(), })); +// The indexing-status middleware is out of scope for these handler-level tests +// — pass through with a synthetic "following" status. +vi.mock("@/middleware/indexing-status.middleware", () => ({ + indexingStatusMiddleware: async ( + c: { set: (k: string, v: unknown) => void }, + next: () => Promise, + ) => { + c.set("indexingStatus", { snapshot: { omnichainSnapshot: { omnichainStatus: "following" } } }); + return await next(); + }, +})); + +vi.mock("@/middleware/ensanalytics.middleware", () => ({ + ensanalyticsApiMiddleware: vi.fn(), +})); + import { buildReferralProgramRulesPieSplit, deserializeReferralProgramEditionSummariesResponse, @@ -63,6 +80,104 @@ import { import app from "./ensanalytics-api"; describe("/v1/ensanalytics", () => { + // Default: prerequisites pass for every test. Tests that exercise the 503 short-circuit + // paths override this within their own `it(...)` body. + beforeEach(() => { + vi.mocked(ensanalyticsMiddleware.ensanalyticsApiMiddleware).mockImplementation( + async (c, next) => { + c.set("ensAnalyticsPrerequisites", { supported: true }); + return await next(); + }, + ); + }); + + describe("prerequisites short-circuit", () => { + const FAILURE_REASON = "test prerequisite failure (e.g. missing 'subgraph' plugin)"; + + function setupPrerequisitesUnsupported(): void { + vi.mocked(ensanalyticsMiddleware.ensanalyticsApiMiddleware).mockImplementation( + async (c, next) => { + c.set("ensAnalyticsPrerequisites", { supported: false, reason: FAILURE_REASON }); + return await next(); + }, + ); + // The downstream middlewares still run; provide minimal stand-ins so the chain + // doesn't throw an Invariant. The handler short-circuits on prerequisites before + // these are consulted. + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", new Map()); + return await next(); + }, + ); + vi.mocked( + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralEditionSnapshotsCaches", new Map()); + return await next(); + }); + } + + it("/referral-leaderboard returns 503 with serialized error when prerequisites fail", async () => { + setupPrerequisitesUnsupported(); + + const httpResponse = await app.request("/referral-leaderboard?edition=any"); + const response = deserializeReferrerLeaderboardPageResponse(await httpResponse.json()); + + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferrerLeaderboardPageResponseCodes.Error); + if (response.responseCode === ReferrerLeaderboardPageResponseCodes.Error) { + expect(response.error).toBe("Service Unavailable"); + expect(response.errorMessage).toBe(FAILURE_REASON); + } + }); + + it("/referrer/:address returns 503 with serialized error when prerequisites fail", async () => { + setupPrerequisitesUnsupported(); + + const httpResponse = await app.request( + "/referrer/0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e?editions=any", + ); + const response = deserializeReferrerMetricsEditionsResponse(await httpResponse.json()); + + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Error); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Error) { + expect(response.error).toBe("Service Unavailable"); + expect(response.errorMessage).toBe(FAILURE_REASON); + } + }); + + it("/editions returns 503 with serialized error when prerequisites fail", async () => { + setupPrerequisitesUnsupported(); + + const httpResponse = await app.request("/editions"); + const response = deserializeReferralProgramEditionSummariesResponse( + await httpResponse.json(), + ); + + expect(httpResponse.status).toBe(503); + expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Error); + if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Error) { + expect(response.error).toBe("Service Unavailable"); + expect(response.errorMessage).toBe(FAILURE_REASON); + } + }); + + it("/accounting returns 503 with plain-text body when prerequisites fail", async () => { + setupPrerequisitesUnsupported(); + + const httpResponse = await app.request("/accounting?edition=any"); + + expect(httpResponse.status).toBe(503); + // CSV endpoint uses plain text 503 to match its handler-level error convention. + expect(httpResponse.headers.get("content-type")).toContain("text/plain"); + const body = await httpResponse.text(); + expect(body).toContain("Service Unavailable"); + expect(body).toContain(FAILURE_REASON); + }); + }); + describe("/referral-leaderboard", () => { it("returns requested records when referrer leaderboard has multiple pages of data", async () => { // Arrange: mock cache map with 2025-12 @@ -1111,7 +1226,7 @@ describe("/v1/ensanalytics", () => { // The exact CSV cell values are exercised by `formatAccountingCsv`'s own unit coverage. const body = await httpResponse.text(); const expectedHeaderRow = - "timestamp,name,action,transactionHash,incrementalDuration,registrant,referrer," + + "referralId,timestamp,name,action,transactionHash,incrementalDuration,registrant,referrer," + "incrementalRevenueContributionWei,accumulatedRevenueContributionWei," + "incrementalBaseRevenueContributionUsdc,accumulatedBaseRevenueContributionUsdc," + "awardPoolRemainingUsdc,disqualified,disqualificationReason,maxRevShare," + diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index ffe2ea5a2b..bfd54bb104 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -20,6 +20,8 @@ import { import { formatAccountingCsv } from "@/lib/ensanalytics/referrer-leaderboard/format-accounting-csv"; import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; +import { ensanalyticsApiMiddleware } from "@/middleware/ensanalytics.middleware"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { referralEditionSnapshotsCachesMiddleware } from "@/middleware/referral-edition-snapshots-caches.middleware"; import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; @@ -34,6 +36,8 @@ const logger = makeLogger("ensanalytics-api"); const app = createApp({ middlewares: [ + indexingStatusMiddleware, + ensanalyticsApiMiddleware, referralProgramEditionConfigSetMiddleware, referralEditionSnapshotsCachesMiddleware, ], @@ -44,6 +48,18 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { try { const { edition, page, recordsPerPage } = c.req.valid("query"); + // Service-prerequisite check (set by ensanalyticsApiMiddleware) + if (!c.var.ensAnalyticsPrerequisites.supported) { + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Service Unavailable", + errorMessage: c.var.ensAnalyticsPrerequisites.reason, + } satisfies ReferrerLeaderboardPageResponse), + 503, + ); + } + // Check if edition set failed to load if (c.var.referralEditionSnapshotsCaches instanceof Error) { logger.error( @@ -124,6 +140,18 @@ app.openapi(getReferrerDetailRoute, async (c) => { const { referrer } = c.req.valid("param"); const { editions } = c.req.valid("query"); + // Service-prerequisite check (set by ensanalyticsApiMiddleware) + if (!c.var.ensAnalyticsPrerequisites.supported) { + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Service Unavailable", + errorMessage: c.var.ensAnalyticsPrerequisites.reason, + } satisfies ReferrerMetricsEditionsResponse), + 503, + ); + } + // Check if edition set failed to load if (c.var.referralEditionSnapshotsCaches instanceof Error) { logger.error( @@ -231,6 +259,18 @@ app.openapi(getReferrerDetailRoute, async (c) => { // Get edition summaries app.openapi(getEditionsRoute, async (c) => { try { + // Service-prerequisite check (set by ensanalyticsApiMiddleware) + if (!c.var.ensAnalyticsPrerequisites.supported) { + return c.json( + serializeReferralProgramEditionSummariesResponse({ + responseCode: ReferralProgramEditionSummariesResponseCodes.Error, + error: "Service Unavailable", + errorMessage: c.var.ensAnalyticsPrerequisites.reason, + } satisfies ReferralProgramEditionSummariesResponse), + 503, + ); + } + // Check if edition config set failed to load if (c.var.referralProgramEditionConfigSet instanceof Error) { logger.error( @@ -339,6 +379,11 @@ app.openapi(getAccountingCsvRoute, async (c) => { try { const { edition } = c.req.valid("query"); + // Service-prerequisite check (set by ensanalyticsApiMiddleware) + if (!c.var.ensAnalyticsPrerequisites.supported) { + return c.text(`Service Unavailable: ${c.var.ensAnalyticsPrerequisites.reason}`, 503); + } + if (c.var.referralEditionSnapshotsCaches instanceof Error) { logger.error( { error: c.var.referralEditionSnapshotsCaches }, diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts index 48e5702fd1..9d97f30f7a 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/format-accounting-csv.ts @@ -21,6 +21,7 @@ const CSV_COLUMNS: ReadonlyArray<{ header: string; value: (r: ReferralAccountingRecordRevShareCap) => string; }> = [ + { header: "referralId", value: (r) => r.registrarActionId }, { header: "timestamp", value: (r) => new Date(r.timestamp * 1000).toISOString() }, { header: "name", value: (r) => r.name }, { header: "action", value: (r) => r.actionType }, diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index cde2c2daaa..f2f93e32d9 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -4,6 +4,7 @@ import { createFactory } from "hono/factory"; import { errorResponse } from "@/lib/handlers/error-response"; import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware"; +import type { EnsAnalyticsPrerequisitesMiddlewareVariables } from "@/middleware/ensanalytics.middleware"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware"; import type { ReferralEditionSnapshotsCachesMiddlewareVariables } from "@/middleware/referral-edition-snapshots-caches.middleware"; @@ -15,6 +16,7 @@ export type MiddlewareVariables = IndexingStatusMiddlewareVariables & CanAccelerateMiddlewareVariables & ReferralProgramEditionConfigSetMiddlewareVariables & ReferralEditionSnapshotsCachesMiddlewareVariables & + EnsAnalyticsPrerequisitesMiddlewareVariables & StackInfoMiddlewareVariables; type AppEnv = { Variables: Partial }; diff --git a/apps/ensapi/src/middleware/ensanalytics.middleware.ts b/apps/ensapi/src/middleware/ensanalytics.middleware.ts new file mode 100644 index 0000000000..c3ea70fe0f --- /dev/null +++ b/apps/ensapi/src/middleware/ensanalytics.middleware.ts @@ -0,0 +1,80 @@ +import config from "@/config"; + +import { + hasEnsAnalyticsConfigSupport, + hasEnsAnalyticsIndexingStatusSupport, +} from "@namehash/ens-referrals"; + +import type { PrerequisiteResult } from "@ensnode/ensnode-sdk"; + +import { factory, producing } from "@/lib/hono-factory"; + +/** + * Type definition for the ENSAnalytics prerequisites middleware context passed + * to downstream middleware and handlers. + */ +export type EnsAnalyticsPrerequisitesMiddlewareVariables = { + /** + * Result of checking the ENSAnalytics API's runtime prerequisites: + * - The connected ENSIndexer has all required plugins active. + * - ENSApi has a valid indexing status cached. + * - The cached indexing status is "following" or "completed". + * + * `{ supported: true }` means all prerequisites are met and the request can be served. + * `{ supported: false, reason }` means at least one prerequisite failed; handlers should + * short-circuit with a 503 in their endpoint-specific response shape, surfacing the + * `reason` string verbatim to clients. + */ + ensAnalyticsPrerequisites: PrerequisiteResult; +}; + +/** + * ENSAnalytics API Middleware + * + * Computes the ENSAnalytics API's runtime prerequisites and exposes the result + * to handlers via `c.var.ensAnalyticsPrerequisites`. Does NOT short-circuit the + * request — each handler reads the result and produces a 503 in its own + * response-union shape. + * + * Sets `{ supported: false, reason }` for any of the following: + * 1) Not all required plugins are active in the connected ENSIndexer + * configuration. + * 2) ENSApi has not yet successfully cached the Indexing Status in memory from + * the connected ENSIndexer. + * 3) The omnichain indexing status of the connected ENSIndexer that is cached + * in memory is not "completed" or "following". + */ +export const ensanalyticsApiMiddleware = producing( + ["ensAnalyticsPrerequisites"], + factory.createMiddleware(async function ensanalyticsApiMiddleware(c, next) { + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(ensanalytics.middleware): indexingStatusMiddleware required`); + } + + const configSupport = hasEnsAnalyticsConfigSupport(config.ensIndexerPublicConfig); + if (!configSupport.supported) { + c.set("ensAnalyticsPrerequisites", configSupport); + return await next(); + } + + if (c.var.indexingStatus instanceof Error) { + c.set("ensAnalyticsPrerequisites", { + supported: false, + reason: `Indexing status is currently unavailable to this ENSApi instance.`, + }); + return await next(); + } + + const { omnichainSnapshot } = c.var.indexingStatus.snapshot; + const indexingStatusSupport = hasEnsAnalyticsIndexingStatusSupport( + omnichainSnapshot.omnichainStatus, + ); + if (!indexingStatusSupport.supported) { + c.set("ensAnalyticsPrerequisites", indexingStatusSupport); + return await next(); + } + + c.set("ensAnalyticsPrerequisites", { supported: true }); + await next(); + }), +); diff --git a/packages/ens-referrals/src/api/index.ts b/packages/ens-referrals/src/api/index.ts index c80c44518e..b27eaa2f17 100644 --- a/packages/ens-referrals/src/api/index.ts +++ b/packages/ens-referrals/src/api/index.ts @@ -1,4 +1,5 @@ export * from "./deserialize"; +export * from "./prerequisites"; export * from "./serialize"; export * from "./serialized-types"; export * from "./types"; diff --git a/packages/ens-referrals/src/api/prerequisites.ts b/packages/ens-referrals/src/api/prerequisites.ts new file mode 100644 index 0000000000..e71900875e --- /dev/null +++ b/packages/ens-referrals/src/api/prerequisites.ts @@ -0,0 +1,72 @@ +import { + type EnsIndexerPublicConfig, + type OmnichainIndexingStatusId, + OmnichainIndexingStatusIds, + PluginName, + type PrerequisiteResult, +} from "@ensnode/ensnode-sdk"; + +/** + * Required plugins to enable the ENSAnalytics API routes. + * + * 1. `registrars` plugin is required so that data in the `registrarActions` + * and `registrationLifecycles` tables is populated. + * 2. `subgraph`, `basenames`, and `lineanames` are required so that data in + * the `subgraph_domain` table is populated for the names associated with + * each registrar action — read to look up `name` for each row. + * + * Each ENSAnalytics edition is scoped to a single `subregistryId`, so any one + * edition only joins against rows from its own namespace's `subgraph_domain`. + * In theory not all of `subgraph` / `basenames` / `lineanames` are required + * for any single edition. In practice we require all three so that no edition + * configuration can silently miss data because its namespace plugin was not + * activated. This matches the precedent set by the Registrar Actions API + * (`hasRegistrarActionsConfigSupport`). + */ +const ensAnalyticsRequiredPlugins = [ + PluginName.Subgraph, + PluginName.Basenames, + PluginName.Lineanames, + PluginName.Registrars, +] as const; + +/** + * Check if provided EnsIndexerPublicConfig supports the ENSAnalytics API. + */ +export function hasEnsAnalyticsConfigSupport(config: EnsIndexerPublicConfig): PrerequisiteResult { + const supported = ensAnalyticsRequiredPlugins.every((plugin) => config.plugins.includes(plugin)); + if (supported) return { supported }; + + return { + supported: false, + reason: `The ENSAnalytics API requires all of the following plugins to be activated in the connected ENSNode's Config: ${ensAnalyticsRequiredPlugins.map((plugin) => `'${plugin}'`).join(", ")}.`, + }; +} + +/** + * Required Indexing Status IDs + * + * Database indexes are created by the time the omnichain indexing status + * is either `completed` or `following`. + */ +const ensAnalyticsSupportedIndexingStatusIds = [ + OmnichainIndexingStatusIds.Completed, + OmnichainIndexingStatusIds.Following, +]; + +/** + * Check if provided indexing status supports the ENSAnalytics API. + */ +export function hasEnsAnalyticsIndexingStatusSupport( + omnichainIndexingStatusId: OmnichainIndexingStatusId, +): PrerequisiteResult { + const supported = ensAnalyticsSupportedIndexingStatusIds.some( + (supportedIndexingStatusId) => supportedIndexingStatusId === omnichainIndexingStatusId, + ); + if (supported) return { supported }; + + return { + supported: false, + reason: `The ENSAnalytics API requires the connected ENSNode's Indexing Status to be one of the following: ${ensAnalyticsSupportedIndexingStatusIds.join(", ")}.`, + }; +} From f26c10d9f737d169aa7e4116947c64a5db9e8976 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 28 Apr 2026 16:47:33 +0200 Subject: [PATCH 06/12] pluging gating for the cache --- .../cache/referral-edition-snapshots.cache.ts | 21 ++++++++++++++----- .../referrer-leaderboard/database.ts | 8 +++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts index 4c459031ae..2761b93f7a 100644 --- a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts +++ b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts @@ -1,4 +1,7 @@ +import config from "@/config"; + import { + hasEnsAnalyticsConfigSupport, hasEnsAnalyticsIndexingStatusSupport, type ReferralEditionSnapshot, type ReferralProgramEditionConfig, @@ -64,11 +67,19 @@ function createEditionSnapshotBuilder( } } - // This check duplicates `ensanalyticsApiMiddleware`'s indexing-status gate, but is required - // here because `proactivelyInitialize: true` runs the cache builder at startup — before any - // request — so the middleware can't gate it. Without this, the cache could capture a snapshot - // derived from a not-yet-final indexer state and serve it for the rest of its (effectively - // infinite, for closed editions) TTL. + // The plugin-support and indexing-status checks below duplicate `ensanalyticsApiMiddleware`'s + // gates, but are required here because `proactivelyInitialize: true` runs the cache builder + // at startup — before any request — so the middleware can't gate it. Without these checks, + // the cache could capture a snapshot derived from a not-yet-final indexer state, or one with + // silently dropped rows because a required namespace plugin is inactive, and serve it for the + // rest of its (effectively infinite, for closed editions) TTL. + const configSupport = hasEnsAnalyticsConfigSupport(config.ensIndexerPublicConfig); + if (!configSupport.supported) { + throw new Error( + `Unable to generate edition snapshot for ${editionSlug}. ${configSupport.reason}`, + ); + } + const indexingStatus = await indexingStatusCache.read(); if (indexingStatus instanceof Error) { logger.error( diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts index 069aa44998..9cdf6eef86 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts @@ -132,6 +132,14 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise Date: Wed, 29 Apr 2026 03:16:45 +0200 Subject: [PATCH 07/12] review --- .../referrer-leaderboard/database.ts | 30 ++++++++++++------- .../award-models/rev-share-cap/leaderboard.ts | 2 +- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts index 9cdf6eef86..7227936af5 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts @@ -129,22 +129,25 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise(); for (const action of rules.adminActions) { adminActionByReferrer.set(action.referrer, action); From 87ed70b90b207026e15cf9c98f4083b08490d341 Mon Sep 17 00:00:00 2001 From: Goader Date: Fri, 1 May 2026 17:34:13 +0200 Subject: [PATCH 08/12] docs(changeset): --- .changeset/curly-buses-lick.md | 2 + .changeset/magical-hedgehog-jump.md | 6 + .../ensanalytics/ensanalytics-api.routes.ts | 14 +- .../ensanalytics/ensanalytics-api.test.ts | 86 +++++--- .../handlers/ensanalytics/ensanalytics-api.ts | 2 +- .../referrer-leaderboard/database.ts | 7 +- .../format-accounting-csv.ts | 3 +- .../referrer-leaderboard/mocks.ts | 190 ++++++++++-------- docs/ensnode.io/ensapi-openapi.json | 9 +- packages/ens-referrals/src/api/types.ts | 6 +- .../ens-referrals/src/api/zod-schemas.test.ts | 13 +- packages/ens-referrals/src/api/zod-schemas.ts | 4 +- .../award-models/pie-split/api/zod-schemas.ts | 6 +- .../award-models/pie-split/edition-metrics.ts | 4 +- .../src/award-models/pie-split/leaderboard.ts | 10 +- .../src/award-models/pie-split/metrics.ts | 10 +- .../rev-share-cap/accounting.test.ts | 25 ++- .../award-models/rev-share-cap/accounting.ts | 6 +- .../rev-share-cap/api/zod-schemas.ts | 50 +++-- .../rev-share-cap/edition-metrics.ts | 2 +- .../rev-share-cap/leaderboard.test.ts | 107 +++++----- .../award-models/rev-share-cap/leaderboard.ts | 39 ++-- .../src/award-models/rev-share-cap/metrics.ts | 20 +- .../rev-share-cap/referral-event.ts | 6 +- .../src/award-models/rev-share-cap/rules.ts | 35 ++-- .../award-models/shared/leaderboard-page.ts | 4 +- .../src/award-models/shared/rank.ts | 15 +- packages/ens-referrals/src/client.ts | 12 +- packages/ens-referrals/src/edition-metrics.ts | 13 +- .../src/leaderboard-page.test.ts | 40 ++-- .../ens-referrals/src/referrer-metrics.ts | 13 +- .../ensnode-sdk/src/shared/account-id.test.ts | 22 ++ .../ensnode-sdk/src/shared/zod-schemas.ts | 21 +- 33 files changed, 474 insertions(+), 328 deletions(-) create mode 100644 .changeset/curly-buses-lick.md create mode 100644 .changeset/magical-hedgehog-jump.md diff --git a/.changeset/curly-buses-lick.md b/.changeset/curly-buses-lick.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/curly-buses-lick.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/magical-hedgehog-jump.md b/.changeset/magical-hedgehog-jump.md new file mode 100644 index 0000000000..676994bed9 --- /dev/null +++ b/.changeset/magical-hedgehog-jump.md @@ -0,0 +1,6 @@ +--- +"@namehash/ens-referrals": minor +"ensapi": minor +--- + +Identify referrers in the ENSAnalytics v1 surface by `AccountId` instead of bare address. Domain types (`ReferrerMetrics`, `RankedReferrer`, `AdminAction`, `ReferralEvent`, leaderboard maps, etc.), JSON wire format, and the `getReferrerMetricsEditions` client now all use `AccountId`. The `GET /v1/ensanalytics/referrer/{referrer}` path param is now a URL-encoded CAIP-10 string (e.g. `eip155%3A1%3A0xabc...`). diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts index e5560fb916..f52ccba818 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts @@ -8,7 +8,7 @@ import { makeReferrerMetricsEditionsArraySchema, } from "@namehash/ens-referrals/internal"; -import { makeNormalizedAddressSchema } from "@ensnode/ensnode-sdk/internal"; +import { makeAccountIdStringSchema } from "@ensnode/ensnode-sdk/internal"; export const basePath = "/v1/ensanalytics"; @@ -37,9 +37,11 @@ const referrerLeaderboardPageQuerySchema = z.object({ .describe("Number of referrers per page"), }); -// Referrer address parameter schema -const referrerAddressSchema = z.object({ - referrer: makeNormalizedAddressSchema("Referrer address").describe("Referrer Ethereum address"), +// Referrer AccountId path parameter schema (CAIP-10 string, e.g. "eip155:1:0xabc...") +const referrerAccountIdSchema = z.object({ + referrer: makeAccountIdStringSchema("Referrer AccountId").describe( + "Referrer CAIP-10 AccountId (e.g. eip155:1:0xabc...)", + ), }); // Editions query parameter schema @@ -86,9 +88,9 @@ export const getReferrerDetailRoute = createRoute({ operationId: "getReferrerDetail", tags: ["ENSAwards"], summary: "Get Referrer Detail for Editions", - description: `Returns detailed information for a specific referrer for the requested editions. Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`, + description: `Returns detailed information for a specific referrer for the requested editions. The referrer is identified by its CAIP-10 AccountId (URL-encoded, e.g. \`eip155%3A1%3A0xabc...\`). Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`, request: { - params: referrerAddressSchema, + params: referrerAccountIdSchema, query: editionsQuerySchema, }, responses: { diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index cda9b7f7c0..0a5e278e44 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -61,7 +61,13 @@ import { ReferrerMetricsEditionsResponseCodes, type ReferrerMetricsEditionsResponseOk, } from "@namehash/ens-referrals"; -import { asInterpretedName, toNormalizedAddress } from "enssdk"; +import { + type AccountId, + type Address, + asInterpretedName, + stringifyAccountId, + toNormalizedAddress, +} from "enssdk"; import { parseEth, @@ -79,6 +85,11 @@ import { import app from "./ensanalytics-api"; +const acct = (address: Address): AccountId => ({ + chainId: 1, + address: toNormalizedAddress(address), +}); + describe("/v1/ensanalytics", () => { // Default: prerequisites pass for every test. Tests that exercise the 503 short-circuit // paths override this within their own `it(...)` body. @@ -132,11 +143,12 @@ describe("/v1/ensanalytics", () => { } }); - it("/referrer/:address returns 503 with serialized error when prerequisites fail", async () => { + it("/referrer/:referrer returns 503 with serialized error when prerequisites fail", async () => { setupPrerequisitesUnsupported(); + const referrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); const httpResponse = await app.request( - "/referrer/0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e?editions=any", + `/referrer/${encodeURIComponent(stringifyAccountId(referrer))}?editions=any`, ); const response = deserializeReferrerMetricsEditionsResponse(await httpResponse.json()); @@ -474,14 +486,16 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use a referrer address that exists in the leaderboard (rank 1) - const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; - const expectedMetrics = populatedReferrerLeaderboard.referrers.get(existingReferrer)!; + // Arrange: use a referrer that exists in the leaderboard (rank 1) + const existingReferrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); + const expectedMetrics = populatedReferrerLeaderboard.referrers.get( + stringifyAccountId(existingReferrer), + )!; const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf; // Act: send test request to fetch referrer detail for requested editions const httpResponse = await app.request( - `/referrer/${existingReferrer}?editions=2025-12,2026-03`, + `/referrer/${encodeURIComponent(stringifyAccountId(existingReferrer))}?editions=2025-12,2026-03`, ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -554,12 +568,12 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use a referrer address that does NOT exist in the leaderboard - const nonExistingReferrer = "0x0000000000000000000000000000000000000099"; + // Arrange: use a referrer that does NOT exist in the leaderboard + const nonExistingReferrer = acct("0x0000000000000000000000000000000000000099"); // Act: send test request to fetch referrer detail const httpResponse = await app.request( - `/referrer/${nonExistingReferrer}?editions=2025-12,2026-03`, + `/referrer/${encodeURIComponent(stringifyAccountId(nonExistingReferrer))}?editions=2025-12,2026-03`, ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -579,7 +593,7 @@ describe("/v1/ensanalytics", () => { if (edition1.type !== ReferrerEditionMetricsTypeIds.Unranked) throw new Error(); expect(edition1.rules).toEqual(populatedReferrerLeaderboard.rules); expect(edition1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); - expect(edition1.referrer.referrer).toBe(nonExistingReferrer); + expect(edition1.referrer.referrer).toEqual(nonExistingReferrer); expect(edition1.referrer.rank).toBe(null); expect(edition1.referrer.totalReferrals).toBe(0); expect(edition1.referrer.totalIncrementalDuration).toBe(0); @@ -599,7 +613,7 @@ describe("/v1/ensanalytics", () => { if (edition2.awardModel !== ReferralProgramAwardModels.PieSplit) throw new Error(); expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); if (edition2.type !== ReferrerEditionMetricsTypeIds.Unranked) throw new Error(); - expect(edition2.referrer.referrer).toBe(nonExistingReferrer); + expect(edition2.referrer.referrer).toEqual(nonExistingReferrer); expect(edition2.referrer.rank).toBe(null); } }); @@ -644,11 +658,13 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use any referrer address - const referrer = "0x0000000000000000000000000000000000000001"; + // Arrange: use any referrer + const referrer = acct("0x0000000000000000000000000000000000000001"); // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); + const httpResponse = await app.request( + `/referrer/${encodeURIComponent(stringifyAccountId(referrer))}?editions=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -667,7 +683,7 @@ describe("/v1/ensanalytics", () => { if (edition1.type !== ReferrerEditionMetricsTypeIds.Unranked) throw new Error(); expect(edition1.rules).toEqual(emptyReferralLeaderboard.rules); expect(edition1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); - expect(edition1.referrer.referrer).toBe(referrer); + expect(edition1.referrer.referrer).toEqual(referrer); expect(edition1.referrer.rank).toBe(null); expect(edition1.referrer.totalReferrals).toBe(0); expect(edition1.referrer.totalIncrementalDuration).toBe(0); @@ -687,7 +703,7 @@ describe("/v1/ensanalytics", () => { if (edition2.awardModel !== ReferralProgramAwardModels.PieSplit) throw new Error(); expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); if (edition2.type !== ReferrerEditionMetricsTypeIds.Unranked) throw new Error(); - expect(edition2.referrer.referrer).toBe(referrer); + expect(edition2.referrer.referrer).toEqual(referrer); expect(edition2.referrer.rank).toBe(null); } }); @@ -732,11 +748,13 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use any referrer address - const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + // Arrange: use any referrer + const referrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); // Act: send test request to fetch referrer detail for both editions - const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); + const httpResponse = await app.request( + `/referrer/${encodeURIComponent(stringifyAccountId(referrer))}?editions=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -792,11 +810,13 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use any referrer address - const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + // Arrange: use any referrer + const referrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); // Act: send test request to fetch referrer detail - const httpResponse = await app.request(`/referrer/${referrer}?editions=2025-12,2026-03`); + const httpResponse = await app.request( + `/referrer/${encodeURIComponent(stringifyAccountId(referrer))}?editions=2025-12,2026-03`, + ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -853,12 +873,12 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use any referrer address - const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + // Arrange: use any referrer + const referrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); // Act: send test request with one valid and one invalid edition const httpResponse = await app.request( - `/referrer/${referrer}?editions=2025-12,invalid-edition`, + `/referrer/${encodeURIComponent(stringifyAccountId(referrer))}?editions=2025-12,invalid-edition`, ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -922,12 +942,12 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: use a referrer address that exists in the leaderboard - const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"; + // Arrange: use a referrer that exists in the leaderboard + const existingReferrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); // Act: send test request requesting only 2 out of 3 editions const httpResponse = await app.request( - `/referrer/${existingReferrer}?editions=2025-12,2026-06`, + `/referrer/${encodeURIComponent(stringifyAccountId(existingReferrer))}?editions=2025-12,2026-06`, ); const responseData = await httpResponse.json(); const response = deserializeReferrerMetricsEditionsResponse(responseData); @@ -956,7 +976,7 @@ describe("/v1/ensanalytics", () => { 100, parseTimestamp("2025-12-01T00:00:00Z"), parseTimestamp("2025-12-31T23:59:59Z"), - { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + acct("0x0000000000000000000000000000000000000000"), new URL("https://example.com/rules"), false, ), @@ -972,7 +992,7 @@ describe("/v1/ensanalytics", () => { 100, parseTimestamp("2026-03-01T00:00:00Z"), parseTimestamp("2026-03-31T23:59:59Z"), - { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + acct("0x0000000000000000000000000000000000000000"), new URL("https://example.com/rules"), false, ), @@ -988,7 +1008,7 @@ describe("/v1/ensanalytics", () => { 100, parseTimestamp("2026-06-01T00:00:00Z"), parseTimestamp("2026-06-30T23:59:59Z"), - { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + acct("0x0000000000000000000000000000000000000000"), new URL("https://example.com/rules"), false, ), @@ -1168,7 +1188,7 @@ describe("/v1/ensanalytics", () => { actionType: RegistrarActionTypes.Registration, transactionHash: "0xabc", registrant: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e", - referrer: toNormalizedAddress("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"), + referrer: acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"), incrementalDuration: 31536000, tentativeAward: { incrementalRevenueContribution: parseEth("0.01"), diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index bfd54bb104..9c6f076b01 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -134,7 +134,7 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { } }); -// Get referrer detail for a specific address for requested editions +// Get referrer detail for a specific referrer for requested editions app.openapi(getReferrerDetailRoute, async (c) => { try { const { referrer } = c.req.valid("param"); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts index 7227936af5..d0574bee72 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts @@ -91,7 +91,7 @@ export const getReferrerMetrics = async ( return (records as NonNullRecord[]).map((record) => { return buildReferrerMetrics( - record.referrer, + { chainId: rules.subregistryId.chainId, address: record.referrer }, record.totalReferrals, deserializeDuration(record.totalIncrementalDuration), priceEth(BigInt(record.totalRevenueContribution)), @@ -211,7 +211,10 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise r.transactionHash }, { header: "incrementalDuration", value: (r) => r.incrementalDuration.toString() }, { header: "registrant", value: (r) => r.registrant }, - { header: "referrer", value: (r) => r.referrer }, + { header: "referrer", value: (r) => stringifyAccountId(r.referrer) }, { header: "incrementalRevenueContributionWei", value: (r) => r.tentativeAward.incrementalRevenueContribution.amount.toString(), diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks.ts index 5f2403ef53..1d96a5bd0b 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks.ts @@ -8,182 +8,194 @@ import { type ReferrerLeaderboardPieSplit, type ReferrerMetrics, } from "@namehash/ens-referrals"; +import { + type AccountId, + type AccountIdString, + type Address, + stringifyAccountId, + toNormalizedAddress, +} from "enssdk"; import { parseEth, parseUsdc } from "@ensnode/ensnode-sdk"; +// All mocks live on the pieSplit edition's subregistry chain (Ethereum mainnet). +const MOCK_CHAIN_ID = 1; +const acct = (address: Address): AccountId => ({ + chainId: MOCK_CHAIN_ID, + address: toNormalizedAddress(address), +}); +const acctKey = (address: Address): AccountIdString => stringifyAccountId(acct(address)); + const pieSplitRules: ReferralProgramRulesPieSplit = { awardModel: ReferralProgramAwardModels.PieSplit, awardPool: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, endTime: 1767225599, - subregistryId: { - chainId: 1, - address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", - }, + subregistryId: acct("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), rulesUrl: new URL("https://example.com/rules"), areAwardsDistributed: false, }; export const dbResultsReferrerLeaderboard: ReferrerMetrics[] = [ { - referrer: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e", + referrer: acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"), totalReferrals: 3, totalIncrementalDuration: 94694400, totalRevenueContribution: parseEth("0.015"), }, { - referrer: "0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca", + referrer: acct("0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca"), totalReferrals: 2, totalIncrementalDuration: 63072000, totalRevenueContribution: parseEth("0.01"), }, { - referrer: "0x00000000000000000000000000000000000000f1", + referrer: acct("0x00000000000000000000000000000000000000f1"), totalReferrals: 3, totalIncrementalDuration: 39657600, totalRevenueContribution: parseEth("0.012"), }, { - referrer: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + referrer: acct("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), totalReferrals: 4, totalIncrementalDuration: 34214400, totalRevenueContribution: parseEth("0.018"), }, { - referrer: "0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a", + referrer: acct("0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a"), totalReferrals: 7, totalIncrementalDuration: 15120000, totalRevenueContribution: parseEth("0.021"), }, { - referrer: "0xffa596cdf9a69676e689b1a92e5e681711227d75", + referrer: acct("0xffa596cdf9a69676e689b1a92e5e681711227d75"), totalReferrals: 5, totalIncrementalDuration: 12960000, totalRevenueContribution: parseEth("0.016"), }, { - referrer: "0x2a614b7984854177d22fa23a4034a13ea82e4f97", + referrer: acct("0x2a614b7984854177d22fa23a4034a13ea82e4f97"), totalReferrals: 5, totalIncrementalDuration: 12096000, totalRevenueContribution: parseEth("0.014"), }, { - referrer: "0x2382a5878a44a6de5c3d91537d4132dc29e93c60", + referrer: acct("0x2382a5878a44a6de5c3d91537d4132dc29e93c60"), totalReferrals: 4, totalIncrementalDuration: 9676800, totalRevenueContribution: parseEth("0.013"), }, { - referrer: "0x0000ffa596cdf9a69676e689b1a92e5e68171122", + referrer: acct("0x0000ffa596cdf9a69676e689b1a92e5e68171122"), totalReferrals: 1, totalIncrementalDuration: 7948800, totalRevenueContribution: parseEth("0.005"), }, { - referrer: "0xc7190732aa0c3d523d945530bec6caeb8489b4a5", + referrer: acct("0xc7190732aa0c3d523d945530bec6caeb8489b4a5"), totalReferrals: 3, totalIncrementalDuration: 7257600, totalRevenueContribution: parseEth("0.009"), }, { - referrer: "0x98c54f630c38c434cff2a1e3be9e095977cdc6af", + referrer: acct("0x98c54f630c38c434cff2a1e3be9e095977cdc6af"), totalReferrals: 3, totalIncrementalDuration: 7257600, totalRevenueContribution: parseEth("0.0095"), }, { - referrer: "0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481", + referrer: acct("0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), }, { - referrer: "0x58879236e40b73482f585a5f74766d6b99cb1057", + referrer: acct("0x58879236e40b73482f585a5f74766d6b99cb1057"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.007"), }, { - referrer: "0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6", + referrer: acct("0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.0065"), }, { - referrer: "0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf", + referrer: acct("0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.0075"), }, { - referrer: "0xadc318567a4a16db3839208b435184ae86ba3e43", + referrer: acct("0xadc318567a4a16db3839208b435184ae86ba3e43"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.008"), }, { - referrer: "0x2254f9bab9b3d56994504c46932289447a708529", + referrer: acct("0x2254f9bab9b3d56994504c46932289447a708529"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.0085"), }, { - referrer: "0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0", + referrer: acct("0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0"), totalReferrals: 1, totalIncrementalDuration: 3628800, totalRevenueContribution: parseEth("0.004"), }, { - referrer: "0x8354d821a89cc3c37902b60e9f30a15a6f810096", + referrer: acct("0x8354d821a89cc3c37902b60e9f30a15a6f810096"), totalReferrals: 2, totalIncrementalDuration: 2505600, totalRevenueContribution: parseEth("0.0055"), }, { - referrer: "0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f", + referrer: acct("0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f"), totalReferrals: 3, totalIncrementalDuration: 2419203, totalRevenueContribution: parseEth("0.011"), }, { - referrer: "0x3d93f8a930023263c17a639580525a561072458c", + referrer: acct("0x3d93f8a930023263c17a639580525a561072458c"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), }, { - referrer: "0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4", + referrer: acct("0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.0035"), }, { - referrer: "0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f", + referrer: acct("0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.0032"), }, { - referrer: "0xf35d9e265d20096af90a891205020ffab9291c8b", + referrer: acct("0xf35d9e265d20096af90a891205020ffab9291c8b"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.0045"), }, { - referrer: "0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64", + referrer: acct("0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.0028"), }, { - referrer: "0xf5746ef53ed961afd3b2a6c6d13de65e1605d215", + referrer: acct("0xf5746ef53ed961afd3b2a6c6d13de65e1605d215"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.0025"), }, { - referrer: "0x531a360408b69dcf325115921064c6e784cdc297", + referrer: acct("0x531a360408b69dcf325115921064c6e784cdc297"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.0033"), @@ -216,9 +228,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, referrers: new Map([ [ - "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e", + acctKey("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"), { - referrer: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e", + referrer: acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"), totalReferrals: 3, totalIncrementalDuration: 94694400, totalRevenueContribution: parseEth("0.015"), @@ -232,9 +244,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca", + acctKey("0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca"), { - referrer: "0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca", + referrer: acct("0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca"), totalReferrals: 2, totalIncrementalDuration: 63072000, totalRevenueContribution: parseEth("0.01"), @@ -248,9 +260,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x00000000000000000000000000000000000000f1", + acctKey("0x00000000000000000000000000000000000000f1"), { - referrer: "0x00000000000000000000000000000000000000f1", + referrer: acct("0x00000000000000000000000000000000000000f1"), totalReferrals: 3, totalIncrementalDuration: 39657600, totalRevenueContribution: parseEth("0.012"), @@ -264,9 +276,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + acctKey("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), { - referrer: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + referrer: acct("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), totalReferrals: 4, totalIncrementalDuration: 34214400, totalRevenueContribution: parseEth("0.018"), @@ -280,9 +292,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a", + acctKey("0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a"), { - referrer: "0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a", + referrer: acct("0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a"), totalReferrals: 7, totalIncrementalDuration: 15120000, totalRevenueContribution: parseEth("0.021"), @@ -296,9 +308,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xffa596cdf9a69676e689b1a92e5e681711227d75", + acctKey("0xffa596cdf9a69676e689b1a92e5e681711227d75"), { - referrer: "0xffa596cdf9a69676e689b1a92e5e681711227d75", + referrer: acct("0xffa596cdf9a69676e689b1a92e5e681711227d75"), totalReferrals: 5, totalIncrementalDuration: 12960000, totalRevenueContribution: parseEth("0.016"), @@ -312,9 +324,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x2a614b7984854177d22fa23a4034a13ea82e4f97", + acctKey("0x2a614b7984854177d22fa23a4034a13ea82e4f97"), { - referrer: "0x2a614b7984854177d22fa23a4034a13ea82e4f97", + referrer: acct("0x2a614b7984854177d22fa23a4034a13ea82e4f97"), totalReferrals: 5, totalIncrementalDuration: 12096000, totalRevenueContribution: parseEth("0.014"), @@ -328,9 +340,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x2382a5878a44a6de5c3d91537d4132dc29e93c60", + acctKey("0x2382a5878a44a6de5c3d91537d4132dc29e93c60"), { - referrer: "0x2382a5878a44a6de5c3d91537d4132dc29e93c60", + referrer: acct("0x2382a5878a44a6de5c3d91537d4132dc29e93c60"), totalReferrals: 4, totalIncrementalDuration: 9676800, totalRevenueContribution: parseEth("0.013"), @@ -344,9 +356,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x0000ffa596cdf9a69676e689b1a92e5e68171122", + acctKey("0x0000ffa596cdf9a69676e689b1a92e5e68171122"), { - referrer: "0x0000ffa596cdf9a69676e689b1a92e5e68171122", + referrer: acct("0x0000ffa596cdf9a69676e689b1a92e5e68171122"), totalReferrals: 1, totalIncrementalDuration: 7948800, totalRevenueContribution: parseEth("0.005"), @@ -360,9 +372,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xc7190732aa0c3d523d945530bec6caeb8489b4a5", + acctKey("0xc7190732aa0c3d523d945530bec6caeb8489b4a5"), { - referrer: "0xc7190732aa0c3d523d945530bec6caeb8489b4a5", + referrer: acct("0xc7190732aa0c3d523d945530bec6caeb8489b4a5"), totalReferrals: 3, totalIncrementalDuration: 7257600, totalRevenueContribution: parseEth("0.009"), @@ -376,9 +388,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x98c54f630c38c434cff2a1e3be9e095977cdc6af", + acctKey("0x98c54f630c38c434cff2a1e3be9e095977cdc6af"), { - referrer: "0x98c54f630c38c434cff2a1e3be9e095977cdc6af", + referrer: acct("0x98c54f630c38c434cff2a1e3be9e095977cdc6af"), totalReferrals: 3, totalIncrementalDuration: 7257600, totalRevenueContribution: parseEth("0.009"), @@ -392,9 +404,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xadc318567a4a16db3839208b435184ae86ba3e43", + acctKey("0xadc318567a4a16db3839208b435184ae86ba3e43"), { - referrer: "0xadc318567a4a16db3839208b435184ae86ba3e43", + referrer: acct("0xadc318567a4a16db3839208b435184ae86ba3e43"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), @@ -408,9 +420,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf", + acctKey("0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf"), { - referrer: "0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf", + referrer: acct("0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), @@ -424,9 +436,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6", + acctKey("0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6"), { - referrer: "0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6", + referrer: acct("0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), @@ -440,9 +452,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x58879236e40b73482f585a5f74766d6b99cb1057", + acctKey("0x58879236e40b73482f585a5f74766d6b99cb1057"), { - referrer: "0x58879236e40b73482f585a5f74766d6b99cb1057", + referrer: acct("0x58879236e40b73482f585a5f74766d6b99cb1057"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), @@ -456,9 +468,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481", + acctKey("0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481"), { - referrer: "0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481", + referrer: acct("0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), @@ -472,9 +484,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x2254f9bab9b3d56994504c46932289447a708529", + acctKey("0x2254f9bab9b3d56994504c46932289447a708529"), { - referrer: "0x2254f9bab9b3d56994504c46932289447a708529", + referrer: acct("0x2254f9bab9b3d56994504c46932289447a708529"), totalReferrals: 2, totalIncrementalDuration: 4838400, totalRevenueContribution: parseEth("0.006"), @@ -488,9 +500,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x54e7c79aceb6b736da4c29da088aae30991635bb", + acctKey("0x54e7c79aceb6b736da4c29da088aae30991635bb"), { - referrer: "0x54e7c79aceb6b736da4c29da088aae30991635bb", + referrer: acct("0x54e7c79aceb6b736da4c29da088aae30991635bb"), totalReferrals: 2, totalIncrementalDuration: 4579200, totalRevenueContribution: parseEth("0"), @@ -504,9 +516,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c", + acctKey("0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c"), { - referrer: "0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c", + referrer: acct("0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c"), totalReferrals: 1, totalIncrementalDuration: 3974400, totalRevenueContribution: parseEth("0"), @@ -520,9 +532,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0", + acctKey("0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0"), { - referrer: "0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0", + referrer: acct("0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0"), totalReferrals: 1, totalIncrementalDuration: 3628800, totalRevenueContribution: parseEth("0.004"), @@ -536,9 +548,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x8354d821a89cc3c37902b60e9f30a15a6f810096", + acctKey("0x8354d821a89cc3c37902b60e9f30a15a6f810096"), { - referrer: "0x8354d821a89cc3c37902b60e9f30a15a6f810096", + referrer: acct("0x8354d821a89cc3c37902b60e9f30a15a6f810096"), totalReferrals: 2, totalIncrementalDuration: 2505600, totalRevenueContribution: parseEth("0.0055"), @@ -552,9 +564,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f", + acctKey("0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f"), { - referrer: "0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f", + referrer: acct("0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f"), totalReferrals: 3, totalIncrementalDuration: 2419203, totalRevenueContribution: parseEth("0.011"), @@ -568,9 +580,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xf5746ef53ed961afd3b2a6c6d13de65e1605d215", + acctKey("0xf5746ef53ed961afd3b2a6c6d13de65e1605d215"), { - referrer: "0xf5746ef53ed961afd3b2a6c6d13de65e1605d215", + referrer: acct("0xf5746ef53ed961afd3b2a6c6d13de65e1605d215"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), @@ -584,9 +596,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xf35d9e265d20096af90a891205020ffab9291c8b", + acctKey("0xf35d9e265d20096af90a891205020ffab9291c8b"), { - referrer: "0xf35d9e265d20096af90a891205020ffab9291c8b", + referrer: acct("0xf35d9e265d20096af90a891205020ffab9291c8b"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), @@ -600,9 +612,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f", + acctKey("0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f"), { - referrer: "0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f", + referrer: acct("0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), @@ -616,9 +628,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64", + acctKey("0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64"), { - referrer: "0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64", + referrer: acct("0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), @@ -632,9 +644,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x531a360408b69dcf325115921064c6e784cdc297", + acctKey("0x531a360408b69dcf325115921064c6e784cdc297"), { - referrer: "0x531a360408b69dcf325115921064c6e784cdc297", + referrer: acct("0x531a360408b69dcf325115921064c6e784cdc297"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), @@ -648,9 +660,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x3d93f8a930023263c17a639580525a561072458c", + acctKey("0x3d93f8a930023263c17a639580525a561072458c"), { - referrer: "0x3d93f8a930023263c17a639580525a561072458c", + referrer: acct("0x3d93f8a930023263c17a639580525a561072458c"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), @@ -664,9 +676,9 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { }, ], [ - "0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4", + acctKey("0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4"), { - referrer: "0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4", + referrer: acct("0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4"), totalReferrals: 1, totalIncrementalDuration: 2419200, totalRevenueContribution: parseEth("0.003"), diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index 5efabc512f..be9190a9ba 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -2934,12 +2934,15 @@ "operationId": "getReferrerDetail", "tags": ["ENSAwards"], "summary": "Get Referrer Detail for Editions", - "description": "Returns detailed information for a specific referrer for the requested editions. Requires 1-20 distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.", + "description": "Returns detailed information for a specific referrer for the requested editions. The referrer is identified by its CAIP-10 AccountId (URL-encoded, e.g. `eip155%3A1%3A0xabc...`). Requires 1-20 distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.", "parameters": [ { - "schema": { "type": "string", "description": "Referrer Ethereum address" }, + "schema": { + "type": "string", + "description": "Referrer CAIP-10 AccountId (e.g. eip155:1:0xabc...)" + }, "required": true, - "description": "Referrer Ethereum address", + "description": "Referrer CAIP-10 AccountId (e.g. eip155:1:0xabc...)", "name": "referrer", "in": "path" }, diff --git a/packages/ens-referrals/src/api/types.ts b/packages/ens-referrals/src/api/types.ts index d23898965d..4932beef7a 100644 --- a/packages/ens-referrals/src/api/types.ts +++ b/packages/ens-referrals/src/api/types.ts @@ -1,4 +1,4 @@ -import type { Address } from "enssdk"; +import type { AccountId } from "enssdk"; import type { ReferrerLeaderboardPageParams } from "../award-models/shared/leaderboard-page"; import type { ReferralProgramEditionSlug } from "../edition"; @@ -71,8 +71,8 @@ export const MAX_EDITIONS_PER_REQUEST = 20; * Request parameters for referrer metrics query. */ export interface ReferrerMetricsEditionsRequest { - /** The Ethereum address of the referrer to query */ - referrer: Address; + /** The {@link AccountId} of the referrer to query */ + referrer: AccountId; /** Array of edition slugs to query (min 1, max {@link MAX_EDITIONS_PER_REQUEST}, must be distinct) */ editions: ReferralProgramEditionSlug[]; } diff --git a/packages/ens-referrals/src/api/zod-schemas.test.ts b/packages/ens-referrals/src/api/zod-schemas.test.ts index a2197379c3..7c956f7603 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -644,7 +644,10 @@ describe("makeReferrerEditionMetricsSchema", () => { minFinalScoreToQualify: 0, }; - const revShareCapReferrerAddress = "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"; + const revShareCapReferrerAddress = { + chainId: 1, + address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + }; const revShareCapRules = { awardModel: ReferralProgramAwardModels.RevShareCap, @@ -684,7 +687,7 @@ describe("makeReferrerEditionMetricsSchema", () => { type: ReferrerEditionMetricsTypeIds.Ranked, rules: pieSplitRules, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, totalReferrals: 5, totalIncrementalDuration: 100, totalRevenueContribution: parseEth("500"), @@ -714,7 +717,7 @@ describe("makeReferrerEditionMetricsSchema", () => { type: ReferrerEditionMetricsTypeIds.Unranked, rules: pieSplitRules, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, totalReferrals: 0, totalIncrementalDuration: 0, totalRevenueContribution: parseEth("0"), @@ -870,7 +873,7 @@ describe("makeReferrerEditionMetricsSchema", () => { cappedAward: parseUsdc("200"), adminAction: { ...warningAction, - referrer: "0x0000000000000000000000000000000000000001", + referrer: { chainId: 1, address: "0x0000000000000000000000000000000000000001" }, }, }, aggregatedMetrics: revShareCapAggregatedMetrics, @@ -897,7 +900,7 @@ describe("makeReferrerEditionMetricsSchema", () => { endTime: 500000, // endTime < startTime → refine violation }, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, totalReferrals: 5, totalIncrementalDuration: 100, totalRevenueContribution: parseEth("500"), diff --git a/packages/ens-referrals/src/api/zod-schemas.ts b/packages/ens-referrals/src/api/zod-schemas.ts index 416cf9868e..d77b153188 100644 --- a/packages/ens-referrals/src/api/zod-schemas.ts +++ b/packages/ens-referrals/src/api/zod-schemas.ts @@ -9,7 +9,7 @@ import z from "zod/v4"; -import { makeNormalizedAddressSchema } from "@ensnode/ensnode-sdk/internal"; +import { makeAccountIdStringSchema } from "@ensnode/ensnode-sdk/internal"; import { makeReferralProgramEditionSummaryPieSplitSchema, @@ -227,7 +227,7 @@ export const makeReferrerMetricsEditionsRequestSchema = ( valueLabel: string = "ReferrerMetricsEditionsRequest", ) => z.object({ - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdStringSchema(`${valueLabel}.referrer`), editions: makeReferrerMetricsEditionsArraySchema(`${valueLabel}.editions`), }); diff --git a/packages/ens-referrals/src/award-models/pie-split/api/zod-schemas.ts b/packages/ens-referrals/src/award-models/pie-split/api/zod-schemas.ts index 457e5bcc17..4c92052138 100644 --- a/packages/ens-referrals/src/award-models/pie-split/api/zod-schemas.ts +++ b/packages/ens-referrals/src/award-models/pie-split/api/zod-schemas.ts @@ -1,10 +1,10 @@ import z from "zod/v4"; import { + makeAccountIdSchema, makeDurationSchema, makeFiniteNonNegativeNumberSchema, makeNonNegativeIntegerSchema, - makeNormalizedAddressSchema, makePositiveIntegerSchema, makePriceEthSchema, makePriceUsdcSchema, @@ -39,7 +39,7 @@ export const makeAwardedReferrerMetricsPieSplitSchema = ( valueLabel: string = "AwardedReferrerMetricsPieSplit", ) => z.object({ - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdSchema(`${valueLabel}.referrer`), totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), @@ -65,7 +65,7 @@ export const makeUnrankedReferrerMetricsPieSplitSchema = ( valueLabel: string = "UnrankedReferrerMetricsPieSplit", ) => z.object({ - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdSchema(`${valueLabel}.referrer`), totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), diff --git a/packages/ens-referrals/src/award-models/pie-split/edition-metrics.ts b/packages/ens-referrals/src/award-models/pie-split/edition-metrics.ts index e4c3f21047..e4dfa93da0 100644 --- a/packages/ens-referrals/src/award-models/pie-split/edition-metrics.ts +++ b/packages/ens-referrals/src/award-models/pie-split/edition-metrics.ts @@ -8,7 +8,7 @@ import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } import type { ReferralProgramRulesPieSplit } from "./rules"; /** - * Referrer edition metrics data for a specific referrer address on the pie-split leaderboard. + * Referrer edition metrics data for a specific referrer on the pie-split leaderboard. * * Includes the referrer's awarded metrics from the leaderboard plus timestamp. * @@ -62,7 +62,7 @@ export interface ReferrerEditionMetricsRankedPieSplit { } /** - * Referrer edition metrics data for a specific referrer address NOT on the pie-split leaderboard. + * Referrer edition metrics data for a specific referrer NOT on the pie-split leaderboard. * * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. * diff --git a/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts b/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts index 9806de5004..d85efe865c 100644 --- a/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts +++ b/packages/ens-referrals/src/award-models/pie-split/leaderboard.ts @@ -1,4 +1,4 @@ -import type { NormalizedAddress, UnixTimestamp } from "enssdk"; +import { type AccountIdString, stringifyAccountId, type UnixTimestamp } from "enssdk"; import type { ReferrerMetrics } from "../../referrer-metrics"; import { assertLeaderboardInputs } from "../shared/leaderboard-guards"; @@ -39,16 +39,18 @@ export interface ReferrerLeaderboardPieSplit { * Ordered map containing `AwardedReferrerMetricsPieSplit` for all referrers with 1 or more * `totalReferrals` within the `rules` as of `accurateAsOf`. * + * Keys are CAIP-10 strings produced via {@link stringifyAccountId}. + * * @invariant Map entries are ordered by `rank` (ascending). * @invariant Map is empty if there are no referrers with 1 or more `totalReferrals` * within the `rules` as of `accurateAsOf`. - * @invariant If a `NormalizedAddress` is not a key in this map then that `NormalizedAddress` had + * @invariant If an {@link AccountIdString} is not a key in this map then that referrer had * 0 `totalReferrals`, `totalIncrementalDuration`, and `score` within the * `rules` as of `accurateAsOf`. * @invariant Each value in this map is guaranteed to have a non-zero * `totalReferrals`, `totalIncrementalDuration`, and `score`. */ - referrers: Map; + referrers: Map; /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardPieSplit} was accurate as of. @@ -94,7 +96,7 @@ export const buildReferralEditionSnapshotPieSplit = ( buildAwardedReferrerMetricsPieSplit(r, aggregatedMetrics, rules), ); - const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); + const referrers = new Map(awardedReferrers.map((r) => [stringifyAccountId(r.referrer), r])); const leaderboard: ReferrerLeaderboardPieSplit = { awardModel: rules.awardModel, diff --git a/packages/ens-referrals/src/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/award-models/pie-split/metrics.ts index 37ff2b6195..a5de96ff32 100644 --- a/packages/ens-referrals/src/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/award-models/pie-split/metrics.ts @@ -1,4 +1,4 @@ -import type { NormalizedAddress } from "enssdk"; +import type { AccountId } from "enssdk"; import { type PriceUsdc, priceEth, priceUsdc, scalePrice } from "@ensnode/ensnode-sdk"; import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; @@ -307,16 +307,16 @@ export const validateUnrankedReferrerMetricsPieSplit = ( }; /** - * Build an unranked zero-score referrer record for a referrer address that is not in the leaderboard. + * Build an unranked zero-score referrer record for a referrer that is not on the leaderboard. * - * This is useful when you want to return a referrer record for an address that has no referrals + * This is useful when you want to return a referrer record for an account that has no referrals * and is not qualified for the leaderboard. * - * @param referrer - The referrer address + * @param referrer - The referrer {@link AccountId} * @returns An {@link UnrankedReferrerMetricsPieSplit} with zero values for all metrics and null rank */ export const buildUnrankedReferrerMetricsPieSplit = ( - referrer: NormalizedAddress, + referrer: AccountId, ): UnrankedReferrerMetricsPieSplit => { const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); const scoredMetrics = buildScoredReferrerMetricsPieSplit(metrics); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts index 4ac3bdcf1a..8a470f56a5 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.test.ts @@ -1,4 +1,10 @@ -import { type Address, asInterpretedName } from "enssdk"; +import { + type AccountId, + type Address, + asInterpretedName, + stringifyAccountId, + toNormalizedAddress, +} from "enssdk"; import type { Hash } from "viem"; import { beforeEach, describe, expect, it } from "vitest"; @@ -11,8 +17,15 @@ import { type AdminAction, AdminActionTypes, buildReferralProgramRulesRevShareCa // ─── Fixtures ──────────────────────────────────────────────────────────────── -const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as const; -const ADDR_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as const; +const acct = (address: Address): AccountId => ({ + chainId: 1, + address: toNormalizedAddress(address), +}); + +const ADDR_A: AccountId = acct("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); +const ADDR_B: AccountId = acct("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + +const KEY_A = stringifyAccountId(ADDR_A); const CHECKPOINT_PREFIX = "0000000000" + "0000000000000001" + "0000000000000001" + "0000000000000000" + "0"; @@ -29,7 +42,7 @@ function buildTestRules( 0.5, parseTimestamp("2026-01-01T00:00:00Z"), parseTimestamp("2026-12-31T23:59:59Z"), - { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), new URL("https://example.com/rules"), false, adminActions, @@ -38,7 +51,7 @@ function buildTestRules( let eventIdCounter = 0; function makeEvent( - referrer: `0x${string}`, + referrer: AccountId, timestamp: number, incrementalDuration: number, ): ReferralEvent { @@ -279,7 +292,7 @@ describe("buildReferralEditionSnapshotRevShareCap — per-event trace", () => { expect(leaderboard.awardModel).toBe(rules.awardModel); expect(leaderboard.rules).toBe(rules); expect(leaderboard.accurateAsOf).toBe(accurateAsOf); - const a = leaderboard.referrers.get(ADDR_A)!; + const a = leaderboard.referrers.get(KEY_A)!; expect(a.isQualified).toBe(true); // A: 1.5 years → base $7.50 → uncapped $3.75 expect(a.uncappedAward.amount).toBe(parseUsdc("3.75").amount); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts index 3f52e0e0a0..7ad1487221 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/accounting.ts @@ -1,4 +1,4 @@ -import type { Address, Duration, InterpretedName, NormalizedAddress, UnixTimestamp } from "enssdk"; +import type { AccountId, Address, Duration, InterpretedName, UnixTimestamp } from "enssdk"; import type { Hash } from "viem"; import type { PriceEth, PriceUsdc, RegistrarActionType } from "@ensnode/ensnode-sdk"; @@ -115,9 +115,9 @@ export interface ReferralAccountingRecordRevShareCap { registrant: Address; /** - * Referrer that received credit. + * Referrer that received credit, as an {@link AccountId}. */ - referrer: NormalizedAddress; + referrer: AccountId; /** * Incremental duration (seconds) contributed by this referral. diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts b/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts index ac12320a9a..e9ccab5e6f 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts @@ -1,11 +1,12 @@ -import type { NormalizedAddress } from "enssdk"; +import { type AccountId, stringifyAccountId } from "enssdk"; import z from "zod/v4"; +import { accountIdEqual } from "@ensnode/ensnode-sdk"; import { + makeAccountIdSchema, makeDurationSchema, makeFiniteNonNegativeNumberSchema, makeNonNegativeIntegerSchema, - makeNormalizedAddressSchema, makePositiveIntegerSchema, makePriceEthSchema, makePriceUsdcSchema, @@ -28,7 +29,7 @@ import { type AdminAction, AdminActionTypes } from "../rules"; export const makeAdminActionDisqualificationSchema = (valueLabel = "AdminActionDisqualification") => z.object({ actionType: z.literal(AdminActionTypes.Disqualification), - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdSchema(`${valueLabel}.referrer`), reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`), }); @@ -38,7 +39,7 @@ export const makeAdminActionDisqualificationSchema = (valueLabel = "AdminActionD export const makeAdminActionWarningSchema = (valueLabel = "AdminActionWarning") => z.object({ actionType: z.literal(AdminActionTypes.Warning), - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdSchema(`${valueLabel}.referrer`), reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`), }); @@ -70,14 +71,13 @@ export const makeReferralProgramRulesRevShareCapSchema = ( ), adminActions: z .array(makeAdminActionSchema(`${valueLabel}.adminActions[item]`)) - // NOTE: addresses are already normalized, so string equivalence here is accurate .refine( (items) => { - const referrers = items.map((a) => a.referrer); - return new Set(referrers).size === referrers.length; + const keys = items.map((a) => stringifyAccountId(a.referrer)); + return new Set(keys).size === keys.length; }, { - message: `${valueLabel}.adminActions must not contain duplicate referrer addresses`, + message: `${valueLabel}.adminActions must not contain duplicate referrer AccountIds`, }, ) .default([]), @@ -91,7 +91,7 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( ) => z .object({ - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdSchema(`${valueLabel}.referrer`), totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), @@ -121,10 +121,14 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( message: `${valueLabel}.cappedAward must be 0 when isQualified is false`, path: ["cappedAward"], }) - .refine((data) => data.adminAction === null || data.adminAction.referrer === data.referrer, { - message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, - path: ["adminAction", "referrer"], - }); + .refine( + (data) => + data.adminAction === null || accountIdEqual(data.adminAction.referrer, data.referrer), + { + message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, + path: ["adminAction", "referrer"], + }, + ); /** * Schema for {@link UnrankedReferrerMetricsRevShareCap} (with null rank). @@ -134,7 +138,7 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( ) => z .object({ - referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + referrer: makeAccountIdSchema(`${valueLabel}.referrer`), totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), @@ -171,10 +175,14 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( message: `${valueLabel}.cappedAward must be 0 for unranked referrers`, path: ["cappedAward"], }) - .refine((data) => data.adminAction === null || data.adminAction.referrer === data.referrer, { - message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, - path: ["adminAction", "referrer"], - }); + .refine( + (data) => + data.adminAction === null || accountIdEqual(data.adminAction.referrer, data.referrer), + { + message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, + path: ["adminAction", "referrer"], + }, + ); /** * Schema for {@link AggregatedReferrerMetricsRevShareCap}. @@ -200,11 +208,11 @@ export const makeAggregatedReferrerMetricsRevShareCapSchema = ( const addAdminActionConsistencyIssues = ( ctx: z.RefinementCtx, metricsAdminAction: AdminAction | null, - referrer: NormalizedAddress, + referrer: AccountId, rulesAdminActions: AdminAction[], path: (string | number)[], ): void => { - const expected = rulesAdminActions.find((a) => a.referrer === referrer) ?? null; + const expected = rulesAdminActions.find((a) => accountIdEqual(a.referrer, referrer)) ?? null; if (expected === null && metricsAdminAction !== null) { ctx.addIssue({ @@ -219,7 +227,7 @@ const addAdminActionConsistencyIssues = ( expected !== null && (metricsAdminAction === null || metricsAdminAction.actionType !== expected.actionType || - metricsAdminAction.referrer !== expected.referrer || + !accountIdEqual(metricsAdminAction.referrer, expected.referrer) || metricsAdminAction.reason !== expected.reason) ) { ctx.addIssue({ diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/edition-metrics.ts b/packages/ens-referrals/src/award-models/rev-share-cap/edition-metrics.ts index 45dafac461..2213b56100 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/edition-metrics.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/edition-metrics.ts @@ -61,7 +61,7 @@ export interface ReferrerEditionMetricsRankedRevShareCap { } /** - * Referrer edition metrics data for a specific referrer address NOT on the rev-share-cap leaderboard. + * Referrer edition metrics data for a specific referrer NOT on the rev-share-cap leaderboard. * * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. * diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts index 1035a0c2cb..3961952442 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts @@ -1,4 +1,10 @@ -import { type Address, asInterpretedName, type NormalizedAddress } from "enssdk"; +import { + type AccountId, + type Address, + asInterpretedName, + stringifyAccountId, + toNormalizedAddress, +} from "enssdk"; import type { Hash } from "viem"; import { beforeEach, describe, expect, it } from "vitest"; @@ -14,9 +20,20 @@ import { type AdminAction, AdminActionTypes, buildReferralProgramRulesRevShareCa // ─── Test fixtures ─────────────────────────────────────────────────────────── -const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as const; -const ADDR_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as const; -const ADDR_C = "0xcccccccccccccccccccccccccccccccccccccccc" as const; +const TEST_CHAIN_ID = 1; +const acct = (address: Address): AccountId => ({ + chainId: TEST_CHAIN_ID, + address: toNormalizedAddress(address), +}); + +const ADDR_A: AccountId = acct("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); +const ADDR_B: AccountId = acct("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); +const ADDR_C: AccountId = acct("0xcccccccccccccccccccccccccccccccccccccccc"); + +// CAIP-10 keys for Map.get lookups (the leaderboard's `referrers` map is keyed by AccountIdString). +const KEY_A = stringifyAccountId(ADDR_A); +const KEY_B = stringifyAccountId(ADDR_B); +const KEY_C = stringifyAccountId(ADDR_C); const ZERO_ETH = priceEth(0n); @@ -60,7 +77,7 @@ function buildTestRules( 0.5, // maxBaseRevenueShare parseTimestamp("2026-01-01T00:00:00Z"), parseTimestamp("2026-12-31T23:59:59Z"), - { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), new URL("https://example.com/rules"), false, adminActions, @@ -73,7 +90,7 @@ function buildTestRules( let eventIdCounter = 0; function makeEvent( - referrer: `0x${string}`, + referrer: AccountId, timestamp: number, incrementalDuration: number, opts: Partial> = {}, @@ -99,11 +116,11 @@ const accurateAsOf = parseTimestamp("2026-06-01T00:00:00Z"); /** $2.50 USDC in raw amount (uncapped award for 1 year of duration at 50% share) */ const UNCAPPED_AWARD_1Y = parseUsdc("2.5"); -function disqualification(referrer: NormalizedAddress, reason: string): AdminAction { +function disqualification(referrer: AccountId, reason: string): AdminAction { return { actionType: AdminActionTypes.Disqualification, referrer, reason }; } -function warning(referrer: NormalizedAddress, reason: string): AdminAction { +function warning(referrer: AccountId, reason: string): AdminAction { return { actionType: AdminActionTypes.Warning, referrer, reason }; } @@ -141,7 +158,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrer = result.referrers.get(ADDR_A)!; + const referrer = result.referrers.get(KEY_A)!; expect(referrer).toBeDefined(); expect(referrer.isQualified).toBe(false); @@ -170,7 +187,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrer = result.referrers.get(ADDR_A)!; + const referrer = result.referrers.get(KEY_A)!; expect(referrer.isQualified).toBe(true); expect(referrer.uncappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); @@ -194,7 +211,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrer = result.referrers.get(ADDR_A)!; + const referrer = result.referrers.get(KEY_A)!; expect(referrer.isQualified).toBe(true); // uncappedAward = $2.50 (uncapped) @@ -222,7 +239,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrer = result.referrers.get(ADDR_A)!; + const referrer = result.referrers.get(KEY_A)!; expect(referrer.isQualified).toBe(true); // uncappedAward = 0.5 × (2 × $5) = $5.00 @@ -248,7 +265,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrer = result.referrers.get(ADDR_A)!; + const referrer = result.referrers.get(KEY_A)!; expect(referrer.isQualified).toBe(true); // uncappedAward = 0.5 × $10 = $5.00 (uncapped) @@ -271,7 +288,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrer = result.referrers.get(ADDR_A)!; + const referrer = result.referrers.get(KEY_A)!; expect(referrer.isQualified).toBe(true); expect(referrer.uncappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); @@ -295,8 +312,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; expect(referrerA.isQualified).toBe(true); expect(referrerA.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); // $2.50 @@ -323,8 +340,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; expect(referrerA.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); // $2.50 expect(referrerB.cappedAward.amount).toBe(0n); // $0 — pool empty @@ -348,9 +365,9 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; - const referrerC = result.referrers.get(ADDR_C)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; + const referrerC = result.referrers.get(KEY_C)!; // Non-truncated: full uncapped award expect(referrerA.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); @@ -384,8 +401,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { ).leaderboard; // ADDR_A has the lower (earlier) id, should claim the pool first - expect(result.referrers.get(ADDR_A)!.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); - expect(result.referrers.get(ADDR_B)!.cappedAward.amount).toBe(0n); + expect(result.referrers.get(KEY_A)!.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); + expect(result.referrers.get(KEY_B)!.cappedAward.amount).toBe(0n); }); }); @@ -411,9 +428,9 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { // ADDR_B: cappedAward $5.00 → rank 1 (highest pool claim) // ADDR_A: cappedAward $2.50 → rank 2 // ADDR_C: cappedAward $0, uncappedAward $1.25 → rank 3 (unqualified) - expect(result.referrers.get(ADDR_B)!.rank).toBe(1); - expect(result.referrers.get(ADDR_A)!.rank).toBe(2); - expect(result.referrers.get(ADDR_C)!.rank).toBe(3); + expect(result.referrers.get(KEY_B)!.rank).toBe(1); + expect(result.referrers.get(KEY_A)!.rank).toBe(2); + expect(result.referrers.get(KEY_C)!.rank).toBe(3); }); it("two fully-truncated referrers are ranked by uncappedAward desc", () => { @@ -433,8 +450,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { ).leaderboard; // Both have $0 cappedAward; ADDR_A has higher uncappedAward (longer duration) → rank 1 - expect(result.referrers.get(ADDR_A)!.rank).toBe(1); - expect(result.referrers.get(ADDR_B)!.rank).toBe(2); + expect(result.referrers.get(KEY_A)!.rank).toBe(1); + expect(result.referrers.get(KEY_B)!.rank).toBe(2); }); it("referrers map is ordered by rank ascending", () => { @@ -499,8 +516,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; // ADDR_A: 1 year at $10/yr → $10 base → uncapped = 0.5 × $10 = $5.00 expect(referrerA.isQualified).toBe(true); @@ -551,8 +568,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; expect(referrerA.isQualified).toBe(true); expect(referrerA.adminAction).toBe(null); @@ -578,8 +595,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; expect(referrerA.adminAction).toEqual(disqualification(ADDR_A, "self-referral")); expect(referrerA.isQualified).toBe(false); @@ -603,7 +620,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; + const referrerA = result.referrers.get(KEY_A)!; expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); expect(referrerA.adminAction?.reason).toBe("promoting discounts"); @@ -636,9 +653,9 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; - const referrerC = result.referrers.get(ADDR_C)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; + const referrerC = result.referrers.get(KEY_C)!; expect(referrerB.rank).toBe(1); expect(referrerB.isQualified).toBe(true); @@ -673,9 +690,9 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; - const referrerC = result.referrers.get(ADDR_C)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; + const referrerC = result.referrers.get(KEY_C)!; expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); expect(referrerA.adminAction?.reason).toBe("reason-a"); @@ -699,7 +716,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { warning(ADDR_A, "duplicate"), ]), ).toThrow( - "ReferralProgramRulesRevShareCap: adminActions must not contain duplicate referrer addresses.", + "ReferralProgramRulesRevShareCap: adminActions must not contain duplicate referrer AccountIds.", ); }); @@ -717,8 +734,8 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; - const referrerB = result.referrers.get(ADDR_B)!; + const referrerA = result.referrers.get(KEY_A)!; + const referrerB = result.referrers.get(KEY_B)!; // Warned referrer is NOT disqualified — they still qualify and get awards expect(referrerA.adminAction).toEqual(warning(ADDR_A, "suspicious activity")); @@ -740,7 +757,7 @@ describe("buildReferralEditionSnapshotRevShareCap", () => { rules, accurateAsOf, ).leaderboard; - const referrerA = result.referrers.get(ADDR_A)!; + const referrerA = result.referrers.get(KEY_A)!; expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Warning); expect(referrerA.isQualified).toBe(false); // below threshold, not because of warning diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts index 49f2964731..03a136a30e 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.ts @@ -1,4 +1,10 @@ -import type { Duration, NormalizedAddress, UnixTimestamp } from "enssdk"; +import { + type AccountId, + type AccountIdString, + type Duration, + stringifyAccountId, + type UnixTimestamp, +} from "enssdk"; import { addPrices, @@ -60,16 +66,18 @@ export interface ReferrerLeaderboardRevShareCap { * Ordered map containing {@link AwardedReferrerMetricsRevShareCap} for all referrers with 1 or more * `totalReferrals` within the `rules` as of `accurateAsOf`. * + * Keys are CAIP-10 strings produced via {@link stringifyAccountId}. + * * @invariant Map entries are ordered by `rank` (ascending). * @invariant Map is empty if there are no referrers with 1 or more `totalReferrals` * within the `rules` as of `accurateAsOf`. - * @invariant If a `NormalizedAddress` is not a key in this map then that `NormalizedAddress` had + * @invariant If an {@link AccountIdString} is not a key in this map then that referrer had * 0 `totalReferrals`, `totalIncrementalDuration`, and `totalRevenueContribution` within the * `rules` as of `accurateAsOf`. * @invariant Each value in this map is guaranteed to have a non-zero * `totalReferrals` and `totalIncrementalDuration`. */ - referrers: Map; + referrers: Map; /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardRevShareCap} was accurate as of. @@ -105,6 +113,7 @@ export interface ReferralEditionSnapshotRevShareCap { * Per-referrer mutable state used during sequential race processing. */ interface ReferrerRaceState { + referrer: AccountId; totalReferrals: number; totalIncrementalDuration: Duration; totalRevenueContribution: PriceEth; @@ -140,29 +149,31 @@ export const buildReferralEditionSnapshotRevShareCap = ( const sortedEvents = sortReferralEvents(events); // Index admin actions by referrer; `rules.adminActions` is validated to have at most one action per referrer. - const adminActionByReferrer = new Map(); + const adminActionByReferrer = new Map(); for (const action of rules.adminActions) { - adminActionByReferrer.set(action.referrer, action); + adminActionByReferrer.set(stringifyAccountId(action.referrer), action); } // 2. Process events sequentially to run the race. - const referrerStates = new Map(); + const referrerStates = new Map(); const accountingRecords: ReferralAccountingRecordRevShareCap[] = []; let awardPoolRemaining: PriceUsdc = rules.awardPool; for (const event of sortedEvents) { const referrerId = event.referrer; + const referrerKey = stringifyAccountId(referrerId); - let referrerState = referrerStates.get(referrerId); + let referrerState = referrerStates.get(referrerKey); if (!referrerState) { referrerState = { + referrer: referrerId, totalReferrals: 0, totalIncrementalDuration: 0, totalRevenueContribution: priceEth(0n), hasQualified: false, cappedAward: priceUsdc(0n), }; - referrerStates.set(referrerId, referrerState); + referrerStates.set(referrerKey, referrerState); } // Update raw totals BEFORE computing the accounting record. @@ -176,7 +187,7 @@ export const buildReferralEditionSnapshotRevShareCap = ( const hasQualifiedBefore = referrerState.hasQualified; const awardPoolRemainingBefore = awardPoolRemaining; - const adminAction = adminActionByReferrer.get(referrerId); + const adminAction = adminActionByReferrer.get(referrerKey); const adminDisqualification = adminAction?.actionType === AdminActionTypes.Disqualification ? adminAction : null; @@ -254,7 +265,7 @@ export const buildReferralEditionSnapshotRevShareCap = ( // 3. Sort referrers to assign ranks: // 1. cappedAward desc — actual pool claims, race winners first // 2. totalIncrementalDuration desc — tie-break for pool-depleted referrers - // 3. referrer address desc — deterministic tie-break + // 3. referrer AccountIdString desc — deterministic tie-break const sortedEntries = [...referrerStates.entries()].sort( ([referrerIdA, referrerStateA], [referrerIdB, referrerStateB]) => { // Primary: cappedAward desc (bigint comparison) @@ -267,7 +278,7 @@ export const buildReferralEditionSnapshotRevShareCap = ( return referrerStateB.totalIncrementalDuration - referrerStateA.totalIncrementalDuration; } - // Tertiary: referrer address desc (lexicographic) + // Tertiary: referrer AccountIdString desc (lexicographic) if (referrerIdB > referrerIdA) return 1; if (referrerIdB < referrerIdA) return -1; return 0; @@ -276,9 +287,9 @@ export const buildReferralEditionSnapshotRevShareCap = ( // 4. Build AwardedReferrerMetricsRevShareCap for each referrer. const awardedReferrers: AwardedReferrerMetricsRevShareCap[] = sortedEntries.map( - ([referrerId, referrerState], index) => { + ([, referrerState], index) => { const baseMetrics = buildReferrerMetrics( - referrerId, + referrerState.referrer, referrerState.totalReferrals, referrerState.totalIncrementalDuration, referrerState.totalRevenueContribution, @@ -311,7 +322,7 @@ export const buildReferralEditionSnapshotRevShareCap = ( awardPoolRemaining, ); - const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); + const referrers = new Map(awardedReferrers.map((r) => [stringifyAccountId(r.referrer), r])); return { awardModel: rules.awardModel, diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts b/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts index c5ae131430..617d628658 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts @@ -1,6 +1,6 @@ -import type { NormalizedAddress } from "enssdk"; +import type { AccountId } from "enssdk"; -import { type PriceUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; +import { accountIdEqual, type PriceUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; import type { ReferrerMetrics } from "../../referrer-metrics"; @@ -106,11 +106,11 @@ export interface RankedReferrerMetricsRevShareCap extends ReferrerMetricsRevShar */ const validateAdminActionConsistency = ( metricsAdminAction: AdminAction | null, - referrer: NormalizedAddress, + referrer: AccountId, rules: ReferralProgramRulesRevShareCap, context: string, ): void => { - const expected = rules.adminActions.find((a) => a.referrer === referrer) ?? null; + const expected = rules.adminActions.find((a) => accountIdEqual(a.referrer, referrer)) ?? null; if (expected === null && metricsAdminAction !== null) { throw new Error( @@ -122,7 +122,7 @@ const validateAdminActionConsistency = ( if ( metricsAdminAction === null || metricsAdminAction.actionType !== expected.actionType || - metricsAdminAction.referrer !== expected.referrer || + !accountIdEqual(metricsAdminAction.referrer, expected.referrer) || metricsAdminAction.reason !== expected.reason ) { throw new Error(`${context}: does not match expected action from rules.`); @@ -161,7 +161,8 @@ export const buildRankedReferrerMetricsRevShareCap = ( rank: ReferrerRank, rules: ReferralProgramRulesRevShareCap, ): RankedReferrerMetricsRevShareCap => { - const adminAction = rules.adminActions.find((a) => a.referrer === referrer.referrer) ?? null; + const adminAction = + rules.adminActions.find((a) => accountIdEqual(a.referrer, referrer.referrer)) ?? null; const result = { ...referrer, @@ -349,15 +350,16 @@ export const validateUnrankedReferrerMetricsRevShareCap = ( }; /** - * Build an unranked zero-metrics rev-share-cap referrer record for an address not on the leaderboard. + * Build an unranked zero-metrics rev-share-cap referrer record for a referrer not on the leaderboard. */ export const buildUnrankedReferrerMetricsRevShareCap = ( - referrer: NormalizedAddress, + referrer: AccountId, rules: ReferralProgramRulesRevShareCap, ): UnrankedReferrerMetricsRevShareCap => { const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); - const adminAction = rules.adminActions.find((a) => a.referrer === metrics.referrer) ?? null; + const adminAction = + rules.adminActions.find((a) => accountIdEqual(a.referrer, metrics.referrer)) ?? null; const result = { ...metrics, diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts b/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts index c258b32c98..f86cbdbb6d 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/referral-event.ts @@ -1,4 +1,4 @@ -import type { Address, Duration, InterpretedName, NormalizedAddress, UnixTimestamp } from "enssdk"; +import type { AccountId, Address, Duration, InterpretedName, UnixTimestamp } from "enssdk"; import type { Hash } from "viem"; import type { PriceEth, RegistrarActionType } from "@ensnode/ensnode-sdk"; @@ -11,9 +11,9 @@ import type { PriceEth, RegistrarActionType } from "@ensnode/ensnode-sdk"; */ export interface ReferralEvent { /** - * The Ethereum address of the referrer, as a {@link NormalizedAddress}. + * The {@link AccountId} of the referrer. */ - referrer: NormalizedAddress; + referrer: AccountId; /** * Unix seconds block timestamp. diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts b/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts index 782939db71..c906c2a2e1 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts @@ -1,9 +1,8 @@ -import type { AccountId, Duration, NormalizedAddress, UnixTimestamp } from "enssdk"; +import { type AccountId, type Duration, stringifyAccountId, type UnixTimestamp } from "enssdk"; -import { type PriceUsdc, priceUsdc } from "@ensnode/ensnode-sdk"; -import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; +import { accountIdEqual, type PriceUsdc, priceUsdc } from "@ensnode/ensnode-sdk"; +import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; -import { validateNormalizedAddress } from "../../address"; import { SECONDS_PER_YEAR } from "../../time"; import { type BaseReferralProgramRules, @@ -35,9 +34,9 @@ export interface AdminActionDisqualification { actionType: typeof AdminActionTypes.Disqualification; /** - * The Ethereum address of the affected referrer, as a {@link NormalizedAddress}. + * The {@link AccountId} of the affected referrer. */ - referrer: NormalizedAddress; + referrer: AccountId; /** * A short message explaining the disqualification. @@ -55,9 +54,9 @@ export interface AdminActionWarning { actionType: typeof AdminActionTypes.Warning; /** - * The Ethereum address of the affected referrer, as a {@link NormalizedAddress}. + * The {@link AccountId} of the affected referrer. */ - referrer: NormalizedAddress; + referrer: AccountId; /** * A short message explaining the warning. @@ -111,7 +110,7 @@ export interface ReferralProgramRulesRevShareCap extends BaseReferralProgramRule /** * Admin actions for this edition. * - * @invariant No duplicate referrer addresses (a referrer can have at most one admin action). + * @invariant No duplicate referrer AccountIds (a referrer can have at most one admin action). */ adminActions: AdminAction[]; } @@ -145,8 +144,9 @@ export const validateReferralProgramRulesRevShareCap = ( ); } + const adminActionAccountIdSchema = makeAccountIdSchema("AdminAction.referrer"); for (const action of rules.adminActions) { - validateNormalizedAddress(action.referrer); + adminActionAccountIdSchema.parse(action.referrer); if (action.reason.trim().length === 0 || action.reason !== action.reason.trim()) { throw new Error( "ReferralProgramRulesRevShareCap: admin action reason must be a trimmed, non-empty string.", @@ -154,11 +154,11 @@ export const validateReferralProgramRulesRevShareCap = ( } } - const adminActionAddresses = rules.adminActions.map((a) => a.referrer); - const uniqueAdminActionAddresses = new Set(adminActionAddresses); - if (uniqueAdminActionAddresses.size !== adminActionAddresses.length) { + const adminActionAccountIds = rules.adminActions.map((a) => stringifyAccountId(a.referrer)); + const uniqueAdminActionAccountIds = new Set(adminActionAccountIds); + if (uniqueAdminActionAccountIds.size !== adminActionAccountIds.length) { throw new Error( - "ReferralProgramRulesRevShareCap: adminActions must not contain duplicate referrer addresses.", + "ReferralProgramRulesRevShareCap: adminActions must not contain duplicate referrer AccountIds.", ); } @@ -218,17 +218,18 @@ export function calcBaseRevenueContribution( * * A referrer is qualified if they meet the revenue threshold AND are not admin-disqualified. * - * @param referrer - The referrer's address. + * @param referrer - The referrer's {@link AccountId}. * @param totalBaseRevenueContribution - The referrer's total base revenue contribution. * @param rules - The rev-share-cap rules of the referral program. */ export function isReferrerQualifiedRevShareCap( - referrer: NormalizedAddress, + referrer: AccountId, totalBaseRevenueContribution: PriceUsdc, rules: ReferralProgramRulesRevShareCap, ): boolean { const isAdminDisqualified = rules.adminActions.some( - (a) => a.referrer === referrer && a.actionType === AdminActionTypes.Disqualification, + (a) => + accountIdEqual(a.referrer, referrer) && a.actionType === AdminActionTypes.Disqualification, ); return ( totalBaseRevenueContribution.amount >= rules.minBaseRevenueContribution.amount && diff --git a/packages/ens-referrals/src/award-models/shared/leaderboard-page.ts b/packages/ens-referrals/src/award-models/shared/leaderboard-page.ts index 72a436223c..c0d9c31646 100644 --- a/packages/ens-referrals/src/award-models/shared/leaderboard-page.ts +++ b/packages/ens-referrals/src/award-models/shared/leaderboard-page.ts @@ -1,4 +1,4 @@ -import type { NormalizedAddress, UnixTimestamp } from "enssdk"; +import type { AccountIdString, UnixTimestamp } from "enssdk"; import type { ReferrerLeaderboard } from "../../leaderboard"; import { isNonNegativeInteger, isPositiveInteger } from "../../number"; @@ -309,7 +309,7 @@ export interface ReferrerLeaderboardPageUnrecognized extends BaseReferrerLeaderb * Generic over the referrer type so each model variant retains its specific type. */ export function sliceReferrers( - referrers: Map, + referrers: Map, pageContext: ReferrerLeaderboardPageContext, ): T[] { // pageContext invariants: startIndex and endIndex are defined iff totalRecords > 0 diff --git a/packages/ens-referrals/src/award-models/shared/rank.ts b/packages/ens-referrals/src/award-models/shared/rank.ts index d4a9f11b0b..5f11037ee1 100644 --- a/packages/ens-referrals/src/award-models/shared/rank.ts +++ b/packages/ens-referrals/src/award-models/shared/rank.ts @@ -1,4 +1,4 @@ -import type { Duration, NormalizedAddress } from "enssdk"; +import { type AccountId, type Duration, stringifyAccountId } from "enssdk"; import { isPositiveInteger } from "../../number"; import type { ReferrerMetrics } from "../../referrer-metrics"; @@ -25,9 +25,9 @@ export interface ReferrerMetricsForComparison { totalIncrementalDuration: Duration; /** - * The Ethereum address of the referrer, as a {@link NormalizedAddress}. + * The {@link AccountId} of the referrer. */ - referrer: NormalizedAddress; + referrer: AccountId; } export const compareReferrerMetrics = ( @@ -39,9 +39,12 @@ export const compareReferrerMetrics = ( return b.totalIncrementalDuration - a.totalIncrementalDuration; } - // Secondary sort: referrer address using lexicographic comparison of ASCII hex strings (descending) - if (b.referrer > a.referrer) return 1; - if (b.referrer < a.referrer) return -1; + // Secondary sort: referrer CAIP-10 string lexicographic (descending) — deterministic tie-break + // across both chainId and address. + const aKey = stringifyAccountId(a.referrer); + const bKey = stringifyAccountId(b.referrer); + if (bKey > aKey) return 1; + if (bKey < aKey) return -1; return 0; }; diff --git a/packages/ens-referrals/src/client.ts b/packages/ens-referrals/src/client.ts index 262b34c360..9886be31c5 100644 --- a/packages/ens-referrals/src/client.ts +++ b/packages/ens-referrals/src/client.ts @@ -1,3 +1,5 @@ +import { stringifyAccountId } from "enssdk"; + import { deserializeReferralProgramEditionConfigSetArray, deserializeReferralProgramEditionSummariesResponse, @@ -238,7 +240,7 @@ export class ENSReferralsClient { * * @see {@link https://www.npmjs.com/package/@namehash/ens-referrals|@namehash/ens-referrals} for calculation details * - * @param request The referrer address and edition slugs to query + * @param request The referrer {@link AccountId} and edition slugs to query * @returns {ReferrerMetricsEditionsResponse} Returns the referrer metrics for requested editions * * @remarks If the server returns metrics for an edition whose `awardModel` is not recognized by @@ -253,7 +255,7 @@ export class ENSReferralsClient { * ```typescript * // Get referrer metrics for specific editions * const response = await client.getReferrerMetricsEditions({ - * referrer: "0x1234567890123456789012345678901234567890", + * referrer: { chainId: 1, address: "0x1234567890123456789012345678901234567890" }, * editions: ["2025-12", "2026-01"] * }); * if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { @@ -277,7 +279,7 @@ export class ENSReferralsClient { * ```typescript * // Access specific edition data directly (edition is guaranteed to exist when OK) * const response = await client.getReferrerMetricsEditions({ - * referrer: "0x1234567890123456789012345678901234567890", + * referrer: { chainId: 1, address: "0x1234567890123456789012345678901234567890" }, * editions: ["2025-12"] * }); * if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { @@ -296,7 +298,7 @@ export class ENSReferralsClient { * ```typescript * // Handle error response (e.g., unknown edition or data not available) * const response = await client.getReferrerMetricsEditions({ - * referrer: "0x1234567890123456789012345678901234567890", + * referrer: { chainId: 1, address: "0x1234567890123456789012345678901234567890" }, * editions: ["2025-12", "invalid-edition"] * }); * @@ -310,7 +312,7 @@ export class ENSReferralsClient { request: ReferrerMetricsEditionsRequest, ): Promise { const url = new URL( - `/v1/ensanalytics/referrer/${encodeURIComponent(request.referrer)}`, + `/v1/ensanalytics/referrer/${encodeURIComponent(stringifyAccountId(request.referrer))}`, this.options.url, ); diff --git a/packages/ens-referrals/src/edition-metrics.ts b/packages/ens-referrals/src/edition-metrics.ts index 8a7e3eb3b3..c4ebb19cff 100644 --- a/packages/ens-referrals/src/edition-metrics.ts +++ b/packages/ens-referrals/src/edition-metrics.ts @@ -1,4 +1,4 @@ -import type { NormalizedAddress } from "enssdk"; +import { type AccountId, stringifyAccountId } from "enssdk"; import type { ReferrerEditionMetricsPieSplit, @@ -22,7 +22,7 @@ import { ReferralProgramAwardModels } from "./award-models/shared/rules"; import type { ReferrerLeaderboard } from "./leaderboard"; /** - * Referrer edition metrics data for a specific referrer address. + * Referrer edition metrics data for a specific referrer. * * Use `awardModel` to narrow the award model variant, then `type` to narrow ranked vs unranked. * When `awardModel` is `"unrecognized"`, the data was produced by a server running a newer @@ -39,20 +39,21 @@ export type ReferrerEditionMetrics = * Returns a {@link ReferrerEditionMetricsPieSplit} or {@link ReferrerEditionMetricsRevShareCap} * with `type: "ranked"` if the referrer is on the leaderboard, or `type: "unranked"` otherwise. * - * @param referrer - The referrer address to look up + * @param referrer - The referrer {@link AccountId} to look up * @param leaderboard - The referrer leaderboard to query */ export const getReferrerEditionMetrics = ( - referrer: NormalizedAddress, + referrer: AccountId, leaderboard: ReferrerLeaderboard, ): ReferrerEditionMetrics => { + const referrerKey = stringifyAccountId(referrer); switch (leaderboard.awardModel) { case ReferralProgramAwardModels.PieSplit: { const status = calcReferralProgramEditionStatusPieSplit( leaderboard.rules, leaderboard.accurateAsOf, ); - const awardedReferrerMetrics = leaderboard.referrers.get(referrer); + const awardedReferrerMetrics = leaderboard.referrers.get(referrerKey); if (awardedReferrerMetrics) { return { awardModel: leaderboard.awardModel, @@ -81,7 +82,7 @@ export const getReferrerEditionMetrics = ( leaderboard.accurateAsOf, leaderboard.aggregatedMetrics, ); - const awardedReferrerMetrics = leaderboard.referrers.get(referrer); + const awardedReferrerMetrics = leaderboard.referrers.get(referrerKey); if (awardedReferrerMetrics) { return { awardModel: leaderboard.awardModel, diff --git a/packages/ens-referrals/src/leaderboard-page.test.ts b/packages/ens-referrals/src/leaderboard-page.test.ts index df902cc168..c2666c6ec7 100644 --- a/packages/ens-referrals/src/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/leaderboard-page.test.ts @@ -1,4 +1,10 @@ -import type { NormalizedAddress } from "enssdk"; +import { + type AccountId, + type AccountIdString, + type Address, + stringifyAccountId, + toNormalizedAddress, +} from "enssdk"; import { describe, expect, it, vi } from "vitest"; import { priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; @@ -12,6 +18,12 @@ import { } from "./award-models/shared/leaderboard-page"; import { ReferralProgramAwardModels } from "./award-models/shared/rules"; +const acct = (address: Address): AccountId => ({ + chainId: 1, + address: toNormalizedAddress(address), +}); +const acctKey = (address: Address): AccountIdString => stringifyAccountId(acct(address)); + describe("buildReferrerLeaderboardPageContext", () => { const pageParams: ReferrerLeaderboardPageParams = { page: 1, @@ -27,10 +39,7 @@ describe("buildReferrerLeaderboardPageContext", () => { maxQualifiedReferrers: 10, startTime: 1764547200, endTime: 1767225599, - subregistryId: { - chainId: 1, - address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - }, + subregistryId: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), rulesUrl: new URL("https://example.com/rules"), areAwardsDistributed: false, }, @@ -41,11 +50,11 @@ describe("buildReferrerLeaderboardPageContext", () => { grandTotalQualifiedReferrersFinalScore: 28.05273061366773, minFinalScoreToQualify: 0, }, - referrers: new Map([ + referrers: new Map([ [ - "0x03c098d2bed4609e6ed9beb2c4877741f45f290d", + acctKey("0x03c098d2bed4609e6ed9beb2c4877741f45f290d"), { - referrer: "0x6837047f46da1d5d9a79846b25810b92adf456f6", + referrer: acct("0x6837047f46da1d5d9a79846b25810b92adf456f6"), totalReferrals: 1, totalIncrementalDuration: 189302400, totalRevenueContribution: priceEth(20_000_000_000_000_000n), // 0.02 ETH @@ -59,9 +68,9 @@ describe("buildReferrerLeaderboardPageContext", () => { }, ], [ - "0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a", + acctKey("0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a"), { - referrer: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + referrer: acct("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), totalReferrals: 10, totalIncrementalDuration: 155847533, totalRevenueContribution: priceEth(25_000_000_000_000_000n), // 0.025 ETH @@ -75,9 +84,9 @@ describe("buildReferrerLeaderboardPageContext", () => { }, ], [ - "0xffa596cdf9a69676e689b1a92e5e681711227d75", + acctKey("0xffa596cdf9a69676e689b1a92e5e681711227d75"), { - referrer: "0x7e491cde0fbf08e51f54c4fb6b9e24afbd18966d", + referrer: acct("0x7e491cde0fbf08e51f54c4fb6b9e24afbd18966d"), totalReferrals: 6, totalIncrementalDuration: 119404800, totalRevenueContribution: priceEth(15_000_000_000_000_000n), // 0.015 ETH @@ -116,10 +125,7 @@ describe("buildReferrerLeaderboardPageContext", () => { maxQualifiedReferrers: 10, startTime: 1764547200, endTime: 1767225599, - subregistryId: { - chainId: 1, - address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - }, + subregistryId: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), rulesUrl: new URL("https://example.com/rules"), areAwardsDistributed: false, }, @@ -130,7 +136,7 @@ describe("buildReferrerLeaderboardPageContext", () => { grandTotalQualifiedReferrersFinalScore: 28.05273061366773, minFinalScoreToQualify: 0, }, - referrers: new Map(), + referrers: new Map(), accurateAsOf: 1764580368, }; diff --git a/packages/ens-referrals/src/referrer-metrics.ts b/packages/ens-referrals/src/referrer-metrics.ts index 111e099168..90cd35ebc6 100644 --- a/packages/ens-referrals/src/referrer-metrics.ts +++ b/packages/ens-referrals/src/referrer-metrics.ts @@ -1,9 +1,8 @@ -import type { Duration, NormalizedAddress } from "enssdk"; +import type { AccountId, Duration } from "enssdk"; import type { PriceEth } from "@ensnode/ensnode-sdk"; -import { makePriceEthSchema } from "@ensnode/ensnode-sdk/internal"; +import { makeAccountIdSchema, makePriceEthSchema } from "@ensnode/ensnode-sdk/internal"; -import { validateNormalizedAddress } from "./address"; import { validateNonNegativeInteger } from "./number"; import { ReferralProgramRules } from "./rules"; import { validateDuration } from "./time"; @@ -14,9 +13,9 @@ import { validateDuration } from "./time"; */ export interface ReferrerMetrics { /** - * The Ethereum address of the referrer, as a {@link NormalizedAddress}. + * The {@link AccountId} of the referrer. */ - referrer: NormalizedAddress; + referrer: AccountId; /** * The total number of referrals made by the referrer within the {@link ReferralProgramRules}. @@ -43,7 +42,7 @@ export interface ReferrerMetrics { } export const buildReferrerMetrics = ( - referrer: NormalizedAddress, + referrer: AccountId, totalReferrals: number, totalIncrementalDuration: Duration, totalRevenueContribution: PriceEth, @@ -60,7 +59,7 @@ export const buildReferrerMetrics = ( }; export const validateReferrerMetrics = (metrics: ReferrerMetrics): void => { - validateNormalizedAddress(metrics.referrer); + makeAccountIdSchema("ReferrerMetrics.referrer").parse(metrics.referrer); validateNonNegativeInteger(metrics.totalReferrals); validateDuration(metrics.totalIncrementalDuration); diff --git a/packages/ensnode-sdk/src/shared/account-id.test.ts b/packages/ensnode-sdk/src/shared/account-id.test.ts index 4d87b6e737..1a48e788fd 100644 --- a/packages/ensnode-sdk/src/shared/account-id.test.ts +++ b/packages/ensnode-sdk/src/shared/account-id.test.ts @@ -3,6 +3,7 @@ import { stringifyAccountId } from "enssdk"; import { describe, expect, it } from "vitest"; import { parseAccountId } from "./deserialize"; +import { makeAccountIdStringSchema } from "./zod-schemas"; const vitalikEthAddressLowercase: Address = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; const vitalikEthAddressChecksummed: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; @@ -45,4 +46,25 @@ describe("ENSNode SDK Shared: AccountId", () => { /Account ID String address must be a valid EVM address/i, ); }); + + describe("makeAccountIdStringSchema safeParse never throws", () => { + const schema = makeAccountIdStringSchema(); + + it("returns a Zod issue (not a thrown Error) for completely malformed strings", () => { + const result = schema.safeParse("not-a-caip-10-string"); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toMatch(/invalid CAIP-10 AccountId string/i); + }); + + it("returns a Zod issue (not a thrown Error) when probed with undefined", () => { + const result = schema.safeParse(undefined); + expect(result.success).toBe(false); + }); + + it("parseAccountId surfaces invalid CAIP-10 strings as a clean RangeError", () => { + expect(() => parseAccountId("not-a-caip-10-string")).toThrowError( + /invalid CAIP-10 AccountId string/i, + ); + }); + }); }); diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index d9a352f591..02d185416d 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -324,13 +324,20 @@ export const makeAccountIdSchema = (valueLabel: string = "AccountId") => export const makeAccountIdStringSchema = (valueLabel: string = "Account ID String") => z.coerce .string() - .transform((v) => { - const result = new CaipAccountId(v); - - return { - chainId: Number(result.chainId.reference), - address: result.address, - }; + .transform((v, ctx) => { + try { + const result = new CaipAccountId(v); + return { + chainId: Number(result.chainId.reference), + address: result.address, + }; + } catch { + ctx.addIssue({ + code: "custom", + message: `${valueLabel}: invalid CAIP-10 AccountId string '${v}'`, + }); + return z.NEVER; + } }) .pipe(makeAccountIdSchema(valueLabel)); From a39ae4d7d75bad7e57b69b9b767a7460c0f479d8 Mon Sep 17 00:00:00 2001 From: Goader Date: Fri, 1 May 2026 17:38:11 +0200 Subject: [PATCH 09/12] changesets --- .changeset/curly-buses-lick.md | 2 -- .changeset/magical-hedgehog-jump.md | 2 +- .changeset/quiet-foxes-stumble.md | 5 +++++ 3 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 .changeset/curly-buses-lick.md create mode 100644 .changeset/quiet-foxes-stumble.md diff --git a/.changeset/curly-buses-lick.md b/.changeset/curly-buses-lick.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/curly-buses-lick.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/magical-hedgehog-jump.md b/.changeset/magical-hedgehog-jump.md index 676994bed9..d840efd104 100644 --- a/.changeset/magical-hedgehog-jump.md +++ b/.changeset/magical-hedgehog-jump.md @@ -3,4 +3,4 @@ "ensapi": minor --- -Identify referrers in the ENSAnalytics v1 surface by `AccountId` instead of bare address. Domain types (`ReferrerMetrics`, `RankedReferrer`, `AdminAction`, `ReferralEvent`, leaderboard maps, etc.), JSON wire format, and the `getReferrerMetricsEditions` client now all use `AccountId`. The `GET /v1/ensanalytics/referrer/{referrer}` path param is now a URL-encoded CAIP-10 string (e.g. `eip155%3A1%3A0xabc...`). +Identify referrers in the ENSAnalytics v1 surface by `AccountId` instead of bare address. Domain types (`ReferrerMetrics`, `AwardedReferrerMetricsPieSplit`, `AwardedReferrerMetricsRevShareCap`, `AdminAction`, `ReferralEvent`, leaderboard maps, etc.), serialized JSON responses, and the `getReferrerMetricsEditions` client now all use `AccountId`. The `GET /v1/ensanalytics/referrer/{referrer}` path param is now a URL-encoded CAIP-10 string (e.g. `eip155%3A1%3A0xabc...`). diff --git a/.changeset/quiet-foxes-stumble.md b/.changeset/quiet-foxes-stumble.md new file mode 100644 index 0000000000..326394a44c --- /dev/null +++ b/.changeset/quiet-foxes-stumble.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": patch +--- + +Fix `makeAccountIdStringSchema` to surface invalid CAIP-10 strings as a Zod issue instead of throwing a synchronous `Error` from inside the transform. From c888b6d957974e5bc7a8d26ded76b4031312b148 Mon Sep 17 00:00:00 2001 From: Goader Date: Fri, 1 May 2026 17:57:39 +0200 Subject: [PATCH 10/12] fixture fix --- packages/ens-referrals/src/leaderboard-page.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ens-referrals/src/leaderboard-page.test.ts b/packages/ens-referrals/src/leaderboard-page.test.ts index c2666c6ec7..0e2f4673aa 100644 --- a/packages/ens-referrals/src/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/leaderboard-page.test.ts @@ -52,7 +52,7 @@ describe("buildReferrerLeaderboardPageContext", () => { }, referrers: new Map([ [ - acctKey("0x03c098d2bed4609e6ed9beb2c4877741f45f290d"), + acctKey("0x6837047f46da1d5d9a79846b25810b92adf456f6"), { referrer: acct("0x6837047f46da1d5d9a79846b25810b92adf456f6"), totalReferrals: 1, @@ -68,7 +68,7 @@ describe("buildReferrerLeaderboardPageContext", () => { }, ], [ - acctKey("0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a"), + acctKey("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), { referrer: acct("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), totalReferrals: 10, @@ -84,7 +84,7 @@ describe("buildReferrerLeaderboardPageContext", () => { }, ], [ - acctKey("0xffa596cdf9a69676e689b1a92e5e681711227d75"), + acctKey("0x7e491cde0fbf08e51f54c4fb6b9e24afbd18966d"), { referrer: acct("0x7e491cde0fbf08e51f54c4fb6b9e24afbd18966d"), totalReferrals: 6, From f5c4ea563d723a803437ae56fc1f95a051deffa1 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 12 May 2026 14:25:20 +0200 Subject: [PATCH 11/12] cleaning --- packages/ens-referrals/src/address.ts | 8 -------- packages/ens-referrals/src/api/zod-schemas.ts | 13 ------------- packages/ens-referrals/src/index.ts | 1 - 3 files changed, 22 deletions(-) delete mode 100644 packages/ens-referrals/src/address.ts diff --git a/packages/ens-referrals/src/address.ts b/packages/ens-referrals/src/address.ts deleted file mode 100644 index fb8f6fe042..0000000000 --- a/packages/ens-referrals/src/address.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { NormalizedAddress } from "enssdk"; -import { isNormalizedAddress } from "enssdk"; - -export const validateNormalizedAddress = (address: NormalizedAddress): void => { - if (!isNormalizedAddress(address)) { - throw new Error(`Invalid address: '${address}'. Address must be a lowercase EVM Address.`); - } -}; diff --git a/packages/ens-referrals/src/api/zod-schemas.ts b/packages/ens-referrals/src/api/zod-schemas.ts index d77b153188..13c8acc2cb 100644 --- a/packages/ens-referrals/src/api/zod-schemas.ts +++ b/packages/ens-referrals/src/api/zod-schemas.ts @@ -9,8 +9,6 @@ import z from "zod/v4"; -import { makeAccountIdStringSchema } from "@ensnode/ensnode-sdk/internal"; - import { makeReferralProgramEditionSummaryPieSplitSchema, makeReferralProgramRulesPieSplitSchema, @@ -220,17 +218,6 @@ export const makeReferrerMetricsEditionsArraySchema = ( { message: `${valueLabel} must not contain duplicate edition slugs` }, ); -/** - * Schema for {@link ReferrerMetricsEditionsRequest} - */ -export const makeReferrerMetricsEditionsRequestSchema = ( - valueLabel: string = "ReferrerMetricsEditionsRequest", -) => - z.object({ - referrer: makeAccountIdStringSchema(`${valueLabel}.referrer`), - editions: makeReferrerMetricsEditionsArraySchema(`${valueLabel}.editions`), - }); - /** * Schema for {@link ReferrerMetricsEditionsResponseOk} */ diff --git a/packages/ens-referrals/src/index.ts b/packages/ens-referrals/src/index.ts index fc161023a5..4ce930f4f3 100644 --- a/packages/ens-referrals/src/index.ts +++ b/packages/ens-referrals/src/index.ts @@ -1,4 +1,3 @@ -export * from "./address"; export * from "./api"; export * from "./award-models/pie-split/aggregations"; export * from "./award-models/pie-split/api/serialized-types"; From 8718a509ce0df7cd54958835df3f54f42074ddf9 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 12 May 2026 16:19:18 +0200 Subject: [PATCH 12/12] equality regression fix --- .changeset/purple-frogs-dream.md | 5 -- .changeset/shiny-pandas-account.md | 5 -- .../ensanalytics/ensanalytics-api.test.ts | 55 ++++++++++++++++ .../referrer-leaderboard/database.ts | 8 ++- .../ens-referrals/src/api/zod-schemas.test.ts | 19 +++--- .../shared/leaderboard-guards.test.ts | 66 +++++++++++++++++++ .../award-models/shared/leaderboard-guards.ts | 5 +- 7 files changed, 141 insertions(+), 22 deletions(-) delete mode 100644 .changeset/purple-frogs-dream.md delete mode 100644 .changeset/shiny-pandas-account.md create mode 100644 packages/ens-referrals/src/award-models/shared/leaderboard-guards.test.ts diff --git a/.changeset/purple-frogs-dream.md b/.changeset/purple-frogs-dream.md deleted file mode 100644 index 2075e4e70e..0000000000 --- a/.changeset/purple-frogs-dream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@namehash/ens-referrals": minor ---- - -Add per-event accounting trace for rev-share-cap editions. The new `ReferralEditionSnapshot` (returned by `buildReferralEditionSnapshot*`) bundles the leaderboard with a chronological array of `ReferralAccountingRecordRevShareCap`. diff --git a/.changeset/shiny-pandas-account.md b/.changeset/shiny-pandas-account.md deleted file mode 100644 index 317e712baf..0000000000 --- a/.changeset/shiny-pandas-account.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ensapi": minor ---- - -Add `GET /v1/ensanalytics/accounting?edition={slug}` for rev-share-cap editions: returns a CSV dump of the per-event accounting trace, ordered chronologically. diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index b9b200a5d3..37567d7722 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -528,6 +528,61 @@ describe("/v1/ensanalytics", () => { expect(response).toMatchObject(expectedResponse); }); + it("accepts a checksum-cased CAIP-10 referrer in the path and returns the same data as the lowercase form", async () => { + // Arrange: mock cache map with one edition + const mockEditionsCaches = new Map< + ReferralProgramEditionSlug, + SWRCache + >([ + [ + "2025-12", + { + read: async () => ({ leaderboard: populatedReferrerLeaderboard }), + } as SWRCache, + ], + ]); + + const mockEditionConfigSet = new Map([ + ["2025-12", { slug: "2025-12", displayName: "Edition 1", rules: {} as any }], + ]); + vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( + async (c, next) => { + c.set("referralProgramEditionConfigSet", mockEditionConfigSet); + return await next(); + }, + ); + vi.mocked( + editionsCachesMiddleware.referralEditionSnapshotsCachesMiddleware, + ).mockImplementation(async (c, next) => { + c.set("referralEditionSnapshotsCaches", mockEditionsCaches); + return await next(); + }); + + // The leaderboard fixture is keyed by the lowercase AccountIdString. Send a + // checksum-cased CAIP-10 in the URL path and confirm the route handler still + // matches the same leaderboard entry (i.e. the CAIP-10 parser normalizes the + // address case end-to-end). + const referrer = acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"); + const checksumCasedCaip10 = "eip155:1:0x538e35B2888Ed5BC58CF2825D76cF6265Aa4E31e"; + + const httpResponse = await app.request( + `/referrer/${encodeURIComponent(checksumCasedCaip10)}?editions=2025-12`, + ); + const response = deserializeReferrerMetricsEditionsResponse(await httpResponse.json()); + + expect(httpResponse.status).toBe(200); + expect(response.responseCode).toBe(ReferrerMetricsEditionsResponseCodes.Ok); + if (response.responseCode === ReferrerMetricsEditionsResponseCodes.Ok) { + const edition = response.data["2025-12"]!; + expect(edition.awardModel).toBe(ReferralProgramAwardModels.PieSplit); + if (edition.awardModel !== ReferralProgramAwardModels.PieSplit) throw new Error(); + expect(edition.type).toBe(ReferrerEditionMetricsTypeIds.Ranked); + if (edition.type !== ReferrerEditionMetricsTypeIds.Ranked) throw new Error(); + expect(edition.referrer.referrer).toEqual(referrer); + expect(edition.referrer.rank).toBe(1); + } + }); + it("returns zero-score metrics for requested editions when referrer does not exist", async () => { // Arrange: mock cache map with multiple editions const mockEditionsCaches = new Map< diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts index d0574bee72..5aa59ab648 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database.ts @@ -10,6 +10,7 @@ import { type InterpretedName, type NormalizedAddress, stringifyAccountId, + toNormalizedAddress, } from "enssdk"; import type { Hash } from "viem"; import { zeroAddress } from "viem"; @@ -91,7 +92,10 @@ export const getReferrerMetrics = async ( return (records as NonNullRecord[]).map((record) => { return buildReferrerMetrics( - { chainId: rules.subregistryId.chainId, address: record.referrer }, + { + chainId: rules.subregistryId.chainId, + address: toNormalizedAddress(record.referrer), + }, record.totalReferrals, deserializeDuration(record.totalIncrementalDuration), priceEth(BigInt(record.totalRevenueContribution)), @@ -213,7 +217,7 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise ({ + chainId: 1, + address: toNormalizedAddress(address), +}); + describe("makeReferralProgramEditionConfigSetArraySchema", () => { const schema = makeReferralProgramEditionConfigSetArraySchema(); @@ -644,10 +650,7 @@ describe("makeReferrerEditionMetricsSchema", () => { minFinalScoreToQualify: 0, }; - const revShareCapReferrerAddress = { - chainId: 1, - address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - }; + const revShareCapReferrerAddress = acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"); const revShareCapRules = { awardModel: ReferralProgramAwardModels.RevShareCap, @@ -687,7 +690,7 @@ describe("makeReferrerEditionMetricsSchema", () => { type: ReferrerEditionMetricsTypeIds.Ranked, rules: pieSplitRules, referrer: { - referrer: { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + referrer: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), totalReferrals: 5, totalIncrementalDuration: 100, totalRevenueContribution: parseEth("500"), @@ -717,7 +720,7 @@ describe("makeReferrerEditionMetricsSchema", () => { type: ReferrerEditionMetricsTypeIds.Unranked, rules: pieSplitRules, referrer: { - referrer: { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + referrer: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), totalReferrals: 0, totalIncrementalDuration: 0, totalRevenueContribution: parseEth("0"), @@ -873,7 +876,7 @@ describe("makeReferrerEditionMetricsSchema", () => { cappedAward: parseUsdc("200"), adminAction: { ...warningAction, - referrer: { chainId: 1, address: "0x0000000000000000000000000000000000000001" }, + referrer: acct("0x0000000000000000000000000000000000000001"), }, }, aggregatedMetrics: revShareCapAggregatedMetrics, @@ -900,7 +903,7 @@ describe("makeReferrerEditionMetricsSchema", () => { endTime: 500000, // endTime < startTime → refine violation }, referrer: { - referrer: { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + referrer: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), totalReferrals: 5, totalIncrementalDuration: 100, totalRevenueContribution: parseEth("500"), diff --git a/packages/ens-referrals/src/award-models/shared/leaderboard-guards.test.ts b/packages/ens-referrals/src/award-models/shared/leaderboard-guards.test.ts new file mode 100644 index 0000000000..73e5dbadde --- /dev/null +++ b/packages/ens-referrals/src/award-models/shared/leaderboard-guards.test.ts @@ -0,0 +1,66 @@ +import { type AccountId, type Address, toNormalizedAddress } from "enssdk"; +import { describe, expect, it } from "vitest"; + +import { priceEth } from "@ensnode/ensnode-sdk"; + +import { buildReferrerMetrics, type ReferrerMetrics } from "../../referrer-metrics"; +import { assertLeaderboardInputs } from "./leaderboard-guards"; +import { type BaseReferralProgramRules, ReferralProgramAwardModels } from "./rules"; + +const acct = (address: Address): AccountId => ({ + chainId: 1, + address: toNormalizedAddress(address), +}); + +const rules: BaseReferralProgramRules = { + awardModel: ReferralProgramAwardModels.PieSplit, + startTime: 1000, + endTime: 2000, + subregistryId: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), + rulesUrl: new URL("https://example.com/rules"), + areAwardsDistributed: false, +}; + +const accurateAsOf = 1500; + +const metrics = (referrer: AccountId): ReferrerMetrics => + buildReferrerMetrics(referrer, 1, 60, priceEth(0n)); + +describe("assertLeaderboardInputs", () => { + it("accepts distinct referrers", () => { + const inputs = [ + metrics(acct("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), + metrics(acct("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")), + ]; + + expect(() => assertLeaderboardInputs(inputs, rules, accurateAsOf)).not.toThrow(); + }); + + it("rejects two distinct AccountId object instances with the same chainId+address (value-equality dedupe)", () => { + // Two separate object instances representing the same referrer — a Set keyed by object + // identity would NOT catch this; the guard must compare by stringified CAIP-10. + const referrerA = acct("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + const referrerAClone = acct("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(referrerA).not.toBe(referrerAClone); // sanity: distinct object identities + + const inputs = [metrics(referrerA), metrics(referrerAClone)]; + + expect(() => assertLeaderboardInputs(inputs, rules, accurateAsOf)).toThrow( + /duplicate referrers/i, + ); + }); + + it("accepts the same address on different chainIds (chain-scoped identity)", () => { + const address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Address; + const inputs = [ + metrics({ chainId: 1, address: toNormalizedAddress(address) }), + metrics({ chainId: 8453, address: toNormalizedAddress(address) }), + ]; + + expect(() => assertLeaderboardInputs(inputs, rules, accurateAsOf)).not.toThrow(); + }); + + it("accepts an empty referrers array", () => { + expect(() => assertLeaderboardInputs([], rules, accurateAsOf)).not.toThrow(); + }); +}); diff --git a/packages/ens-referrals/src/award-models/shared/leaderboard-guards.ts b/packages/ens-referrals/src/award-models/shared/leaderboard-guards.ts index 1676896449..12c284d007 100644 --- a/packages/ens-referrals/src/award-models/shared/leaderboard-guards.ts +++ b/packages/ens-referrals/src/award-models/shared/leaderboard-guards.ts @@ -1,4 +1,4 @@ -import type { UnixTimestamp } from "enssdk"; +import { stringifyAccountId, type UnixTimestamp } from "enssdk"; import type { ReferrerMetrics } from "../../referrer-metrics"; import type { BaseReferralProgramRules } from "./rules"; @@ -11,7 +11,8 @@ export const assertLeaderboardInputs = ( rules: BaseReferralProgramRules, accurateAsOf: UnixTimestamp, ): void => { - const uniqueReferrers = new Set(allReferrers.map((r) => r.referrer)); + // Dedupe by stringified CAIP-10 — AccountId objects would otherwise be Set-compared by reference. + const uniqueReferrers = new Set(allReferrers.map((r) => stringifyAccountId(r.referrer))); if (uniqueReferrers.size !== allReferrers.length) { throw new Error( "ReferrerLeaderboard: Cannot build a leaderboard containing duplicate referrers",