diff --git a/.changeset/sharp-wolves-march.md b/.changeset/sharp-wolves-march.md new file mode 100644 index 000000000..21811bb19 --- /dev/null +++ b/.changeset/sharp-wolves-march.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-referrals": minor +--- + +Generalize disqualifications into admin actions: introduce AdminAction discriminated union (Disqualification | Warning), rename `disqualifications` to `adminActions` in rev-share-cap rules, and replace `adminDisqualificationReason` with `adminAction` in referrer metrics. diff --git a/packages/ens-referrals/src/api/zod-schemas.test.ts b/packages/ens-referrals/src/api/zod-schemas.test.ts index f840856c0..26d51c616 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CurrencyIds, parseEth, parseUsdc } from "@ensnode/ensnode-sdk"; +import { AdminActionTypes } from "../award-models/rev-share-cap/rules"; import type { ReferrerEditionMetricsUnrecognized } from "../award-models/shared/edition-metrics"; import { ReferrerEditionMetricsTypeIds } from "../award-models/shared/edition-metrics"; import type { ReferralProgramEditionSummaryUnrecognized } from "../award-models/shared/edition-summary"; @@ -451,6 +452,40 @@ describe("makeReferrerEditionMetricsSchema", () => { minFinalScoreToQualify: 0, }; + const revShareCapReferrerAddress = "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"; + + const revShareCapRules = { + awardModel: ReferralProgramAwardModels.RevShareCap, + awardPool: parseUsdc("2000"), + minBaseRevenueContribution: parseUsdc("10"), + baseAnnualRevenueContribution: parseUsdc("5"), + maxBaseRevenueShare: 0.5, + startTime: 1000000, + endTime: 2000000, + subregistryId, + rulesUrl: "https://ensawards.org/rules", + areAwardsDistributed: false, + }; + + const revShareCapAggregatedMetrics = { + grandTotalReferrals: 3, + grandTotalIncrementalDuration: 60, + grandTotalRevenueContribution: parseEth("300"), + awardPoolRemaining: parseUsdc("1800"), + }; + + const disqualificationAction = { + actionType: AdminActionTypes.Disqualification, + referrer: revShareCapReferrerAddress, + reason: "Self-referral", + }; + + const warningAction = { + actionType: AdminActionTypes.Warning, + referrer: revShareCapReferrerAddress, + reason: "Suspicious activity", + }; + it("parses a known pie-split ranked edition metrics correctly", () => { const input = { awardModel: ReferralProgramAwardModels.PieSplit, @@ -515,20 +550,9 @@ describe("makeReferrerEditionMetricsSchema", () => { const input = { awardModel: ReferralProgramAwardModels.RevShareCap, type: ReferrerEditionMetricsTypeIds.Ranked, - rules: { - awardModel: ReferralProgramAwardModels.RevShareCap, - awardPool: parseUsdc("2000"), - minBaseRevenueContribution: parseUsdc("10"), - baseAnnualRevenueContribution: parseUsdc("5"), - maxBaseRevenueShare: 0.5, - startTime: 1000000, - endTime: 2000000, - subregistryId, - rulesUrl: "https://ensawards.org/rules", - areAwardsDistributed: false, - }, + rules: revShareCapRules, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: revShareCapReferrerAddress, totalReferrals: 3, totalIncrementalDuration: 60, totalRevenueContribution: parseEth("300"), @@ -537,15 +561,9 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - isAdminDisqualified: false, - adminDisqualificationReason: null, - }, - aggregatedMetrics: { - grandTotalReferrals: 3, - grandTotalIncrementalDuration: 60, - grandTotalRevenueContribution: parseEth("300"), - awardPoolRemaining: parseUsdc("1800"), + adminAction: null, }, + aggregatedMetrics: revShareCapAggregatedMetrics, status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -557,6 +575,127 @@ describe("makeReferrerEditionMetricsSchema", () => { expect(result.type).toBe(ReferrerEditionMetricsTypeIds.Ranked); }); + it("parses rev-share-cap ranked with Disqualification adminAction", () => { + const input = { + awardModel: ReferralProgramAwardModels.RevShareCap, + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: { ...revShareCapRules, adminActions: [disqualificationAction] }, + referrer: { + referrer: revShareCapReferrerAddress, + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 3, + isQualified: false, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("0"), + adminAction: disqualificationAction, + }, + aggregatedMetrics: { ...revShareCapAggregatedMetrics, awardPoolRemaining: parseUsdc("2000") }, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + const result = schema.parse(input); + expect(result.awardModel).toBe(ReferralProgramAwardModels.RevShareCap); + }); + + it("parses rev-share-cap ranked with Warning adminAction", () => { + const input = { + awardModel: ReferralProgramAwardModels.RevShareCap, + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: { ...revShareCapRules, adminActions: [warningAction] }, + referrer: { + referrer: revShareCapReferrerAddress, + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 1, + isQualified: true, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("200"), + adminAction: warningAction, + }, + aggregatedMetrics: revShareCapAggregatedMetrics, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + const result = schema.parse(input); + expect(result.awardModel).toBe(ReferralProgramAwardModels.RevShareCap); + }); + + it("fails when Disqualification adminAction has isQualified=true or non-zero cappedAward", () => { + const input = { + awardModel: ReferralProgramAwardModels.RevShareCap, + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: { ...revShareCapRules, adminActions: [disqualificationAction] }, + referrer: { + referrer: revShareCapReferrerAddress, + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 1, + isQualified: true, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("200"), + adminAction: disqualificationAction, + }, + aggregatedMetrics: revShareCapAggregatedMetrics, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + const result = schema.safeParse(input); + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual( + expect.objectContaining({ + path: ["referrer", "adminAction"], + message: expect.stringContaining( + "isQualified must be false and cappedAward.amount must be 0", + ), + }), + ); + }); + + it("fails when adminAction.referrer does not match outer referrer", () => { + const input = { + awardModel: ReferralProgramAwardModels.RevShareCap, + type: ReferrerEditionMetricsTypeIds.Ranked, + rules: { ...revShareCapRules, adminActions: [warningAction] }, + referrer: { + referrer: revShareCapReferrerAddress, + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 1, + isQualified: true, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("200"), + adminAction: { + ...warningAction, + referrer: "0x0000000000000000000000000000000000000001", + }, + }, + aggregatedMetrics: revShareCapAggregatedMetrics, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + const result = schema.safeParse(input); + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual( + expect.objectContaining({ + path: ["referrer", "adminAction", "referrer"], + message: expect.stringContaining("adminAction.referrer must match"), + }), + ); + }); + it("fails when a known awardModel has invalid fields", () => { const invalid = { awardModel: ReferralProgramAwardModels.PieSplit, diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/api/serialize.ts b/packages/ens-referrals/src/award-models/rev-share-cap/api/serialize.ts index bae96978f..b25d40d60 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/api/serialize.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/api/serialize.ts @@ -43,7 +43,7 @@ export function serializeReferralProgramRulesRevShareCap( subregistryId: rules.subregistryId, rulesUrl: rules.rulesUrl.toString(), areAwardsDistributed: rules.areAwardsDistributed, - disqualifications: rules.disqualifications, + adminActions: rules.adminActions, }; } @@ -77,8 +77,7 @@ export function serializeAwardedReferrerMetricsRevShareCap( isQualified: metrics.isQualified, uncappedAward: serializePriceUsdc(metrics.uncappedAward), cappedAward: serializePriceUsdc(metrics.cappedAward), - isAdminDisqualified: metrics.isAdminDisqualified, - adminDisqualificationReason: metrics.adminDisqualificationReason, + adminAction: metrics.adminAction, }; } @@ -98,8 +97,7 @@ export function serializeUnrankedReferrerMetricsRevShareCap( isQualified: metrics.isQualified, uncappedAward: serializePriceUsdc(metrics.uncappedAward), cappedAward: serializePriceUsdc(metrics.cappedAward), - isAdminDisqualified: metrics.isAdminDisqualified, - adminDisqualificationReason: metrics.adminDisqualificationReason, + adminAction: metrics.adminAction, }; } diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts b/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts index 57e0901f6..ac12320a9 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts @@ -1,3 +1,4 @@ +import type { NormalizedAddress } from "enssdk"; import z from "zod/v4"; import { @@ -19,18 +20,37 @@ import { } from "../../shared/api/zod-schemas"; import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; import { ReferralProgramAwardModels } from "../../shared/rules"; +import { type AdminAction, AdminActionTypes } from "../rules"; /** - * Schema for {@link ReferralProgramEditionDisqualification}. + * Schema for {@link AdminActionDisqualification}. */ -export const makeReferralProgramEditionDisqualificationSchema = ( - valueLabel = "ReferralProgramEditionDisqualification", -) => +export const makeAdminActionDisqualificationSchema = (valueLabel = "AdminActionDisqualification") => z.object({ + actionType: z.literal(AdminActionTypes.Disqualification), referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`), }); +/** + * Schema for {@link AdminActionWarning}. + */ +export const makeAdminActionWarningSchema = (valueLabel = "AdminActionWarning") => + z.object({ + actionType: z.literal(AdminActionTypes.Warning), + referrer: makeNormalizedAddressSchema(`${valueLabel}.referrer`), + reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`), + }); + +/** + * Schema for {@link AdminAction}. + */ +export const makeAdminActionSchema = (valueLabel = "AdminAction") => + z.discriminatedUnion("actionType", [ + makeAdminActionDisqualificationSchema(valueLabel), + makeAdminActionWarningSchema(valueLabel), + ]); + /** * Schema for {@link ReferralProgramRulesRevShareCap}. */ @@ -48,18 +68,16 @@ export const makeReferralProgramRulesRevShareCapSchema = ( 1, `${valueLabel}.maxBaseRevenueShare must be <= 1`, ), - disqualifications: z - .array( - makeReferralProgramEditionDisqualificationSchema(`${valueLabel}.disqualifications[item]`), - ) + adminActions: z + .array(makeAdminActionSchema(`${valueLabel}.adminActions[item]`)) // NOTE: addresses are already normalized, so string equivalence here is accurate .refine( (items) => { - const referrers = items.map((d) => d.referrer); + const referrers = items.map((a) => a.referrer); return new Set(referrers).size === referrers.length; }, { - message: `${valueLabel}.disqualifications must not contain duplicate referrer addresses`, + message: `${valueLabel}.adminActions must not contain duplicate referrer addresses`, }, ) .default([]), @@ -84,12 +102,7 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( isQualified: z.boolean(), uncappedAward: makePriceUsdcSchema(`${valueLabel}.uncappedAward`), cappedAward: makePriceUsdcSchema(`${valueLabel}.cappedAward`), - isAdminDisqualified: z.boolean(), - adminDisqualificationReason: z - .string() - .trim() - .min(1, `${valueLabel}.adminDisqualificationReason must not be empty`) - .nullable(), + adminAction: makeAdminActionSchema(`${valueLabel}.adminAction`).nullable(), }) .refine((data) => data.cappedAward.amount <= data.uncappedAward.amount, { message: `${valueLabel}.cappedAward must be <= ${valueLabel}.uncappedAward`, @@ -97,19 +110,20 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( }) .refine( (data) => - !data.isAdminDisqualified || (data.isQualified === false && data.cappedAward.amount === 0n), + data.adminAction?.actionType !== AdminActionTypes.Disqualification || + (data.isQualified === false && data.cappedAward.amount === 0n), { - message: `When ${valueLabel}.isAdminDisqualified is true, isQualified must be false and cappedAward.amount must be 0`, - path: ["isAdminDisqualified"], + message: `When ${valueLabel}.adminAction.actionType is Disqualification, isQualified must be false and cappedAward.amount must be 0`, + path: ["adminAction"], }, ) - .refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), { - message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`, - path: ["adminDisqualificationReason"], - }) .refine((data) => data.isQualified || data.cappedAward.amount === 0n, { message: `${valueLabel}.cappedAward must be 0 when isQualified is false`, path: ["cappedAward"], + }) + .refine((data) => data.adminAction === null || data.adminAction.referrer === data.referrer, { + message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, + path: ["adminAction", "referrer"], }); /** @@ -131,12 +145,7 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( isQualified: z.literal(false), uncappedAward: makePriceUsdcSchema(`${valueLabel}.uncappedAward`), cappedAward: makePriceUsdcSchema(`${valueLabel}.cappedAward`), - isAdminDisqualified: z.boolean(), - adminDisqualificationReason: z - .string() - .trim() - .min(1, `${valueLabel}.adminDisqualificationReason must not be empty`) - .nullable(), + adminAction: makeAdminActionSchema(`${valueLabel}.adminAction`).nullable(), }) .refine((data) => data.totalReferrals === 0, { message: `${valueLabel}.totalReferrals must be 0 for unranked referrers`, @@ -162,9 +171,9 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( message: `${valueLabel}.cappedAward must be 0 for unranked referrers`, path: ["cappedAward"], }) - .refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), { - message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`, - path: ["adminDisqualificationReason"], + .refine((data) => data.adminAction === null || data.adminAction.referrer === data.referrer, { + message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, + path: ["adminAction", "referrer"], }); /** @@ -184,6 +193,43 @@ export const makeAggregatedReferrerMetricsRevShareCapSchema = ( awardPoolRemaining: makePriceUsdcSchema(`${valueLabel}.awardPoolRemaining`), }); +/** + * Adds {@link z.RefinementCtx} issues when `metricsAdminAction` does not match the entry for + * `referrer` in `rulesAdminActions`. + */ +const addAdminActionConsistencyIssues = ( + ctx: z.RefinementCtx, + metricsAdminAction: AdminAction | null, + referrer: NormalizedAddress, + rulesAdminActions: AdminAction[], + path: (string | number)[], +): void => { + const expected = rulesAdminActions.find((a) => a.referrer === referrer) ?? null; + + if (expected === null && metricsAdminAction !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `adminAction expected null, got actionType="${metricsAdminAction.actionType}"`, + path, + }); + return; + } + + if ( + expected !== null && + (metricsAdminAction === null || + metricsAdminAction.actionType !== expected.actionType || + metricsAdminAction.referrer !== expected.referrer || + metricsAdminAction.reason !== expected.reason) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `adminAction does not match the corresponding entry in rules.adminActions`, + path, + }); + } +}; + /** * Schema for {@link ReferrerEditionMetricsRankedRevShareCap}. */ @@ -209,6 +255,15 @@ export const makeReferrerEditionMetricsRankedRevShareCapSchema = ( .refine((data) => data.referrer.cappedAward.amount <= data.rules.awardPool.amount, { message: `${valueLabel}.referrer.cappedAward must be <= ${valueLabel}.rules.awardPool`, path: ["referrer", "cappedAward", "amount"], + }) + .superRefine((data, ctx) => { + addAdminActionConsistencyIssues( + ctx, + data.referrer.adminAction, + data.referrer.referrer, + data.rules.adminActions, + ["referrer", "adminAction"], + ); }); /** @@ -232,6 +287,15 @@ export const makeReferrerEditionMetricsUnrankedRevShareCapSchema = ( .refine((data) => data.awardModel === data.rules.awardModel, { message: `${valueLabel}.awardModel must equal ${valueLabel}.rules.awardModel`, path: ["awardModel"], + }) + .superRefine((data, ctx) => { + addAdminActionConsistencyIssues( + ctx, + data.referrer.adminAction, + data.referrer.referrer, + data.rules.adminActions, + ["referrer", "adminAction"], + ); }); /** @@ -293,5 +357,12 @@ export const makeReferrerLeaderboardPageRevShareCapSchema = ( path: ["referrers", index, "cappedAward", "amount"], }); } + addAdminActionConsistencyIssues( + ctx, + referrer.adminAction, + referrer.referrer, + data.rules.adminActions, + ["referrers", index, "adminAction"], + ); }); }); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts index 0243c9741..8915aa5a6 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/leaderboard.test.ts @@ -1,3 +1,4 @@ +import type { NormalizedAddress } from "enssdk"; import { beforeEach, describe, expect, it } from "vitest"; import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode-sdk"; @@ -8,8 +9,7 @@ import { ReferralProgramEditionStatuses } from "../shared/status"; import { buildReferrerLeaderboardRevShareCap } from "./leaderboard"; import { buildLeaderboardPageRevShareCap } from "./leaderboard-page"; import type { ReferralEvent } from "./referral-event"; -import type { ReferralProgramEditionDisqualification } from "./rules"; -import { buildReferralProgramRulesRevShareCap } from "./rules"; +import { type AdminAction, AdminActionTypes, buildReferralProgramRulesRevShareCap } from "./rules"; // ─── Test fixtures ─────────────────────────────────────────────────────────── @@ -43,13 +43,13 @@ const CHECKPOINT_PREFIX = * * @param awardPool - USDC amount for the pool (default: $1000) * @param minBaseRevenueContribution - USDC threshold (default: $5 = 1 year) - * @param disqualifications - Admin disqualification list (default: none) + * @param adminActions - Admin actions list (default: none) * @param baseAnnualRevenueContribution - Base revenue per year (default: $5) */ function buildTestRules( awardPool = parseUsdc("1000"), minBaseRevenueContribution = parseUsdc("5"), - disqualifications: ReferralProgramEditionDisqualification[] = [], + adminActions: AdminAction[] = [], baseAnnualRevenueContribution = parseUsdc("5"), ) { return buildReferralProgramRulesRevShareCap( @@ -62,7 +62,7 @@ function buildTestRules( { chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" }, new URL("https://example.com/rules"), false, - disqualifications, + adminActions, ); } @@ -94,6 +94,14 @@ const accurateAsOf = parseTimestamp("2026-06-01T00:00:00Z"); /** $2.50 USDC in raw amount (uncapped award for 1 year of duration at 50% share) */ const UNCAPPED_AWARD_1Y = parseUsdc("2.5"); +function disqualification(referrer: NormalizedAddress, reason: string): AdminAction { + return { actionType: AdminActionTypes.Disqualification, referrer, reason }; +} + +function warning(referrer: NormalizedAddress, reason: string): AdminAction { + return { actionType: AdminActionTypes.Warning, referrer, reason }; +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe("buildReferrerLeaderboardRevShareCap", () => { @@ -417,7 +425,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const rules = buildTestRules( parseUsdc("1000"), // awardPool parseUsdc("10"), // minBaseRevenueContribution - [], // disqualifications + [], // adminActions parseUsdc("10"), // baseAnnualRevenueContribution ); const events = [ @@ -461,8 +469,8 @@ describe("buildReferrerLeaderboardRevShareCap", () => { }); }); - describe("Admin disqualifications", () => { - it("no disqualifications — qualified referrers receive awards normally", () => { + describe("Admin actions", () => { + it("no admin actions — qualified referrers receive awards normally", () => { const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), []); const events = [ makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), @@ -474,20 +482,18 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerB = result.referrers.get(ADDR_B)!; expect(referrerA.isQualified).toBe(true); - expect(referrerA.isAdminDisqualified).toBe(false); - expect(referrerA.adminDisqualificationReason).toBe(null); + expect(referrerA.adminAction).toBe(null); expect(referrerA.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); expect(referrerB.isQualified).toBe(true); - expect(referrerB.isAdminDisqualified).toBe(false); - expect(referrerB.adminDisqualificationReason).toBe(null); + expect(referrerB.adminAction).toBe(null); }); it("disqualified referrer who met threshold: cappedAward = 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" }, + disqualification(ADDR_A, "self-referral"), ]); const events = [ makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), // would qualify, but disqualified @@ -498,29 +504,28 @@ describe("buildReferrerLeaderboardRevShareCap", () => { 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.adminAction).toEqual(disqualification(ADDR_A, "self-referral")); expect(referrerA.isQualified).toBe(false); expect(referrerA.cappedAward.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.adminAction).toBe(null); expect(referrerB.cappedAward.amount).toBe(UNCAPPED_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" }, + disqualification(ADDR_A, "promoting discounts"), ]); const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); const referrerA = result.referrers.get(ADDR_A)!; - expect(referrerA.isAdminDisqualified).toBe(true); - expect(referrerA.adminDisqualificationReason).toBe("promoting discounts"); + expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); + expect(referrerA.adminAction?.reason).toBe("promoting discounts"); expect(referrerA.isQualified).toBe(false); expect(referrerA.cappedAward.amount).toBe(0n); // Pool fully intact @@ -537,7 +542,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { // 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" }, + disqualification(ADDR_A, "cheating"), ]); const events = [ makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR * 2), @@ -552,25 +557,25 @@ describe("buildReferrerLeaderboardRevShareCap", () => { expect(referrerB.rank).toBe(1); expect(referrerB.isQualified).toBe(true); - expect(referrerB.isAdminDisqualified).toBe(false); + expect(referrerB.adminAction).toBe(null); expect(referrerB.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); expect(referrerA.rank).toBe(2); - expect(referrerA.isAdminDisqualified).toBe(true); - expect(referrerA.adminDisqualificationReason).toBe("cheating"); + expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); + expect(referrerA.adminAction?.reason).toBe("cheating"); expect(referrerA.isQualified).toBe(false); expect(referrerA.cappedAward.amount).toBe(0n); expect(referrerC.rank).toBe(3); expect(referrerC.isQualified).toBe(false); - expect(referrerC.isAdminDisqualified).toBe(false); + expect(referrerC.adminAction).toBe(null); expect(referrerC.cappedAward.amount).toBe(0n); }); - it("multiple disqualifications: all disqualified referrers get isAdminDisqualified=true", () => { + it("multiple admin actions: all disqualified referrers have Disqualification adminAction", () => { const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ - { referrer: ADDR_A, reason: "reason-a" }, - { referrer: ADDR_B, reason: "reason-b" }, + disqualification(ADDR_A, "reason-a"), + disqualification(ADDR_B, "reason-b"), ]); const events = [ makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), @@ -583,31 +588,66 @@ describe("buildReferrerLeaderboardRevShareCap", () => { 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.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); + expect(referrerA.adminAction?.reason).toBe("reason-a"); expect(referrerA.isQualified).toBe(false); expect(referrerA.cappedAward.amount).toBe(0n); - expect(referrerB.isAdminDisqualified).toBe(true); - expect(referrerB.adminDisqualificationReason).toBe("reason-b"); + expect(referrerB.adminAction?.actionType).toBe(AdminActionTypes.Disqualification); + expect(referrerB.adminAction?.reason).toBe("reason-b"); expect(referrerB.isQualified).toBe(false); expect(referrerB.cappedAward.amount).toBe(0n); - expect(referrerC.isAdminDisqualified).toBe(false); - expect(referrerC.adminDisqualificationReason).toBe(null); + expect(referrerC.adminAction).toBe(null); expect(referrerC.isQualified).toBe(true); expect(referrerC.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); }); - it("duplicate address in disqualifications: buildReferralProgramRulesRevShareCap throws", () => { + it("duplicate address in adminActions: buildReferralProgramRulesRevShareCap throws", () => { expect(() => buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ - { referrer: ADDR_A, reason: "first" }, - { referrer: ADDR_A, reason: "duplicate" }, + disqualification(ADDR_A, "first"), + warning(ADDR_A, "duplicate"), ]), ).toThrow( - "ReferralProgramRulesRevShareCap: disqualifications must not contain duplicate referrer addresses.", + "ReferralProgramRulesRevShareCap: adminActions must not contain duplicate referrer addresses.", ); }); + + it("warned referrer still qualifies and receives awards", () => { + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + warning(ADDR_A, "suspicious activity"), + ]); + const events = [ + makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), + makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), + ]; + + const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + const referrerB = result.referrers.get(ADDR_B)!; + + // Warned referrer is NOT disqualified — they still qualify and get awards + expect(referrerA.adminAction).toEqual(warning(ADDR_A, "suspicious activity")); + expect(referrerA.isQualified).toBe(true); + expect(referrerA.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); + + expect(referrerB.isQualified).toBe(true); + expect(referrerB.adminAction).toBe(null); + }); + + it("warned referrer who is below threshold: warning present but not qualified", () => { + const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ + warning(ADDR_A, "under review"), + ]); + const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))]; + + const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); + const referrerA = result.referrers.get(ADDR_A)!; + + expect(referrerA.adminAction?.actionType).toBe(AdminActionTypes.Warning); + expect(referrerA.isQualified).toBe(false); // below threshold, not because of warning + expect(referrerA.cappedAward.amount).toBe(0n); + }); }); }); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts b/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts index 146c3c594..8df89bd34 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts @@ -8,7 +8,12 @@ import { buildReferrerMetrics, validateReferrerMetrics } from "../../referrer-me import { SECONDS_PER_YEAR } from "../../time"; import type { ReferrerRank } from "../shared/rank"; import { validateReferrerRank } from "../shared/rank"; -import { isReferrerQualifiedRevShareCap, type ReferralProgramRulesRevShareCap } from "./rules"; +import { + type AdminAction, + AdminActionTypes, + isReferrerQualifiedRevShareCap, + type ReferralProgramRulesRevShareCap, +} from "./rules"; /** * Extends {@link ReferrerMetrics} with computed base revenue contribution. @@ -81,26 +86,50 @@ export interface RankedReferrerMetricsRevShareCap extends ReferrerMetricsRevShar * * @invariant true if and only if `totalBaseRevenueContribution` is greater than or equal to * {@link ReferralProgramRulesRevShareCap.minBaseRevenueContribution} AND - * {@link isAdminDisqualified} is false. + * {@link adminAction} does not have `actionType` of {@link AdminActionTypes.Disqualification}. */ isQualified: boolean; /** - * Whether this referrer has been admin-disqualified from the edition. + * The admin action taken on this referrer, or null if no admin action has been taken. * - * @invariant When true, {@link isQualified} is false. + * @invariant null when no admin action has been taken on this referrer. + * @invariant Must match the corresponding entry in {@link ReferralProgramRulesRevShareCap.adminActions}. */ - 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; + adminAction: AdminAction | null; } +/** + * Validates that `metricsAdminAction` matches the admin action (or absence thereof) recorded for + * `referrer` in `rules.adminActions`. Errors are prefixed with `context` so callers can preserve + * their existing message format. + */ +const validateAdminActionConsistency = ( + metricsAdminAction: AdminAction | null, + referrer: NormalizedAddress, + rules: ReferralProgramRulesRevShareCap, + context: string, +): void => { + const expected = rules.adminActions.find((a) => a.referrer === referrer) ?? null; + + if (expected === null && metricsAdminAction !== null) { + throw new Error( + `${context}: expected null, got actionType="${metricsAdminAction.actionType}".`, + ); + } + + if (expected !== null) { + if ( + metricsAdminAction === null || + metricsAdminAction.actionType !== expected.actionType || + metricsAdminAction.referrer !== expected.referrer || + metricsAdminAction.reason !== expected.reason + ) { + throw new Error(`${context}: does not match expected action from rules.`); + } + } +}; + export const validateRankedReferrerMetricsRevShareCap = ( metrics: RankedReferrerMetricsRevShareCap, rules: ReferralProgramRulesRevShareCap, @@ -119,21 +148,12 @@ export const validateRankedReferrerMetricsRevShareCap = ( ); } - const disqualification = - rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; - - if (metrics.isAdminDisqualified !== (disqualification !== null)) { - throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${disqualification !== null}.`, - ); - } - - const expectedReason = disqualification?.reason ?? null; - if (metrics.adminDisqualificationReason !== expectedReason) { - throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid adminDisqualificationReason: ${metrics.adminDisqualificationReason}, expected: ${expectedReason}.`, - ); - } + validateAdminActionConsistency( + metrics.adminAction, + metrics.referrer, + rules, + "RankedReferrerMetricsRevShareCap: Invalid adminAction", + ); }; export const buildRankedReferrerMetricsRevShareCap = ( @@ -141,8 +161,7 @@ export const buildRankedReferrerMetricsRevShareCap = ( rank: ReferrerRank, rules: ReferralProgramRulesRevShareCap, ): RankedReferrerMetricsRevShareCap => { - const disqualification = - rules.disqualifications.find((d) => d.referrer === referrer.referrer) ?? null; + const adminAction = rules.adminActions.find((a) => a.referrer === referrer.referrer) ?? null; const result = { ...referrer, @@ -152,8 +171,7 @@ export const buildRankedReferrerMetricsRevShareCap = ( referrer.totalBaseRevenueContribution, rules, ), - isAdminDisqualified: disqualification !== null, - adminDisqualificationReason: disqualification?.reason ?? null, + adminAction, } satisfies RankedReferrerMetricsRevShareCap; validateRankedReferrerMetricsRevShareCap(result, rules); @@ -181,7 +199,7 @@ export interface AwardedReferrerMetricsRevShareCap extends RankedReferrerMetrics * * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRulesRevShareCap.awardPool.amount} (inclusive) * @invariant Always <= uncappedAward.amount - * @invariant Amount equal to 0 when {@link isAdminDisqualified} is true. + * @invariant Amount equal to 0 when {@link adminAction} has `actionType` of {@link AdminActionTypes.Disqualification}. * @invariant Amount equal to 0 when {@link isQualified} is false. */ cappedAward: PriceUsdc; @@ -199,7 +217,10 @@ export const validateAwardedReferrerMetricsRevShareCap = ( makePriceUsdcSchema("AwardedReferrerMetricsRevShareCap.cappedAward").parse(metrics.cappedAward); - if (metrics.isAdminDisqualified && metrics.cappedAward.amount !== 0n) { + if ( + metrics.adminAction?.actionType === AdminActionTypes.Disqualification && + metrics.cappedAward.amount !== 0n + ) { throw new Error( `AwardedReferrerMetricsRevShareCap: cappedAward.amount must be 0n for admin-disqualified referrers, got ${metrics.cappedAward.amount.toString()}.`, ); @@ -274,21 +295,12 @@ export const validateUnrankedReferrerMetricsRevShareCap = ( ); } - const disqualification = - rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; - - if (metrics.isAdminDisqualified !== (disqualification !== null)) { - throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${disqualification !== null}.`, - ); - } - - const expectedReason = disqualification?.reason ?? null; - if (metrics.adminDisqualificationReason !== expectedReason) { - throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: adminDisqualificationReason: ${metrics.adminDisqualificationReason}, expected: ${expectedReason}.`, - ); - } + validateAdminActionConsistency( + metrics.adminAction, + metrics.referrer, + rules, + "Invalid UnrankedReferrerMetricsRevShareCap: adminAction", + ); if (metrics.totalReferrals !== 0) { throw new Error( @@ -345,8 +357,7 @@ export const buildUnrankedReferrerMetricsRevShareCap = ( ): UnrankedReferrerMetricsRevShareCap => { const metrics = buildReferrerMetrics(referrer, 0, 0, priceEth(0n)); - const disqualification = - rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; + const adminAction = rules.adminActions.find((a) => a.referrer === metrics.referrer) ?? null; const result = { ...metrics, @@ -355,8 +366,7 @@ export const buildUnrankedReferrerMetricsRevShareCap = ( isQualified: false, uncappedAward: priceUsdc(0n), cappedAward: priceUsdc(0n), - isAdminDisqualified: disqualification !== null, - adminDisqualificationReason: disqualification?.reason ?? null, + adminAction, } satisfies UnrankedReferrerMetricsRevShareCap; validateUnrankedReferrerMetricsRevShareCap(result, rules); diff --git a/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts b/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts index d78e34d34..a84287d28 100644 --- a/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts +++ b/packages/ens-referrals/src/award-models/rev-share-cap/rules.ts @@ -11,22 +11,66 @@ import { } from "../shared/rules"; /** - * An admin-imposed disqualification entry of a specific referrer in an edition. + * The types of admin actions that can be taken upon a referrer in a rev-share-cap edition. */ -export interface ReferralProgramEditionDisqualification { +export const AdminActionTypes = { /** - * The Ethereum address of the disqualified referrer, as a {@link NormalizedAddress}. + * The referrer is disqualified for awards. + */ + Disqualification: "Disqualification", + + /** + * The referrer is warned about a potential disqualification but may still be qualified for awards. + */ + Warning: "Warning", +} as const; + +export type AdminActionType = (typeof AdminActionTypes)[keyof typeof AdminActionTypes]; + +/** + * An admin action to disqualify a referrer from receiving awards for an edition. + */ +export interface AdminActionDisqualification { + actionType: typeof AdminActionTypes.Disqualification; + + /** + * The Ethereum address of the affected referrer, as a {@link NormalizedAddress}. */ referrer: NormalizedAddress; /** - * A human-readable explanation of why the referrer was disqualified. + * A short message explaining the disqualification. * - * @invariant Must be a non-empty string. + * @invariant Must be a trimmed, non-empty string. */ reason: string; } +/** + * An admin action to warn a referrer that their eligibility for receiving awards for an edition + * is at risk unless the referrer takes corrective actions. + */ +export interface AdminActionWarning { + actionType: typeof AdminActionTypes.Warning; + + /** + * The Ethereum address of the affected referrer, as a {@link NormalizedAddress}. + */ + referrer: NormalizedAddress; + + /** + * A short message explaining the warning. + * + * @invariant Must be a trimmed, non-empty string. + */ + reason: string; +} + +/** + * A discriminated union of all admin action types. + */ +export type AdminAction = AdminActionDisqualification | AdminActionWarning; + export interface ReferralProgramRulesRevShareCap extends BaseReferralProgramRules { /** * Discriminant: identifies this as a "rev-share-cap" award model edition. @@ -64,12 +108,11 @@ export interface ReferralProgramRulesRevShareCap extends BaseReferralProgramRule maxBaseRevenueShare: number; /** - * Admin-imposed disqualifications for this edition. - * Disqualified referrers receive no awards. + * Admin actions for this edition. * - * @invariant No duplicate referrer addresses. + * @invariant No duplicate referrer addresses (a referrer can have at most one admin action). */ - disqualifications: ReferralProgramEditionDisqualification[]; + adminActions: AdminAction[]; } export const validateReferralProgramRulesRevShareCap = ( @@ -101,20 +144,20 @@ export const validateReferralProgramRulesRevShareCap = ( ); } - for (const d of rules.disqualifications) { - validateNormalizedAddress(d.referrer); - if (d.reason.trim().length === 0) { + for (const action of rules.adminActions) { + validateNormalizedAddress(action.referrer); + if (action.reason.trim().length === 0 || action.reason !== action.reason.trim()) { throw new Error( - "ReferralProgramRulesRevShareCap: disqualification reason must not be empty.", + "ReferralProgramRulesRevShareCap: admin action reason must be a trimmed, non-empty string.", ); } } - const disqualificationAddresses = rules.disqualifications.map((d) => d.referrer); - const uniqueDisqualificationAddresses = new Set(disqualificationAddresses); - if (uniqueDisqualificationAddresses.size !== disqualificationAddresses.length) { + const adminActionAddresses = rules.adminActions.map((a) => a.referrer); + const uniqueAdminActionAddresses = new Set(adminActionAddresses); + if (uniqueAdminActionAddresses.size !== adminActionAddresses.length) { throw new Error( - "ReferralProgramRulesRevShareCap: disqualifications must not contain duplicate referrer addresses.", + "ReferralProgramRulesRevShareCap: adminActions must not contain duplicate referrer addresses.", ); } @@ -131,7 +174,7 @@ export const buildReferralProgramRulesRevShareCap = ( subregistryId: AccountId, rulesUrl: URL, areAwardsDistributed: boolean, - disqualifications: ReferralProgramEditionDisqualification[] = [], + adminActions: AdminAction[] = [], ): ReferralProgramRulesRevShareCap => { const result = { awardModel: ReferralProgramAwardModels.RevShareCap, @@ -144,7 +187,7 @@ export const buildReferralProgramRulesRevShareCap = ( subregistryId, rulesUrl, areAwardsDistributed, - disqualifications, + adminActions, } satisfies ReferralProgramRulesRevShareCap; validateReferralProgramRulesRevShareCap(result); @@ -166,7 +209,9 @@ export function isReferrerQualifiedRevShareCap( totalBaseRevenueContribution: PriceUsdc, rules: ReferralProgramRulesRevShareCap, ): boolean { - const isAdminDisqualified = rules.disqualifications.some((d) => d.referrer === referrer); + const isAdminDisqualified = rules.adminActions.some( + (a) => a.referrer === referrer && a.actionType === AdminActionTypes.Disqualification, + ); return ( totalBaseRevenueContribution.amount >= rules.minBaseRevenueContribution.amount && !isAdminDisqualified