diff --git a/.changeset/magical-hedgehog-jump.md b/.changeset/magical-hedgehog-jump.md new file mode 100644 index 0000000000..d840efd104 --- /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`, `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. 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 7b97784290..37567d7722 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); @@ -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 + >([ + [ + "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< @@ -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); @@ -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); @@ -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); } }); @@ -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); @@ -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); @@ -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); } }); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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, ), @@ -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, ), @@ -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, ), @@ -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"), 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..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( - record.referrer, + { + chainId: rules.subregistryId.chainId, + address: toNormalizedAddress(record.referrer), + }, record.totalReferrals, deserializeDuration(record.totalIncrementalDuration), priceEth(BigInt(record.totalRevenueContribution)), @@ -211,7 +215,10 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise formatDurationYears(r.incrementalDuration), }, { header: "Registrant", value: (r) => r.registrant }, - { header: "Referrer", value: (r) => r.referrer }, + { header: "Referrer", value: (r) => stringifyAccountId(r.referrer) }, { header: "Incremental Revenue Contribution (Wei)", 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/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/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..9e3de6e271 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -1,3 +1,4 @@ +import { type AccountId, type Address, toNormalizedAddress } from "enssdk"; import { describe, expect, it } from "vitest"; import { CurrencyIds, parseEth, parseUsdc } from "@ensnode/ensnode-sdk"; @@ -17,6 +18,11 @@ import { makeReferrerLeaderboardPageSchema, } from "./zod-schemas"; +const acct = (address: Address): AccountId => ({ + chainId: 1, + address: toNormalizedAddress(address), +}); + describe("makeReferralProgramEditionConfigSetArraySchema", () => { const schema = makeReferralProgramEditionConfigSetArraySchema(); @@ -644,7 +650,7 @@ describe("makeReferrerEditionMetricsSchema", () => { minFinalScoreToQualify: 0, }; - const revShareCapReferrerAddress = "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"; + const revShareCapReferrerAddress = acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"); const revShareCapRules = { awardModel: ReferralProgramAwardModels.RevShareCap, @@ -684,7 +690,7 @@ describe("makeReferrerEditionMetricsSchema", () => { type: ReferrerEditionMetricsTypeIds.Ranked, rules: pieSplitRules, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), totalReferrals: 5, totalIncrementalDuration: 100, totalRevenueContribution: parseEth("500"), @@ -714,7 +720,7 @@ describe("makeReferrerEditionMetricsSchema", () => { type: ReferrerEditionMetricsTypeIds.Unranked, rules: pieSplitRules, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: acct("0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"), totalReferrals: 0, totalIncrementalDuration: 0, totalRevenueContribution: parseEth("0"), @@ -870,7 +876,7 @@ describe("makeReferrerEditionMetricsSchema", () => { cappedAward: parseUsdc("200"), adminAction: { ...warningAction, - referrer: "0x0000000000000000000000000000000000000001", + referrer: acct("0x0000000000000000000000000000000000000001"), }, }, aggregatedMetrics: revShareCapAggregatedMetrics, @@ -897,7 +903,7 @@ describe("makeReferrerEditionMetricsSchema", () => { endTime: 500000, // endTime < startTime → refine violation }, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: acct("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..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 { makeNormalizedAddressSchema } 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: makeNormalizedAddressSchema(`${valueLabel}.referrer`), - editions: makeReferrerMetricsEditionsArraySchema(`${valueLabel}.editions`), - }); - /** * Schema for {@link ReferrerMetricsEditionsResponseOk} */ 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-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", 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/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"; diff --git a/packages/ens-referrals/src/leaderboard-page.test.ts b/packages/ens-referrals/src/leaderboard-page.test.ts index df902cc168..0e2f4673aa 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("0x6837047f46da1d5d9a79846b25810b92adf456f6"), { - 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("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"), { - 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("0x7e491cde0fbf08e51f54c4fb6b9e24afbd18966d"), { - 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 ea21e504ed..5bd2a9948c 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -332,13 +332,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));