Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Comment thread
Goader marked this conversation as resolved.
---

Add admin disqualification support for rev-share-limit referral program editions.
Comment thread
Goader marked this conversation as resolved.
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 ReferralProgramAdminDisqualification}.
*/
export const makeReferralProgramAdminDisqualificationSchema = (
valueLabel = "ReferralProgramAdminDisqualification",
) =>
z.object({
referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`),
reason: z.string().trim().min(1, `${valueLabel}.reason must not be empty`),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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(
makeReferralProgramAdminDisqualificationSchema(`${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([]),
Comment thread
Goader marked this conversation as resolved.
Comment thread
Goader marked this conversation as resolved.
});

/**
Expand All @@ -55,10 +81,25 @@ export const makeAwardedReferrerMetricsRevShareLimitSchema = (
isQualified: z.boolean(),
standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
isAdminDisqualified: z.boolean(),
adminDisqualificationReason: z.string().nullable(),
Comment thread
Goader marked this conversation as resolved.
Outdated
})
.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 +121,16 @@ export const makeUnrankedReferrerMetricsRevShareLimitSchema = (
isQualified: z.literal(false),
standardAwardValue: makePriceUsdcSchema(`${valueLabel}.standardAwardValue`),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
isAdminDisqualified: z.boolean(),
adminDisqualificationReason: z.string().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 { ReferralProgramAdminDisqualification } 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: ReferralProgramAdminDisqualification[] = [],
) {
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,
Comment thread
Goader marked this conversation as resolved.
priceUsdc(totalBaseRevenueAmount),
rules,
);
Comment thread
Goader marked this conversation as resolved.

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