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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/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/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.
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
141 changes: 108 additions & 33 deletions apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -514,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<ReferralEditionSnapshot>
>([
[
"2025-12",
{
read: async () => ({ leaderboard: populatedReferrerLeaderboard }),
} as SWRCache<ReferralEditionSnapshot>,
],
]);

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<
Expand Down Expand Up @@ -554,12 +623,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);
Expand All @@ -579,7 +648,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);
Expand All @@ -599,7 +668,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);
}
});
Expand Down Expand Up @@ -644,11 +713,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);

Expand All @@ -667,7 +738,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);
Expand All @@ -687,7 +758,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);
}
});
Expand Down Expand Up @@ -732,11 +803,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);

Expand Down Expand Up @@ -792,11 +865,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);

Expand Down Expand Up @@ -853,12 +928,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);
Expand Down Expand Up @@ -922,12 +997,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);
Expand Down Expand Up @@ -956,7 +1031,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,
),
Expand All @@ -972,7 +1047,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,
),
Expand All @@ -988,7 +1063,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,
),
Expand Down Expand Up @@ -1168,7 +1243,7 @@ describe("/v1/ensanalytics", () => {
actionType: RegistrarActionTypes.Registration,
transactionHash: "0xabc",
registrant: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e",
referrer: toNormalizedAddress("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"),
referrer: acct("0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e"),
incrementalDuration: 31536000,
tentativeAward: {
incrementalRevenueContribution: parseEth("0.01"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type InterpretedName,
type NormalizedAddress,
stringifyAccountId,
toNormalizedAddress,
} from "enssdk";
import type { Hash } from "viem";
import { zeroAddress } from "viem";
Expand Down Expand Up @@ -91,7 +92,10 @@ export const getReferrerMetrics = async (

return (records as NonNullRecord[]).map((record) => {
return buildReferrerMetrics(
record.referrer,
{
chainId: rules.subregistryId.chainId,
address: toNormalizedAddress(record.referrer),
},
record.totalReferrals,
deserializeDuration(record.totalIncrementalDuration),
priceEth(BigInt(record.totalRevenueContribution)),
Expand Down Expand Up @@ -211,7 +215,10 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise<Re

return {
id: record.id,
referrer: record.referrer as NormalizedAddress,
referrer: {
chainId: rules.subregistryId.chainId,
address: toNormalizedAddress(record.referrer),
},
timestamp: Number(record.timestamp),
incrementalDuration: Number(record.incrementalDuration),
incrementalRevenueContribution: priceEth(BigInt(record.total)),
Expand Down
Loading
Loading