Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-wolves-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@namehash/ens-referrals": minor
---

Add admin disqualification support for rev-share-limit referral program editions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function serializeReferralProgramRulesRevShareLimit(
endTime: rules.endTime,
subregistryId: rules.subregistryId,
rulesUrl: rules.rulesUrl.toString(),
disqualifications: rules.disqualifications,
};
}

Expand Down Expand Up @@ -69,6 +70,8 @@ export function serializeAwardedReferrerMetricsRevShareLimit(
isQualified: metrics.isQualified,
standardAwardValue: serializePriceUsdc(metrics.standardAwardValue),
awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue),
isAdminDisqualified: metrics.isAdminDisqualified,
adminDisqualificationReason: metrics.adminDisqualificationReason,
};
}

Expand All @@ -88,6 +91,8 @@ export function serializeUnrankedReferrerMetricsRevShareLimit(
isQualified: metrics.isQualified,
standardAwardValue: serializePriceUsdc(metrics.standardAwardValue),
awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue),
isAdminDisqualified: metrics.isAdminDisqualified,
adminDisqualificationReason: metrics.adminDisqualificationReason,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
makeUnixTimestampSchema,
} from "@ensnode/ensnode-sdk/internal";

import { normalizeAddress } from "../../../address";
import {
makeBaseReferralProgramRulesSchema,
makeReferralProgramStatusSchema,
Expand All @@ -19,6 +20,17 @@ import {
import { ReferrerEditionMetricsTypeIds } from "../../shared/edition-metrics";
import { ReferralProgramAwardModels } from "../../shared/rules";

/**
* Schema for {@link ReferralProgramEditionDisqualification}.
*/
export const makeReferralProgramEditionDisqualificationSchema = (
valueLabel = "ReferralProgramEditionDisqualification",
) =>
z.object({
referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`),
reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`),
});

/**
* Schema for {@link ReferralProgramRulesRevShareLimit}.
*/
Expand All @@ -34,6 +46,20 @@ export const makeReferralProgramRulesRevShareLimitSchema = (
qualifiedRevenueShare: makeFiniteNonNegativeNumberSchema(
`${valueLabel}.qualifiedRevenueShare`,
).max(1, `${valueLabel}.qualifiedRevenueShare must be <= 1`),
disqualifications: z
.array(
makeReferralProgramEditionDisqualificationSchema(`${valueLabel}.disqualifications[item]`),
)
.refine(
(items) => {
const addresses = items.map((item) => normalizeAddress(item.referrer));
return new Set(addresses).size === addresses.length;
},
{
message: `${valueLabel}.disqualifications must not contain duplicate referrer addresses`,
},
)
.default([]),
});

/**
Expand All @@ -55,10 +81,29 @@ export const makeAwardedReferrerMetricsRevShareLimitSchema = (
isQualified: z.boolean(),
standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
isAdminDisqualified: z.boolean(),
adminDisqualificationReason: z
.string()
.trim()
.min(1, `${valueLabel}.adminDisqualificationReason must not be empty`)
.nullable(),
})
.refine((data) => data.awardPoolApproxValue.amount <= data.standardAwardValue.amount, {
message: `${valueLabel}.awardPoolApproxValue must be <= ${valueLabel}.standardAwardValue`,
path: ["awardPoolApproxValue"],
})
.refine(
(data) =>
!data.isAdminDisqualified ||
(data.isQualified === false && data.awardPoolApproxValue.amount === 0n),
{
message: `When ${valueLabel}.isAdminDisqualified is true, isQualified must be false and awardPoolApproxValue.amount must be 0`,
path: ["isAdminDisqualified"],
},
)
.refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), {
message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`,
path: ["adminDisqualificationReason"],
});

/**
Expand All @@ -80,10 +125,20 @@ export const makeUnrankedReferrerMetricsRevShareLimitSchema = (
isQualified: z.literal(false),
standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
isAdminDisqualified: z.boolean(),
adminDisqualificationReason: z
.string()
.trim()
.min(1, `${valueLabel}.adminDisqualificationReason must not be empty`)
.nullable(),
})
.refine((data) => data.awardPoolApproxValue.amount <= data.standardAwardValue.amount, {
message: `${valueLabel}.awardPoolApproxValue must be <= ${valueLabel}.standardAwardValue`,
path: ["awardPoolApproxValue"],
})
.refine((data) => data.isAdminDisqualified === (data.adminDisqualificationReason !== null), {
message: `${valueLabel}.adminDisqualificationReason must be non-null iff isAdminDisqualified is true`,
path: ["adminDisqualificationReason"],
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { parseTimestamp, parseUsdc, priceEth, priceUsdc } from "@ensnode/ensnode
import { SECONDS_PER_YEAR } from "../../time";
import { buildReferrerLeaderboardRevShareLimit } from "./leaderboard";
import type { ReferralEvent } from "./referral-event";
import type { ReferralProgramEditionDisqualification } from "./rules";
import { buildReferralProgramRulesRevShareLimit } from "./rules";

// ─── Test fixtures ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -42,6 +43,7 @@ const CHECKPOINT_PREFIX =
function buildTestRules(
totalAwardPoolValue = parseUsdc("1000"),
minQualifiedRevenueContribution = parseUsdc("5"),
disqualifications: ReferralProgramEditionDisqualification[] = [],
) {
return buildReferralProgramRulesRevShareLimit(
totalAwardPoolValue,
Expand All @@ -51,6 +53,7 @@ function buildTestRules(
parseTimestamp("2026-12-31T23:59:59Z"),
{ chainId: 1, address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85" },
new URL("https://example.com/rules"),
disqualifications,
);
}

Expand Down Expand Up @@ -395,4 +398,154 @@ describe("buildReferrerLeaderboardRevShareLimit", () => {
expect(result.aggregatedMetrics.grandTotalIncrementalDuration).toBe(3 * SECONDS_PER_YEAR);
});
});

describe("Admin disqualifications", () => {
it("no disqualifications — qualified referrers receive awards normally", () => {
const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), []);
const events = [
makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR),
makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR),
];

const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf);
const referrerA = result.referrers.get(ADDR_A)!;
const referrerB = result.referrers.get(ADDR_B)!;

expect(referrerA.isQualified).toBe(true);
expect(referrerA.isAdminDisqualified).toBe(false);
expect(referrerA.adminDisqualificationReason).toBe(null);
expect(referrerA.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount);

expect(referrerB.isQualified).toBe(true);
expect(referrerB.isAdminDisqualified).toBe(false);
expect(referrerB.adminDisqualificationReason).toBe(null);
});

it("disqualified referrer who met threshold: awardPoolApproxValue = 0, pool preserved for next", () => {
// ADDR_A qualifies by revenue but is admin-disqualified → pool claim = 0
// ADDR_B qualifies later → gets the full pool share
const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [
{ referrer: ADDR_A, reason: "self-referral" },
]);
const events = [
makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR), // would qualify, but disqualified
makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR), // qualifies normally
];

const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf);
const referrerA = result.referrers.get(ADDR_A)!;
const referrerB = result.referrers.get(ADDR_B)!;

expect(referrerA.isAdminDisqualified).toBe(true);
expect(referrerA.adminDisqualificationReason).toBe("self-referral");
expect(referrerA.isQualified).toBe(false);
expect(referrerA.awardPoolApproxValue.amount).toBe(0n);

// Pool was not consumed by ADDR_A, so ADDR_B gets the full award
expect(referrerB.isQualified).toBe(true);
expect(referrerB.isAdminDisqualified).toBe(false);
expect(referrerB.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount);
});

it("disqualified referrer who never met the revenue threshold: pool unchanged", () => {
// ADDR_A has half a year (below threshold) and is disqualified — pool should be fully intact
const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [
{ referrer: ADDR_A, reason: "promoting discounts" },
]);
const events = [makeEvent(ADDR_A, 1000, Math.floor(SECONDS_PER_YEAR / 2))];

const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf);
const referrerA = result.referrers.get(ADDR_A)!;

expect(referrerA.isAdminDisqualified).toBe(true);
expect(referrerA.adminDisqualificationReason).toBe("promoting discounts");
expect(referrerA.isQualified).toBe(false);
expect(referrerA.awardPoolApproxValue.amount).toBe(0n);
// Pool fully intact
expect(result.aggregatedMetrics.awardPoolRemaining.amount).toBe(parseUsdc("1000").amount);
});

it("disqualified referrer ranks between qualified (pool claim) and unqualified (below threshold)", () => {
// ADDR_A: 2 years, disqualified → standardAward $5.00, pool claim $0
// ADDR_B: 1 year, qualified → standardAward $2.50, pool claim $2.50
// ADDR_C: 0.5 years, below threshold → standardAward $1.25, pool claim $0
//
// Sort by pool claim desc, then duration desc:
// rank 1 → ADDR_B ($2.50 claim)
// rank 2 → ADDR_A ($0 claim, 2y duration — beats ADDR_C on duration)
// rank 3 → ADDR_C ($0 claim, 0.5y duration)
const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [
{ referrer: ADDR_A, reason: "cheating" },
]);
const events = [
makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR * 2),
makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR),
makeEvent(ADDR_C, 3000, Math.floor(SECONDS_PER_YEAR / 2)),
];

const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf);
const referrerA = result.referrers.get(ADDR_A)!;
const referrerB = result.referrers.get(ADDR_B)!;
const referrerC = result.referrers.get(ADDR_C)!;

expect(referrerB.rank).toBe(1);
expect(referrerB.isQualified).toBe(true);
expect(referrerB.isAdminDisqualified).toBe(false);
expect(referrerB.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount);

expect(referrerA.rank).toBe(2);
expect(referrerA.isAdminDisqualified).toBe(true);
expect(referrerA.adminDisqualificationReason).toBe("cheating");
expect(referrerA.isQualified).toBe(false);
expect(referrerA.awardPoolApproxValue.amount).toBe(0n);

expect(referrerC.rank).toBe(3);
expect(referrerC.isQualified).toBe(false);
expect(referrerC.isAdminDisqualified).toBe(false);
expect(referrerC.awardPoolApproxValue.amount).toBe(0n);
});

it("multiple disqualifications: all disqualified referrers get isAdminDisqualified=true", () => {
const rules = buildTestRules(parseUsdc("1000"), parseUsdc("5"), [
{ referrer: ADDR_A, reason: "reason-a" },
{ referrer: ADDR_B, reason: "reason-b" },
]);
const events = [
makeEvent(ADDR_A, 1000, SECONDS_PER_YEAR),
makeEvent(ADDR_B, 2000, SECONDS_PER_YEAR),
makeEvent(ADDR_C, 3000, SECONDS_PER_YEAR), // only C qualifies and claims
];

const result = buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf);
const referrerA = result.referrers.get(ADDR_A)!;
const referrerB = result.referrers.get(ADDR_B)!;
const referrerC = result.referrers.get(ADDR_C)!;

expect(referrerA.isAdminDisqualified).toBe(true);
expect(referrerA.adminDisqualificationReason).toBe("reason-a");
expect(referrerA.isQualified).toBe(false);
expect(referrerA.awardPoolApproxValue.amount).toBe(0n);

expect(referrerB.isAdminDisqualified).toBe(true);
expect(referrerB.adminDisqualificationReason).toBe("reason-b");
expect(referrerB.isQualified).toBe(false);
expect(referrerB.awardPoolApproxValue.amount).toBe(0n);

expect(referrerC.isAdminDisqualified).toBe(false);
expect(referrerC.adminDisqualificationReason).toBe(null);
expect(referrerC.isQualified).toBe(true);
expect(referrerC.awardPoolApproxValue.amount).toBe(STANDARD_AWARD_1Y.amount);
});

it("duplicate address in disqualifications: buildReferralProgramRulesRevShareLimit throws", () => {
expect(() =>
buildTestRules(parseUsdc("1000"), parseUsdc("5"), [
{ referrer: ADDR_A, reason: "first" },
{ referrer: ADDR_A, reason: "duplicate" },
]),
).toThrow(
"ReferralProgramRulesRevShareLimit: disqualifications must not contain duplicate referrer addresses.",
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import type { ReferralEvent } from "./referral-event";
import {
BASE_REVENUE_CONTRIBUTION_PER_YEAR,
isReferrerQualifiedRevShareLimit,
type ReferralProgramRulesRevShareLimit,
} from "./rules";

Expand Down Expand Up @@ -137,7 +138,11 @@ export const buildReferrerLeaderboardRevShareLimit = (
BigInt(SECONDS_PER_YEAR);

// Determine if newly qualifying or already qualified.
const isNowQualified = totalBaseRevenueAmount >= rules.minQualifiedRevenueContribution.amount;
const isNowQualified = isReferrerQualifiedRevShareLimit(
referrer,
priceUsdc(totalBaseRevenueAmount),
rules,
);

if (isNowQualified && !state.wasQualified) {
// First time crossing the qualification threshold: claim all accumulated standard award.
Expand Down
Loading