Skip to content

Commit 85f0e51

Browse files
committed
proper rev-share-limit race implemented
1 parent ec71e94 commit 85f0e51

File tree

13 files changed

+831
-142
lines changed

13 files changed

+831
-142
lines changed

apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {
22
buildReferrerMetrics,
3+
type ReferralEvent,
34
type ReferralProgramRules,
45
type ReferrerMetrics,
56
} from "@namehash/ens-referrals/v1";
6-
import { and, count, desc, eq, gte, isNotNull, lte, ne, sql, sum } from "drizzle-orm";
7+
import { and, asc, count, desc, eq, gte, isNotNull, lte, ne, sql, sum } from "drizzle-orm";
78
import { type Address, zeroAddress } from "viem";
89

910
import * as schema from "@ensnode/ensnode-schema";
@@ -93,3 +94,71 @@ export const getReferrerMetrics = async (
9394
throw new Error(`Failed to fetch referrer metrics from database: ${errorMessage}`);
9495
}
9596
};
97+
98+
/**
99+
* Get raw referral events from the database for the sequential race algorithm (V1 API).
100+
*
101+
* Returns individual rows (no GROUP BY) ordered chronologically for deterministic race processing.
102+
*
103+
* @param rules - The referral program rules for filtering registrar actions
104+
* @returns A promise that resolves to an array of {@link ReferralEvent} values.
105+
* @throws Error if the database query fails.
106+
*/
107+
export const getReferralEvents = async (rules: ReferralProgramRules): Promise<ReferralEvent[]> => {
108+
try {
109+
const records = await db
110+
.select({
111+
referrer: schema.registrarActions.decodedReferrer,
112+
timestamp: schema.registrarActions.timestamp,
113+
blockNumber: schema.registrarActions.blockNumber,
114+
transactionHash: schema.registrarActions.transactionHash,
115+
incrementalDuration: schema.registrarActions.incrementalDuration,
116+
// Note: Using raw SQL for COALESCE because Drizzle doesn't natively support it yet.
117+
// See: https://github.com/drizzle-team/drizzle-orm/issues/3708
118+
total: sql<string>`COALESCE(${schema.registrarActions.total}, 0)`.as("total"),
119+
})
120+
.from(schema.registrarActions)
121+
.where(
122+
and(
123+
// Filter by timestamp range
124+
gte(schema.registrarActions.timestamp, BigInt(rules.startTime)),
125+
lte(schema.registrarActions.timestamp, BigInt(rules.endTime)),
126+
// Filter by decodedReferrer not null
127+
isNotNull(schema.registrarActions.decodedReferrer),
128+
// Filter by decodedReferrer not zero address
129+
ne(schema.registrarActions.decodedReferrer, zeroAddress),
130+
// Filter by subregistryId matching the provided subregistryId
131+
eq(schema.registrarActions.subregistryId, formatAccountId(rules.subregistryId)),
132+
),
133+
)
134+
.orderBy(
135+
asc(schema.registrarActions.timestamp),
136+
asc(schema.registrarActions.blockNumber),
137+
asc(schema.registrarActions.transactionHash),
138+
);
139+
140+
// Type assertion: The WHERE clause in the query above guarantees non-null values for:
141+
// 1. `referrer` is guaranteed to be non-null due to isNotNull filter
142+
interface NonNullRecord {
143+
referrer: Address;
144+
timestamp: bigint;
145+
blockNumber: bigint;
146+
transactionHash: `0x${string}`;
147+
incrementalDuration: bigint;
148+
total: string;
149+
}
150+
151+
return (records as NonNullRecord[]).map((record) => ({
152+
referrer: record.referrer,
153+
timestamp: Number(record.timestamp),
154+
blockNumber: record.blockNumber,
155+
transactionHash: record.transactionHash,
156+
incrementalDuration: Number(record.incrementalDuration),
157+
incrementalRevenueContribution: priceEth(BigInt(record.total)),
158+
}));
159+
} catch (error) {
160+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
161+
logger.error({ error }, "Failed to fetch referral events from database");
162+
throw new Error(`Failed to fetch referral events from database: ${errorMessage}`);
163+
}
164+
};
Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import {
2-
buildReferrerLeaderboard,
2+
buildReferrerLeaderboardPieSplit,
3+
buildReferrerLeaderboardRevShareLimit,
4+
ReferralProgramAwardModels,
35
type ReferralProgramRules,
46
type ReferrerLeaderboard,
57
} from "@namehash/ens-referrals/v1";
68

79
import type { UnixTimestamp } from "@ensnode/ensnode-sdk";
810

9-
import { getReferrerMetrics } from "./database-v1";
11+
import { getReferralEvents, getReferrerMetrics } from "./database-v1";
1012

1113
/**
1214
* Builds a `ReferralLeaderboard` from the database using the provided referral program rules (V1 API).
1315
*
16+
* Dispatches to the appropriate model-specific builder based on `rules.awardModel`:
17+
* - PieSplit: uses aggregated referrer metrics (GROUP BY query).
18+
* - RevShareLimit: uses raw referral events (no GROUP BY) for the sequential race algorithm.
19+
*
1420
* @param rules - The referral program rules for filtering registrar actions
1521
* @param accurateAsOf - The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboard} was accurate as of.
1622
* @returns A promise that resolves to a {@link ReferrerLeaderboard}
@@ -20,6 +26,14 @@ export async function getReferrerLeaderboard(
2026
rules: ReferralProgramRules,
2127
accurateAsOf: UnixTimestamp,
2228
): Promise<ReferrerLeaderboard> {
23-
const allReferrers = await getReferrerMetrics(rules);
24-
return buildReferrerLeaderboard(allReferrers, rules, accurateAsOf);
29+
switch (rules.awardModel) {
30+
case ReferralProgramAwardModels.PieSplit: {
31+
const allReferrers = await getReferrerMetrics(rules);
32+
return buildReferrerLeaderboardPieSplit(allReferrers, rules, accurateAsOf);
33+
}
34+
case ReferralProgramAwardModels.RevShareLimit: {
35+
const events = await getReferralEvents(rules);
36+
return buildReferrerLeaderboardRevShareLimit(events, rules, accurateAsOf);
37+
}
38+
}
2539
}

packages/ens-referrals/src/v1/api/deserialize.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,7 @@ export function deserializeReferrerLeaderboardPageResponse(
3434
);
3535
}
3636

37-
// The Zod schema includes passthrough catch-alls for unknown award model types,
38-
// making its inferred output type wider than ReferrerLeaderboardPageResponse.
39-
// This assertion is safe: the schema validates all known fields correctly.
40-
return parsed.data as unknown as ReferrerLeaderboardPageResponse;
37+
return parsed.data;
4138
}
4239

4340
/**
@@ -56,8 +53,7 @@ export function deserializeReferrerMetricsEditionsResponse(
5653
);
5754
}
5855

59-
// Same passthrough-widened type assertion as above.
60-
return parsed.data as unknown as ReferrerMetricsEditionsResponse;
56+
return parsed.data;
6157
}
6258

6359
/**
@@ -76,7 +72,9 @@ export function deserializeReferralProgramEditionConfigSetArray(
7672
);
7773
}
7874

79-
// Same passthrough-widened type assertion as above.
75+
// makeReferralProgramRulesSchema uses .passthrough() for forward compatibility with unknown award
76+
// model types, widening its output to { awardModel: string } & Record<string, unknown>.
77+
// This assertion is safe: the schema validates all known fields correctly.
8078
return parsed.data as unknown as ReferralProgramEditionConfig[];
8179
}
8280

@@ -96,6 +94,6 @@ export function deserializeReferralProgramEditionConfigSetResponse(
9694
);
9795
}
9896

99-
// Same passthrough-widened type assertion as above.
97+
// Same reason as deserializeReferralProgramEditionConfigSetArray above.
10098
return parsed.data as unknown as ReferralProgramEditionConfigSetResponse;
10199
}
Lines changed: 20 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,38 @@
1-
import {
2-
type Duration,
3-
type PriceEth,
4-
type PriceUsdc,
5-
priceEth,
6-
priceUsdc,
7-
scalePrice,
8-
} from "@ensnode/ensnode-sdk";
1+
import { type Duration, type PriceEth, type PriceUsdc, priceEth } from "@ensnode/ensnode-sdk";
92
import { makePriceEthSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal";
103

114
import { validateNonNegativeInteger } from "../../number";
125
import { validateDuration } from "../../time";
13-
import type { RankedReferrerMetricsRevShareLimit } from "./metrics";
14-
import type { ReferralProgramRulesRevShareLimit } from "./rules";
6+
import type { AwardedReferrerMetricsRevShareLimit } from "./metrics";
157

168
/**
17-
* Represents aggregated metrics for a list of {@link RankedReferrerMetricsRevShareLimit}.
9+
* Represents aggregated metrics for a list of referrers on a rev-share-limit leaderboard.
1810
*/
1911
export interface AggregatedReferrerMetricsRevShareLimit {
2012
/**
21-
* @invariant The sum of `totalReferrals` across all {@link RankedReferrerMetricsRevShareLimit} in the list.
13+
* @invariant The sum of `totalReferrals` across all referrers in the list.
2214
* @invariant Guaranteed to be a non-negative integer (>= 0)
2315
*/
2416
grandTotalReferrals: number;
2517

2618
/**
27-
* @invariant The sum of `totalIncrementalDuration` across all {@link RankedReferrerMetricsRevShareLimit} in the list.
19+
* @invariant The sum of `totalIncrementalDuration` across all referrers in the list.
2820
*/
2921
grandTotalIncrementalDuration: Duration;
3022

3123
/**
3224
* The total revenue contribution in ETH to the ENS DAO from all referrals
3325
* across all referrers on the leaderboard.
3426
*
35-
* This is the sum of `totalRevenueContribution` across all {@link RankedReferrerMetricsRevShareLimit} in the list.
27+
* This is the sum of `totalRevenueContribution` across all referrers in the list.
3628
*
3729
* @invariant Guaranteed to be a valid PriceEth with non-negative amount (>= 0n)
3830
*/
3931
grandTotalRevenueContribution: PriceEth;
4032

4133
/**
42-
* The remaining amount in the award pool after subtracting the total potential awards
43-
* (capped at 0 if total potential awards exceed the pool).
34+
* The remaining amount in the award pool after subtracting all qualified awards
35+
* claimed during the sequential race processing.
4436
*
4537
* @invariant Guaranteed to be a valid PriceUsdc with non-negative amount (>= 0n)
4638
*/
@@ -75,65 +67,41 @@ export const validateAggregatedReferrerMetricsRevShareLimit = (
7567
};
7668

7769
/**
78-
* Builds aggregated rev-share-limit metrics from a complete, globally ranked list of referrers.
70+
* Builds aggregated rev-share-limit metrics from a complete list of referrers and
71+
* the award pool remaining after sequential race processing.
7972
*
80-
* **IMPORTANT: This function expects a complete ranking of all referrers.**
73+
* **IMPORTANT: This function expects a complete list of all referrers.**
8174
*
82-
* @param referrers - Must be a complete, globally ranked list of {@link RankedReferrerMetricsRevShareLimit}
83-
* where ranks start at 1 and are consecutive.
84-
* **This must NOT be a paginated or partial slice of the rankings.**
75+
* @param referrers - Must be a complete list of referrers with their totals.
76+
* **This must NOT be a paginated or partial slice.**
8577
*
86-
* @param rules - The {@link ReferralProgramRulesRevShareLimit} object that define qualification criteria.
78+
* @param awardPoolRemaining - The amount remaining in the award pool after the sequential
79+
* race algorithm has processed all events.
8780
*
8881
* @returns Aggregated metrics including totals across all referrers and the award pool remaining.
89-
*
90-
* @remarks
91-
* - If you need to work with paginated data, aggregate the full ranking first before
92-
* calling this function, or call this function on the complete dataset and then paginate
93-
* the results.
9482
*/
9583
export const buildAggregatedReferrerMetricsRevShareLimit = (
96-
referrers: RankedReferrerMetricsRevShareLimit[],
97-
rules: ReferralProgramRulesRevShareLimit,
98-
): { aggregatedMetrics: AggregatedReferrerMetricsRevShareLimit; scalingFactor: number } => {
84+
referrers: AwardedReferrerMetricsRevShareLimit[],
85+
awardPoolRemaining: PriceUsdc,
86+
): AggregatedReferrerMetricsRevShareLimit => {
9987
let grandTotalReferrals = 0;
10088
let grandTotalIncrementalDuration = 0;
10189
let grandTotalRevenueContributionAmount = 0n;
102-
let totalPotentialAwardsAmount = 0n;
10390

10491
for (const referrer of referrers) {
10592
grandTotalReferrals += referrer.totalReferrals;
10693
grandTotalIncrementalDuration += referrer.totalIncrementalDuration;
10794
grandTotalRevenueContributionAmount += referrer.totalRevenueContribution.amount;
108-
if (referrer.isQualified) {
109-
const potentialAward = scalePrice(
110-
referrer.totalBaseRevenueContribution,
111-
rules.qualifiedRevenueShare,
112-
);
113-
totalPotentialAwardsAmount += potentialAward.amount;
114-
}
11595
}
11696

117-
const scalingFactor =
118-
totalPotentialAwardsAmount > 0n
119-
? Math.min(1, Number(rules.totalAwardPoolValue.amount) / Number(totalPotentialAwardsAmount))
120-
: 1;
121-
122-
const cappedTotalPotentialAwards =
123-
totalPotentialAwardsAmount < rules.totalAwardPoolValue.amount
124-
? totalPotentialAwardsAmount
125-
: rules.totalAwardPoolValue.amount;
126-
127-
const awardPoolRemainingAmount = rules.totalAwardPoolValue.amount - cappedTotalPotentialAwards;
128-
12997
const aggregatedMetrics = {
13098
grandTotalReferrals,
13199
grandTotalIncrementalDuration,
132100
grandTotalRevenueContribution: priceEth(grandTotalRevenueContributionAmount),
133-
awardPoolRemaining: priceUsdc(awardPoolRemainingAmount),
101+
awardPoolRemaining,
134102
} satisfies AggregatedReferrerMetricsRevShareLimit;
135103

136104
validateAggregatedReferrerMetricsRevShareLimit(aggregatedMetrics);
137105

138-
return { aggregatedMetrics, scalingFactor };
106+
return aggregatedMetrics;
139107
};

packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function serializeAwardedReferrerMetricsRevShareLimit(
6767
totalBaseRevenueContribution: serializePriceUsdc(metrics.totalBaseRevenueContribution),
6868
rank: metrics.rank,
6969
isQualified: metrics.isQualified,
70+
standardAwardValue: serializePriceUsdc(metrics.standardAwardValue),
7071
awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue),
7172
};
7273
}
@@ -85,6 +86,7 @@ export function serializeUnrankedReferrerMetricsRevShareLimit(
8586
totalBaseRevenueContribution: serializePriceUsdc(metrics.totalBaseRevenueContribution),
8687
rank: metrics.rank,
8788
isQualified: metrics.isQualified,
89+
standardAwardValue: serializePriceUsdc(metrics.standardAwardValue),
8890
awardPoolApproxValue: serializePriceUsdc(metrics.awardPoolApproxValue),
8991
};
9092
}

packages/ens-referrals/src/v1/award-models/rev-share-limit/api/serialized-types.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@ export interface SerializedAggregatedReferrerMetricsRevShareLimit
4343
export interface SerializedAwardedReferrerMetricsRevShareLimit
4444
extends Omit<
4545
AwardedReferrerMetricsRevShareLimit,
46-
"totalRevenueContribution" | "totalBaseRevenueContribution" | "awardPoolApproxValue"
46+
| "totalRevenueContribution"
47+
| "totalBaseRevenueContribution"
48+
| "standardAwardValue"
49+
| "awardPoolApproxValue"
4750
> {
4851
totalRevenueContribution: SerializedPriceEth;
4952
totalBaseRevenueContribution: SerializedPriceUsdc;
53+
standardAwardValue: SerializedPriceUsdc;
5054
awardPoolApproxValue: SerializedPriceUsdc;
5155
}
5256

@@ -56,10 +60,14 @@ export interface SerializedAwardedReferrerMetricsRevShareLimit
5660
export interface SerializedUnrankedReferrerMetricsRevShareLimit
5761
extends Omit<
5862
UnrankedReferrerMetricsRevShareLimit,
59-
"totalRevenueContribution" | "totalBaseRevenueContribution" | "awardPoolApproxValue"
63+
| "totalRevenueContribution"
64+
| "totalBaseRevenueContribution"
65+
| "standardAwardValue"
66+
| "awardPoolApproxValue"
6067
> {
6168
totalRevenueContribution: SerializedPriceEth;
6269
totalBaseRevenueContribution: SerializedPriceUsdc;
70+
standardAwardValue: SerializedPriceUsdc;
6371
awardPoolApproxValue: SerializedPriceUsdc;
6472
}
6573

0 commit comments

Comments
 (0)