From cca4e636229a8f09b694035140566a466a54d220 Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 16 Apr 2026 18:07:37 +0200 Subject: [PATCH 1/6] introducing admin actions --- .changeset/sharp-wolves-march.md | 5 + .../ens-referrals/src/api/zod-schemas.test.ts | 2 +- .../rev-share-cap/api/serialize.ts | 6 +- .../rev-share-cap/api/zod-schemas.ts | 75 ++++++++------ .../rev-share-cap/leaderboard.test.ts | 97 ++++++++++++++----- .../src/award-models/rev-share-cap/metrics.ts | 86 ++++++++++------ .../src/award-models/rev-share-cap/rules.ts | 87 +++++++++++++---- 7 files changed, 253 insertions(+), 105 deletions(-) create mode 100644 .changeset/sharp-wolves-march.md 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..9ebf5feca 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -538,7 +538,7 @@ describe("makeReferrerEditionMetricsSchema", () => { uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), isAdminDisqualified: false, - adminDisqualificationReason: null, + adminAction: null, }, aggregatedMetrics: { grandTotalReferrals: 3, 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..0ba725cdc 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, }; } @@ -78,7 +78,7 @@ export function serializeAwardedReferrerMetricsRevShareCap( uncappedAward: serializePriceUsdc(metrics.uncappedAward), cappedAward: serializePriceUsdc(metrics.cappedAward), isAdminDisqualified: metrics.isAdminDisqualified, - adminDisqualificationReason: metrics.adminDisqualificationReason, + adminAction: metrics.adminAction, }; } @@ -99,7 +99,7 @@ export function serializeUnrankedReferrerMetricsRevShareCap( 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..44f9db259 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 @@ -19,18 +19,37 @@ import { } from "../../shared/api/zod-schemas"; import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; import { ReferralProgramAwardModels } from "../../shared/rules"; +import { 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 +67,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([]), @@ -85,11 +102,7 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( 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`, @@ -103,10 +116,15 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( path: ["isAdminDisqualified"], }, ) - .refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), { - message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`, - path: ["adminDisqualificationReason"], - }) + .refine( + (data) => + data.isAdminDisqualified === + (data.adminAction?.actionType === AdminActionTypes.Disqualification), + { + message: `${valueLabel}.isAdminDisqualified must be true iff adminAction.actionType is Disqualification`, + path: ["isAdminDisqualified"], + }, + ) .refine((data) => data.isQualified || data.cappedAward.amount === 0n, { message: `${valueLabel}.cappedAward must be 0 when isQualified is false`, path: ["cappedAward"], @@ -132,11 +150,7 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( 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,10 +176,15 @@ 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.isAdminDisqualified === + (data.adminAction?.actionType === AdminActionTypes.Disqualification), + { + message: `${valueLabel}.isAdminDisqualified must be true iff adminAction.actionType is Disqualification`, + path: ["isAdminDisqualified"], + }, + ); /** * Schema for {@link AggregatedReferrerMetricsRevShareCap}. 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..9a29d7727 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), @@ -475,19 +483,19 @@ describe("buildReferrerLeaderboardRevShareCap", () => { 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 @@ -499,7 +507,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { 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); @@ -512,7 +520,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { 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))]; @@ -520,7 +528,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerA = result.referrers.get(ADDR_A)!; expect(referrerA.isAdminDisqualified).toBe(true); - expect(referrerA.adminDisqualificationReason).toBe("promoting discounts"); + expect(referrerA.adminAction?.reason).toBe("promoting discounts"); expect(referrerA.isQualified).toBe(false); expect(referrerA.cappedAward.amount).toBe(0n); // Pool fully intact @@ -537,7 +545,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), @@ -557,7 +565,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { expect(referrerA.rank).toBe(2); expect(referrerA.isAdminDisqualified).toBe(true); - expect(referrerA.adminDisqualificationReason).toBe("cheating"); + expect(referrerA.adminAction?.reason).toBe("cheating"); expect(referrerA.isQualified).toBe(false); expect(referrerA.cappedAward.amount).toBe(0n); @@ -569,8 +577,8 @@ describe("buildReferrerLeaderboardRevShareCap", () => { 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" }, + disqualification(ADDR_A, "reason-a"), + disqualification(ADDR_B, "reason-b"), ]); const events = [ makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), @@ -584,30 +592,69 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerC = result.referrers.get(ADDR_C)!; expect(referrerA.isAdminDisqualified).toBe(true); - expect(referrerA.adminDisqualificationReason).toBe("reason-a"); + 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?.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.isAdminDisqualified).toBe(false); + 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.isAdminDisqualified).toBe(false); + 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.isAdminDisqualified).toBe(false); + 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..140389d78 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. @@ -89,16 +94,17 @@ export interface RankedReferrerMetricsRevShareCap extends ReferrerMetricsRevShar * Whether this referrer has been admin-disqualified from the edition. * * @invariant When true, {@link isQualified} is false. + * @invariant true if and only if {@link adminAction} has `actionType === "Disqualification"`. */ isAdminDisqualified: boolean; /** - * The reason for admin disqualification, or null if not disqualified. + * The admin action imposed on this referrer, or null if no action exists. * - * @invariant null when {@link isAdminDisqualified} is false. - * @invariant Non-empty string when {@link isAdminDisqualified} is true. + * @invariant null when no admin action exists for this referrer. + * @invariant Must match the corresponding entry in {@link ReferralProgramRulesRevShareCap.adminActions}. */ - adminDisqualificationReason: string | null; + adminAction: AdminAction | null; } export const validateRankedReferrerMetricsRevShareCap = ( @@ -119,19 +125,33 @@ export const validateRankedReferrerMetricsRevShareCap = ( ); } - const disqualification = - rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; + const expectedAdminAction = + rules.adminActions.find((a) => a.referrer === metrics.referrer) ?? null; - if (metrics.isAdminDisqualified !== (disqualification !== null)) { + if (expectedAdminAction === null && metrics.adminAction !== null) { throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${disqualification !== null}.`, + `RankedReferrerMetricsRevShareCap: Invalid adminAction: expected null, got actionType="${metrics.adminAction.actionType}".`, ); } - const expectedReason = disqualification?.reason ?? null; - if (metrics.adminDisqualificationReason !== expectedReason) { + if (expectedAdminAction !== null) { + if ( + metrics.adminAction === null || + metrics.adminAction.actionType !== expectedAdminAction.actionType || + metrics.adminAction.referrer !== expectedAdminAction.referrer || + metrics.adminAction.reason !== expectedAdminAction.reason + ) { + throw new Error( + `RankedReferrerMetricsRevShareCap: Invalid adminAction: does not match expected action from rules.`, + ); + } + } + + const expectedIsAdminDisqualified = + metrics.adminAction?.actionType === AdminActionTypes.Disqualification; + if (metrics.isAdminDisqualified !== expectedIsAdminDisqualified) { throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid adminDisqualificationReason: ${metrics.adminDisqualificationReason}, expected: ${expectedReason}.`, + `RankedReferrerMetricsRevShareCap: Invalid isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${expectedIsAdminDisqualified}.`, ); } }; @@ -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,8 @@ export const buildRankedReferrerMetricsRevShareCap = ( referrer.totalBaseRevenueContribution, rules, ), - isAdminDisqualified: disqualification !== null, - adminDisqualificationReason: disqualification?.reason ?? null, + isAdminDisqualified: adminAction?.actionType === AdminActionTypes.Disqualification, + adminAction, } satisfies RankedReferrerMetricsRevShareCap; validateRankedReferrerMetricsRevShareCap(result, rules); @@ -274,19 +293,33 @@ export const validateUnrankedReferrerMetricsRevShareCap = ( ); } - const disqualification = - rules.disqualifications.find((d) => d.referrer === metrics.referrer) ?? null; + const expectedAdminAction = + rules.adminActions.find((a) => a.referrer === metrics.referrer) ?? null; - if (metrics.isAdminDisqualified !== (disqualification !== null)) { + if (expectedAdminAction === null && metrics.adminAction !== null) { throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${disqualification !== null}.`, + `Invalid UnrankedReferrerMetricsRevShareCap: adminAction: expected null, got actionType="${metrics.adminAction.actionType}".`, ); } - const expectedReason = disqualification?.reason ?? null; - if (metrics.adminDisqualificationReason !== expectedReason) { + if (expectedAdminAction !== null) { + if ( + metrics.adminAction === null || + metrics.adminAction.actionType !== expectedAdminAction.actionType || + metrics.adminAction.referrer !== expectedAdminAction.referrer || + metrics.adminAction.reason !== expectedAdminAction.reason + ) { + throw new Error( + `Invalid UnrankedReferrerMetricsRevShareCap: adminAction does not match expected action from rules.`, + ); + } + } + + const expectedIsAdminDisqualified = + metrics.adminAction?.actionType === AdminActionTypes.Disqualification; + if (metrics.isAdminDisqualified !== expectedIsAdminDisqualified) { throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: adminDisqualificationReason: ${metrics.adminDisqualificationReason}, expected: ${expectedReason}.`, + `Invalid UnrankedReferrerMetricsRevShareCap: isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${expectedIsAdminDisqualified}.`, ); } @@ -345,8 +378,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 +387,8 @@ export const buildUnrankedReferrerMetricsRevShareCap = ( isQualified: false, uncappedAward: priceUsdc(0n), cappedAward: priceUsdc(0n), - isAdminDisqualified: disqualification !== null, - adminDisqualificationReason: disqualification?.reason ?? null, + isAdminDisqualified: adminAction?.actionType === AdminActionTypes.Disqualification, + 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..f0ec4eebc 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,67 @@ import { } from "../shared/rules"; /** - * An admin-imposed disqualification entry of a specific referrer in an edition. + * The types of admin actions that can be imposed on 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 ineligible for awards. + */ + Disqualification: "Disqualification", + + /** + * The referrer is flagged but still eligible for awards. + */ + Warning: "Warning", +} as const; + +export type AdminActionType = (typeof AdminActionTypes)[keyof typeof AdminActionTypes]; + +/** + * An admin-imposed disqualification of a specific referrer in an edition. + * Disqualified referrers receive no awards. + */ +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 action was imposed. + * + * @invariant Must be a non-empty string. + */ + reason: string; +} + +/** + * An admin-imposed warning for a specific referrer in an edition. + * Warned referrers are still eligible for awards. + */ +export interface AdminActionWarning { + actionType: typeof AdminActionTypes.Warning; + + /** + * The Ethereum address of the affected referrer, as a {@link NormalizedAddress}. */ referrer: NormalizedAddress; /** - * A human-readable explanation of why the referrer was disqualified. + * A human-readable explanation of why the action was imposed. * * @invariant Must be a 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 +109,12 @@ export interface ReferralProgramRulesRevShareCap extends BaseReferralProgramRule maxBaseRevenueShare: number; /** - * Admin-imposed disqualifications for this edition. - * Disqualified referrers receive no awards. + * Admin-imposed actions for this edition. + * Disqualified referrers receive no awards. Warned referrers are still eligible. * - * @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 +146,18 @@ export const validateReferralProgramRulesRevShareCap = ( ); } - for (const d of rules.disqualifications) { - validateNormalizedAddress(d.referrer); - if (d.reason.trim().length === 0) { - throw new Error( - "ReferralProgramRulesRevShareCap: disqualification reason must not be empty.", - ); + for (const action of rules.adminActions) { + validateNormalizedAddress(action.referrer); + if (action.reason.trim().length === 0) { + throw new Error("ReferralProgramRulesRevShareCap: admin action reason must not be empty."); } } - 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 From 017e6a5d76b98065093388b5a1314e03cb90cb39 Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 16 Apr 2026 20:20:25 +0200 Subject: [PATCH 2/6] review, enforcing trimmed reason --- .../src/award-models/rev-share-cap/leaderboard.test.ts | 2 +- .../src/award-models/rev-share-cap/rules.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) 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 9a29d7727..0e317845b 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 @@ -575,7 +575,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { expect(referrerC.cappedAward.amount).toBe(0n); }); - it("multiple disqualifications: all disqualified referrers get isAdminDisqualified=true", () => { + it("multiple admin actions: all disqualified referrers get isAdminDisqualified=true", () => { const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ disqualification(ADDR_A, "reason-a"), disqualification(ADDR_B, "reason-b"), 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 f0ec4eebc..6aff88878 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 @@ -42,7 +42,7 @@ export interface AdminActionDisqualification { /** * A human-readable explanation of why the action was imposed. * - * @invariant Must be a non-empty string. + * @invariant Must be a trimmed, non-empty string. */ reason: string; } @@ -62,7 +62,7 @@ export interface AdminActionWarning { /** * A human-readable explanation of why the action was imposed. * - * @invariant Must be a non-empty string. + * @invariant Must be a trimmed, non-empty string. */ reason: string; } @@ -148,8 +148,10 @@ export const validateReferralProgramRulesRevShareCap = ( for (const action of rules.adminActions) { validateNormalizedAddress(action.referrer); - if (action.reason.trim().length === 0) { - throw new Error("ReferralProgramRulesRevShareCap: admin action reason must not be empty."); + if (action.reason.trim().length === 0 || action.reason !== action.reason.trim()) { + throw new Error( + "ReferralProgramRulesRevShareCap: admin action reason must be a trimmed, non-empty string.", + ); } } From d6a00e1acc7cd7182b22e69c51dc0facdff21cbd Mon Sep 17 00:00:00 2001 From: Goader Date: Thu, 16 Apr 2026 20:35:47 +0200 Subject: [PATCH 3/6] better zod schemas --- .../ens-referrals/src/api/zod-schemas.test.ts | 201 ++++++++++++++++++ .../rev-share-cap/api/zod-schemas.ts | 10 +- 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/packages/ens-referrals/src/api/zod-schemas.test.ts b/packages/ens-referrals/src/api/zod-schemas.test.ts index 9ebf5feca..393045f37 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"; @@ -557,6 +558,206 @@ 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: { + 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, + adminActions: [ + { + actionType: AdminActionTypes.Disqualification, + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + reason: "Self-referral", + }, + ], + }, + referrer: { + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 3, + isQualified: false, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("0"), + isAdminDisqualified: true, + adminAction: { + actionType: AdminActionTypes.Disqualification, + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + reason: "Self-referral", + }, + }, + aggregatedMetrics: { + grandTotalReferrals: 3, + grandTotalIncrementalDuration: 60, + grandTotalRevenueContribution: parseEth("300"), + 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: { + 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, + adminActions: [ + { + actionType: AdminActionTypes.Warning, + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + reason: "Suspicious activity", + }, + ], + }, + referrer: { + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 1, + isQualified: true, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("200"), + isAdminDisqualified: false, + adminAction: { + actionType: AdminActionTypes.Warning, + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + reason: "Suspicious activity", + }, + }, + aggregatedMetrics: { + grandTotalReferrals: 3, + grandTotalIncrementalDuration: 60, + grandTotalRevenueContribution: parseEth("300"), + awardPoolRemaining: parseUsdc("1800"), + }, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + const result = schema.parse(input); + expect(result.awardModel).toBe(ReferralProgramAwardModels.RevShareCap); + }); + + it("fails when isAdminDisqualified mismatches adminAction.actionType", () => { + 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, + }, + referrer: { + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 1, + isQualified: true, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("200"), + isAdminDisqualified: false, + adminAction: { + actionType: AdminActionTypes.Disqualification, + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + reason: "Self-referral", + }, + }, + aggregatedMetrics: { + grandTotalReferrals: 3, + grandTotalIncrementalDuration: 60, + grandTotalRevenueContribution: parseEth("300"), + awardPoolRemaining: parseUsdc("1800"), + }, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + expect(() => schema.parse(input)).toThrow(); + }); + + it("fails when adminAction.referrer does not match outer referrer", () => { + 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, + }, + referrer: { + referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + totalReferrals: 3, + totalIncrementalDuration: 60, + totalRevenueContribution: parseEth("300"), + totalBaseRevenueContribution: parseUsdc("150"), + rank: 1, + isQualified: true, + uncappedAward: parseUsdc("200"), + cappedAward: parseUsdc("200"), + isAdminDisqualified: false, + adminAction: { + actionType: AdminActionTypes.Warning, + referrer: "0x0000000000000000000000000000000000000001", + reason: "Wrong address", + }, + }, + aggregatedMetrics: { + grandTotalReferrals: 3, + grandTotalIncrementalDuration: 60, + grandTotalRevenueContribution: parseEth("300"), + awardPoolRemaining: parseUsdc("1800"), + }, + status: ReferralProgramEditionStatuses.Active, + accurateAsOf: 1500000, + }; + + expect(() => schema.parse(input)).toThrow(); + }); + 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/zod-schemas.ts b/packages/ens-referrals/src/award-models/rev-share-cap/api/zod-schemas.ts index 44f9db259..6e627721f 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 @@ -128,6 +128,10 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( .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"], }); /** @@ -184,7 +188,11 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( message: `${valueLabel}.isAdminDisqualified must be true iff adminAction.actionType is Disqualification`, path: ["isAdminDisqualified"], }, - ); + ) + .refine((data) => data.adminAction === null || data.adminAction.referrer === data.referrer, { + message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, + path: ["adminAction", "referrer"], + }); /** * Schema for {@link AggregatedReferrerMetricsRevShareCap}. From a0017231f84fe05be7ae37597b546462392e32f2 Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 20 Apr 2026 15:49:27 +0200 Subject: [PATCH 4/6] review + removing isAdminDisqualified --- .../ens-referrals/src/api/zod-schemas.test.ts | 7 +--- .../rev-share-cap/api/serialize.ts | 2 - .../rev-share-cap/api/zod-schemas.ts | 27 ++----------- .../rev-share-cap/leaderboard.test.ts | 23 ++++------- .../src/award-models/rev-share-cap/metrics.ts | 39 ++++--------------- .../src/award-models/rev-share-cap/rules.ts | 20 +++++----- 6 files changed, 30 insertions(+), 88 deletions(-) diff --git a/packages/ens-referrals/src/api/zod-schemas.test.ts b/packages/ens-referrals/src/api/zod-schemas.test.ts index 393045f37..76bff2130 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -538,7 +538,6 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - isAdminDisqualified: false, adminAction: null, }, aggregatedMetrics: { @@ -591,7 +590,6 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: false, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("0"), - isAdminDisqualified: true, adminAction: { actionType: AdminActionTypes.Disqualification, referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", @@ -645,7 +643,6 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - isAdminDisqualified: false, adminAction: { actionType: AdminActionTypes.Warning, referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", @@ -666,7 +663,7 @@ describe("makeReferrerEditionMetricsSchema", () => { expect(result.awardModel).toBe(ReferralProgramAwardModels.RevShareCap); }); - it("fails when isAdminDisqualified mismatches adminAction.actionType", () => { + it("fails when Disqualification adminAction has isQualified=true or non-zero cappedAward", () => { const input = { awardModel: ReferralProgramAwardModels.RevShareCap, type: ReferrerEditionMetricsTypeIds.Ranked, @@ -692,7 +689,6 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - isAdminDisqualified: false, adminAction: { actionType: AdminActionTypes.Disqualification, referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", @@ -738,7 +734,6 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - isAdminDisqualified: false, adminAction: { actionType: AdminActionTypes.Warning, referrer: "0x0000000000000000000000000000000000000001", 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 0ba725cdc..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 @@ -77,7 +77,6 @@ export function serializeAwardedReferrerMetricsRevShareCap( isQualified: metrics.isQualified, uncappedAward: serializePriceUsdc(metrics.uncappedAward), cappedAward: serializePriceUsdc(metrics.cappedAward), - isAdminDisqualified: metrics.isAdminDisqualified, adminAction: metrics.adminAction, }; } @@ -98,7 +97,6 @@ export function serializeUnrankedReferrerMetricsRevShareCap( isQualified: metrics.isQualified, uncappedAward: serializePriceUsdc(metrics.uncappedAward), cappedAward: serializePriceUsdc(metrics.cappedAward), - isAdminDisqualified: metrics.isAdminDisqualified, 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 6e627721f..c4a7dc7b6 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 @@ -101,7 +101,6 @@ export const makeAwardedReferrerMetricsRevShareCapSchema = ( isQualified: z.boolean(), uncappedAward: makePriceUsdcSchema(`${valueLabel}.uncappedAward`), cappedAward: makePriceUsdcSchema(`${valueLabel}.cappedAward`), - isAdminDisqualified: z.boolean(), adminAction: makeAdminActionSchema(`${valueLabel}.adminAction`).nullable(), }) .refine((data) => data.cappedAward.amount <= data.uncappedAward.amount, { @@ -110,19 +109,11 @@ 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"], - }, - ) - .refine( - (data) => - data.isAdminDisqualified === - (data.adminAction?.actionType === AdminActionTypes.Disqualification), - { - message: `${valueLabel}.isAdminDisqualified must be true iff adminAction.actionType is Disqualification`, - path: ["isAdminDisqualified"], + message: `When ${valueLabel}.adminAction.actionType is Disqualification, isQualified must be false and cappedAward.amount must be 0`, + path: ["adminAction"], }, ) .refine((data) => data.isQualified || data.cappedAward.amount === 0n, { @@ -153,7 +144,6 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( isQualified: z.literal(false), uncappedAward: makePriceUsdcSchema(`${valueLabel}.uncappedAward`), cappedAward: makePriceUsdcSchema(`${valueLabel}.cappedAward`), - isAdminDisqualified: z.boolean(), adminAction: makeAdminActionSchema(`${valueLabel}.adminAction`).nullable(), }) .refine((data) => data.totalReferrals === 0, { @@ -180,15 +170,6 @@ export const makeUnrankedReferrerMetricsRevShareCapSchema = ( message: `${valueLabel}.cappedAward must be 0 for unranked referrers`, path: ["cappedAward"], }) - .refine( - (data) => - data.isAdminDisqualified === - (data.adminAction?.actionType === AdminActionTypes.Disqualification), - { - message: `${valueLabel}.isAdminDisqualified must be true iff adminAction.actionType is Disqualification`, - path: ["isAdminDisqualified"], - }, - ) .refine((data) => data.adminAction === null || data.adminAction.referrer === data.referrer, { message: `${valueLabel}.adminAction.referrer must match ${valueLabel}.referrer`, path: ["adminAction", "referrer"], 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 0e317845b..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 @@ -482,12 +482,10 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerB = result.referrers.get(ADDR_B)!; expect(referrerA.isQualified).toBe(true); - expect(referrerA.isAdminDisqualified).toBe(false); 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.adminAction).toBe(null); }); @@ -506,14 +504,13 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerA = result.referrers.get(ADDR_A)!; const referrerB = result.referrers.get(ADDR_B)!; - expect(referrerA.isAdminDisqualified).toBe(true); 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); }); @@ -527,7 +524,7 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); const referrerA = result.referrers.get(ADDR_A)!; - expect(referrerA.isAdminDisqualified).toBe(true); + 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); @@ -560,22 +557,22 @@ 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.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 admin actions: all disqualified referrers get isAdminDisqualified=true", () => { + it("multiple admin actions: all disqualified referrers have Disqualification adminAction", () => { const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [ disqualification(ADDR_A, "reason-a"), disqualification(ADDR_B, "reason-b"), @@ -591,17 +588,16 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerB = result.referrers.get(ADDR_B)!; const referrerC = result.referrers.get(ADDR_C)!; - expect(referrerA.isAdminDisqualified).toBe(true); + 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.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.adminAction).toBe(null); expect(referrerC.isQualified).toBe(true); expect(referrerC.cappedAward.amount).toBe(UNCAPPED_AWARD_1Y.amount); @@ -632,13 +628,11 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const referrerB = result.referrers.get(ADDR_B)!; // Warned referrer is NOT disqualified — they still qualify and get awards - expect(referrerA.isAdminDisqualified).toBe(false); 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.isAdminDisqualified).toBe(false); expect(referrerB.adminAction).toBe(null); }); @@ -651,7 +645,6 @@ describe("buildReferrerLeaderboardRevShareCap", () => { const result = buildReferrerLeaderboardRevShareCap(events, rules, accurateAsOf); const referrerA = result.referrers.get(ADDR_A)!; - expect(referrerA.isAdminDisqualified).toBe(false); 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 140389d78..c89dba6b8 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 @@ -86,22 +86,14 @@ 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 true if and only if {@link adminAction} has `actionType === "Disqualification"`. - */ - isAdminDisqualified: boolean; - - /** - * The admin action imposed on this referrer, or null if no action exists. - * - * @invariant null when no admin action exists for this referrer. + * @invariant null when no admin action has been taken on this referrer. * @invariant Must match the corresponding entry in {@link ReferralProgramRulesRevShareCap.adminActions}. */ adminAction: AdminAction | null; @@ -146,14 +138,6 @@ export const validateRankedReferrerMetricsRevShareCap = ( ); } } - - const expectedIsAdminDisqualified = - metrics.adminAction?.actionType === AdminActionTypes.Disqualification; - if (metrics.isAdminDisqualified !== expectedIsAdminDisqualified) { - throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${expectedIsAdminDisqualified}.`, - ); - } }; export const buildRankedReferrerMetricsRevShareCap = ( @@ -171,7 +155,6 @@ export const buildRankedReferrerMetricsRevShareCap = ( referrer.totalBaseRevenueContribution, rules, ), - isAdminDisqualified: adminAction?.actionType === AdminActionTypes.Disqualification, adminAction, } satisfies RankedReferrerMetricsRevShareCap; @@ -200,7 +183,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; @@ -218,7 +201,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()}.`, ); @@ -315,14 +301,6 @@ export const validateUnrankedReferrerMetricsRevShareCap = ( } } - const expectedIsAdminDisqualified = - metrics.adminAction?.actionType === AdminActionTypes.Disqualification; - if (metrics.isAdminDisqualified !== expectedIsAdminDisqualified) { - throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: isAdminDisqualified: ${metrics.isAdminDisqualified}, expected: ${expectedIsAdminDisqualified}.`, - ); - } - if (metrics.totalReferrals !== 0) { throw new Error( `Invalid UnrankedReferrerMetricsRevShareCap: totalReferrals must be 0, got: ${metrics.totalReferrals}.`, @@ -387,7 +365,6 @@ export const buildUnrankedReferrerMetricsRevShareCap = ( isQualified: false, uncappedAward: priceUsdc(0n), cappedAward: priceUsdc(0n), - isAdminDisqualified: adminAction?.actionType === AdminActionTypes.Disqualification, adminAction, } satisfies UnrankedReferrerMetricsRevShareCap; 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 6aff88878..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,16 +11,16 @@ import { } from "../shared/rules"; /** - * The types of admin actions that can be imposed on a referrer in a rev-share-cap edition. + * The types of admin actions that can be taken upon a referrer in a rev-share-cap edition. */ export const AdminActionTypes = { /** - * The referrer is ineligible for awards. + * The referrer is disqualified for awards. */ Disqualification: "Disqualification", /** - * The referrer is flagged but still eligible for awards. + * The referrer is warned about a potential disqualification but may still be qualified for awards. */ Warning: "Warning", } as const; @@ -28,8 +28,7 @@ export const AdminActionTypes = { export type AdminActionType = (typeof AdminActionTypes)[keyof typeof AdminActionTypes]; /** - * An admin-imposed disqualification of a specific referrer in an edition. - * Disqualified referrers receive no awards. + * An admin action to disqualify a referrer from receiving awards for an edition. */ export interface AdminActionDisqualification { actionType: typeof AdminActionTypes.Disqualification; @@ -40,7 +39,7 @@ export interface AdminActionDisqualification { referrer: NormalizedAddress; /** - * A human-readable explanation of why the action was imposed. + * A short message explaining the disqualification. * * @invariant Must be a trimmed, non-empty string. */ @@ -48,8 +47,8 @@ export interface AdminActionDisqualification { } /** - * An admin-imposed warning for a specific referrer in an edition. - * Warned referrers are still eligible for awards. + * 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; @@ -60,7 +59,7 @@ export interface AdminActionWarning { referrer: NormalizedAddress; /** - * A human-readable explanation of why the action was imposed. + * A short message explaining the warning. * * @invariant Must be a trimmed, non-empty string. */ @@ -109,8 +108,7 @@ export interface ReferralProgramRulesRevShareCap extends BaseReferralProgramRule maxBaseRevenueShare: number; /** - * Admin-imposed actions for this edition. - * Disqualified referrers receive no awards. Warned referrers are still eligible. + * Admin actions for this edition. * * @invariant No duplicate referrer addresses (a referrer can have at most one admin action). */ From 06af9ca7f82e805d2d291cedbd3732c83d0d25eb Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 20 Apr 2026 16:24:28 +0200 Subject: [PATCH 5/6] review --- .../rev-share-cap/api/zod-schemas.ts | 65 +++++++++++++- .../src/award-models/rev-share-cap/metrics.ts | 85 ++++++++++--------- 2 files changed, 107 insertions(+), 43 deletions(-) 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 c4a7dc7b6..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,7 +20,7 @@ import { } from "../../shared/api/zod-schemas"; import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics"; import { ReferralProgramAwardModels } from "../../shared/rules"; -import { AdminActionTypes } from "../rules"; +import { type AdminAction, AdminActionTypes } from "../rules"; /** * Schema for {@link AdminActionDisqualification}. @@ -192,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}. */ @@ -217,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"], + ); }); /** @@ -240,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"], + ); }); /** @@ -301,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/metrics.ts b/packages/ens-referrals/src/award-models/rev-share-cap/metrics.ts index c89dba6b8..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 @@ -99,6 +99,37 @@ export interface RankedReferrerMetricsRevShareCap extends ReferrerMetricsRevShar 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, @@ -117,27 +148,12 @@ export const validateRankedReferrerMetricsRevShareCap = ( ); } - const expectedAdminAction = - rules.adminActions.find((a) => a.referrer === metrics.referrer) ?? null; - - if (expectedAdminAction === null && metrics.adminAction !== null) { - throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid adminAction: expected null, got actionType="${metrics.adminAction.actionType}".`, - ); - } - - if (expectedAdminAction !== null) { - if ( - metrics.adminAction === null || - metrics.adminAction.actionType !== expectedAdminAction.actionType || - metrics.adminAction.referrer !== expectedAdminAction.referrer || - metrics.adminAction.reason !== expectedAdminAction.reason - ) { - throw new Error( - `RankedReferrerMetricsRevShareCap: Invalid adminAction: does not match expected action from rules.`, - ); - } - } + validateAdminActionConsistency( + metrics.adminAction, + metrics.referrer, + rules, + "RankedReferrerMetricsRevShareCap: Invalid adminAction", + ); }; export const buildRankedReferrerMetricsRevShareCap = ( @@ -279,27 +295,12 @@ export const validateUnrankedReferrerMetricsRevShareCap = ( ); } - const expectedAdminAction = - rules.adminActions.find((a) => a.referrer === metrics.referrer) ?? null; - - if (expectedAdminAction === null && metrics.adminAction !== null) { - throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: adminAction: expected null, got actionType="${metrics.adminAction.actionType}".`, - ); - } - - if (expectedAdminAction !== null) { - if ( - metrics.adminAction === null || - metrics.adminAction.actionType !== expectedAdminAction.actionType || - metrics.adminAction.referrer !== expectedAdminAction.referrer || - metrics.adminAction.reason !== expectedAdminAction.reason - ) { - throw new Error( - `Invalid UnrankedReferrerMetricsRevShareCap: adminAction does not match expected action from rules.`, - ); - } - } + validateAdminActionConsistency( + metrics.adminAction, + metrics.referrer, + rules, + "Invalid UnrankedReferrerMetricsRevShareCap: adminAction", + ); if (metrics.totalReferrals !== 0) { throw new Error( From 5bae4b282549c81627e930528cfbd9a214f3f1cd Mon Sep 17 00:00:00 2001 From: Goader Date: Mon, 20 Apr 2026 16:47:27 +0200 Subject: [PATCH 6/6] review --- .../ens-referrals/src/api/zod-schemas.test.ts | 199 +++++++----------- 1 file changed, 71 insertions(+), 128 deletions(-) diff --git a/packages/ens-referrals/src/api/zod-schemas.test.ts b/packages/ens-referrals/src/api/zod-schemas.test.ts index 76bff2130..26d51c616 100644 --- a/packages/ens-referrals/src/api/zod-schemas.test.ts +++ b/packages/ens-referrals/src/api/zod-schemas.test.ts @@ -452,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, @@ -516,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"), @@ -540,12 +563,7 @@ describe("makeReferrerEditionMetricsSchema", () => { cappedAward: parseUsdc("200"), adminAction: null, }, - aggregatedMetrics: { - grandTotalReferrals: 3, - grandTotalIncrementalDuration: 60, - grandTotalRevenueContribution: parseEth("300"), - awardPoolRemaining: parseUsdc("1800"), - }, + aggregatedMetrics: revShareCapAggregatedMetrics, status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -561,27 +579,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, - adminActions: [ - { - actionType: AdminActionTypes.Disqualification, - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - reason: "Self-referral", - }, - ], - }, + rules: { ...revShareCapRules, adminActions: [disqualificationAction] }, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: revShareCapReferrerAddress, totalReferrals: 3, totalIncrementalDuration: 60, totalRevenueContribution: parseEth("300"), @@ -590,18 +590,9 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: false, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("0"), - adminAction: { - actionType: AdminActionTypes.Disqualification, - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - reason: "Self-referral", - }, - }, - aggregatedMetrics: { - grandTotalReferrals: 3, - grandTotalIncrementalDuration: 60, - grandTotalRevenueContribution: parseEth("300"), - awardPoolRemaining: parseUsdc("2000"), + adminAction: disqualificationAction, }, + aggregatedMetrics: { ...revShareCapAggregatedMetrics, awardPoolRemaining: parseUsdc("2000") }, status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -614,27 +605,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, - adminActions: [ - { - actionType: AdminActionTypes.Warning, - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - reason: "Suspicious activity", - }, - ], - }, + rules: { ...revShareCapRules, adminActions: [warningAction] }, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: revShareCapReferrerAddress, totalReferrals: 3, totalIncrementalDuration: 60, totalRevenueContribution: parseEth("300"), @@ -643,18 +616,9 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - adminAction: { - actionType: AdminActionTypes.Warning, - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - reason: "Suspicious activity", - }, - }, - aggregatedMetrics: { - grandTotalReferrals: 3, - grandTotalIncrementalDuration: 60, - grandTotalRevenueContribution: parseEth("300"), - awardPoolRemaining: parseUsdc("1800"), + adminAction: warningAction, }, + aggregatedMetrics: revShareCapAggregatedMetrics, status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; @@ -667,20 +631,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, adminActions: [disqualificationAction] }, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: revShareCapReferrerAddress, totalReferrals: 3, totalIncrementalDuration: 60, totalRevenueContribution: parseEth("300"), @@ -689,43 +642,32 @@ describe("makeReferrerEditionMetricsSchema", () => { isQualified: true, uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), - adminAction: { - actionType: AdminActionTypes.Disqualification, - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - reason: "Self-referral", - }, - }, - aggregatedMetrics: { - grandTotalReferrals: 3, - grandTotalIncrementalDuration: 60, - grandTotalRevenueContribution: parseEth("300"), - awardPoolRemaining: parseUsdc("1800"), + adminAction: disqualificationAction, }, + aggregatedMetrics: revShareCapAggregatedMetrics, status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; - expect(() => schema.parse(input)).toThrow(); + 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: { - 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, adminActions: [warningAction] }, referrer: { - referrer: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", + referrer: revShareCapReferrerAddress, totalReferrals: 3, totalIncrementalDuration: 60, totalRevenueContribution: parseEth("300"), @@ -735,22 +677,23 @@ describe("makeReferrerEditionMetricsSchema", () => { uncappedAward: parseUsdc("200"), cappedAward: parseUsdc("200"), adminAction: { - actionType: AdminActionTypes.Warning, + ...warningAction, referrer: "0x0000000000000000000000000000000000000001", - reason: "Wrong address", }, }, - aggregatedMetrics: { - grandTotalReferrals: 3, - grandTotalIncrementalDuration: 60, - grandTotalRevenueContribution: parseEth("300"), - awardPoolRemaining: parseUsdc("1800"), - }, + aggregatedMetrics: revShareCapAggregatedMetrics, status: ReferralProgramEditionStatuses.Active, accurateAsOf: 1500000, }; - expect(() => schema.parse(input)).toThrow(); + 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", () => {