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. 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/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index da08c2db7..f8327c3c3 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -27,10 +27,11 @@ vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => ( })); import { - buildReferralProgramRules, + buildReferralProgramRulesPieSplit, deserializeReferralProgramEditionConfigSetResponse, deserializeReferrerLeaderboardPageResponse, deserializeReferrerMetricsEditionsResponse, + ReferralProgramAwardModels, ReferralProgramEditionConfigSetResponseCodes, type ReferralProgramEditionSlug, ReferralProgramStatuses, @@ -362,6 +363,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerMetricsEditionsResponseCodes.Ok, data: { "2025-12": { + awardModel: populatedReferrerLeaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, @@ -370,6 +372,7 @@ describe("/v1/ensanalytics", () => { status: ReferralProgramStatuses.Active, }, "2026-03": { + awardModel: populatedReferrerLeaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Ranked, rules: populatedReferrerLeaderboard.rules, referrer: expectedMetrics, @@ -441,23 +444,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); - 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); + 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); @@ -522,23 +533,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); - 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); + 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); @@ -802,7 +819,7 @@ describe("/v1/ensanalytics", () => { { slug: "2025-12", displayName: "December 2025", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesPieSplit( parseUsdc("10000"), 100, parseTimestamp("2025-12-01T00:00:00Z"), @@ -817,7 +834,7 @@ describe("/v1/ensanalytics", () => { { slug: "2026-03", displayName: "March 2026", - rules: buildReferralProgramRules( + rules: buildReferralProgramRulesPieSplit( parseUsdc("10000"), 100, parseTimestamp("2026-03-01T00:00:00Z"), @@ -832,7 +849,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/database-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts index cc9494a67..73b1df56c 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,77 @@ 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({ + id: schema.registrarActions.id, + 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), + asc(schema.registrarActions.id), + ); + + // Type assertion: All fields in NonNullRecord are guaranteed non-null: + // 1. `referrer` is guaranteed non-null by isNotNull WHERE filter + // 2. `timestamp`, `blockNumber`, `transactionHash`, `incrementalDuration` are guaranteed non-null by database schema constraints (NOT NULL columns) + // 3. `total` is guaranteed non-null by COALESCE with 0 + interface NonNullRecord { + id: string; + referrer: Address; + timestamp: bigint; + blockNumber: bigint; + transactionHash: `0x${string}`; + incrementalDuration: bigint; + total: string; + } + + return (records as NonNullRecord[]).map((record) => ({ + id: record.id, + 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.test.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts index 5903ad331..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 @@ -1,4 +1,8 @@ -import { buildReferralProgramRules, type ReferrerLeaderboard } from "@namehash/ens-referrals/v1"; +import { + buildReferralProgramRulesPieSplit, + ReferralProgramAwardModels, + 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"), @@ -33,13 +37,19 @@ 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"); + } + expect(result).toMatchObject({ 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}. @@ -57,31 +67,32 @@ 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` - expect( - qualifiedReferrers.every( - ([_, referrer]) => referrer.finalScore === referrer.score * referrer.finalScoreBoost, - ), - ).toBe(true); + // 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)), + // 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); + 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) expect( - unqualifiedReferrers.every(([_, referrer]) => referrer.finalScore === referrer.score), + qualifiedReferrers.every(([_, r]) => r.finalScore === r.score * (1 + r.finalScoreBoost)), ).toBe(true); + expect(unqualifiedReferrers.every(([_, r]) => r.finalScore === r.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.awardPoolShare > 0)).toBe(true); + expect(unqualifiedReferrers.every(([_, r]) => r.awardPoolShare === 0)).toBe(true); // Assert `awardPoolApproxValue` expect( @@ -100,6 +111,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/get-referrer-leaderboard-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts index 412fc7dc4..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 @@ -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,27 @@ 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); + } + 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/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts index ac90c8aab..03837ef46 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,10 @@ export const dbResultsReferrerLeaderboard: ReferrerMetrics[] = [ }, ]; -export const emptyReferralLeaderboard: ReferrerLeaderboard = { +export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, @@ -196,8 +200,10 @@ export const emptyReferralLeaderboard: ReferrerLeaderboard = { accurateAsOf: 1735689600, }; -export const populatedReferrerLeaderboard: ReferrerLeaderboard = { +export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, @@ -684,10 +690,12 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboard = { accurateAsOf: 1735689600, }; -export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseOk = { +export const referrerLeaderboardPageResponseOk = { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: parseUsdc("10000"), maxQualifiedReferrers: 10, startTime: 1735689600, @@ -1096,5 +1104,5 @@ export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseO }, status: ReferralProgramStatuses.Active, accurateAsOf: 1735689600, - }, -}; + } satisfies ReferrerLeaderboardPagePieSplit, +} satisfies ReferrerLeaderboardPageResponseOk; diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 7ab98e429..a5c937057 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -1,6 +1,17 @@ -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 { + serializeReferralProgramRulesRevShareLimit, + serializeReferrerEditionMetricsRankedRevShareLimit, + 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 { ReferrerEditionMetrics, @@ -8,11 +19,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 +31,6 @@ import type { SerializedReferrerLeaderboardPageResponse, SerializedReferrerMetricsEditionsData, SerializedReferrerMetricsEditionsResponse, - SerializedUnrankedReferrerMetrics, } from "./serialized-types"; import { type ReferralProgramEditionConfigSetResponse, @@ -36,75 +43,34 @@ 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, ): SerializedReferralProgramRules { - return { - totalAwardPoolValue: serializePriceUsdc(rules.totalAwardPoolValue), - maxQualifiedReferrers: rules.maxQualifiedReferrers, - startTime: rules.startTime, - endTime: rules.endTime, - subregistryId: rules.subregistryId, - rulesUrl: rules.rulesUrl.toString(), - }; -} + switch (rules.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferralProgramRulesPieSplit(rules); -/** - * 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), - }; -} + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferralProgramRulesRevShareLimit(rules); -/** - * 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), - }; -} + 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.`, + ); + } -/** - * 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, - }; + default: { + const _exhaustiveCheck: never = rules; + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferralProgramRules).awardModel}`, + ); + } + } } /** @@ -113,14 +79,18 @@ 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.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferrerLeaderboardPagePieSplit(page); + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferrerLeaderboardPageRevShareLimit(page); + default: { + const _exhaustiveCheck: never = page; + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferrerLeaderboardPage).awardModel}`, + ); + } + } } /** @@ -129,14 +99,18 @@ 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.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return serializeReferrerEditionMetricsRankedPieSplit(detail); + case ReferralProgramAwardModels.RevShareLimit: + return serializeReferrerEditionMetricsRankedRevShareLimit(detail); + default: { + const _exhaustiveCheck: never = detail; + throw new Error( + `Unknown award model: ${(_exhaustiveCheck as ReferrerEditionMetricsRanked).awardModel}`, + ); + } + } } /** @@ -145,14 +119,18 @@ 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.awardModel) { + case ReferralProgramAwardModels.PieSplit: + 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/api/serialized-types.ts b/packages/ens-referrals/src/v1/api/serialized-types.ts index 1c049bde6..e5d487b65 100644 --- a/packages/ens-referrals/src/v1/api/serialized-types.ts +++ b/packages/ens-referrals/src/v1/api/serialized-types.ts @@ -1,13 +1,22 @@ -import type { SerializedPriceEth, SerializedPriceUsdc } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "../aggregations"; +import type { + SerializedReferralProgramRulesPieSplit, + SerializedReferrerEditionMetricsRankedPieSplit, + SerializedReferrerEditionMetricsUnrankedPieSplit, + SerializedReferrerLeaderboardPagePieSplit, +} from "../award-models/pie-split/api/serialized-types"; +import type { + SerializedReferralProgramRulesRevShareLimit, + SerializedReferrerEditionMetricsRankedRevShareLimit, + SerializedReferrerEditionMetricsUnrankedRevShareLimit, + SerializedReferrerLeaderboardPageRevShareLimit, +} 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 { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics"; import type { ReferralProgramRules } from "../rules"; import type { ReferralProgramEditionConfigSetData, @@ -25,67 +34,30 @@ import type { /** * Serialized representation of {@link ReferralProgramRules}. */ -export interface SerializedReferralProgramRules - extends Omit { - totalAwardPoolValue: SerializedPriceUsdc; - rulesUrl: string; -} - -/** - * Serialized representation of {@link AwardedReferrerMetrics}. - */ -export interface SerializedAwardedReferrerMetrics - extends Omit { - totalRevenueContribution: SerializedPriceEth; - awardPoolApproxValue: SerializedPriceUsdc; -} - -/** - * Serialized representation of {@link UnrankedReferrerMetrics}. - */ -export interface SerializedUnrankedReferrerMetrics - extends Omit { - totalRevenueContribution: SerializedPriceEth; - awardPoolApproxValue: SerializedPriceUsdc; -} - -/** - * Serialized representation of {@link AggregatedReferrerMetrics}. - */ -export interface SerializedAggregatedReferrerMetrics - extends Omit { - grandTotalRevenueContribution: SerializedPriceEth; -} +export type SerializedReferralProgramRules = + | SerializedReferralProgramRulesPieSplit + | SerializedReferralProgramRulesRevShareLimit; /** * 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.test.ts b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts new file mode 100644 index 000000000..32db39f74 --- /dev/null +++ b/packages/ens-referrals/src/v1/api/zod-schemas.test.ts @@ -0,0 +1,146 @@ +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 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", + 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("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"); + + 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 faa2086ae..501e55dd1 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -9,26 +9,22 @@ 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, + makeReferrerEditionMetricsPieSplitSchema, + makeReferrerLeaderboardPagePieSplitSchema, +} from "../award-models/pie-split/api/zod-schemas"; +import { + makeReferralProgramRulesRevShareLimitSchema, + 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 { MAX_EDITIONS_PER_REQUEST, ReferralProgramEditionConfigSetResponseCodes, @@ -40,126 +36,19 @@ import { * Schema for {@link ReferralProgramRules} */ 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.discriminatedUnion("awardModel", [ + makeReferralProgramRulesPieSplitSchema(valueLabel), + makeReferralProgramRulesRevShareLimitSchema(valueLabel), + ]); /** - * 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.discriminatedUnion("awardModel", [ + makeReferrerLeaderboardPagePieSplitSchema(valueLabel), + makeReferrerLeaderboardPageRevShareLimitSchema(valueLabel), + ]); /** * Schema for {@link ReferrerLeaderboardPageResponseOk} @@ -196,42 +85,12 @@ export const makeReferrerLeaderboardPageResponseSchema = ( ]); /** - * Schema for {@link ReferrerEditionMetricsRanked} (with ranked metrics) - */ -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`), - }); - -/** - * Schema for {@link ReferrerEditionMetricsUnranked} (with unranked metrics) - */ -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`), - }); - -/** - * Schema for {@link ReferrerEditionMetrics} (discriminated union of ranked and unranked) + * Schema for {@link ReferrerEditionMetrics} (discriminated union of all ranked and unranked model variants). */ export const makeReferrerEditionMetricsSchema = (valueLabel: string = "ReferrerEditionMetrics") => - z.discriminatedUnion("type", [ - makeReferrerEditionMetricsRankedSchema(valueLabel), - makeReferrerEditionMetricsUnrankedSchema(valueLabel), + z.discriminatedUnion("awardModel", [ + makeReferrerEditionMetricsPieSplitSchema(valueLabel), + makeReferrerEditionMetricsRevShareLimitSchema(valueLabel), ]); /** @@ -322,38 +181,131 @@ 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 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. + * + * The list must not be empty after processing all items. Duplicate slugs are not allowed. + * + * Two-pass approach: + * 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", -) => - 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).filter( + (m) => m !== ReferralProgramAwardModels.Unrecognized, + ) 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(); + + // 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[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + 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); } - return true; - }, - { message: `${valueLabel} must not contain duplicate edition slugs` }, - ); + } else { + // 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 (result.length === 0) { + ctx.addIssue({ + code: "custom", + message: `${valueLabel} must contain at least one edition`, + }); + // 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/aggregations.ts b/packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts similarity index 68% rename from packages/ens-referrals/src/v1/aggregations.ts rename to packages/ens-referrals/src/v1/award-models/pie-split/aggregations.ts index 4b6157ab8..072bbc2f5 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,35 +47,30 @@ 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", + makePriceEthSchema("AggregatedReferrerMetricsPieSplit.grandTotalRevenueContribution").parse( + metrics.grandTotalRevenueContribution, ); - const parseResult = priceEthSchema.safeParse(metrics.grandTotalRevenueContribution); - if (!parseResult.success) { - throw new Error( - `AggregatedReferrerMetrics: grandTotalRevenueContribution validation failed: ${parseResult.error.message}`, - ); - } validateReferrerScore(metrics.grandTotalQualifiedReferrersFinalScore); validateReferrerScore(metrics.minFinalScoreToQualify); }; /** - * 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 +86,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 +117,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 +131,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..3bbdbc775 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/serialize.ts @@ -0,0 +1,144 @@ +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 { + awardModel: detail.awardModel, + 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 { + awardModel: detail.awardModel, + 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 { + awardModel: page.awardModel, + 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..e9bbbac95 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/api/zod-schemas.ts @@ -0,0 +1,189 @@ +import z from "zod/v4"; + +import { + makeDurationSchema, + makeFiniteNonNegativeNumberSchema, + makeLowercaseAddressSchema, + makeNonNegativeIntegerSchema, + makePositiveIntegerSchema, + makePriceEthSchema, + makePriceUsdcSchema, + makeUnixTimestampSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { + makeBaseReferralProgramRulesSchema, + makeReferralProgramStatusSchema, + makeReferrerLeaderboardPageContextSchema, +} from "../../shared/api/zod-schemas"; +import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; +import { ReferralProgramAwardModels } from "../../shared/rules"; + +/** + * Schema for {@link ReferralProgramRulesPieSplit}. + */ +export const makeReferralProgramRulesPieSplitSchema = ( + valueLabel: string = "ReferralProgramRulesPieSplit", +) => + makeBaseReferralProgramRulesSchema(valueLabel).safeExtend({ + awardModel: z.literal(ReferralProgramAwardModels.PieSplit), + totalAwardPoolValue: makePriceUsdcSchema(`${valueLabel}.totalAwardPoolValue`), + maxQualifiedReferrers: makeNonNegativeIntegerSchema(`${valueLabel}.maxQualifiedReferrers`), + }); + +/** + * 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({ + 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}. + */ +export const makeReferrerEditionMetricsUnrankedPieSplitSchema = ( + valueLabel: string = "ReferrerEditionMetricsUnrankedPieSplit", +) => + 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}. + */ +export const makeReferrerLeaderboardPagePieSplitSchema = ( + valueLabel: string = "ReferrerLeaderboardPagePieSplit", +) => + 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 new file mode 100644 index 000000000..934a04d82 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/edition-metrics.ts @@ -0,0 +1,115 @@ +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"; + +/** + * 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}. + * - `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. + */ + 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}. + * - `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. + */ + 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..dee11f4eb --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard-page.ts @@ -0,0 +1,59 @@ +import { calcReferralProgramStatus } from "../../status"; +import { + type BaseReferrerLeaderboardPage, + 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"; +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. + */ + 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 { + awardModel: leaderboard.awardModel, + 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..b09176596 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/leaderboard.ts @@ -0,0 +1,85 @@ +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 { ReferralProgramAwardModels } from "../shared/rules"; +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 { + /** + * 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}. + */ + 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 { awardModel: rules.awardModel, 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..6628a34ae --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/metrics.ts @@ -0,0 +1,336 @@ +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 ScoredReferrerMetricsPieSplit} 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. + * @returns The referrer's share of the award pool as a number between 0 and 1 (inclusive). + */ +export const calcReferrerAwardPoolSharePieSplit = ( + referrer: RankedReferrerMetricsPieSplit, + aggregatedMetrics: AggregatedReferrerMetricsPieSplit, +): number => { + if (!referrer.isQualified) return 0; + if (aggregatedMetrics.grandTotalQualifiedReferrersFinalScore === 0) return 0; + + return referrer.finalScore / aggregatedMetrics.grandTotalQualifiedReferrersFinalScore; +}; + +/** + * Extends {@link RankedReferrerMetricsPieSplit} to include additional metrics + * 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 AggregatedReferrerMetricsPieSplit.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).`, + ); + } + + makePriceUsdcSchema("AwardedReferrerMetricsPieSplit.awardPoolApproxValue").parse( + referrer.awardPoolApproxValue, + ); + + if (referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount) { + throw new Error( + `AwardedReferrerMetricsPieSplit: awardPoolApproxValue.amount ${referrer.awardPoolApproxValue.amount.toString()} exceeds totalAwardPoolValue.amount ${rules.totalAwardPoolValue.amount.toString()}.`, + ); + } +}; + +export const buildAwardedReferrerMetricsPieSplit = ( + referrer: RankedReferrerMetricsPieSplit, + aggregatedMetrics: AggregatedReferrerMetricsPieSplit, + rules: ReferralProgramRulesPieSplit, +): AwardedReferrerMetricsPieSplit => { + 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); + + 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}.`, + ); + } + + 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()}.`, + ); + } + + 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}.`, + ); + } + + 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()}.`, + ); + } +}; + +/** + * 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..652af4f47 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rank.ts @@ -0,0 +1,73 @@ +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. + */ +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..b55a3e634 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/rules.ts @@ -0,0 +1,66 @@ +import type { AccountId, PriceUsdc, UnixTimestamp } from "@ensnode/ensnode-sdk"; +import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import { validateNonNegativeInteger } from "../../number"; +import { + type BaseReferralProgramRules, + ReferralProgramAwardModels, + validateBaseReferralProgramRules, +} 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 => { + makePriceUsdcSchema("ReferralProgramRulesPieSplit.totalAwardPoolValue").parse( + rules.totalAwardPoolValue, + ); + + validateNonNegativeInteger(rules.maxQualifiedReferrers); + + validateBaseReferralProgramRules(rules); +}; + +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..755ad8976 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/pie-split/score.ts @@ -0,0 +1,18 @@ +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 referral program edition. + * + * 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}. + */ +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..1a63e7b87 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/aggregations.ts @@ -0,0 +1,95 @@ +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 { AwardedReferrerMetricsRevShareLimit } from "./metrics"; + +/** + * Represents aggregated metrics for a list of referrers on a rev-share-limit leaderboard. + */ +export interface AggregatedReferrerMetricsRevShareLimit { + /** + * @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 referrers 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 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 all qualified awards + * claimed during the sequential race processing. + * + * @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); + + makePriceEthSchema("AggregatedReferrerMetricsRevShareLimit.grandTotalRevenueContribution").parse( + metrics.grandTotalRevenueContribution, + ); + + makePriceUsdcSchema("AggregatedReferrerMetricsRevShareLimit.awardPoolRemaining").parse( + metrics.awardPoolRemaining, + ); +}; + +/** + * 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 list of all referrers.** + * + * @param referrers - Must be a complete list of referrers with their totals. + * **This must NOT be a paginated or partial slice.** + * + * @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. + */ +export const buildAggregatedReferrerMetricsRevShareLimit = ( + referrers: AwardedReferrerMetricsRevShareLimit[], + awardPoolRemaining: PriceUsdc, +): AggregatedReferrerMetricsRevShareLimit => { + let grandTotalReferrals = 0; + let grandTotalIncrementalDuration = 0; + let grandTotalRevenueContributionAmount = 0n; + + for (const referrer of referrers) { + grandTotalReferrals += referrer.totalReferrals; + grandTotalIncrementalDuration += referrer.totalIncrementalDuration; + grandTotalRevenueContributionAmount += referrer.totalRevenueContribution.amount; + } + + const aggregatedMetrics = { + grandTotalReferrals, + grandTotalIncrementalDuration, + grandTotalRevenueContribution: priceEth(grandTotalRevenueContributionAmount), + awardPoolRemaining, + } satisfies AggregatedReferrerMetricsRevShareLimit; + + validateAggregatedReferrerMetricsRevShareLimit(aggregatedMetrics); + + 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 new file mode 100644 index 000000000..8e5d78775 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts @@ -0,0 +1,143 @@ +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, + standardAwardValue: serializePriceUsdc(metrics.standardAwardValue), + 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, + standardAwardValue: serializePriceUsdc(metrics.standardAwardValue), + awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + }; +} + +/** + * Serializes a {@link ReferrerEditionMetricsRankedRevShareLimit} object. + */ +export function serializeReferrerEditionMetricsRankedRevShareLimit( + detail: ReferrerEditionMetricsRankedRevShareLimit, +): SerializedReferrerEditionMetricsRankedRevShareLimit { + return { + awardModel: detail.awardModel, + 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 { + awardModel: detail.awardModel, + 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 { + awardModel: page.awardModel, + 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..9b4d86f6b --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts @@ -0,0 +1,108 @@ +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" + | "standardAwardValue" + | "awardPoolApproxValue" + > { + totalRevenueContribution: SerializedPriceEth; + totalBaseRevenueContribution: SerializedPriceUsdc; + standardAwardValue: SerializedPriceUsdc; + awardPoolApproxValue: SerializedPriceUsdc; +} + +/** + * Serialized representation of {@link UnrankedReferrerMetricsRevShareLimit}. + */ +export interface SerializedUnrankedReferrerMetricsRevShareLimit + extends Omit< + UnrankedReferrerMetricsRevShareLimit, + | "totalRevenueContribution" + | "totalBaseRevenueContribution" + | "standardAwardValue" + | "awardPoolApproxValue" + > { + totalRevenueContribution: SerializedPriceEth; + totalBaseRevenueContribution: SerializedPriceUsdc; + standardAwardValue: 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..f557e5552 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -0,0 +1,187 @@ +import z from "zod/v4"; + +import { + makeDurationSchema, + makeFiniteNonNegativeNumberSchema, + makeLowercaseAddressSchema, + makeNonNegativeIntegerSchema, + makePositiveIntegerSchema, + makePriceEthSchema, + makePriceUsdcSchema, + makeUnixTimestampSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { + makeBaseReferralProgramRulesSchema, + makeReferralProgramStatusSchema, + makeReferrerLeaderboardPageContextSchema, +} from "../../shared/api/zod-schemas"; +import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; +import { ReferralProgramAwardModels } from "../../shared/rules"; + +/** + * Schema for {@link ReferralProgramRulesRevShareLimit}. + */ +export const makeReferralProgramRulesRevShareLimitSchema = ( + valueLabel: string = "ReferralProgramRulesRevShareLimit", +) => + 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). + */ +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(), + 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). + */ +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), + 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}. + */ +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({ + 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}. + */ +export const makeReferrerEditionMetricsUnrankedRevShareLimitSchema = ( + valueLabel: string = "ReferrerEditionMetricsUnrankedRevShareLimit", +) => + 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}. + */ +export const makeReferrerLeaderboardPageRevShareLimitSchema = ( + valueLabel: string = "ReferrerLeaderboardPageRevShareLimit", +) => + 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 new file mode 100644 index 000000000..4fd5b5cd4 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/edition-metrics.ts @@ -0,0 +1,110 @@ +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, + 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. + * + * @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. + */ + 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 rank, qualification status, + * standard award value, and award pool approximate value. + */ + 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. + * + * @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. + */ + 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..58a86bcb2 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard-page.ts @@ -0,0 +1,59 @@ +import { calcReferralProgramStatus } from "../../status"; +import { + type BaseReferrerLeaderboardPage, + 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"; +import type { ReferralProgramRulesRevShareLimit } from "./rules"; + +/** + * A page of referrers from the rev-share-limit referrer leaderboard. + */ +export interface ReferrerLeaderboardPageRevShareLimit extends BaseReferrerLeaderboardPage { + /** + * 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; + + /** + * 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 { + awardModel: leaderboard.awardModel, + 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.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts new file mode 100644 index 000000000..b60ec1034 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -0,0 +1,455 @@ +import { beforeEach, 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. + */ +let eventIdCounter = 0; + +function makeEvent( + referrer: `0x${string}`, + timestamp: number, + incrementalDuration: number, + opts: Partial> = {}, +): ReferralEvent { + return { + id: opts.id ?? `event-${++eventIdCounter}`, + 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", () => { + beforeEach(() => { + eventIdCounter = 0; + }); + + 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); + }); + + 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", () => { + 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 new file mode 100644 index 000000000..de044f14f --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -0,0 +1,250 @@ +import type { Address } from "viem"; + +import { + type Duration, + priceEth, + priceUsdc, + scalePrice, + type UnixTimestamp, +} from "@ensnode/ensnode-sdk"; + +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"; +import type { AwardedReferrerMetricsRevShareLimit } from "./metrics"; +import { + buildAwardedReferrerMetricsRevShareLimit, + buildRankedReferrerMetricsRevShareLimit, + buildReferrerMetricsRevShareLimit, +} from "./metrics"; +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. + */ +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}. + */ + rules: ReferralProgramRulesRevShareLimit; + + /** + * The {@link AggregatedReferrerMetricsRevShareLimit} for all {@link AwardedReferrerMetricsRevShareLimit} values in `referrers`. + */ + aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; + + /** + * 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). + * @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 `totalRevenueContribution` within the + * `rules` as of `accurateAsOf`. + * @invariant Each value in this map is guaranteed to have a non-zero + * `totalReferrals` and `totalIncrementalDuration`. + */ + referrers: Map; + + /** + * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardRevShareLimit} was accurate as of. + */ + accurateAsOf: UnixTimestamp; +} + +/** + * Per-referrer mutable state used during sequential race processing. + */ +interface ReferrerRaceState { + totalReferrals: number; + totalIncrementalDuration: Duration; + totalRevenueContributionAmount: 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 = ( + events: ReferralEvent[], + rules: ReferralProgramRulesRevShareLimit, + accurateAsOf: UnixTimestamp, +): ReferrerLeaderboardRevShareLimit => { + // 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; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }); + + // 2. Process events sequentially to run the race. + const referrerStates = new Map(); + let poolRemainingAmount = rules.totalAwardPoolValue.amount; + + for (const event of sortedEvents) { + const referrer = normalizeAddress(event.referrer); + + let state = referrerStates.get(referrer); + if (!state) { + state = { + totalReferrals: 0, + totalIncrementalDuration: 0, + totalRevenueContributionAmount: 0n, + wasQualified: false, + qualifiedAwardValueAmount: 0n, + }; + referrerStates.set(referrer, state); + } + + // Update raw totals. + state.totalReferrals += 1; + state.totalIncrementalDuration += event.incrementalDuration; + state.totalRevenueContributionAmount += event.incrementalRevenueContribution.amount; + + // 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 = 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 = + 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 + : 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. 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) => { + 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: totalIncrementalDuration desc (used directly as the tie-breaker). + if (stateB.totalIncrementalDuration !== stateA.totalIncrementalDuration) { + return stateB.totalIncrementalDuration - stateA.totalIncrementalDuration; + } + + // 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), + rules, + ); + }, + ); + + const awardPoolRemaining = priceUsdc(poolRemainingAmount); + + const aggregatedMetrics = buildAggregatedReferrerMetricsRevShareLimit( + awardedReferrers, + awardPoolRemaining, + ); + + const referrers = new Map(awardedReferrers.map((r) => [r.referrer, r])); + + return { awardModel: rules.awardModel, 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..55b9c9182 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -0,0 +1,280 @@ +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, 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, + 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 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 => { + const totalBaseRevenueContribution = priceUsdc( + (BASE_REVENUE_CONTRIBUTION_PER_YEAR.amount * BigInt(metrics.totalIncrementalDuration)) / + BigInt(SECONDS_PER_YEAR), + ); + + const result = { + ...metrics, + totalBaseRevenueContribution, + } satisfies ReferrerMetricsRevShareLimit; + + validateReferrerMetricsRevShareLimit(result); + return result; +}; + +/** + * 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 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 => { + const result = { + ...referrer, + rank, + isQualified: isReferrerQualifiedRevShareLimit(referrer.totalBaseRevenueContribution, rules), + } satisfies RankedReferrerMetricsRevShareLimit; + + validateRankedReferrerMetricsRevShareLimit(result, rules); + return result; +}; + +/** + * 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. + */ + 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 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 => { + const result = { + ...referrer, + standardAwardValue, + awardPoolApproxValue, + } satisfies AwardedReferrerMetricsRevShareLimit; + + validateAwardedReferrerMetricsRevShareLimit(result, rules); + return result; +}; + +/** + * 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; +} + +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. + */ +export const buildUnrankedReferrerMetricsRevShareLimit = ( + referrer: Address, +): UnrankedReferrerMetricsRevShareLimit => { + const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); + + const result = { + ...metrics, + totalBaseRevenueContribution: priceUsdc(0n), + rank: null, + isQualified: false, + 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/referral-event.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts new file mode 100644 index 000000000..0307dd5d2 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/referral-event.ts @@ -0,0 +1,47 @@ +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}`; + + /** + * Registrar action ID. + */ + id: 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/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..d38236013 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts @@ -0,0 +1,113 @@ +import { + type AccountId, + type PriceUsdc, + parseUsdc, + type UnixTimestamp, +} from "@ensnode/ensnode-sdk"; +import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; + +import { + type BaseReferralProgramRules, + ReferralProgramAwardModels, + validateBaseReferralProgramRules, +} 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 => { + makePriceUsdcSchema("ReferralProgramRulesRevShareLimit.totalAwardPoolValue").parse( + rules.totalAwardPoolValue, + ); + + makePriceUsdcSchema("ReferralProgramRulesRevShareLimit.minQualifiedRevenueContribution").parse( + rules.minQualifiedRevenueContribution, + ); + + 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}.`, + ); + } + + validateBaseReferralProgramRules(rules); +}; + +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..77fa74c36 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/api/zod-schemas.ts @@ -0,0 +1,56 @@ +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}. + */ +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..39bf36772 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/leaderboard-page.ts @@ -0,0 +1,305 @@ +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"; +import type { ReferralProgramAwardModel } from "./rules"; + +/** + * 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` < `totalRecords`) + */ + 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: totalRecords must be a non-negative integer but is ${context.totalRecords}.`, + ); + } + + // Validate totalPages + if (!isPositiveInteger(context.totalPages)) { + throw new Error( + `Invalid ReferrerLeaderboardPageContext: totalPages must be a positive integer (>= 1) 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 totalRecords (${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 totalRecords (${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 { + /** + * Discriminant identifying the award model for this leaderboard page. + */ + awardModel: ReferralProgramAwardModel; + + /** + * 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..d42412f87 --- /dev/null +++ b/packages/ens-referrals/src/v1/award-models/shared/rules.ts @@ -0,0 +1,100 @@ +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. + * + * @remarks Clients MUST check `awardModel` before accessing model-specific fields. + * 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 = + (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; +} + +/** + * 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); + + 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/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/client.ts b/packages/ens-referrals/src/v1/client.ts index 1086b13fc..b8a42a88a 100644 --- a/packages/ens-referrals/src/v1/client.ts +++ b/packages/ens-referrals/src/v1/client.ts @@ -86,6 +86,12 @@ 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 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 @@ -132,6 +138,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 +249,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 +344,12 @@ 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 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 * const response = await client.getEditionConfigSet(); 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..88123f417 100644 --- a/packages/ens-referrals/src/v1/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -1,134 +1,43 @@ 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 { buildUnrankedReferrerMetricsPieSplit } from "./award-models/pie-split/metrics"; +import type { + ReferrerEditionMetricsRankedRevShareLimit, + ReferrerEditionMetricsUnrankedRevShareLimit, +} from "./award-models/rev-share-limit/edition-metrics"; +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 `awardModel` to narrow 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 `awardModel` to narrow 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 `type` to determine if the referrer is ranked or unranked. + * Use `awardModel` to narrow the award model variant. */ export type ReferrerEditionMetrics = ReferrerEditionMetricsRanked | ReferrerEditionMetricsUnranked; @@ -146,28 +55,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.awardModel) { + case ReferralProgramAwardModels.PieSplit: { + const awardedReferrerMetrics = leaderboard.referrers.get(referrer); + if (awardedReferrerMetrics) { + return { + awardModel: leaderboard.awardModel, + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: leaderboard.rules, + referrer: awardedReferrerMetrics, + aggregatedMetrics: leaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsRankedPieSplit; + } + return { + awardModel: leaderboard.awardModel, + type: ReferrerEditionMetricsTypeIds.Unranked, + rules: leaderboard.rules, + referrer: buildUnrankedReferrerMetricsPieSplit(referrer), + aggregatedMetrics: leaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsUnrankedPieSplit; + } + + case ReferralProgramAwardModels.RevShareLimit: { + const awardedReferrerMetrics = leaderboard.referrers.get(referrer); + if (awardedReferrerMetrics) { + return { + awardModel: leaderboard.awardModel, + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: leaderboard.rules, + referrer: awardedReferrerMetrics, + aggregatedMetrics: leaderboard.aggregatedMetrics, + status, + accurateAsOf: leaderboard.accurateAsOf, + } satisfies ReferrerEditionMetricsRankedRevShareLimit; + } + return { + awardModel: leaderboard.awardModel, + type: ReferrerEditionMetricsTypeIds.Unranked, + rules: leaderboard.rules, + referrer: buildUnrankedReferrerMetricsRevShareLimit(referrer), + aggregatedMetrics: leaderboard.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..82ebce219 100644 --- a/packages/ens-referrals/src/v1/index.ts +++ b/packages/ens-referrals/src/v1/index.ts @@ -1,6 +1,26 @@ 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/referral-event"; +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 +29,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..d2a41e8e8 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.test.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.test.ts @@ -3,13 +3,14 @@ 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"; +import { ReferralProgramAwardModels } from "./award-models/shared/rules"; describe("buildReferrerLeaderboardPageContext", () => { const pageParams: ReferrerLeaderboardPageParams = { @@ -18,8 +19,10 @@ describe("buildReferrerLeaderboardPageContext", () => { }; it("correctly evaluates `hasNext` when `leaderboard.referrers.size` and `recordsPerPage` are equal", () => { - const leaderboard: ReferrerLeaderboard = { + const leaderboard: ReferrerLeaderboardPieSplit = { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: priceUsdc(10000n), maxQualifiedReferrers: 10, startTime: 1764547200, @@ -37,7 +40,7 @@ describe("buildReferrerLeaderboardPageContext", () => { grandTotalQualifiedReferrersFinalScore: 28.05273061366773, minFinalScoreToQualify: 0, }, - referrers: new Map([ + referrers: new Map([ [ "0x03c098d2bed4609e6ed9beb2c4877741f45f290d", { @@ -104,8 +107,10 @@ describe("buildReferrerLeaderboardPageContext", () => { }); it("Correctly builds the pagination context when `leaderboard.referrers.size` is 0", () => { - const leaderboard: ReferrerLeaderboard = { + const leaderboard: ReferrerLeaderboardPieSplit = { + awardModel: ReferralProgramAwardModels.PieSplit, rules: { + awardModel: ReferralProgramAwardModels.PieSplit, totalAwardPoolValue: priceUsdc(10000n), maxQualifiedReferrers: 10, startTime: 1764547200, @@ -123,7 +128,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..3b696ff5a 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.ts @@ -1,304 +1,27 @@ -import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; - -import type { AggregatedReferrerMetrics } from "./aggregations"; +import { + buildLeaderboardPagePieSplit, + type ReferrerLeaderboardPagePieSplit, +} from "./award-models/pie-split/leaderboard-page"; +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 `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 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 +29,10 @@ 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.awardModel) { + case ReferralProgramAwardModels.PieSplit: + return buildLeaderboardPagePieSplit(pageContext, leaderboard); + case ReferralProgramAwardModels.RevShareLimit: + return buildLeaderboardPageRevShareLimit(pageContext, leaderboard); } - - 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..fdaedef70 100644 --- a/packages/ens-referrals/src/v1/leaderboard.ts +++ b/packages/ens-referrals/src/v1/leaderboard.ts @@ -1,96 +1,9 @@ -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"; -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. + * + * Use `awardModel` to narrow 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 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.`, - ); - } - - 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, - }; -}; +export type ReferrerLeaderboard = ReferrerLeaderboardPieSplit | ReferrerLeaderboardRevShareLimit; 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..90aa11854 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,342 +67,7 @@ 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) { - throw new Error( - `ReferrerMetrics: totalRevenueContribution validation failed: ${parseResult.error.message}`, - ); - } -}; - -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, + makePriceEthSchema("ReferrerMetrics.totalRevenueContribution").parse( + metrics.totalRevenueContribution, ); - 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..f27ba4be6 100644 --- a/packages/ens-referrals/src/v1/rules.ts +++ b/packages/ens-referrals/src/v1/rules.ts @@ -1,101 +1,20 @@ -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"; +import type { ReferralProgramRulesUnrecognized } from "./award-models/shared/rules"; + +/** + * The rules of a referral program edition. + * + * 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 + | ReferralProgramRulesUnrecognized; 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; -};