Skip to content
This repository was archived by the owner on Aug 5, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/magical-hedgehog-jump.md
Original file line number Diff line number Diff line change
@@ -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`, `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...`).
5 changes: 5 additions & 0 deletions .changeset/purple-frogs-dream.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/quiet-foxes-stumble.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/shiny-pandas-account.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,72 +1,61 @@
import config from "@/config";

import {
hasEnsAnalyticsConfigSupport,
hasEnsAnalyticsIndexingStatusSupport,
type ReferralEditionSnapshot,
type ReferralProgramEditionConfig,
type ReferralProgramEditionConfigSet,
type ReferralProgramEditionSlug,
type ReferrerLeaderboard,
serializeReferralProgramRules,
} 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 { 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<ReferrerLeaderboard>
SWRCache<ReferralEditionSnapshot>
>;

/**
* The list of {@link OmnichainIndexingStatusId} values that are supported for generating
* referrer leaderboards.
*
* Other values indicate that we are not ready to generate leaderboards yet.
*/
const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
OmnichainIndexingStatusIds.Following,
OmnichainIndexingStatusIds.Completed,
];

/**
* Creates a cache builder function for a specific edition.
*
* The builder function checks if cached data exists and represents an immutably closed edition.
* 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 createEditionLeaderboardBuilder(
function createEditionSnapshotBuilder(
editionConfig: ReferralProgramEditionConfig,
): (cachedResult?: CachedResult<ReferrerLeaderboard>) => Promise<ReferrerLeaderboard> {
return async (cachedResult?: CachedResult<ReferrerLeaderboard>): Promise<ReferrerLeaderboard> => {
): (cachedResult?: CachedResult<ReferralEditionSnapshot>) => Promise<ReferralEditionSnapshot> {
return async (
cachedResult?: CachedResult<ReferralEditionSnapshot>,
): Promise<ReferralEditionSnapshot> => {
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) {
Expand All @@ -78,21 +67,36 @@ function createEditionLeaderboardBuilder(
}
}

// 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(
{ 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)) {
const indexingStatusSupport = hasEnsAnalyticsIndexingStatusSupport(
indexingStatus.omnichainSnapshot.omnichainStatus,
);
if (!indexingStatusSupport.supported) {
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}. ${indexingStatusSupport.reason}`,
);
}

Expand All @@ -102,36 +106,36 @@ function createEditionLeaderboardBuilder(
);
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,
)}`,
);

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 edition snapshot for ${editionSlug} with ${snapshot.leaderboard.referrers.size} referrers`,
);

return leaderboard;
return snapshot;
};
}

/**
* 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.
Expand All @@ -144,26 +148,26 @@ 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,
});

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
Expand All @@ -172,11 +176,11 @@ export function initializeReferralLeaderboardEditionsCaches(
}

/**
* 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
*/
export function getReferralLeaderboardEditionsCaches(): ReferralLeaderboardEditionsCacheMap | null {
export function getReferralEditionSnapshotsCaches(): ReferralEditionSnapshotsCacheMap | null {
return cachedInstance;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -131,4 +133,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,
];
Loading
Loading