From bd109605bcbf02be931cbf3c7b8ed29e0b8f32f1 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 23 Feb 2026 13:17:37 +0100 Subject: [PATCH 01/15] award models structure and everything --- .../src/handlers/ensanalytics-api-v1.test.ts | 51 +-- .../get-referrer-leaderboard-v1.test.ts | 52 ++- .../referrer-leaderboard/mocks-v1.ts | 17 +- .../ens-referrals/src/v1/api/deserialize.ts | 14 +- .../ens-referrals/src/v1/api/serialize.ts | 154 +++----- .../src/v1/api/serialized-types.ts | 104 ++--- packages/ens-referrals/src/v1/api/types.ts | 3 +- .../ens-referrals/src/v1/api/zod-schemas.ts | 206 +++------- .../pie-split}/aggregations.ts | 53 +-- .../award-models/pie-split/api/serialize.ts | 141 +++++++ .../pie-split/api/serialized-types.ts | 81 ++++ .../award-models/pie-split/api/zod-schemas.ts | 166 ++++++++ .../award-models/pie-split/edition-metrics.ts | 98 +++++ .../pie-split/leaderboard-page.ts | 50 +++ .../v1/award-models/pie-split/leaderboard.ts | 77 ++++ .../src/v1/award-models/pie-split/metrics.ts | 352 +++++++++++++++++ .../src/v1/award-models/pie-split/rank.ts | 74 ++++ .../src/v1/award-models/pie-split/rules.ts | 87 +++++ .../src/v1/award-models/pie-split/score.ts | 19 + .../rev-share-limit/aggregations.ts | 139 +++++++ .../rev-share-limit/api/serialize.ts | 138 +++++++ .../rev-share-limit/api/serialized-types.ts | 100 +++++ .../rev-share-limit/api/zod-schemas.ts | 150 ++++++++ .../rev-share-limit/edition-metrics.ts | 101 +++++ .../rev-share-limit/leaderboard-page.ts | 50 +++ .../rev-share-limit/leaderboard.ts | 80 ++++ .../award-models/rev-share-limit/metrics.ts | 133 +++++++ .../v1/award-models/rev-share-limit/rank.ts | 16 + .../v1/award-models/rev-share-limit/rules.ts | 137 +++++++ .../v1/award-models/shared/api/zod-schemas.ts | 36 ++ .../v1/award-models/shared/edition-metrics.ts | 20 + .../award-models/shared/leaderboard-guards.ts | 26 ++ .../award-models/shared/leaderboard-page.ts | 299 +++++++++++++++ .../src/v1/award-models/shared/rank.ts | 58 +++ .../src/v1/award-models/shared/rules.ts | 53 +++ .../src/v1/award-models/shared/score.ts | 18 + packages/ens-referrals/src/v1/base-metrics.ts | 77 ++++ .../ens-referrals/src/v1/edition-defaults.ts | 10 +- .../ens-referrals/src/v1/edition-metrics.ts | 208 ++++------ packages/ens-referrals/src/v1/index.ts | 23 +- .../src/v1/leaderboard-page.test.ts | 16 +- .../ens-referrals/src/v1/leaderboard-page.ts | 352 ++--------------- packages/ens-referrals/src/v1/leaderboard.ts | 97 +---- packages/ens-referrals/src/v1/rank.ts | 114 ------ .../ens-referrals/src/v1/referrer-metrics.ts | 357 +----------------- packages/ens-referrals/src/v1/rules.ts | 111 +----- packages/ens-referrals/src/v1/score.ts | 35 -- 47 files changed, 3223 insertions(+), 1530 deletions(-) rename packages/ens-referrals/src/v1/{ => award-models/pie-split}/aggregations.ts (69%) create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/rank.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/rules.ts create mode 100644 packages/ens-referrals/src/v1/award-models/pie-split/score.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/edition-metrics.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/leaderboard-guards.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/rank.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/rules.ts create mode 100644 packages/ens-referrals/src/v1/award-models/shared/score.ts create mode 100644 packages/ens-referrals/src/v1/base-metrics.ts delete mode 100644 packages/ens-referrals/src/v1/rank.ts delete mode 100644 packages/ens-referrals/src/v1/score.ts diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 2bbe12ef0..f2bc48f4f 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -28,7 +28,7 @@ vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => ( })); import { - buildReferralProgramRules, + buildReferralProgramRulesPieSplit, deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, @@ -41,6 +41,7 @@ import { type ReferrerLeaderboardPageResponseOk, ReferrerMetricsEditionsResponseCodes, type ReferrerMetricsEditionsResponseOk, + type UnrankedReferrerMetricsPieSplit, } from "@namehash/ens-referrals/v1"; import { parseTimestamp, parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; @@ -446,16 +447,17 @@ describe("/v1/ensanalytics", () => { expect(edition1.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); expect(edition1.rules).toEqual(populatedReferrerLeaderboard.rules); expect(edition1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); - expect(edition1.referrer.referrer).toBe(nonExistingReferrer); - expect(edition1.referrer.rank).toBe(null); - expect(edition1.referrer.totalReferrals).toBe(0); - expect(edition1.referrer.totalIncrementalDuration).toBe(0); - expect(edition1.referrer.score).toBe(0); - expect(edition1.referrer.isQualified).toBe(false); - expect(edition1.referrer.finalScoreBoost).toBe(0); - expect(edition1.referrer.finalScore).toBe(0); - expect(edition1.referrer.awardPoolShare).toBe(0); - expect(edition1.referrer.awardPoolApproxValue).toStrictEqual({ + const edition1Referrer = edition1.referrer as UnrankedReferrerMetricsPieSplit; + expect(edition1Referrer.referrer).toBe(nonExistingReferrer); + expect(edition1Referrer.rank).toBe(null); + expect(edition1Referrer.totalReferrals).toBe(0); + expect(edition1Referrer.totalIncrementalDuration).toBe(0); + expect(edition1Referrer.score).toBe(0); + expect(edition1Referrer.isQualified).toBe(false); + expect(edition1Referrer.finalScoreBoost).toBe(0); + expect(edition1Referrer.finalScore).toBe(0); + expect(edition1Referrer.awardPoolShare).toBe(0); + expect(edition1Referrer.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); @@ -527,16 +529,17 @@ describe("/v1/ensanalytics", () => { expect(edition1.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); expect(edition1.rules).toEqual(emptyReferralLeaderboard.rules); expect(edition1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); - expect(edition1.referrer.referrer).toBe(referrer); - expect(edition1.referrer.rank).toBe(null); - expect(edition1.referrer.totalReferrals).toBe(0); - expect(edition1.referrer.totalIncrementalDuration).toBe(0); - expect(edition1.referrer.score).toBe(0); - expect(edition1.referrer.isQualified).toBe(false); - expect(edition1.referrer.finalScoreBoost).toBe(0); - expect(edition1.referrer.finalScore).toBe(0); - expect(edition1.referrer.awardPoolShare).toBe(0); - expect(edition1.referrer.awardPoolApproxValue).toStrictEqual({ + const edition1Referrer2 = edition1.referrer as UnrankedReferrerMetricsPieSplit; + expect(edition1Referrer2.referrer).toBe(referrer); + expect(edition1Referrer2.rank).toBe(null); + expect(edition1Referrer2.totalReferrals).toBe(0); + expect(edition1Referrer2.totalIncrementalDuration).toBe(0); + expect(edition1Referrer2.score).toBe(0); + expect(edition1Referrer2.isQualified).toBe(false); + expect(edition1Referrer2.finalScoreBoost).toBe(0); + expect(edition1Referrer2.finalScore).toBe(0); + expect(edition1Referrer2.awardPoolShare).toBe(0); + expect(edition1Referrer2.awardPoolApproxValue).toStrictEqual({ currency: "USDC", amount: 0n, }); @@ -804,7 +807,7 @@ describe("/v1/ensanalytics", () => { { slug: "2025-12", displayName: "December 2025", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesPieSplit( parseUsdc("10000"), 100, parseTimestamp("2025-12-01T00:00:00Z"), @@ -819,7 +822,7 @@ describe("/v1/ensanalytics", () => { { slug: "2026-03", displayName: "March 2026", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesPieSplit( parseUsdc("10000"), 100, parseTimestamp("2026-03-01T00:00:00Z"), @@ -834,7 +837,7 @@ describe("/v1/ensanalytics", () => { { slug: "2026-06", displayName: "June 2026", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesPieSplit( parseUsdc("10000"), 100, parseTimestamp("2026-06-01T00:00:00Z"), diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 5903ad331..5df7d20b8 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -1,4 +1,8 @@ -import { buildReferralProgramRules, type ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; +import { + type AwardedReferrerMetricsPieSplit, + buildReferralProgramRulesPieSplit, + type ReferrerLeaderboard, +} from "@namehash/ens-referrals/v1"; import { describe, expect, it, vi } from "vitest"; import { parseTimestamp, parseUsdc } from "@ensnode/ensnode-sdk"; @@ -12,7 +16,7 @@ vi.mock("./database-v1", () => ({ getReferrerMetrics: vi.fn(), })); -const rules = buildReferralProgramRules( +const rules = buildReferralProgramRulesPieSplit( parseUsdc("10000"), 10, // maxQualifiedReferrers parseTimestamp("2025-01-01T00:00:00Z"), @@ -57,31 +61,47 @@ describe("ENSAnalytics Referrer Leaderboard", () => { expect(qualifiedReferrers.every(([_, referrer]) => referrer.isQualified)).toBe(true); expect(unqualifiedReferrers.every(([_, referrer]) => !referrer.isQualified)).toBe(true); - // Assert `finalScoreBoost` - expect(qualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost > 0)).toBe(true); - expect(unqualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost === 0)).toBe( - true, - ); - - // Assert `finalScore` + // Assert `finalScoreBoost` (pie-split specific) expect( qualifiedReferrers.every( - ([_, referrer]) => referrer.finalScore === referrer.score * referrer.finalScoreBoost, + ([_, r]) => (r as AwardedReferrerMetricsPieSplit).finalScoreBoost > 0, + ), + ).toBe(true); + expect( + unqualifiedReferrers.every( + ([_, r]) => (r as AwardedReferrerMetricsPieSplit).finalScoreBoost === 0, ), ).toBe(true); + + // Assert `finalScore` (pie-split specific) + expect( + qualifiedReferrers.every(([_, r]) => { + const referrer = r as AwardedReferrerMetricsPieSplit; + return referrer.finalScore === referrer.score * referrer.finalScoreBoost; + }), + ).toBe(true); expect( - unqualifiedReferrers.every(([_, referrer]) => referrer.finalScore === referrer.score), + unqualifiedReferrers.every(([_, r]) => { + const referrer = r as AwardedReferrerMetricsPieSplit; + return referrer.finalScore === referrer.score; + }), ).toBe(true); /** * Assert {@link AwardedReferrerMetrics}. */ - // Assert `awardPoolShare` - expect(qualifiedReferrers.every(([_, referrer]) => referrer.awardPoolShare > 0)).toBe(true); - expect(unqualifiedReferrers.every(([_, referrer]) => referrer.awardPoolShare === 0)).toBe( - true, - ); + // Assert `awardPoolShare` (pie-split specific) + expect( + qualifiedReferrers.every( + ([_, r]) => (r as AwardedReferrerMetricsPieSplit).awardPoolShare > 0, + ), + ).toBe(true); + expect( + unqualifiedReferrers.every( + ([_, r]) => (r as AwardedReferrerMetricsPieSplit).awardPoolShare === 0, + ), + ).toBe(true); // Assert `awardPoolApproxValue` expect( diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts index ac90c8aab..011a1bddf 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts @@ -1,8 +1,10 @@ import { + ReferralProgramAwardModels, ReferralProgramStatuses, - type ReferrerLeaderboard, + type ReferrerLeaderboardPagePieSplit, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, + type ReferrerLeaderboardPieSplit, type ReferrerMetrics, } from "@namehash/ens-referrals/v1"; @@ -173,8 +175,9 @@ export const dbResultsReferrerLeaderboard: ReferrerMetrics[] = [ }, ]; -export const emptyReferralLeaderboard: ReferrerLeaderboard = { +export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = { rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, @@ -196,8 +199,9 @@ export const emptyReferralLeaderboard: ReferrerLeaderboard = { accurateAsOf: 1735689600, }; -export const populatedReferrerLeaderboard: ReferrerLeaderboard = { +export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, @@ -684,10 +688,11 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboard = { accurateAsOf: 1735689600, }; -export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseOk = { +export const referrerLeaderboardPageResponseOk = { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, @@ -1096,5 +1101,5 @@ export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseO }, status: ReferralProgramStatuses.Active, accurateAsOf: 1735689600, - }, -}; + } satisfies ReferrerLeaderboardPagePieSplit, +} satisfies ReferrerLeaderboardPageResponseOk; diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index ce4096a99..eb1843bd5 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -34,7 +34,10 @@ export function deserializeReferrerLeaderboardPageResponse( ); } - return parsed.data; + // The Zod schema includes passthrough catch-alls for unknown award model types, + // making its inferred output type wider than ReferrerLeaderboardPageResponse. + // This assertion is safe: the schema validates all known fields correctly. + return parsed.data as unknown as ReferrerLeaderboardPageResponse; } /** @@ -53,7 +56,8 @@ export function deserializeReferrerMetricsEditionsResponse( ); } - return parsed.data; + // Same passthrough-widened type assertion as above. + return parsed.data as unknown as ReferrerMetricsEditionsResponse; } /** @@ -72,7 +76,8 @@ export function deserializeReferralProgramEditionConfigSetArray( ); } - return parsed.data; + // Same passthrough-widened type assertion as above. + return parsed.data as unknown as ReferralProgramEditionConfig[]; } /** @@ -91,5 +96,6 @@ export function deserializeReferralProgramEditionConfigSetResponse( ); } - return parsed.data; + // Same passthrough-widened type assertion as above. + return parsed.data as unknown as ReferralProgramEditionConfigSetResponse; } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 7ab98e429..e83d47955 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,6 +1,26 @@ -import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "../aggregations"; +import { + serializeReferralProgramRulesPieSplit, + serializeReferrerEditionMetricsRankedPieSplit, + serializeReferrerEditionMetricsUnrankedPieSplit, + serializeReferrerLeaderboardPagePieSplit, +} from "../award-models/pie-split/api/serialize"; +import type { + ReferrerEditionMetricsRankedPieSplit, + ReferrerEditionMetricsUnrankedPieSplit, +} from "../award-models/pie-split/edition-metrics"; +import type { ReferrerLeaderboardPagePieSplit } from "../award-models/pie-split/leaderboard-page"; +import { + serializeReferralProgramRulesRevShareLimit, + serializeReferrerEditionMetricsRankedRevShareLimit, + serializeReferrerEditionMetricsUnrankedRevShareLimit, + serializeReferrerLeaderboardPageRevShareLimit, +} from "../award-models/rev-share-limit/api/serialize"; +import type { + ReferrerEditionMetricsRankedRevShareLimit, + ReferrerEditionMetricsUnrankedRevShareLimit, +} from "../award-models/rev-share-limit/edition-metrics"; +import type { ReferrerLeaderboardPageRevShareLimit } from "../award-models/rev-share-limit/leaderboard-page"; +import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import type { ReferralProgramEditionConfig } from "../edition"; import type { ReferrerEditionMetrics, @@ -8,11 +28,8 @@ import type { ReferrerEditionMetricsUnranked, } from "../edition-metrics"; import type { ReferrerLeaderboardPage } from "../leaderboard-page"; -import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { - SerializedAggregatedReferrerMetrics, - SerializedAwardedReferrerMetrics, SerializedReferralProgramEditionConfig, SerializedReferralProgramEditionConfigSetResponse, SerializedReferralProgramRules, @@ -23,7 +40,6 @@ import type { SerializedReferrerLeaderboardPageResponse, SerializedReferrerMetricsEditionsData, SerializedReferrerMetricsEditionsResponse, - SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import { type ReferralProgramEditionConfigSetResponse, @@ -40,71 +56,13 @@ import { export function serializeReferralProgramRules( rules: ReferralProgramRules, ): SerializedReferralProgramRules { - return { - totalAwardPoolValue: serializePriceUsdc(rules.totalAwardPoolValue), - maxQualifiedReferrers: rules.maxQualifiedReferrers, - startTime: rules.startTime, - endTime: rules.endTime, - subregistryId: rules.subregistryId, - rulesUrl: rules.rulesUrl.toString(), - }; -} - -/** - * Serializes an {@link AwardedReferrerMetrics} object. - */ -function serializeAwardedReferrerMetrics( - metrics: AwardedReferrerMetrics, -): SerializedAwardedReferrerMetrics { - return { - referrer: metrics.referrer, - totalReferrals: metrics.totalReferrals, - totalIncrementalDuration: metrics.totalIncrementalDuration, - totalRevenueContribution: serializePriceEth(metrics.totalRevenueContribution), - score: metrics.score, - rank: metrics.rank, - isQualified: metrics.isQualified, - finalScoreBoost: metrics.finalScoreBoost, - finalScore: metrics.finalScore, - awardPoolShare: metrics.awardPoolShare, - awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), - }; -} - -/** - * Serializes an {@link UnrankedReferrerMetrics} object. - */ -function serializeUnrankedReferrerMetrics( - metrics: UnrankedReferrerMetrics, -): SerializedUnrankedReferrerMetrics { - return { - referrer: metrics.referrer, - totalReferrals: metrics.totalReferrals, - totalIncrementalDuration: metrics.totalIncrementalDuration, - totalRevenueContribution: serializePriceEth(metrics.totalRevenueContribution), - score: metrics.score, - rank: metrics.rank, - isQualified: metrics.isQualified, - finalScoreBoost: metrics.finalScoreBoost, - finalScore: metrics.finalScore, - awardPoolShare: metrics.awardPoolShare, - awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), - }; -} + switch (rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferralProgramRulesPieSplit(rules); -/** - * Serializes an {@link AggregatedReferrerMetrics} object. - */ -function serializeAggregatedReferrerMetrics( - metrics: AggregatedReferrerMetrics, -): SerializedAggregatedReferrerMetrics { - return { - grandTotalReferrals: metrics.grandTotalReferrals, - grandTotalIncrementalDuration: metrics.grandTotalIncrementalDuration, - grandTotalRevenueContribution: serializePriceEth(metrics.grandTotalRevenueContribution), - grandTotalQualifiedReferrersFinalScore: metrics.grandTotalQualifiedReferrersFinalScore, - minFinalScoreToQualify: metrics.minFinalScoreToQualify, - }; + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferralProgramRulesRevShareLimit(rules); + } } /** @@ -113,14 +71,16 @@ function serializeAggregatedReferrerMetrics( function serializeReferrerLeaderboardPage( page: ReferrerLeaderboardPage, ): SerializedReferrerLeaderboardPage { - return { - rules: serializeReferralProgramRules(page.rules), - referrers: page.referrers.map(serializeAwardedReferrerMetrics), - aggregatedMetrics: serializeAggregatedReferrerMetrics(page.aggregatedMetrics), - pageContext: page.pageContext, - status: page.status, - accurateAsOf: page.accurateAsOf, - }; + switch (page.rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + // Single type assertion per branch: rules.awardModel === "pie-split" guarantees all correlated + // fields are the pie-split variant, but TypeScript cannot narrow a union on a nested property. + return serializeReferrerLeaderboardPagePieSplit(page as ReferrerLeaderboardPagePieSplit); + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferrerLeaderboardPageRevShareLimit( + page as ReferrerLeaderboardPageRevShareLimit, + ); + } } /** @@ -129,14 +89,16 @@ function serializeReferrerLeaderboardPage( function serializeReferrerEditionMetricsRanked( detail: ReferrerEditionMetricsRanked, ): SerializedReferrerEditionMetricsRanked { - return { - type: detail.type, - rules: serializeReferralProgramRules(detail.rules), - referrer: serializeAwardedReferrerMetrics(detail.referrer), - aggregatedMetrics: serializeAggregatedReferrerMetrics(detail.aggregatedMetrics), - status: detail.status, - accurateAsOf: detail.accurateAsOf, - }; + switch (detail.rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferrerEditionMetricsRankedPieSplit( + detail as ReferrerEditionMetricsRankedPieSplit, + ); + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferrerEditionMetricsRankedRevShareLimit( + detail as ReferrerEditionMetricsRankedRevShareLimit, + ); + } } /** @@ -145,14 +107,16 @@ function serializeReferrerEditionMetricsRanked( function serializeReferrerEditionMetricsUnranked( detail: ReferrerEditionMetricsUnranked, ): SerializedReferrerEditionMetricsUnranked { - return { - type: detail.type, - rules: serializeReferralProgramRules(detail.rules), - referrer: serializeUnrankedReferrerMetrics(detail.referrer), - aggregatedMetrics: serializeAggregatedReferrerMetrics(detail.aggregatedMetrics), - status: detail.status, - accurateAsOf: detail.accurateAsOf, - }; + switch (detail.rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferrerEditionMetricsUnrankedPieSplit( + detail as ReferrerEditionMetricsUnrankedPieSplit, + ); + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferrerEditionMetricsUnrankedRevShareLimit( + detail as ReferrerEditionMetricsUnrankedRevShareLimit, + ); + } } /** diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 1c049bde6..68dfbbe7f 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,14 +1,22 @@ -import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "../aggregations"; -import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; import type { - ReferrerEditionMetricsRanked, - ReferrerEditionMetricsUnranked, -} from "../edition-metrics"; -import type { ReferrerLeaderboardPage } from "../leaderboard-page"; -import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; -import type { ReferralProgramRules } from "../rules"; + SerializedAggregatedReferrerMetricsPieSplit, + SerializedAwardedReferrerMetricsPieSplit, + SerializedReferralProgramRulesPieSplit, + SerializedReferrerEditionMetricsRankedPieSplit, + SerializedReferrerEditionMetricsUnrankedPieSplit, + SerializedReferrerLeaderboardPagePieSplit, + SerializedUnrankedReferrerMetricsPieSplit, +} from "../award-models/pie-split/api/serialized-types"; +import type { + SerializedAggregatedReferrerMetricsRevShareLimit, + SerializedAwardedReferrerMetricsRevShareLimit, + SerializedReferralProgramRulesRevShareLimit, + SerializedReferrerEditionMetricsRankedRevShareLimit, + SerializedReferrerEditionMetricsUnrankedRevShareLimit, + SerializedReferrerLeaderboardPageRevShareLimit, + SerializedUnrankedReferrerMetricsRevShareLimit, +} from "../award-models/rev-share-limit/api/serialized-types"; +import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; import type { ReferralProgramEditionConfigSetData, ReferralProgramEditionConfigSetResponse, @@ -23,69 +31,63 @@ import type { } from "./types"; /** - * Serialized representation of {@link ReferralProgramRules}. + * Serialized representation of an unknown future award model rules object. + * Unknown types are already JSON-safe (arrived via deserialization passthrough). */ -export interface SerializedReferralProgramRules - extends Omit { - totalAwardPoolValue: SerializedPriceUsdc; - rulesUrl: string; -} +export type SerializedReferralProgramRulesUnknown = { awardModel: string } & Record< + string, + unknown +>; /** - * Serialized representation of {@link AwardedReferrerMetrics}. + * Serialized representation of referral program rules (union of all award model variants). */ -export interface SerializedAwardedReferrerMetrics - extends Omit { - totalRevenueContribution: SerializedPriceEth; - awardPoolApproxValue: SerializedPriceUsdc; -} +export type SerializedReferralProgramRules = + | SerializedReferralProgramRulesPieSplit + | SerializedReferralProgramRulesRevShareLimit + | SerializedReferralProgramRulesUnknown; /** - * Serialized representation of {@link UnrankedReferrerMetrics}. + * Serialized representation of aggregated referrer metrics (union of all award model variants). */ -export interface SerializedUnrankedReferrerMetrics - extends Omit { - totalRevenueContribution: SerializedPriceEth; - awardPoolApproxValue: SerializedPriceUsdc; -} +export type SerializedAggregatedReferrerMetrics = + | SerializedAggregatedReferrerMetricsPieSplit + | SerializedAggregatedReferrerMetricsRevShareLimit; /** - * Serialized representation of {@link AggregatedReferrerMetrics}. + * Serialized representation of awarded referrer metrics (union of all award model variants). */ -export interface SerializedAggregatedReferrerMetrics - extends Omit { - grandTotalRevenueContribution: SerializedPriceEth; -} +export type SerializedAwardedReferrerMetrics = + | SerializedAwardedReferrerMetricsPieSplit + | SerializedAwardedReferrerMetricsRevShareLimit; + +/** + * Serialized representation of unranked referrer metrics (union of all award model variants). + */ +export type SerializedUnrankedReferrerMetrics = + | SerializedUnrankedReferrerMetricsPieSplit + | SerializedUnrankedReferrerMetricsRevShareLimit; /** * Serialized representation of {@link ReferrerLeaderboardPage}. */ -export interface SerializedReferrerLeaderboardPage - extends Omit { - rules: SerializedReferralProgramRules; - referrers: SerializedAwardedReferrerMetrics[]; - aggregatedMetrics: SerializedAggregatedReferrerMetrics; -} +export type SerializedReferrerLeaderboardPage = + | SerializedReferrerLeaderboardPagePieSplit + | SerializedReferrerLeaderboardPageRevShareLimit; /** * Serialized representation of {@link ReferrerEditionMetricsRanked}. */ -export interface SerializedReferrerEditionMetricsRanked - extends Omit { - rules: SerializedReferralProgramRules; - referrer: SerializedAwardedReferrerMetrics; - aggregatedMetrics: SerializedAggregatedReferrerMetrics; -} +export type SerializedReferrerEditionMetricsRanked = + | SerializedReferrerEditionMetricsRankedPieSplit + | SerializedReferrerEditionMetricsRankedRevShareLimit; /** * Serialized representation of {@link ReferrerEditionMetricsUnranked}. */ -export interface SerializedReferrerEditionMetricsUnranked - extends Omit { - rules: SerializedReferralProgramRules; - referrer: SerializedUnrankedReferrerMetrics; - aggregatedMetrics: SerializedAggregatedReferrerMetrics; -} +export type SerializedReferrerEditionMetricsUnranked = + | SerializedReferrerEditionMetricsUnrankedPieSplit + | SerializedReferrerEditionMetricsUnrankedRevShareLimit; /** * Serialized representation of {@link ReferrerEditionMetrics} (union of ranked and unranked). diff --git a/packages/ens-referrals/src/v1/api/types.ts b/packages/ens-referrals/src/v1/api/types.ts index 5f7e83bc0..3d2e90e75 100644 --- a/packages/ens-referrals/src/v1/api/types.ts +++ b/packages/ens-referrals/src/v1/api/types.ts @@ -1,8 +1,9 @@ import type { Address } from "viem"; +import type { ReferrerLeaderboardPageParams } from "../award-models/shared/leaderboard-page"; import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; import type { ReferrerEditionMetrics } from "../edition-metrics"; -import type { ReferrerLeaderboardPage, ReferrerLeaderboardPageParams } from "../leaderboard-page"; +import type { ReferrerLeaderboardPage } from "../leaderboard-page"; /** * Request parameters for a referrer leaderboard page query. diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index faa2086ae..805ab927b 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -9,26 +9,20 @@ import z from "zod/v4"; -import { - makeAccountIdSchema, - makeDurationSchema, - makeFiniteNonNegativeNumberSchema, - makeLowercaseAddressSchema, - makeNonNegativeIntegerSchema, - makePositiveIntegerSchema, - makePriceEthSchema, - makePriceUsdcSchema, - makeUnixTimestampSchema, - makeUrlSchema, -} from "@ensnode/ensnode-sdk/internal"; +import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; -import type { ReferralProgramEditionSlug } from "../edition"; import { - type ReferrerEditionMetricsRanked, - ReferrerEditionMetricsTypeIds, -} from "../edition-metrics"; -import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; -import { ReferralProgramStatuses } from "../status"; + makeReferralProgramRulesPieSplitSchema, + makeReferrerEditionMetricsRankedPieSplitSchema, + makeReferrerEditionMetricsUnrankedPieSplitSchema, + makeReferrerLeaderboardPagePieSplitSchema, +} from "../award-models/pie-split/api/zod-schemas"; +import { + makeReferralProgramRulesRevShareLimitSchema, + makeReferrerEditionMetricsRankedRevShareLimitSchema, + makeReferrerEditionMetricsUnrankedRevShareLimitSchema, + makeReferrerLeaderboardPageRevShareLimitSchema, +} from "../award-models/rev-share-limit/api/zod-schemas"; import { MAX_EDITIONS_PER_REQUEST, ReferralProgramEditionConfigSetResponseCodes, @@ -37,129 +31,37 @@ import { } from "./types"; /** - * Schema for {@link ReferralProgramRules} + * Schema for {@link ReferralProgramRules}. + * + * Accepts known award model variants (pie-split, rev-share-limit) with full validation, + * plus a passthrough catch-all for unknown future types. + * + * If `awardModel` is not recognized, the object parses as `{ awardModel: string } & Record`. + * Clients must check `awardModel` before accessing model-specific fields. + * This design allows servers to introduce new award model types without breaking existing clients. */ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => - z - .object({ - totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), - maxQualifiedReferrers: makeNonNegativeIntegerSchema(`${valueLabel}.maxQualifiedReferrers`), - startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), - endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), - subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), - rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), - }) - .refine((data) => data.endTime >= data.startTime, { - message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, - path: ["endTime"], - }); - -/** - * Schema for AwardedReferrerMetrics (with numeric rank) - */ -export const makeAwardedReferrerMetricsSchema = (valueLabel: string = "AwardedReferrerMetrics") => - z.object({ - referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), - totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), - totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), - totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), - score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`), - rank: makePositiveIntegerSchema(`${valueLabel}.rank`), - isQualified: z.boolean(), - finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max( - 1, - `${valueLabel}.finalScoreBoost must be <= 1`, - ), - finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`), - awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max( - 1, - `${valueLabel}.awardPoolShare must be <= 1`, - ), - awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), - }); - -/** - * Schema for UnrankedReferrerMetrics (with null rank) - */ -export const makeUnrankedReferrerMetricsSchema = (valueLabel: string = "UnrankedReferrerMetrics") => - z.object({ - referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), - totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), - totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), - totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), - score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`), - rank: z.null(), - isQualified: z.literal(false), - finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max( - 1, - `${valueLabel}.finalScoreBoost must be <= 1`, - ), - finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`), - awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max( - 1, - `${valueLabel}.awardPoolShare must be <= 1`, - ), - awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), - }); - -/** - * Schema for AggregatedReferrerMetrics - */ -export const makeAggregatedReferrerMetricsSchema = ( - valueLabel: string = "AggregatedReferrerMetrics", -) => - z.object({ - grandTotalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.grandTotalReferrals`), - grandTotalIncrementalDuration: makeDurationSchema( - `${valueLabel}.grandTotalIncrementalDuration`, - ), - grandTotalRevenueContribution: makePriceEthSchema( - `${valueLabel}.grandTotalRevenueContribution`, - ), - grandTotalQualifiedReferrersFinalScore: makeFiniteNonNegativeNumberSchema( - `${valueLabel}.grandTotalQualifiedReferrersFinalScore`, - ), - minFinalScoreToQualify: makeFiniteNonNegativeNumberSchema( - `${valueLabel}.minFinalScoreToQualify`, - ), - }); - -export const makeReferrerLeaderboardPageContextSchema = ( - valueLabel: string = "ReferrerLeaderboardPageContext", -) => - z.object({ - page: makePositiveIntegerSchema(`${valueLabel}.page`), - recordsPerPage: makePositiveIntegerSchema(`${valueLabel}.recordsPerPage`).max( - REFERRERS_PER_LEADERBOARD_PAGE_MAX, - `${valueLabel}.recordsPerPage must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`, - ), - totalRecords: makeNonNegativeIntegerSchema(`${valueLabel}.totalRecords`), - totalPages: makePositiveIntegerSchema(`${valueLabel}.totalPages`), - hasNext: z.boolean(), - hasPrev: z.boolean(), - startIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.startIndex`)), - endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)), - }); - -/** - * Schema for referral program status field. - * Validates that the status is one of: "Scheduled", "Active", or "Closed". - */ -export const makeReferralProgramStatusSchema = (_valueLabel: string = "status") => - z.enum(ReferralProgramStatuses); + z.union([ + makeReferralProgramRulesPieSplitSchema(valueLabel), + makeReferralProgramRulesRevShareLimitSchema(valueLabel), + // Passthrough catch-all for unknown future award model types + z + .object({ awardModel: z.string() }) + .passthrough(), + ]); /** - * Schema for ReferrerLeaderboardPage + * Schema for {@link ReferrerLeaderboardPage} */ export const makeReferrerLeaderboardPageSchema = (valueLabel: string = "ReferrerLeaderboardPage") => - z.object({ - rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), - referrers: z.array(makeAwardedReferrerMetricsSchema(`${valueLabel}.referrers[record]`)), - aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), - pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z.union([ + makeReferrerLeaderboardPagePieSplitSchema(valueLabel), + makeReferrerLeaderboardPageRevShareLimitSchema(valueLabel), + // Passthrough for unknown future award model types + z + .object({ rules: z.object({ awardModel: z.string() }).passthrough() }) + .passthrough(), + ]); /** * Schema for {@link ReferrerLeaderboardPageResponseOk} @@ -201,14 +103,10 @@ export const makeReferrerLeaderboardPageResponseSchema = ( export const makeReferrerEditionMetricsRankedSchema = ( valueLabel: string = "ReferrerEditionMetricsRanked", ) => - z.object({ - type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), - rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), - referrer: makeAwardedReferrerMetricsSchema(`${valueLabel}.referrer`), - aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z.union([ + makeReferrerEditionMetricsRankedPieSplitSchema(valueLabel), + makeReferrerEditionMetricsRankedRevShareLimitSchema(valueLabel), + ]); /** * Schema for {@link ReferrerEditionMetricsUnranked} (with unranked metrics) @@ -216,22 +114,20 @@ export const makeReferrerEditionMetricsRankedSchema = ( export const makeReferrerEditionMetricsUnrankedSchema = ( valueLabel: string = "ReferrerEditionMetricsUnranked", ) => - z.object({ - type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), - rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), - referrer: makeUnrankedReferrerMetricsSchema(`${valueLabel}.referrer`), - aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z.union([ + makeReferrerEditionMetricsUnrankedPieSplitSchema(valueLabel), + makeReferrerEditionMetricsUnrankedRevShareLimitSchema(valueLabel), + ]); /** - * Schema for {@link ReferrerEditionMetrics} (discriminated union of ranked and unranked) + * Schema for {@link ReferrerEditionMetrics} (union of all ranked and unranked model variants) */ export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerEditionMetrics") => - z.discriminatedUnion("type", [ - makeReferrerEditionMetricsRankedSchema(valueLabel), - makeReferrerEditionMetricsUnrankedSchema(valueLabel), + z.union([ + makeReferrerEditionMetricsRankedPieSplitSchema(valueLabel), + makeReferrerEditionMetricsRankedRevShareLimitSchema(valueLabel), + makeReferrerEditionMetricsUnrankedPieSplitSchema(valueLabel), + makeReferrerEditionMetricsUnrankedRevShareLimitSchema(valueLabel), ]); /** diff --git a/packages/ens-referrals/src/v1/aggregations.ts b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts similarity index 69% rename from packages/ens-referrals/src/v1/aggregations.ts rename to packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts index 4b6157ab8..6067cdccd 100644 --- a/packages/ens-referrals/src/v1/aggregations.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts @@ -1,24 +1,24 @@ import { type Duration, type PriceEth, priceEth } from "@ensnode/ensnode-sdk"; import { makePriceEthSchema } from "@ensnode/ensnode-sdk/internal"; -import { validateNonNegativeInteger } from "./number"; -import type { RankedReferrerMetrics } from "./referrer-metrics"; -import type { ReferralProgramRules } from "./rules"; -import { type ReferrerScore, validateReferrerScore } from "./score"; -import { validateDuration } from "./time"; +import { validateNonNegativeInteger } from "../../number"; +import { validateDuration } from "../../time"; +import { type ReferrerScore, validateReferrerScore } from "../shared/score"; +import type { RankedReferrerMetricsPieSplit } from "./metrics"; +import type { ReferralProgramRulesPieSplit } from "./rules"; /** - * Represents aggregated metrics for a list of `RankedReferrerMetrics`. + * Represents aggregated metrics for a list of {@link RankedReferrerMetricsPieSplit}. */ -export interface AggregatedReferrerMetrics { +export interface AggregatedReferrerMetricsPieSplit { /** - * @invariant The sum of `totalReferrals` across all `RankedReferrerMetrics` in the list. + * @invariant The sum of `totalReferrals` across all {@link RankedReferrerMetricsPieSplit} in the list. * @invariant Guaranteed to be a non-negative integer (>= 0) */ grandTotalReferrals: number; /** - * @invariant The sum of `totalIncrementalDuration` across all `RankedReferrerMetrics` in the list. + * @invariant The sum of `totalIncrementalDuration` across all {@link RankedReferrerMetricsPieSplit} in the list. */ grandTotalIncrementalDuration: Duration; @@ -26,14 +26,14 @@ export interface AggregatedReferrerMetrics { * The total revenue contribution in ETH to the ENS DAO from all referrals * across all referrers on the leaderboard. * - * This is the sum of `totalRevenueContribution` across all `RankedReferrerMetrics` in the list. + * This is the sum of `totalRevenueContribution` across all {@link RankedReferrerMetricsPieSplit} in the list. * * @invariant Guaranteed to be a valid PriceEth with non-negative amount (>= 0n) */ grandTotalRevenueContribution: PriceEth; /** - * @invariant The sum of `finalScore` across all `RankedReferrerMetrics` where `isQualified` is `true`. + * @invariant The sum of `finalScore` across all {@link RankedReferrerMetricsPieSplit} where `isQualified` is `true`. */ grandTotalQualifiedReferrersFinalScore: ReferrerScore; @@ -47,18 +47,19 @@ export interface AggregatedReferrerMetrics { minFinalScoreToQualify: ReferrerScore; } -export const validateAggregatedReferrerMetrics = (metrics: AggregatedReferrerMetrics): void => { +export const validateAggregatedReferrerMetricsPieSplit = ( + metrics: AggregatedReferrerMetricsPieSplit, +): void => { validateNonNegativeInteger(metrics.grandTotalReferrals); validateDuration(metrics.grandTotalIncrementalDuration); - // Validate grandTotalRevenueContribution using Zod schema const priceEthSchema = makePriceEthSchema( - "AggregatedReferrerMetrics.grandTotalRevenueContribution", + "AggregatedReferrerMetricsPieSplit.grandTotalRevenueContribution", ); const parseResult = priceEthSchema.safeParse(metrics.grandTotalRevenueContribution); if (!parseResult.success) { throw new Error( - `AggregatedReferrerMetrics: grandTotalRevenueContribution validation failed: ${parseResult.error.message}`, + `AggregatedReferrerMetricsPieSplit: grandTotalRevenueContribution validation failed: ${parseResult.error.message}`, ); } @@ -67,15 +68,15 @@ export const validateAggregatedReferrerMetrics = (metrics: AggregatedReferrerMet }; /** - * Builds aggregated metrics from a complete, globally ranked list of referrers. + * Builds aggregated pie-split metrics from a complete, globally ranked list of referrers. * * **IMPORTANT: This function expects a complete ranking of all referrers.** * - * @param referrers - Must be a complete, globally ranked list of `RankedReferrerMetrics` - * where ranks start at 1 and are consecutive (e.g., 1, 2, 3, ...). + * @param referrers - Must be a complete, globally ranked list of {@link RankedReferrerMetricsPieSplit} + * where ranks start at 1 and are consecutive. * **This must NOT be a paginated or partial slice of the rankings.** * - * @param rules - The referral program rules that define qualification criteria, + * @param rules - The {@link ReferralProgramRulesPieSplit} object that define qualification criteria, * including `maxQualifiedReferrers` (the maximum number of referrers * that can qualify for rewards). * @@ -91,10 +92,10 @@ export const validateAggregatedReferrerMetrics = (metrics: AggregatedReferrerMet * - If `referrers` is empty and `rules.maxQualifiedReferrers > 0`, * `minFinalScoreToQualify` will be set to `0` (anyone can qualify). */ -export const buildAggregatedReferrerMetrics = ( - referrers: RankedReferrerMetrics[], - rules: ReferralProgramRules, -): AggregatedReferrerMetrics => { +export const buildAggregatedReferrerMetricsPieSplit = ( + referrers: RankedReferrerMetricsPieSplit[], + rules: ReferralProgramRulesPieSplit, +): AggregatedReferrerMetricsPieSplit => { let grandTotalReferrals = 0; let grandTotalIncrementalDuration = 0; let grandTotalRevenueContributionAmount = 0n; @@ -122,7 +123,7 @@ export const buildAggregatedReferrerMetrics = ( if (referrers.length !== 0) { // invariant sanity check throw new Error( - "AggregatedReferrerMetrics: There are referrers on the leaderboard, and the rules allow for qualified referrers, but no qualified referrers.", + "AggregatedReferrerMetricsPieSplit: There are referrers on the leaderboard, and the rules allow for qualified referrers, but no qualified referrers.", ); } @@ -136,9 +137,9 @@ export const buildAggregatedReferrerMetrics = ( grandTotalRevenueContribution: priceEth(grandTotalRevenueContributionAmount), grandTotalQualifiedReferrersFinalScore, minFinalScoreToQualify, - }; + } satisfies AggregatedReferrerMetricsPieSplit; - validateAggregatedReferrerMetrics(result); + validateAggregatedReferrerMetricsPieSplit(result); return result; }; diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts new file mode 100644 index 000000000..081129776 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts @@ -0,0 +1,141 @@ +import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; + +import type { AggregatedReferrerMetricsPieSplit } from "../aggregations"; +import type { + ReferrerEditionMetricsRankedPieSplit, + ReferrerEditionMetricsUnrankedPieSplit, +} from "../edition-metrics"; +import type { ReferrerLeaderboardPagePieSplit } from "../leaderboard-page"; +import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "../metrics"; +import type { ReferralProgramRulesPieSplit } from "../rules"; +import type { + SerializedAggregatedReferrerMetricsPieSplit, + SerializedAwardedReferrerMetricsPieSplit, + SerializedReferralProgramRulesPieSplit, + SerializedReferrerEditionMetricsRankedPieSplit, + SerializedReferrerEditionMetricsUnrankedPieSplit, + SerializedReferrerLeaderboardPagePieSplit, + SerializedUnrankedReferrerMetricsPieSplit, +} from "./serialized-types"; + +/** + * Serializes a {@link ReferralProgramRulesPieSplit} object. + */ +export function serializeReferralProgramRulesPieSplit( + rules: ReferralProgramRulesPieSplit, +): SerializedReferralProgramRulesPieSplit { + return { + awardModel: rules.awardModel, + totalAwardPoolValue: serializePriceUsdc(rules.totalAwardPoolValue), + maxQualifiedReferrers: rules.maxQualifiedReferrers, + startTime: rules.startTime, + endTime: rules.endTime, + subregistryId: rules.subregistryId, + rulesUrl: rules.rulesUrl.toString(), + }; +} + +/** + * Serializes a {@link AggregatedReferrerMetricsPieSplit} object. + */ +export function serializeAggregatedReferrerMetricsPieSplit( + metrics: AggregatedReferrerMetricsPieSplit, +): SerializedAggregatedReferrerMetricsPieSplit { + return { + grandTotalReferrals: metrics.grandTotalReferrals, + grandTotalIncrementalDuration: metrics.grandTotalIncrementalDuration, + grandTotalRevenueContribution: serializePriceEth(metrics.grandTotalRevenueContribution), + grandTotalQualifiedReferrersFinalScore: metrics.grandTotalQualifiedReferrersFinalScore, + minFinalScoreToQualify: metrics.minFinalScoreToQualify, + }; +} + +/** + * Serializes a {@link AwardedReferrerMetricsPieSplit} object. + */ +export function serializeAwardedReferrerMetricsPieSplit( + metrics: AwardedReferrerMetricsPieSplit, +): SerializedAwardedReferrerMetricsPieSplit { + return { + referrer: metrics.referrer, + totalReferrals: metrics.totalReferrals, + totalIncrementalDuration: metrics.totalIncrementalDuration, + totalRevenueContribution: serializePriceEth(metrics.totalRevenueContribution), + score: metrics.score, + rank: metrics.rank, + isQualified: metrics.isQualified, + finalScoreBoost: metrics.finalScoreBoost, + finalScore: metrics.finalScore, + awardPoolShare: metrics.awardPoolShare, + awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + }; +} + +/** + * Serializes a {@link UnrankedReferrerMetricsPieSplit} object. + */ +export function serializeUnrankedReferrerMetricsPieSplit( + metrics: UnrankedReferrerMetricsPieSplit, +): SerializedUnrankedReferrerMetricsPieSplit { + return { + referrer: metrics.referrer, + totalReferrals: metrics.totalReferrals, + totalIncrementalDuration: metrics.totalIncrementalDuration, + totalRevenueContribution: serializePriceEth(metrics.totalRevenueContribution), + score: metrics.score, + rank: metrics.rank, + isQualified: metrics.isQualified, + finalScoreBoost: metrics.finalScoreBoost, + finalScore: metrics.finalScore, + awardPoolShare: metrics.awardPoolShare, + awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + }; +} + +/** + * Serializes a {@link ReferrerEditionMetricsRankedPieSplit} object. + */ +export function serializeReferrerEditionMetricsRankedPieSplit( + detail: ReferrerEditionMetricsRankedPieSplit, +): SerializedReferrerEditionMetricsRankedPieSplit { + return { + type: detail.type, + rules: serializeReferralProgramRulesPieSplit(detail.rules), + referrer: serializeAwardedReferrerMetricsPieSplit(detail.referrer), + aggregatedMetrics: serializeAggregatedReferrerMetricsPieSplit(detail.aggregatedMetrics), + status: detail.status, + accurateAsOf: detail.accurateAsOf, + }; +} + +/** + * Serializes a {@link ReferrerEditionMetricsUnrankedPieSplit} object. + */ +export function serializeReferrerEditionMetricsUnrankedPieSplit( + detail: ReferrerEditionMetricsUnrankedPieSplit, +): SerializedReferrerEditionMetricsUnrankedPieSplit { + return { + type: detail.type, + rules: serializeReferralProgramRulesPieSplit(detail.rules), + referrer: serializeUnrankedReferrerMetricsPieSplit(detail.referrer), + aggregatedMetrics: serializeAggregatedReferrerMetricsPieSplit(detail.aggregatedMetrics), + status: detail.status, + accurateAsOf: detail.accurateAsOf, + }; +} + +/** + * Serializes a {@link ReferrerLeaderboardPagePieSplit} object. + */ +export function serializeReferrerLeaderboardPagePieSplit( + page: ReferrerLeaderboardPagePieSplit, +): SerializedReferrerLeaderboardPagePieSplit { + return { + rules: serializeReferralProgramRulesPieSplit(page.rules), + referrers: page.referrers.map(serializeAwardedReferrerMetricsPieSplit), + aggregatedMetrics: serializeAggregatedReferrerMetricsPieSplit(page.aggregatedMetrics), + pageContext: page.pageContext, + status: page.status, + accurateAsOf: page.accurateAsOf, + }; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts new file mode 100644 index 000000000..73fd9ff44 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialized-types.ts @@ -0,0 +1,81 @@ +import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; + +import type { AggregatedReferrerMetricsPieSplit } from "../aggregations"; +import type { + ReferrerEditionMetricsRankedPieSplit, + ReferrerEditionMetricsUnrankedPieSplit, +} from "../edition-metrics"; +import type { ReferrerLeaderboardPagePieSplit } from "../leaderboard-page"; +import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "../metrics"; +import type { ReferralProgramRulesPieSplit } from "../rules"; + +/** + * Serialized representation of {@link ReferralProgramRulesPieSplit}. + */ +export interface SerializedReferralProgramRulesPieSplit + extends Omit { + totalAwardPoolValue: SerializedPriceUsdc; + rulesUrl: string; +} + +/** + * Serialized representation of {@link AggregatedReferrerMetricsPieSplit}. + */ +export interface SerializedAggregatedReferrerMetricsPieSplit + extends Omit { + grandTotalRevenueContribution: SerializedPriceEth; +} + +/** + * Serialized representation of {@link AwardedReferrerMetricsPieSplit}. + */ +export interface SerializedAwardedReferrerMetricsPieSplit + extends Omit< + AwardedReferrerMetricsPieSplit, + "totalRevenueContribution" | "awardPoolApproxValue" + > { + totalRevenueContribution: SerializedPriceEth; + awardPoolApproxValue: SerializedPriceUsdc; +} + +/** + * Serialized representation of {@link UnrankedReferrerMetricsPieSplit}. + */ +export interface SerializedUnrankedReferrerMetricsPieSplit + extends Omit< + UnrankedReferrerMetricsPieSplit, + "totalRevenueContribution" | "awardPoolApproxValue" + > { + totalRevenueContribution: SerializedPriceEth; + awardPoolApproxValue: SerializedPriceUsdc; +} + +/** + * Serialized representation of {@link ReferrerLeaderboardPagePieSplit}. + */ +export interface SerializedReferrerLeaderboardPagePieSplit + extends Omit { + rules: SerializedReferralProgramRulesPieSplit; + referrers: SerializedAwardedReferrerMetricsPieSplit[]; + aggregatedMetrics: SerializedAggregatedReferrerMetricsPieSplit; +} + +/** + * Serialized representation of {@link ReferrerEditionMetricsRankedPieSplit}. + */ +export interface SerializedReferrerEditionMetricsRankedPieSplit + extends Omit { + rules: SerializedReferralProgramRulesPieSplit; + referrer: SerializedAwardedReferrerMetricsPieSplit; + aggregatedMetrics: SerializedAggregatedReferrerMetricsPieSplit; +} + +/** + * Serialized representation of {@link ReferrerEditionMetricsUnrankedPieSplit}. + */ +export interface SerializedReferrerEditionMetricsUnrankedPieSplit + extends Omit { + rules: SerializedReferralProgramRulesPieSplit; + referrer: SerializedUnrankedReferrerMetricsPieSplit; + aggregatedMetrics: SerializedAggregatedReferrerMetricsPieSplit; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts new file mode 100644 index 000000000..d7b183d18 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts @@ -0,0 +1,166 @@ +import z from "zod/v4"; + +import { + makeAccountIdSchema, + makeDurationSchema, + makeFiniteNonNegativeNumberSchema, + makeLowercaseAddressSchema, + makeNonNegativeIntegerSchema, + makePositiveIntegerSchema, + makePriceEthSchema, + makePriceUsdcSchema, + makeUnixTimestampSchema, + makeUrlSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { + makeReferralProgramStatusSchema, + makeReferrerLeaderboardPageContextSchema, +} from "../../shared/api/zod-schemas"; +import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; + +/** + * Schema for {@link ReferralProgramRulesPieSplit}. + */ +export const makeReferralProgramRulesPieSplitSchema = ( + valueLabel: string = "ReferralProgramRulesPieSplit", +) => + z + .object({ + awardModel: z.literal("pie-split"), + totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), + maxQualifiedReferrers: makeNonNegativeIntegerSchema(`${valueLabel}.maxQualifiedReferrers`), + startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), + endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), + subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), + rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), + }) + .refine((data) => data.endTime >= data.startTime, { + message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, + path: ["endTime"], + }); + +/** + * Schema for {@link AwardedReferrerMetricsPieSplit} (with numeric rank). + */ +export const makeAwardedReferrerMetricsPieSplitSchema = ( + valueLabel: string = "AwardedReferrerMetricsPieSplit", +) => + z.object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), + totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), + totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), + score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`), + rank: makePositiveIntegerSchema(`${valueLabel}.rank`), + isQualified: z.boolean(), + finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max( + 1, + `${valueLabel}.finalScoreBoost must be <= 1`, + ), + finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`), + awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max( + 1, + `${valueLabel}.awardPoolShare must be <= 1`, + ), + awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + }); + +/** + * Schema for {@link UnrankedReferrerMetricsPieSplit} (with null rank). + */ +export const makeUnrankedReferrerMetricsPieSplitSchema = ( + valueLabel: string = "UnrankedReferrerMetricsPieSplit", +) => + z.object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), + totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), + totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), + score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`), + rank: z.null(), + isQualified: z.literal(false), + finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max( + 1, + `${valueLabel}.finalScoreBoost must be <= 1`, + ), + finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`), + awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max( + 1, + `${valueLabel}.awardPoolShare must be <= 1`, + ), + awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + }); + +/** + * Schema for {@link AggregatedReferrerMetricsPieSplit}. + */ +export const makeAggregatedReferrerMetricsPieSplitSchema = ( + valueLabel: string = "AggregatedReferrerMetricsPieSplit", +) => + z.object({ + grandTotalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.grandTotalReferrals`), + grandTotalIncrementalDuration: makeDurationSchema( + `${valueLabel}.grandTotalIncrementalDuration`, + ), + grandTotalRevenueContribution: makePriceEthSchema( + `${valueLabel}.grandTotalRevenueContribution`, + ), + grandTotalQualifiedReferrersFinalScore: makeFiniteNonNegativeNumberSchema( + `${valueLabel}.grandTotalQualifiedReferrersFinalScore`, + ), + minFinalScoreToQualify: makeFiniteNonNegativeNumberSchema( + `${valueLabel}.minFinalScoreToQualify`, + ), + }); + +/** + * Schema for {@link ReferrerEditionMetricsRankedPieSplit}. + */ +export const makeReferrerEditionMetricsRankedPieSplitSchema = ( + valueLabel: string = "ReferrerEditionMetricsRankedPieSplit", +) => + z.object({ + type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + referrer: makeAwardedReferrerMetricsPieSplitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }); + +/** + * Schema for {@link ReferrerEditionMetricsUnrankedPieSplit}. + */ +export const makeReferrerEditionMetricsUnrankedPieSplitSchema = ( + valueLabel: string = "ReferrerEditionMetricsUnrankedPieSplit", +) => + z.object({ + type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + referrer: makeUnrankedReferrerMetricsPieSplitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }); + +/** + * Schema for {@link ReferrerLeaderboardPagePieSplit}. + */ +export const makeReferrerLeaderboardPagePieSplitSchema = ( + valueLabel: string = "ReferrerLeaderboardPagePieSplit", +) => + z.object({ + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + referrers: z.array(makeAwardedReferrerMetricsPieSplitSchema(`${valueLabel}.referrers[record]`)), + aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts new file mode 100644 index 000000000..7253c13a3 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts @@ -0,0 +1,98 @@ +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { ReferralProgramStatusId } from "../../status"; +import type { ReferrerEditionMetricsTypeIds } from "../shared/edition-metrics"; +import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; +import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "./metrics"; +import type { ReferralProgramRulesPieSplit } from "./rules"; + +/** + * Referrer edition metrics data for a specific referrer address on the pie-split leaderboard. + * + * Includes the referrer's awarded metrics from the leaderboard plus timestamp. + * + * Invariants: + * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. + * + * @see {@link AwardedReferrerMetricsPieSplit} + */ +export interface ReferrerEditionMetricsRankedPieSplit { + /** + * The type of referrer edition metrics data. + */ + type: typeof ReferrerEditionMetricsTypeIds.Ranked; + + /** + * The {@link ReferralProgramRulesPieSplit} used to calculate the {@link AwardedReferrerMetricsPieSplit}. + */ + rules: ReferralProgramRulesPieSplit; + + /** + * The awarded referrer metrics from the leaderboard. + * + * Contains all calculated metrics including score, rank, qualification status, + * and award pool share information. + */ + referrer: AwardedReferrerMetricsPieSplit; + + /** + * Aggregated metrics for all referrers on the leaderboard. + */ + aggregatedMetrics: AggregatedReferrerMetricsPieSplit; + + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRankedPieSplit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} + +/** + * Referrer edition metrics data for a specific referrer address NOT on the pie-split leaderboard. + * + * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. + * + * Invariants: + * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. + * + * @see {@link UnrankedReferrerMetricsPieSplit} + */ +export interface ReferrerEditionMetricsUnrankedPieSplit { + /** + * The type of referrer edition metrics data. + */ + type: typeof ReferrerEditionMetricsTypeIds.Unranked; + + /** + * The {@link ReferralProgramRulesPieSplit} used to calculate the {@link UnrankedReferrerMetricsPieSplit}. + */ + rules: ReferralProgramRulesPieSplit; + + /** + * The unranked referrer metrics (not on the leaderboard). + * + * Contains all calculated metrics with rank set to null and isQualified set to false. + */ + referrer: UnrankedReferrerMetricsPieSplit; + + /** + * Aggregated metrics for all referrers on the leaderboard. + */ + aggregatedMetrics: AggregatedReferrerMetricsPieSplit; + + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnrankedPieSplit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts new file mode 100644 index 000000000..22282a8b5 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts @@ -0,0 +1,50 @@ +import { calcReferralProgramStatus } from "../../status"; +import { + type BaseReferrerLeaderboardPage, + type ReferrerLeaderboardPageContext, + sliceReferrers, +} from "../shared/leaderboard-page"; +import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; +import type { ReferrerLeaderboardPieSplit } from "./leaderboard"; +import type { AwardedReferrerMetricsPieSplit } from "./metrics"; +import type { ReferralProgramRulesPieSplit } from "./rules"; + +/** + * A page of referrers from the pie-split referrer leaderboard. + */ +export interface ReferrerLeaderboardPagePieSplit extends BaseReferrerLeaderboardPage { + /** + * The {@link ReferralProgramRulesPieSplit} used to generate the {@link ReferrerLeaderboardPieSplit} + * that this {@link ReferrerLeaderboardPagePieSplit} comes from. + */ + rules: ReferralProgramRulesPieSplit; + + /** + * Ordered list of {@link AwardedReferrerMetricsPieSplit} for the {@link ReferrerLeaderboardPagePieSplit} + * described by {@link pageContext} within the related {@link ReferrerLeaderboardPieSplit}. + * + * @invariant Array will be empty if `pageContext.totalRecords` is 0. + * @invariant Array entries are ordered by `rank` (ascending). + */ + referrers: AwardedReferrerMetricsPieSplit[]; + + /** + * The aggregated metrics for all referrers on the leaderboard. + */ + aggregatedMetrics: AggregatedReferrerMetricsPieSplit; +} + +export function buildLeaderboardPagePieSplit( + pageContext: ReferrerLeaderboardPageContext, + leaderboard: ReferrerLeaderboardPieSplit, +): ReferrerLeaderboardPagePieSplit { + const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); + return { + rules: leaderboard.rules, + referrers: sliceReferrers(leaderboard.referrers, pageContext), + aggregatedMetrics: leaderboard.aggregatedMetrics, + pageContext, + status, + accurateAsOf: leaderboard.accurateAsOf, + }; +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts new file mode 100644 index 000000000..1d9fd78e2 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts @@ -0,0 +1,77 @@ +import type { Address } from "viem"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { ReferrerMetrics } from "../../referrer-metrics"; +import { assertLeaderboardInputs } from "../shared/leaderboard-guards"; +import { sortReferrerMetrics } from "../shared/rank"; +import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; +import { buildAggregatedReferrerMetricsPieSplit } from "./aggregations"; +import type { AwardedReferrerMetricsPieSplit } from "./metrics"; +import { + buildAwardedReferrerMetricsPieSplit, + buildRankedReferrerMetricsPieSplit, + buildScoredReferrerMetricsPieSplit, +} from "./metrics"; +import type { ReferralProgramRulesPieSplit } from "./rules"; + +/** + * Represents a leaderboard with the pie-split award model for any number of referrers. + */ +export interface ReferrerLeaderboardPieSplit { + /** + * The rules of the referral program that generated the {@link ReferrerLeaderboardPieSplit}. + */ + rules: ReferralProgramRulesPieSplit; + + /** + * The {@link AggregatedReferrerMetricsPieSplit} for all {@link RankedReferrerMetricsPieSplit} values in `referrers`. + */ + aggregatedMetrics: AggregatedReferrerMetricsPieSplit; + + /** + * Ordered map containing `AwardedReferrerMetricsPieSplit` for all referrers with 1 or more + * `totalReferrals` within the `rules` as of `accurateAsOf`. + * + * @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 fully-lowercase `Address` is not a key in this map then that `Address` 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; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardPieSplit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} + +export const buildReferrerLeaderboardPieSplit = ( + allReferrers: ReferrerMetrics[], + rules: ReferralProgramRulesPieSplit, + accurateAsOf: UnixTimestamp, +): ReferrerLeaderboardPieSplit => { + assertLeaderboardInputs(allReferrers, rules, accurateAsOf); + + const sortedReferrers = sortReferrerMetrics(allReferrers); + + const scoredReferrers = sortedReferrers.map((r) => buildScoredReferrerMetricsPieSplit(r)); + + const rankedReferrers = scoredReferrers.map((r, index) => + buildRankedReferrerMetricsPieSplit(r, index + 1, rules), + ); + + const aggregatedMetrics = buildAggregatedReferrerMetricsPieSplit(rankedReferrers, rules); + + const awardedReferrers = rankedReferrers.map((r) => + buildAwardedReferrerMetricsPieSplit(r, aggregatedMetrics, rules), + ); + + const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); + + return { rules, aggregatedMetrics, referrers, accurateAsOf }; +}; diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts new file mode 100644 index 000000000..dabbff00d --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -0,0 +1,352 @@ +import type { Address } from "viem"; + +import { type PriceUsdc, priceEth, priceUsdc, scalePrice } from "@ensnode/ensnode-sdk"; +import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import type { ReferrerMetrics } from "../../referrer-metrics"; +import { buildReferrerMetrics, validateReferrerMetrics } from "../../referrer-metrics"; +import type { ReferrerRank } from "../shared/rank"; +import { validateReferrerRank } from "../shared/rank"; +import { type ReferrerScore, validateReferrerScore } from "../shared/score"; +import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; +import { + calcReferrerFinalScoreBoostPieSplit, + calcReferrerFinalScorePieSplit, + isReferrerQualifiedPieSplit, +} from "./rank"; +import type { ReferralProgramRulesPieSplit } from "./rules"; +import { calcReferrerScorePieSplit } from "./score"; + +/** + * Represents metrics for a single referrer independent of other referrers, + * including a calculation of the referrer's score. + */ +export interface ScoredReferrerMetricsPieSplit extends ReferrerMetrics { + /** + * The referrer's score. + * + * @invariant Guaranteed to be `calcReferrerScorePieSplit(totalIncrementalDuration)` + */ + score: ReferrerScore; +} + +export const buildScoredReferrerMetricsPieSplit = ( + referrer: ReferrerMetrics, +): ScoredReferrerMetricsPieSplit => { + const result = { + ...referrer, + score: calcReferrerScorePieSplit(referrer.totalIncrementalDuration), + } satisfies ScoredReferrerMetricsPieSplit; + + validateScoredReferrerMetricsPieSplit(result); + return result; +}; + +export const validateScoredReferrerMetricsPieSplit = ( + metrics: ScoredReferrerMetricsPieSplit, +): void => { + validateReferrerMetrics(metrics); + validateReferrerScore(metrics.score); + + const expectedScore = calcReferrerScorePieSplit(metrics.totalIncrementalDuration); + if (metrics.score !== expectedScore) { + throw new Error(`Referrer: Invalid score: ${metrics.score}, expected: ${expectedScore}.`); + } +}; + +/** + * Extends {@link ScoredReferrerMetrics} to include additional metrics relative to all + * other referrers on a {@link ReferrerLeaderboardPieSplit} and {@link ReferralProgramRulesPieSplit}. + */ +export interface RankedReferrerMetricsPieSplit extends ScoredReferrerMetricsPieSplit { + /** + * The referrer's rank on the {@link ReferrerLeaderboardPieSplit} relative to all other referrers. + */ + rank: ReferrerRank; + + /** + * Identifies if the referrer meets the qualifications of the {@link ReferralProgramRulesPieSplit} to receive a non-zero `awardPoolShare`. + * + * @invariant true if and only if `rank` is less than or equal to {@link ReferralProgramRulesPieSplit.maxQualifiedReferrers} + */ + isQualified: boolean; + + /** + * The referrer's final score boost. + * + * @invariant Guaranteed to be a number between 0 and 1 (inclusive) + * @invariant Calculated as: `1-((rank-1)/({@link ReferralProgramRulesPieSplit.maxQualifiedReferrers}-1))` if `isQualified` is `true`, else `0` + */ + finalScoreBoost: number; + + /** + * The referrer's final score. + * + * @invariant Calculated as: `score * (1 + finalScoreBoost)` + */ + finalScore: ReferrerScore; +} + +export const validateRankedReferrerMetricsPieSplit = ( + metrics: RankedReferrerMetricsPieSplit, + rules: ReferralProgramRulesPieSplit, +): void => { + validateScoredReferrerMetricsPieSplit(metrics); + validateReferrerRank(metrics.rank); + + if (metrics.finalScoreBoost < 0 || metrics.finalScoreBoost > 1) { + throw new Error( + `Invalid RankedReferrerMetricsPieSplit: Invalid finalScoreBoost: ${metrics.finalScoreBoost}. finalScoreBoost must be between 0 and 1 (inclusive).`, + ); + } + + validateReferrerScore(metrics.finalScore); + + const expectedIsQualified = isReferrerQualifiedPieSplit(metrics.rank, rules); + if (metrics.isQualified !== expectedIsQualified) { + throw new Error( + `RankedReferrerMetricsPieSplit: Invalid isQualified: ${metrics.isQualified}, expected: ${expectedIsQualified}.`, + ); + } + + const expectedFinalScoreBoost = calcReferrerFinalScoreBoostPieSplit(metrics.rank, rules); + if (metrics.finalScoreBoost !== expectedFinalScoreBoost) { + throw new Error( + `RankedReferrerMetricsPieSplit: Invalid finalScoreBoost: ${metrics.finalScoreBoost}, expected: ${expectedFinalScoreBoost}.`, + ); + } + + const expectedFinalScore = calcReferrerFinalScorePieSplit( + metrics.rank, + metrics.totalIncrementalDuration, + rules, + ); + if (metrics.finalScore !== expectedFinalScore) { + throw new Error( + `RankedReferrerMetricsPieSplit: Invalid finalScore: ${metrics.finalScore}, expected: ${expectedFinalScore}.`, + ); + } +}; + +export const buildRankedReferrerMetricsPieSplit = ( + referrer: ScoredReferrerMetricsPieSplit, + rank: ReferrerRank, + rules: ReferralProgramRulesPieSplit, +): RankedReferrerMetricsPieSplit => { + const result = { + ...referrer, + rank, + isQualified: isReferrerQualifiedPieSplit(rank, rules), + finalScoreBoost: calcReferrerFinalScoreBoostPieSplit(rank, rules), + finalScore: calcReferrerFinalScorePieSplit(rank, referrer.totalIncrementalDuration, rules), + } satisfies RankedReferrerMetricsPieSplit; + validateRankedReferrerMetricsPieSplit(result, rules); + return result; +}; + +/** + * Calculate the share of the award pool for a referrer. + * @param referrer - The referrer to calculate the award pool share for. + * @param aggregatedMetrics - Aggregated metrics for all referrers. + * @param rules - The rules of the referral program. + * @returns The referrer's share of the award pool as a number between 0 and 1 (inclusive). + */ +export const calcReferrerAwardPoolSharePieSplit = ( + referrer: RankedReferrerMetricsPieSplit, + aggregatedMetrics: AggregatedReferrerMetricsPieSplit, + rules: ReferralProgramRulesPieSplit, +): number => { + if (!isReferrerQualifiedPieSplit(referrer.rank, rules)) return 0; + if (aggregatedMetrics.grandTotalQualifiedReferrersFinalScore === 0) return 0; + + return ( + calcReferrerFinalScorePieSplit(referrer.rank, referrer.totalIncrementalDuration, rules) / + aggregatedMetrics.grandTotalQualifiedReferrersFinalScore + ); +}; + +/** + * Extends {@link RankedReferrerMetricsPieSplit} to include additional metrics + * relative to {@link AggregatedRankedReferrerMetricsPieSplit}. + */ +export interface AwardedReferrerMetricsPieSplit extends RankedReferrerMetricsPieSplit { + /** + * The referrer's share of the award pool. + * + * @invariant Guaranteed to be a number between 0 and 1 (inclusive) + * @invariant Calculated as: `finalScore / {@link AggregatedRankedReferrerMetricsPieSplit.grandTotalQualifiedReferrersFinalScore}` if `isQualified` is `true`, else `0` + */ + awardPoolShare: number; + + /** + * The approximate USDC value of the referrer's share of the {@link ReferralProgramRulesPieSplit.totalAwardPoolValue}. + * + * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRulesPieSplit.totalAwardPoolValue.amount} (inclusive) + * @invariant Calculated as: `awardPoolShare` * {@link ReferralProgramRulesPieSplit.totalAwardPoolValue.amount} + */ + awardPoolApproxValue: PriceUsdc; +} + +export const validateAwardedReferrerMetricsPieSplit = ( + referrer: AwardedReferrerMetricsPieSplit, + rules: ReferralProgramRulesPieSplit, +): void => { + validateRankedReferrerMetricsPieSplit(referrer, rules); + if (referrer.awardPoolShare < 0 || referrer.awardPoolShare > 1) { + throw new Error( + `Invalid AwardedReferrerMetricsPieSplit: ${referrer.awardPoolShare}. awardPoolShare must be between 0 and 1 (inclusive).`, + ); + } + + if ( + referrer.awardPoolApproxValue.amount < 0n || + referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount + ) { + throw new Error( + `Invalid AwardedReferrerMetricsPieSplit: ${referrer.awardPoolApproxValue.amount.toString()}. awardPoolApproxValue must be between 0 and ${rules.totalAwardPoolValue.amount.toString()} (inclusive).`, + ); + } +}; + +export const buildAwardedReferrerMetricsPieSplit = ( + referrer: RankedReferrerMetricsPieSplit, + aggregatedMetrics: AggregatedReferrerMetricsPieSplit, + rules: ReferralProgramRulesPieSplit, +): AwardedReferrerMetricsPieSplit => { + const awardPoolShare = calcReferrerAwardPoolSharePieSplit(referrer, aggregatedMetrics, rules); + + // Calculate the approximate USDC value by multiplying the share by the total award pool value + const awardPoolApproxValue = scalePrice(rules.totalAwardPoolValue, awardPoolShare); + + const result = { + ...referrer, + awardPoolShare, + awardPoolApproxValue, + } satisfies AwardedReferrerMetricsPieSplit; + validateAwardedReferrerMetricsPieSplit(result, rules); + return result; +}; + +/** + * Extends {@link AwardedReferrerMetricsPieSplit} but with rank set to null to represent + * a referrer who is not on the leaderboard (has zero referrals within the rules associated with the leaderboard). + */ +export interface UnrankedReferrerMetricsPieSplit + extends Omit { + /** + * The referrer is not on the leaderboard and therefore has no rank. + */ + rank: null; + + /** + * Always false for unranked referrers. + */ + isQualified: false; +} + +export const validateUnrankedReferrerMetricsPieSplit = ( + metrics: UnrankedReferrerMetricsPieSplit, +): void => { + validateScoredReferrerMetricsPieSplit(metrics); + + if (metrics.rank !== null) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: rank must be null, got: ${metrics.rank}.`, + ); + } + if (metrics.isQualified !== false) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: isQualified must be false, got: ${metrics.isQualified}.`, + ); + } + if (metrics.totalReferrals !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: totalReferrals must be 0, got: ${metrics.totalReferrals}.`, + ); + } + if (metrics.totalIncrementalDuration !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: totalIncrementalDuration must be 0, got: ${metrics.totalIncrementalDuration}.`, + ); + } + + const priceEthSchema = makePriceEthSchema( + "UnrankedReferrerMetricsPieSplit.totalRevenueContribution", + ); + const ethParseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); + if (!ethParseResult.success) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: totalRevenueContribution validation failed: ${ethParseResult.error.message}`, + ); + } + if (metrics.totalRevenueContribution.amount !== 0n) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: totalRevenueContribution.amount must be 0n, got: ${metrics.totalRevenueContribution.amount.toString()}.`, + ); + } + + if (metrics.score !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: score must be 0, got: ${metrics.score}.`, + ); + } + if (metrics.finalScoreBoost !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: finalScoreBoost must be 0, got: ${metrics.finalScoreBoost}.`, + ); + } + if (metrics.finalScore !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: finalScore must be 0, got: ${metrics.finalScore}.`, + ); + } + if (metrics.awardPoolShare !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: awardPoolShare must be 0, got: ${metrics.awardPoolShare}.`, + ); + } + + const priceUsdcSchema = makePriceUsdcSchema( + "UnrankedReferrerMetricsPieSplit.awardPoolApproxValue", + ); + const usdcParseResult = priceUsdcSchema.safeParse(metrics.awardPoolApproxValue); + if (!usdcParseResult.success) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: awardPoolApproxValue validation failed: ${usdcParseResult.error.message}`, + ); + } + if (metrics.awardPoolApproxValue.amount !== 0n) { + throw new Error( + `Invalid UnrankedReferrerMetricsPieSplit: awardPoolApproxValue must be 0n, got: ${metrics.awardPoolApproxValue.amount.toString()}.`, + ); + } +}; + +/** + * Build an unranked zero-score referrer record for a referrer address that is not in the leaderboard. + * + * This is useful when you want to return a referrer record for an address that has no referrals + * and is not qualified for the leaderboard. + * + * @param referrer - The referrer address + * @returns An {@link UnrankedReferrerMetricsPieSplit} with zero values for all metrics and null rank + */ +export const buildUnrankedReferrerMetricsPieSplit = ( + referrer: Address, +): UnrankedReferrerMetricsPieSplit => { + const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); + const scoredMetrics = buildScoredReferrerMetricsPieSplit(metrics); + + const result = { + ...scoredMetrics, + rank: null, + isQualified: false, + finalScoreBoost: 0, + finalScore: 0, + awardPoolShare: 0, + awardPoolApproxValue: priceUsdc(0n), + } satisfies UnrankedReferrerMetricsPieSplit; + + validateUnrankedReferrerMetricsPieSplit(result); + return result; +}; diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts b/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts new file mode 100644 index 000000000..340996b22 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts @@ -0,0 +1,74 @@ +import type { Duration } from "@ensnode/ensnode-sdk"; + +import type { ReferrerRank } from "../shared/rank"; +import type { ReferrerScore } from "../shared/score"; +import type { ReferralProgramRulesPieSplit } from "./rules"; +import { calcReferrerScorePieSplit } from "./score"; + +/** + * Determine if a referrer with the given `rank` is qualified to receive a non-zero + * `awardPoolShare` under pie-split rules. + * + * @param rank - The rank of the referrer relative to all other referrers on a leaderboard. + * @param rules - The pie-split rules of the referral program. + */ +export function isReferrerQualifiedPieSplit( + rank: ReferrerRank, + rules: ReferralProgramRulesPieSplit, +): boolean { + return rank <= rules.maxQualifiedReferrers; +} + +/** + * Calculate the final score boost of a referrer based on their rank (pie-split only). + * + * @param rank - The rank of the referrer relative to all other referrers, where 1 is the + * top-ranked referrer. + * @returns The final score boost of the referrer as a number between 0 and 1 (inclusive). + */ +export function calcReferrerFinalScoreBoostPieSplit( + rank: ReferrerRank, + rules: ReferralProgramRulesPieSplit, +): number { + if (!isReferrerQualifiedPieSplit(rank, rules)) return 0; + + // Avoid division by zero when only a single referrer is qualified. + // In this case, that single referrer (rank 1) should receive the maximum boost. + if (rules.maxQualifiedReferrers === 1) return 1; + + return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1); +} + +/** + * Calculate the final score multiplier of a referrer based on their rank (pie-split only). + * + * @param rank - The rank of the referrer relative to all other referrers, where 1 is the + * top-ranked referrer. + * @returns The final score multiplier of the referrer as a number between 1 and 2 (inclusive). + */ +export function calcReferrerFinalScoreMultiplierPieSplit( + rank: ReferrerRank, + rules: ReferralProgramRulesPieSplit, +): number { + return 1 + calcReferrerFinalScoreBoostPieSplit(rank, rules); +} + +/** + * Calculate the final score of a referrer based on their score and final score boost (pie-split only). + * + * @param rank - The rank of the referrer relative to all other referrers. + * @param totalIncrementalDuration - The total incremental duration (in seconds) + * of referrals made by the referrer within the `rules`. + * @param rules - The pie-split rules of the referral program. + * @returns The final score of the referrer. + */ +export function calcReferrerFinalScorePieSplit( + rank: ReferrerRank, + totalIncrementalDuration: Duration, + rules: ReferralProgramRulesPieSplit, +): ReferrerScore { + return ( + calcReferrerScorePieSplit(totalIncrementalDuration) * + calcReferrerFinalScoreMultiplierPieSplit(rank, rules) + ); +} diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts new file mode 100644 index 000000000..4f42572a1 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts @@ -0,0 +1,87 @@ +import type { AccountId, PriceUsdc, UnixTimestamp } from "@ensnode/ensnode-sdk"; +import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import { validateNonNegativeInteger } from "../../number"; +import { validateUnixTimestamp } from "../../time"; +import { type BaseReferralProgramRules, ReferralProgramAwardModels } from "../shared/rules"; + +export interface ReferralProgramRulesPieSplit extends BaseReferralProgramRules { + /** + * Discriminant: identifies this as a "pie-split" award model edition. + * + * In pie-split, the top-N referrers split an award pool proportionally + * based on their scored duration (with rank-based boost). + */ + awardModel: typeof ReferralProgramAwardModels.PieSplit; + + /** + * The total value of the award pool in USDC. + * + * NOTE: Awards will actually be distributed in $ENS tokens. + */ + totalAwardPoolValue: PriceUsdc; + + /** + * The maximum number of referrers that will qualify to receive a non-zero `awardPoolShare`. + * + * @invariant Guaranteed to be a non-negative integer (>= 0) + */ + maxQualifiedReferrers: number; +} + +export const validateReferralProgramRulesPieSplit = (rules: ReferralProgramRulesPieSplit): void => { + const priceUsdcSchema = makePriceUsdcSchema("ReferralProgramRulesPieSplit.totalAwardPoolValue"); + const parseResult = priceUsdcSchema.safeParse(rules.totalAwardPoolValue); + if (!parseResult.success) { + throw new Error( + `ReferralProgramRulesPieSplit: totalAwardPoolValue validation failed: ${parseResult.error.message}`, + ); + } + + const accountIdSchema = makeAccountIdSchema("ReferralProgramRulesPieSplit.subregistryId"); + const accountIdParseResult = accountIdSchema.safeParse(rules.subregistryId); + if (!accountIdParseResult.success) { + throw new Error( + `ReferralProgramRulesPieSplit: subregistryId validation failed: ${accountIdParseResult.error.message}`, + ); + } + + validateNonNegativeInteger(rules.maxQualifiedReferrers); + validateUnixTimestamp(rules.startTime); + validateUnixTimestamp(rules.endTime); + + if (!(rules.rulesUrl instanceof URL)) { + throw new Error( + `ReferralProgramRulesPieSplit: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, + ); + } + + if (rules.endTime < rules.startTime) { + throw new Error( + `ReferralProgramRulesPieSplit: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, + ); + } +}; + +export const buildReferralProgramRulesPieSplit = ( + totalAwardPoolValue: PriceUsdc, + maxQualifiedReferrers: number, + startTime: UnixTimestamp, + endTime: UnixTimestamp, + subregistryId: AccountId, + rulesUrl: URL, +): ReferralProgramRulesPieSplit => { + const result = { + awardModel: ReferralProgramAwardModels.PieSplit, + totalAwardPoolValue, + maxQualifiedReferrers, + startTime, + endTime, + subregistryId, + rulesUrl, + } satisfies ReferralProgramRulesPieSplit; + + validateReferralProgramRulesPieSplit(result); + + return result; +}; diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/score.ts b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts new file mode 100644 index 000000000..20f22b612 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts @@ -0,0 +1,19 @@ +import type { Duration } from "@ensnode/ensnode-sdk"; + +import { SECONDS_PER_YEAR } from "../../time"; +import type { ReferrerScore } from "../shared/score"; + +/** + * Calculate the score of a referrer based on the total incremental duration + * (in seconds) of registrations and renewals for direct subnames of .eth + * referred by the referrer within the ENS Holiday Awards period. + * + * Used exclusively in the pie-split award model pipeline. + * + * @param totalIncrementalDuration - The total incremental duration (in seconds) + * of referrals made by a referrer within the {@link ReferralProgramRulesPieSplit}. + * @returns The score of the referrer. + */ +export const calcReferrerScorePieSplit = (totalIncrementalDuration: Duration): ReferrerScore => { + return totalIncrementalDuration / SECONDS_PER_YEAR; +}; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts new file mode 100644 index 000000000..ac47aa56b --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts @@ -0,0 +1,139 @@ +import { + type Duration, + type PriceEth, + type PriceUsdc, + priceEth, + priceUsdc, + scalePrice, +} from "@ensnode/ensnode-sdk"; +import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import { validateNonNegativeInteger } from "../../number"; +import { validateDuration } from "../../time"; +import type { RankedReferrerMetricsRevShareLimit } from "./metrics"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * Represents aggregated metrics for a list of {@link RankedReferrerMetricsRevShareLimit}. + */ +export interface AggregatedReferrerMetricsRevShareLimit { + /** + * @invariant The sum of `totalReferrals` across all {@link RankedReferrerMetricsRevShareLimit} in the list. + * @invariant Guaranteed to be a non-negative integer (>= 0) + */ + grandTotalReferrals: number; + + /** + * @invariant The sum of `totalIncrementalDuration` across all {@link RankedReferrerMetricsRevShareLimit} in the list. + */ + grandTotalIncrementalDuration: Duration; + + /** + * The total revenue contribution in ETH to the ENS DAO from all referrals + * across all referrers on the leaderboard. + * + * This is the sum of `totalRevenueContribution` across all {@link RankedReferrerMetricsRevShareLimit} in the list. + * + * @invariant Guaranteed to be a valid PriceEth with non-negative amount (>= 0n) + */ + grandTotalRevenueContribution: PriceEth; + + /** + * The remaining amount in the award pool after subtracting the total potential awards + * (capped at 0 if total potential awards exceed the pool). + * + * @invariant Guaranteed to be a valid PriceUsdc with non-negative amount (>= 0n) + */ + awardPoolRemaining: PriceUsdc; +} + +export const validateAggregatedReferrerMetricsRevShareLimit = ( + metrics: AggregatedReferrerMetricsRevShareLimit, +): void => { + validateNonNegativeInteger(metrics.grandTotalReferrals); + validateDuration(metrics.grandTotalIncrementalDuration); + + const priceEthSchema = makePriceEthSchema( + "AggregatedReferrerMetricsRevShareLimit.grandTotalRevenueContribution", + ); + const parseResultEth = priceEthSchema.safeParse(metrics.grandTotalRevenueContribution); + if (!parseResultEth.success) { + throw new Error( + `AggregatedReferrerMetricsRevShareLimit: grandTotalRevenueContribution validation failed: ${parseResultEth.error.message}`, + ); + } + + const priceUsdcSchema = makePriceUsdcSchema( + "AggregatedReferrerMetricsRevShareLimit.awardPoolRemaining", + ); + const parseResultUsdc = priceUsdcSchema.safeParse(metrics.awardPoolRemaining); + if (!parseResultUsdc.success) { + throw new Error( + `AggregatedReferrerMetricsRevShareLimit: awardPoolRemaining validation failed: ${parseResultUsdc.error.message}`, + ); + } +}; + +/** + * Builds aggregated rev-share-limit metrics from a complete, globally ranked list of referrers. + * + * **IMPORTANT: This function expects a complete ranking of all referrers.** + * + * @param referrers - Must be a complete, globally ranked list of {@link RankedReferrerMetricsRevShareLimit} + * where ranks start at 1 and are consecutive. + * **This must NOT be a paginated or partial slice of the rankings.** + * + * @param rules - The {@link ReferralProgramRulesRevShareLimit} object that define qualification criteria. + * + * @returns Aggregated metrics including totals across all referrers and the award pool remaining. + * + * @remarks + * - If you need to work with paginated data, aggregate the full ranking first before + * calling this function, or call this function on the complete dataset and then paginate + * the results. + */ +export const buildAggregatedReferrerMetricsRevShareLimit = ( + referrers: RankedReferrerMetricsRevShareLimit[], + rules: ReferralProgramRulesRevShareLimit, +): { aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; scalingFactor: number } => { + let grandTotalReferrals = 0; + let grandTotalIncrementalDuration = 0; + let grandTotalRevenueContributionAmount = 0n; + let totalPotentialAwardsAmount = 0n; + + for (const referrer of referrers) { + grandTotalReferrals += referrer.totalReferrals; + grandTotalIncrementalDuration += referrer.totalIncrementalDuration; + grandTotalRevenueContributionAmount += referrer.totalRevenueContribution.amount; + if (referrer.isQualified) { + const potentialAward = scalePrice( + referrer.totalBaseRevenueContribution, + rules.qualifiedRevenueShare, + ); + totalPotentialAwardsAmount += potentialAward.amount; + } + } + + const scalingFactor = + totalPotentialAwardsAmount > 0n + ? Math.min(1, Number(rules.totalAwardPoolValue.amount) / Number(totalPotentialAwardsAmount)) + : 1; + + const cappedTotalPotentialAwards = + totalPotentialAwardsAmount < rules.totalAwardPoolValue.amount + ? totalPotentialAwardsAmount + : rules.totalAwardPoolValue.amount; + + const awardPoolRemainingAmount = rules.totalAwardPoolValue.amount - cappedTotalPotentialAwards; + + const aggregatedMetrics = { + grandTotalReferrals, + grandTotalIncrementalDuration, + grandTotalRevenueContribution: priceEth(grandTotalRevenueContributionAmount), + awardPoolRemaining: priceUsdc(awardPoolRemainingAmount), + } satisfies AggregatedReferrerMetricsRevShareLimit; + + validateAggregatedReferrerMetricsRevShareLimit(aggregatedMetrics); + + return { aggregatedMetrics, scalingFactor }; +}; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts new file mode 100644 index 000000000..ca3364032 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts @@ -0,0 +1,138 @@ +import { serializePriceEth, serializePriceUsdc } from "@ensnode/ensnode-sdk"; + +import type { AggregatedReferrerMetricsRevShareLimit } from "../aggregations"; +import type { + ReferrerEditionMetricsRankedRevShareLimit, + ReferrerEditionMetricsUnrankedRevShareLimit, +} from "../edition-metrics"; +import type { ReferrerLeaderboardPageRevShareLimit } from "../leaderboard-page"; +import type { + AwardedReferrerMetricsRevShareLimit, + UnrankedReferrerMetricsRevShareLimit, +} from "../metrics"; +import type { ReferralProgramRulesRevShareLimit } from "../rules"; +import type { + SerializedAggregatedReferrerMetricsRevShareLimit, + SerializedAwardedReferrerMetricsRevShareLimit, + SerializedReferralProgramRulesRevShareLimit, + SerializedReferrerEditionMetricsRankedRevShareLimit, + SerializedReferrerEditionMetricsUnrankedRevShareLimit, + SerializedReferrerLeaderboardPageRevShareLimit, + SerializedUnrankedReferrerMetricsRevShareLimit, +} from "./serialized-types"; + +/** + * Serializes a {@link ReferralProgramRulesRevShareLimit} object. + */ +export function serializeReferralProgramRulesRevShareLimit( + rules: ReferralProgramRulesRevShareLimit, +): SerializedReferralProgramRulesRevShareLimit { + return { + awardModel: rules.awardModel, + totalAwardPoolValue: serializePriceUsdc(rules.totalAwardPoolValue), + minQualifiedRevenueContribution: serializePriceUsdc(rules.minQualifiedRevenueContribution), + qualifiedRevenueShare: rules.qualifiedRevenueShare, + startTime: rules.startTime, + endTime: rules.endTime, + subregistryId: rules.subregistryId, + rulesUrl: rules.rulesUrl.toString(), + }; +} + +/** + * Serializes a {@link AggregatedReferrerMetricsRevShareLimit} object. + */ +export function serializeAggregatedReferrerMetricsRevShareLimit( + metrics: AggregatedReferrerMetricsRevShareLimit, +): SerializedAggregatedReferrerMetricsRevShareLimit { + return { + grandTotalReferrals: metrics.grandTotalReferrals, + grandTotalIncrementalDuration: metrics.grandTotalIncrementalDuration, + grandTotalRevenueContribution: serializePriceEth(metrics.grandTotalRevenueContribution), + awardPoolRemaining: serializePriceUsdc(metrics.awardPoolRemaining), + }; +} + +/** + * Serializes a {@link AwardedReferrerMetricsRevShareLimit} object. + */ +export function serializeAwardedReferrerMetricsRevShareLimit( + metrics: AwardedReferrerMetricsRevShareLimit, +): SerializedAwardedReferrerMetricsRevShareLimit { + return { + referrer: metrics.referrer, + totalReferrals: metrics.totalReferrals, + totalIncrementalDuration: metrics.totalIncrementalDuration, + totalRevenueContribution: serializePriceEth(metrics.totalRevenueContribution), + totalBaseRevenueContribution: serializePriceUsdc(metrics.totalBaseRevenueContribution), + rank: metrics.rank, + isQualified: metrics.isQualified, + awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + }; +} + +/** + * Serializes a {@link UnrankedReferrerMetricsRevShareLimit} object. + */ +export function serializeUnrankedReferrerMetricsRevShareLimit( + metrics: UnrankedReferrerMetricsRevShareLimit, +): SerializedUnrankedReferrerMetricsRevShareLimit { + return { + referrer: metrics.referrer, + totalReferrals: metrics.totalReferrals, + totalIncrementalDuration: metrics.totalIncrementalDuration, + totalRevenueContribution: serializePriceEth(metrics.totalRevenueContribution), + totalBaseRevenueContribution: serializePriceUsdc(metrics.totalBaseRevenueContribution), + rank: metrics.rank, + isQualified: metrics.isQualified, + awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + }; +} + +/** + * Serializes a {@link ReferrerEditionMetricsRankedRevShareLimit} object. + */ +export function serializeReferrerEditionMetricsRankedRevShareLimit( + detail: ReferrerEditionMetricsRankedRevShareLimit, +): SerializedReferrerEditionMetricsRankedRevShareLimit { + return { + type: detail.type, + rules: serializeReferralProgramRulesRevShareLimit(detail.rules), + referrer: serializeAwardedReferrerMetricsRevShareLimit(detail.referrer), + aggregatedMetrics: serializeAggregatedReferrerMetricsRevShareLimit(detail.aggregatedMetrics), + status: detail.status, + accurateAsOf: detail.accurateAsOf, + }; +} + +/** + * Serializes a {@link ReferrerEditionMetricsUnrankedRevShareLimit} object. + */ +export function serializeReferrerEditionMetricsUnrankedRevShareLimit( + detail: ReferrerEditionMetricsUnrankedRevShareLimit, +): SerializedReferrerEditionMetricsUnrankedRevShareLimit { + return { + type: detail.type, + rules: serializeReferralProgramRulesRevShareLimit(detail.rules), + referrer: serializeUnrankedReferrerMetricsRevShareLimit(detail.referrer), + aggregatedMetrics: serializeAggregatedReferrerMetricsRevShareLimit(detail.aggregatedMetrics), + status: detail.status, + accurateAsOf: detail.accurateAsOf, + }; +} + +/** + * Serializes a {@link ReferrerLeaderboardPageRevShareLimit} object. + */ +export function serializeReferrerLeaderboardPageRevShareLimit( + page: ReferrerLeaderboardPageRevShareLimit, +): SerializedReferrerLeaderboardPageRevShareLimit { + return { + rules: serializeReferralProgramRulesRevShareLimit(page.rules), + referrers: page.referrers.map(serializeAwardedReferrerMetricsRevShareLimit), + aggregatedMetrics: serializeAggregatedReferrerMetricsRevShareLimit(page.aggregatedMetrics), + pageContext: page.pageContext, + status: page.status, + accurateAsOf: page.accurateAsOf, + }; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts new file mode 100644 index 000000000..4f5f6f280 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts @@ -0,0 +1,100 @@ +import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; + +import type { AggregatedReferrerMetricsRevShareLimit } from "../aggregations"; +import type { + ReferrerEditionMetricsRankedRevShareLimit, + ReferrerEditionMetricsUnrankedRevShareLimit, +} from "../edition-metrics"; +import type { ReferrerLeaderboardPageRevShareLimit } from "../leaderboard-page"; +import type { + AwardedReferrerMetricsRevShareLimit, + UnrankedReferrerMetricsRevShareLimit, +} from "../metrics"; +import type { ReferralProgramRulesRevShareLimit } from "../rules"; + +/** + * Serialized representation of {@link ReferralProgramRulesRevShareLimit}. + */ +export interface SerializedReferralProgramRulesRevShareLimit + extends Omit< + ReferralProgramRulesRevShareLimit, + "totalAwardPoolValue" | "minQualifiedRevenueContribution" | "rulesUrl" + > { + totalAwardPoolValue: SerializedPriceUsdc; + minQualifiedRevenueContribution: SerializedPriceUsdc; + rulesUrl: string; +} + +/** + * Serialized representation of {@link AggregatedReferrerMetricsRevShareLimit}. + */ +export interface SerializedAggregatedReferrerMetricsRevShareLimit + extends Omit< + AggregatedReferrerMetricsRevShareLimit, + "grandTotalRevenueContribution" | "awardPoolRemaining" + > { + grandTotalRevenueContribution: SerializedPriceEth; + awardPoolRemaining: SerializedPriceUsdc; +} + +/** + * Serialized representation of {@link AwardedReferrerMetricsRevShareLimit}. + */ +export interface SerializedAwardedReferrerMetricsRevShareLimit + extends Omit< + AwardedReferrerMetricsRevShareLimit, + "totalRevenueContribution" | "totalBaseRevenueContribution" | "awardPoolApproxValue" + > { + totalRevenueContribution: SerializedPriceEth; + totalBaseRevenueContribution: SerializedPriceUsdc; + awardPoolApproxValue: SerializedPriceUsdc; +} + +/** + * Serialized representation of {@link UnrankedReferrerMetricsRevShareLimit}. + */ +export interface SerializedUnrankedReferrerMetricsRevShareLimit + extends Omit< + UnrankedReferrerMetricsRevShareLimit, + "totalRevenueContribution" | "totalBaseRevenueContribution" | "awardPoolApproxValue" + > { + totalRevenueContribution: SerializedPriceEth; + totalBaseRevenueContribution: SerializedPriceUsdc; + awardPoolApproxValue: SerializedPriceUsdc; +} + +/** + * Serialized representation of {@link ReferrerLeaderboardPageRevShareLimit}. + */ +export interface SerializedReferrerLeaderboardPageRevShareLimit + extends Omit { + rules: SerializedReferralProgramRulesRevShareLimit; + referrers: SerializedAwardedReferrerMetricsRevShareLimit[]; + aggregatedMetrics: SerializedAggregatedReferrerMetricsRevShareLimit; +} + +/** + * Serialized representation of {@link ReferrerEditionMetricsRankedRevShareLimit}. + */ +export interface SerializedReferrerEditionMetricsRankedRevShareLimit + extends Omit< + ReferrerEditionMetricsRankedRevShareLimit, + "rules" | "referrer" | "aggregatedMetrics" + > { + rules: SerializedReferralProgramRulesRevShareLimit; + referrer: SerializedAwardedReferrerMetricsRevShareLimit; + aggregatedMetrics: SerializedAggregatedReferrerMetricsRevShareLimit; +} + +/** + * Serialized representation of {@link ReferrerEditionMetricsUnrankedRevShareLimit}. + */ +export interface SerializedReferrerEditionMetricsUnrankedRevShareLimit + extends Omit< + ReferrerEditionMetricsUnrankedRevShareLimit, + "rules" | "referrer" | "aggregatedMetrics" + > { + rules: SerializedReferralProgramRulesRevShareLimit; + referrer: SerializedUnrankedReferrerMetricsRevShareLimit; + aggregatedMetrics: SerializedAggregatedReferrerMetricsRevShareLimit; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts new file mode 100644 index 000000000..197a633b2 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -0,0 +1,150 @@ +import z from "zod/v4"; + +import { + makeAccountIdSchema, + makeDurationSchema, + makeFiniteNonNegativeNumberSchema, + makeLowercaseAddressSchema, + makeNonNegativeIntegerSchema, + makePositiveIntegerSchema, + makePriceEthSchema, + makePriceUsdcSchema, + makeUnixTimestampSchema, + makeUrlSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { + makeReferralProgramStatusSchema, + makeReferrerLeaderboardPageContextSchema, +} from "../../shared/api/zod-schemas"; +import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; + +/** + * Schema for {@link ReferralProgramRulesRevShareLimit}. + */ +export const makeReferralProgramRulesRevShareLimitSchema = ( + valueLabel: string = "ReferralProgramRulesRevShareLimit", +) => + z + .object({ + awardModel: z.literal("rev-share-limit"), + totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), + minQualifiedRevenueContribution: makePriceUsdcSchema( + `${valueLabel}.minQualifiedRevenueContribution`, + ), + qualifiedRevenueShare: makeFiniteNonNegativeNumberSchema( + `${valueLabel}.qualifiedRevenueShare`, + ).max(1, `${valueLabel}.qualifiedRevenueShare must be <= 1`), + startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), + endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), + subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), + rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), + }) + .refine((data) => data.endTime >= data.startTime, { + message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, + path: ["endTime"], + }); + +/** + * Schema for {@link AwardedReferrerMetricsRevShareLimit} (with numeric rank). + */ +export const makeAwardedReferrerMetricsRevShareLimitSchema = ( + valueLabel: string = "AwardedReferrerMetricsRevShareLimit", +) => + z.object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), + totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), + totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), + totalBaseRevenueContribution: makePriceUsdcSchema(`${valueLabel}.totalBaseRevenueContribution`), + rank: makePositiveIntegerSchema(`${valueLabel}.rank`), + isQualified: z.boolean(), + awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + }); + +/** + * Schema for {@link UnrankedReferrerMetricsRevShareLimit} (with null rank). + */ +export const makeUnrankedReferrerMetricsRevShareLimitSchema = ( + valueLabel: string = "UnrankedReferrerMetricsRevShareLimit", +) => + z.object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), + totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), + totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), + totalBaseRevenueContribution: makePriceUsdcSchema(`${valueLabel}.totalBaseRevenueContribution`), + rank: z.null(), + isQualified: z.literal(false), + awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + }); + +/** + * Schema for {@link AggregatedReferrerMetricsRevShareLimit}. + */ +export const makeAggregatedReferrerMetricsRevShareLimitSchema = ( + valueLabel: string = "AggregatedReferrerMetricsRevShareLimit", +) => + z.object({ + grandTotalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.grandTotalReferrals`), + grandTotalIncrementalDuration: makeDurationSchema( + `${valueLabel}.grandTotalIncrementalDuration`, + ), + grandTotalRevenueContribution: makePriceEthSchema( + `${valueLabel}.grandTotalRevenueContribution`, + ), + awardPoolRemaining: makePriceUsdcSchema(`${valueLabel}.awardPoolRemaining`), + }); + +/** + * Schema for {@link ReferrerEditionMetricsRankedRevShareLimit}. + */ +export const makeReferrerEditionMetricsRankedRevShareLimitSchema = ( + valueLabel: string = "ReferrerEditionMetricsRankedRevShareLimit", +) => + z.object({ + type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + referrer: makeAwardedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }); + +/** + * Schema for {@link ReferrerEditionMetricsUnrankedRevShareLimit}. + */ +export const makeReferrerEditionMetricsUnrankedRevShareLimitSchema = ( + valueLabel: string = "ReferrerEditionMetricsUnrankedRevShareLimit", +) => + z.object({ + type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + referrer: makeUnrankedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }); + +/** + * Schema for {@link ReferrerLeaderboardPageRevShareLimit}. + */ +export const makeReferrerLeaderboardPageRevShareLimitSchema = ( + valueLabel: string = "ReferrerLeaderboardPageRevShareLimit", +) => + z.object({ + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + referrers: z.array( + makeAwardedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrers[record]`), + ), + aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts new file mode 100644 index 000000000..beccc23aa --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts @@ -0,0 +1,101 @@ +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { ReferralProgramStatusId } from "../../status"; +import type { ReferrerEditionMetricsTypeIds } from "../shared/edition-metrics"; +import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; +import type { + AwardedReferrerMetricsRevShareLimit, + UnrankedReferrerMetricsRevShareLimit, +} from "./metrics"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * Referrer edition metrics data for a specific referrer on a rev-share-limit leaderboard. + * + * Includes the referrer's awarded metrics from the leaderboard plus timestamp. + * + * Invariants: + * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. + * + * @see {@link AwardedReferrerMetricsRevShareLimit} + */ +export interface ReferrerEditionMetricsRankedRevShareLimit { + /** + * The type of referrer edition metrics data. + */ + type: typeof ReferrerEditionMetricsTypeIds.Ranked; + + /** + * The {@link ReferralProgramRulesRevShareLimit} used to calculate the {@link AwardedReferrerMetricsRevShareLimit}. + */ + rules: ReferralProgramRulesRevShareLimit; + + /** + * The awarded referrer metrics from the leaderboard. + * + * Contains all calculated metrics including score, rank, qualification status, + * and award pool share information. + */ + referrer: AwardedReferrerMetricsRevShareLimit; + + /** + * Aggregated metrics for all referrers on the leaderboard. + */ + aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; + + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRankedRevShareLimit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} + +/** + * Referrer edition metrics data for a specific referrer address NOT on the rev-share-limit leaderboard. + * + * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. + * + * Invariants: + * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. + * + * @see {@link UnrankedReferrerMetricsRevShareLimit} + */ +export interface ReferrerEditionMetricsUnrankedRevShareLimit { + /** + * The type of referrer edition metrics data. + */ + type: typeof ReferrerEditionMetricsTypeIds.Unranked; + + /** + * The {@link ReferralProgramRulesRevShareLimit} used to calculate the {@link UnrankedReferrerMetricsRevShareLimit}. + */ + rules: ReferralProgramRulesRevShareLimit; + + /** + * The unranked referrer metrics (not on the leaderboard). + * + * Contains all calculated metrics with rank set to null and isQualified set to false. + */ + referrer: UnrankedReferrerMetricsRevShareLimit; + + /** + * Aggregated metrics for all referrers on the leaderboard. + */ + aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; + + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnrankedRevShareLimit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts new file mode 100644 index 000000000..4d701f1e1 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts @@ -0,0 +1,50 @@ +import { calcReferralProgramStatus } from "../../status"; +import { + type BaseReferrerLeaderboardPage, + type ReferrerLeaderboardPageContext, + sliceReferrers, +} from "../shared/leaderboard-page"; +import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; +import type { ReferrerLeaderboardRevShareLimit } from "./leaderboard"; +import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * A page of referrers from the rev-share-limit referrer leaderboard. + */ +export interface ReferrerLeaderboardPageRevShareLimit extends BaseReferrerLeaderboardPage { + /** + * The {@link ReferralProgramRulesRevShareLimit} used to generate the {@link ReferrerLeaderboard} + * that this {@link ReferrerLeaderboardPageRevShareLimit} comes from. + */ + rules: ReferralProgramRulesRevShareLimit; + + /** + * Ordered list of {@link AwardedReferrerMetricsRevShareLimit} for the {@link ReferrerLeaderboardPageRevShareLimit} + * described by {@link pageContext} within the related {@link ReferrerLeaderboard}. + * + * @invariant Array will be empty if `pageContext.totalRecords` is 0. + * @invariant Array entries are ordered by `rank` (ascending). + */ + referrers: AwardedReferrerMetricsRevShareLimit[]; + + /** + * The aggregated metrics for all referrers on the leaderboard. + */ + aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; +} + +export function buildLeaderboardPageRevShareLimit( + pageContext: ReferrerLeaderboardPageContext, + leaderboard: ReferrerLeaderboardRevShareLimit, +): ReferrerLeaderboardPageRevShareLimit { + const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); + return { + rules: leaderboard.rules, + referrers: sliceReferrers(leaderboard.referrers, pageContext), + aggregatedMetrics: leaderboard.aggregatedMetrics, + pageContext, + status, + accurateAsOf: leaderboard.accurateAsOf, + }; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts new file mode 100644 index 000000000..c3be47839 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -0,0 +1,80 @@ +import type { Address } from "viem"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { ReferrerMetrics } from "../../referrer-metrics"; +import { assertLeaderboardInputs } from "../shared/leaderboard-guards"; +import { sortReferrerMetrics } from "../shared/rank"; +import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; +import { buildAggregatedReferrerMetricsRevShareLimit } from "./aggregations"; +import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; +import { + buildAwardedReferrerMetricsRevShareLimit, + buildRankedReferrerMetricsRevShareLimit, + buildReferrerMetricsRevShareLimit, +} from "./metrics"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * Represents a leaderboard with the rev-share-limit award model for any number of referrers. + */ +export interface ReferrerLeaderboardRevShareLimit { + /** + * The rules of the referral program that generated the {@link ReferrerLeaderboardRevShareLimit}. + */ + rules: ReferralProgramRulesRevShareLimit; + + /** + * The {@link AggregatedReferrerMetricsRevShareLimit} for all {@link RankedReferrerMetricsRevShareLimit} values in `referrers`. + */ + aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; + + /** + * Ordered map containing `AwardedReferrerMetricsPieSplit` for all referrers with 1 or more + * `totalReferrals` within the `rules` as of `accurateAsOf`. + * + * @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 fully-lowercase `Address` is not a key in this map then that `Address` 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; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardRevShareLimit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} + +export const buildReferrerLeaderboardRevShareLimit = ( + allReferrers: ReferrerMetrics[], + rules: ReferralProgramRulesRevShareLimit, + accurateAsOf: UnixTimestamp, +): ReferrerLeaderboardRevShareLimit => { + assertLeaderboardInputs(allReferrers, rules, accurateAsOf); + + const sortedReferrers = sortReferrerMetrics(allReferrers); + + const revShareMetrics = sortedReferrers.map((r) => buildReferrerMetricsRevShareLimit(r)); + + const rankedReferrers = revShareMetrics.map((r, index) => + buildRankedReferrerMetricsRevShareLimit(r, index + 1, rules), + ); + + const { aggregatedMetrics, scalingFactor } = buildAggregatedReferrerMetricsRevShareLimit( + rankedReferrers, + rules, + ); + + const awardedReferrers = rankedReferrers.map((r) => + buildAwardedReferrerMetricsRevShareLimit(r, rules, scalingFactor), + ); + + const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); + + return { rules, aggregatedMetrics, referrers, accurateAsOf }; +}; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts new file mode 100644 index 000000000..2ef2a9bad --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -0,0 +1,133 @@ +import type { Address } from "viem"; + +import { type PriceUsdc, priceEth, priceUsdc, scalePrice } from "@ensnode/ensnode-sdk"; + +import type { ReferrerMetrics } from "../../referrer-metrics"; +import { buildReferrerMetrics } from "../../referrer-metrics"; +import { SECONDS_PER_YEAR } from "../../time"; +import type { ReferrerRank } from "../shared/rank"; +import { + BASE_REVENUE_CONTRIBUTION_PER_YEAR, + isReferrerQualifiedRevShareLimit, + type ReferralProgramRulesRevShareLimit, +} from "./rules"; + +/** + * Extends {@link ReferrerMetrics} with computed base revenue contribution. + */ +export interface ReferrerMetricsRevShareLimit extends ReferrerMetrics { + /** + * The referrer's base revenue contribution (base-fee-only: $5 × years of incremental duration). + * Used for qualification and award calculation in the rev-share-limit model. + * + * @invariant Guaranteed to be `priceUsdc(BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(totalIncrementalDuration) / BigInt(SECONDS_PER_YEAR))` + */ + totalBaseRevenueContribution: PriceUsdc; +} + +export const buildReferrerMetricsRevShareLimit = ( + metrics: ReferrerMetrics, +): ReferrerMetricsRevShareLimit => { + const totalBaseRevenueContribution = priceUsdc( + (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(metrics.totalIncrementalDuration)) / + BigInt(SECONDS_PER_YEAR), + ); + + return { + ...metrics, + totalBaseRevenueContribution, + } satisfies ReferrerMetricsRevShareLimit; +}; + +/** + * Extends {@link ReferrerMetricsRevShareLimit} with rank and qualification status. + */ +export interface RankedReferrerMetricsRevShareLimit extends ReferrerMetricsRevShareLimit { + /** + * The referrer's rank on the {@link ReferrerLeaderboardRevShareLimit} relative to all other referrers. + */ + rank: ReferrerRank; + + /** + * Identifies if the referrer meets the qualifications of the {@link ReferralProgramRulesRevShareLimit} to receive a non-zero `awardPoolShare`. + * + * @invariant true if and only if `totalBaseRevenueContribution` is greater than or equal to {@link ReferralProgramRulesRevShareLimit.minQualifiedRevenueContribution} + */ + isQualified: boolean; +} + +export const buildRankedReferrerMetricsRevShareLimit = ( + referrer: ReferrerMetricsRevShareLimit, + rank: ReferrerRank, + rules: ReferralProgramRulesRevShareLimit, +): RankedReferrerMetricsRevShareLimit => { + return { + ...referrer, + rank, + isQualified: isReferrerQualifiedRevShareLimit(referrer.totalBaseRevenueContribution, rules), + } satisfies RankedReferrerMetricsRevShareLimit; +}; + +/** + * Extends {@link RankedReferrerMetricsRevShareLimit} with approximate award value. + */ +export interface AwardedReferrerMetricsRevShareLimit extends RankedReferrerMetricsRevShareLimit { + /** + * The approximate USDC value of the referrer's award. + * + * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRulesRevShareLimit.totalAwardPoolValue.amount} (inclusive) + */ + awardPoolApproxValue: PriceUsdc; +} + +export const buildAwardedReferrerMetricsRevShareLimit = ( + referrer: RankedReferrerMetricsRevShareLimit, + rules: ReferralProgramRulesRevShareLimit, + scalingFactor: number, +): AwardedReferrerMetricsRevShareLimit => { + const awardPoolApproxValue = referrer.isQualified + ? scalePrice( + scalePrice(referrer.totalBaseRevenueContribution, rules.qualifiedRevenueShare), + scalingFactor, + ) + : priceUsdc(0n); + + return { + ...referrer, + awardPoolApproxValue, + } satisfies AwardedReferrerMetricsRevShareLimit; +}; + +/** + * Extends {@link AwardedReferrerMetricsRevShareLimit} but with rank set to null to represent + * a referrer who is not on the leaderboard (has zero referrals within the rules associated with the leaderboard). + */ +export interface UnrankedReferrerMetricsRevShareLimit + extends Omit { + /** + * The referrer is not on the leaderboard and therefore has no rank. + */ + rank: null; + + /** + * Always false for unranked referrers. + */ + isQualified: false; +} + +/** + * Build an unranked zero-score rev-share-limit referrer record for an address not on the leaderboard. + */ +export const buildUnrankedReferrerMetricsRevShareLimit = ( + referrer: Address, +): UnrankedReferrerMetricsRevShareLimit => { + const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); + + return { + ...metrics, + totalBaseRevenueContribution: priceUsdc(0n), + rank: null, + isQualified: false, + awardPoolApproxValue: priceUsdc(0n), + } satisfies UnrankedReferrerMetricsRevShareLimit; +}; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts new file mode 100644 index 000000000..fb2ad507d --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts @@ -0,0 +1,16 @@ +import type { PriceUsdc } from "@ensnode/ensnode-sdk"; + +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * Determine if a referrer meets the revenue threshold to qualify under rev-share-limit rules. + * + * @param totalBaseRevenueContribution - The referrer's total base revenue contribution. + * @param rules - The rev-share-limit rules of the referral program. + */ +export function isReferrerQualifiedRevShareLimit( + totalBaseRevenueContribution: PriceUsdc, + rules: ReferralProgramRulesRevShareLimit, +): boolean { + return totalBaseRevenueContribution.amount >= rules.minQualifiedRevenueContribution.amount; +} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts new file mode 100644 index 000000000..44c97ff33 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts @@ -0,0 +1,137 @@ +import { + type AccountId, + type PriceUsdc, + parseUsdc, + type UnixTimestamp, +} from "@ensnode/ensnode-sdk"; +import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import { validateUnixTimestamp } from "../../time"; +import { type BaseReferralProgramRules, ReferralProgramAwardModels } from "../shared/rules"; + +/** + * Base revenue contribution per year of incremental duration. + * + * Used in `rev-share-limit` qualification and award calculations: + * 1 year of incremental duration = $5 in base revenue (base-fee-only, excluding premiums). + */ +export const BASE_REVENUE_CONTRIBUTION_PER_YEAR: PriceUsdc = parseUsdc("5"); + +export interface ReferralProgramRulesRevShareLimit extends BaseReferralProgramRules { + /** + * Discriminant: identifies this as a "rev-share-limit" award model edition. + * + * In rev-share-limit, each qualified referrer receives a share of their base revenue + * contribution (base-fee-only: $5 × years of incremental duration), subject to a + * pool cap and a minimum qualification threshold. + */ + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + + /** + * The total value of the award pool in USDC (acts as a cap on total payouts). + * + * NOTE: Awards will actually be distributed in $ENS tokens. + */ + totalAwardPoolValue: PriceUsdc; + + /** + * The minimum base revenue contribution required for a referrer to qualify. + */ + minQualifiedRevenueContribution: PriceUsdc; + + /** + * The fraction of the referrer's base revenue contribution that constitutes their potential award. + * + * @invariant Guaranteed to be a number between 0 and 1 (inclusive) + */ + qualifiedRevenueShare: number; +} + +export const validateReferralProgramRulesRevShareLimit = ( + rules: ReferralProgramRulesRevShareLimit, +): void => { + const poolSchema = makePriceUsdcSchema("ReferralProgramRulesRevShareLimit.totalAwardPoolValue"); + const poolResult = poolSchema.safeParse(rules.totalAwardPoolValue); + if (!poolResult.success) { + throw new Error( + `ReferralProgramRulesRevShareLimit: totalAwardPoolValue validation failed: ${poolResult.error.message}`, + ); + } + + const minSchema = makePriceUsdcSchema( + "ReferralProgramRulesRevShareLimit.minQualifiedRevenueContribution", + ); + const minResult = minSchema.safeParse(rules.minQualifiedRevenueContribution); + if (!minResult.success) { + throw new Error( + `ReferralProgramRulesRevShareLimit: minQualifiedRevenueContribution validation failed: ${minResult.error.message}`, + ); + } + + if (rules.qualifiedRevenueShare < 0 || rules.qualifiedRevenueShare > 1) { + throw new Error( + `ReferralProgramRulesRevShareLimit: qualifiedRevenueShare must be between 0 and 1 (inclusive), got ${rules.qualifiedRevenueShare}.`, + ); + } + + const accountIdSchema = makeAccountIdSchema("ReferralProgramRulesRevShareLimit.subregistryId"); + const accountIdResult = accountIdSchema.safeParse(rules.subregistryId); + if (!accountIdResult.success) { + throw new Error( + `ReferralProgramRulesRevShareLimit: subregistryId validation failed: ${accountIdResult.error.message}`, + ); + } + + validateUnixTimestamp(rules.startTime); + validateUnixTimestamp(rules.endTime); + + if (!(rules.rulesUrl instanceof URL)) { + throw new Error( + `ReferralProgramRulesRevShareLimit: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, + ); + } + + if (rules.endTime < rules.startTime) { + throw new Error( + `ReferralProgramRulesRevShareLimit: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, + ); + } +}; + +export const buildReferralProgramRulesRevShareLimit = ( + totalAwardPoolValue: PriceUsdc, + minQualifiedRevenueContribution: PriceUsdc, + qualifiedRevenueShare: number, + startTime: UnixTimestamp, + endTime: UnixTimestamp, + subregistryId: AccountId, + rulesUrl: URL, +): ReferralProgramRulesRevShareLimit => { + const result = { + awardModel: ReferralProgramAwardModels.RevShareLimit, + totalAwardPoolValue, + minQualifiedRevenueContribution, + qualifiedRevenueShare, + startTime, + endTime, + subregistryId, + rulesUrl, + } satisfies ReferralProgramRulesRevShareLimit; + + validateReferralProgramRulesRevShareLimit(result); + + return result; +}; + +/** + * Determine if a referrer meets the revenue threshold to qualify under rev-share-limit rules. + * + * @param totalBaseRevenueContribution - The referrer's total base revenue contribution. + * @param rules - The rev-share-limit rules of the referral program. + */ +export function isReferrerQualifiedRevShareLimit( + totalBaseRevenueContribution: PriceUsdc, + rules: ReferralProgramRulesRevShareLimit, +): boolean { + return totalBaseRevenueContribution.amount >= rules.minQualifiedRevenueContribution.amount; +} diff --git a/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts new file mode 100644 index 000000000..af5eeb76b --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts @@ -0,0 +1,36 @@ +import z from "zod/v4"; + +import { + makeNonNegativeIntegerSchema, + makePositiveIntegerSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { ReferralProgramStatuses } from "../../../status"; +import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; + +/** + * Schema for {@link ReferrerLeaderboardPageContext}. + */ +export const makeReferrerLeaderboardPageContextSchema = ( + valueLabel: string = "ReferrerLeaderboardPageContext", +) => + z.object({ + page: makePositiveIntegerSchema(`${valueLabel}.page`), + recordsPerPage: makePositiveIntegerSchema(`${valueLabel}.recordsPerPage`).max( + REFERRERS_PER_LEADERBOARD_PAGE_MAX, + `${valueLabel}.recordsPerPage must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`, + ), + totalRecords: makeNonNegativeIntegerSchema(`${valueLabel}.totalRecords`), + totalPages: makePositiveIntegerSchema(`${valueLabel}.totalPages`), + hasNext: z.boolean(), + hasPrev: z.boolean(), + startIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.startIndex`)), + endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)), + }); + +/** + * Schema for referral program status field. + * Validates that the status is one of: "Scheduled", "Active", or "Closed". + */ +export const makeReferralProgramStatusSchema = (_valueLabel: string = "status") => + z.enum(ReferralProgramStatuses); diff --git a/packages/ens-referrals/src/v1/award-models/shared/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/shared/edition-metrics.ts new file mode 100644 index 000000000..1eea2c3f8 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/edition-metrics.ts @@ -0,0 +1,20 @@ +/** + * The type of referrer edition metrics data. + */ +export const ReferrerEditionMetricsTypeIds = { + /** + * Represents a referrer who is ranked on the leaderboard. + */ + Ranked: "ranked", + + /** + * Represents a referrer who is not ranked on the leaderboard. + */ + Unranked: "unranked", +} as const; + +/** + * The derived string union of possible {@link ReferrerEditionMetricsTypeIds}. + */ +export type ReferrerEditionMetricsTypeId = + (typeof ReferrerEditionMetricsTypeIds)[keyof typeof ReferrerEditionMetricsTypeIds]; diff --git a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-guards.ts b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-guards.ts new file mode 100644 index 000000000..717f44cb1 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-guards.ts @@ -0,0 +1,26 @@ +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { ReferrerMetrics } from "../../referrer-metrics"; +import type { BaseReferralProgramRules } from "./rules"; + +/** + * Asserts invariants that must hold for any leaderboard builder regardless of award model. + */ +export const assertLeaderboardInputs = ( + allReferrers: ReferrerMetrics[], + rules: BaseReferralProgramRules, + accurateAsOf: UnixTimestamp, +): void => { + const uniqueReferrers = new Set(allReferrers.map((r) => r.referrer)); + if (uniqueReferrers.size !== allReferrers.length) { + throw new Error( + "ReferrerLeaderboard: Cannot build a leaderboard containing duplicate referrers", + ); + } + + if (accurateAsOf < rules.startTime && allReferrers.length > 0) { + throw new Error( + `ReferrerLeaderboard: accurateAsOf (${accurateAsOf}) is before startTime (${rules.startTime}) which indicates allReferrers should be empty, but allReferrers is not empty.`, + ); + } +}; diff --git a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts new file mode 100644 index 000000000..f875b45d3 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts @@ -0,0 +1,299 @@ +import type { Address } from "viem"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import type { ReferrerLeaderboard } from "../../leaderboard"; +import { isNonNegativeInteger, isPositiveInteger } from "../../number"; +import type { ReferralProgramStatusId } from "../../status"; + +/** + * The default number of referrers per leaderboard page. + */ +export const REFERRERS_PER_LEADERBOARD_PAGE_DEFAULT = 25; + +/** + * The maximum number of referrers per leaderboard page. + */ + +export const REFERRERS_PER_LEADERBOARD_PAGE_MAX = 100; + +/** + * Pagination params for leaderboard queries. + */ +export interface ReferrerLeaderboardPageParams { + /** + * Requested referrer leaderboard page number (1-indexed) + * @invariant Must be a positive integer (>= 1) + * @default 1 + */ + page?: number; + + /** + * Maximum number of referrers to return per leaderboard page + * @invariant Must be a positive integer (>= 1) and less than or equal to {@link REFERRERS_PER_LEADERBOARD_PAGE_MAX} + * @default {@link REFERRERS_PER_LEADERBOARD_PAGE_DEFAULT} + */ + recordsPerPage?: number; +} + +const validateReferrerLeaderboardPageParams = (params: ReferrerLeaderboardPageParams): void => { + if (params.page !== undefined && !isPositiveInteger(params.page)) { + throw new Error( + `Invalid ReferrerLeaderboardPageParams: ${params.page}. page must be a positive integer.`, + ); + } + if (params.recordsPerPage !== undefined && !isPositiveInteger(params.recordsPerPage)) { + throw new Error( + `Invalid ReferrerLeaderboardPageParams: ${params.recordsPerPage}. recordsPerPage must be a positive integer.`, + ); + } + if ( + params.recordsPerPage !== undefined && + params.recordsPerPage > REFERRERS_PER_LEADERBOARD_PAGE_MAX + ) { + throw new Error( + `Invalid ReferrerLeaderboardPageParams: ${params.recordsPerPage}. recordsPerPage must be less than or equal to ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}.`, + ); + } +}; + +export const buildReferrerLeaderboardPageParams = ( + params: ReferrerLeaderboardPageParams, +): Required => { + const result = { + page: params.page ?? 1, + recordsPerPage: params.recordsPerPage ?? REFERRERS_PER_LEADERBOARD_PAGE_DEFAULT, + } satisfies Required; + validateReferrerLeaderboardPageParams(result); + return result; +}; + +export interface ReferrerLeaderboardPageContext extends Required { + /** + * Total number of referrers across all leaderboard pages + * @invariant Guaranteed to be a non-negative integer (>= 0) + */ + totalRecords: number; + + /** + * Total number of pages in the leaderboard + * @invariant Guaranteed to be a positive integer (>= 1) + */ + totalPages: number; + + /** + * Indicates if there is a next page available + * @invariant true if and only if (`page` * `recordsPerPage` < `total`) + */ + hasNext: boolean; + + /** + * Indicates if there is a previous page available + * @invariant true if and only if (`page` > 1) + */ + hasPrev: boolean; + + /** + * The start index of the referrers on the page (0-indexed) + * + * `undefined` if and only if `totalRecords` is 0. + * + * @invariant Guaranteed to be a non-negative integer (>= 0) + */ + startIndex?: number; + + /** + * The end index of the referrers on the page (0-indexed) + * + * `undefined` if and only if `totalRecords` is 0. + * + * @invariant Guaranteed to be a non-negative integer (>= 0) + * @invariant If `totalRecords` is > 0: + * - Guaranteed to be greater than or equal to `startIndex`. + * - Guaranteed to be less than `totalRecords`. + */ + endIndex?: number; +} + +export const validateReferrerLeaderboardPageContext = ( + context: ReferrerLeaderboardPageContext, +): void => { + validateReferrerLeaderboardPageParams(context); + if (!isNonNegativeInteger(context.totalRecords)) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: total must be a non-negative integer but is ${context.totalRecords}.`, + ); + } + + // Validate totalPages + if (!isNonNegativeInteger(context.totalPages)) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: totalPages must be a non-negative integer but is ${context.totalPages}.`, + ); + } + + const expectedTotalPages = + context.totalRecords === 0 ? 1 : Math.ceil(context.totalRecords / context.recordsPerPage); + + if (context.totalPages !== expectedTotalPages) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: totalPages is ${context.totalPages} but expected ${expectedTotalPages} based on totalRecords (${context.totalRecords}) and recordsPerPage (${context.recordsPerPage}).`, + ); + } + + if (context.page > context.totalPages) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: page ${context.page} exceeds totalPages ${context.totalPages}.`, + ); + } + + // Validate startIndex and endIndex + const expectedStartIndex = (context.page - 1) * context.recordsPerPage; + const expectedEndIndex = Math.min( + expectedStartIndex + context.recordsPerPage - 1, + context.totalRecords - 1, + ); + + if (context.totalRecords === 0) { + if (context.startIndex !== undefined) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: startIndex must be undefined when totalRecords is 0 but is ${context.startIndex}.`, + ); + } + if (context.endIndex !== undefined) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: endIndex must be undefined when totalRecords is 0 but is ${context.endIndex}.`, + ); + } + } else { + if (context.startIndex !== expectedStartIndex) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: startIndex is ${context.startIndex} but expected ${expectedStartIndex} based on page (${context.page}) and recordsPerPage (${context.recordsPerPage}).`, + ); + } + if (context.endIndex !== expectedEndIndex) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: endIndex is ${context.endIndex} but expected ${expectedEndIndex} based on startIndex (${expectedStartIndex}), recordsPerPage (${context.recordsPerPage}), and totalRecords (${context.totalRecords}).`, + ); + } + if ( + typeof context.endIndex !== "undefined" && + typeof context.startIndex !== "undefined" && + context.endIndex < context.startIndex + ) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: endIndex (${context.endIndex}) must be greater than or equal to startIndex (${context.startIndex}).`, + ); + } + } + + const startIndex = (context.page - 1) * context.recordsPerPage; + const endIndex = startIndex + context.recordsPerPage; + + if (!context.hasNext && endIndex < context.totalRecords) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: if hasNext is false, endIndex (${endIndex}) must be greater than or equal to total (${context.totalRecords}).`, + ); + } else if (context.hasNext && context.page * context.recordsPerPage >= context.totalRecords) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: if hasNext is true, endIndex (${endIndex}) must be less than total (${context.totalRecords}).`, + ); + } + if (!context.hasPrev && context.page !== 1) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: if hasPrev is false, page must be the first page (1) but is ${context.page}.`, + ); + } else if (context.hasPrev && context.page === 1) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: if hasPrev is true, page must not be the first page (1) but is ${context.page}.`, + ); + } +}; + +export const buildReferrerLeaderboardPageContext = ( + optionalParams: ReferrerLeaderboardPageParams, + leaderboard: ReferrerLeaderboard, +): ReferrerLeaderboardPageContext => { + const materializedParams = buildReferrerLeaderboardPageParams(optionalParams); + + const totalRecords = leaderboard.referrers.size; + + const totalPages = Math.max(1, Math.ceil(totalRecords / materializedParams.recordsPerPage)); + + if (materializedParams.page > totalPages) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: page ${materializedParams.page} exceeds total pages ${totalPages}.`, + ); + } + + if (totalRecords === 0) { + return { + ...materializedParams, + totalRecords: 0, + totalPages: 1, + hasNext: false, + hasPrev: false, + startIndex: undefined, + endIndex: undefined, + } satisfies ReferrerLeaderboardPageContext; + } + + const startIndex = (materializedParams.page - 1) * materializedParams.recordsPerPage; + const maxTheoreticalIndexOnPage = startIndex + (materializedParams.recordsPerPage - 1); + const endIndex = Math.min(maxTheoreticalIndexOnPage, totalRecords - 1); + const hasNext = maxTheoreticalIndexOnPage < totalRecords - 1; + const hasPrev = materializedParams.page > 1; + + const result = { + ...materializedParams, + totalRecords, + totalPages, + hasNext, + hasPrev, + startIndex, + endIndex, + } satisfies ReferrerLeaderboardPageContext; + validateReferrerLeaderboardPageContext(result); + return result; +}; + +/** + * Base fields shared by all leaderboard page variants. + */ +export interface BaseReferrerLeaderboardPage { + /** + * The {@link ReferrerLeaderboardPageContext} of this page relative to the overall leaderboard. + */ + pageContext: ReferrerLeaderboardPageContext; + + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + + /** + * The {@link UnixTimestamp} of when the data used to build this page was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} + +/** + * Extracts the referrers for the current page from a fully-ranked Map. + * Generic over the referrer type so each model variant retains its specific type. + */ +export function sliceReferrers( + referrers: Map, + pageContext: ReferrerLeaderboardPageContext, +): T[] { + // pageContext invariants: startIndex and endIndex are defined iff totalRecords > 0 + if ( + pageContext.totalRecords === 0 || + pageContext.startIndex === undefined || + pageContext.endIndex === undefined + ) { + return []; + } + const all = [...referrers.values()]; + return all.slice(pageContext.startIndex, pageContext.endIndex + 1); +} diff --git a/packages/ens-referrals/src/v1/award-models/shared/rank.ts b/packages/ens-referrals/src/v1/award-models/shared/rank.ts new file mode 100644 index 000000000..e407d27e7 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/rank.ts @@ -0,0 +1,58 @@ +import type { Address } from "viem"; + +import type { Duration } from "@ensnode/ensnode-sdk"; + +import { isPositiveInteger } from "../../number"; +import type { ReferrerMetrics } from "../../referrer-metrics"; + +/** + * The rank of a referrer relative to all other referrers, where 1 is the + * top-ranked referrer. + * + * @invariant Guaranteed to be a positive integer (> 0) + */ +export type ReferrerRank = number; + +export const validateReferrerRank = (rank: ReferrerRank): void => { + if (!isPositiveInteger(rank)) { + throw new Error(`Invalid ReferrerRank: ${rank}. ReferrerRank must be a positive integer.`); + } +}; + +export interface ReferrerMetricsForComparison { + /** + * The total incremental duration (in seconds) of all referrals made by the referrer within + * the {@link ReferralProgramRules}. + */ + totalIncrementalDuration: Duration; + + /** + * The fully lowercase Ethereum address of the referrer. + * + * @invariant Guaranteed to be a valid EVM address in lowercase format. + */ + referrer: Address; +} + +export const compareReferrerMetrics = ( + a: ReferrerMetricsForComparison, + b: ReferrerMetricsForComparison, +): number => { + // Primary sort: totalIncrementalDuration (descending) + if (a.totalIncrementalDuration !== b.totalIncrementalDuration) { + 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; + return 0; +}; + +/** + * Sorts a list of referrers for leaderboard ranking. + * Returns a new array — does not mutate the input. + */ +export const sortReferrerMetrics = (referrers: ReferrerMetrics[]): ReferrerMetrics[] => { + return [...referrers].sort(compareReferrerMetrics); +}; diff --git a/packages/ens-referrals/src/v1/award-models/shared/rules.ts b/packages/ens-referrals/src/v1/award-models/shared/rules.ts new file mode 100644 index 000000000..3e6e8eb05 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/rules.ts @@ -0,0 +1,53 @@ +import type { AccountId, UnixTimestamp } from "@ensnode/ensnode-sdk"; + +/** + * Discriminant values for the award model used in a referral program edition. + * + * @remarks Clients MUST check `awardModel` before accessing model-specific fields. + * Unrecognized `awardModel` values MUST be handled gracefully - when parsed via Zod schemas, + * unknown award model objects are returned as `{ awardModel: string } & Record`. + * Servers may introduce new award model types at any time without breaking existing clients. + */ +export const ReferralProgramAwardModels = { + PieSplit: "pie-split", + RevShareLimit: "rev-share-limit", +} as const; + +export type ReferralProgramAwardModel = + (typeof ReferralProgramAwardModels)[keyof typeof ReferralProgramAwardModels]; + +/** + * Base fields shared across all referral program rule types. + * + * Both `ReferralProgramRulesPieSplit` and `ReferralProgramRulesRevShareLimit` are structurally + * compatible with this interface, so it can be used wherever only the common fields are needed + * (e.g., `assertLeaderboardInputs`). + */ +export interface BaseReferralProgramRules { + /** + * Discriminant: identifies the award model for this edition. + */ + awardModel: ReferralProgramAwardModel; + + /** + * The start time of the referral program. + */ + startTime: UnixTimestamp; + + /** + * The end time of the referral program. + * @invariant Guaranteed to be greater than or equal to `startTime` + */ + endTime: UnixTimestamp; + + /** + * The account ID of the subregistry for the referral program. + */ + subregistryId: AccountId; + + /** + * URL to the full rules document for these rules. + * @example new URL("https://ensawards.org/ens-holiday-awards-rules") + */ + rulesUrl: URL; +} diff --git a/packages/ens-referrals/src/v1/award-models/shared/score.ts b/packages/ens-referrals/src/v1/award-models/shared/score.ts new file mode 100644 index 000000000..d3bf0f61a --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/score.ts @@ -0,0 +1,18 @@ +/** + * The score of a referrer. + * + * @invariant Guaranteed to be a finite non-negative number (>= 0) + */ +export type ReferrerScore = number; + +export const isValidReferrerScore = (score: ReferrerScore): boolean => { + return score >= 0 && Number.isFinite(score); +}; + +export const validateReferrerScore = (score: ReferrerScore): void => { + if (!isValidReferrerScore(score)) { + throw new Error( + `Invalid referrer score: ${score}. Referrer score must be a finite non-negative number.`, + ); + } +}; diff --git a/packages/ens-referrals/src/v1/base-metrics.ts b/packages/ens-referrals/src/v1/base-metrics.ts new file mode 100644 index 000000000..94a307c9c --- /dev/null +++ b/packages/ens-referrals/src/v1/base-metrics.ts @@ -0,0 +1,77 @@ +import type { Address } from "viem"; + +import type { Duration, PriceEth } from "@ensnode/ensnode-sdk"; +import { makePriceEthSchema } from "@ensnode/ensnode-sdk/internal"; + +import { normalizeAddress, validateLowercaseAddress } from "./address"; +import { validateNonNegativeInteger } from "./number"; +import { ReferralProgramRules } from "./rules"; +import { validateDuration } from "./time"; + +/** + * Base metrics for a single referrer independent of other referrers and award model. + * Used as input from the DB layer; does not carry an `awardModel` discriminant. + */ +export interface BaseReferrerMetrics { + /** + * The fully lowercase Ethereum address of the referrer. + * + * @invariant Guaranteed to be a valid EVM address in lowercase format + */ + referrer: Address; + + /** + * The total number of referrals made by the referrer within the {@link ReferralProgramRules}. + * @invariant Guaranteed to be a non-negative integer (>= 0) + */ + totalReferrals: number; + + /** + * The total incremental duration (in seconds) of all referrals made by the referrer within + * the {@link ReferralProgramRules}. + */ + totalIncrementalDuration: Duration; + + /** + * The total revenue contribution in ETH made to the ENS DAO by all referrals + * from this referrer. + * + * This is the sum of the total cost paid by registrants for all registrar actions + * where this address was the referrer. + * + * @invariant Guaranteed to be a valid PriceEth with non-negative amount (>= 0n) + * @invariant Never null (records with null `total` in the database are treated as 0 when summing) + */ + totalRevenueContribution: PriceEth; +} + +export const buildReferrerMetrics = ( + referrer: Address, + totalReferrals: number, + totalIncrementalDuration: Duration, + totalRevenueContribution: PriceEth, +): BaseReferrerMetrics => { + const result = { + referrer: normalizeAddress(referrer), + totalReferrals, + totalIncrementalDuration, + totalRevenueContribution, + } satisfies BaseReferrerMetrics; + + validateReferrerMetrics(result); + return result; +}; + +export const validateReferrerMetrics = (metrics: BaseReferrerMetrics): void => { + validateLowercaseAddress(metrics.referrer); + validateNonNegativeInteger(metrics.totalReferrals); + validateDuration(metrics.totalIncrementalDuration); + + const priceEthSchema = makePriceEthSchema("BaseReferrerMetrics.totalRevenueContribution"); + const parseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); + if (!parseResult.success) { + throw new Error( + `BaseReferrerMetrics: totalRevenueContribution validation failed: ${parseResult.error.message}`, + ); + } +}; diff --git a/packages/ens-referrals/src/v1/edition-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts index df6086a29..d50e66d93 100644 --- a/packages/ens-referrals/src/v1/edition-defaults.ts +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -5,12 +5,13 @@ import { parseUsdc, } from "@ensnode/ensnode-sdk"; +import { buildReferralProgramRulesPieSplit } from "./award-models/pie-split/rules"; +import { buildReferralProgramRulesRevShareLimit } from "./award-models/rev-share-limit/rules"; import { buildReferralProgramEditionConfigSet, type ReferralProgramEditionConfig, type ReferralProgramEditionConfigSet, } from "./edition"; -import { buildReferralProgramRules } from "./rules"; /** * Returns the default referral program edition set with pre-built edition configurations. @@ -30,7 +31,7 @@ export function getDefaultReferralProgramEditionConfigSet( const edition1: ReferralProgramEditionConfig = { slug: "2025-12", displayName: "ENS Holiday Awards", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesPieSplit( parseUsdc("10000"), 10, parseTimestamp("2025-12-01T00:00:00Z"), @@ -43,9 +44,10 @@ export function getDefaultReferralProgramEditionConfigSet( const edition2: ReferralProgramEditionConfig = { slug: "2026-03", displayName: "March 2026", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesRevShareLimit( parseUsdc("10000"), - 10, + parseUsdc("500"), + 0.5, parseTimestamp("2026-03-01T00:00:00Z"), parseTimestamp("2026-03-31T23:59:59Z"), subregistryId, diff --git a/packages/ens-referrals/src/v1/edition-metrics.ts b/packages/ens-referrals/src/v1/edition-metrics.ts index f1e54e0cb..a3a15ebad 100644 --- a/packages/ens-referrals/src/v1/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -1,134 +1,45 @@ import type { Address } from "viem"; -import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "./aggregations"; +import type { + ReferrerEditionMetricsRankedPieSplit, + ReferrerEditionMetricsUnrankedPieSplit, +} from "./award-models/pie-split/edition-metrics"; +import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; +import { buildUnrankedReferrerMetricsPieSplit } from "./award-models/pie-split/metrics"; +import type { + ReferrerEditionMetricsRankedRevShareLimit, + ReferrerEditionMetricsUnrankedRevShareLimit, +} from "./award-models/rev-share-limit/edition-metrics"; +import type { ReferrerLeaderboardRevShareLimit } from "./award-models/rev-share-limit/leaderboard"; +import { buildUnrankedReferrerMetricsRevShareLimit } from "./award-models/rev-share-limit/metrics"; +import { ReferrerEditionMetricsTypeIds } from "./award-models/shared/edition-metrics"; +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; import type { ReferrerLeaderboard } from "./leaderboard"; -import { - type AwardedReferrerMetrics, - buildUnrankedReferrerMetrics, - type UnrankedReferrerMetrics, -} from "./referrer-metrics"; -import type { ReferralProgramRules } from "./rules"; -import { calcReferralProgramStatus, type ReferralProgramStatusId } from "./status"; - -/** - * The type of referrer edition metrics data. - */ -export const ReferrerEditionMetricsTypeIds = { - /** - * Represents a referrer who is ranked on the leaderboard. - */ - Ranked: "ranked", - - /** - * Represents a referrer who is not ranked on the leaderboard. - */ - Unranked: "unranked", -} as const; - -/** - * The derived string union of possible {@link ReferrerEditionMetricsTypeIds}. - */ -export type ReferrerEditionMetricsTypeId = - (typeof ReferrerEditionMetricsTypeIds)[keyof typeof ReferrerEditionMetricsTypeIds]; +import { calcReferralProgramStatus } from "./status"; /** * Referrer edition metrics data for a specific referrer address on the leaderboard. * - * Includes the referrer's awarded metrics from the leaderboard plus timestamp. - * - * Invariants: - * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. - * - * @see {@link AwardedReferrerMetrics} + * Use `rules.awardModel` to determine the specific model variant at runtime. */ -export interface ReferrerEditionMetricsRanked { - /** - * The type of referrer edition metrics data. - */ - type: typeof ReferrerEditionMetricsTypeIds.Ranked; - - /** - * The {@link ReferralProgramRules} used to calculate the {@link AwardedReferrerMetrics}. - */ - rules: ReferralProgramRules; - - /** - * The awarded referrer metrics from the leaderboard. - * - * Contains all calculated metrics including score, rank, qualification status, - * and award pool share information. - */ - referrer: AwardedReferrerMetrics; - - /** - * Aggregated metrics for all referrers on the leaderboard. - */ - aggregatedMetrics: AggregatedReferrerMetrics; - - /** - * The status of the referral program ("Scheduled", "Active", or "Closed") - * calculated based on the program's timing relative to {@link accurateAsOf}. - */ - status: ReferralProgramStatusId; - - /** - * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRanked} was accurate as of. - */ - accurateAsOf: UnixTimestamp; -} +export type ReferrerEditionMetricsRanked = + | ReferrerEditionMetricsRankedPieSplit + | ReferrerEditionMetricsRankedRevShareLimit; /** * Referrer edition metrics data for a specific referrer address NOT on the leaderboard. * - * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. - * - * Invariants: - * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. - * - * @see {@link UnrankedReferrerMetrics} + * Use `rules.awardModel` to determine the specific model variant at runtime. */ -export interface ReferrerEditionMetricsUnranked { - /** - * The type of referrer edition metrics data. - */ - type: typeof ReferrerEditionMetricsTypeIds.Unranked; - - /** - * The {@link ReferralProgramRules} used to calculate the {@link UnrankedReferrerMetrics}. - */ - rules: ReferralProgramRules; - - /** - * The unranked referrer metrics (not on the leaderboard). - * - * Contains all calculated metrics with rank set to null and isQualified set to false. - */ - referrer: UnrankedReferrerMetrics; - - /** - * Aggregated metrics for all referrers on the leaderboard. - */ - aggregatedMetrics: AggregatedReferrerMetrics; - - /** - * The status of the referral program ("Scheduled", "Active", or "Closed") - * calculated based on the program's timing relative to {@link accurateAsOf}. - */ - status: ReferralProgramStatusId; - - /** - * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnranked} was accurate as of. - */ - accurateAsOf: UnixTimestamp; -} +export type ReferrerEditionMetricsUnranked = + | ReferrerEditionMetricsUnrankedPieSplit + | ReferrerEditionMetricsUnrankedRevShareLimit; /** * Referrer edition metrics data for a specific referrer address. * - * Use the `type` field to determine the specific type interpretation - * at runtime. + * Use the `type` field to determine if the referrer is ranked or unranked. + * Use `rules.awardModel` to determine the award model variant. */ export type ReferrerEditionMetrics = ReferrerEditionMetricsRanked | ReferrerEditionMetricsUnranked; @@ -146,28 +57,55 @@ export const getReferrerEditionMetrics = ( referrer: Address, leaderboard: ReferrerLeaderboard, ): ReferrerEditionMetrics => { - const awardedReferrerMetrics = leaderboard.referrers.get(referrer); const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); - // If referrer is on the leaderboard, return their ranked metrics - if (awardedReferrerMetrics) { - return { - type: ReferrerEditionMetricsTypeIds.Ranked, - rules: leaderboard.rules, - referrer: awardedReferrerMetrics, - aggregatedMetrics: leaderboard.aggregatedMetrics, - status, - accurateAsOf: leaderboard.accurateAsOf, - }; + switch (leaderboard.rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: { + // Single type assertion per branch: rules.awardModel === "pie-split" guarantees the leaderboard + // is ReferrerLeaderboardPieSplit, but TypeScript cannot narrow a union on a nested property. + const typedLeaderboard = leaderboard as ReferrerLeaderboardPieSplit; + const awardedReferrerMetrics = typedLeaderboard.referrers.get(referrer); + if (awardedReferrerMetrics) { + return { + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: typedLeaderboard.rules, + referrer: awardedReferrerMetrics, + aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsRankedPieSplit; + } + return { + type: ReferrerEditionMetricsTypeIds.Unranked, + rules: typedLeaderboard.rules, + referrer: buildUnrankedReferrerMetricsPieSplit(referrer), + aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsUnrankedPieSplit; + } + + case ReferralProgramAwardModels.RevShareLimit: { + const typedLeaderboard = leaderboard as ReferrerLeaderboardRevShareLimit; + const awardedReferrerMetrics = typedLeaderboard.referrers.get(referrer); + if (awardedReferrerMetrics) { + return { + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: typedLeaderboard.rules, + referrer: awardedReferrerMetrics, + aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsRankedRevShareLimit; + } + return { + type: ReferrerEditionMetricsTypeIds.Unranked, + rules: typedLeaderboard.rules, + referrer: buildUnrankedReferrerMetricsRevShareLimit(referrer), + aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsUnrankedRevShareLimit; + } } - - // If referrer not found, return an unranked referrer record - return { - type: ReferrerEditionMetricsTypeIds.Unranked, - rules: leaderboard.rules, - referrer: buildUnrankedReferrerMetrics(referrer), - aggregatedMetrics: leaderboard.aggregatedMetrics, - status, - accurateAsOf: leaderboard.accurateAsOf, - }; }; diff --git a/packages/ens-referrals/src/v1/index.ts b/packages/ens-referrals/src/v1/index.ts index 72af819ce..f689fe255 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -1,6 +1,25 @@ export * from "./address"; -export * from "./aggregations"; export * from "./api"; +export * from "./award-models/pie-split/aggregations"; +export * from "./award-models/pie-split/api/serialized-types"; +export * from "./award-models/pie-split/edition-metrics"; +export * from "./award-models/pie-split/leaderboard"; +export * from "./award-models/pie-split/leaderboard-page"; +export * from "./award-models/pie-split/metrics"; +export * from "./award-models/pie-split/rules"; +export * from "./award-models/pie-split/score"; +export * from "./award-models/rev-share-limit/aggregations"; +export * from "./award-models/rev-share-limit/api/serialized-types"; +export * from "./award-models/rev-share-limit/edition-metrics"; +export * from "./award-models/rev-share-limit/leaderboard"; +export * from "./award-models/rev-share-limit/leaderboard-page"; +export * from "./award-models/rev-share-limit/metrics"; +export * from "./award-models/rev-share-limit/rules"; +export * from "./award-models/shared/edition-metrics"; +export * from "./award-models/shared/leaderboard-page"; +export * from "./award-models/shared/rank"; +export * from "./award-models/shared/rules"; +export * from "./award-models/shared/score"; export * from "./client"; export * from "./edition"; export * from "./edition-defaults"; @@ -9,9 +28,7 @@ export * from "./leaderboard"; export * from "./leaderboard-page"; export * from "./link"; export * from "./number"; -export * from "./rank"; export * from "./referrer-metrics"; export * from "./rules"; -export * from "./score"; export * from "./status"; export * from "./time"; diff --git a/packages/ens-referrals/src/v1/leaderboard-page.test.ts b/packages/ens-referrals/src/v1/leaderboard-page.test.ts index a5ea43826..af0c4a279 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import { priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; -import type { ReferrerLeaderboard } from "./leaderboard"; +import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; +import type { AwardedReferrerMetricsPieSplit } from "./award-models/pie-split/metrics"; import { buildReferrerLeaderboardPageContext, type ReferrerLeaderboardPageContext, type ReferrerLeaderboardPageParams, -} from "./leaderboard-page"; -import type { AwardedReferrerMetrics } from "./referrer-metrics"; +} from "./award-models/shared/leaderboard-page"; describe("buildReferrerLeaderboardPageContext", () => { const pageParams: ReferrerLeaderboardPageParams = { @@ -18,8 +18,9 @@ describe("buildReferrerLeaderboardPageContext", () => { }; it("correctly evaluates `hasNext` when `leaderboard.referrers.size` and `recordsPerPage` are equal", () => { - const leaderboard: ReferrerLeaderboard = { + const leaderboard: ReferrerLeaderboardPieSplit = { rules: { + awardModel: "pie-split", totalAwardPoolValue: priceUsdc(10000n), maxQualifiedReferrers: 10, startTime: 1764547200, @@ -37,7 +38,7 @@ describe("buildReferrerLeaderboardPageContext", () => { grandTotalQualifiedReferrersFinalScore: 28.05273061366773, minFinalScoreToQualify: 0, }, - referrers: new Map([ + referrers: new Map([ [ "0x03c098d2bed4609e6ed9beb2c4877741f45f290d", { @@ -104,8 +105,9 @@ describe("buildReferrerLeaderboardPageContext", () => { }); it("Correctly builds the pagination context when `leaderboard.referrers.size` is 0", () => { - const leaderboard: ReferrerLeaderboard = { + const leaderboard: ReferrerLeaderboardPieSplit = { rules: { + awardModel: "pie-split", totalAwardPoolValue: priceUsdc(10000n), maxQualifiedReferrers: 10, startTime: 1764547200, @@ -123,7 +125,7 @@ describe("buildReferrerLeaderboardPageContext", () => { grandTotalQualifiedReferrersFinalScore: 28.05273061366773, minFinalScoreToQualify: 0, }, - referrers: new Map(), + referrers: new Map(), accurateAsOf: 1764580368, }; diff --git a/packages/ens-referrals/src/v1/leaderboard-page.ts b/packages/ens-referrals/src/v1/leaderboard-page.ts index 72d5a75ff..f216f586d 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.ts @@ -1,304 +1,29 @@ -import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "./aggregations"; +import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; +import { + buildLeaderboardPagePieSplit, + type ReferrerLeaderboardPagePieSplit, +} from "./award-models/pie-split/leaderboard-page"; +import type { ReferrerLeaderboardRevShareLimit } from "./award-models/rev-share-limit/leaderboard"; +import { + buildLeaderboardPageRevShareLimit, + type ReferrerLeaderboardPageRevShareLimit, +} from "./award-models/rev-share-limit/leaderboard-page"; +import { + buildReferrerLeaderboardPageContext, + type ReferrerLeaderboardPageParams, +} from "./award-models/shared/leaderboard-page"; +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; import type { ReferrerLeaderboard } from "./leaderboard"; -import { isNonNegativeInteger, isPositiveInteger } from "./number"; -import type { AwardedReferrerMetrics } from "./referrer-metrics"; -import type { ReferralProgramRules } from "./rules"; -import { calcReferralProgramStatus, type ReferralProgramStatusId } from "./status"; - -/** - * The default number of referrers per leaderboard page. - */ -export const REFERRERS_PER_LEADERBOARD_PAGE_DEFAULT = 25; - -/** - * The maximum number of referrers per leaderboard page. - */ - -export const REFERRERS_PER_LEADERBOARD_PAGE_MAX = 100; - -/** - * Pagination params for leaderboard queries. - */ -export interface ReferrerLeaderboardPageParams { - /** - * Requested referrer leaderboard page number (1-indexed) - * @invariant Must be a positive integer (>= 1) - * @default 1 - */ - page?: number; - - /** - * Maximum number of referrers to return per leaderboard page - * @invariant Must be a positive integer (>= 1) and less than or equal to {@link REFERRERS_PER_LEADERBOARD_PAGE_MAX} - * @default {@link REFERRERS_PER_LEADERBOARD_PAGE_DEFAULT} - */ - recordsPerPage?: number; -} - -const validateReferrerLeaderboardPageParams = (params: ReferrerLeaderboardPageParams): void => { - if (params.page !== undefined && !isPositiveInteger(params.page)) { - throw new Error( - `Invalid ReferrerLeaderboardPageParams: ${params.page}. page must be a positive integer.`, - ); - } - if (params.recordsPerPage !== undefined && !isPositiveInteger(params.recordsPerPage)) { - throw new Error( - `Invalid ReferrerLeaderboardPageParams: ${params.recordsPerPage}. recordsPerPage must be a positive integer.`, - ); - } - if ( - params.recordsPerPage !== undefined && - params.recordsPerPage > REFERRERS_PER_LEADERBOARD_PAGE_MAX - ) { - throw new Error( - `Invalid ReferrerLeaderboardPageParams: ${params.recordsPerPage}. recordsPerPage must be less than or equal to ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}.`, - ); - } -}; - -export const buildReferrerLeaderboardPageParams = ( - params: ReferrerLeaderboardPageParams, -): Required => { - const result = { - page: params.page ?? 1, - recordsPerPage: params.recordsPerPage ?? REFERRERS_PER_LEADERBOARD_PAGE_DEFAULT, - } satisfies Required; - validateReferrerLeaderboardPageParams(result); - return result; -}; - -export interface ReferrerLeaderboardPageContext extends Required { - /** - * Total number of referrers across all leaderboard pages - * @invariant Guaranteed to be a non-negative integer (>= 0) - */ - totalRecords: number; - - /** - * Total number of pages in the leaderboard - * @invariant Guaranteed to be a positive integer (>= 1) - */ - totalPages: number; - - /** - * Indicates if there is a next page available - * @invariant true if and only if (`page` * `recordsPerPage` < `total`) - */ - hasNext: boolean; - - /** - * Indicates if there is a previous page available - * @invariant true if and only if (`page` > 1) - */ - hasPrev: boolean; - - /** - * The start index of the referrers on the page (0-indexed) - * - * `undefined` if and only if `totalRecords` is 0. - * - * @invariant Guaranteed to be a non-negative integer (>= 0) - */ - startIndex?: number; - - /** - * The end index of the referrers on the page (0-indexed) - * - * `undefined` if and only if `totalRecords` is 0. - * - * @invariant Guaranteed to be a non-negative integer (>= 0) - * @invariant If `totalRecords` is > 0: - * - Guaranteed to be greater than or equal to `startIndex`. - * - Guaranteed to be less than `totalRecords`. - */ - endIndex?: number; -} - -export const validateReferrerLeaderboardPageContext = ( - context: ReferrerLeaderboardPageContext, -): void => { - validateReferrerLeaderboardPageParams(context); - if (!isNonNegativeInteger(context.totalRecords)) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: total must be a non-negative integer but is ${context.totalRecords}.`, - ); - } - - // Validate totalPages - if (!isNonNegativeInteger(context.totalPages)) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: totalPages must be a non-negative integer but is ${context.totalPages}.`, - ); - } - - const expectedTotalPages = - context.totalRecords === 0 ? 1 : Math.ceil(context.totalRecords / context.recordsPerPage); - - if (context.totalPages !== expectedTotalPages) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: totalPages is ${context.totalPages} but expected ${expectedTotalPages} based on totalRecords (${context.totalRecords}) and recordsPerPage (${context.recordsPerPage}).`, - ); - } - - if (context.page > context.totalPages) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: page ${context.page} exceeds totalPages ${context.totalPages}.`, - ); - } - - // Validate startIndex and endIndex - const expectedStartIndex = (context.page - 1) * context.recordsPerPage; - const expectedEndIndex = Math.min( - expectedStartIndex + context.recordsPerPage - 1, - context.totalRecords - 1, - ); - - if (context.totalRecords === 0) { - if (context.startIndex !== undefined) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: startIndex must be undefined when totalRecords is 0 but is ${context.startIndex}.`, - ); - } - if (context.endIndex !== undefined) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: endIndex must be undefined when totalRecords is 0 but is ${context.endIndex}.`, - ); - } - } else { - if (context.startIndex !== expectedStartIndex) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: startIndex is ${context.startIndex} but expected ${expectedStartIndex} based on page (${context.page}) and recordsPerPage (${context.recordsPerPage}).`, - ); - } - if (context.endIndex !== expectedEndIndex) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: endIndex is ${context.endIndex} but expected ${expectedEndIndex} based on startIndex (${expectedStartIndex}), recordsPerPage (${context.recordsPerPage}), and totalRecords (${context.totalRecords}).`, - ); - } - if ( - typeof context.endIndex !== "undefined" && - typeof context.startIndex !== "undefined" && - context.endIndex < context.startIndex - ) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: endIndex (${context.endIndex}) must be greater than or equal to startIndex (${context.startIndex}).`, - ); - } - } - - const startIndex = (context.page - 1) * context.recordsPerPage; - const endIndex = startIndex + context.recordsPerPage; - - if (!context.hasNext && endIndex < context.totalRecords) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: if hasNext is false, endIndex (${endIndex}) must be greater than or equal to total (${context.totalRecords}).`, - ); - } else if (context.hasNext && context.page * context.recordsPerPage >= context.totalRecords) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: if hasNext is true, endIndex (${endIndex}) must be less than total (${context.totalRecords}).`, - ); - } - if (!context.hasPrev && context.page !== 1) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: if hasPrev is false, page must be the first page (1) but is ${context.page}.`, - ); - } else if (context.hasPrev && context.page === 1) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: if hasPrev is true, page must not be the first page (1) but is ${context.page}.`, - ); - } -}; - -export const buildReferrerLeaderboardPageContext = ( - optionalParams: ReferrerLeaderboardPageParams, - leaderboard: ReferrerLeaderboard, -): ReferrerLeaderboardPageContext => { - const materializedParams = buildReferrerLeaderboardPageParams(optionalParams); - - const totalRecords = leaderboard.referrers.size; - - const totalPages = Math.max(1, Math.ceil(totalRecords / materializedParams.recordsPerPage)); - - if (materializedParams.page > totalPages) { - throw new Error( - `Invalid ReferrerLeaderboardPageContext: page ${materializedParams.page} exceeds total pages ${totalPages}.`, - ); - } - - if (totalRecords === 0) { - return { - ...materializedParams, - totalRecords: 0, - totalPages: 1, - hasNext: false, - hasPrev: false, - startIndex: undefined, - endIndex: undefined, - } satisfies ReferrerLeaderboardPageContext; - } - - const startIndex = (materializedParams.page - 1) * materializedParams.recordsPerPage; - const maxTheoreticalIndexOnPage = startIndex + (materializedParams.recordsPerPage - 1); - const endIndex = Math.min(maxTheoreticalIndexOnPage, totalRecords - 1); - const hasNext = maxTheoreticalIndexOnPage < totalRecords - 1; - const hasPrev = materializedParams.page > 1; - - const result = { - ...materializedParams, - totalRecords, - totalPages, - hasNext, - hasPrev, - startIndex, - endIndex, - } satisfies ReferrerLeaderboardPageContext; - validateReferrerLeaderboardPageContext(result); - return result; -}; /** * A page of referrers from the referrer leaderboard. + * + * Use `rules.awardModel` to determine the specific variant at runtime. Within each variant, + * `rules`, `referrers`, and `aggregatedMetrics` are all guaranteed to be from the same model. */ -export interface ReferrerLeaderboardPage { - /** - * The {@link ReferralProgramRules} used to generate the {@link ReferrerLeaderboard} - * that this {@link ReferrerLeaderboardPage} comes from. - */ - rules: ReferralProgramRules; - - /** - * Ordered list of {@link AwardedReferrerMetrics} for the {@link ReferrerLeaderboardPage} - * described by `pageContext` within the related {@link ReferrerLeaderboard}. - * - * @invariant Array will be empty if `pageContext.totalRecords` is 0. - * @invariant Array entries are ordered by `rank` (ascending). - */ - referrers: AwardedReferrerMetrics[]; - - /** - * Aggregated metrics for all referrers on the leaderboard. - */ - aggregatedMetrics: AggregatedReferrerMetrics; - - /** - * The {@link ReferrerLeaderboardPageContext} of this {@link ReferrerLeaderboardPage} relative to the overall - * {@link ReferrerLeaderboard}. - */ - pageContext: ReferrerLeaderboardPageContext; - - /** - * The status of the referral program ("Scheduled", "Active", or "Closed") - * calculated based on the program's timing relative to {@link accurateAsOf}. - */ - status: ReferralProgramStatusId; - - /** - * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardPage} was accurate as of. - */ - accurateAsOf: UnixTimestamp; -} +export type ReferrerLeaderboardPage = + | ReferrerLeaderboardPagePieSplit + | ReferrerLeaderboardPageRevShareLimit; export const getReferrerLeaderboardPage = ( pageParams: ReferrerLeaderboardPageParams, @@ -306,30 +31,15 @@ export const getReferrerLeaderboardPage = ( ): ReferrerLeaderboardPage => { const pageContext = buildReferrerLeaderboardPageContext(pageParams, leaderboard); - let referrers: AwardedReferrerMetrics[]; - - if ( - pageContext.totalRecords > 0 && - typeof pageContext.startIndex !== "undefined" && - typeof pageContext.endIndex !== "undefined" - ) { - // extract the referrers from the leaderboard in the range specified by `pageContext`. - referrers = Array.from(leaderboard.referrers.values()).slice( - pageContext.startIndex, - pageContext.endIndex + 1, // For `slice`, this is exclusive of the element at the index 'end'. We need it to be inclusive, hence plus one. - ); - } else { - referrers = []; + switch (leaderboard.rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + // Single type assertion per branch: rules.awardModel === "pie-split" guarantees the leaderboard + // is ReferrerLeaderboardPieSplit, but TypeScript cannot narrow a union on a nested property. + return buildLeaderboardPagePieSplit(pageContext, leaderboard as ReferrerLeaderboardPieSplit); + case ReferralProgramAwardModels.RevShareLimit: + return buildLeaderboardPageRevShareLimit( + pageContext, + leaderboard as ReferrerLeaderboardRevShareLimit, + ); } - - const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); - - return { - rules: leaderboard.rules, - referrers, - aggregatedMetrics: leaderboard.aggregatedMetrics, - pageContext, - accurateAsOf: leaderboard.accurateAsOf, - status, - }; }; diff --git a/packages/ens-referrals/src/v1/leaderboard.ts b/packages/ens-referrals/src/v1/leaderboard.ts index 57351e125..e033cb07f 100644 --- a/packages/ens-referrals/src/v1/leaderboard.ts +++ b/packages/ens-referrals/src/v1/leaderboard.ts @@ -1,96 +1,33 @@ -import type { Address } from "viem"; - import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; -import { type AggregatedReferrerMetrics, buildAggregatedReferrerMetrics } from "./aggregations"; import { - type AwardedReferrerMetrics, - buildAwardedReferrerMetrics, - buildRankedReferrerMetrics, - buildScoredReferrerMetrics, - type ReferrerMetrics, - sortReferrerMetrics, -} from "./referrer-metrics"; + buildReferrerLeaderboardPieSplit, + type ReferrerLeaderboardPieSplit, +} from "./award-models/pie-split/leaderboard"; +import { + buildReferrerLeaderboardRevShareLimit, + type ReferrerLeaderboardRevShareLimit, +} from "./award-models/rev-share-limit/leaderboard"; +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; +import type { ReferrerMetrics } from "./referrer-metrics"; import type { ReferralProgramRules } from "./rules"; /** * Represents a leaderboard for any number of referrers. + * + * Use `rules.awardModel` to determine the specific variant at runtime. */ -export interface ReferrerLeaderboard { - /** - * The rules of the referral program that generated the {@link ReferrerLeaderboard}. - */ - rules: ReferralProgramRules; - - /** - * The {@link AggregatedReferrerMetrics} for all `RankedReferrerMetrics` values in `leaderboard`. - */ - aggregatedMetrics: AggregatedReferrerMetrics; - - /** - * Ordered map containing `AwardedReferrerMetrics` for all referrers with 1 or more - * `totalReferrals` within the `rules` as of `updatedAt`. - * - * @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 `updatedAt`. - * @invariant If a fully-lowercase `Address` is not a key in this map then that `Address` had - * 0 `totalReferrals`, `totalIncrementalDuration`, and `score` within the - * `rules` as of `updatedAt`. - * @invariant Each value in this map is guaranteed to have a non-zero - * `totalReferrals`, `totalIncrementalDuration`, and `score`. - */ - referrers: Map; - - /** - * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboard} was accurate as of. - */ - accurateAsOf: UnixTimestamp; -} +export type ReferrerLeaderboard = ReferrerLeaderboardPieSplit | ReferrerLeaderboardRevShareLimit; export const buildReferrerLeaderboard = ( allReferrers: ReferrerMetrics[], rules: ReferralProgramRules, accurateAsOf: UnixTimestamp, ): ReferrerLeaderboard => { - const uniqueReferrers = new Set(allReferrers.map((referrer) => referrer.referrer)); - if (uniqueReferrers.size !== allReferrers.length) { - throw new Error( - "ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers", - ); - } - - if (accurateAsOf < rules.startTime && allReferrers.length > 0) { - throw new Error( - `ReferrerLeaderboard: accurateAsOf (${accurateAsOf}) is before startTime (${rules.startTime}) which indicates allReferrers should be empty, but allReferrers is not empty.`, - ); + switch (rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return buildReferrerLeaderboardPieSplit(allReferrers, rules, accurateAsOf); + case ReferralProgramAwardModels.RevShareLimit: + return buildReferrerLeaderboardRevShareLimit(allReferrers, rules, accurateAsOf); } - - const sortedReferrers = sortReferrerMetrics(allReferrers); - - const scoredReferrers = sortedReferrers.map((referrer) => buildScoredReferrerMetrics(referrer)); - - const rankedReferrers = scoredReferrers.map((referrer, index) => { - return buildRankedReferrerMetrics(referrer, index + 1, rules); - }); - - const aggregatedMetrics = buildAggregatedReferrerMetrics(rankedReferrers, rules); - - const awardedReferrers = rankedReferrers.map((referrer) => { - return buildAwardedReferrerMetrics(referrer, aggregatedMetrics, rules); - }); - - // Transform ordered list into an ordered map (preserves sort order) - const referrers = new Map( - awardedReferrers.map((referrer) => { - return [referrer.referrer, referrer]; - }), - ); - - return { - rules, - aggregatedMetrics, - referrers, - accurateAsOf, - }; }; diff --git a/packages/ens-referrals/src/v1/rank.ts b/packages/ens-referrals/src/v1/rank.ts deleted file mode 100644 index c63722b93..000000000 --- a/packages/ens-referrals/src/v1/rank.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { Address } from "viem"; - -import type { Duration } from "@ensnode/ensnode-sdk"; - -import { isPositiveInteger } from "./number"; -import type { ReferralProgramRules } from "./rules"; -import { calcReferrerScore, type ReferrerScore } from "./score"; - -/** - * The rank of a referrer relative to all other referrers, where 1 is the - * top-ranked referrer. - * - * @invariant Guaranteed to be a positive integer (> 0) - */ -export type ReferrerRank = number; - -export const validateReferrerRank = (rank: ReferrerRank): void => { - if (!isPositiveInteger(rank)) { - throw new Error(`Invalid ReferrerRank: ${rank}. ReferrerRank must be a positive integer.`); - } -}; - -/** - * Determine if a referrer with the given `rank` is qualified to receive a non-zero `awardPoolShare` according to the given `rules`. - * - * @param rank - The rank of the referrer relative to all other referrers on a {@link ReferrerLeaderboard}. - * @param rules - The rules of the referral program that generated the `rank`. - */ -export function isReferrerQualified(rank: ReferrerRank, rules: ReferralProgramRules): boolean { - return rank <= rules.maxQualifiedReferrers; -} - -/** - * Calculate the final score boost of a referrer based on their rank. - * - * @param rank - The rank of the referrer relative to all other referrers, where 1 is the - * top-ranked referrer. - * @returns The final score boost of the referrer as a number between 0 and 1 (inclusive). - */ -export function calcReferrerFinalScoreBoost( - rank: ReferrerRank, - rules: ReferralProgramRules, -): number { - if (!isReferrerQualified(rank, rules)) return 0; - - // Avoid division by zero when only a single referrer is qualified. - // In this case, that single referrer (rank 1) should receive the maximum boost. - if (rules.maxQualifiedReferrers === 1) return 1; - - return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1); -} - -/** - * Calculate the final score multiplier of a referrer based on their rank. - * - * @param rank - The rank of the referrer relative to all other referrers, where 1 is the - * top-ranked referrer. - * @returns The final score multiplier of the referrer as a number between 1 and 2 (inclusive). - */ -export function calcReferrerFinalScoreMultiplier( - rank: ReferrerRank, - rules: ReferralProgramRules, -): number { - return 1 + calcReferrerFinalScoreBoost(rank, rules); -} - -/** - * Calculate the final score of a referrer based on their score and final score boost. - * - * @param rank - The rank of the referrer relative to all other referrers. - * @param totalIncrementalDuration - The total incremental duration (in seconds) - * of referrals made by the referrer within the `rules`. - * @param rules - The rules of the referral program that generated the `rank`. - * @returns The final score of the referrer. - */ -export function calcReferrerFinalScore( - rank: ReferrerRank, - totalIncrementalDuration: Duration, - rules: ReferralProgramRules, -): ReferrerScore { - return ( - calcReferrerScore(totalIncrementalDuration) * calcReferrerFinalScoreMultiplier(rank, rules) - ); -} - -export interface ReferrerMetricsForComparison { - /** - * The total incremental duration (in seconds) of all referrals made by the referrer within - * the {@link ReferralProgramRules}. - */ - totalIncrementalDuration: Duration; - - /** - * The fully lowercase Ethereum address of the referrer. - * - * @invariant Guaranteed to be a valid EVM address in lowercase format. - */ - referrer: Address; -} - -export const compareReferrerMetrics = ( - a: ReferrerMetricsForComparison, - b: ReferrerMetricsForComparison, -): number => { - // Primary sort: totalIncrementalDuration (descending) - if (a.totalIncrementalDuration !== b.totalIncrementalDuration) { - 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; - return 0; -}; diff --git a/packages/ens-referrals/src/v1/referrer-metrics.ts b/packages/ens-referrals/src/v1/referrer-metrics.ts index c734fc418..9697198bf 100644 --- a/packages/ens-referrals/src/v1/referrer-metrics.ts +++ b/packages/ens-referrals/src/v1/referrer-metrics.ts @@ -1,32 +1,16 @@ import type { Address } from "viem"; -import { - type Duration, - type PriceEth, - type PriceUsdc, - priceEth, - priceUsdc, - scalePrice, -} from "@ensnode/ensnode-sdk"; -import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; +import type { Duration, PriceEth } from "@ensnode/ensnode-sdk"; +import { makePriceEthSchema } from "@ensnode/ensnode-sdk/internal"; import { normalizeAddress, validateLowercaseAddress } from "./address"; -import type { AggregatedReferrerMetrics } from "./aggregations"; import { validateNonNegativeInteger } from "./number"; -import { - calcReferrerFinalScore, - calcReferrerFinalScoreBoost, - compareReferrerMetrics, - isReferrerQualified, - type ReferrerRank, - validateReferrerRank, -} from "./rank"; -import type { ReferralProgramRules } from "./rules"; -import { calcReferrerScore, type ReferrerScore, validateReferrerScore } from "./score"; +import { ReferralProgramRules } from "./rules"; import { validateDuration } from "./time"; /** - * Represents metrics for a single referrer independent of other referrers. + * Metrics for a single referrer, as aggregated from the DB layer. + * Independent of other referrers and award model; does not carry an `awardModel` discriminant. */ export interface ReferrerMetrics { /** @@ -83,7 +67,6 @@ export const validateReferrerMetrics = (metrics: ReferrerMetrics): void => { validateNonNegativeInteger(metrics.totalReferrals); validateDuration(metrics.totalIncrementalDuration); - // Validate totalRevenueContribution using Zod schema const priceEthSchema = makePriceEthSchema("ReferrerMetrics.totalRevenueContribution"); const parseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); if (!parseResult.success) { @@ -92,333 +75,3 @@ export const validateReferrerMetrics = (metrics: ReferrerMetrics): void => { ); } }; - -export const sortReferrerMetrics = (referrers: ReferrerMetrics[]): ReferrerMetrics[] => { - return [...referrers].sort(compareReferrerMetrics); -}; - -/** - * Represents metrics for a single referrer independent of other referrers, - * including a calculation of the referrer's score. - */ -export interface ScoredReferrerMetrics extends ReferrerMetrics { - /** - * The referrer's score. - * - * @invariant Guaranteed to be `calcReferrerScore(totalIncrementalDuration)` - */ - score: ReferrerScore; -} - -export const buildScoredReferrerMetrics = (referrer: ReferrerMetrics): ScoredReferrerMetrics => { - const result = { - ...referrer, - score: calcReferrerScore(referrer.totalIncrementalDuration), - } satisfies ScoredReferrerMetrics; - - validateScoredReferrerMetrics(result); - return result; -}; - -export const validateScoredReferrerMetrics = (metrics: ScoredReferrerMetrics): void => { - validateReferrerMetrics(metrics); - validateReferrerScore(metrics.score); - - const expectedScore = calcReferrerScore(metrics.totalIncrementalDuration); - if (metrics.score !== expectedScore) { - throw new Error(`Referrer: Invalid score: ${metrics.score}, expected: ${expectedScore}.`); - } -}; - -/** - * Extends {@link ScoredReferrerMetrics} to include additional metrics - * relative to all other referrers on a {@link ReferrerLeaderboard} and {@link ReferralProgramRules}. - */ -export interface RankedReferrerMetrics extends ScoredReferrerMetrics { - /** - * The referrer's rank on the {@link ReferrerLeaderboard} relative to all other referrers. - */ - rank: ReferrerRank; - - /** - * Identifies if the referrer meets the qualifications of the {@link ReferralProgramRules} to receive a non-zero `awardPoolShare`. - * - * @invariant true if and only if `rank` is less than or equal to {@link ReferralProgramRules.maxQualifiedReferrers} - */ - isQualified: boolean; - - /** - * The referrer's final score boost. - * - * @invariant Guaranteed to be a number between 0 and 1 (inclusive) - * @invariant Calculated as: `1-((rank-1)/({@link ReferralProgramRules.maxQualifiedReferrers}-1))` if `isQualified` is `true`, else `0` - */ - finalScoreBoost: number; - - /** - * The referrer's final score. - * - * @invariant Calculated as: `score * (1 + finalScoreBoost)` - */ - finalScore: ReferrerScore; -} - -export const validateRankedReferrerMetrics = ( - metrics: RankedReferrerMetrics, - rules: ReferralProgramRules, -): void => { - validateScoredReferrerMetrics(metrics); - validateReferrerRank(metrics.rank); - - if (metrics.finalScoreBoost < 0 || metrics.finalScoreBoost > 1) { - throw new Error( - `Invalid RankedReferrerMetrics: Invalid finalScoreBoost: ${metrics.finalScoreBoost}. finalScoreBoost must be between 0 and 1 (inclusive).`, - ); - } - - validateReferrerScore(metrics.finalScore); - - const expectedIsQualified = isReferrerQualified(metrics.rank, rules); - if (metrics.isQualified !== expectedIsQualified) { - throw new Error( - `RankedReferrerMetrics: Invalid isQualified: ${metrics.isQualified}, expected: ${expectedIsQualified}.`, - ); - } - - const expectedFinalScoreBoost = calcReferrerFinalScoreBoost(metrics.rank, rules); - if (metrics.finalScoreBoost !== expectedFinalScoreBoost) { - throw new Error( - `RankedReferrerMetrics: Invalid finalScoreBoost: ${metrics.finalScoreBoost}, expected: ${expectedFinalScoreBoost}.`, - ); - } - - const expectedFinalScore = calcReferrerFinalScore( - metrics.rank, - metrics.totalIncrementalDuration, - rules, - ); - if (metrics.finalScore !== expectedFinalScore) { - throw new Error( - `RankedReferrerMetrics: Invalid finalScore: ${metrics.finalScore}, expected: ${expectedFinalScore}.`, - ); - } -}; - -export const buildRankedReferrerMetrics = ( - referrer: ScoredReferrerMetrics, - rank: ReferrerRank, - rules: ReferralProgramRules, -): RankedReferrerMetrics => { - const result = { - ...referrer, - rank, - isQualified: isReferrerQualified(rank, rules), - finalScoreBoost: calcReferrerFinalScoreBoost(rank, rules), - finalScore: calcReferrerFinalScore(rank, referrer.totalIncrementalDuration, rules), - } satisfies RankedReferrerMetrics; - validateRankedReferrerMetrics(result, rules); - return result; -}; - -/** - * Calculate the share of the award pool for a referrer. - * @param referrer - The referrer to calculate the award pool share for. - * @param aggregatedMetrics - Aggregated metrics for all referrers. - * @param rules - The rules of the referral program. - * @returns The referrer's share of the award pool as a number between 0 and 1 (inclusive). - */ -export const calcReferrerAwardPoolShare = ( - referrer: RankedReferrerMetrics, - aggregatedMetrics: AggregatedReferrerMetrics, - rules: ReferralProgramRules, -): number => { - if (!isReferrerQualified(referrer.rank, rules)) return 0; - if (aggregatedMetrics.grandTotalQualifiedReferrersFinalScore === 0) return 0; - - return ( - calcReferrerFinalScore(referrer.rank, referrer.totalIncrementalDuration, rules) / - aggregatedMetrics.grandTotalQualifiedReferrersFinalScore - ); -}; - -/** - * Extends {@link RankedReferrerMetrics} to include additional metrics - * relative to {@link AggregatedRankedReferrerMetrics}. - */ -export interface AwardedReferrerMetrics extends RankedReferrerMetrics { - /** - * The referrer's share of the award pool. - * - * @invariant Guaranteed to be a number between 0 and 1 (inclusive) - * @invariant Calculated as: `finalScore / {@link AggregatedRankedReferrerMetrics.grandTotalQualifiedReferrersFinalScore}` if `isQualified` is `true`, else `0` - */ - awardPoolShare: number; - - /** - * The approximate USDC value of the referrer's share of the {@link ReferralProgramRules.totalAwardPoolValue}. - * - * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRules.totalAwardPoolValue.amount} (inclusive) - * @invariant Calculated as: `awardPoolShare` * {@link ReferralProgramRules.totalAwardPoolValue.amount} - */ - awardPoolApproxValue: PriceUsdc; -} - -export const validateAwardedReferrerMetrics = ( - referrer: AwardedReferrerMetrics, - rules: ReferralProgramRules, -): void => { - validateRankedReferrerMetrics(referrer, rules); - if (referrer.awardPoolShare < 0 || referrer.awardPoolShare > 1) { - throw new Error( - `Invalid AwardedReferrerMetrics: ${referrer.awardPoolShare}. awardPoolShare must be between 0 and 1 (inclusive).`, - ); - } - - if ( - referrer.awardPoolApproxValue.amount < 0n || - referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount - ) { - throw new Error( - `Invalid AwardedReferrerMetrics: ${referrer.awardPoolApproxValue.amount.toString()}. awardPoolApproxValue must be between 0 and ${rules.totalAwardPoolValue.amount.toString()} (inclusive).`, - ); - } -}; - -export const buildAwardedReferrerMetrics = ( - referrer: RankedReferrerMetrics, - aggregatedMetrics: AggregatedReferrerMetrics, - rules: ReferralProgramRules, -): AwardedReferrerMetrics => { - const awardPoolShare = calcReferrerAwardPoolShare(referrer, aggregatedMetrics, rules); - - // Calculate the approximate USDC value by multiplying the share by the total award pool value - const awardPoolApproxValue = scalePrice(rules.totalAwardPoolValue, awardPoolShare); - - const result = { - ...referrer, - awardPoolShare, - awardPoolApproxValue, - } satisfies AwardedReferrerMetrics; - validateAwardedReferrerMetrics(result, rules); - return result; -}; - -/** - * Extends {@link AwardedReferrerMetrics} but with rank set to null to represent - * a referrer who is not on the leaderboard (has zero referrals within the rules associated with the leaderboard). - */ -export interface UnrankedReferrerMetrics - extends Omit { - /** - * The referrer is not on the leaderboard and therefore has no rank. - */ - rank: null; - - /** - * Always false for unranked referrers. - */ - isQualified: false; -} - -export const validateUnrankedReferrerMetrics = (metrics: UnrankedReferrerMetrics): void => { - validateScoredReferrerMetrics(metrics); - - if (metrics.rank !== null) { - throw new Error(`Invalid UnrankedReferrerMetrics: rank must be null, got: ${metrics.rank}.`); - } - - if (metrics.isQualified !== false) { - throw new Error( - `Invalid UnrankedReferrerMetrics: isQualified must be false, got: ${metrics.isQualified}.`, - ); - } - - if (metrics.totalReferrals !== 0) { - throw new Error( - `Invalid UnrankedReferrerMetrics: totalReferrals must be 0, got: ${metrics.totalReferrals}.`, - ); - } - - if (metrics.totalIncrementalDuration !== 0) { - throw new Error( - `Invalid UnrankedReferrerMetrics: totalIncrementalDuration must be 0, got: ${metrics.totalIncrementalDuration}.`, - ); - } - - // Validate totalRevenueContribution using Zod schema - const priceEthSchema = makePriceEthSchema("UnrankedReferrerMetrics.totalRevenueContribution"); - const ethParseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); - if (!ethParseResult.success) { - throw new Error( - `Invalid UnrankedReferrerMetrics: totalRevenueContribution validation failed: ${ethParseResult.error.message}`, - ); - } - if (metrics.totalRevenueContribution.amount !== 0n) { - throw new Error( - `Invalid UnrankedReferrerMetrics: totalRevenueContribution.amount must be 0n, got: ${metrics.totalRevenueContribution.amount.toString()}.`, - ); - } - - if (metrics.score !== 0) { - throw new Error(`Invalid UnrankedReferrerMetrics: score must be 0, got: ${metrics.score}.`); - } - - if (metrics.finalScoreBoost !== 0) { - throw new Error( - `Invalid UnrankedReferrerMetrics: finalScoreBoost must be 0, got: ${metrics.finalScoreBoost}.`, - ); - } - - if (metrics.finalScore !== 0) { - throw new Error( - `Invalid UnrankedReferrerMetrics: finalScore must be 0, got: ${metrics.finalScore}.`, - ); - } - - if (metrics.awardPoolShare !== 0) { - throw new Error( - `Invalid UnrankedReferrerMetrics: awardPoolShare must be 0, got: ${metrics.awardPoolShare}.`, - ); - } - - // Validate awardPoolApproxValue using Zod schema - const priceUsdcSchema = makePriceUsdcSchema("UnrankedReferrerMetrics.awardPoolApproxValue"); - const usdcParseResult = priceUsdcSchema.safeParse(metrics.awardPoolApproxValue); - if (!usdcParseResult.success) { - throw new Error( - `Invalid UnrankedReferrerMetrics: awardPoolApproxValue validation failed: ${usdcParseResult.error.message}`, - ); - } - if (metrics.awardPoolApproxValue.amount !== 0n) { - throw new Error( - `Invalid UnrankedReferrerMetrics: awardPoolApproxValue must be 0n, got: ${metrics.awardPoolApproxValue.amount.toString()}.`, - ); - } -}; - -/** - * Build an unranked zero-score referrer record for a referrer address that is not in the leaderboard. - * - * This is useful when you want to return a referrer record for an address that has no referrals - * and is not qualified for the leaderboard. - * - * @param referrer - The referrer address - * @returns An {@link UnrankedReferrerMetrics} with zero values for all metrics and null rank - */ -export const buildUnrankedReferrerMetrics = (referrer: Address): UnrankedReferrerMetrics => { - const baseMetrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); - const scoredMetrics = buildScoredReferrerMetrics(baseMetrics); - - const result = { - ...scoredMetrics, - rank: null, - isQualified: false, - finalScoreBoost: 0, - finalScore: 0, - awardPoolShare: 0, - awardPoolApproxValue: priceUsdc(0n), - } satisfies UnrankedReferrerMetrics; - - validateUnrankedReferrerMetrics(result); - return result; -}; diff --git a/packages/ens-referrals/src/v1/rules.ts b/packages/ens-referrals/src/v1/rules.ts index 13c7137e8..d44fe82a8 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -1,101 +1,10 @@ -import type { AccountId, PriceUsdc, UnixTimestamp } from "@ensnode/ensnode-sdk"; -import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; - -import { validateNonNegativeInteger } from "./number"; -import { validateUnixTimestamp } from "./time"; - -export interface ReferralProgramRules { - /** - * The total value of the award pool in USDC. - * - * NOTE: Awards will actually be distributed in $ENS tokens. - */ - totalAwardPoolValue: PriceUsdc; - - /** - * The maximum number of referrers that will qualify to receive a non-zero `awardPoolShare`. - * - * @invariant Guaranteed to be a non-negative integer (>= 0) - */ - maxQualifiedReferrers: number; - - /** - * The start time of the referral program. - */ - startTime: UnixTimestamp; - - /** - * The end time of the referral program. - * @invariant Guaranteed to be greater than or equal to `startTime` - */ - endTime: UnixTimestamp; - - /** - * The account ID of the subregistry for the referral program. - */ - subregistryId: AccountId; - - /** - * URL to the full rules document for these rules. - * @example new URL("https://ensawards.org/ens-holiday-awards-rules") - */ - rulesUrl: URL; -} - -export const validateReferralProgramRules = (rules: ReferralProgramRules): void => { - // Validate totalAwardPoolValue using Zod schema - const priceUsdcSchema = makePriceUsdcSchema("ReferralProgramRules.totalAwardPoolValue"); - const parseResult = priceUsdcSchema.safeParse(rules.totalAwardPoolValue); - if (!parseResult.success) { - throw new Error( - `ReferralProgramRules: totalAwardPoolValue validation failed: ${parseResult.error.message}`, - ); - } - - // Validate subregistryId using Zod schema - const accountIdSchema = makeAccountIdSchema("ReferralProgramRules.subregistryId"); - const accountIdParseResult = accountIdSchema.safeParse(rules.subregistryId); - if (!accountIdParseResult.success) { - throw new Error( - `ReferralProgramRules: subregistryId validation failed: ${accountIdParseResult.error.message}`, - ); - } - - validateNonNegativeInteger(rules.maxQualifiedReferrers); - validateUnixTimestamp(rules.startTime); - validateUnixTimestamp(rules.endTime); - - if (!(rules.rulesUrl instanceof URL)) { - throw new Error( - `ReferralProgramRules: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, - ); - } - - if (rules.endTime < rules.startTime) { - throw new Error( - `ReferralProgramRules: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, - ); - } -}; - -export const buildReferralProgramRules = ( - totalAwardPoolValue: PriceUsdc, - maxQualifiedReferrers: number, - startTime: UnixTimestamp, - endTime: UnixTimestamp, - subregistryId: AccountId, - rulesUrl: URL, -): ReferralProgramRules => { - const result = { - totalAwardPoolValue, - maxQualifiedReferrers, - startTime, - endTime, - subregistryId, - rulesUrl, - } satisfies ReferralProgramRules; - - validateReferralProgramRules(result); - - return result; -}; +import type { ReferralProgramRulesPieSplit } from "./award-models/pie-split/rules"; +import type { ReferralProgramRulesRevShareLimit } from "./award-models/rev-share-limit/rules"; + +/** + * The rules of a referral program edition. + * + * Use `awardModel` to discriminate between rule types at runtime. + * Internal business logic only handles the known variants listed here. + */ +export type ReferralProgramRules = ReferralProgramRulesPieSplit | ReferralProgramRulesRevShareLimit; diff --git a/packages/ens-referrals/src/v1/score.ts b/packages/ens-referrals/src/v1/score.ts deleted file mode 100644 index 5995b6e1d..000000000 --- a/packages/ens-referrals/src/v1/score.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Duration } from "@ensnode/ensnode-sdk"; - -import { SECONDS_PER_YEAR } from "./time"; - -/** - * The score of a referrer. - * - * @invariant Guaranteed to be a finite non-negative number (>= 0) - */ -export type ReferrerScore = number; - -export const isValidReferrerScore = (score: ReferrerScore): boolean => { - return score >= 0 && Number.isFinite(score); -}; - -export const validateReferrerScore = (score: ReferrerScore): void => { - if (!isValidReferrerScore(score)) { - throw new Error( - `Invalid referrer score: ${score}. Referrer score must be a finite non-negative number.`, - ); - } -}; - -/** - * Calculate the score of a referrer based on the total incremental duration - * (in seconds) of registrations and renewals for direct subnames of .eth - * referred by the referrer within the ENS Holiday Awards period. - * - * @param totalIncrementalDuration - The total incremental duration (in seconds) - * of referrals made by a referrer within the {@link ReferralProgramRules}. - * @returns The score of the referrer. - */ -export const calcReferrerScore = (totalIncrementalDuration: Duration): ReferrerScore => { - return totalIncrementalDuration / SECONDS_PER_YEAR; -}; From 4829b182c10baf888d4b752c18936b34f2aefb5e Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 23 Feb 2026 15:19:18 +0100 Subject: [PATCH 02/15] type discriminating --- .../src/handlers/ensanalytics-api-v1.test.ts | 2 + .../get-referrer-leaderboard-v1.test.ts | 1 + .../referrer-leaderboard/mocks-v1.ts | 3 + .../ens-referrals/src/v1/api/serialize.ts | 40 ++------ .../ens-referrals/src/v1/api/zod-schemas.ts | 45 ++------- .../award-models/pie-split/api/serialize.ts | 3 + .../award-models/pie-split/api/zod-schemas.ts | 93 ++++++++++++------ .../award-models/pie-split/edition-metrics.ts | 17 ++++ .../pie-split/leaderboard-page.ts | 9 ++ .../v1/award-models/pie-split/leaderboard.ts | 10 +- .../rev-share-limit/api/serialize.ts | 3 + .../rev-share-limit/api/zod-schemas.ts | 95 ++++++++++++------- .../rev-share-limit/edition-metrics.ts | 17 ++++ .../rev-share-limit/leaderboard-page.ts | 11 ++- .../rev-share-limit/leaderboard.ts | 10 +- .../award-models/shared/leaderboard-page.ts | 6 ++ .../ens-referrals/src/v1/edition-metrics.ts | 40 ++++---- .../src/v1/leaderboard-page.test.ts | 2 + .../ens-referrals/src/v1/leaderboard-page.ts | 15 +-- packages/ens-referrals/src/v1/leaderboard.ts | 2 +- 20 files changed, 260 insertions(+), 164 deletions(-) diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index f2bc48f4f..80e84d478 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -365,6 +365,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerMetricsEditionsResponseCodes.Ok, data: { "2025-12": { + awardModel: populatedReferrerLeaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, @@ -373,6 +374,7 @@ describe("/v1/ensanalytics", () => { status: ReferralProgramStatuses.Active, }, "2026-03": { + awardModel: populatedReferrerLeaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 5df7d20b8..469f5495c 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -120,6 +120,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { const result = await getReferrerLeaderboard(rules, accurateAsOf); expect(result).toMatchObject({ + awardModel: rules.awardModel, aggregatedMetrics: { grandTotalIncrementalDuration: 0, grandTotalRevenueContribution: { diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts index 011a1bddf..03837ef46 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts @@ -176,6 +176,7 @@ export const dbResultsReferrerLeaderboard: ReferrerMetrics[] = [ ]; export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), @@ -200,6 +201,7 @@ export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = { }; export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), @@ -691,6 +693,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { export const referrerLeaderboardPageResponseOk = { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index e83d47955..0472b69b2 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -4,22 +4,12 @@ import { serializeReferrerEditionMetricsUnrankedPieSplit, serializeReferrerLeaderboardPagePieSplit, } from "../award-models/pie-split/api/serialize"; -import type { - ReferrerEditionMetricsRankedPieSplit, - ReferrerEditionMetricsUnrankedPieSplit, -} from "../award-models/pie-split/edition-metrics"; -import type { ReferrerLeaderboardPagePieSplit } from "../award-models/pie-split/leaderboard-page"; import { serializeReferralProgramRulesRevShareLimit, serializeReferrerEditionMetricsRankedRevShareLimit, serializeReferrerEditionMetricsUnrankedRevShareLimit, serializeReferrerLeaderboardPageRevShareLimit, } from "../award-models/rev-share-limit/api/serialize"; -import type { - ReferrerEditionMetricsRankedRevShareLimit, - ReferrerEditionMetricsUnrankedRevShareLimit, -} from "../award-models/rev-share-limit/edition-metrics"; -import type { ReferrerLeaderboardPageRevShareLimit } from "../award-models/rev-share-limit/leaderboard-page"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import type { ReferralProgramEditionConfig } from "../edition"; import type { @@ -71,15 +61,11 @@ export function serializeReferralProgramRules( function serializeReferrerLeaderboardPage( page: ReferrerLeaderboardPage, ): SerializedReferrerLeaderboardPage { - switch (page.rules.awardModel) { + switch (page.awardModel) { case ReferralProgramAwardModels.PieSplit: - // Single type assertion per branch: rules.awardModel === "pie-split" guarantees all correlated - // fields are the pie-split variant, but TypeScript cannot narrow a union on a nested property. - return serializeReferrerLeaderboardPagePieSplit(page as ReferrerLeaderboardPagePieSplit); + return serializeReferrerLeaderboardPagePieSplit(page); case ReferralProgramAwardModels.RevShareLimit: - return serializeReferrerLeaderboardPageRevShareLimit( - page as ReferrerLeaderboardPageRevShareLimit, - ); + return serializeReferrerLeaderboardPageRevShareLimit(page); } } @@ -89,15 +75,11 @@ function serializeReferrerLeaderboardPage( function serializeReferrerEditionMetricsRanked( detail: ReferrerEditionMetricsRanked, ): SerializedReferrerEditionMetricsRanked { - switch (detail.rules.awardModel) { + switch (detail.awardModel) { case ReferralProgramAwardModels.PieSplit: - return serializeReferrerEditionMetricsRankedPieSplit( - detail as ReferrerEditionMetricsRankedPieSplit, - ); + return serializeReferrerEditionMetricsRankedPieSplit(detail); case ReferralProgramAwardModels.RevShareLimit: - return serializeReferrerEditionMetricsRankedRevShareLimit( - detail as ReferrerEditionMetricsRankedRevShareLimit, - ); + return serializeReferrerEditionMetricsRankedRevShareLimit(detail); } } @@ -107,15 +89,11 @@ function serializeReferrerEditionMetricsRanked( function serializeReferrerEditionMetricsUnranked( detail: ReferrerEditionMetricsUnranked, ): SerializedReferrerEditionMetricsUnranked { - switch (detail.rules.awardModel) { + switch (detail.awardModel) { case ReferralProgramAwardModels.PieSplit: - return serializeReferrerEditionMetricsUnrankedPieSplit( - detail as ReferrerEditionMetricsUnrankedPieSplit, - ); + return serializeReferrerEditionMetricsUnrankedPieSplit(detail); case ReferralProgramAwardModels.RevShareLimit: - return serializeReferrerEditionMetricsUnrankedRevShareLimit( - detail as ReferrerEditionMetricsUnrankedRevShareLimit, - ); + return serializeReferrerEditionMetricsUnrankedRevShareLimit(detail); } } diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 805ab927b..639446865 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -13,14 +13,12 @@ import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; import { makeReferralProgramRulesPieSplitSchema, - makeReferrerEditionMetricsRankedPieSplitSchema, - makeReferrerEditionMetricsUnrankedPieSplitSchema, + makeReferrerEditionMetricsPieSplitSchema, makeReferrerLeaderboardPagePieSplitSchema, } from "../award-models/pie-split/api/zod-schemas"; import { makeReferralProgramRulesRevShareLimitSchema, - makeReferrerEditionMetricsRankedRevShareLimitSchema, - makeReferrerEditionMetricsUnrankedRevShareLimitSchema, + makeReferrerEditionMetricsRevShareLimitSchema, makeReferrerLeaderboardPageRevShareLimitSchema, } from "../award-models/rev-share-limit/api/zod-schemas"; import { @@ -51,15 +49,16 @@ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralPro ]); /** - * Schema for {@link ReferrerLeaderboardPage} + * Schema for {@link ReferrerLeaderboardPage}. */ export const makeReferrerLeaderboardPageSchema = (valueLabel: string = "ReferrerLeaderboardPage") => z.union([ makeReferrerLeaderboardPagePieSplitSchema(valueLabel), makeReferrerLeaderboardPageRevShareLimitSchema(valueLabel), - // Passthrough for unknown future award model types + // Passthrough catch-all for unknown future award model types. + // Servers may introduce new award model types at any time without breaking existing clients. z - .object({ rules: z.object({ awardModel: z.string() }).passthrough() }) + .object({ awardModel: z.string() }) .passthrough(), ]); @@ -98,36 +97,12 @@ export const makeReferrerLeaderboardPageResponseSchema = ( ]); /** - * Schema for {@link ReferrerEditionMetricsRanked} (with ranked metrics) - */ -export const makeReferrerEditionMetricsRankedSchema = ( - valueLabel: string = "ReferrerEditionMetricsRanked", -) => - z.union([ - makeReferrerEditionMetricsRankedPieSplitSchema(valueLabel), - makeReferrerEditionMetricsRankedRevShareLimitSchema(valueLabel), - ]); - -/** - * Schema for {@link ReferrerEditionMetricsUnranked} (with unranked metrics) - */ -export const makeReferrerEditionMetricsUnrankedSchema = ( - valueLabel: string = "ReferrerEditionMetricsUnranked", -) => - z.union([ - makeReferrerEditionMetricsUnrankedPieSplitSchema(valueLabel), - makeReferrerEditionMetricsUnrankedRevShareLimitSchema(valueLabel), - ]); - -/** - * Schema for {@link ReferrerEditionMetrics} (union of all ranked and unranked model variants) + * Schema for {@link ReferrerEditionMetrics} (discriminated union of all ranked and unranked model variants). */ export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerEditionMetrics") => - z.union([ - makeReferrerEditionMetricsRankedPieSplitSchema(valueLabel), - makeReferrerEditionMetricsRankedRevShareLimitSchema(valueLabel), - makeReferrerEditionMetricsUnrankedPieSplitSchema(valueLabel), - makeReferrerEditionMetricsUnrankedRevShareLimitSchema(valueLabel), + z.discriminatedUnion("awardModel", [ + makeReferrerEditionMetricsPieSplitSchema(valueLabel), + makeReferrerEditionMetricsRevShareLimitSchema(valueLabel), ]); /** diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts index 081129776..3bbdbc775 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts @@ -99,6 +99,7 @@ export function serializeReferrerEditionMetricsRankedPieSplit( detail: ReferrerEditionMetricsRankedPieSplit, ): SerializedReferrerEditionMetricsRankedPieSplit { return { + awardModel: detail.awardModel, type: detail.type, rules: serializeReferralProgramRulesPieSplit(detail.rules), referrer: serializeAwardedReferrerMetricsPieSplit(detail.referrer), @@ -115,6 +116,7 @@ export function serializeReferrerEditionMetricsUnrankedPieSplit( detail: ReferrerEditionMetricsUnrankedPieSplit, ): SerializedReferrerEditionMetricsUnrankedPieSplit { return { + awardModel: detail.awardModel, type: detail.type, rules: serializeReferralProgramRulesPieSplit(detail.rules), referrer: serializeUnrankedReferrerMetricsPieSplit(detail.referrer), @@ -131,6 +133,7 @@ export function serializeReferrerLeaderboardPagePieSplit( page: ReferrerLeaderboardPagePieSplit, ): SerializedReferrerLeaderboardPagePieSplit { return { + awardModel: page.awardModel, rules: serializeReferralProgramRulesPieSplit(page.rules), referrers: page.referrers.map(serializeAwardedReferrerMetricsPieSplit), aggregatedMetrics: serializeAggregatedReferrerMetricsPieSplit(page.aggregatedMetrics), diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts index d7b183d18..c3447fcf3 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts @@ -18,6 +18,7 @@ import { makeReferrerLeaderboardPageContextSchema, } from "../../shared/api/zod-schemas"; import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; +import { ReferralProgramAwardModels } from "../../shared/rules"; /** * Schema for {@link ReferralProgramRulesPieSplit}. @@ -120,16 +121,22 @@ export const makeAggregatedReferrerMetricsPieSplitSchema = ( export const makeReferrerEditionMetricsRankedPieSplitSchema = ( valueLabel: string = "ReferrerEditionMetricsRankedPieSplit", ) => - z.object({ - type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), - rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), - referrer: makeAwardedReferrerMetricsPieSplitSchema(`${valueLabel}.referrer`), - aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( - `${valueLabel}.aggregatedMetrics`, - ), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z + .object({ + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), + type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + referrer: makeAwardedReferrerMetricsPieSplitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); /** * Schema for {@link ReferrerEditionMetricsUnrankedPieSplit}. @@ -137,16 +144,34 @@ export const makeReferrerEditionMetricsRankedPieSplitSchema = ( export const makeReferrerEditionMetricsUnrankedPieSplitSchema = ( valueLabel: string = "ReferrerEditionMetricsUnrankedPieSplit", ) => - z.object({ - type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), - rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), - referrer: makeUnrankedReferrerMetricsPieSplitSchema(`${valueLabel}.referrer`), - aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( - `${valueLabel}.aggregatedMetrics`, - ), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z + .object({ + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), + type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + referrer: makeUnrankedReferrerMetricsPieSplitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); + +/** + * Schema for all {@link ReferrerEditionMetrics} variants of the pie-split award model + * (both ranked and unranked). + */ +export const makeReferrerEditionMetricsPieSplitSchema = ( + valueLabel: string = "ReferrerEditionMetricsPieSplit", +) => + z.discriminatedUnion("type", [ + makeReferrerEditionMetricsRankedPieSplitSchema(valueLabel), + makeReferrerEditionMetricsUnrankedPieSplitSchema(valueLabel), + ]); /** * Schema for {@link ReferrerLeaderboardPagePieSplit}. @@ -154,13 +179,21 @@ export const makeReferrerEditionMetricsUnrankedPieSplitSchema = ( export const makeReferrerLeaderboardPagePieSplitSchema = ( valueLabel: string = "ReferrerLeaderboardPagePieSplit", ) => - z.object({ - rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), - referrers: z.array(makeAwardedReferrerMetricsPieSplitSchema(`${valueLabel}.referrers[record]`)), - aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( - `${valueLabel}.aggregatedMetrics`, - ), - pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z + .object({ + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), + rules: makeReferralProgramRulesPieSplitSchema(`${valueLabel}.rules`), + referrers: z.array( + makeAwardedReferrerMetricsPieSplitSchema(`${valueLabel}.referrers[record]`), + ), + aggregatedMetrics: makeAggregatedReferrerMetricsPieSplitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts index 7253c13a3..934a04d82 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts @@ -2,6 +2,7 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; import type { ReferralProgramStatusId } from "../../status"; import type { ReferrerEditionMetricsTypeIds } from "../shared/edition-metrics"; +import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; import type { AwardedReferrerMetricsPieSplit, UnrankedReferrerMetricsPieSplit } from "./metrics"; import type { ReferralProgramRulesPieSplit } from "./rules"; @@ -13,10 +14,18 @@ import type { ReferralProgramRulesPieSplit } from "./rules"; * * Invariants: * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. + * - `awardModel` is always {@link ReferralProgramAwardModels.PieSplit} and equals `rules.awardModel`. * * @see {@link AwardedReferrerMetricsPieSplit} */ export interface ReferrerEditionMetricsRankedPieSplit { + /** + * Discriminant identifying this as data from a pie-split leaderboard edition. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.PieSplit}). + */ + awardModel: typeof ReferralProgramAwardModels.PieSplit; + /** * The type of referrer edition metrics data. */ @@ -59,10 +68,18 @@ export interface ReferrerEditionMetricsRankedPieSplit { * * Invariants: * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. + * - `awardModel` is always {@link ReferralProgramAwardModels.PieSplit} and equals `rules.awardModel`. * * @see {@link UnrankedReferrerMetricsPieSplit} */ export interface ReferrerEditionMetricsUnrankedPieSplit { + /** + * Discriminant identifying this as data from a pie-split leaderboard edition. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.PieSplit}). + */ + awardModel: typeof ReferralProgramAwardModels.PieSplit; + /** * The type of referrer edition metrics data. */ diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts index 22282a8b5..dee11f4eb 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts @@ -4,6 +4,7 @@ import { type ReferrerLeaderboardPageContext, sliceReferrers, } from "../shared/leaderboard-page"; +import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; import type { ReferrerLeaderboardPieSplit } from "./leaderboard"; import type { AwardedReferrerMetricsPieSplit } from "./metrics"; @@ -13,6 +14,13 @@ import type { ReferralProgramRulesPieSplit } from "./rules"; * A page of referrers from the pie-split referrer leaderboard. */ export interface ReferrerLeaderboardPagePieSplit extends BaseReferrerLeaderboardPage { + /** + * Discriminant identifying this as a page from a pie-split leaderboard. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.PieSplit}). + */ + awardModel: typeof ReferralProgramAwardModels.PieSplit; + /** * The {@link ReferralProgramRulesPieSplit} used to generate the {@link ReferrerLeaderboardPieSplit} * that this {@link ReferrerLeaderboardPagePieSplit} comes from. @@ -40,6 +48,7 @@ export function buildLeaderboardPagePieSplit( ): ReferrerLeaderboardPagePieSplit { const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); return { + awardModel: leaderboard.awardModel, rules: leaderboard.rules, referrers: sliceReferrers(leaderboard.referrers, pageContext), aggregatedMetrics: leaderboard.aggregatedMetrics, diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts index 1d9fd78e2..b09176596 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts @@ -5,6 +5,7 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; import type { ReferrerMetrics } from "../../referrer-metrics"; import { assertLeaderboardInputs } from "../shared/leaderboard-guards"; import { sortReferrerMetrics } from "../shared/rank"; +import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsPieSplit } from "./aggregations"; import { buildAggregatedReferrerMetricsPieSplit } from "./aggregations"; import type { AwardedReferrerMetricsPieSplit } from "./metrics"; @@ -19,6 +20,13 @@ import type { ReferralProgramRulesPieSplit } from "./rules"; * Represents a leaderboard with the pie-split award model for any number of referrers. */ export interface ReferrerLeaderboardPieSplit { + /** + * Discriminant identifying this as a pie-split leaderboard. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.PieSplit}). + */ + awardModel: typeof ReferralProgramAwardModels.PieSplit; + /** * The rules of the referral program that generated the {@link ReferrerLeaderboardPieSplit}. */ @@ -73,5 +81,5 @@ export const buildReferrerLeaderboardPieSplit = ( const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); - return { rules, aggregatedMetrics, referrers, accurateAsOf }; + return { awardModel: rules.awardModel, rules, aggregatedMetrics, referrers, accurateAsOf }; }; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts index ca3364032..098e1ac8d 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts @@ -96,6 +96,7 @@ export function serializeReferrerEditionMetricsRankedRevShareLimit( detail: ReferrerEditionMetricsRankedRevShareLimit, ): SerializedReferrerEditionMetricsRankedRevShareLimit { return { + awardModel: detail.awardModel, type: detail.type, rules: serializeReferralProgramRulesRevShareLimit(detail.rules), referrer: serializeAwardedReferrerMetricsRevShareLimit(detail.referrer), @@ -112,6 +113,7 @@ export function serializeReferrerEditionMetricsUnrankedRevShareLimit( detail: ReferrerEditionMetricsUnrankedRevShareLimit, ): SerializedReferrerEditionMetricsUnrankedRevShareLimit { return { + awardModel: detail.awardModel, type: detail.type, rules: serializeReferralProgramRulesRevShareLimit(detail.rules), referrer: serializeUnrankedReferrerMetricsRevShareLimit(detail.referrer), @@ -128,6 +130,7 @@ export function serializeReferrerLeaderboardPageRevShareLimit( page: ReferrerLeaderboardPageRevShareLimit, ): SerializedReferrerLeaderboardPageRevShareLimit { return { + awardModel: page.awardModel, rules: serializeReferralProgramRulesRevShareLimit(page.rules), referrers: page.referrers.map(serializeAwardedReferrerMetricsRevShareLimit), aggregatedMetrics: serializeAggregatedReferrerMetricsRevShareLimit(page.aggregatedMetrics), diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts index 197a633b2..9348ae6b7 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -18,6 +18,7 @@ import { makeReferrerLeaderboardPageContextSchema, } from "../../shared/api/zod-schemas"; import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; +import { ReferralProgramAwardModels } from "../../shared/rules"; /** * Schema for {@link ReferralProgramRulesRevShareLimit}. @@ -102,16 +103,22 @@ export const makeAggregatedReferrerMetricsRevShareLimitSchema = ( export const makeReferrerEditionMetricsRankedRevShareLimitSchema = ( valueLabel: string = "ReferrerEditionMetricsRankedRevShareLimit", ) => - z.object({ - type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), - rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), - referrer: makeAwardedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrer`), - aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( - `${valueLabel}.aggregatedMetrics`, - ), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z + .object({ + awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), + type: z.literal(ReferrerEditionMetricsTypeIds.Ranked), + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + referrer: makeAwardedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); /** * Schema for {@link ReferrerEditionMetricsUnrankedRevShareLimit}. @@ -119,16 +126,34 @@ export const makeReferrerEditionMetricsRankedRevShareLimitSchema = ( export const makeReferrerEditionMetricsUnrankedRevShareLimitSchema = ( valueLabel: string = "ReferrerEditionMetricsUnrankedRevShareLimit", ) => - z.object({ - type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), - rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), - referrer: makeUnrankedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrer`), - aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( - `${valueLabel}.aggregatedMetrics`, - ), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z + .object({ + awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), + type: z.literal(ReferrerEditionMetricsTypeIds.Unranked), + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + referrer: makeUnrankedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrer`), + aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); + +/** + * Schema for all {@link ReferrerEditionMetrics} variants of the rev-share-limit award model + * (both ranked and unranked). + */ +export const makeReferrerEditionMetricsRevShareLimitSchema = ( + valueLabel: string = "ReferrerEditionMetricsRevShareLimit", +) => + z.discriminatedUnion("type", [ + makeReferrerEditionMetricsRankedRevShareLimitSchema(valueLabel), + makeReferrerEditionMetricsUnrankedRevShareLimitSchema(valueLabel), + ]); /** * Schema for {@link ReferrerLeaderboardPageRevShareLimit}. @@ -136,15 +161,21 @@ export const makeReferrerEditionMetricsUnrankedRevShareLimitSchema = ( export const makeReferrerLeaderboardPageRevShareLimitSchema = ( valueLabel: string = "ReferrerLeaderboardPageRevShareLimit", ) => - z.object({ - rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), - referrers: z.array( - makeAwardedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrers[record]`), - ), - aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( - `${valueLabel}.aggregatedMetrics`, - ), - pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), - status: makeReferralProgramStatusSchema(`${valueLabel}.status`), - accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), - }); + z + .object({ + awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), + rules: makeReferralProgramRulesRevShareLimitSchema(`${valueLabel}.rules`), + referrers: z.array( + makeAwardedReferrerMetricsRevShareLimitSchema(`${valueLabel}.referrers[record]`), + ), + aggregatedMetrics: makeAggregatedReferrerMetricsRevShareLimitSchema( + `${valueLabel}.aggregatedMetrics`, + ), + pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), + accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), + }) + .refine((data) => data.awardModel === data.rules.awardModel, { + message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, + path: ["awardModel"], + }); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts index beccc23aa..08e9d80d4 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts @@ -2,6 +2,7 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; import type { ReferralProgramStatusId } from "../../status"; import type { ReferrerEditionMetricsTypeIds } from "../shared/edition-metrics"; +import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import type { AwardedReferrerMetricsRevShareLimit, @@ -16,10 +17,18 @@ import type { ReferralProgramRulesRevShareLimit } from "./rules"; * * Invariants: * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. + * - `awardModel` is always {@link ReferralProgramAwardModels.RevShareLimit} and equals `rules.awardModel`. * * @see {@link AwardedReferrerMetricsRevShareLimit} */ export interface ReferrerEditionMetricsRankedRevShareLimit { + /** + * Discriminant identifying this as data from a rev-share-limit leaderboard edition. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.RevShareLimit}). + */ + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + /** * The type of referrer edition metrics data. */ @@ -62,10 +71,18 @@ export interface ReferrerEditionMetricsRankedRevShareLimit { * * Invariants: * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. + * - `awardModel` is always {@link ReferralProgramAwardModels.RevShareLimit} and equals `rules.awardModel`. * * @see {@link UnrankedReferrerMetricsRevShareLimit} */ export interface ReferrerEditionMetricsUnrankedRevShareLimit { + /** + * Discriminant identifying this as data from a rev-share-limit leaderboard edition. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.RevShareLimit}). + */ + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + /** * The type of referrer edition metrics data. */ diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts index 4d701f1e1..58a86bcb2 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts @@ -4,6 +4,7 @@ import { type ReferrerLeaderboardPageContext, sliceReferrers, } from "../shared/leaderboard-page"; +import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import type { ReferrerLeaderboardRevShareLimit } from "./leaderboard"; import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; @@ -14,7 +15,14 @@ import type { ReferralProgramRulesRevShareLimit } from "./rules"; */ export interface ReferrerLeaderboardPageRevShareLimit extends BaseReferrerLeaderboardPage { /** - * The {@link ReferralProgramRulesRevShareLimit} used to generate the {@link ReferrerLeaderboard} + * Discriminant identifying this as a page from a rev-share-limit leaderboard. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.RevShareLimit}). + */ + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + + /** + * The {@link ReferralProgramRulesRevShareLimit} used to generate the {@link ReferrerLeaderboardRevShareLimit} * that this {@link ReferrerLeaderboardPageRevShareLimit} comes from. */ rules: ReferralProgramRulesRevShareLimit; @@ -40,6 +48,7 @@ export function buildLeaderboardPageRevShareLimit( ): ReferrerLeaderboardPageRevShareLimit { const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); return { + awardModel: leaderboard.awardModel, rules: leaderboard.rules, referrers: sliceReferrers(leaderboard.referrers, pageContext), aggregatedMetrics: leaderboard.aggregatedMetrics, diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index c3be47839..40b79d36c 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -5,6 +5,7 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; import type { ReferrerMetrics } from "../../referrer-metrics"; import { assertLeaderboardInputs } from "../shared/leaderboard-guards"; import { sortReferrerMetrics } from "../shared/rank"; +import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import { buildAggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; @@ -19,6 +20,13 @@ import type { ReferralProgramRulesRevShareLimit } from "./rules"; * Represents a leaderboard with the rev-share-limit award model for any number of referrers. */ export interface ReferrerLeaderboardRevShareLimit { + /** + * Discriminant identifying this as a rev-share-limit leaderboard. + * + * @invariant Always equals `rules.awardModel` ({@link ReferralProgramAwardModels.RevShareLimit}). + */ + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + /** * The rules of the referral program that generated the {@link ReferrerLeaderboardRevShareLimit}. */ @@ -76,5 +84,5 @@ export const buildReferrerLeaderboardRevShareLimit = ( const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); - return { rules, aggregatedMetrics, referrers, accurateAsOf }; + return { awardModel: rules.awardModel, rules, aggregatedMetrics, referrers, accurateAsOf }; }; diff --git a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts index f875b45d3..f4933be8a 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts @@ -5,6 +5,7 @@ import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; import type { ReferrerLeaderboard } from "../../leaderboard"; import { isNonNegativeInteger, isPositiveInteger } from "../../number"; import type { ReferralProgramStatusId } from "../../status"; +import type { ReferralProgramAwardModel } from "./rules"; /** * The default number of referrers per leaderboard page. @@ -261,6 +262,11 @@ export const buildReferrerLeaderboardPageContext = ( * Base fields shared by all leaderboard page variants. */ export interface BaseReferrerLeaderboardPage { + /** + * Discriminant identifying the award model for this leaderboard page. + */ + awardModel: ReferralProgramAwardModel; + /** * The {@link ReferrerLeaderboardPageContext} of this page relative to the overall leaderboard. */ diff --git a/packages/ens-referrals/src/v1/edition-metrics.ts b/packages/ens-referrals/src/v1/edition-metrics.ts index a3a15ebad..88123f417 100644 --- a/packages/ens-referrals/src/v1/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -4,13 +4,11 @@ import type { ReferrerEditionMetricsRankedPieSplit, ReferrerEditionMetricsUnrankedPieSplit, } from "./award-models/pie-split/edition-metrics"; -import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; import { buildUnrankedReferrerMetricsPieSplit } from "./award-models/pie-split/metrics"; import type { ReferrerEditionMetricsRankedRevShareLimit, ReferrerEditionMetricsUnrankedRevShareLimit, } from "./award-models/rev-share-limit/edition-metrics"; -import type { ReferrerLeaderboardRevShareLimit } from "./award-models/rev-share-limit/leaderboard"; import { buildUnrankedReferrerMetricsRevShareLimit } from "./award-models/rev-share-limit/metrics"; import { ReferrerEditionMetricsTypeIds } from "./award-models/shared/edition-metrics"; import { ReferralProgramAwardModels } from "./award-models/shared/rules"; @@ -20,7 +18,7 @@ import { calcReferralProgramStatus } from "./status"; /** * Referrer edition metrics data for a specific referrer address on the leaderboard. * - * Use `rules.awardModel` to determine the specific model variant at runtime. + * Use `awardModel` to narrow the specific model variant at runtime. */ export type ReferrerEditionMetricsRanked = | ReferrerEditionMetricsRankedPieSplit @@ -29,7 +27,7 @@ export type ReferrerEditionMetricsRanked = /** * Referrer edition metrics data for a specific referrer address NOT on the leaderboard. * - * Use `rules.awardModel` to determine the specific model variant at runtime. + * Use `awardModel` to narrow the specific model variant at runtime. */ export type ReferrerEditionMetricsUnranked = | ReferrerEditionMetricsUnrankedPieSplit @@ -38,8 +36,8 @@ export type ReferrerEditionMetricsUnranked = /** * Referrer edition metrics data for a specific referrer address. * - * Use the `type` field to determine if the referrer is ranked or unranked. - * Use `rules.awardModel` to determine the award model variant. + * Use `type` to determine if the referrer is ranked or unranked. + * Use `awardModel` to narrow the award model variant. */ export type ReferrerEditionMetrics = ReferrerEditionMetricsRanked | ReferrerEditionMetricsUnranked; @@ -59,50 +57,50 @@ export const getReferrerEditionMetrics = ( ): ReferrerEditionMetrics => { const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); - switch (leaderboard.rules.awardModel) { + switch (leaderboard.awardModel) { case ReferralProgramAwardModels.PieSplit: { - // Single type assertion per branch: rules.awardModel === "pie-split" guarantees the leaderboard - // is ReferrerLeaderboardPieSplit, but TypeScript cannot narrow a union on a nested property. - const typedLeaderboard = leaderboard as ReferrerLeaderboardPieSplit; - const awardedReferrerMetrics = typedLeaderboard.referrers.get(referrer); + const awardedReferrerMetrics = leaderboard.referrers.get(referrer); if (awardedReferrerMetrics) { return { + awardModel: leaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Ranked, - rules: typedLeaderboard.rules, + rules: leaderboard.rules, referrer: awardedReferrerMetrics, - aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + aggregatedMetrics: leaderboard.aggregatedMetrics, status, accurateAsOf: leaderboard.accurateAsOf, } satisfies ReferrerEditionMetricsRankedPieSplit; } return { + awardModel: leaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Unranked, - rules: typedLeaderboard.rules, + rules: leaderboard.rules, referrer: buildUnrankedReferrerMetricsPieSplit(referrer), - aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + aggregatedMetrics: leaderboard.aggregatedMetrics, status, accurateAsOf: leaderboard.accurateAsOf, } satisfies ReferrerEditionMetricsUnrankedPieSplit; } case ReferralProgramAwardModels.RevShareLimit: { - const typedLeaderboard = leaderboard as ReferrerLeaderboardRevShareLimit; - const awardedReferrerMetrics = typedLeaderboard.referrers.get(referrer); + const awardedReferrerMetrics = leaderboard.referrers.get(referrer); if (awardedReferrerMetrics) { return { + awardModel: leaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Ranked, - rules: typedLeaderboard.rules, + rules: leaderboard.rules, referrer: awardedReferrerMetrics, - aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + aggregatedMetrics: leaderboard.aggregatedMetrics, status, accurateAsOf: leaderboard.accurateAsOf, } satisfies ReferrerEditionMetricsRankedRevShareLimit; } return { + awardModel: leaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Unranked, - rules: typedLeaderboard.rules, + rules: leaderboard.rules, referrer: buildUnrankedReferrerMetricsRevShareLimit(referrer), - aggregatedMetrics: typedLeaderboard.aggregatedMetrics, + aggregatedMetrics: leaderboard.aggregatedMetrics, status, accurateAsOf: leaderboard.accurateAsOf, } satisfies ReferrerEditionMetricsUnrankedRevShareLimit; diff --git a/packages/ens-referrals/src/v1/leaderboard-page.test.ts b/packages/ens-referrals/src/v1/leaderboard-page.test.ts index af0c4a279..a78e71b9d 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.test.ts @@ -19,6 +19,7 @@ describe("buildReferrerLeaderboardPageContext", () => { it("correctly evaluates `hasNext` when `leaderboard.referrers.size` and `recordsPerPage` are equal", () => { const leaderboard: ReferrerLeaderboardPieSplit = { + awardModel: "pie-split", rules: { awardModel: "pie-split", totalAwardPoolValue: priceUsdc(10000n), @@ -106,6 +107,7 @@ describe("buildReferrerLeaderboardPageContext", () => { it("Correctly builds the pagination context when `leaderboard.referrers.size` is 0", () => { const leaderboard: ReferrerLeaderboardPieSplit = { + awardModel: "pie-split", rules: { awardModel: "pie-split", totalAwardPoolValue: priceUsdc(10000n), diff --git a/packages/ens-referrals/src/v1/leaderboard-page.ts b/packages/ens-referrals/src/v1/leaderboard-page.ts index f216f586d..3b696ff5a 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.ts @@ -1,9 +1,7 @@ -import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; import { buildLeaderboardPagePieSplit, type ReferrerLeaderboardPagePieSplit, } from "./award-models/pie-split/leaderboard-page"; -import type { ReferrerLeaderboardRevShareLimit } from "./award-models/rev-share-limit/leaderboard"; import { buildLeaderboardPageRevShareLimit, type ReferrerLeaderboardPageRevShareLimit, @@ -18,7 +16,7 @@ import type { ReferrerLeaderboard } from "./leaderboard"; /** * A page of referrers from the referrer leaderboard. * - * Use `rules.awardModel` to determine the specific variant at runtime. Within each variant, + * Use `awardModel` to narrow the specific variant at runtime. Within each variant, * `rules`, `referrers`, and `aggregatedMetrics` are all guaranteed to be from the same model. */ export type ReferrerLeaderboardPage = @@ -31,15 +29,10 @@ export const getReferrerLeaderboardPage = ( ): ReferrerLeaderboardPage => { const pageContext = buildReferrerLeaderboardPageContext(pageParams, leaderboard); - switch (leaderboard.rules.awardModel) { + switch (leaderboard.awardModel) { case ReferralProgramAwardModels.PieSplit: - // Single type assertion per branch: rules.awardModel === "pie-split" guarantees the leaderboard - // is ReferrerLeaderboardPieSplit, but TypeScript cannot narrow a union on a nested property. - return buildLeaderboardPagePieSplit(pageContext, leaderboard as ReferrerLeaderboardPieSplit); + return buildLeaderboardPagePieSplit(pageContext, leaderboard); case ReferralProgramAwardModels.RevShareLimit: - return buildLeaderboardPageRevShareLimit( - pageContext, - leaderboard as ReferrerLeaderboardRevShareLimit, - ); + return buildLeaderboardPageRevShareLimit(pageContext, leaderboard); } }; diff --git a/packages/ens-referrals/src/v1/leaderboard.ts b/packages/ens-referrals/src/v1/leaderboard.ts index e033cb07f..056769fe7 100644 --- a/packages/ens-referrals/src/v1/leaderboard.ts +++ b/packages/ens-referrals/src/v1/leaderboard.ts @@ -15,7 +15,7 @@ import type { ReferralProgramRules } from "./rules"; /** * Represents a leaderboard for any number of referrers. * - * Use `rules.awardModel` to determine the specific variant at runtime. + * Use `awardModel` to narrow the specific variant at runtime. */ export type ReferrerLeaderboard = ReferrerLeaderboardPieSplit | ReferrerLeaderboardRevShareLimit; From ec71e94fb20241b64950d4fa0a631ac2fd9ac944 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 23 Feb 2026 18:20:14 +0100 Subject: [PATCH 03/15] unknown award model handling --- .../src/handlers/ensanalytics-api-v1.test.ts | 82 +++++++++++-------- .../get-referrer-leaderboard-v1.test.ts | 42 +++------- .../ens-referrals/src/v1/api/zod-schemas.ts | 30 ++++--- .../src/v1/award-models/pie-split/metrics.ts | 2 +- packages/ens-referrals/src/v1/client.ts | 11 +++ 5 files changed, 88 insertions(+), 79 deletions(-) diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 80e84d478..8669f615c 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -32,6 +32,7 @@ import { deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, + ReferralProgramAwardModels, ReferralProgramEditionConfigSetResponseCodes, type ReferralProgramEditionSlug, ReferralProgramStatuses, @@ -41,7 +42,6 @@ import { type ReferrerLeaderboardPageResponseOk, ReferrerMetricsEditionsResponseCodes, type ReferrerMetricsEditionsResponseOk, - type UnrankedReferrerMetricsPieSplit, } from "@namehash/ens-referrals/v1"; import { parseTimestamp, parseUsdc, type SWRCache } from "@ensnode/ensnode-sdk"; @@ -446,24 +446,31 @@ describe("/v1/ensanalytics", () => { const edition2 = response.data["2026-03"]!; // Check 2025-12 + expect(edition1.awardModel).toBe(ReferralProgramAwardModels.PieSplit); expect(edition1.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); - expect(edition1.rules).toEqual(populatedReferrerLeaderboard.rules); - expect(edition1.aggregatedMetrics).toEqual(populatedReferrerLeaderboard.aggregatedMetrics); - const edition1Referrer = edition1.referrer as UnrankedReferrerMetricsPieSplit; - expect(edition1Referrer.referrer).toBe(nonExistingReferrer); - expect(edition1Referrer.rank).toBe(null); - expect(edition1Referrer.totalReferrals).toBe(0); - expect(edition1Referrer.totalIncrementalDuration).toBe(0); - expect(edition1Referrer.score).toBe(0); - expect(edition1Referrer.isQualified).toBe(false); - expect(edition1Referrer.finalScoreBoost).toBe(0); - expect(edition1Referrer.finalScore).toBe(0); - expect(edition1Referrer.awardPoolShare).toBe(0); - expect(edition1Referrer.awardPoolApproxValue).toStrictEqual({ - currency: "USDC", - amount: 0n, - }); - expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); + if ( + edition1.awardModel === ReferralProgramAwardModels.PieSplit && + edition1.type === ReferrerEditionMetricsTypeIds.Unranked + ) { + expect(edition1.rules).toEqual(populatedReferrerLeaderboard.rules); + expect(edition1.aggregatedMetrics).toEqual( + populatedReferrerLeaderboard.aggregatedMetrics, + ); + expect(edition1.referrer.referrer).toBe(nonExistingReferrer); + expect(edition1.referrer.rank).toBe(null); + expect(edition1.referrer.totalReferrals).toBe(0); + expect(edition1.referrer.totalIncrementalDuration).toBe(0); + expect(edition1.referrer.score).toBe(0); + expect(edition1.referrer.isQualified).toBe(false); + expect(edition1.referrer.finalScoreBoost).toBe(0); + expect(edition1.referrer.finalScore).toBe(0); + expect(edition1.referrer.awardPoolShare).toBe(0); + expect(edition1.referrer.awardPoolApproxValue).toStrictEqual({ + currency: "USDC", + amount: 0n, + }); + expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); + } // Check 2026-03 expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); @@ -528,24 +535,29 @@ describe("/v1/ensanalytics", () => { const edition2 = response.data["2026-03"]!; // Check 2025-12 + expect(edition1.awardModel).toBe(ReferralProgramAwardModels.PieSplit); expect(edition1.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); - expect(edition1.rules).toEqual(emptyReferralLeaderboard.rules); - expect(edition1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); - const edition1Referrer2 = edition1.referrer as UnrankedReferrerMetricsPieSplit; - expect(edition1Referrer2.referrer).toBe(referrer); - expect(edition1Referrer2.rank).toBe(null); - expect(edition1Referrer2.totalReferrals).toBe(0); - expect(edition1Referrer2.totalIncrementalDuration).toBe(0); - expect(edition1Referrer2.score).toBe(0); - expect(edition1Referrer2.isQualified).toBe(false); - expect(edition1Referrer2.finalScoreBoost).toBe(0); - expect(edition1Referrer2.finalScore).toBe(0); - expect(edition1Referrer2.awardPoolShare).toBe(0); - expect(edition1Referrer2.awardPoolApproxValue).toStrictEqual({ - currency: "USDC", - amount: 0n, - }); - expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); + if ( + edition1.awardModel === ReferralProgramAwardModels.PieSplit && + edition1.type === ReferrerEditionMetricsTypeIds.Unranked + ) { + expect(edition1.rules).toEqual(emptyReferralLeaderboard.rules); + expect(edition1.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics); + expect(edition1.referrer.referrer).toBe(referrer); + expect(edition1.referrer.rank).toBe(null); + expect(edition1.referrer.totalReferrals).toBe(0); + expect(edition1.referrer.totalIncrementalDuration).toBe(0); + expect(edition1.referrer.score).toBe(0); + expect(edition1.referrer.isQualified).toBe(false); + expect(edition1.referrer.finalScoreBoost).toBe(0); + expect(edition1.referrer.finalScore).toBe(0); + expect(edition1.referrer.awardPoolShare).toBe(0); + expect(edition1.referrer.awardPoolApproxValue).toStrictEqual({ + currency: "USDC", + amount: 0n, + }); + expect(edition1.accurateAsOf).toBe(expectedAccurateAsOf); + } // Check 2026-03 expect(edition2.type).toBe(ReferrerEditionMetricsTypeIds.Unranked); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 469f5495c..49fad74d3 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -1,6 +1,6 @@ import { - type AwardedReferrerMetricsPieSplit, buildReferralProgramRulesPieSplit, + ReferralProgramAwardModels, type ReferrerLeaderboard, } from "@namehash/ens-referrals/v1"; import { describe, expect, it, vi } from "vitest"; @@ -37,6 +37,10 @@ describe("ENSAnalytics Referrer Leaderboard", () => { const result = await getReferrerLeaderboard(rules, accurateAsOf); + if (result.awardModel !== ReferralProgramAwardModels.PieSplit) { + throw new Error("Expected PieSplit leaderboard"); + } + expect(result).toMatchObject({ rules, }); @@ -62,46 +66,22 @@ describe("ENSAnalytics Referrer Leaderboard", () => { expect(unqualifiedReferrers.every(([_, referrer]) => !referrer.isQualified)).toBe(true); // Assert `finalScoreBoost` (pie-split specific) - expect( - qualifiedReferrers.every( - ([_, r]) => (r as AwardedReferrerMetricsPieSplit).finalScoreBoost > 0, - ), - ).toBe(true); - expect( - unqualifiedReferrers.every( - ([_, r]) => (r as AwardedReferrerMetricsPieSplit).finalScoreBoost === 0, - ), - ).toBe(true); + expect(qualifiedReferrers.every(([_, r]) => r.finalScoreBoost > 0)).toBe(true); + expect(unqualifiedReferrers.every(([_, r]) => r.finalScoreBoost === 0)).toBe(true); // Assert `finalScore` (pie-split specific) expect( - qualifiedReferrers.every(([_, r]) => { - const referrer = r as AwardedReferrerMetricsPieSplit; - return referrer.finalScore === referrer.score * referrer.finalScoreBoost; - }), - ).toBe(true); - expect( - unqualifiedReferrers.every(([_, r]) => { - const referrer = r as AwardedReferrerMetricsPieSplit; - return referrer.finalScore === referrer.score; - }), + qualifiedReferrers.every(([_, r]) => r.finalScore === r.score * r.finalScoreBoost), ).toBe(true); + expect(unqualifiedReferrers.every(([_, r]) => r.finalScore === r.score)).toBe(true); /** * Assert {@link AwardedReferrerMetrics}. */ // Assert `awardPoolShare` (pie-split specific) - expect( - qualifiedReferrers.every( - ([_, r]) => (r as AwardedReferrerMetricsPieSplit).awardPoolShare > 0, - ), - ).toBe(true); - expect( - unqualifiedReferrers.every( - ([_, r]) => (r as AwardedReferrerMetricsPieSplit).awardPoolShare === 0, - ), - ).toBe(true); + expect(qualifiedReferrers.every(([_, r]) => r.awardPoolShare > 0)).toBe(true); + expect(unqualifiedReferrers.every(([_, r]) => r.awardPoolShare === 0)).toBe(true); // Assert `awardPoolApproxValue` expect( diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 639446865..cce243fe0 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -21,6 +21,7 @@ import { makeReferrerEditionMetricsRevShareLimitSchema, makeReferrerLeaderboardPageRevShareLimitSchema, } from "../award-models/rev-share-limit/api/zod-schemas"; +import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import { MAX_EDITIONS_PER_REQUEST, ReferralProgramEditionConfigSetResponseCodes, @@ -38,28 +39,33 @@ import { * Clients must check `awardModel` before accessing model-specific fields. * This design allows servers to introduce new award model types without breaking existing clients. */ -export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => - z.union([ +export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => { + const knownVariants = z.discriminatedUnion("awardModel", [ makeReferralProgramRulesPieSplitSchema(valueLabel), makeReferralProgramRulesRevShareLimitSchema(valueLabel), - // Passthrough catch-all for unknown future award model types - z - .object({ awardModel: z.string() }) - .passthrough(), ]); + const knownAwardModels = Object.values(ReferralProgramAwardModels) as string[]; + return z + .object({ awardModel: z.string() }) + .passthrough() + .superRefine((val, ctx) => { + if (!knownAwardModels.includes(val.awardModel)) return; + const result = knownVariants.safeParse(val); + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ code: "custom", path: issue.path, message: issue.message }); + } + } + }); +}; /** * Schema for {@link ReferrerLeaderboardPage}. */ export const makeReferrerLeaderboardPageSchema = (valueLabel: string = "ReferrerLeaderboardPage") => - z.union([ + z.discriminatedUnion("awardModel", [ makeReferrerLeaderboardPagePieSplitSchema(valueLabel), makeReferrerLeaderboardPageRevShareLimitSchema(valueLabel), - // Passthrough catch-all for unknown future award model types. - // Servers may introduce new award model types at any time without breaking existing clients. - z - .object({ awardModel: z.string() }) - .passthrough(), ]); /** diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index dabbff00d..3ba2d8e5f 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -55,7 +55,7 @@ export const validateScoredReferrerMetricsPieSplit = ( }; /** - * Extends {@link ScoredReferrerMetrics} to include additional metrics relative to all + * Extends {@link ScoredReferrerMetricsPieSplit} to include additional metrics relative to all * other referrers on a {@link ReferrerLeaderboardPieSplit} and {@link ReferralProgramRulesPieSplit}. */ export interface RankedReferrerMetricsPieSplit extends ScoredReferrerMetricsPieSplit { diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 1086b13fc..493e50091 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -132,6 +132,9 @@ export class ENSReferralsClient { * @throws if the ENSNode request fails * @throws if the ENSNode API returns an error response * @throws if the ENSNode response breaks required invariants + * @throws if the requested edition uses an award model not recognized by this version of + * the client. Call {@link getEditionConfigSet} first to verify the edition's `awardModel` + * is supported before requesting its leaderboard. * * @example * ```typescript @@ -240,6 +243,9 @@ export class ENSReferralsClient { * * @throws if the ENSNode request fails * @throws if the response data is malformed + * @throws if any of the requested editions use an award model not recognized by this + * version of the client. Call {@link getEditionConfigSet} first to verify each + * edition's `awardModel` is supported before requesting metrics. * * @example * ```typescript @@ -332,6 +338,11 @@ export class ENSReferralsClient { * * @returns A response containing the edition config set, or an error response if unavailable. * + * @remarks Editions with unrecognized `rules.awardModel` values are deserialized as + * `{ awardModel: string } & Record`. Callers should check `awardModel` + * before accessing model-specific fields and skip editions whose award model they do + * not recognize. + * * @example * ```typescript * const response = await client.getEditionConfigSet(); From 85f0e513bc1da3749ce0c490d2bebb1ebd6204da Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 24 Feb 2026 00:12:03 +0100 Subject: [PATCH 04/15] proper rev-share-limit race implemented --- .../referrer-leaderboard/database-v1.ts | 71 ++- .../get-referrer-leaderboard-v1.ts | 22 +- .../ens-referrals/src/v1/api/deserialize.ts | 14 +- .../rev-share-limit/aggregations.ts | 72 +-- .../rev-share-limit/api/serialize.ts | 2 + .../rev-share-limit/api/serialized-types.ts | 12 +- .../rev-share-limit/api/zod-schemas.ts | 56 ++- .../rev-share-limit/leaderboard.test.ts | 417 ++++++++++++++++++ .../rev-share-limit/leaderboard.ts | 206 ++++++++- .../award-models/rev-share-limit/metrics.ts | 30 +- .../rev-share-limit/referral-event.ts | 42 ++ packages/ens-referrals/src/v1/index.ts | 1 + packages/ens-referrals/src/v1/leaderboard.ts | 28 +- 13 files changed, 831 insertions(+), 142 deletions(-) create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts create mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts index cc9494a67..79c266175 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts @@ -1,9 +1,10 @@ import { buildReferrerMetrics, + type ReferralEvent, type ReferralProgramRules, type ReferrerMetrics, } from "@namehash/ens-referrals/v1"; -import { and, count, desc, eq, gte, isNotNull, lte, ne, sql, sum } from "drizzle-orm"; +import { and, asc, count, desc, eq, gte, isNotNull, lte, ne, sql, sum } from "drizzle-orm"; import { type Address, zeroAddress } from "viem"; import * as schema from "@ensnode/ensnode-schema"; @@ -93,3 +94,71 @@ export const getReferrerMetrics = async ( throw new Error(`Failed to fetch referrer metrics from database: ${errorMessage}`); } }; + +/** + * Get raw referral events from the database for the sequential race algorithm (V1 API). + * + * Returns individual rows (no GROUP BY) ordered chronologically for deterministic race processing. + * + * @param rules - The referral program rules for filtering registrar actions + * @returns A promise that resolves to an array of {@link ReferralEvent} values. + * @throws Error if the database query fails. + */ +export const getReferralEvents = async (rules: ReferralProgramRules): Promise => { + try { + const records = await db + .select({ + referrer: schema.registrarActions.decodedReferrer, + timestamp: schema.registrarActions.timestamp, + blockNumber: schema.registrarActions.blockNumber, + transactionHash: schema.registrarActions.transactionHash, + incrementalDuration: schema.registrarActions.incrementalDuration, + // Note: Using raw SQL for COALESCE because Drizzle doesn't natively support it yet. + // See: https://github.com/drizzle-team/drizzle-orm/issues/3708 + total: sql`COALESCE(${schema.registrarActions.total}, 0)`.as("total"), + }) + .from(schema.registrarActions) + .where( + and( + // Filter by timestamp range + gte(schema.registrarActions.timestamp, BigInt(rules.startTime)), + lte(schema.registrarActions.timestamp, BigInt(rules.endTime)), + // Filter by decodedReferrer not null + isNotNull(schema.registrarActions.decodedReferrer), + // Filter by decodedReferrer not zero address + ne(schema.registrarActions.decodedReferrer, zeroAddress), + // Filter by subregistryId matching the provided subregistryId + eq(schema.registrarActions.subregistryId, formatAccountId(rules.subregistryId)), + ), + ) + .orderBy( + asc(schema.registrarActions.timestamp), + asc(schema.registrarActions.blockNumber), + asc(schema.registrarActions.transactionHash), + ); + + // Type assertion: The WHERE clause in the query above guarantees non-null values for: + // 1. `referrer` is guaranteed to be non-null due to isNotNull filter + interface NonNullRecord { + referrer: Address; + timestamp: bigint; + blockNumber: bigint; + transactionHash: `0x${string}`; + incrementalDuration: bigint; + total: string; + } + + return (records as NonNullRecord[]).map((record) => ({ + referrer: record.referrer, + timestamp: Number(record.timestamp), + blockNumber: record.blockNumber, + transactionHash: record.transactionHash, + incrementalDuration: Number(record.incrementalDuration), + incrementalRevenueContribution: priceEth(BigInt(record.total)), + })); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error({ error }, "Failed to fetch referral events from database"); + throw new Error(`Failed to fetch referral events from database: ${errorMessage}`); + } +}; diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts index 412fc7dc4..048eddf66 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts @@ -1,16 +1,22 @@ import { - buildReferrerLeaderboard, + buildReferrerLeaderboardPieSplit, + buildReferrerLeaderboardRevShareLimit, + ReferralProgramAwardModels, type ReferralProgramRules, type ReferrerLeaderboard, } from "@namehash/ens-referrals/v1"; import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; -import { getReferrerMetrics } from "./database-v1"; +import { getReferralEvents, getReferrerMetrics } from "./database-v1"; /** * Builds a `ReferralLeaderboard` from the database using the provided referral program rules (V1 API). * + * Dispatches to the appropriate model-specific builder based on `rules.awardModel`: + * - PieSplit: uses aggregated referrer metrics (GROUP BY query). + * - RevShareLimit: uses raw referral events (no GROUP BY) for the sequential race algorithm. + * * @param rules - The referral program rules for filtering registrar actions * @param accurateAsOf - The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboard} was accurate as of. * @returns A promise that resolves to a {@link ReferrerLeaderboard} @@ -20,6 +26,14 @@ export async function getReferrerLeaderboard( rules: ReferralProgramRules, accurateAsOf: UnixTimestamp, ): Promise { - const allReferrers = await getReferrerMetrics(rules); - return buildReferrerLeaderboard(allReferrers, rules, accurateAsOf); + switch (rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: { + const allReferrers = await getReferrerMetrics(rules); + return buildReferrerLeaderboardPieSplit(allReferrers, rules, accurateAsOf); + } + case ReferralProgramAwardModels.RevShareLimit: { + const events = await getReferralEvents(rules); + return buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + } + } } diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index eb1843bd5..101ab04cb 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -34,10 +34,7 @@ export function deserializeReferrerLeaderboardPageResponse( ); } - // The Zod schema includes passthrough catch-alls for unknown award model types, - // making its inferred output type wider than ReferrerLeaderboardPageResponse. - // This assertion is safe: the schema validates all known fields correctly. - return parsed.data as unknown as ReferrerLeaderboardPageResponse; + return parsed.data; } /** @@ -56,8 +53,7 @@ export function deserializeReferrerMetricsEditionsResponse( ); } - // Same passthrough-widened type assertion as above. - return parsed.data as unknown as ReferrerMetricsEditionsResponse; + return parsed.data; } /** @@ -76,7 +72,9 @@ export function deserializeReferralProgramEditionConfigSetArray( ); } - // Same passthrough-widened type assertion as above. + // makeReferralProgramRulesSchema uses .passthrough() for forward compatibility with unknown award + // model types, widening its output to { awardModel: string } & Record. + // This assertion is safe: the schema validates all known fields correctly. return parsed.data as unknown as ReferralProgramEditionConfig[]; } @@ -96,6 +94,6 @@ export function deserializeReferralProgramEditionConfigSetResponse( ); } - // Same passthrough-widened type assertion as above. + // Same reason as deserializeReferralProgramEditionConfigSetArray above. return parsed.data as unknown as ReferralProgramEditionConfigSetResponse; } diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts index ac47aa56b..7994f817b 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts @@ -1,30 +1,22 @@ -import { - type Duration, - type PriceEth, - type PriceUsdc, - priceEth, - priceUsdc, - scalePrice, -} from "@ensnode/ensnode-sdk"; +import { type Duration, type PriceEth, type PriceUsdc, priceEth } from "@ensnode/ensnode-sdk"; import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; import { validateNonNegativeInteger } from "../../number"; import { validateDuration } from "../../time"; -import type { RankedReferrerMetricsRevShareLimit } from "./metrics"; -import type { ReferralProgramRulesRevShareLimit } from "./rules"; +import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; /** - * Represents aggregated metrics for a list of {@link RankedReferrerMetricsRevShareLimit}. + * Represents aggregated metrics for a list of referrers on a rev-share-limit leaderboard. */ export interface AggregatedReferrerMetricsRevShareLimit { /** - * @invariant The sum of `totalReferrals` across all {@link RankedReferrerMetricsRevShareLimit} in the list. + * @invariant The sum of `totalReferrals` across all referrers in the list. * @invariant Guaranteed to be a non-negative integer (>= 0) */ grandTotalReferrals: number; /** - * @invariant The sum of `totalIncrementalDuration` across all {@link RankedReferrerMetricsRevShareLimit} in the list. + * @invariant The sum of `totalIncrementalDuration` across all referrers in the list. */ grandTotalIncrementalDuration: Duration; @@ -32,15 +24,15 @@ export interface AggregatedReferrerMetricsRevShareLimit { * The total revenue contribution in ETH to the ENS DAO from all referrals * across all referrers on the leaderboard. * - * This is the sum of `totalRevenueContribution` across all {@link RankedReferrerMetricsRevShareLimit} in the list. + * This is the sum of `totalRevenueContribution` across all referrers in the list. * * @invariant Guaranteed to be a valid PriceEth with non-negative amount (>= 0n) */ grandTotalRevenueContribution: PriceEth; /** - * The remaining amount in the award pool after subtracting the total potential awards - * (capped at 0 if total potential awards exceed the pool). + * The remaining amount in the award pool after subtracting all qualified awards + * claimed during the sequential race processing. * * @invariant Guaranteed to be a valid PriceUsdc with non-negative amount (>= 0n) */ @@ -75,65 +67,41 @@ export const validateAggregatedReferrerMetricsRevShareLimit = ( }; /** - * Builds aggregated rev-share-limit metrics from a complete, globally ranked list of referrers. + * Builds aggregated rev-share-limit metrics from a complete list of referrers and + * the award pool remaining after sequential race processing. * - * **IMPORTANT: This function expects a complete ranking of all referrers.** + * **IMPORTANT: This function expects a complete list of all referrers.** * - * @param referrers - Must be a complete, globally ranked list of {@link RankedReferrerMetricsRevShareLimit} - * where ranks start at 1 and are consecutive. - * **This must NOT be a paginated or partial slice of the rankings.** + * @param referrers - Must be a complete list of referrers with their totals. + * **This must NOT be a paginated or partial slice.** * - * @param rules - The {@link ReferralProgramRulesRevShareLimit} object that define qualification criteria. + * @param awardPoolRemaining - The amount remaining in the award pool after the sequential + * race algorithm has processed all events. * * @returns Aggregated metrics including totals across all referrers and the award pool remaining. - * - * @remarks - * - If you need to work with paginated data, aggregate the full ranking first before - * calling this function, or call this function on the complete dataset and then paginate - * the results. */ export const buildAggregatedReferrerMetricsRevShareLimit = ( - referrers: RankedReferrerMetricsRevShareLimit[], - rules: ReferralProgramRulesRevShareLimit, -): { aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; scalingFactor: number } => { + referrers: AwardedReferrerMetricsRevShareLimit[], + awardPoolRemaining: PriceUsdc, +): AggregatedReferrerMetricsRevShareLimit => { let grandTotalReferrals = 0; let grandTotalIncrementalDuration = 0; let grandTotalRevenueContributionAmount = 0n; - let totalPotentialAwardsAmount = 0n; for (const referrer of referrers) { grandTotalReferrals += referrer.totalReferrals; grandTotalIncrementalDuration += referrer.totalIncrementalDuration; grandTotalRevenueContributionAmount += referrer.totalRevenueContribution.amount; - if (referrer.isQualified) { - const potentialAward = scalePrice( - referrer.totalBaseRevenueContribution, - rules.qualifiedRevenueShare, - ); - totalPotentialAwardsAmount += potentialAward.amount; - } } - const scalingFactor = - totalPotentialAwardsAmount > 0n - ? Math.min(1, Number(rules.totalAwardPoolValue.amount) / Number(totalPotentialAwardsAmount)) - : 1; - - const cappedTotalPotentialAwards = - totalPotentialAwardsAmount < rules.totalAwardPoolValue.amount - ? totalPotentialAwardsAmount - : rules.totalAwardPoolValue.amount; - - const awardPoolRemainingAmount = rules.totalAwardPoolValue.amount - cappedTotalPotentialAwards; - const aggregatedMetrics = { grandTotalReferrals, grandTotalIncrementalDuration, grandTotalRevenueContribution: priceEth(grandTotalRevenueContributionAmount), - awardPoolRemaining: priceUsdc(awardPoolRemainingAmount), + awardPoolRemaining, } satisfies AggregatedReferrerMetricsRevShareLimit; validateAggregatedReferrerMetricsRevShareLimit(aggregatedMetrics); - return { aggregatedMetrics, scalingFactor }; + return aggregatedMetrics; }; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts index 098e1ac8d..8e5d78775 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts @@ -67,6 +67,7 @@ export function serializeAwardedReferrerMetricsRevShareLimit( totalBaseRevenueContribution: serializePriceUsdc(metrics.totalBaseRevenueContribution), rank: metrics.rank, isQualified: metrics.isQualified, + standardAwardValue: serializePriceUsdc(metrics.standardAwardValue), awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), }; } @@ -85,6 +86,7 @@ export function serializeUnrankedReferrerMetricsRevShareLimit( totalBaseRevenueContribution: serializePriceUsdc(metrics.totalBaseRevenueContribution), rank: metrics.rank, isQualified: metrics.isQualified, + standardAwardValue: serializePriceUsdc(metrics.standardAwardValue), awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), }; } diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts index 4f5f6f280..9b4d86f6b 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts @@ -43,10 +43,14 @@ export interface SerializedAggregatedReferrerMetricsRevShareLimit export interface SerializedAwardedReferrerMetricsRevShareLimit extends Omit< AwardedReferrerMetricsRevShareLimit, - "totalRevenueContribution" | "totalBaseRevenueContribution" | "awardPoolApproxValue" + | "totalRevenueContribution" + | "totalBaseRevenueContribution" + | "standardAwardValue" + | "awardPoolApproxValue" > { totalRevenueContribution: SerializedPriceEth; totalBaseRevenueContribution: SerializedPriceUsdc; + standardAwardValue: SerializedPriceUsdc; awardPoolApproxValue: SerializedPriceUsdc; } @@ -56,10 +60,14 @@ export interface SerializedAwardedReferrerMetricsRevShareLimit export interface SerializedUnrankedReferrerMetricsRevShareLimit extends Omit< UnrankedReferrerMetricsRevShareLimit, - "totalRevenueContribution" | "totalBaseRevenueContribution" | "awardPoolApproxValue" + | "totalRevenueContribution" + | "totalBaseRevenueContribution" + | "standardAwardValue" + | "awardPoolApproxValue" > { totalRevenueContribution: SerializedPriceEth; totalBaseRevenueContribution: SerializedPriceUsdc; + standardAwardValue: SerializedPriceUsdc; awardPoolApproxValue: SerializedPriceUsdc; } diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts index 9348ae6b7..fa57ae6a9 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -52,16 +52,24 @@ export const makeReferralProgramRulesRevShareLimitSchema = ( export const makeAwardedReferrerMetricsRevShareLimitSchema = ( valueLabel: string = "AwardedReferrerMetricsRevShareLimit", ) => - z.object({ - referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), - totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), - totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), - totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), - totalBaseRevenueContribution: makePriceUsdcSchema(`${valueLabel}.totalBaseRevenueContribution`), - rank: makePositiveIntegerSchema(`${valueLabel}.rank`), - isQualified: z.boolean(), - awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), - }); + z + .object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), + totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), + totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), + totalBaseRevenueContribution: makePriceUsdcSchema( + `${valueLabel}.totalBaseRevenueContribution`, + ), + rank: makePositiveIntegerSchema(`${valueLabel}.rank`), + isQualified: z.boolean(), + standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`), + awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + }) + .refine((data) => data.awardPoolApproxValue.amount <= data.standardAwardValue.amount, { + message: `${valueLabel}.awardPoolApproxValue must be <= ${valueLabel}.standardAwardValue`, + path: ["awardPoolApproxValue"], + }); /** * Schema for {@link UnrankedReferrerMetricsRevShareLimit} (with null rank). @@ -69,16 +77,24 @@ export const makeAwardedReferrerMetricsRevShareLimitSchema = ( export const makeUnrankedReferrerMetricsRevShareLimitSchema = ( valueLabel: string = "UnrankedReferrerMetricsRevShareLimit", ) => - z.object({ - referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), - totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), - totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), - totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), - totalBaseRevenueContribution: makePriceUsdcSchema(`${valueLabel}.totalBaseRevenueContribution`), - rank: z.null(), - isQualified: z.literal(false), - awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), - }); + z + .object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`), + totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`), + totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`), + totalBaseRevenueContribution: makePriceUsdcSchema( + `${valueLabel}.totalBaseRevenueContribution`, + ), + rank: z.null(), + isQualified: z.literal(false), + standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`), + awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + }) + .refine((data) => data.awardPoolApproxValue.amount <= data.standardAwardValue.amount, { + message: `${valueLabel}.awardPoolApproxValue must be <= ${valueLabel}.standardAwardValue`, + path: ["awardPoolApproxValue"], + }); /** * Schema for {@link AggregatedReferrerMetricsRevShareLimit}. diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts new file mode 100644 index 000000000..51bf0a9bb --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from "vitest"; + +import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; + +import { SECONDS_PER_YEAR } from "../../time"; +import { buildReferrerLeaderboardRevShareLimit } from "./leaderboard"; +import type { ReferralEvent } from "./referral-event"; +import { buildReferralProgramRulesRevShareLimit } from "./rules"; + +// ─── Test fixtures ─────────────────────────────────────────────────────────── + +const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as const; +const ADDR_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as const; +const ADDR_C = "0xcccccccccccccccccccccccccccccccccccccccc" as const; + +const TX_1 = "0x0000000000000000000000000000000000000000000000000000000000000001" as const; +const TX_2 = "0x0000000000000000000000000000000000000000000000000000000000000002" as const; +const TX_3 = "0x0000000000000000000000000000000000000000000000000000000000000003" as const; + +const ZERO_ETH = priceEth(0n); + +/** + * Build test rules. + * + * - BASE_REVENUE_CONTRIBUTION_PER_YEAR = $5 USDC + * - qualifiedRevenueShare = 0.5 + * - 1 year of duration → $5 base revenue → $2.50 standard award + * - minQualifiedRevenueContribution = $5 → need exactly 1 year to qualify + * + * @param totalAwardPoolValue - USDC amount for the pool (default: $1000) + * @param minQualifiedRevenueContribution - USDC threshold (default: $5 = 1 year) + */ +function buildTestRules( + totalAwardPoolValue = parseUsdc("1000"), + minQualifiedRevenueContribution = parseUsdc("5"), +) { + return buildReferralProgramRulesRevShareLimit( + totalAwardPoolValue, + minQualifiedRevenueContribution, + 0.5, // qualifiedRevenueShare + parseTimestamp("2026-01-01T00:00:00Z"), + parseTimestamp("2026-12-31T23:59:59Z"), + { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, + new URL("https://example.com/rules"), + ); +} + +/** + * Build a ReferralEvent with sensible defaults. + */ +function makeEvent( + referrer: `0x${string}`, + timestamp: number, + incrementalDuration: number, + opts: Partial> = {}, +): ReferralEvent { + return { + referrer, + timestamp, + blockNumber: opts.blockNumber ?? 1n, + transactionHash: opts.transactionHash ?? TX_1, + incrementalDuration, + incrementalRevenueContribution: ZERO_ETH, + }; +} + +const accurateAsOf = parseTimestamp("2026-06-01T00:00:00Z"); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** $2.50 USDC in raw amount (standard award for 1 year of duration at 50% share) */ +const STANDARD_AWARD_1Y = parseUsdc("2.5"); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("buildReferrerLeaderboardRevShareLimit", () => { + it("returns empty leaderboard when events list is empty", () => { + const rules = buildTestRules(); + const result = buildReferrerLeaderboardRevShareLimit([], rules, accurateAsOf); + + expect(result.awardModel).toBe(rules.awardModel); + expect(result.rules).toBe(rules); + expect(result.accurateAsOf).toBe(accurateAsOf); + expect(result.referrers.size).toBe(0); + expect(result.aggregatedMetrics).toMatchObject({ + grandTotalReferrals: 0, + grandTotalIncrementalDuration: 0, + grandTotalRevenueContribution: ZERO_ETH, + awardPoolRemaining: rules.totalAwardPoolValue, + }); + }); + + describe("Scenario A — unqualified referrer: no award claimed", () => { + it("accumulates standard award but awardPoolApproxValue is $0 when not qualified", () => { + // Half a year of duration → base revenue = $2.50 (< $5 threshold) + const events = [ + makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2), { transactionHash: TX_1 }), + ]; + const rules = buildTestRules(); + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrer = result.referrers.get(ADDR_A)!; + + expect(referrer).toBeDefined(); + expect(referrer.isQualified).toBe(false); + // standardAwardValue = 0.5 × ($5 × 0.5 years) = 0.5 × $2.50 = $1.25 + expect(referrer.standardAwardValue.amount).toBe(parseUsdc("1.25").amount); + expect(referrer.awardPoolApproxValue.amount).toBe(0n); + + // Pool should be fully intact + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe( + rules.totalAwardPoolValue.amount, + ); + }); + }); + + describe("Scenario B — referrer just qualifies, claims all accumulated standard award", () => { + it("claims all accumulated standard award when qualifying (unlimited pool)", () => { + // Event 1: half year → base revenue = $2.50 (not qualified) + // Event 2: half year → base revenue = $5.00 (just qualified!) + // Accumulated standard award = 2 × $1.25 = $2.50 + const rules = buildTestRules(parseUsdc("10000")); // large pool + const events = [ + makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2), { transactionHash: TX_1 }), + makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2), { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrer = result.referrers.get(ADDR_A)!; + + expect(referrer.isQualified).toBe(true); + expect(referrer.standardAwardValue.amount).toBe(STANDARD_AWARD_1Y.amount); + // Claims all accumulated: 2 × $1.25 = $2.50 + expect(referrer.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); + }); + }); + + describe("Scenario B-2 — just qualifies, but pool is too small to cover full accumulated award", () => { + it("awardPoolApproxValue is capped by remaining pool when qualifying", () => { + // Same as Scenario B but pool only has $1.50 + const poolAmount = parseUsdc("1.5"); + const rules = buildTestRules(poolAmount); + const events = [ + makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2), { transactionHash: TX_1 }), + makeEvent(ADDR_A, 2000, Math.floor(SECONDS_PER_YEAR / 2), { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrer = result.referrers.get(ADDR_A)!; + + expect(referrer.isQualified).toBe(true); + // standardAwardValue = $2.50 (uncapped) + expect(referrer.standardAwardValue.amount).toBe(STANDARD_AWARD_1Y.amount); + // awardPoolApproxValue capped at $1.50 (pool limit) + expect(referrer.awardPoolApproxValue.amount).toBe(poolAmount.amount); + // Pool fully depleted + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); + }); + }); + + describe("Scenario C — already qualified, claims incremental standard award per event", () => { + it("qualified referrer claims incremental award on subsequent events (unlimited pool)", () => { + // Event 1: 1 year → base revenue = $5 (just qualifies), accumulated standard = $2.50, claim $2.50 + // Event 2: 1 year → already qualified, incremental standard = $2.50, claim $2.50 + // Total: $5.00 + const rules = buildTestRules(parseUsdc("10000")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrer = result.referrers.get(ADDR_A)!; + + expect(referrer.isQualified).toBe(true); + // standardAwardValue = 0.5 × (2 × $5) = $5.00 + expect(referrer.standardAwardValue.amount).toBe(parseUsdc("5").amount); + // awardPoolApproxValue = $2.50 (qualifying) + $2.50 (incremental) = $5.00 + expect(referrer.awardPoolApproxValue.amount).toBe(parseUsdc("5").amount); + }); + }); + + describe("Scenario C-2 — already qualified, pool only partially covers incremental award", () => { + it("awardPoolApproxValue is partially truncated on subsequent event when pool is nearly empty", () => { + // Pool = $3.00 + // Event 1 at t=1000: 1 year → qualifies, claim min($2.50, $3.00) = $2.50, pool = $0.50 + // Event 2 at t=2000: 1 year → already qualified, incremental $2.50, claim min($2.50, $0.50) = $0.50, pool = $0 + const rules = buildTestRules(parseUsdc("3")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrer = result.referrers.get(ADDR_A)!; + + expect(referrer.isQualified).toBe(true); + // standardAwardValue = 0.5 × $10 = $5.00 (uncapped) + expect(referrer.standardAwardValue.amount).toBe(parseUsdc("5").amount); + // awardPoolApproxValue = $2.50 + $0.50 = $3.00 (capped at pool) + expect(referrer.awardPoolApproxValue.amount).toBe(parseUsdc("3").amount); + // Pool fully depleted + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); + }); + }); + + describe("Scenario D — pool is empty, no award for qualified referrer", () => { + it("qualified referrer gets $0 when pool is already depleted", () => { + // Pool = $0 + const rules = buildTestRules(priceUsdc(0n)); + const events = [makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 })]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrer = result.referrers.get(ADDR_A)!; + + expect(referrer.isQualified).toBe(true); + expect(referrer.standardAwardValue.amount).toBe(STANDARD_AWARD_1Y.amount); + expect(referrer.awardPoolApproxValue.amount).toBe(0n); + }); + }); + + describe("Multiple referrers racing — first-come, first-served", () => { + it("earlier referrer gets more of the pool than a later referrer", () => { + // Pool = $4 + // ReferrerA qualifies at t=1000 (1 year), claims min($2.50, $4) = $2.50, pool = $1.50 + // ReferrerB qualifies at t=2000 (1 year), claims min($2.50, $1.50) = $1.50, pool = $0 + const rules = buildTestRules(parseUsdc("4")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + const referrerB = result.referrers.get(ADDR_B)!; + + expect(referrerA.isQualified).toBe(true); + expect(referrerA.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); // $2.50 + + expect(referrerB.isQualified).toBe(true); + expect(referrerB.awardPoolApproxValue.amount).toBe(parseUsdc("1.5").amount); // $1.50 (only remaining) + + // Pool fully depleted + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); + }); + + it("referrer who qualifies after pool is empty gets $0 awardPoolApproxValue", () => { + // Pool = $2.50 (only enough for 1 qualifying referrer) + // ReferrerA qualifies at t=1000, claims $2.50, pool = $0 + // ReferrerB qualifies at t=2000, claims min($2.50, $0) = $0 + const rules = buildTestRules(parseUsdc("2.5")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + const referrerB = result.referrers.get(ADDR_B)!; + + expect(referrerA.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); // $2.50 + expect(referrerB.awardPoolApproxValue.amount).toBe(0n); // $0 — pool empty + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); + }); + + it("exactly one referrer can be partially truncated (at most)", () => { + // Pool = $3.75 — enough for ReferrerA ($2.50) + $1.25 for ReferrerB (partial) + // ReferrerA qualifies at t=1000, claims $2.50, pool = $1.25 + // ReferrerB qualifies at t=2000, claims $1.25 (partial — truncated), pool = $0 + // ReferrerC qualifies at t=3000, claims $0 (pool empty — fully truncated) + const rules = buildTestRules(parseUsdc("3.75")); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + makeEvent(ADDR_C, 3000, SECONDS_PER_YEAR, { transactionHash: TX_3 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + const referrerB = result.referrers.get(ADDR_B)!; + const referrerC = result.referrers.get(ADDR_C)!; + + // Non-truncated: full standard award + expect(referrerA.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); + // Partially truncated: less than standard but > 0 + expect(referrerB.awardPoolApproxValue.amount).toBeGreaterThan(0n); + expect(referrerB.awardPoolApproxValue.amount).toBeLessThan(STANDARD_AWARD_1Y.amount); + // Fully truncated: pool empty + expect(referrerC.awardPoolApproxValue.amount).toBe(0n); + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(0n); + }); + }); + + describe("Deterministic ordering within same timestamp", () => { + it("breaks ties by blockNumber (lower block wins)", () => { + // Both referrers have the same timestamp but different block numbers + // Pool = $2.50 — only enough for one + const rules = buildTestRules(parseUsdc("2.5")); + const events = [ + { + ...makeEvent(ADDR_B, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + blockNumber: 200n, + }, + { + ...makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + blockNumber: 100n, + }, + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + + // ADDR_A is in block 100 (earlier), should get the award + expect(result.referrers.get(ADDR_A)!.awardPoolApproxValue.amount).toBe( + STANDARD_AWARD_1Y.amount, + ); + expect(result.referrers.get(ADDR_B)!.awardPoolApproxValue.amount).toBe(0n); + }); + + it("breaks ties by transactionHash (lexicographic) when block is the same", () => { + // Both in same block — TX_1 < TX_2 lexicographically + // Pool = $2.50 — only enough for one + const TX_EARLY = + "0x0000000000000000000000000000000000000000000000000000000000000001" as const; + const TX_LATE = "0x0000000000000000000000000000000000000000000000000000000000000002" as const; + const rules = buildTestRules(parseUsdc("2.5")); + const events = [ + { + ...makeEvent(ADDR_B, 1000, SECONDS_PER_YEAR), + blockNumber: 100n, + transactionHash: TX_LATE, + }, + { + ...makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + blockNumber: 100n, + transactionHash: TX_EARLY, + }, + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + + // ADDR_A has earlier tx hash (TX_EARLY), should claim the pool first + expect(result.referrers.get(ADDR_A)!.awardPoolApproxValue.amount).toBe( + STANDARD_AWARD_1Y.amount, + ); + expect(result.referrers.get(ADDR_B)!.awardPoolApproxValue.amount).toBe(0n); + }); + }); + + describe("Ranking", () => { + it("ranks referrers by qualifiedAwardValue desc, then standardAwardValue desc", () => { + // Pool = $1000 (unlimited for this test) + // ADDR_A: 1 year → qualifies at t=1000, qualifiedAward = $2.50, standardAward = $2.50 + // ADDR_B: 2 years → qualifies at t=2000, qualifiedAward = $5.00, standardAward = $5.00 + // ADDR_C: 0.5 years → never qualifies, qualifiedAward = $0, standardAward = $1.25 + const rules = buildTestRules(); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR * 2, { transactionHash: TX_2 }), + makeEvent(ADDR_C, 3000, Math.floor(SECONDS_PER_YEAR / 2), { transactionHash: TX_3 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + + // ADDR_B: qualifiedAward $5.00 → rank 1 (highest pool claim) + // ADDR_A: qualifiedAward $2.50 → rank 2 + // ADDR_C: qualifiedAward $0, standardAward $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); + }); + + it("two fully-truncated referrers are ranked by standardAwardValue desc", () => { + // Pool = $0 — nobody gets pool money + // ADDR_A: 2 years → qualifies, standardAward = $5.00, qualifiedAward = $0 + // ADDR_B: 1 year → qualifies, standardAward = $2.50, qualifiedAward = $0 + const rules = buildTestRules(priceUsdc(0n)); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR * 2, { transactionHash: TX_1 }), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + + // Both have $0 qualifiedAward; ADDR_A has higher standardAward → rank 1 + expect(result.referrers.get(ADDR_A)!.rank).toBe(1); + expect(result.referrers.get(ADDR_B)!.rank).toBe(2); + }); + + it("referrers map is ordered by rank ascending", () => { + const rules = buildTestRules(); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR * 2, { transactionHash: TX_2 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const ranks = [...result.referrers.values()].map((r) => r.rank); + expect(ranks).toEqual([1, 2]); + }); + }); + + describe("Aggregated metrics", () => { + it("correctly sums grandTotalReferrals and grandTotalIncrementalDuration", () => { + const rules = buildTestRules(); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR, { transactionHash: TX_1 }), + makeEvent(ADDR_A, 2000, SECONDS_PER_YEAR, { transactionHash: TX_2 }), + makeEvent(ADDR_B, 3000, SECONDS_PER_YEAR, { transactionHash: TX_3 }), + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + + expect(result.aggregatedMetrics.grandTotalReferrals).toBe(3); + expect(result.aggregatedMetrics.grandTotalIncrementalDuration).toBe(3 * SECONDS_PER_YEAR); + }); + }); +}); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index 40b79d36c..ff08fd0f7 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -1,10 +1,16 @@ import type { Address } from "viem"; -import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; +import { + type Duration, + priceEth, + priceUsdc, + scalePrice, + type UnixTimestamp, +} from "@ensnode/ensnode-sdk"; -import type { ReferrerMetrics } from "../../referrer-metrics"; -import { assertLeaderboardInputs } from "../shared/leaderboard-guards"; -import { sortReferrerMetrics } from "../shared/rank"; +import { normalizeAddress } from "../../address"; +import { buildReferrerMetrics } from "../../referrer-metrics"; +import { SECONDS_PER_YEAR } from "../../time"; import type { ReferralProgramAwardModels } from "../shared/rules"; import type { AggregatedReferrerMetricsRevShareLimit } from "./aggregations"; import { buildAggregatedReferrerMetricsRevShareLimit } from "./aggregations"; @@ -14,7 +20,11 @@ import { buildRankedReferrerMetricsRevShareLimit, buildReferrerMetricsRevShareLimit, } from "./metrics"; -import type { ReferralProgramRulesRevShareLimit } from "./rules"; +import type { ReferralEvent } from "./referral-event"; +import { + BASE_REVENUE_CONTRIBUTION_PER_YEAR, + type ReferralProgramRulesRevShareLimit, +} from "./rules"; /** * Represents a leaderboard with the rev-share-limit award model for any number of referrers. @@ -33,12 +43,12 @@ export interface ReferrerLeaderboardRevShareLimit { rules: ReferralProgramRulesRevShareLimit; /** - * The {@link AggregatedReferrerMetricsRevShareLimit} for all {@link RankedReferrerMetricsRevShareLimit} values in `referrers`. + * The {@link AggregatedReferrerMetricsRevShareLimit} for all {@link AwardedReferrerMetricsRevShareLimit} values in `referrers`. */ aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; /** - * Ordered map containing `AwardedReferrerMetricsPieSplit` for all referrers with 1 or more + * Ordered map containing {@link AwardedReferrerMetricsRevShareLimit} for all referrers with 1 or more * `totalReferrals` within the `rules` as of `accurateAsOf`. * * @invariant Map entries are ordered by `rank` (ascending). @@ -58,28 +68,186 @@ export interface ReferrerLeaderboardRevShareLimit { accurateAsOf: UnixTimestamp; } +/** + * Per-referrer mutable state used during sequential race processing. + */ +interface ReferrerRaceState { + totalReferrals: number; + totalIncrementalDuration: Duration; + totalRevenueContributionAmount: bigint; + totalBaseRevenueContributionAmount: bigint; + /** Accumulated standard award (qualifiedRevenueShare × baseRevenue), regardless of pool. */ + accumulatedStandardAwardAmount: bigint; + /** Whether this referrer has ever crossed the qualification threshold. */ + wasQualified: boolean; + /** Amount actually claimed from the award pool. */ + qualifiedAwardValueAmount: bigint; +} + +/** + * Builds a {@link ReferrerLeaderboardRevShareLimit} using a sequential "first-come, first-served" + * race algorithm over individual referral events. + * + * Events are processed in chronological order. When a referrer first crosses the qualification + * threshold, they claim ALL accumulated standard award value at once (capped by remaining pool). + * After qualifying, each subsequent event claims that event's incremental standard award (also + * capped). Once the pool reaches $0, no further awards are issued to anyone. + * + * @param events - Raw referral events from the database (unsorted; will be sorted internally). + * @param rules - The {@link ReferralProgramRulesRevShareLimit} defining the program parameters. + * @param accurateAsOf - Timestamp indicating data freshness. + */ export const buildReferrerLeaderboardRevShareLimit = ( - allReferrers: ReferrerMetrics[], + events: ReferralEvent[], rules: ReferralProgramRulesRevShareLimit, accurateAsOf: UnixTimestamp, ): ReferrerLeaderboardRevShareLimit => { - assertLeaderboardInputs(allReferrers, rules, accurateAsOf); + // 1. Sort events deterministically: timestamp asc, blockNumber asc, transactionHash asc. + const sortedEvents = [...events].sort((a, b) => { + if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp; + if (a.blockNumber !== b.blockNumber) return a.blockNumber < b.blockNumber ? -1 : 1; + if (a.transactionHash < b.transactionHash) return -1; + if (a.transactionHash > b.transactionHash) return 1; + return 0; + }); - const sortedReferrers = sortReferrerMetrics(allReferrers); + // 2. Process events sequentially to run the race. + const referrerStates = new Map(); + let poolRemainingAmount = rules.totalAwardPoolValue.amount; - const revShareMetrics = sortedReferrers.map((r) => buildReferrerMetricsRevShareLimit(r)); + for (const event of sortedEvents) { + const referrer = normalizeAddress(event.referrer); - const rankedReferrers = revShareMetrics.map((r, index) => - buildRankedReferrerMetricsRevShareLimit(r, index + 1, rules), - ); + let state = referrerStates.get(referrer); + if (!state) { + state = { + totalReferrals: 0, + totalIncrementalDuration: 0, + totalRevenueContributionAmount: 0n, + totalBaseRevenueContributionAmount: 0n, + accumulatedStandardAwardAmount: 0n, + wasQualified: false, + qualifiedAwardValueAmount: 0n, + }; + referrerStates.set(referrer, state); + } + + // Compute incremental base revenue: BASE_REVENUE_CONTRIBUTION_PER_YEAR × (duration / SECONDS_PER_YEAR) + const incrementalBaseRevenueAmount = + (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(event.incrementalDuration)) / + BigInt(SECONDS_PER_YEAR); + + // Compute incremental standard award: qualifiedRevenueShare × incrementalBaseRevenue + const incrementalStandardAwardAmount = scalePrice( + priceUsdc(incrementalBaseRevenueAmount), + rules.qualifiedRevenueShare, + ).amount; + + // Update running totals. + state.totalReferrals += 1; + state.totalIncrementalDuration += event.incrementalDuration; + state.totalRevenueContributionAmount += event.incrementalRevenueContribution.amount; + state.totalBaseRevenueContributionAmount += incrementalBaseRevenueAmount; + state.accumulatedStandardAwardAmount += incrementalStandardAwardAmount; - const { aggregatedMetrics, scalingFactor } = buildAggregatedReferrerMetricsRevShareLimit( - rankedReferrers, - rules, + // Determine if newly qualifying or already qualified. + const isNowQualified = + state.totalBaseRevenueContributionAmount >= rules.minQualifiedRevenueContribution.amount; + + if (isNowQualified && !state.wasQualified) { + // First time crossing the qualification threshold: claim all accumulated standard award. + const claimAmount = + state.accumulatedStandardAwardAmount < poolRemainingAmount + ? state.accumulatedStandardAwardAmount + : poolRemainingAmount; + state.qualifiedAwardValueAmount += claimAmount; + poolRemainingAmount -= claimAmount; + state.wasQualified = true; + } else if (state.wasQualified) { + // Already qualified: claim this event's incremental standard award. + const claimAmount = + incrementalStandardAwardAmount < poolRemainingAmount + ? incrementalStandardAwardAmount + : poolRemainingAmount; + state.qualifiedAwardValueAmount += claimAmount; + poolRemainingAmount -= claimAmount; + } + // If not yet qualified, nothing is claimed from the pool. + } + + // 3. Sort referrers to assign ranks: + // 1. qualifiedAwardValue (awardPoolApproxValue) desc — actual pool claims, race winners first + // 2. standardAwardValue desc — uncapped earned value, separates pool-depleted referrers + // 3. referrer address desc — deterministic tie-break + // Both `a` and `b` are keys from `referrerStates`, so lookups are always defined. + const sortedAddresses = [...referrerStates.keys()].sort((a, b) => { + const stateA = referrerStates.get(a) as ReferrerRaceState; + const stateB = referrerStates.get(b) as ReferrerRaceState; + + // Primary: qualifiedAwardValue desc (bigint comparison) + if (stateB.qualifiedAwardValueAmount !== stateA.qualifiedAwardValueAmount) { + return stateB.qualifiedAwardValueAmount > stateA.qualifiedAwardValueAmount ? 1 : -1; + } + + // Secondary: standardAwardValue = qualifiedRevenueShare × totalBaseRevenue, desc + const standardA = scalePrice( + priceUsdc(stateA.totalBaseRevenueContributionAmount), + rules.qualifiedRevenueShare, + ).amount; + const standardB = scalePrice( + priceUsdc(stateB.totalBaseRevenueContributionAmount), + rules.qualifiedRevenueShare, + ).amount; + if (standardB !== standardA) { + return standardB > standardA ? 1 : -1; + } + + // Tertiary: referrer address desc (lexicographic) + if (b > a) return 1; + if (b < a) return -1; + return 0; + }); + + // 4. Build AwardedReferrerMetricsRevShareLimit for each referrer. + const awardedReferrers: AwardedReferrerMetricsRevShareLimit[] = sortedAddresses.map( + (referrerAddr, index) => { + // `sortedAddresses` is derived directly from `referrerStates.keys()`, so + // the state entry is always present. + const state = referrerStates.get(referrerAddr) as ReferrerRaceState; + + const baseMetrics = buildReferrerMetrics( + referrerAddr, + state.totalReferrals, + state.totalIncrementalDuration, + priceEth(state.totalRevenueContributionAmount), + ); + + const revShareMetrics = buildReferrerMetricsRevShareLimit(baseMetrics); + + const rankedMetrics = buildRankedReferrerMetricsRevShareLimit( + revShareMetrics, + index + 1, + rules, + ); + + const standardAwardValue = scalePrice( + revShareMetrics.totalBaseRevenueContribution, + rules.qualifiedRevenueShare, + ); + + return buildAwardedReferrerMetricsRevShareLimit( + rankedMetrics, + standardAwardValue, + priceUsdc(state.qualifiedAwardValueAmount), + ); + }, ); - const awardedReferrers = rankedReferrers.map((r) => - buildAwardedReferrerMetricsRevShareLimit(r, rules, scalingFactor), + const awardPoolRemaining = priceUsdc(poolRemainingAmount); + + const aggregatedMetrics = buildAggregatedReferrerMetricsRevShareLimit( + awardedReferrers, + awardPoolRemaining, ); const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts index 2ef2a9bad..dfc2a2a97 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -1,6 +1,6 @@ import type { Address } from "viem"; -import { type PriceUsdc, priceEth, priceUsdc, scalePrice } from "@ensnode/ensnode-sdk"; +import { type PriceUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; import type { ReferrerMetrics } from "../../referrer-metrics"; import { buildReferrerMetrics } from "../../referrer-metrics"; @@ -72,28 +72,37 @@ export const buildRankedReferrerMetricsRevShareLimit = ( * Extends {@link RankedReferrerMetricsRevShareLimit} with approximate award value. */ export interface AwardedReferrerMetricsRevShareLimit extends RankedReferrerMetricsRevShareLimit { + /** + * The standard (uncapped) USDC award value for this referrer, computed as + * `qualifiedRevenueShare × totalBaseRevenueContribution`. + * + * Represents what the referrer would receive if the pool were unlimited. + * Independent of the pool state. + * + * @invariant Guaranteed to be a valid PriceUsdc with non-negative amount (>= 0n) + */ + standardAwardValue: PriceUsdc; + /** * The approximate USDC value of the referrer's award. * + * This is the amount actually claimed from the pool by this referrer, capped by + * the remaining pool at the time of their qualifying events. + * * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRulesRevShareLimit.totalAwardPoolValue.amount} (inclusive) + * @invariant Always <= standardAwardValue.amount */ awardPoolApproxValue: PriceUsdc; } export const buildAwardedReferrerMetricsRevShareLimit = ( referrer: RankedReferrerMetricsRevShareLimit, - rules: ReferralProgramRulesRevShareLimit, - scalingFactor: number, + standardAwardValue: PriceUsdc, + awardPoolApproxValue: PriceUsdc, ): AwardedReferrerMetricsRevShareLimit => { - const awardPoolApproxValue = referrer.isQualified - ? scalePrice( - scalePrice(referrer.totalBaseRevenueContribution, rules.qualifiedRevenueShare), - scalingFactor, - ) - : priceUsdc(0n); - return { ...referrer, + standardAwardValue, awardPoolApproxValue, } satisfies AwardedReferrerMetricsRevShareLimit; }; @@ -128,6 +137,7 @@ export const buildUnrankedReferrerMetricsRevShareLimit = ( totalBaseRevenueContribution: priceUsdc(0n), rank: null, isQualified: false, + standardAwardValue: priceUsdc(0n), awardPoolApproxValue: priceUsdc(0n), } satisfies UnrankedReferrerMetricsRevShareLimit; }; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts new file mode 100644 index 000000000..719712d4e --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts @@ -0,0 +1,42 @@ +import type { Address } from "viem"; + +import type { Duration, PriceEth, UnixTimestamp } from "@ensnode/ensnode-sdk"; + +/** + * Represents a single raw referral event. + * + * Used as input to the sequential race algorithm for the rev-share-limit award model. + * Events are processed in chronological order to determine award claims from the pool. + */ +export interface ReferralEvent { + /** + * The fully lowercase Ethereum address of the referrer. + */ + referrer: Address; + + /** + * Unix seconds block timestamp. + * Used as the primary sort key for chronological ordering. + */ + timestamp: UnixTimestamp; + + /** + * Block number. Used for tie-breaking within the same timestamp. + */ + blockNumber: bigint; + + /** + * Transaction hash. Used for tie-breaking within the same block. + */ + transactionHash: `0x${string}`; + + /** + * Duration in seconds contributed by this single referral event. + */ + incrementalDuration: Duration; + + /** + * Revenue contribution in ETH from this single referral event. + */ + incrementalRevenueContribution: PriceEth; +} diff --git a/packages/ens-referrals/src/v1/index.ts b/packages/ens-referrals/src/v1/index.ts index f689fe255..82ebce219 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -14,6 +14,7 @@ export * from "./award-models/rev-share-limit/edition-metrics"; export * from "./award-models/rev-share-limit/leaderboard"; export * from "./award-models/rev-share-limit/leaderboard-page"; export * from "./award-models/rev-share-limit/metrics"; +export * from "./award-models/rev-share-limit/referral-event"; export * from "./award-models/rev-share-limit/rules"; export * from "./award-models/shared/edition-metrics"; export * from "./award-models/shared/leaderboard-page"; diff --git a/packages/ens-referrals/src/v1/leaderboard.ts b/packages/ens-referrals/src/v1/leaderboard.ts index 056769fe7..fdaedef70 100644 --- a/packages/ens-referrals/src/v1/leaderboard.ts +++ b/packages/ens-referrals/src/v1/leaderboard.ts @@ -1,16 +1,5 @@ -import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; - -import { - buildReferrerLeaderboardPieSplit, - type ReferrerLeaderboardPieSplit, -} from "./award-models/pie-split/leaderboard"; -import { - buildReferrerLeaderboardRevShareLimit, - type ReferrerLeaderboardRevShareLimit, -} from "./award-models/rev-share-limit/leaderboard"; -import { ReferralProgramAwardModels } from "./award-models/shared/rules"; -import type { ReferrerMetrics } from "./referrer-metrics"; -import type { ReferralProgramRules } from "./rules"; +import type { ReferrerLeaderboardPieSplit } from "./award-models/pie-split/leaderboard"; +import type { ReferrerLeaderboardRevShareLimit } from "./award-models/rev-share-limit/leaderboard"; /** * Represents a leaderboard for any number of referrers. @@ -18,16 +7,3 @@ import type { ReferralProgramRules } from "./rules"; * Use `awardModel` to narrow the specific variant at runtime. */ export type ReferrerLeaderboard = ReferrerLeaderboardPieSplit | ReferrerLeaderboardRevShareLimit; - -export const buildReferrerLeaderboard = ( - allReferrers: ReferrerMetrics[], - rules: ReferralProgramRules, - accurateAsOf: UnixTimestamp, -): ReferrerLeaderboard => { - switch (rules.awardModel) { - case ReferralProgramAwardModels.PieSplit: - return buildReferrerLeaderboardPieSplit(allReferrers, rules, accurateAsOf); - case ReferralProgramAwardModels.RevShareLimit: - return buildReferrerLeaderboardRevShareLimit(allReferrers, rules, accurateAsOf); - } -}; From 5e5cdc63031e06252715bfe501324b63d00ae470 Mon Sep 17 00:00:00 2001 From: Goader Date: Tue, 24 Feb 2026 04:16:57 +0100 Subject: [PATCH 05/15] initial review applied --- .../get-referrer-leaderboard.test.ts | 3 +- .../src/v1/award-models/pie-split/metrics.ts | 4 +- .../src/v1/award-models/pie-split/score.ts | 2 +- .../rev-share-limit/api/zod-schemas.ts | 2 +- .../rev-share-limit/edition-metrics.ts | 4 +- .../rev-share-limit/leaderboard.ts | 4 +- .../award-models/rev-share-limit/metrics.ts | 2 +- .../v1/award-models/rev-share-limit/rank.ts | 16 ---- .../v1/award-models/rev-share-limit/rules.ts | 6 +- packages/ens-referrals/src/v1/base-metrics.ts | 77 ------------------- 10 files changed, 16 insertions(+), 104 deletions(-) delete mode 100644 packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts delete mode 100644 packages/ens-referrals/src/v1/base-metrics.ts diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts index cc6eb52c1..d7bb0cacb 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts @@ -69,7 +69,8 @@ describe("ENSAnalytics Referrer Leaderboard", () => { // Assert `finalScore` expect( qualifiedReferrers.every( - ([_, referrer]) => referrer.finalScore === referrer.score * referrer.finalScoreBoost, + ([_, referrer]) => + referrer.finalScore === referrer.score * (1 + referrer.finalScoreBoost), ), ).toBe(true); expect( diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index 3ba2d8e5f..015b5be96 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -167,14 +167,14 @@ export const calcReferrerAwardPoolSharePieSplit = ( /** * Extends {@link RankedReferrerMetricsPieSplit} to include additional metrics - * relative to {@link AggregatedRankedReferrerMetricsPieSplit}. + * relative to {@link AggregatedReferrerMetricsPieSplit}. */ export interface AwardedReferrerMetricsPieSplit extends RankedReferrerMetricsPieSplit { /** * The referrer's share of the award pool. * * @invariant Guaranteed to be a number between 0 and 1 (inclusive) - * @invariant Calculated as: `finalScore / {@link AggregatedRankedReferrerMetricsPieSplit.grandTotalQualifiedReferrersFinalScore}` if `isQualified` is `true`, else `0` + * @invariant Calculated as: `finalScore / {@link AggregatedReferrerMetricsPieSplit.grandTotalQualifiedReferrersFinalScore}` if `isQualified` is `true`, else `0` */ awardPoolShare: number; diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/score.ts b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts index 20f22b612..b251e4fd1 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/score.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts @@ -6,7 +6,7 @@ import type { ReferrerScore } from "../shared/score"; /** * Calculate the score of a referrer based on the total incremental duration * (in seconds) of registrations and renewals for direct subnames of .eth - * referred by the referrer within the ENS Holiday Awards period. + * referred by the referrer within the referral program edition. * * Used exclusively in the pie-split award model pipeline. * diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts index fa57ae6a9..c7c34c2d2 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -28,7 +28,7 @@ export const makeReferralProgramRulesRevShareLimitSchema = ( ) => z .object({ - awardModel: z.literal("rev-share-limit"), + awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), minQualifiedRevenueContribution: makePriceUsdcSchema( `${valueLabel}.minQualifiedRevenueContribution`, diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts index 08e9d80d4..10cf7d7b3 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts @@ -42,8 +42,8 @@ export interface ReferrerEditionMetricsRankedRevShareLimit { /** * The awarded referrer metrics from the leaderboard. * - * Contains all calculated metrics including score, rank, qualification status, - * and award pool share information. + * Contains all calculated metrics including rank, qualification status, + * standard award value, and award pool approximate value. */ referrer: AwardedReferrerMetricsRevShareLimit; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index ff08fd0f7..248e09d41 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -55,10 +55,10 @@ export interface ReferrerLeaderboardRevShareLimit { * @invariant Map is empty if there are no referrers with 1 or more `totalReferrals` * within the `rules` as of `accurateAsOf`. * @invariant If a fully-lowercase `Address` is not a key in this map then that `Address` had - * 0 `totalReferrals`, `totalIncrementalDuration`, and `score` within the + * 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`, `totalIncrementalDuration`, and `score`. + * `totalReferrals` and `totalIncrementalDuration`. */ referrers: Map; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts index dfc2a2a97..8a1674c1d 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -125,7 +125,7 @@ export interface UnrankedReferrerMetricsRevShareLimit } /** - * Build an unranked zero-score rev-share-limit referrer record for an address not on the leaderboard. + * Build an unranked zero-metrics rev-share-limit referrer record for an address not on the leaderboard. */ export const buildUnrankedReferrerMetricsRevShareLimit = ( referrer: Address, diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts deleted file mode 100644 index fb2ad507d..000000000 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rank.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PriceUsdc } from "@ensnode/ensnode-sdk"; - -import type { ReferralProgramRulesRevShareLimit } from "./rules"; - -/** - * Determine if a referrer meets the revenue threshold to qualify under rev-share-limit rules. - * - * @param totalBaseRevenueContribution - The referrer's total base revenue contribution. - * @param rules - The rev-share-limit rules of the referral program. - */ -export function isReferrerQualifiedRevShareLimit( - totalBaseRevenueContribution: PriceUsdc, - rules: ReferralProgramRulesRevShareLimit, -): boolean { - return totalBaseRevenueContribution.amount >= rules.minQualifiedRevenueContribution.amount; -} diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts index 44c97ff33..8134a642d 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts @@ -68,7 +68,11 @@ export const validateReferralProgramRulesRevShareLimit = ( ); } - if (rules.qualifiedRevenueShare < 0 || rules.qualifiedRevenueShare > 1) { + if ( + !Number.isFinite(rules.qualifiedRevenueShare) || + rules.qualifiedRevenueShare < 0 || + rules.qualifiedRevenueShare > 1 + ) { throw new Error( `ReferralProgramRulesRevShareLimit: qualifiedRevenueShare must be between 0 and 1 (inclusive), got ${rules.qualifiedRevenueShare}.`, ); diff --git a/packages/ens-referrals/src/v1/base-metrics.ts b/packages/ens-referrals/src/v1/base-metrics.ts deleted file mode 100644 index 94a307c9c..000000000 --- a/packages/ens-referrals/src/v1/base-metrics.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Address } from "viem"; - -import type { Duration, PriceEth } from "@ensnode/ensnode-sdk"; -import { makePriceEthSchema } from "@ensnode/ensnode-sdk/internal"; - -import { normalizeAddress, validateLowercaseAddress } from "./address"; -import { validateNonNegativeInteger } from "./number"; -import { ReferralProgramRules } from "./rules"; -import { validateDuration } from "./time"; - -/** - * Base metrics for a single referrer independent of other referrers and award model. - * Used as input from the DB layer; does not carry an `awardModel` discriminant. - */ -export interface BaseReferrerMetrics { - /** - * The fully lowercase Ethereum address of the referrer. - * - * @invariant Guaranteed to be a valid EVM address in lowercase format - */ - referrer: Address; - - /** - * The total number of referrals made by the referrer within the {@link ReferralProgramRules}. - * @invariant Guaranteed to be a non-negative integer (>= 0) - */ - totalReferrals: number; - - /** - * The total incremental duration (in seconds) of all referrals made by the referrer within - * the {@link ReferralProgramRules}. - */ - totalIncrementalDuration: Duration; - - /** - * The total revenue contribution in ETH made to the ENS DAO by all referrals - * from this referrer. - * - * This is the sum of the total cost paid by registrants for all registrar actions - * where this address was the referrer. - * - * @invariant Guaranteed to be a valid PriceEth with non-negative amount (>= 0n) - * @invariant Never null (records with null `total` in the database are treated as 0 when summing) - */ - totalRevenueContribution: PriceEth; -} - -export const buildReferrerMetrics = ( - referrer: Address, - totalReferrals: number, - totalIncrementalDuration: Duration, - totalRevenueContribution: PriceEth, -): BaseReferrerMetrics => { - const result = { - referrer: normalizeAddress(referrer), - totalReferrals, - totalIncrementalDuration, - totalRevenueContribution, - } satisfies BaseReferrerMetrics; - - validateReferrerMetrics(result); - return result; -}; - -export const validateReferrerMetrics = (metrics: BaseReferrerMetrics): void => { - validateLowercaseAddress(metrics.referrer); - validateNonNegativeInteger(metrics.totalReferrals); - validateDuration(metrics.totalIncrementalDuration); - - const priceEthSchema = makePriceEthSchema("BaseReferrerMetrics.totalRevenueContribution"); - const parseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); - if (!parseResult.success) { - throw new Error( - `BaseReferrerMetrics: totalRevenueContribution validation failed: ${parseResult.error.message}`, - ); - } -}; From 8e8e4cfc723ef7439ae996b26692a0924e85f1e6 Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 25 Feb 2026 02:36:23 +0100 Subject: [PATCH 06/15] review applied cont. --- .../referrer-leaderboard/database-v1.ts | 6 +- .../get-referrer-leaderboard.test.ts | 8 +-- .../src/v1/award-models/pie-split/metrics.ts | 5 +- .../src/v1/award-models/pie-split/rank.ts | 1 - .../src/v1/award-models/pie-split/score.ts | 1 - .../rev-share-limit/edition-metrics.ts | 8 --- .../rev-share-limit/leaderboard.ts | 60 ++++++++----------- .../award-models/rev-share-limit/metrics.ts | 2 - 8 files changed, 35 insertions(+), 56 deletions(-) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts index 79c266175..e98dd134c 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts @@ -137,8 +137,10 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise { rules, }); - const referrers = result.referrers.entries(); - const qualifiedReferrers = referrers.take(rules.maxQualifiedReferrers); - const unqualifiedReferrers = referrers.drop(rules.maxQualifiedReferrers); + const referrerEntries = Array.from(result.referrers.entries()); + const qualifiedReferrers = referrerEntries.slice(0, rules.maxQualifiedReferrers); + const unqualifiedReferrers = referrerEntries.slice(rules.maxQualifiedReferrers); /** * Assert {@link RankedReferrerMetrics}. @@ -61,7 +61,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { expect(unqualifiedReferrers.every(([_, referrer]) => !referrer.isQualified)).toBe(true); // Assert `finalScoreBoost` - expect(qualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost > 0)).toBe(true); + expect(qualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost >= 0)).toBe(true); expect(unqualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost === 0)).toBe( true, ); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index 015b5be96..10289e136 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -159,10 +159,7 @@ export const calcReferrerAwardPoolSharePieSplit = ( if (!isReferrerQualifiedPieSplit(referrer.rank, rules)) return 0; if (aggregatedMetrics.grandTotalQualifiedReferrersFinalScore === 0) return 0; - return ( - calcReferrerFinalScorePieSplit(referrer.rank, referrer.totalIncrementalDuration, rules) / - aggregatedMetrics.grandTotalQualifiedReferrersFinalScore - ); + return referrer.finalScore / aggregatedMetrics.grandTotalQualifiedReferrersFinalScore; }; /** diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts b/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts index 340996b22..652af4f47 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts @@ -60,7 +60,6 @@ export function calcReferrerFinalScoreMultiplierPieSplit( * @param totalIncrementalDuration - The total incremental duration (in seconds) * of referrals made by the referrer within the `rules`. * @param rules - The pie-split rules of the referral program. - * @returns The final score of the referrer. */ export function calcReferrerFinalScorePieSplit( rank: ReferrerRank, diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/score.ts b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts index b251e4fd1..755ad8976 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/score.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts @@ -12,7 +12,6 @@ import type { ReferrerScore } from "../shared/score"; * * @param totalIncrementalDuration - The total incremental duration (in seconds) * of referrals made by a referrer within the {@link ReferralProgramRulesPieSplit}. - * @returns The score of the referrer. */ export const calcReferrerScorePieSplit = (totalIncrementalDuration: Duration): ReferrerScore => { return totalIncrementalDuration / SECONDS_PER_YEAR; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts index 10cf7d7b3..4fd5b5cd4 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts @@ -15,10 +15,6 @@ import type { ReferralProgramRulesRevShareLimit } from "./rules"; * * Includes the referrer's awarded metrics from the leaderboard plus timestamp. * - * Invariants: - * - `type` is always {@link ReferrerEditionMetricsTypeIds.Ranked}. - * - `awardModel` is always {@link ReferralProgramAwardModels.RevShareLimit} and equals `rules.awardModel`. - * * @see {@link AwardedReferrerMetricsRevShareLimit} */ export interface ReferrerEditionMetricsRankedRevShareLimit { @@ -69,10 +65,6 @@ export interface ReferrerEditionMetricsRankedRevShareLimit { * * Includes the referrer's unranked metrics (with null rank and isQualified: false) plus timestamp. * - * Invariants: - * - `type` is always {@link ReferrerEditionMetricsTypeIds.Unranked}. - * - `awardModel` is always {@link ReferralProgramAwardModels.RevShareLimit} and equals `rules.awardModel`. - * * @see {@link UnrankedReferrerMetricsRevShareLimit} */ export interface ReferrerEditionMetricsUnrankedRevShareLimit { diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index 248e09d41..c78e3a858 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -75,9 +75,6 @@ interface ReferrerRaceState { totalReferrals: number; totalIncrementalDuration: Duration; totalRevenueContributionAmount: bigint; - totalBaseRevenueContributionAmount: bigint; - /** Accumulated standard award (qualifiedRevenueShare × baseRevenue), regardless of pool. */ - accumulatedStandardAwardAmount: bigint; /** Whether this referrer has ever crossed the qualification threshold. */ wasQualified: boolean; /** Amount actually claimed from the award pool. */ @@ -124,47 +121,49 @@ export const buildReferrerLeaderboardRevShareLimit = ( totalReferrals: 0, totalIncrementalDuration: 0, totalRevenueContributionAmount: 0n, - totalBaseRevenueContributionAmount: 0n, - accumulatedStandardAwardAmount: 0n, wasQualified: false, qualifiedAwardValueAmount: 0n, }; referrerStates.set(referrer, state); } - // Compute incremental base revenue: BASE_REVENUE_CONTRIBUTION_PER_YEAR × (duration / SECONDS_PER_YEAR) - const incrementalBaseRevenueAmount = - (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(event.incrementalDuration)) / - BigInt(SECONDS_PER_YEAR); - - // Compute incremental standard award: qualifiedRevenueShare × incrementalBaseRevenue - const incrementalStandardAwardAmount = scalePrice( - priceUsdc(incrementalBaseRevenueAmount), - rules.qualifiedRevenueShare, - ).amount; - - // Update running totals. + // Update raw totals. state.totalReferrals += 1; state.totalIncrementalDuration += event.incrementalDuration; state.totalRevenueContributionAmount += event.incrementalRevenueContribution.amount; - state.totalBaseRevenueContributionAmount += incrementalBaseRevenueAmount; - state.accumulatedStandardAwardAmount += incrementalStandardAwardAmount; + + // Compute totalBaseRevenue from aggregated duration (single division — avoids per-event + // truncation that would compound into a sum lower than the correct aggregated value). + const totalBaseRevenueAmount = + (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(state.totalIncrementalDuration)) / + BigInt(SECONDS_PER_YEAR); // Determine if newly qualifying or already qualified. - const isNowQualified = - state.totalBaseRevenueContributionAmount >= rules.minQualifiedRevenueContribution.amount; + const isNowQualified = totalBaseRevenueAmount >= rules.minQualifiedRevenueContribution.amount; if (isNowQualified && !state.wasQualified) { // First time crossing the qualification threshold: claim all accumulated standard award. + // Compute from aggregated totals to match the single-division used in final output. + const accumulatedStandardAwardAmount = scalePrice( + priceUsdc(totalBaseRevenueAmount), + rules.qualifiedRevenueShare, + ).amount; const claimAmount = - state.accumulatedStandardAwardAmount < poolRemainingAmount - ? state.accumulatedStandardAwardAmount + accumulatedStandardAwardAmount < poolRemainingAmount + ? accumulatedStandardAwardAmount : poolRemainingAmount; state.qualifiedAwardValueAmount += claimAmount; poolRemainingAmount -= claimAmount; state.wasQualified = true; } else if (state.wasQualified) { // Already qualified: claim this event's incremental standard award. + const incrementalBaseRevenueAmount = + (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(event.incrementalDuration)) / + BigInt(SECONDS_PER_YEAR); + const incrementalStandardAwardAmount = scalePrice( + priceUsdc(incrementalBaseRevenueAmount), + rules.qualifiedRevenueShare, + ).amount; const claimAmount = incrementalStandardAwardAmount < poolRemainingAmount ? incrementalStandardAwardAmount @@ -189,17 +188,10 @@ export const buildReferrerLeaderboardRevShareLimit = ( return stateB.qualifiedAwardValueAmount > stateA.qualifiedAwardValueAmount ? 1 : -1; } - // Secondary: standardAwardValue = qualifiedRevenueShare × totalBaseRevenue, desc - const standardA = scalePrice( - priceUsdc(stateA.totalBaseRevenueContributionAmount), - rules.qualifiedRevenueShare, - ).amount; - const standardB = scalePrice( - priceUsdc(stateB.totalBaseRevenueContributionAmount), - rules.qualifiedRevenueShare, - ).amount; - if (standardB !== standardA) { - return standardB > standardA ? 1 : -1; + // Secondary: totalIncrementalDuration desc — monotonically equivalent to standardAwardValue desc + // and avoids recomputing scalePrice for every comparison pair. + if (stateB.totalIncrementalDuration !== stateA.totalIncrementalDuration) { + return stateB.totalIncrementalDuration - stateA.totalIncrementalDuration; } // Tertiary: referrer address desc (lexicographic) diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts index 8a1674c1d..eeec84b69 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -78,8 +78,6 @@ export interface AwardedReferrerMetricsRevShareLimit extends RankedReferrerMetri * * Represents what the referrer would receive if the pool were unlimited. * Independent of the pool state. - * - * @invariant Guaranteed to be a valid PriceUsdc with non-negative amount (>= 0n) */ standardAwardValue: PriceUsdc; From 16c1ae3db0bcc97aba57f37986a09ffd7a077eea Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 25 Feb 2026 04:19:00 +0100 Subject: [PATCH 07/15] review applied cont. 2 --- .../ens-referrals/src/v1/api/serialize.ts | 17 ++ .../v1/award-models/pie-split/aggregations.ts | 10 +- .../src/v1/award-models/pie-split/metrics.ts | 20 +-- .../src/v1/award-models/pie-split/rules.ts | 41 ++--- .../rev-share-limit/aggregations.ts | 20 +-- .../rev-share-limit/leaderboard.ts | 1 + .../award-models/rev-share-limit/metrics.ts | 149 +++++++++++++++++- .../v1/award-models/rev-share-limit/rules.ts | 52 ++---- .../award-models/shared/leaderboard-page.ts | 4 +- .../src/v1/award-models/shared/rules.ts | 22 +++ .../ens-referrals/src/v1/referrer-metrics.ts | 10 +- 11 files changed, 221 insertions(+), 125 deletions(-) diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 0472b69b2..cce559d9c 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -52,6 +52,11 @@ export function serializeReferralProgramRules( case ReferralProgramAwardModels.RevShareLimit: return serializeReferralProgramRulesRevShareLimit(rules); + + default: { + const _exhaustiveCheck: never = rules; + throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`); + } } } @@ -66,6 +71,10 @@ function serializeReferrerLeaderboardPage( return serializeReferrerLeaderboardPagePieSplit(page); case ReferralProgramAwardModels.RevShareLimit: return serializeReferrerLeaderboardPageRevShareLimit(page); + default: { + const _exhaustiveCheck: never = page; + throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferrerLeaderboardPage).awardModel}`); + } } } @@ -80,6 +89,10 @@ function serializeReferrerEditionMetricsRanked( return serializeReferrerEditionMetricsRankedPieSplit(detail); case ReferralProgramAwardModels.RevShareLimit: return serializeReferrerEditionMetricsRankedRevShareLimit(detail); + default: { + const _exhaustiveCheck: never = detail; + throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsRanked).awardModel}`); + } } } @@ -94,6 +107,10 @@ function serializeReferrerEditionMetricsUnranked( return serializeReferrerEditionMetricsUnrankedPieSplit(detail); case ReferralProgramAwardModels.RevShareLimit: return serializeReferrerEditionMetricsUnrankedRevShareLimit(detail); + default: { + const _exhaustiveCheck: never = detail; + throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsUnranked).awardModel}`); + } } } diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts index 6067cdccd..b11cbe368 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts @@ -53,15 +53,9 @@ export const validateAggregatedReferrerMetricsPieSplit = ( validateNonNegativeInteger(metrics.grandTotalReferrals); validateDuration(metrics.grandTotalIncrementalDuration); - const priceEthSchema = makePriceEthSchema( + makePriceEthSchema( "AggregatedReferrerMetricsPieSplit.grandTotalRevenueContribution", - ); - const parseResult = priceEthSchema.safeParse(metrics.grandTotalRevenueContribution); - if (!parseResult.success) { - throw new Error( - `AggregatedReferrerMetricsPieSplit: grandTotalRevenueContribution validation failed: ${parseResult.error.message}`, - ); - } + ).parse(metrics.grandTotalRevenueContribution); validateReferrerScore(metrics.grandTotalQualifiedReferrersFinalScore); validateReferrerScore(metrics.minFinalScoreToQualify); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index 10289e136..377998b05 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -267,15 +267,9 @@ export const validateUnrankedReferrerMetricsPieSplit = ( ); } - const priceEthSchema = makePriceEthSchema( + makePriceEthSchema( "UnrankedReferrerMetricsPieSplit.totalRevenueContribution", - ); - const ethParseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); - if (!ethParseResult.success) { - throw new Error( - `Invalid UnrankedReferrerMetricsPieSplit: totalRevenueContribution validation failed: ${ethParseResult.error.message}`, - ); - } + ).parse(metrics.totalRevenueContribution); if (metrics.totalRevenueContribution.amount !== 0n) { throw new Error( `Invalid UnrankedReferrerMetricsPieSplit: totalRevenueContribution.amount must be 0n, got: ${metrics.totalRevenueContribution.amount.toString()}.`, @@ -303,15 +297,9 @@ export const validateUnrankedReferrerMetricsPieSplit = ( ); } - const priceUsdcSchema = makePriceUsdcSchema( + makePriceUsdcSchema( "UnrankedReferrerMetricsPieSplit.awardPoolApproxValue", - ); - const usdcParseResult = priceUsdcSchema.safeParse(metrics.awardPoolApproxValue); - if (!usdcParseResult.success) { - throw new Error( - `Invalid UnrankedReferrerMetricsPieSplit: awardPoolApproxValue validation failed: ${usdcParseResult.error.message}`, - ); - } + ).parse(metrics.awardPoolApproxValue); if (metrics.awardPoolApproxValue.amount !== 0n) { throw new Error( `Invalid UnrankedReferrerMetricsPieSplit: awardPoolApproxValue must be 0n, got: ${metrics.awardPoolApproxValue.amount.toString()}.`, diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts index 4f42572a1..b55a3e634 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts @@ -1,9 +1,12 @@ import type { AccountId, PriceUsdc, UnixTimestamp } from "@ensnode/ensnode-sdk"; -import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; +import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; import { validateNonNegativeInteger } from "../../number"; -import { validateUnixTimestamp } from "../../time"; -import { type BaseReferralProgramRules, ReferralProgramAwardModels } from "../shared/rules"; +import { + type BaseReferralProgramRules, + ReferralProgramAwardModels, + validateBaseReferralProgramRules, +} from "../shared/rules"; export interface ReferralProgramRulesPieSplit extends BaseReferralProgramRules { /** @@ -30,37 +33,13 @@ export interface ReferralProgramRulesPieSplit extends BaseReferralProgramRules { } export const validateReferralProgramRulesPieSplit = (rules: ReferralProgramRulesPieSplit): void => { - const priceUsdcSchema = makePriceUsdcSchema("ReferralProgramRulesPieSplit.totalAwardPoolValue"); - const parseResult = priceUsdcSchema.safeParse(rules.totalAwardPoolValue); - if (!parseResult.success) { - throw new Error( - `ReferralProgramRulesPieSplit: totalAwardPoolValue validation failed: ${parseResult.error.message}`, - ); - } - - const accountIdSchema = makeAccountIdSchema("ReferralProgramRulesPieSplit.subregistryId"); - const accountIdParseResult = accountIdSchema.safeParse(rules.subregistryId); - if (!accountIdParseResult.success) { - throw new Error( - `ReferralProgramRulesPieSplit: subregistryId validation failed: ${accountIdParseResult.error.message}`, - ); - } + makePriceUsdcSchema("ReferralProgramRulesPieSplit.totalAwardPoolValue").parse( + rules.totalAwardPoolValue, + ); validateNonNegativeInteger(rules.maxQualifiedReferrers); - validateUnixTimestamp(rules.startTime); - validateUnixTimestamp(rules.endTime); - - if (!(rules.rulesUrl instanceof URL)) { - throw new Error( - `ReferralProgramRulesPieSplit: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, - ); - } - if (rules.endTime < rules.startTime) { - throw new Error( - `ReferralProgramRulesPieSplit: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, - ); - } + validateBaseReferralProgramRules(rules); }; export const buildReferralProgramRulesPieSplit = ( diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts index 7994f817b..51247baf6 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts @@ -45,25 +45,13 @@ export const validateAggregatedReferrerMetricsRevShareLimit = ( validateNonNegativeInteger(metrics.grandTotalReferrals); validateDuration(metrics.grandTotalIncrementalDuration); - const priceEthSchema = makePriceEthSchema( + makePriceEthSchema( "AggregatedReferrerMetricsRevShareLimit.grandTotalRevenueContribution", - ); - const parseResultEth = priceEthSchema.safeParse(metrics.grandTotalRevenueContribution); - if (!parseResultEth.success) { - throw new Error( - `AggregatedReferrerMetricsRevShareLimit: grandTotalRevenueContribution validation failed: ${parseResultEth.error.message}`, - ); - } + ).parse(metrics.grandTotalRevenueContribution); - const priceUsdcSchema = makePriceUsdcSchema( + makePriceUsdcSchema( "AggregatedReferrerMetricsRevShareLimit.awardPoolRemaining", - ); - const parseResultUsdc = priceUsdcSchema.safeParse(metrics.awardPoolRemaining); - if (!parseResultUsdc.success) { - throw new Error( - `AggregatedReferrerMetricsRevShareLimit: awardPoolRemaining validation failed: ${parseResultUsdc.error.message}`, - ); - } + ).parse(metrics.awardPoolRemaining); }; /** diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index c78e3a858..fe88a2cce 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -231,6 +231,7 @@ export const buildReferrerLeaderboardRevShareLimit = ( rankedMetrics, standardAwardValue, priceUsdc(state.qualifiedAwardValueAmount), + rules, ); }, ); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts index eeec84b69..fff987a92 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -1,11 +1,13 @@ import type { Address } from "viem"; import { type PriceUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; +import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; import type { ReferrerMetrics } from "../../referrer-metrics"; -import { buildReferrerMetrics } from "../../referrer-metrics"; +import { buildReferrerMetrics, validateReferrerMetrics } from "../../referrer-metrics"; import { SECONDS_PER_YEAR } from "../../time"; import type { ReferrerRank } from "../shared/rank"; +import { validateReferrerRank } from "../shared/rank"; import { BASE_REVENUE_CONTRIBUTION_PER_YEAR, isReferrerQualifiedRevShareLimit, @@ -25,6 +27,22 @@ export interface ReferrerMetricsRevShareLimit extends ReferrerMetrics { totalBaseRevenueContribution: PriceUsdc; } +export const validateReferrerMetricsRevShareLimit = ( + metrics: ReferrerMetricsRevShareLimit, +): void => { + validateReferrerMetrics(metrics); + + const expectedTotalBaseRevenueContribution = priceUsdc( + (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(metrics.totalIncrementalDuration)) / + BigInt(SECONDS_PER_YEAR), + ); + if (metrics.totalBaseRevenueContribution.amount !== expectedTotalBaseRevenueContribution.amount) { + throw new Error( + `ReferrerMetricsRevShareLimit: Invalid totalBaseRevenueContribution: ${metrics.totalBaseRevenueContribution.amount.toString()}, expected: ${expectedTotalBaseRevenueContribution.amount.toString()}.`, + ); + } +}; + export const buildReferrerMetricsRevShareLimit = ( metrics: ReferrerMetrics, ): ReferrerMetricsRevShareLimit => { @@ -33,10 +51,13 @@ export const buildReferrerMetricsRevShareLimit = ( BigInt(SECONDS_PER_YEAR), ); - return { + const result = { ...metrics, totalBaseRevenueContribution, } satisfies ReferrerMetricsRevShareLimit; + + validateReferrerMetricsRevShareLimit(result); + return result; }; /** @@ -56,16 +77,37 @@ export interface RankedReferrerMetricsRevShareLimit extends ReferrerMetricsRevSh isQualified: boolean; } +export const validateRankedReferrerMetricsRevShareLimit = ( + metrics: RankedReferrerMetricsRevShareLimit, + rules: ReferralProgramRulesRevShareLimit, +): void => { + validateReferrerMetricsRevShareLimit(metrics); + validateReferrerRank(metrics.rank); + + const expectedIsQualified = isReferrerQualifiedRevShareLimit( + metrics.totalBaseRevenueContribution, + rules, + ); + if (metrics.isQualified !== expectedIsQualified) { + throw new Error( + `RankedReferrerMetricsRevShareLimit: Invalid isQualified: ${metrics.isQualified}, expected: ${expectedIsQualified}.`, + ); + } +}; + export const buildRankedReferrerMetricsRevShareLimit = ( referrer: ReferrerMetricsRevShareLimit, rank: ReferrerRank, rules: ReferralProgramRulesRevShareLimit, ): RankedReferrerMetricsRevShareLimit => { - return { + const result = { ...referrer, rank, isQualified: isReferrerQualifiedRevShareLimit(referrer.totalBaseRevenueContribution, rules), } satisfies RankedReferrerMetricsRevShareLimit; + + validateRankedReferrerMetricsRevShareLimit(result, rules); + return result; }; /** @@ -93,16 +135,47 @@ export interface AwardedReferrerMetricsRevShareLimit extends RankedReferrerMetri awardPoolApproxValue: PriceUsdc; } +export const validateAwardedReferrerMetricsRevShareLimit = ( + metrics: AwardedReferrerMetricsRevShareLimit, + rules: ReferralProgramRulesRevShareLimit, +): void => { + validateRankedReferrerMetricsRevShareLimit(metrics, rules); + + makePriceUsdcSchema("AwardedReferrerMetricsRevShareLimit.standardAwardValue").parse( + metrics.standardAwardValue, + ); + + makePriceUsdcSchema("AwardedReferrerMetricsRevShareLimit.awardPoolApproxValue").parse( + metrics.awardPoolApproxValue, + ); + + if (metrics.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount) { + throw new Error( + `AwardedReferrerMetricsRevShareLimit: awardPoolApproxValue.amount ${metrics.awardPoolApproxValue.amount.toString()} exceeds totalAwardPoolValue.amount ${rules.totalAwardPoolValue.amount.toString()}.`, + ); + } + + if (metrics.awardPoolApproxValue.amount > metrics.standardAwardValue.amount) { + throw new Error( + `AwardedReferrerMetricsRevShareLimit: awardPoolApproxValue.amount ${metrics.awardPoolApproxValue.amount.toString()} exceeds standardAwardValue.amount ${metrics.standardAwardValue.amount.toString()}.`, + ); + } +}; + export const buildAwardedReferrerMetricsRevShareLimit = ( referrer: RankedReferrerMetricsRevShareLimit, standardAwardValue: PriceUsdc, awardPoolApproxValue: PriceUsdc, + rules: ReferralProgramRulesRevShareLimit, ): AwardedReferrerMetricsRevShareLimit => { - return { + const result = { ...referrer, standardAwardValue, awardPoolApproxValue, } satisfies AwardedReferrerMetricsRevShareLimit; + + validateAwardedReferrerMetricsRevShareLimit(result, rules); + return result; }; /** @@ -122,6 +195,69 @@ export interface UnrankedReferrerMetricsRevShareLimit isQualified: false; } +export const validateUnrankedReferrerMetricsRevShareLimit = ( + metrics: UnrankedReferrerMetricsRevShareLimit, +): void => { + validateReferrerMetrics(metrics); + + if (metrics.rank !== null) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: rank must be null, got: ${metrics.rank}.`, + ); + } + if (metrics.isQualified !== false) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: isQualified must be false, got: ${metrics.isQualified}.`, + ); + } + if (metrics.totalReferrals !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: totalReferrals must be 0, got: ${metrics.totalReferrals}.`, + ); + } + if (metrics.totalIncrementalDuration !== 0) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: totalIncrementalDuration must be 0, got: ${metrics.totalIncrementalDuration}.`, + ); + } + + makePriceEthSchema("UnrankedReferrerMetricsRevShareLimit.totalRevenueContribution").parse( + metrics.totalRevenueContribution, + ); + if (metrics.totalRevenueContribution.amount !== 0n) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: totalRevenueContribution.amount must be 0n, got: ${metrics.totalRevenueContribution.amount.toString()}.`, + ); + } + + makePriceUsdcSchema( + "UnrankedReferrerMetricsRevShareLimit.totalBaseRevenueContribution", + ).parse(metrics.totalBaseRevenueContribution); + if (metrics.totalBaseRevenueContribution.amount !== 0n) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: totalBaseRevenueContribution.amount must be 0n, got: ${metrics.totalBaseRevenueContribution.amount.toString()}.`, + ); + } + + makePriceUsdcSchema("UnrankedReferrerMetricsRevShareLimit.standardAwardValue").parse( + metrics.standardAwardValue, + ); + if (metrics.standardAwardValue.amount !== 0n) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: standardAwardValue.amount must be 0n, got: ${metrics.standardAwardValue.amount.toString()}.`, + ); + } + + makePriceUsdcSchema("UnrankedReferrerMetricsRevShareLimit.awardPoolApproxValue").parse( + metrics.awardPoolApproxValue, + ); + if (metrics.awardPoolApproxValue.amount !== 0n) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: awardPoolApproxValue.amount must be 0n, got: ${metrics.awardPoolApproxValue.amount.toString()}.`, + ); + } +}; + /** * Build an unranked zero-metrics rev-share-limit referrer record for an address not on the leaderboard. */ @@ -130,7 +266,7 @@ export const buildUnrankedReferrerMetricsRevShareLimit = ( ): UnrankedReferrerMetricsRevShareLimit => { const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); - return { + const result = { ...metrics, totalBaseRevenueContribution: priceUsdc(0n), rank: null, @@ -138,4 +274,7 @@ export const buildUnrankedReferrerMetricsRevShareLimit = ( standardAwardValue: priceUsdc(0n), awardPoolApproxValue: priceUsdc(0n), } satisfies UnrankedReferrerMetricsRevShareLimit; + + validateUnrankedReferrerMetricsRevShareLimit(result); + return result; }; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts index 8134a642d..d38236013 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts @@ -4,10 +4,13 @@ import { parseUsdc, type UnixTimestamp, } from "@ensnode/ensnode-sdk"; -import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; +import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; -import { validateUnixTimestamp } from "../../time"; -import { type BaseReferralProgramRules, ReferralProgramAwardModels } from "../shared/rules"; +import { + type BaseReferralProgramRules, + ReferralProgramAwardModels, + validateBaseReferralProgramRules, +} from "../shared/rules"; /** * Base revenue contribution per year of incremental duration. @@ -50,23 +53,13 @@ export interface ReferralProgramRulesRevShareLimit extends BaseReferralProgramRu export const validateReferralProgramRulesRevShareLimit = ( rules: ReferralProgramRulesRevShareLimit, ): void => { - const poolSchema = makePriceUsdcSchema("ReferralProgramRulesRevShareLimit.totalAwardPoolValue"); - const poolResult = poolSchema.safeParse(rules.totalAwardPoolValue); - if (!poolResult.success) { - throw new Error( - `ReferralProgramRulesRevShareLimit: totalAwardPoolValue validation failed: ${poolResult.error.message}`, - ); - } + makePriceUsdcSchema("ReferralProgramRulesRevShareLimit.totalAwardPoolValue").parse( + rules.totalAwardPoolValue, + ); - const minSchema = makePriceUsdcSchema( - "ReferralProgramRulesRevShareLimit.minQualifiedRevenueContribution", + makePriceUsdcSchema("ReferralProgramRulesRevShareLimit.minQualifiedRevenueContribution").parse( + rules.minQualifiedRevenueContribution, ); - const minResult = minSchema.safeParse(rules.minQualifiedRevenueContribution); - if (!minResult.success) { - throw new Error( - `ReferralProgramRulesRevShareLimit: minQualifiedRevenueContribution validation failed: ${minResult.error.message}`, - ); - } if ( !Number.isFinite(rules.qualifiedRevenueShare) || @@ -78,28 +71,7 @@ export const validateReferralProgramRulesRevShareLimit = ( ); } - const accountIdSchema = makeAccountIdSchema("ReferralProgramRulesRevShareLimit.subregistryId"); - const accountIdResult = accountIdSchema.safeParse(rules.subregistryId); - if (!accountIdResult.success) { - throw new Error( - `ReferralProgramRulesRevShareLimit: subregistryId validation failed: ${accountIdResult.error.message}`, - ); - } - - validateUnixTimestamp(rules.startTime); - validateUnixTimestamp(rules.endTime); - - if (!(rules.rulesUrl instanceof URL)) { - throw new Error( - `ReferralProgramRulesRevShareLimit: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, - ); - } - - if (rules.endTime < rules.startTime) { - throw new Error( - `ReferralProgramRulesRevShareLimit: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, - ); - } + validateBaseReferralProgramRules(rules); }; export const buildReferralProgramRulesRevShareLimit = ( diff --git a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts index f4933be8a..678a0026d 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts @@ -127,9 +127,9 @@ export const validateReferrerLeaderboardPageContext = ( } // Validate totalPages - if (!isNonNegativeInteger(context.totalPages)) { + if (!isPositiveInteger(context.totalPages)) { throw new Error( - `Invalid ReferrerLeaderboardPageContext: totalPages must be a non-negative integer but is ${context.totalPages}.`, + `Invalid ReferrerLeaderboardPageContext: totalPages must be a positive integer (>= 1) but is ${context.totalPages}.`, ); } diff --git a/packages/ens-referrals/src/v1/award-models/shared/rules.ts b/packages/ens-referrals/src/v1/award-models/shared/rules.ts index 3e6e8eb05..bc4f90154 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/rules.ts @@ -1,4 +1,7 @@ import type { AccountId, UnixTimestamp } from "@ensnode/ensnode-sdk"; +import { makeAccountIdSchema } from "@ensnode/ensnode-sdk/internal"; + +import { validateUnixTimestamp } from "../../time"; /** * Discriminant values for the award model used in a referral program edition. @@ -51,3 +54,22 @@ export interface BaseReferralProgramRules { */ rulesUrl: URL; } + +export const validateBaseReferralProgramRules = (rules: BaseReferralProgramRules): void => { + makeAccountIdSchema("BaseReferralProgramRules.subregistryId").parse(rules.subregistryId); + + validateUnixTimestamp(rules.startTime); + validateUnixTimestamp(rules.endTime); + + if (!(rules.rulesUrl instanceof URL)) { + throw new Error( + `BaseReferralProgramRules: rulesUrl must be a URL instance, got ${typeof rules.rulesUrl}.`, + ); + } + + if (rules.endTime < rules.startTime) { + throw new Error( + `BaseReferralProgramRules: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`, + ); + } +}; diff --git a/packages/ens-referrals/src/v1/referrer-metrics.ts b/packages/ens-referrals/src/v1/referrer-metrics.ts index 9697198bf..90aa11854 100644 --- a/packages/ens-referrals/src/v1/referrer-metrics.ts +++ b/packages/ens-referrals/src/v1/referrer-metrics.ts @@ -67,11 +67,7 @@ export const validateReferrerMetrics = (metrics: ReferrerMetrics): void => { validateNonNegativeInteger(metrics.totalReferrals); validateDuration(metrics.totalIncrementalDuration); - const priceEthSchema = makePriceEthSchema("ReferrerMetrics.totalRevenueContribution"); - const parseResult = priceEthSchema.safeParse(metrics.totalRevenueContribution); - if (!parseResult.success) { - throw new Error( - `ReferrerMetrics: totalRevenueContribution validation failed: ${parseResult.error.message}`, - ); - } + makePriceEthSchema("ReferrerMetrics.totalRevenueContribution").parse( + metrics.totalRevenueContribution, + ); }; From 91ca301fdcdae7cd3df5ea0ecf6d98e62a333e51 Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 25 Feb 2026 05:09:25 +0100 Subject: [PATCH 08/15] review applied cont. 3 --- .../ens-referrals/src/v1/api/deserialize.ts | 8 +- .../ens-referrals/src/v1/api/serialize.ts | 16 ++- .../ens-referrals/src/v1/api/zod-schemas.ts | 111 ++++++++++++------ .../v1/award-models/pie-split/aggregations.ts | 6 +- .../src/v1/award-models/pie-split/metrics.ts | 12 +- .../rev-share-limit/aggregations.ts | 12 +- .../award-models/rev-share-limit/metrics.ts | 6 +- packages/ens-referrals/src/v1/client.ts | 16 ++- 8 files changed, 118 insertions(+), 69 deletions(-) diff --git a/packages/ens-referrals/src/v1/api/deserialize.ts b/packages/ens-referrals/src/v1/api/deserialize.ts index 101ab04cb..ce4096a99 100644 --- a/packages/ens-referrals/src/v1/api/deserialize.ts +++ b/packages/ens-referrals/src/v1/api/deserialize.ts @@ -72,10 +72,7 @@ export function deserializeReferralProgramEditionConfigSetArray( ); } - // makeReferralProgramRulesSchema uses .passthrough() for forward compatibility with unknown award - // model types, widening its output to { awardModel: string } & Record. - // This assertion is safe: the schema validates all known fields correctly. - return parsed.data as unknown as ReferralProgramEditionConfig[]; + return parsed.data; } /** @@ -94,6 +91,5 @@ export function deserializeReferralProgramEditionConfigSetResponse( ); } - // Same reason as deserializeReferralProgramEditionConfigSetArray above. - return parsed.data as unknown as ReferralProgramEditionConfigSetResponse; + return parsed.data; } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index cce559d9c..7fb6e9920 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -55,7 +55,9 @@ export function serializeReferralProgramRules( default: { const _exhaustiveCheck: never = rules; - throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`); + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`, + ); } } } @@ -73,7 +75,9 @@ function serializeReferrerLeaderboardPage( return serializeReferrerLeaderboardPageRevShareLimit(page); default: { const _exhaustiveCheck: never = page; - throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferrerLeaderboardPage).awardModel}`); + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferrerLeaderboardPage).awardModel}`, + ); } } } @@ -91,7 +95,9 @@ function serializeReferrerEditionMetricsRanked( return serializeReferrerEditionMetricsRankedRevShareLimit(detail); default: { const _exhaustiveCheck: never = detail; - throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsRanked).awardModel}`); + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsRanked).awardModel}`, + ); } } } @@ -109,7 +115,9 @@ function serializeReferrerEditionMetricsUnranked( return serializeReferrerEditionMetricsUnrankedRevShareLimit(detail); default: { const _exhaustiveCheck: never = detail; - throw new Error(`Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsUnranked).awardModel}`); + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsUnranked).awardModel}`, + ); } } } diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index cce243fe0..7957f9b88 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -22,6 +22,7 @@ import { makeReferrerLeaderboardPageRevShareLimitSchema, } from "../award-models/rev-share-limit/api/zod-schemas"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; +import type { ReferralProgramEditionConfig } from "../edition"; import { MAX_EDITIONS_PER_REQUEST, ReferralProgramEditionConfigSetResponseCodes, @@ -31,33 +32,12 @@ import { /** * Schema for {@link ReferralProgramRules}. - * - * Accepts known award model variants (pie-split, rev-share-limit) with full validation, - * plus a passthrough catch-all for unknown future types. - * - * If `awardModel` is not recognized, the object parses as `{ awardModel: string } & Record`. - * Clients must check `awardModel` before accessing model-specific fields. - * This design allows servers to introduce new award model types without breaking existing clients. */ -export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => { - const knownVariants = z.discriminatedUnion("awardModel", [ +export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => + z.discriminatedUnion("awardModel", [ makeReferralProgramRulesPieSplitSchema(valueLabel), makeReferralProgramRulesRevShareLimitSchema(valueLabel), ]); - const knownAwardModels = Object.values(ReferralProgramAwardModels) as string[]; - return z - .object({ awardModel: z.string() }) - .passthrough() - .superRefine((val, ctx) => { - if (!knownAwardModels.includes(val.awardModel)) return; - const result = knownVariants.safeParse(val); - if (!result.success) { - for (const issue of result.error.issues) { - ctx.addIssue({ code: "custom", path: issue.path, message: issue.message }); - } - } - }); -}; /** * Schema for {@link ReferrerLeaderboardPage}. @@ -213,24 +193,81 @@ export const makeReferralProgramEditionConfigSchema = ( /** * Schema for validating referral program edition config set array. + * + * Editions whose `rules.awardModel` is not recognized by this client version are + * silently dropped for forward compatibility — the result contains only editions + * with fully validated, recognized award models. + * + * At least one edition with a recognized award model must remain after filtering, + * and all remaining editions must have unique slugs. + * + * Two-pass approach: + * 1. Each item is loosely parsed (only `rules.awardModel` is checked) and unknown + * award models are dropped silently. + * 2. Remaining items are fully validated with {@link makeReferralProgramEditionConfigSchema}. */ export const makeReferralProgramEditionConfigSetArraySchema = ( valueLabel: string = "ReferralProgramEditionConfigSetArray", -) => - z - .array(makeReferralProgramEditionConfigSchema(`${valueLabel}[edition]`)) - .min(1, `${valueLabel} must contain at least one edition`) - .refine( - (editions) => { - const slugs = new Set(); - for (const edition of editions) { - if (slugs.has(edition.slug)) return false; - slugs.add(edition.slug); +) => { + const knownAwardModels = Object.values(ReferralProgramAwardModels) as string[]; + const configSchema = makeReferralProgramEditionConfigSchema(`${valueLabel}[edition]`); + + // Loose schema used only to peek at rules.awardModel before full validation. + const looseItemSchema = z + .object({ rules: z.object({ awardModel: z.string() }).passthrough() }) + .passthrough(); + + return z.array(looseItemSchema).transform((items, ctx): ReferralProgramEditionConfig[] => { + const result: ReferralProgramEditionConfig[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (!knownAwardModels.includes(item.rules.awardModel)) { + // Unknown award model — silently skip this edition. + // This allows servers to introduce new award model types without breaking clients. + continue; + } + + const parsed = configSchema.safeParse(item); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: [i, ...(issue.path as PropertyKey[])], + message: issue.message, + }); } - return true; - }, - { message: `${valueLabel} must not contain duplicate edition slugs` }, - ); + } else { + result.push(parsed.data); + } + } + + if (result.length < 1) { + ctx.addIssue({ + code: "custom", + message: `${valueLabel} must contain at least one edition with a recognized award model`, + }); + // Issue above causes the overall parse to fail; this value is never used. + return []; + } + + const slugs = new Set(); + for (const edition of result) { + if (slugs.has(edition.slug)) { + ctx.addIssue({ + code: "custom", + message: `${valueLabel} must not contain duplicate edition slugs`, + }); + // Issue above causes the overall parse to fail; this value is never used. + return []; + } + slugs.add(edition.slug); + } + + return result; + }); +}; /** * Schema for {@link ReferralProgramEditionConfigSetData}. diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts index b11cbe368..072bbc2f5 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts @@ -53,9 +53,9 @@ export const validateAggregatedReferrerMetricsPieSplit = ( validateNonNegativeInteger(metrics.grandTotalReferrals); validateDuration(metrics.grandTotalIncrementalDuration); - makePriceEthSchema( - "AggregatedReferrerMetricsPieSplit.grandTotalRevenueContribution", - ).parse(metrics.grandTotalRevenueContribution); + makePriceEthSchema("AggregatedReferrerMetricsPieSplit.grandTotalRevenueContribution").parse( + metrics.grandTotalRevenueContribution, + ); validateReferrerScore(metrics.grandTotalQualifiedReferrersFinalScore); validateReferrerScore(metrics.minFinalScoreToQualify); diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index 377998b05..6c1c34687 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -267,9 +267,9 @@ export const validateUnrankedReferrerMetricsPieSplit = ( ); } - makePriceEthSchema( - "UnrankedReferrerMetricsPieSplit.totalRevenueContribution", - ).parse(metrics.totalRevenueContribution); + makePriceEthSchema("UnrankedReferrerMetricsPieSplit.totalRevenueContribution").parse( + metrics.totalRevenueContribution, + ); if (metrics.totalRevenueContribution.amount !== 0n) { throw new Error( `Invalid UnrankedReferrerMetricsPieSplit: totalRevenueContribution.amount must be 0n, got: ${metrics.totalRevenueContribution.amount.toString()}.`, @@ -297,9 +297,9 @@ export const validateUnrankedReferrerMetricsPieSplit = ( ); } - makePriceUsdcSchema( - "UnrankedReferrerMetricsPieSplit.awardPoolApproxValue", - ).parse(metrics.awardPoolApproxValue); + makePriceUsdcSchema("UnrankedReferrerMetricsPieSplit.awardPoolApproxValue").parse( + metrics.awardPoolApproxValue, + ); if (metrics.awardPoolApproxValue.amount !== 0n) { throw new Error( `Invalid UnrankedReferrerMetricsPieSplit: awardPoolApproxValue must be 0n, got: ${metrics.awardPoolApproxValue.amount.toString()}.`, diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts index 51247baf6..1a63e7b87 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts @@ -45,13 +45,13 @@ export const validateAggregatedReferrerMetricsRevShareLimit = ( validateNonNegativeInteger(metrics.grandTotalReferrals); validateDuration(metrics.grandTotalIncrementalDuration); - makePriceEthSchema( - "AggregatedReferrerMetricsRevShareLimit.grandTotalRevenueContribution", - ).parse(metrics.grandTotalRevenueContribution); + makePriceEthSchema("AggregatedReferrerMetricsRevShareLimit.grandTotalRevenueContribution").parse( + metrics.grandTotalRevenueContribution, + ); - makePriceUsdcSchema( - "AggregatedReferrerMetricsRevShareLimit.awardPoolRemaining", - ).parse(metrics.awardPoolRemaining); + makePriceUsdcSchema("AggregatedReferrerMetricsRevShareLimit.awardPoolRemaining").parse( + metrics.awardPoolRemaining, + ); }; /** diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts index fff987a92..55b9c9182 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -230,9 +230,9 @@ export const validateUnrankedReferrerMetricsRevShareLimit = ( ); } - makePriceUsdcSchema( - "UnrankedReferrerMetricsRevShareLimit.totalBaseRevenueContribution", - ).parse(metrics.totalBaseRevenueContribution); + makePriceUsdcSchema("UnrankedReferrerMetricsRevShareLimit.totalBaseRevenueContribution").parse( + metrics.totalBaseRevenueContribution, + ); if (metrics.totalBaseRevenueContribution.amount !== 0n) { throw new Error( `Invalid UnrankedReferrerMetricsRevShareLimit: totalBaseRevenueContribution.amount must be 0n, got: ${metrics.totalBaseRevenueContribution.amount.toString()}.`, diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index 493e50091..be21a5e7c 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -86,9 +86,16 @@ export class ENSReferralsClient { * @param url - The URL to fetch the edition config set from * @returns A ReferralProgramEditionConfigSet (Map of edition slugs to edition configurations) * + * @remarks Editions whose `rules.awardModel` is not recognized by this version of the client + * are **silently dropped** for forward compatibility. The returned map contains only editions + * with fully validated, recognized award models. If the server introduces a new award model + * type, older clients will simply not see those editions rather than crashing. + * At least one recognized edition must be present, otherwise deserialization throws. + * * @throws if the fetch fails * @throws if the response is not valid JSON * @throws if the data doesn't match the expected schema + * @throws if no editions with a recognized award model remain after filtering * * @example * ```typescript @@ -338,10 +345,11 @@ export class ENSReferralsClient { * * @returns A response containing the edition config set, or an error response if unavailable. * - * @remarks Editions with unrecognized `rules.awardModel` values are deserialized as - * `{ awardModel: string } & Record`. Callers should check `awardModel` - * before accessing model-specific fields and skip editions whose award model they do - * not recognize. + * @remarks Editions whose `rules.awardModel` is not recognized by this version of the client + * are **silently dropped** for forward compatibility. The `data.editions` array contains only + * editions with fully validated, recognized award models. If the server introduces a new award + * model type, older clients will simply not see those editions rather than crashing. + * At least one recognized edition must be present, otherwise an error response is returned. * * @example * ```typescript From b3e2803e4f671ba555777975486a73e6216dcb7e Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 25 Feb 2026 15:41:57 +0100 Subject: [PATCH 09/15] review applied cont. 4 --- .../ensanalytics/referrer-leaderboard/database-v1.ts | 4 ++++ .../get-referrer-leaderboard.test.ts | 10 +++++++++- packages/ens-referrals/src/v1/api/zod-schemas.ts | 5 ++++- .../src/v1/award-models/pie-split/metrics.ts | 5 ++--- .../award-models/rev-share-limit/leaderboard.test.ts | 5 ++++- .../src/v1/award-models/rev-share-limit/leaderboard.ts | 2 ++ .../v1/award-models/rev-share-limit/referral-event.ts | 5 +++++ .../src/v1/award-models/shared/leaderboard-page.ts | 8 ++++---- 8 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts index e98dd134c..73b1df56c 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts @@ -108,6 +108,7 @@ export const getReferralEvents = async (rules: ReferralProgramRules): Promise ({ + id: record.id, referrer: record.referrer, timestamp: Number(record.timestamp), blockNumber: record.blockNumber, diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts index 5814af0c8..add0dfd73 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts @@ -40,6 +40,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { rules, }); + // result.referrers is expected to be in rank order (rank 1 first), matching Map insertion order const referrerEntries = Array.from(result.referrers.entries()); const qualifiedReferrers = referrerEntries.slice(0, rules.maxQualifiedReferrers); const unqualifiedReferrers = referrerEntries.slice(rules.maxQualifiedReferrers); @@ -61,7 +62,14 @@ describe("ENSAnalytics Referrer Leaderboard", () => { expect(unqualifiedReferrers.every(([_, referrer]) => !referrer.isQualified)).toBe(true); // Assert `finalScoreBoost` - expect(qualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost >= 0)).toBe(true); + // All qualified referrers except the last have boost > 0; the last qualified referrer + // receives boost === 0 by design (formula: 1 - (rank-1)/(maxQualifiedReferrers-1)). + const topQualifiedReferrers = qualifiedReferrers.slice(0, -1); + const lastQualifiedReferrer = qualifiedReferrers.at(-1); + expect(topQualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost > 0)).toBe( + true, + ); + expect(lastQualifiedReferrer![1].finalScoreBoost).toBe(0); expect(unqualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost === 0)).toBe( true, ); diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 7957f9b88..36ace8447 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -219,6 +219,7 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( return z.array(looseItemSchema).transform((items, ctx): ReferralProgramEditionConfig[] => { const result: ReferralProgramEditionConfig[] = []; + let attemptedKnownCount = 0; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -229,6 +230,8 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( continue; } + attemptedKnownCount++; + const parsed = configSchema.safeParse(item); if (!parsed.success) { for (const issue of parsed.error.issues) { @@ -243,7 +246,7 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( } } - if (result.length < 1) { + if (attemptedKnownCount === 0) { ctx.addIssue({ code: "custom", message: `${valueLabel} must contain at least one edition with a recognized award model`, diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index 6c1c34687..eb88a1b1b 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -154,9 +154,8 @@ export const buildRankedReferrerMetricsPieSplit = ( export const calcReferrerAwardPoolSharePieSplit = ( referrer: RankedReferrerMetricsPieSplit, aggregatedMetrics: AggregatedReferrerMetricsPieSplit, - rules: ReferralProgramRulesPieSplit, ): number => { - if (!isReferrerQualifiedPieSplit(referrer.rank, rules)) return 0; + if (!referrer.isQualified) return 0; if (aggregatedMetrics.grandTotalQualifiedReferrersFinalScore === 0) return 0; return referrer.finalScore / aggregatedMetrics.grandTotalQualifiedReferrersFinalScore; @@ -210,7 +209,7 @@ export const buildAwardedReferrerMetricsPieSplit = ( aggregatedMetrics: AggregatedReferrerMetricsPieSplit, rules: ReferralProgramRulesPieSplit, ): AwardedReferrerMetricsPieSplit => { - const awardPoolShare = calcReferrerAwardPoolSharePieSplit(referrer, aggregatedMetrics, rules); + const awardPoolShare = calcReferrerAwardPoolSharePieSplit(referrer, aggregatedMetrics); // Calculate the approximate USDC value by multiplying the share by the total award pool value const awardPoolApproxValue = scalePrice(rules.totalAwardPoolValue, awardPoolShare); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts index 51bf0a9bb..780d782c1 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -48,13 +48,16 @@ function buildTestRules( /** * Build a ReferralEvent with sensible defaults. */ +let eventIdCounter = 0; + function makeEvent( referrer: `0x${string}`, timestamp: number, incrementalDuration: number, - opts: Partial> = {}, + opts: Partial> = {}, ): ReferralEvent { return { + id: opts.id ?? `event-${++eventIdCounter}`, referrer, timestamp, blockNumber: opts.blockNumber ?? 1n, diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index fe88a2cce..97ac2edd5 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -105,6 +105,8 @@ export const buildReferrerLeaderboardRevShareLimit = ( if (a.blockNumber !== b.blockNumber) return a.blockNumber < b.blockNumber ? -1 : 1; if (a.transactionHash < b.transactionHash) return -1; if (a.transactionHash > b.transactionHash) return 1; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; return 0; }); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts index 719712d4e..0307dd5d2 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts @@ -30,6 +30,11 @@ export interface ReferralEvent { */ transactionHash: `0x${string}`; + /** + * Registrar action ID. + */ + id: string; + /** * Duration in seconds contributed by this single referral event. */ diff --git a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts index 678a0026d..39bf36772 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts @@ -84,7 +84,7 @@ export interface ReferrerLeaderboardPageContext extends Required= context.totalRecords) { throw new Error( - `Invalid ReferrerLeaderboardPageContext: if hasNext is true, endIndex (${endIndex}) must be less than total (${context.totalRecords}).`, + `Invalid ReferrerLeaderboardPageContext: if hasNext is true, endIndex (${endIndex}) must be less than totalRecords (${context.totalRecords}).`, ); } if (!context.hasPrev && context.page !== 1) { From 5652fd018f220e04feed4b8926247085c81a6042 Mon Sep 17 00:00:00 2001 From: Goader Date: Wed, 25 Feb 2026 16:55:11 +0100 Subject: [PATCH 10/15] review applied cont. 5 --- .../get-referrer-leaderboard-v1.test.ts | 16 +++++++--- .../get-referrer-leaderboard.test.ts | 19 +++--------- .../src/v1/api/serialized-types.ts | 12 +------ .../award-models/pie-split/api/zod-schemas.ts | 2 +- .../src/v1/award-models/pie-split/metrics.ts | 11 ++++--- .../rev-share-limit/leaderboard.test.ts | 31 +++++++++++++++++++ .../rev-share-limit/leaderboard.ts | 5 ++- .../src/v1/award-models/shared/rules.ts | 6 ++-- .../src/v1/leaderboard-page.test.ts | 9 +++--- 9 files changed, 65 insertions(+), 46 deletions(-) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 49fad74d3..76c673b58 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -45,9 +45,10 @@ describe("ENSAnalytics Referrer Leaderboard", () => { rules, }); - const referrers = result.referrers.entries(); - const qualifiedReferrers = referrers.take(rules.maxQualifiedReferrers); - const unqualifiedReferrers = referrers.drop(rules.maxQualifiedReferrers); + // result.referrers is expected to be in rank order (rank 1 first), matching Map insertion order + const referrerEntries = Array.from(result.referrers.entries()); + const qualifiedReferrers = referrerEntries.slice(0, rules.maxQualifiedReferrers); + const unqualifiedReferrers = referrerEntries.slice(rules.maxQualifiedReferrers); /** * Assert {@link RankedReferrerMetrics}. @@ -66,12 +67,17 @@ describe("ENSAnalytics Referrer Leaderboard", () => { expect(unqualifiedReferrers.every(([_, referrer]) => !referrer.isQualified)).toBe(true); // Assert `finalScoreBoost` (pie-split specific) - expect(qualifiedReferrers.every(([_, r]) => r.finalScoreBoost > 0)).toBe(true); + // All qualified referrers except the last have boost > 0; the last qualified referrer + // receives boost === 0 by design (formula: 1 - (rank-1)/(maxQualifiedReferrers-1)). + const topQualifiedReferrers = qualifiedReferrers.slice(0, -1); + const lastQualifiedReferrer = qualifiedReferrers.at(-1); + expect(topQualifiedReferrers.every(([_, r]) => r.finalScoreBoost > 0)).toBe(true); + expect(lastQualifiedReferrer![1].finalScoreBoost).toBe(0); expect(unqualifiedReferrers.every(([_, r]) => r.finalScoreBoost === 0)).toBe(true); // Assert `finalScore` (pie-split specific) expect( - qualifiedReferrers.every(([_, r]) => r.finalScore === r.score * r.finalScoreBoost), + qualifiedReferrers.every(([_, r]) => r.finalScore === r.score * (1 + r.finalScoreBoost)), ).toBe(true); expect(unqualifiedReferrers.every(([_, r]) => r.finalScore === r.score)).toBe(true); diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts index add0dfd73..cc6eb52c1 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard.test.ts @@ -40,10 +40,9 @@ describe("ENSAnalytics Referrer Leaderboard", () => { rules, }); - // result.referrers is expected to be in rank order (rank 1 first), matching Map insertion order - const referrerEntries = Array.from(result.referrers.entries()); - const qualifiedReferrers = referrerEntries.slice(0, rules.maxQualifiedReferrers); - const unqualifiedReferrers = referrerEntries.slice(rules.maxQualifiedReferrers); + const referrers = result.referrers.entries(); + const qualifiedReferrers = referrers.take(rules.maxQualifiedReferrers); + const unqualifiedReferrers = referrers.drop(rules.maxQualifiedReferrers); /** * Assert {@link RankedReferrerMetrics}. @@ -62,14 +61,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { expect(unqualifiedReferrers.every(([_, referrer]) => !referrer.isQualified)).toBe(true); // Assert `finalScoreBoost` - // All qualified referrers except the last have boost > 0; the last qualified referrer - // receives boost === 0 by design (formula: 1 - (rank-1)/(maxQualifiedReferrers-1)). - const topQualifiedReferrers = qualifiedReferrers.slice(0, -1); - const lastQualifiedReferrer = qualifiedReferrers.at(-1); - expect(topQualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost > 0)).toBe( - true, - ); - expect(lastQualifiedReferrer![1].finalScoreBoost).toBe(0); + expect(qualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost > 0)).toBe(true); expect(unqualifiedReferrers.every(([_, referrer]) => referrer.finalScoreBoost === 0)).toBe( true, ); @@ -77,8 +69,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { // Assert `finalScore` expect( qualifiedReferrers.every( - ([_, referrer]) => - referrer.finalScore === referrer.score * (1 + referrer.finalScoreBoost), + ([_, referrer]) => referrer.finalScore === referrer.score * referrer.finalScoreBoost, ), ).toBe(true); expect( diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 68dfbbe7f..4fc3687d0 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -30,22 +30,12 @@ import type { ReferrerMetricsEditionsResponseOk, } from "./types"; -/** - * Serialized representation of an unknown future award model rules object. - * Unknown types are already JSON-safe (arrived via deserialization passthrough). - */ -export type SerializedReferralProgramRulesUnknown = { awardModel: string } & Record< - string, - unknown ->; - /** * Serialized representation of referral program rules (union of all award model variants). */ export type SerializedReferralProgramRules = | SerializedReferralProgramRulesPieSplit - | SerializedReferralProgramRulesRevShareLimit - | SerializedReferralProgramRulesUnknown; + | SerializedReferralProgramRulesRevShareLimit; /** * Serialized representation of aggregated referrer metrics (union of all award model variants). diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts index c3447fcf3..dc6c0fc19 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts @@ -28,7 +28,7 @@ export const makeReferralProgramRulesPieSplitSchema = ( ) => z .object({ - awardModel: z.literal("pie-split"), + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), maxQualifiedReferrers: makeNonNegativeIntegerSchema(`${valueLabel}.maxQualifiedReferrers`), startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index eb88a1b1b..d7fa155bc 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -194,12 +194,13 @@ export const validateAwardedReferrerMetricsPieSplit = ( ); } - if ( - referrer.awardPoolApproxValue.amount < 0n || - referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount - ) { + makePriceUsdcSchema("AwardedReferrerMetricsPieSplit.awardPoolApproxValue").parse( + referrer.awardPoolApproxValue, + ); + + if (referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount) { throw new Error( - `Invalid AwardedReferrerMetricsPieSplit: ${referrer.awardPoolApproxValue.amount.toString()}. awardPoolApproxValue must be between 0 and ${rules.totalAwardPoolValue.amount.toString()} (inclusive).`, + `AwardedReferrerMetricsPieSplit: awardPoolApproxValue.amount ${referrer.awardPoolApproxValue.amount.toString()} exceeds totalAwardPoolValue.amount ${rules.totalAwardPoolValue.amount.toString()}.`, ); } }; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts index 780d782c1..e68bca417 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -347,6 +347,37 @@ describe("buildReferrerLeaderboardRevShareLimit", () => { ); expect(result.referrers.get(ADDR_B)!.awardPoolApproxValue.amount).toBe(0n); }); + + it("breaks ties by id (lexicographic) when timestamp, blockNumber, and transactionHash are identical", () => { + // Both events share identical timestamp, blockNumber, and transactionHash + // Pool = $2.50 — only enough for one + // id "1" < "2" lexicographically, so ADDR_A (id "1") wins + const SHARED_TX = + "0x0000000000000000000000000000000000000000000000000000000000000001" as const; + const rules = buildTestRules(parseUsdc("2.5")); + const events = [ + { + ...makeEvent(ADDR_B, 1000, SECONDS_PER_YEAR), + blockNumber: 100n, + transactionHash: SHARED_TX, + id: "2", + }, + { + ...makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + blockNumber: 100n, + transactionHash: SHARED_TX, + id: "1", + }, + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + + // ADDR_A has id "1" (lower), should claim the pool first + expect(result.referrers.get(ADDR_A)!.awardPoolApproxValue.amount).toBe( + STANDARD_AWARD_1Y.amount, + ); + expect(result.referrers.get(ADDR_B)!.awardPoolApproxValue.amount).toBe(0n); + }); }); describe("Ranking", () => { diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index 97ac2edd5..de044f14f 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -178,7 +178,7 @@ export const buildReferrerLeaderboardRevShareLimit = ( // 3. Sort referrers to assign ranks: // 1. qualifiedAwardValue (awardPoolApproxValue) desc — actual pool claims, race winners first - // 2. standardAwardValue desc — uncapped earned value, separates pool-depleted referrers + // 2. totalIncrementalDuration desc — tie-break for pool-depleted referrers // 3. referrer address desc — deterministic tie-break // Both `a` and `b` are keys from `referrerStates`, so lookups are always defined. const sortedAddresses = [...referrerStates.keys()].sort((a, b) => { @@ -190,8 +190,7 @@ export const buildReferrerLeaderboardRevShareLimit = ( return stateB.qualifiedAwardValueAmount > stateA.qualifiedAwardValueAmount ? 1 : -1; } - // Secondary: totalIncrementalDuration desc — monotonically equivalent to standardAwardValue desc - // and avoids recomputing scalePrice for every comparison pair. + // Secondary: totalIncrementalDuration desc (used directly as the tie-breaker). if (stateB.totalIncrementalDuration !== stateA.totalIncrementalDuration) { return stateB.totalIncrementalDuration - stateA.totalIncrementalDuration; } diff --git a/packages/ens-referrals/src/v1/award-models/shared/rules.ts b/packages/ens-referrals/src/v1/award-models/shared/rules.ts index bc4f90154..e7fceed04 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/rules.ts @@ -7,9 +7,9 @@ import { validateUnixTimestamp } from "../../time"; * Discriminant values for the award model used in a referral program edition. * * @remarks Clients MUST check `awardModel` before accessing model-specific fields. - * Unrecognized `awardModel` values MUST be handled gracefully - when parsed via Zod schemas, - * unknown award model objects are returned as `{ awardModel: string } & Record`. - * Servers may introduce new award model types at any time without breaking existing clients. + * Editions with unrecognized `awardModel` values are silently dropped during parsing + * (see `makeReferralProgramEditionConfigSetArraySchema`), so clients will only ever + * encounter award models listed here. */ export const ReferralProgramAwardModels = { PieSplit: "pie-split", diff --git a/packages/ens-referrals/src/v1/leaderboard-page.test.ts b/packages/ens-referrals/src/v1/leaderboard-page.test.ts index a78e71b9d..d2a41e8e8 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.test.ts @@ -10,6 +10,7 @@ import { type ReferrerLeaderboardPageContext, type ReferrerLeaderboardPageParams, } from "./award-models/shared/leaderboard-page"; +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; describe("buildReferrerLeaderboardPageContext", () => { const pageParams: ReferrerLeaderboardPageParams = { @@ -19,9 +20,9 @@ describe("buildReferrerLeaderboardPageContext", () => { it("correctly evaluates `hasNext` when `leaderboard.referrers.size` and `recordsPerPage` are equal", () => { const leaderboard: ReferrerLeaderboardPieSplit = { - awardModel: "pie-split", + awardModel: ReferralProgramAwardModels.PieSplit, rules: { - awardModel: "pie-split", + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: priceUsdc(10000n), maxQualifiedReferrers: 10, startTime: 1764547200, @@ -107,9 +108,9 @@ describe("buildReferrerLeaderboardPageContext", () => { it("Correctly builds the pagination context when `leaderboard.referrers.size` is 0", () => { const leaderboard: ReferrerLeaderboardPieSplit = { - awardModel: "pie-split", + awardModel: ReferralProgramAwardModels.PieSplit, rules: { - awardModel: "pie-split", + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: priceUsdc(10000n), maxQualifiedReferrers: 10, startTime: 1764547200, From 8fe46925eef34066b384c7a155735e7ba8fa2122 Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 26 Feb 2026 01:48:25 +0100 Subject: [PATCH 11/15] unrecognized rules through zod --- .../referral-program-edition-set.cache.ts | 15 +++ .../get-referrer-leaderboard-v1.ts | 13 ++ .../ens-referrals/src/v1/api/serialize.ts | 11 ++ .../src/v1/api/zod-schemas.test.ts | 112 ++++++++++++++++++ .../ens-referrals/src/v1/api/zod-schemas.ts | 99 +++++++++++----- .../award-models/pie-split/api/zod-schemas.ts | 22 +--- .../rev-share-limit/api/zod-schemas.ts | 32 ++--- .../v1/award-models/shared/api/zod-schemas.ts | 20 ++++ .../src/v1/award-models/shared/rules.ts | 31 ++++- packages/ens-referrals/src/v1/client.ts | 21 ++-- packages/ens-referrals/src/v1/rules.ts | 16 ++- 11 files changed, 306 insertions(+), 86 deletions(-) create mode 100644 packages/ens-referrals/src/v1/api/zod-schemas.test.ts diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts index 54de3c226..5ca2ae3e7 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -3,6 +3,7 @@ import config from "@/config"; import { ENSReferralsClient, getDefaultReferralProgramEditionConfigSet, + ReferralProgramAwardModels, type ReferralProgramEditionConfigSet, } from "@namehash/ens-referrals/v1"; import { minutesToSeconds } from "date-fns"; @@ -28,6 +29,20 @@ async function loadReferralProgramEditionConfigSet( const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet( config.customReferralProgramEditionConfigSetUrl, ); + + // Strip any unrecognized editions immediately — they are client-side forward-compatibility + // placeholders that must never enter the server's operational config set (they can't be + // serialized and would cause API handlers to crash). + for (const [slug, editionConfig] of editionConfigSet) { + if (editionConfig.rules.awardModel === ReferralProgramAwardModels.Unrecognized) { + logger.warn( + { editionSlug: slug, originalAwardModel: editionConfig.rules.originalAwardModel }, + `Skipping custom edition with unrecognized award model`, + ); + editionConfigSet.delete(slug); + } + } + logger.info(`Successfully loaded ${editionConfigSet.size} custom referral program editions`); return editionConfigSet; } catch (error) { diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts index 048eddf66..2562174a7 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts @@ -35,5 +35,18 @@ export async function getReferrerLeaderboard( const events = await getReferralEvents(rules); return buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); } + case ReferralProgramAwardModels.Unrecognized: + // ReferralProgramRulesUnrecognized editions are filtered at cache-init time + // and should never reach this function. + throw new Error( + `getReferrerLeaderboard called with unrecognized award model '${rules.originalAwardModel}' — edition should have been filtered before reaching this point.`, + ); + + default: { + const _exhaustiveCheck: never = rules; + throw new Error( + `Unexpected award model in getReferrerLeaderboard: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`, + ); + } } } diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 7fb6e9920..a5c937057 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -10,6 +10,7 @@ import { serializeReferrerEditionMetricsUnrankedRevShareLimit, serializeReferrerLeaderboardPageRevShareLimit, } from "../award-models/rev-share-limit/api/serialize"; +import type { ReferralProgramRulesUnrecognized } from "../award-models/shared/rules"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import type { ReferralProgramEditionConfig } from "../edition"; import type { @@ -42,6 +43,9 @@ import { /** * Serializes a {@link ReferralProgramRules} object. + * + * @throws if called with a {@link ReferralProgramRulesUnrecognized} — unrecognized editions are + * client-side forward-compatibility placeholders and must never be serialized. */ export function serializeReferralProgramRules( rules: ReferralProgramRules, @@ -53,6 +57,13 @@ export function serializeReferralProgramRules( case ReferralProgramAwardModels.RevShareLimit: return serializeReferralProgramRulesRevShareLimit(rules); + case ReferralProgramAwardModels.Unrecognized: { + const unrecognized = rules as ReferralProgramRulesUnrecognized; + throw new Error( + `ReferralProgramRulesUnrecognized (originalAwardModel: '${unrecognized.originalAwardModel}') must not be serialized — it is a client-side forward-compatibility placeholder only.`, + ); + } + default: { const _exhaustiveCheck: never = rules; throw new Error( diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.test.ts b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts new file mode 100644 index 000000000..43c35cb17 --- /dev/null +++ b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { ReferralProgramAwardModels } from "../award-models/shared/rules"; +import { makeReferralProgramEditionConfigSetArraySchema } from "./zod-schemas"; + +describe("makeReferralProgramEditionConfigSetArraySchema", () => { + const schema = makeReferralProgramEditionConfigSetArraySchema(); + + const subregistryId = { + chainId: 1, + address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + }; + + const pieSplitEdition = { + slug: "2025-12", + displayName: "December 2025", + rules: { + awardModel: "pie-split", + totalAwardPoolValue: { amount: "1000", currency: "USDC" }, + maxQualifiedReferrers: 100, + startTime: 1000000, + endTime: 2000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + }, + }; + + const futureModelEdition = { + slug: "2026-03", + displayName: "March 2026", + rules: { + awardModel: "future-model", + startTime: 2000000, + endTime: 3000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + someNewField: "extra-data", + }, + }; + + it("preserves both a recognized and an unrecognized edition", () => { + const result = schema.parse([pieSplitEdition, futureModelEdition]); + + expect(result).toHaveLength(2); + }); + + it("parses the recognized pie-split edition correctly", () => { + const result = schema.parse([pieSplitEdition, futureModelEdition]); + const pieSplit = result.find((e) => e.slug === "2025-12"); + + expect(pieSplit).toBeDefined(); + expect(pieSplit!.rules.awardModel).toBe(ReferralProgramAwardModels.PieSplit); + }); + + it("wraps the unrecognized edition as ReferralProgramRulesUnrecognized", () => { + const result = schema.parse([pieSplitEdition, futureModelEdition]); + const unrecognized = result.find((e) => e.slug === "2026-03"); + + expect(unrecognized).toBeDefined(); + expect(unrecognized!.rules.awardModel).toBe(ReferralProgramAwardModels.Unrecognized); + expect((unrecognized!.rules as { originalAwardModel: string }).originalAwardModel).toBe( + "future-model", + ); + }); + + it("copies base fields onto the unrecognized edition", () => { + const result = schema.parse([pieSplitEdition, futureModelEdition]); + const unrecognized = result.find((e) => e.slug === "2026-03"); + + expect(unrecognized!.rules.startTime).toBe(2000000); + expect(unrecognized!.rules.endTime).toBe(3000000); + expect(unrecognized!.rules.rulesUrl).toBeInstanceOf(URL); + expect(unrecognized!.rules.rulesUrl.href).toBe("https://ensawards.org/rules"); + }); + + it("fails when an unrecognized edition has malformed base fields", () => { + const malformedUnrecognized = { + slug: "2026-03", + displayName: "March 2026", + rules: { + awardModel: "future-model", + // startTime missing, endTime missing + subregistryId, + rulesUrl: "https://ensawards.org/rules", + }, + }; + + expect(() => schema.parse([pieSplitEdition, malformedUnrecognized])).toThrow(); + }); + + it("fails when the result list would be empty", () => { + const malformedUnrecognized = { + slug: "2026-03", + displayName: "March 2026", + rules: { + awardModel: "future-model", + // no valid base fields + }, + }; + + expect(() => schema.parse([malformedUnrecognized])).toThrow(); + }); + + it("fails when duplicate slugs exist across recognized and unrecognized editions", () => { + const duplicateUnrecognized = { + ...futureModelEdition, + slug: "2025-12", // same slug as pie-split edition + }; + + expect(() => schema.parse([pieSplitEdition, duplicateUnrecognized])).toThrow(); + }); +}); diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 36ace8447..aa4903df7 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -21,6 +21,8 @@ import { makeReferrerEditionMetricsRevShareLimitSchema, makeReferrerLeaderboardPageRevShareLimitSchema, } from "../award-models/rev-share-limit/api/zod-schemas"; +import { makeBaseReferralProgramRulesSchema } from "../award-models/shared/api/zod-schemas"; +import type { ReferralProgramRulesUnrecognized } from "../award-models/shared/rules"; import { ReferralProgramAwardModels } from "../award-models/shared/rules"; import type { ReferralProgramEditionConfig } from "../edition"; import { @@ -179,37 +181,49 @@ export const makeReferrerMetricsEditionsResponseSchema = ( makeReferrerMetricsEditionsResponseErrorSchema(valueLabel), ]); +/** + * Schema for the shared base fields of a {@link ReferralProgramEditionConfig}. + */ +const makeReferralProgramEditionConfigBaseSchema = (valueLabel: string) => + z.object({ + slug: makeReferralProgramEditionSlugSchema(`${valueLabel}.slug`), + displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), + rules: makeBaseReferralProgramRulesSchema(`${valueLabel}.rules`), + }); + /** * Schema for validating a {@link ReferralProgramEditionConfig}. */ export const makeReferralProgramEditionConfigSchema = ( valueLabel: string = "ReferralProgramEditionConfig", ) => - z.object({ - slug: makeReferralProgramEditionSlugSchema(`${valueLabel}.slug`), - displayName: z.string().min(1, `${valueLabel}.displayName must not be empty`), + makeReferralProgramEditionConfigBaseSchema(valueLabel).safeExtend({ rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), }); /** * Schema for validating referral program edition config set array. * - * Editions whose `rules.awardModel` is not recognized by this client version are - * silently dropped for forward compatibility — the result contains only editions - * with fully validated, recognized award models. + * Editions whose `rules.awardModel` is not recognized by this client version are preserved as + * {@link ReferralProgramRulesUnrecognized} for forward compatibility — nothing is silently dropped. + * Downstream code (e.g., leaderboard cache setup) is responsible for skipping unrecognized + * editions with a warning log rather than crashing. * - * At least one edition with a recognized award model must remain after filtering, - * and all remaining editions must have unique slugs. + * The list must not be empty after processing all items. Duplicate slugs are not allowed. * * Two-pass approach: - * 1. Each item is loosely parsed (only `rules.awardModel` is checked) and unknown - * award models are dropped silently. - * 2. Remaining items are fully validated with {@link makeReferralProgramEditionConfigSchema}. + * 1. Each item is loosely parsed (based on `rules.awardModel` field). + * - Known award models are fully validated with {@link makeReferralProgramEditionConfigSchema}. + * - Unknown award models are parsed with {@link makeBaseReferralProgramRulesSchema} and wrapped as + * `ReferralProgramRulesUnrecognized`. + * 2. After processing all items, the result must be non-empty and have no duplicate slugs. */ export const makeReferralProgramEditionConfigSetArraySchema = ( valueLabel: string = "ReferralProgramEditionConfigSetArray", ) => { - const knownAwardModels = Object.values(ReferralProgramAwardModels) as string[]; + const knownAwardModels = Object.values(ReferralProgramAwardModels).filter( + (m) => m !== ReferralProgramAwardModels.Unrecognized, + ) as string[]; const configSchema = makeReferralProgramEditionConfigSchema(`${valueLabel}[edition]`); // Loose schema used only to peek at rules.awardModel before full validation. @@ -217,39 +231,60 @@ export const makeReferralProgramEditionConfigSetArraySchema = ( .object({ rules: z.object({ awardModel: z.string() }).passthrough() }) .passthrough(); + // Schema for extracting base fields from an unrecognized edition. + const unrecognizedBaseSchema = makeReferralProgramEditionConfigBaseSchema( + `${valueLabel}[edition]`, + ); + return z.array(looseItemSchema).transform((items, ctx): ReferralProgramEditionConfig[] => { const result: ReferralProgramEditionConfig[] = []; - let attemptedKnownCount = 0; for (let i = 0; i < items.length; i++) { const item = items[i]; - if (!knownAwardModels.includes(item.rules.awardModel)) { - // Unknown award model — silently skip this edition. - // This allows servers to introduce new award model types without breaking clients. - continue; - } - - attemptedKnownCount++; - - const parsed = configSchema.safeParse(item); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - ctx.addIssue({ - code: "custom", - path: [i, ...(issue.path as PropertyKey[])], - message: issue.message, - }); + if (knownAwardModels.includes(item.rules.awardModel)) { + // Known award model — fully validate. + const parsed = configSchema.safeParse(item); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: [i, ...(issue.path as PropertyKey[])], + message: issue.message, + }); + } + } else { + result.push(parsed.data); } } else { - result.push(parsed.data); + // Unknown award model — preserve as ReferralProgramRulesUnrecognized using base fields. + const parsed = unrecognizedBaseSchema.safeParse(item); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: [i, ...(issue.path as PropertyKey[])], + message: issue.message, + }); + } + continue; + } + + result.push({ + ...parsed.data, + rules: { + ...parsed.data.rules, + awardModel: ReferralProgramAwardModels.Unrecognized, + originalAwardModel: item.rules.awardModel, + } satisfies ReferralProgramRulesUnrecognized, + }); } } - if (attemptedKnownCount === 0) { + if (result.length === 0) { ctx.addIssue({ code: "custom", - message: `${valueLabel} must contain at least one edition with a recognized award model`, + message: `${valueLabel} must contain at least one edition`, }); // Issue above causes the overall parse to fail; this value is never used. return []; diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts index dc6c0fc19..e9bbbac95 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts @@ -1,7 +1,6 @@ import z from "zod/v4"; import { - makeAccountIdSchema, makeDurationSchema, makeFiniteNonNegativeNumberSchema, makeLowercaseAddressSchema, @@ -10,10 +9,10 @@ import { makePriceEthSchema, makePriceUsdcSchema, makeUnixTimestampSchema, - makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; import { + makeBaseReferralProgramRulesSchema, makeReferralProgramStatusSchema, makeReferrerLeaderboardPageContextSchema, } from "../../shared/api/zod-schemas"; @@ -26,20 +25,11 @@ import { ReferralProgramAwardModels } from "../../shared/rules"; export const makeReferralProgramRulesPieSplitSchema = ( valueLabel: string = "ReferralProgramRulesPieSplit", ) => - z - .object({ - awardModel: z.literal(ReferralProgramAwardModels.PieSplit), - totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), - maxQualifiedReferrers: makeNonNegativeIntegerSchema(`${valueLabel}.maxQualifiedReferrers`), - startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), - endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), - subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), - rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), - }) - .refine((data) => data.endTime >= data.startTime, { - message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, - path: ["endTime"], - }); + makeBaseReferralProgramRulesSchema(valueLabel).safeExtend({ + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), + totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), + maxQualifiedReferrers: makeNonNegativeIntegerSchema(`${valueLabel}.maxQualifiedReferrers`), + }); /** * Schema for {@link AwardedReferrerMetricsPieSplit} (with numeric rank). diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts index c7c34c2d2..f557e5552 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -1,7 +1,6 @@ import z from "zod/v4"; import { - makeAccountIdSchema, makeDurationSchema, makeFiniteNonNegativeNumberSchema, makeLowercaseAddressSchema, @@ -10,10 +9,10 @@ import { makePriceEthSchema, makePriceUsdcSchema, makeUnixTimestampSchema, - makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; import { + makeBaseReferralProgramRulesSchema, makeReferralProgramStatusSchema, makeReferrerLeaderboardPageContextSchema, } from "../../shared/api/zod-schemas"; @@ -26,25 +25,16 @@ import { ReferralProgramAwardModels } from "../../shared/rules"; export const makeReferralProgramRulesRevShareLimitSchema = ( valueLabel: string = "ReferralProgramRulesRevShareLimit", ) => - z - .object({ - awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), - totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), - minQualifiedRevenueContribution: makePriceUsdcSchema( - `${valueLabel}.minQualifiedRevenueContribution`, - ), - qualifiedRevenueShare: makeFiniteNonNegativeNumberSchema( - `${valueLabel}.qualifiedRevenueShare`, - ).max(1, `${valueLabel}.qualifiedRevenueShare must be <= 1`), - startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), - endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), - subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), - rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), - }) - .refine((data) => data.endTime >= data.startTime, { - message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, - path: ["endTime"], - }); + makeBaseReferralProgramRulesSchema(valueLabel).safeExtend({ + awardModel: z.literal(ReferralProgramAwardModels.RevShareLimit), + totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), + minQualifiedRevenueContribution: makePriceUsdcSchema( + `${valueLabel}.minQualifiedRevenueContribution`, + ), + qualifiedRevenueShare: makeFiniteNonNegativeNumberSchema( + `${valueLabel}.qualifiedRevenueShare`, + ).max(1, `${valueLabel}.qualifiedRevenueShare must be <= 1`), + }); /** * Schema for {@link AwardedReferrerMetricsRevShareLimit} (with numeric rank). diff --git a/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts index af5eeb76b..77fa74c36 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts @@ -1,13 +1,33 @@ import z from "zod/v4"; import { + makeAccountIdSchema, makeNonNegativeIntegerSchema, makePositiveIntegerSchema, + makeUnixTimestampSchema, + makeUrlSchema, } from "@ensnode/ensnode-sdk/internal"; import { ReferralProgramStatuses } from "../../../status"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; +/** + * Schema for {@link BaseReferralProgramRules}. + */ +export const makeBaseReferralProgramRulesSchema = (valueLabel: string) => + z + .object({ + awardModel: z.string(), + startTime: makeUnixTimestampSchema(`${valueLabel}.startTime`), + endTime: makeUnixTimestampSchema(`${valueLabel}.endTime`), + subregistryId: makeAccountIdSchema(`${valueLabel}.subregistryId`), + rulesUrl: makeUrlSchema(`${valueLabel}.rulesUrl`), + }) + .refine((data) => data.endTime >= data.startTime, { + message: `${valueLabel}.endTime must be >= ${valueLabel}.startTime`, + path: ["endTime"], + }); + /** * Schema for {@link ReferrerLeaderboardPageContext}. */ diff --git a/packages/ens-referrals/src/v1/award-models/shared/rules.ts b/packages/ens-referrals/src/v1/award-models/shared/rules.ts index e7fceed04..d42412f87 100644 --- a/packages/ens-referrals/src/v1/award-models/shared/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/shared/rules.ts @@ -7,13 +7,15 @@ import { validateUnixTimestamp } from "../../time"; * Discriminant values for the award model used in a referral program edition. * * @remarks Clients MUST check `awardModel` before accessing model-specific fields. - * Editions with unrecognized `awardModel` values are silently dropped during parsing - * (see `makeReferralProgramEditionConfigSetArraySchema`), so clients will only ever - * encounter award models listed here. + * Editions with unrecognized `awardModel` values are preserved as + * {@link ReferralProgramRulesUnrecognized} during parsing (see + * `makeReferralProgramEditionConfigSetArraySchema`). Clients must handle this variant — typically + * by skipping those editions with a warning log rather than crashing. */ export const ReferralProgramAwardModels = { PieSplit: "pie-split", RevShareLimit: "rev-share-limit", + Unrecognized: "unrecognized", } as const; export type ReferralProgramAwardModel = @@ -55,6 +57,29 @@ export interface BaseReferralProgramRules { rulesUrl: URL; } +/** + * Rules for a referral program edition whose `awardModel` is not recognized by this client version. + * + * @remarks + * This is a **client-side forward-compatibility** type only. It is never serialized or processed + * by business logic on the backend. When the server introduces a new award model type, older + * clients preserve the edition rather than silently dropping it, and downstream code that + * encounters this type should skip it with a warning log rather than crashing. + */ +export interface ReferralProgramRulesUnrecognized extends BaseReferralProgramRules { + /** + * Discriminant — always `"unrecognized"`. + */ + awardModel: typeof ReferralProgramAwardModels.Unrecognized; + + /** + * The original, unrecognized `awardModel` string received from the server. + * + * @remarks Preserved for logging and debugging. Never used for business logic. + */ + originalAwardModel: string; +} + export const validateBaseReferralProgramRules = (rules: BaseReferralProgramRules): void => { makeAccountIdSchema("BaseReferralProgramRules.subregistryId").parse(rules.subregistryId); diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts index be21a5e7c..b8a42a88a 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -86,16 +86,15 @@ export class ENSReferralsClient { * @param url - The URL to fetch the edition config set from * @returns A ReferralProgramEditionConfigSet (Map of edition slugs to edition configurations) * - * @remarks Editions whose `rules.awardModel` is not recognized by this version of the client - * are **silently dropped** for forward compatibility. The returned map contains only editions - * with fully validated, recognized award models. If the server introduces a new award model - * type, older clients will simply not see those editions rather than crashing. - * At least one recognized edition must be present, otherwise deserialization throws. + * @remarks Editions whose `rules.awardModel` is not recognized by this client version are + * preserved as {@link ReferralProgramRulesUnrecognized}. The returned map includes all + * editions — recognized and unrecognized alike. Callers should check `editionConfig.rules.awardModel` + * and skip editions with `"unrecognized"` as appropriate. At least one edition of any kind must + * be present, otherwise deserialization throws. * * @throws if the fetch fails * @throws if the response is not valid JSON * @throws if the data doesn't match the expected schema - * @throws if no editions with a recognized award model remain after filtering * * @example * ```typescript @@ -345,11 +344,11 @@ export class ENSReferralsClient { * * @returns A response containing the edition config set, or an error response if unavailable. * - * @remarks Editions whose `rules.awardModel` is not recognized by this version of the client - * are **silently dropped** for forward compatibility. The `data.editions` array contains only - * editions with fully validated, recognized award models. If the server introduces a new award - * model type, older clients will simply not see those editions rather than crashing. - * At least one recognized edition must be present, otherwise an error response is returned. + * @remarks Editions whose `rules.awardModel` is not recognized by this client version are + * preserved as {@link ReferralProgramRulesUnrecognized}. The returned map includes all + * editions — recognized and unrecognized alike. Callers should check `editionConfig.rules.awardModel` + * and skip editions with `"unrecognized"` as appropriate. At least one edition of any kind must + * be present, otherwise deserialization throws. * * @example * ```typescript diff --git a/packages/ens-referrals/src/v1/rules.ts b/packages/ens-referrals/src/v1/rules.ts index d44fe82a8..f27ba4be6 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -1,10 +1,20 @@ import type { ReferralProgramRulesPieSplit } from "./award-models/pie-split/rules"; import type { ReferralProgramRulesRevShareLimit } from "./award-models/rev-share-limit/rules"; +import type { ReferralProgramRulesUnrecognized } from "./award-models/shared/rules"; /** * The rules of a referral program edition. * - * Use `awardModel` to discriminate between rule types at runtime. - * Internal business logic only handles the known variants listed here. + * Use `awardModel` to discriminate between rule types at runtime: + * - `"pie-split"` → {@link ReferralProgramRulesPieSplit} + * - `"rev-share-limit"` → {@link ReferralProgramRulesRevShareLimit} + * - `"unrecognized"` → {@link ReferralProgramRulesUnrecognized} (client-side forward-compatibility + * placeholder for editions whose `awardModel` string is not known to this client version) + * + * Internal business logic only handles the known variants (`pie-split`, `rev-share-limit`). + * Unrecognized editions should be skipped with a warning log rather than crashing. */ -export type ReferralProgramRules = ReferralProgramRulesPieSplit | ReferralProgramRulesRevShareLimit; +export type ReferralProgramRules = + | ReferralProgramRulesPieSplit + | ReferralProgramRulesRevShareLimit + | ReferralProgramRulesUnrecognized; From 4852347a7a5b6f2f04dce523d7b44839dea87766 Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 26 Feb 2026 02:56:05 +0100 Subject: [PATCH 12/15] changesets --- .changeset/brave-eagles-award.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/brave-eagles-award.md diff --git a/.changeset/brave-eagles-award.md b/.changeset/brave-eagles-award.md new file mode 100644 index 000000000..52576e652 --- /dev/null +++ b/.changeset/brave-eagles-award.md @@ -0,0 +1,6 @@ +--- +"@namehash/ens-referrals": minor +"ensapi": minor +--- + +Introduces a pluggable award model architecture for referral program editions. The original Holiday Awards logic is now encapsulated as the `pie-split` model. A new `rev-share-limit` model is added to support the upcoming referral program edition. `ReferralProgramRules` is now a discriminated union over `awardModel`, with an `Unrecognized` variant for forward compatibility — older clients safely skip editions with unknown models rather than crashing. From 6f7b021da98e8a0bfd91283128ebc31d8fec8bde Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 26 Feb 2026 04:55:20 +0100 Subject: [PATCH 13/15] self-review --- .../get-referrer-leaderboard-v1.test.ts | 1 + .../src/v1/api/serialized-types.ts | 36 +++++-------------- .../ens-referrals/src/v1/api/zod-schemas.ts | 4 +-- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 76c673b58..77de09a74 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -37,6 +37,7 @@ describe("ENSAnalytics Referrer Leaderboard", () => { const result = await getReferrerLeaderboard(rules, accurateAsOf); + expect(result.awardModel).toBe(ReferralProgramAwardModels.PieSplit); if (result.awardModel !== ReferralProgramAwardModels.PieSplit) { throw new Error("Expected PieSplit leaderboard"); } diff --git a/packages/ens-referrals/src/v1/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 4fc3687d0..e5d487b65 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,22 +1,23 @@ import type { - SerializedAggregatedReferrerMetricsPieSplit, - SerializedAwardedReferrerMetricsPieSplit, SerializedReferralProgramRulesPieSplit, SerializedReferrerEditionMetricsRankedPieSplit, SerializedReferrerEditionMetricsUnrankedPieSplit, SerializedReferrerLeaderboardPagePieSplit, - SerializedUnrankedReferrerMetricsPieSplit, } from "../award-models/pie-split/api/serialized-types"; import type { - SerializedAggregatedReferrerMetricsRevShareLimit, - SerializedAwardedReferrerMetricsRevShareLimit, SerializedReferralProgramRulesRevShareLimit, SerializedReferrerEditionMetricsRankedRevShareLimit, SerializedReferrerEditionMetricsUnrankedRevShareLimit, SerializedReferrerLeaderboardPageRevShareLimit, - SerializedUnrankedReferrerMetricsRevShareLimit, } from "../award-models/rev-share-limit/api/serialized-types"; import type { ReferralProgramEditionConfig, ReferralProgramEditionSlug } from "../edition"; +import type { + ReferrerEditionMetrics, + ReferrerEditionMetricsRanked, + ReferrerEditionMetricsUnranked, +} from "../edition-metrics"; +import type { ReferrerLeaderboardPage } from "../leaderboard-page"; +import type { ReferralProgramRules } from "../rules"; import type { ReferralProgramEditionConfigSetData, ReferralProgramEditionConfigSetResponse, @@ -31,33 +32,12 @@ import type { } from "./types"; /** - * Serialized representation of referral program rules (union of all award model variants). + * Serialized representation of {@link ReferralProgramRules}. */ export type SerializedReferralProgramRules = | SerializedReferralProgramRulesPieSplit | SerializedReferralProgramRulesRevShareLimit; -/** - * Serialized representation of aggregated referrer metrics (union of all award model variants). - */ -export type SerializedAggregatedReferrerMetrics = - | SerializedAggregatedReferrerMetricsPieSplit - | SerializedAggregatedReferrerMetricsRevShareLimit; - -/** - * Serialized representation of awarded referrer metrics (union of all award model variants). - */ -export type SerializedAwardedReferrerMetrics = - | SerializedAwardedReferrerMetricsPieSplit - | SerializedAwardedReferrerMetricsRevShareLimit; - -/** - * Serialized representation of unranked referrer metrics (union of all award model variants). - */ -export type SerializedUnrankedReferrerMetrics = - | SerializedUnrankedReferrerMetricsPieSplit - | SerializedUnrankedReferrerMetricsRevShareLimit; - /** * Serialized representation of {@link ReferrerLeaderboardPage}. */ diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index aa4903df7..501e55dd1 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -33,7 +33,7 @@ import { } from "./types"; /** - * Schema for {@link ReferralProgramRules}. + * Schema for {@link ReferralProgramRules} */ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralProgramRules") => z.discriminatedUnion("awardModel", [ @@ -42,7 +42,7 @@ export const makeReferralProgramRulesSchema = (valueLabel: string = "ReferralPro ]); /** - * Schema for {@link ReferrerLeaderboardPage}. + * Schema for {@link ReferrerLeaderboardPage} */ export const makeReferrerLeaderboardPageSchema = (valueLabel: string = "ReferrerLeaderboardPage") => z.discriminatedUnion("awardModel", [ From 96390223733d8b1d24b716451f201d54eff33249 Mon Sep 17 00:00:00 2001 From: Goader Date: Fri, 27 Feb 2026 01:54:53 +0100 Subject: [PATCH 14/15] review applied --- .../get-referrer-leaderboard-v1.test.ts | 8 +++-- .../src/v1/api/zod-schemas.test.ts | 34 +++++++++++++++++++ .../rev-share-limit/leaderboard.test.ts | 6 +++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 77de09a74..f86d59950 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts @@ -69,11 +69,15 @@ describe("ENSAnalytics Referrer Leaderboard", () => { // Assert `finalScoreBoost` (pie-split specific) // All qualified referrers except the last have boost > 0; the last qualified referrer - // receives boost === 0 by design (formula: 1 - (rank-1)/(maxQualifiedReferrers-1)). + // receives boost === 0 by design (formula: 1 - (rank-1)/(maxQualifiedReferrers-1)), + // but only when the qualified slots are fully filled (length === maxQualifiedReferrers). + // With fewer referrers, the last qualified referrer is below the cutoff rank and has boost > 0. const topQualifiedReferrers = qualifiedReferrers.slice(0, -1); const lastQualifiedReferrer = qualifiedReferrers.at(-1); expect(topQualifiedReferrers.every(([_, r]) => r.finalScoreBoost > 0)).toBe(true); - expect(lastQualifiedReferrer![1].finalScoreBoost).toBe(0); + if (qualifiedReferrers.length === rules.maxQualifiedReferrers) { + expect(lastQualifiedReferrer![1].finalScoreBoost).toBe(0); + } expect(unqualifiedReferrers.every(([_, r]) => r.finalScoreBoost === 0)).toBe(true); // Assert `finalScore` (pie-split specific) diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.test.ts b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts index 43c35cb17..32db39f74 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts @@ -25,6 +25,21 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { }, }; + const revShareLimitEdition = { + slug: "2026-01", + displayName: "January 2026", + rules: { + awardModel: "rev-share-limit", + totalAwardPoolValue: { amount: "500", currency: "USDC" }, + minQualifiedRevenueContribution: { amount: "10", currency: "USDC" }, + qualifiedRevenueShare: 0.5, + startTime: 1000000, + endTime: 2000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + }, + }; + const futureModelEdition = { slug: "2026-03", displayName: "March 2026", @@ -52,6 +67,25 @@ describe("makeReferralProgramEditionConfigSetArraySchema", () => { expect(pieSplit!.rules.awardModel).toBe(ReferralProgramAwardModels.PieSplit); }); + it("parses the recognized rev-share-limit edition correctly", () => { + const result = schema.parse([pieSplitEdition, revShareLimitEdition]); + const revShareLimit = result.find((e) => e.slug === "2026-01"); + + expect(revShareLimit).toBeDefined(); + expect(revShareLimit!.rules.awardModel).toBe(ReferralProgramAwardModels.RevShareLimit); + + const rules = revShareLimit!.rules as { + awardModel: typeof ReferralProgramAwardModels.RevShareLimit; + totalAwardPoolValue: { amount: bigint; currency: string }; + minQualifiedRevenueContribution: { amount: bigint; currency: string }; + qualifiedRevenueShare: number; + }; + expect(rules.totalAwardPoolValue).toBeDefined(); + expect(rules.minQualifiedRevenueContribution).toBeDefined(); + expect(typeof rules.qualifiedRevenueShare).toBe("number"); + expect(rules.qualifiedRevenueShare).toBe(0.5); + }); + it("wraps the unrecognized edition as ReferralProgramRulesUnrecognized", () => { const result = schema.parse([pieSplitEdition, futureModelEdition]); const unrecognized = result.find((e) => e.slug === "2026-03"); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts index e68bca417..b60ec1034 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; @@ -77,6 +77,10 @@ const STANDARD_AWARD_1Y = parseUsdc("2.5"); // ─── Tests ──────────────────────────────────────────────────────────────────── describe("buildReferrerLeaderboardRevShareLimit", () => { + beforeEach(() => { + eventIdCounter = 0; + }); + it("returns empty leaderboard when events list is empty", () => { const rules = buildTestRules(); const result = buildReferrerLeaderboardRevShareLimit([], rules, accurateAsOf); From 9af1f05e966e295951ae9823d208b49059d2eb45 Mon Sep 17 00:00:00 2001 From: Goader Date: Fri, 27 Feb 2026 17:44:00 +0100 Subject: [PATCH 15/15] micro fix of the jsdocs --- packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts index d7fa155bc..6628a34ae 100644 --- a/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -148,7 +148,6 @@ export const buildRankedReferrerMetricsPieSplit = ( * Calculate the share of the award pool for a referrer. * @param referrer - The referrer to calculate the award pool share for. * @param aggregatedMetrics - Aggregated metrics for all referrers. - * @param rules - The rules of the referral program. * @returns The referrer's share of the award pool as a number between 0 and 1 (inclusive). */ export const calcReferrerAwardPoolSharePieSplit = (