diff --git a/.changeset/proud-wolves-cheer.md b/.changeset/proud-wolves-cheer.md new file mode 100644 index 000000000..c87cd780f --- /dev/null +++ b/.changeset/proud-wolves-cheer.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-referrals": minor +--- + +Add admin disqualification support for rev-share-limit referral program editions. diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts index 8e5d78775..229adc3c7 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts @@ -36,6 +36,7 @@ export function serializeReferralProgramRulesRevShareLimit( endTime: rules.endTime, subregistryId: rules.subregistryId, rulesUrl: rules.rulesUrl.toString(), + disqualifications: rules.disqualifications, }; } @@ -69,6 +70,8 @@ export function serializeAwardedReferrerMetricsRevShareLimit( isQualified: metrics.isQualified, standardAwardValue: serializePriceUsdc(metrics.standardAwardValue), awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + isAdminDisqualified: metrics.isAdminDisqualified, + adminDisqualificationReason: metrics.adminDisqualificationReason, }; } @@ -88,6 +91,8 @@ export function serializeUnrankedReferrerMetricsRevShareLimit( isQualified: metrics.isQualified, standardAwardValue: serializePriceUsdc(metrics.standardAwardValue), awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue), + isAdminDisqualified: metrics.isAdminDisqualified, + adminDisqualificationReason: metrics.adminDisqualificationReason, }; } diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts index f557e5552..2ab07fc92 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/api/zod-schemas.ts @@ -11,6 +11,7 @@ import { makeUnixTimestampSchema, } from "@ensnode/ensnode-sdk/internal"; +import { normalizeAddress } from "../../../address"; import { makeBaseReferralProgramRulesSchema, makeReferralProgramStatusSchema, @@ -19,6 +20,17 @@ import { import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; import { ReferralProgramAwardModels } from "../../shared/rules"; +/** + * Schema for {@link ReferralProgramEditionDisqualification}. + */ +export const makeReferralProgramEditionDisqualificationSchema = ( + valueLabel = "ReferralProgramEditionDisqualification", +) => + z.object({ + referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`), + reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`), + }); + /** * Schema for {@link ReferralProgramRulesRevShareLimit}. */ @@ -34,6 +46,20 @@ export const makeReferralProgramRulesRevShareLimitSchema = ( qualifiedRevenueShare: makeFiniteNonNegativeNumberSchema( `${valueLabel}.qualifiedRevenueShare`, ).max(1, `${valueLabel}.qualifiedRevenueShare must be <= 1`), + disqualifications: z + .array( + makeReferralProgramEditionDisqualificationSchema(`${valueLabel}.disqualifications[item]`), + ) + .refine( + (items) => { + const addresses = items.map((item) => normalizeAddress(item.referrer)); + return new Set(addresses).size === addresses.length; + }, + { + message: `${valueLabel}.disqualifications must not contain duplicate referrer addresses`, + }, + ) + .default([]), }); /** @@ -55,10 +81,29 @@ export const makeAwardedReferrerMetricsRevShareLimitSchema = ( isQualified: z.boolean(), standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`), awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + isAdminDisqualified: z.boolean(), + adminDisqualificationReason: z + .string() + .trim() + .min(1, `${valueLabel}.adminDisqualificationReason must not be empty`) + .nullable(), }) .refine((data) => data.awardPoolApproxValue.amount <= data.standardAwardValue.amount, { message: `${valueLabel}.awardPoolApproxValue must be <= ${valueLabel}.standardAwardValue`, path: ["awardPoolApproxValue"], + }) + .refine( + (data) => + !data.isAdminDisqualified || + (data.isQualified === false && data.awardPoolApproxValue.amount === 0n), + { + message: `When ${valueLabel}.isAdminDisqualified is true, isQualified must be false and awardPoolApproxValue.amount must be 0`, + path: ["isAdminDisqualified"], + }, + ) + .refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), { + message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`, + path: ["adminDisqualificationReason"], }); /** @@ -80,10 +125,20 @@ export const makeUnrankedReferrerMetricsRevShareLimitSchema = ( isQualified: z.literal(false), standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`), awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`), + isAdminDisqualified: z.boolean(), + adminDisqualificationReason: z + .string() + .trim() + .min(1, `${valueLabel}.adminDisqualificationReason must not be empty`) + .nullable(), }) .refine((data) => data.awardPoolApproxValue.amount <= data.standardAwardValue.amount, { message: `${valueLabel}.awardPoolApproxValue must be <= ${valueLabel}.standardAwardValue`, path: ["awardPoolApproxValue"], + }) + .refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), { + message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`, + path: ["adminDisqualificationReason"], }); /** diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts index 13321cbdc..c797219fe 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.test.ts @@ -5,6 +5,7 @@ import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode import { SECONDS_PER_YEAR } from "../../time"; import { buildReferrerLeaderboardRevShareLimit } from "./leaderboard"; import type { ReferralEvent } from "./referral-event"; +import type { ReferralProgramEditionDisqualification } from "./rules"; import { buildReferralProgramRulesRevShareLimit } from "./rules"; // ─── Test fixtures ─────────────────────────────────────────────────────────── @@ -42,6 +43,7 @@ const CHECKPOINT_PREFIX = function buildTestRules( totalAwardPoolValue = parseUsdc("1000"), minQualifiedRevenueContribution = parseUsdc("5"), + disqualifications: ReferralProgramEditionDisqualification[] = [], ) { return buildReferralProgramRulesRevShareLimit( totalAwardPoolValue, @@ -51,6 +53,7 @@ function buildTestRules( parseTimestamp("2026-12-31T23:59:59Z"), { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, new URL("https://example.com/rules"), + disqualifications, ); } @@ -395,4 +398,154 @@ describe("buildReferrerLeaderboardRevShareLimit", () => { expect(result.aggregatedMetrics.grandTotalIncrementalDuration).toBe(3 * SECONDS_PER_YEAR); }); }); + + describe("Admin disqualifications", () => { + it("no disqualifications — qualified referrers receive awards normally", () => { + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), []); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), + ]; + + 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.isAdminDisqualified).toBe(false); + expect(referrerA.adminDisqualificationReason).toBe(null); + expect(referrerA.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); + + expect(referrerB.isQualified).toBe(true); + expect(referrerB.isAdminDisqualified).toBe(false); + expect(referrerB.adminDisqualificationReason).toBe(null); + }); + + it("disqualified referrer who met threshold: awardPoolApproxValue = 0, pool preserved for next", () => { + // ADDR_A qualifies by revenue but is admin-disqualified → pool claim = 0 + // ADDR_B qualifies later → gets the full pool share + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + { referrer: ADDR_A, reason: "self-referral" }, + ]); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), // would qualify, but disqualified + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), // qualifies normally + ]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + const referrerB = result.referrers.get(ADDR_B)!; + + expect(referrerA.isAdminDisqualified).toBe(true); + expect(referrerA.adminDisqualificationReason).toBe("self-referral"); + expect(referrerA.isQualified).toBe(false); + expect(referrerA.awardPoolApproxValue.amount).toBe(0n); + + // Pool was not consumed by ADDR_A, so ADDR_B gets the full award + expect(referrerB.isQualified).toBe(true); + expect(referrerB.isAdminDisqualified).toBe(false); + expect(referrerB.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); + }); + + it("disqualified referrer who never met the revenue threshold: pool unchanged", () => { + // ADDR_A has half a year (below threshold) and is disqualified — pool should be fully intact + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + { referrer: ADDR_A, reason: "promoting discounts" }, + ]); + const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; + + const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + + expect(referrerA.isAdminDisqualified).toBe(true); + expect(referrerA.adminDisqualificationReason).toBe("promoting discounts"); + expect(referrerA.isQualified).toBe(false); + expect(referrerA.awardPoolApproxValue.amount).toBe(0n); + // Pool fully intact + expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(parseUsdc("1000").amount); + }); + + it("disqualified referrer ranks between qualified (pool claim) and unqualified (below threshold)", () => { + // ADDR_A: 2 years, disqualified → standardAward $5.00, pool claim $0 + // ADDR_B: 1 year, qualified → standardAward $2.50, pool claim $2.50 + // ADDR_C: 0.5 years, below threshold → standardAward $1.25, pool claim $0 + // + // Sort by pool claim desc, then duration desc: + // rank 1 → ADDR_B ($2.50 claim) + // rank 2 → ADDR_A ($0 claim, 2y duration — beats ADDR_C on duration) + // rank 3 → ADDR_C ($0 claim, 0.5y duration) + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + { referrer: ADDR_A, reason: "cheating" }, + ]); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR * 2), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), + makeEvent(ADDR_C, 3000, Math.floor(SECONDS_PER_YEAR / 2)), + ]; + + 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)!; + + expect(referrerB.rank).toBe(1); + expect(referrerB.isQualified).toBe(true); + expect(referrerB.isAdminDisqualified).toBe(false); + expect(referrerB.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); + + expect(referrerA.rank).toBe(2); + expect(referrerA.isAdminDisqualified).toBe(true); + expect(referrerA.adminDisqualificationReason).toBe("cheating"); + expect(referrerA.isQualified).toBe(false); + expect(referrerA.awardPoolApproxValue.amount).toBe(0n); + + expect(referrerC.rank).toBe(3); + expect(referrerC.isQualified).toBe(false); + expect(referrerC.isAdminDisqualified).toBe(false); + expect(referrerC.awardPoolApproxValue.amount).toBe(0n); + }); + + it("multiple disqualifications: all disqualified referrers get isAdminDisqualified=true", () => { + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + { referrer: ADDR_A, reason: "reason-a" }, + { referrer: ADDR_B, reason: "reason-b" }, + ]); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), + makeEvent(ADDR_C, 3000, SECONDS_PER_YEAR), // only C qualifies and claims + ]; + + 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)!; + + expect(referrerA.isAdminDisqualified).toBe(true); + expect(referrerA.adminDisqualificationReason).toBe("reason-a"); + expect(referrerA.isQualified).toBe(false); + expect(referrerA.awardPoolApproxValue.amount).toBe(0n); + + expect(referrerB.isAdminDisqualified).toBe(true); + expect(referrerB.adminDisqualificationReason).toBe("reason-b"); + expect(referrerB.isQualified).toBe(false); + expect(referrerB.awardPoolApproxValue.amount).toBe(0n); + + expect(referrerC.isAdminDisqualified).toBe(false); + expect(referrerC.adminDisqualificationReason).toBe(null); + expect(referrerC.isQualified).toBe(true); + expect(referrerC.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount); + }); + + it("duplicate address in disqualifications: buildReferralProgramRulesRevShareLimit throws", () => { + expect(() => + buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + { referrer: ADDR_A, reason: "first" }, + { referrer: ADDR_A, reason: "duplicate" }, + ]), + ).toThrow( + "ReferralProgramRulesRevShareLimit: disqualifications must not contain duplicate referrer addresses.", + ); + }); + }); }); diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts index d39ca656e..8a392ab93 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/leaderboard.ts @@ -24,6 +24,7 @@ import { import type { ReferralEvent } from "./referral-event"; import { BASE_REVENUE_CONTRIBUTION_PER_YEAR, + isReferrerQualifiedRevShareLimit, type ReferralProgramRulesRevShareLimit, } from "./rules"; @@ -137,7 +138,11 @@ export const buildReferrerLeaderboardRevShareLimit = ( BigInt(SECONDS_PER_YEAR); // Determine if newly qualifying or already qualified. - const isNowQualified = totalBaseRevenueAmount >= rules.minQualifiedRevenueContribution.amount; + const isNowQualified = isReferrerQualifiedRevShareLimit( + referrer, + priceUsdc(totalBaseRevenueAmount), + rules, + ); if (isNowQualified && !state.wasQualified) { // First time crossing the qualification threshold: claim all accumulated standard award. diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts index 4477e143b..b5d0d6571 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts @@ -61,7 +61,7 @@ export const buildReferrerMetricsRevShareLimit = ( }; /** - * Extends {@link ReferrerMetricsRevShareLimit} with rank and qualification status. + * Extends {@link ReferrerMetricsRevShareLimit} with rank, qualification status, and admin disqualification. */ export interface RankedReferrerMetricsRevShareLimit extends ReferrerMetricsRevShareLimit { /** @@ -72,9 +72,26 @@ export interface RankedReferrerMetricsRevShareLimit extends ReferrerMetricsRevSh /** * 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} + * @invariant true if and only if `totalBaseRevenueContribution` is greater than or equal to + * {@link ReferralProgramRulesRevShareLimit.minQualifiedRevenueContribution} AND + * {@link isAdminDisqualified} is false. */ isQualified: boolean; + + /** + * Whether this referrer has been admin-disqualified from the edition. + * + * @invariant When true, {@link isQualified} is false. + */ + isAdminDisqualified: boolean; + + /** + * The reason for admin disqualification, or null if not disqualified. + * + * @invariant null when {@link isAdminDisqualified} is false. + * @invariant Non-empty string when {@link isAdminDisqualified} is true. + */ + adminDisqualificationReason: string | null; } export const validateRankedReferrerMetricsRevShareLimit = ( @@ -85,6 +102,7 @@ export const validateRankedReferrerMetricsRevShareLimit = ( validateReferrerRank(metrics.rank); const expectedIsQualified = isReferrerQualifiedRevShareLimit( + metrics.referrer, metrics.totalBaseRevenueContribution, rules, ); @@ -93,6 +111,22 @@ export const validateRankedReferrerMetricsRevShareLimit = ( `RankedReferrerMetricsRevShareLimit: Invalid isQualified: ${metrics.isQualified}, expected: ${expectedIsQualified}.`, ); } + + const disqualification = + rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; + + if (metrics.isAdminDisqualified !== (disqualification !== null)) { + throw new Error( + `RankedReferrerMetricsRevShareLimit: Invalid isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${disqualification !== null}.`, + ); + } + + const expectedReason = disqualification?.reason ?? null; + if (metrics.adminDisqualificationReason !== expectedReason) { + throw new Error( + `RankedReferrerMetricsRevShareLimit: Invalid adminDisqualificationReason: ${metrics.adminDisqualificationReason}, expected: ${expectedReason}.`, + ); + } }; export const buildRankedReferrerMetricsRevShareLimit = ( @@ -100,10 +134,19 @@ export const buildRankedReferrerMetricsRevShareLimit = ( rank: ReferrerRank, rules: ReferralProgramRulesRevShareLimit, ): RankedReferrerMetricsRevShareLimit => { + const disqualification = + rules.disqualifications.find((d) => d.referrer === referrer.referrer) ?? null; + const result = { ...referrer, rank, - isQualified: isReferrerQualifiedRevShareLimit(referrer.totalBaseRevenueContribution, rules), + isQualified: isReferrerQualifiedRevShareLimit( + referrer.referrer, + referrer.totalBaseRevenueContribution, + rules, + ), + isAdminDisqualified: disqualification !== null, + adminDisqualificationReason: disqualification?.reason ?? null, } satisfies RankedReferrerMetricsRevShareLimit; validateRankedReferrerMetricsRevShareLimit(result, rules); @@ -131,6 +174,7 @@ export interface AwardedReferrerMetricsRevShareLimit extends RankedReferrerMetri * * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRulesRevShareLimit.totalAwardPoolValue.amount} (inclusive) * @invariant Always <= standardAwardValue.amount + * @invariant Amount equal to 0 when {@link isAdminDisqualified} is true. */ awardPoolApproxValue: PriceUsdc; } @@ -149,6 +193,12 @@ export const validateAwardedReferrerMetricsRevShareLimit = ( metrics.awardPoolApproxValue, ); + if (metrics.isAdminDisqualified && metrics.awardPoolApproxValue.amount !== 0n) { + throw new Error( + `AwardedReferrerMetricsRevShareLimit: awardPoolApproxValue.amount must be 0n for admin-disqualified referrers, got ${metrics.awardPoolApproxValue.amount.toString()}.`, + ); + } + if (metrics.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount) { throw new Error( `AwardedReferrerMetricsRevShareLimit: awardPoolApproxValue.amount ${metrics.awardPoolApproxValue.amount.toString()} exceeds totalAwardPoolValue.amount ${rules.totalAwardPoolValue.amount.toString()}.`, @@ -197,6 +247,7 @@ export interface UnrankedReferrerMetricsRevShareLimit export const validateUnrankedReferrerMetricsRevShareLimit = ( metrics: UnrankedReferrerMetricsRevShareLimit, + rules: ReferralProgramRulesRevShareLimit, ): void => { validateReferrerMetrics(metrics); @@ -210,6 +261,23 @@ export const validateUnrankedReferrerMetricsRevShareLimit = ( `Invalid UnrankedReferrerMetricsRevShareLimit: isQualified must be false, got: ${metrics.isQualified}.`, ); } + + const disqualification = + rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; + + if (metrics.isAdminDisqualified !== (disqualification !== null)) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${disqualification !== null}.`, + ); + } + + const expectedReason = disqualification?.reason ?? null; + if (metrics.adminDisqualificationReason !== expectedReason) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareLimit: adminDisqualificationReason: ${metrics.adminDisqualificationReason}, expected: ${expectedReason}.`, + ); + } + if (metrics.totalReferrals !== 0) { throw new Error( `Invalid UnrankedReferrerMetricsRevShareLimit: totalReferrals must be 0, got: ${metrics.totalReferrals}.`, @@ -263,9 +331,13 @@ export const validateUnrankedReferrerMetricsRevShareLimit = ( */ export const buildUnrankedReferrerMetricsRevShareLimit = ( referrer: Address, + rules: ReferralProgramRulesRevShareLimit, ): UnrankedReferrerMetricsRevShareLimit => { const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); + const disqualification = + rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; + const result = { ...metrics, totalBaseRevenueContribution: priceUsdc(0n), @@ -273,8 +345,10 @@ export const buildUnrankedReferrerMetricsRevShareLimit = ( isQualified: false, standardAwardValue: priceUsdc(0n), awardPoolApproxValue: priceUsdc(0n), + isAdminDisqualified: disqualification !== null, + adminDisqualificationReason: disqualification?.reason ?? null, } satisfies UnrankedReferrerMetricsRevShareLimit; - validateUnrankedReferrerMetricsRevShareLimit(result); + validateUnrankedReferrerMetricsRevShareLimit(result, rules); return result; }; diff --git a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts index 6a03f1da3..cafd25603 100644 --- a/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts +++ b/packages/ens-referrals/src/v1/award-models/rev-share-limit/rules.ts @@ -1,3 +1,5 @@ +import type { Address } from "viem"; + import { type AccountId, type PriceUsdc, @@ -6,12 +8,32 @@ import { } from "@ensnode/ensnode-sdk"; import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; +import { normalizeAddress, validateLowercaseAddress } from "../../address"; import { type BaseReferralProgramRules, ReferralProgramAwardModels, validateBaseReferralProgramRules, } from "../shared/rules"; +/** + * An admin-imposed disqualification entry of a specific referrer in an edition. + */ +export interface ReferralProgramEditionDisqualification { + /** + * The address of the disqualified referrer. + * + * @invariant Guaranteed to be a valid EVM address in lowercase format. + */ + referrer: Address; + + /** + * A human-readable explanation of why the referrer was disqualified. + * + * @invariant Must be a non-empty string. + */ + reason: string; +} + /** * Base revenue contribution per year of incremental duration. * @@ -46,6 +68,14 @@ export interface ReferralProgramRulesRevShareLimit extends BaseReferralProgramRu * @invariant Guaranteed to be a number between 0 and 1 (inclusive) */ qualifiedRevenueShare: number; + + /** + * Admin-imposed disqualifications for this edition. + * Disqualified referrers receive no awards. + * + * @invariant No duplicate referrer addresses. + */ + disqualifications: ReferralProgramEditionDisqualification[]; } export const validateReferralProgramRulesRevShareLimit = ( @@ -69,6 +99,23 @@ export const validateReferralProgramRulesRevShareLimit = ( ); } + for (const d of rules.disqualifications) { + validateLowercaseAddress(d.referrer); + if (d.reason.trim().length === 0) { + throw new Error( + "ReferralProgramRulesRevShareLimit: disqualification reason must not be empty.", + ); + } + } + + const disqualificationAddresses = rules.disqualifications.map((d) => d.referrer); + const uniqueDisqualificationAddresses = new Set(disqualificationAddresses); + if (uniqueDisqualificationAddresses.size !== disqualificationAddresses.length) { + throw new Error( + "ReferralProgramRulesRevShareLimit: disqualifications must not contain duplicate referrer addresses.", + ); + } + validateBaseReferralProgramRules(rules); }; @@ -80,6 +127,7 @@ export const buildReferralProgramRulesRevShareLimit = ( endTime: UnixTimestamp, subregistryId: AccountId, rulesUrl: URL, + disqualifications: ReferralProgramEditionDisqualification[] = [], ): ReferralProgramRulesRevShareLimit => { const result = { awardModel: ReferralProgramAwardModels.RevShareLimit, @@ -90,6 +138,7 @@ export const buildReferralProgramRulesRevShareLimit = ( endTime, subregistryId, rulesUrl, + disqualifications, } satisfies ReferralProgramRulesRevShareLimit; validateReferralProgramRulesRevShareLimit(result); @@ -98,14 +147,25 @@ export const buildReferralProgramRulesRevShareLimit = ( }; /** - * Determine if a referrer meets the revenue threshold to qualify under rev-share-limit rules. + * Determine if a referrer is qualified under rev-share-limit rules. * + * A referrer is qualified if they meet the revenue threshold AND are not admin-disqualified. + * + * @param referrer - The referrer's address. * @param totalBaseRevenueContribution - The referrer's total base revenue contribution. * @param rules - The rev-share-limit rules of the referral program. */ export function isReferrerQualifiedRevShareLimit( + referrer: Address, totalBaseRevenueContribution: PriceUsdc, rules: ReferralProgramRulesRevShareLimit, ): boolean { - return totalBaseRevenueContribution.amount >= rules.minQualifiedRevenueContribution.amount; + const normalizedReferrer = normalizeAddress(referrer); + const isAdminDisqualified = rules.disqualifications.some( + (d) => d.referrer === normalizedReferrer, + ); + return ( + totalBaseRevenueContribution.amount >= rules.minQualifiedRevenueContribution.amount && + !isAdminDisqualified + ); } diff --git a/packages/ens-referrals/src/v1/edition-defaults.ts b/packages/ens-referrals/src/v1/edition-defaults.ts index d50e66d93..e7e11f7ea 100644 --- a/packages/ens-referrals/src/v1/edition-defaults.ts +++ b/packages/ens-referrals/src/v1/edition-defaults.ts @@ -53,6 +53,7 @@ export function getDefaultReferralProgramEditionConfigSet( subregistryId, // TODO: replace this with the dedicated March 2026 rules URL once published new URL("https://ensawards.org/ens-holiday-awards-rules"), + [], ), }; diff --git a/packages/ens-referrals/src/v1/edition-metrics.ts b/packages/ens-referrals/src/v1/edition-metrics.ts index 88123f417..315c983d6 100644 --- a/packages/ens-referrals/src/v1/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -99,7 +99,7 @@ export const getReferrerEditionMetrics = ( awardModel: leaderboard.awardModel, type: ReferrerEditionMetricsTypeIds.Unranked, rules: leaderboard.rules, - referrer: buildUnrankedReferrerMetricsRevShareLimit(referrer), + referrer: buildUnrankedReferrerMetricsRevShareLimit(referrer, leaderboard.rules), aggregatedMetrics: leaderboard.aggregatedMetrics, status, accurateAsOf: leaderboard.accurateAsOf,