Skip to content

Conversation

@Goader
Copy link
Contributor

@Goader Goader commented Jan 26, 2026

wip

@Goader Goader self-assigned this Jan 26, 2026
Copilot AI review requested due to automatic review settings January 26, 2026 13:43
@changeset-bot
Copy link

changeset-bot bot commented Jan 26, 2026

⚠️ No Changeset found

Latest commit: d973ec5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Jan 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Review Updated (UTC)
admin.ensnode.io Skipped Skipped Jan 26, 2026 2:27pm
ensnode.io Skipped Skipped Jan 26, 2026 2:27pm
ensrainbow.io Skipped Skipped Jan 26, 2026 2:27pm

@coderabbitai
Copy link

coderabbitai bot commented Jan 26, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Adds a versioned V1 ENS referrals API and consumer surfaces: new v1 domain types, serialization/deserialization, pagination, ranking/aggregation logic, database aggregation for referrer metrics, test fixtures, package exports for ./v1, and SDK currency (USDC/DAI) serializers/deserializers.

Changes

Cohort / File(s) Summary
ENS API Handler & Tests
apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts, apps/ensapi/src/handlers/ensanalytics-api-v1.ts
Switched imports to @namehash/ens-referrals/v1 and mocks-v1; updated tests to expect awardPoolApproxValue as { currency: "USDC", amount: 0n } shape.
ENS API DB & Orchestration
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts, apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts
New DB aggregation getReferrerMetrics(rules) using Drizzle to compute referrer aggregates; getReferrerLeaderboard() composes metrics into a V1 leaderboard via builder.
ENS API Mocks/Fixtures
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts
Large mock dataset and typed responses: dbResultsReferrerLeaderboard, emptyReferralLeaderboard, populatedReferrerLeaderboard, referrerLeaderboardPageResponseOk.
ens-referrals V1 Public Surface
packages/ens-referrals/src/v1.ts, packages/ens-referrals/package.json, packages/ens-referrals/tsup.config.ts
New v1 entry re-exporting V1 modules; package.json adds ./v1 export and adjusts publish config; build config includes src/v1.ts.
Domain: Rules, Metrics, Ranking, Aggregation, Leaderboard, Pagination
packages/ens-referrals/src/rules-v1.ts, referrer-metrics-v1.ts, rank-v1.ts, aggregations-v1.ts, leaderboard-v1.ts, leaderboard-page-v1.ts, referrer-detail-v1.ts
New V1 modules implementing ReferralProgramRules, full referrer metrics hierarchy (build/validate), ranking and final-score logic, aggregated metrics builders/validators, leaderboard construction, pagination context/validation, and referrer detail retrieval (ranked/unranked).
API: Types, Serialization, Deserialization & Schemas (v1)
packages/ens-referrals/src/api/types-v1.ts, serialized-types-v1.ts, serialize-v1.ts, deserialize-v1.ts, zod-schemas-v1.ts
New request/response V1 types, serialized shape interfaces, serializers and deserializers for wire format (price conversions), and Zod schema factories for validation of pages and detail responses.
SDK: Currency serializers / deserializers & schemas
packages/ensnode-sdk/src/shared/deserialize.ts, serialize.ts, zod-schemas.ts
Added deserializePriceUsdc/deserializePriceDai/deserializePriceEth; added serializePriceUsdc/serializePriceDai; new Price USDC/DAI schema builders.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ENSAPI_Handler as ENSAPI Handler
  participant Orchestrator as getReferrerLeaderboard
  participant DB as Database
  participant Builder as buildReferrerLeaderboard

  Client->>ENSAPI_Handler: Request leaderboard (v1)
  ENSAPI_Handler->>Orchestrator: getReferrerLeaderboard(rules, accurateAsOf)
  Orchestrator->>DB: getReferrerMetrics(rules)
  DB-->>Orchestrator: referrer metrics[]
  Orchestrator->>Builder: buildReferrerLeaderboard(metrics, rules, accurateAsOf)
  Builder-->>Orchestrator: ReferrerLeaderboard
  Orchestrator-->>ENSAPI_Handler: ReferrerLeaderboard
  ENSAPI_Handler-->>Client: Serialized response (v1)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

ensanalytics, ensnode-sdk

Poem

🐰 I hopped through v1 with a twitch and a grin,
I sorted and scored every referrer within,
USDC coins clink in structured arrays,
Tests and mocks cheer in bright little ways,
A rabbit’s small hop for analytics kin.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is incomplete and does not follow the required template. It only contains 'wip' and lacks all required sections including Summary, Why, Testing, and Pre-Review Checklist. Complete the PR description by following the Lite PR template: add Summary (1-3 bullets of changes), Why (rationale and issue links), Testing (how it was tested), Notes for Reviewer if applicable, and Pre-Review Checklist with required confirmations.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Mature Price Models for "v1" ENSAnalytics' is specific and clearly describes the primary change: introduction of mature price models for v1 ENSAnalytics.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/mature-price-models

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +16 to +23
import {
type CurrencyId,
CurrencyIds,
Price,
type PriceDai,
type PriceEth,
type PriceUsdc,
} from "./currencies";
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a mature v1 API for ENS referral analytics with comprehensive price model support, adding USDC and DAI currencies alongside ETH. The changes establish a parallel v1 API structure that can coexist with the existing v0 API while providing enhanced functionality for the ENS Holiday Awards referral program.

Changes:

  • Added USDC and DAI price type support to @ensnode/ensnode-sdk with corresponding schemas, serialization, and deserialization functions
  • Created comprehensive v1 API modules for @namehash/ens-referrals including leaderboard, metrics, rankings, and award calculations
  • Implemented v1-specific database queries, handlers, and tests in ensapi to support the new API structure

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
packages/ensnode-sdk/src/shared/zod-schemas.ts Added Zod schemas for PriceUsdc and PriceDai types
packages/ensnode-sdk/src/shared/serialize.ts Added serialization functions for USDC and DAI prices; included inline copy of uint256ToHex32 to avoid module resolution issues
packages/ensnode-sdk/src/shared/deserialize.ts Added deserialization functions for ETH, USDC, and DAI prices
packages/ens-referrals/tsup.config.ts Added v1.ts as a build entry point to support parallel v1 exports
packages/ens-referrals/src/v1.ts New entry point that exports all v1-specific modules
packages/ens-referrals/src/rules-v1.ts Defines referral program rules including award pool value, qualification thresholds, and time boundaries
packages/ens-referrals/src/referrer-metrics-v1.ts Core metrics types and calculations for referrers including scoring, ranking, and award distribution
packages/ens-referrals/src/referrer-detail-v1.ts Implements referrer detail lookup with support for both ranked and unranked referrers
packages/ens-referrals/src/rank-v1.ts Ranking logic including score boost calculations and comparisons
packages/ens-referrals/src/leaderboard-v1.ts Leaderboard construction from raw metrics with full calculations
packages/ens-referrals/src/leaderboard-page-v1.ts Pagination logic for leaderboard queries
packages/ens-referrals/src/api/zod-schemas-v1.ts Zod schemas for v1 API types including validation rules
packages/ens-referrals/src/api/types-v1.ts TypeScript types for v1 API request/response structures
packages/ens-referrals/src/api/serialized-types-v1.ts Serialized type definitions for v1 API data transfer
packages/ens-referrals/src/api/serialize-v1.ts Serialization functions for converting runtime types to wire format
packages/ens-referrals/src/api/deserialize-v1.ts Deserialization functions with Zod validation for v1 API responses
packages/ens-referrals/src/aggregations-v1.ts Aggregation calculations for leaderboard-wide metrics
packages/ens-referrals/package.json Updated exports to include ./v1 entry point for consumers
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts Mock data for v1 API testing
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.ts Database-backed leaderboard construction for v1 API
apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts Drizzle ORM queries for fetching referrer metrics from the database
apps/ensapi/src/handlers/ensanalytics-api-v1.ts Updated import to use v1 entry point
apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts Updated tests to use v1 imports and properly test USDC price objects

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

rules: ReferralProgramRules,
): number {
if (!isReferrerQualified(rank, rules)) return 0;

Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential division by zero issue when maxQualifiedReferrers is 1. In line 46, the expression (rules.maxQualifiedReferrers - 1) becomes 0 when maxQualifiedReferrers is 1, resulting in division by zero. This would cause calcReferrerFinalScoreBoost to return Infinity or NaN.

While it may be unlikely that maxQualifiedReferrers would be set to 1 in practice, this should be handled explicitly to avoid runtime errors. Consider adding a check: if maxQualifiedReferrers === 1, return 1 (since rank 1 is the only qualified referrer and should get maximum boost).

Suggested change
// Avoid division by zero when only a single referrer is qualified.
// In this case, that single referrer (rank 1) should receive the maximum boost.
if (rules.maxQualifiedReferrers === 1) return 1;

Copilot uses AI. Check for mistakes.
Comment on lines +182 to +186
break;

case ReferrerDetailResponseCodes.Error:
return response;
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serializeReferrerDetailResponse function has an unreachable fallthrough case. The outer switch statement on line 167 handles both "ok" and "error" response codes, but after the "ok" case's nested switch completes (line 181), there is a break statement on line 182 that prevents any further code from executing. However, the function doesn't have an explicit return statement at the end, which means if somehow the code reaches past line 186 (which shouldn't happen), the function would return undefined.

While this is unlikely to cause issues in practice due to TypeScript's type checking, it would be cleaner to either:

  1. Remove the break statement on line 182 and let the nested switch return directly
  2. Add an exhaustive check or throw an error after line 186 to ensure all cases are handled

This could be considered a TypeScript exhaustiveness check issue.

Suggested change
break;
case ReferrerDetailResponseCodes.Error:
return response;
}
case ReferrerDetailResponseCodes.Error:
return response;
}
throw new Error(`Unexpected ReferrerDetailResponseCode: ${String(response.responseCode)}`);

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +229
export function deserializeReferrerDetailResponse(
maybeResponse: SerializedReferrerDetailResponse,
valueLabel?: string,
): ReferrerDetailResponse {
let deserialized: ReferrerDetailResponse;
switch (maybeResponse.responseCode) {
case "ok": {
switch (maybeResponse.data.type) {
case "ranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailRanked(maybeResponse.data),
} as ReferrerDetailResponse;
break;

case "unranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailUnranked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
}
break;
}

case "error":
deserialized = maybeResponse;
break;
}

// Then validate the deserialized structure using zod schemas
const schema = makeReferrerDetailResponseSchema(valueLabel);
const parsed = schema.safeParse(deserialized);

if (parsed.error) {
throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
}

return parsed.data;
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deserializeReferrerDetailResponse function has the same issue as the serialize variant. The deserialized variable is declared with let but may not be initialized if an unexpected code path is reached. Specifically, after the nested switch statement completes (line 211), there's a break statement (line 212) that exits the outer switch. While TypeScript should catch this at compile time, there's a theoretical code path where deserialized could be undefined when accessed on line 222.

Consider restructuring to ensure the variable is always initialized, or add an exhaustive check that throws an error if all cases aren't handled properly.

Copilot uses AI. Check for mistakes.
import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics-v1";
import type { ReferralProgramRules } from "../rules-v1";
import type {
ReferrerDetailResponse,
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'ReferrerDetailResponse' is defined but never used.

Suggested change
ReferrerDetailResponse,

Copilot uses AI. Check for mistakes.
ReferrerDetailResponse,
ReferrerDetailResponseError,
ReferrerDetailResponseOk,
ReferrerLeaderboardPageResponse,
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'ReferrerLeaderboardPageResponse' is defined but never used.

Suggested change
ReferrerLeaderboardPageResponse,

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +159
_valueLabel: string = "ReferrerLeaderboardPageResponseError",
) =>
z.object({
responseCode: z.literal(ReferrerLeaderboardPageResponseCodes.Error),
error: z.string(),
errorMessage: z.string(),
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'_valueLabel' is assigned a value but never used.

Suggested change
_valueLabel: string = "ReferrerLeaderboardPageResponseError",
) =>
z.object({
responseCode: z.literal(ReferrerLeaderboardPageResponseCodes.Error),
error: z.string(),
errorMessage: z.string(),
valueLabel: string = "ReferrerLeaderboardPageResponseError",
) =>
z.object({
responseCode: z.literal(ReferrerLeaderboardPageResponseCodes.Error),
error: z.string().describe(`${valueLabel}.error`),
errorMessage: z.string().describe(`${valueLabel}.errorMessage`),

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +220
_valueLabel: string = "ReferrerDetailResponse",
) =>
z.object({
responseCode: z.literal(ReferrerDetailResponseCodes.Error),
error: z.string(),
errorMessage: z.string(),
});
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'_valueLabel' is assigned a value but never used.

Suggested change
_valueLabel: string = "ReferrerDetailResponse",
) =>
z.object({
responseCode: z.literal(ReferrerDetailResponseCodes.Error),
error: z.string(),
errorMessage: z.string(),
});
valueLabel: string = "ReferrerDetailResponse",
) =>
z
.object({
responseCode: z.literal(ReferrerDetailResponseCodes.Error),
error: z.string(),
errorMessage: z.string(),
})
.describe(valueLabel);

Copilot uses AI. Check for mistakes.
*/
import { z } from "zod/v4";

import { ENSNamespaceIds, type InterpretedName, Node } from "../ens";
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Node' is defined but never used.

Suggested change
import { ENSNamespaceIds, type InterpretedName, Node } from "../ens";
import { ENSNamespaceIds, type InterpretedName } from "../ens";

Copilot uses AI. Check for mistakes.
import {
type CurrencyId,
CurrencyIds,
Price,
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Price' is defined but never used.

Suggested change
Price,

Copilot uses AI. Check for mistakes.
type PriceUsdc,
} from "./currencies";
import { reinterpretName } from "./interpretation/reinterpretation";
import type { AccountIdString } from "./serialized-types";
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'AccountIdString' is defined but never used.

Suggested change
import type { AccountIdString } from "./serialized-types";

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/ensnode-sdk/src/shared/serialize.ts (1)

2-36: Consider centralizing uint256ToHex32 to avoid drift.

This is now a local copy of packages/ensnode-sdk/src/ens/subname-helpers.ts:uint256ToHex32. If that implementation changes, these can diverge. A small shared util imported by both modules would keep behavior in sync while still avoiding the Vite SSR import issue noted in the comment.

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts`:
- Around line 71-89: Add lightweight runtime validation before casting records
to NonNullRecord to avoid runtime crashes if DB shape changes: in the mapping
code around records → NonNullRecord[] (used with buildReferrerMetrics,
deserializeDuration, and priceEth) check each record has a non-null/defined
referrer, a numeric totalReferrals, and stringifiable
totalIncrementalDuration/totalRevenueContribution; if any check fails either
skip the record with a warning log or throw a descriptive error. Alternatively,
replace the manual checks with a small Zod schema that validates/refines {
referrer: string, totalReferrals: number, totalIncrementalDuration: string,
totalRevenueContribution: string } and parse records through it before calling
buildReferrerMetrics.

In `@packages/ens-referrals/src/api/deserialize-v1.ts`:
- Around line 151-181: The switch in deserializeReferrerLeaderboardPageResponse
can leave deserialized undefined if maybeResponse.responseCode is unexpected;
add a default branch (or an exhaustive check) after the existing cases that
throws a clear error including the unexpected maybeResponse.responseCode (and
optionally the serialized payload) so callers fail-fast before calling
makeReferrerLeaderboardPageResponseSchema/safeParse; ensure the thrown message
uses prettifyError or similar context so debugging the malformed response is
easy.
- Around line 190-229: deserializeReferrerDetailResponse lacks default branches
in the outer switch on maybeResponse.responseCode and the inner switch on
maybeResponse.data.type, so add defensive default cases that throw clear Errors
(including the unexpected value) to make the function exhaustive and ensure
deserialized is always assigned; update the outer switch to handle unknown
responseCode by throwing an Error indicating the unexpected responseCode and
include a default in the inner switch to throw an Error indicating the
unexpected data.type (or use a never-exhaustiveness helper if preferred) before
the final zod validation in deserializeReferrerDetailResponse.

In `@packages/ens-referrals/src/api/zod-schemas-v1.ts`:
- Around line 67-86: Update makeUnrankedReferrerMetricsSchema so the eight
fields that must be zero are validated at parse time: totalReferrals,
totalIncrementalDuration, totalRevenueContribution, score, finalScoreBoost,
finalScore, awardPoolShare, and awardPoolApproxValue. Replace their current
permissive schemas (e.g., makeNonNegativeIntegerSchema, makeDurationSchema,
makePriceEthSchema, makeFiniteNonNegativeNumberSchema, makePriceUsdcSchema) with
explicit zero constraints—either z.literal(0) for exact-zero or apply
.min(0).max(0, "<field> must be 0") to the existing schema factories—to ensure
these fields can only be 0 and include a clear error message referencing the
field via the valueLabel.

In `@packages/ens-referrals/src/leaderboard-page-v1.ts`:
- Around line 208-214: Doc invariant for ReferrerLeaderboardPage.referrers
conflicts with ReferrerLeaderboard/getReferrerLeaderboardPage which preserve Map
order (ascending by rank). Fix by aligning docs: update the invariant on
ReferrerLeaderboardPage.referrers to state entries are ordered by `rank`
(ascending) instead of descending (or alternatively, if you prefer descending,
reverse the array returned by `getReferrerLeaderboardPage`/the Map iteration);
reference symbols: ReferrerLeaderboard, getReferrerLeaderboardPage,
ReferrerLeaderboardPage.referrers, AwardedReferrerMetrics.
- Around line 118-148: The validator must also check totalPages and that the
provided startIndex/endIndex match derived values: inside
validateReferrerLeaderboardPageContext compute expectedTotalPages =
Math.ceil(context.totalRecords / context.recordsPerPage) (handle zero records),
compute expectedStartIndex = (context.page - 1) * context.recordsPerPage and
expectedEndIndex = Math.min(expectedStartIndex + context.recordsPerPage,
context.totalRecords), then validate that context.totalPages is a non-negative
integer and equals expectedTotalPages, that context.page <= context.totalPages
(and valid for zero/empty cases), and that context.startIndex ===
expectedStartIndex and context.endIndex === expectedEndIndex (also ensure
endIndex >= startIndex); throw the same style of Error with clear messages when
any of these invariants fail.

In `@packages/ens-referrals/src/leaderboard-v1.ts`:
- Around line 56-61: The duplicate-referrer check is wrong because
uniqueReferrers is just a mapping copy; replace it by creating a Set of referrer
strings from allReferrers (e.g., new Set(allReferrers.map(r => r.referrer))) and
compare set.size to allReferrers.length inside buildReferrerLeaderboard (or
wherever uniqueReferrers is defined); if sizes differ, throw the existing Error
so duplicates are detected before constructing the Map and silently overwriting
entries.

In `@packages/ens-referrals/src/rank-v1.ts`:
- Around line 40-47: calcReferrerFinalScoreBoost currently divides by
(rules.maxQualifiedReferrers - 1) causing divide-by-zero when
maxQualifiedReferrers === 1; update calcReferrerFinalScoreBoost to guard that
case by returning the correct boost (return 1 for the sole qualified referrer)
when rules.maxQualifiedReferrers <= 1 (and keep the existing isReferrerQualified
check), otherwise compute 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1) as
before.

In `@packages/ens-referrals/src/referrer-metrics-v1.ts`:
- Around line 280-303: In buildAwardedReferrerMetrics the code converts
rules.totalAwardPoolValue.amount to Number which can overflow; keep all math in
bigint by using fixed‑point scaling or an integer fraction instead of Number().
Change calcReferrerAwardPoolShare usage so you either obtain a rational
numerator/denominator (or use a fixed SCALE as a BigInt) and compute
awardPoolApproxAmount with bigint arithmetic: (BigInt(numerator) *
rules.totalAwardPoolValue.amount) / BigInt(denominator) (or
(BigInt(Math.round(awardPoolShare*SCALE)) * totalAmount) / SCALE as BigInt),
then set awardPoolApproxValue.amount to that bigint and run
validateAwardedReferrerMetrics as before.

In `@packages/ens-referrals/src/rules-v1.ts`:
- Line 7: validateReferralProgramRules currently omits validation for the
subregistryId field; update the function (and the same checks around lines
67-85) to validate subregistryId alongside price/timestamp/count invariants:
locate the validateReferralProgramRules function and add a check that
subregistryId is present and matches your expected account-id format (e.g.,
non-empty string and the same pattern used elsewhere for ENS account IDs or a
shared validator helper if one exists), and throw or return an error when it
fails so invalid account IDs cannot pass through.

In `@packages/ens-referrals/src/v1.ts`:
- Around line 14-15: Remove the public re-export of the internal Zod schemas by
deleting the export of "./api/zod-schemas-v1" from the v1 public entry (leave
"export * from \"./api/types-v1\""), so the internal module "zod-schemas-v1" is
no longer exposed; ensure any consumers that relied on that export are updated
to import from the internal re-export location (internal.ts) instead and run the
build/tests to confirm no public API leak remains.

Comment on lines +71 to +89
// Type assertion: The WHERE clause in the query above guarantees non-null values for:
// 1. `referrer` is guaranteed to be non-null due to isNotNull filter
// 2. `totalIncrementalDuration` is guaranteed to be non-null as it is the sum of non-null bigint values
// 3. `totalRevenueContribution` is guaranteed to be non-null due to COALESCE with 0
interface NonNullRecord {
referrer: Address;
totalReferrals: number;
totalIncrementalDuration: string;
totalRevenueContribution: string;
}

return (records as NonNullRecord[]).map((record) => {
return buildReferrerMetrics(
record.referrer,
record.totalReferrals,
deserializeDuration(record.totalIncrementalDuration),
priceEth(BigInt(record.totalRevenueContribution)),
);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Type assertion is justified but consider adding runtime validation.

The NonNullRecord type assertion is justified by the WHERE clause guarantees (documented in comments). However, if the database schema changes or there's a bug, this could lead to runtime errors. Consider adding minimal validation or using Zod for parsing the records.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts` around
lines 71 - 89, Add lightweight runtime validation before casting records to
NonNullRecord to avoid runtime crashes if DB shape changes: in the mapping code
around records → NonNullRecord[] (used with buildReferrerMetrics,
deserializeDuration, and priceEth) check each record has a non-null/defined
referrer, a numeric totalReferrals, and stringifiable
totalIncrementalDuration/totalRevenueContribution; if any check fails either
skip the record with a warning log or throw a descriptive error. Alternatively,
replace the manual checks with a small Zod schema that validates/refines {
referrer: string, totalReferrals: number, totalIncrementalDuration: string,
totalRevenueContribution: string } and parse records through it before calling
buildReferrerMetrics.

Comment on lines +151 to +181
export function deserializeReferrerLeaderboardPageResponse(
maybeResponse: SerializedReferrerLeaderboardPageResponse,
valueLabel?: string,
): ReferrerLeaderboardPageResponse {
let deserialized: ReferrerLeaderboardPageResponse;
switch (maybeResponse.responseCode) {
case "ok": {
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerLeaderboardPage(maybeResponse.data),
} as ReferrerLeaderboardPageResponse;
break;
}

case "error":
deserialized = maybeResponse;
break;
}

// Then validate the deserialized structure using zod schemas
const schema = makeReferrerLeaderboardPageResponseSchema(valueLabel);
const parsed = schema.safeParse(deserialized);

if (parsed.error) {
throw new Error(
`Cannot deserialize SerializedReferrerLeaderboardPageResponse:\n${prettifyError(parsed.error)}\n`,
);
}

return parsed.data;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding exhaustiveness check for switch statement.

If maybeResponse.responseCode receives an unexpected value at runtime (e.g., from malformed API response), deserialized would be uninitialized, causing a runtime error at the safeParse call. Adding a default case improves defensive coding.

🛡️ Suggested improvement
   switch (maybeResponse.responseCode) {
     case "ok": {
       deserialized = {
         responseCode: maybeResponse.responseCode,
         data: deserializeReferrerLeaderboardPage(maybeResponse.data),
       } as ReferrerLeaderboardPageResponse;
       break;
     }

     case "error":
       deserialized = maybeResponse;
       break;
+
+    default:
+      throw new Error(
+        `Unexpected responseCode: ${(maybeResponse as { responseCode: unknown }).responseCode}`,
+      );
   }
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/api/deserialize-v1.ts` around lines 151 - 181, The
switch in deserializeReferrerLeaderboardPageResponse can leave deserialized
undefined if maybeResponse.responseCode is unexpected; add a default branch (or
an exhaustive check) after the existing cases that throws a clear error
including the unexpected maybeResponse.responseCode (and optionally the
serialized payload) so callers fail-fast before calling
makeReferrerLeaderboardPageResponseSchema/safeParse; ensure the thrown message
uses prettifyError or similar context so debugging the malformed response is
easy.

Comment on lines +190 to +229
export function deserializeReferrerDetailResponse(
maybeResponse: SerializedReferrerDetailResponse,
valueLabel?: string,
): ReferrerDetailResponse {
let deserialized: ReferrerDetailResponse;
switch (maybeResponse.responseCode) {
case "ok": {
switch (maybeResponse.data.type) {
case "ranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailRanked(maybeResponse.data),
} as ReferrerDetailResponse;
break;

case "unranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailUnranked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
}
break;
}

case "error":
deserialized = maybeResponse;
break;
}

// Then validate the deserialized structure using zod schemas
const schema = makeReferrerDetailResponseSchema(valueLabel);
const parsed = schema.safeParse(deserialized);

if (parsed.error) {
throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
}

return parsed.data;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Same exhaustiveness concern for deserializeReferrerDetailResponse.

Both the outer switch on responseCode and the inner switch on maybeResponse.data.type could benefit from default cases for defensive error handling against malformed inputs.

🛡️ Suggested improvement
   switch (maybeResponse.responseCode) {
     case "ok": {
       switch (maybeResponse.data.type) {
         case "ranked":
           deserialized = {
             responseCode: maybeResponse.responseCode,
             data: deserializeReferrerDetailRanked(maybeResponse.data),
           } as ReferrerDetailResponse;
           break;

         case "unranked":
           deserialized = {
             responseCode: maybeResponse.responseCode,
             data: deserializeReferrerDetailUnranked(maybeResponse.data),
           } as ReferrerDetailResponse;
           break;
+
+        default:
+          throw new Error(
+            `Unexpected detail type: ${(maybeResponse.data as { type: unknown }).type}`,
+          );
       }
       break;
     }

     case "error":
       deserialized = maybeResponse;
       break;
+
+    default:
+      throw new Error(
+        `Unexpected responseCode: ${(maybeResponse as { responseCode: unknown }).responseCode}`,
+      );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function deserializeReferrerDetailResponse(
maybeResponse: SerializedReferrerDetailResponse,
valueLabel?: string,
): ReferrerDetailResponse {
let deserialized: ReferrerDetailResponse;
switch (maybeResponse.responseCode) {
case "ok": {
switch (maybeResponse.data.type) {
case "ranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailRanked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
case "unranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailUnranked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
}
break;
}
case "error":
deserialized = maybeResponse;
break;
}
// Then validate the deserialized structure using zod schemas
const schema = makeReferrerDetailResponseSchema(valueLabel);
const parsed = schema.safeParse(deserialized);
if (parsed.error) {
throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
}
return parsed.data;
}
export function deserializeReferrerDetailResponse(
maybeResponse: SerializedReferrerDetailResponse,
valueLabel?: string,
): ReferrerDetailResponse {
let deserialized: ReferrerDetailResponse;
switch (maybeResponse.responseCode) {
case "ok": {
switch (maybeResponse.data.type) {
case "ranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailRanked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
case "unranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailUnranked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
default:
throw new Error(
`Unexpected detail type: ${(maybeResponse.data as { type: unknown }).type}`,
);
}
break;
}
case "error":
deserialized = maybeResponse;
break;
default:
throw new Error(
`Unexpected responseCode: ${(maybeResponse as { responseCode: unknown }).responseCode}`,
);
}
// Then validate the deserialized structure using zod schemas
const schema = makeReferrerDetailResponseSchema(valueLabel);
const parsed = schema.safeParse(deserialized);
if (parsed.error) {
throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
}
return parsed.data;
}
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/api/deserialize-v1.ts` around lines 190 - 229,
deserializeReferrerDetailResponse lacks default branches in the outer switch on
maybeResponse.responseCode and the inner switch on maybeResponse.data.type, so
add defensive default cases that throw clear Errors (including the unexpected
value) to make the function exhaustive and ensure deserialized is always
assigned; update the outer switch to handle unknown responseCode by throwing an
Error indicating the unexpected responseCode and include a default in the inner
switch to throw an Error indicating the unexpected data.type (or use a
never-exhaustiveness helper if preferred) before the final zod validation in
deserializeReferrerDetailResponse.

Comment on lines +67 to +86
export const makeUnrankedReferrerMetricsSchema = (valueLabel: string = "UnrankedReferrerMetrics") =>
z.object({
referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`),
totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`),
totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`),
totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`),
score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`),
rank: z.null(),
isQualified: z.literal(false),
finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max(
1,
`${valueLabel}.finalScoreBoost must be <= 1`,
),
finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`),
awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max(
1,
`${valueLabel}.awardPoolShare must be <= 1`,
),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find packages/ens-referrals -name "*referrer-metrics*" -type f

Repository: namehash/ensnode

Length of output: 156


🏁 Script executed:

fd -t f referrer-metrics packages/ens-referrals/

Repository: namehash/ensnode

Length of output: 156


🏁 Script executed:

git ls-files packages/ens-referrals/ | grep -i "referrer\|metric"

Repository: namehash/ensnode

Length of output: 251


🏁 Script executed:

wc -l packages/ens-referrals/src/referrer-metrics-v1.ts

Repository: namehash/ensnode

Length of output: 113


🏁 Script executed:

cat -n packages/ens-referrals/src/referrer-metrics-v1.ts

Repository: namehash/ensnode

Length of output: 17799


🏁 Script executed:

wc -l packages/ens-referrals/src/api/zod-schemas-v1.ts

Repository: namehash/ensnode

Length of output: 112


🏁 Script executed:

sed -n '67,86p' packages/ens-referrals/src/api/zod-schemas-v1.ts

Repository: namehash/ensnode

Length of output: 1120


🏁 Script executed:

sed -n '60,100p' packages/ens-referrals/src/api/zod-schemas-v1.ts

Repository: namehash/ensnode

Length of output: 1769


Add zero-value constraints to the UnrankedReferrerMetrics schema.

The Zod schema currently allows non-zero values, but UnrankedReferrerMetrics must have eight fields zeroed according to validation in referrer-metrics-v1.ts. The schema should enforce this at parse time to prevent invalid API payloads.

✅ Suggested refinement
 export const makeUnrankedReferrerMetricsSchema = (valueLabel: string = "UnrankedReferrerMetrics") =>
   z.object({
     referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`),
     totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`),
     totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`),
     totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`),
     score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`),
     rank: z.null(),
     isQualified: z.literal(false),
     finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max(
       1,
       `${valueLabel}.finalScoreBoost must be <= 1`,
     ),
     finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`),
     awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max(
       1,
       `${valueLabel}.awardPoolShare must be <= 1`,
     ),
     awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
-  });
+  }).refine(
+    (value) =>
+      value.totalReferrals === 0 &&
+      value.totalIncrementalDuration === 0 &&
+      value.score === 0 &&
+      value.finalScoreBoost === 0 &&
+      value.finalScore === 0 &&
+      value.awardPoolShare === 0 &&
+      value.totalRevenueContribution.amount === 0n &&
+      value.awardPoolApproxValue.amount === 0n,
+    { message: `${valueLabel} must have zeroed metrics for unranked referrers` },
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const makeUnrankedReferrerMetricsSchema = (valueLabel: string = "UnrankedReferrerMetrics") =>
z.object({
referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`),
totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`),
totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`),
totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`),
score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`),
rank: z.null(),
isQualified: z.literal(false),
finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max(
1,
`${valueLabel}.finalScoreBoost must be <= 1`,
),
finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`),
awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max(
1,
`${valueLabel}.awardPoolShare must be <= 1`,
),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
});
export const makeUnrankedReferrerMetricsSchema = (valueLabel: string = "UnrankedReferrerMetrics") =>
z.object({
referrer: makeLowercaseAddressSchema(`${valueLabel}.referrer`),
totalReferrals: makeNonNegativeIntegerSchema(`${valueLabel}.totalReferrals`),
totalIncrementalDuration: makeDurationSchema(`${valueLabel}.totalIncrementalDuration`),
totalRevenueContribution: makePriceEthSchema(`${valueLabel}.totalRevenueContribution`),
score: makeFiniteNonNegativeNumberSchema(`${valueLabel}.score`),
rank: z.null(),
isQualified: z.literal(false),
finalScoreBoost: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScoreBoost`).max(
1,
`${valueLabel}.finalScoreBoost must be <= 1`,
),
finalScore: makeFiniteNonNegativeNumberSchema(`${valueLabel}.finalScore`),
awardPoolShare: makeFiniteNonNegativeNumberSchema(`${valueLabel}.awardPoolShare`).max(
1,
`${valueLabel}.awardPoolShare must be <= 1`,
),
awardPoolApproxValue: makePriceUsdcSchema(`${valueLabel}.awardPoolApproxValue`),
}).refine(
(value) =>
value.totalReferrals === 0 &&
value.totalIncrementalDuration === 0 &&
value.score === 0 &&
value.finalScoreBoost === 0 &&
value.finalScore === 0 &&
value.awardPoolShare === 0 &&
value.totalRevenueContribution.amount === 0n &&
value.awardPoolApproxValue.amount === 0n,
{ message: `${valueLabel} must have zeroed metrics for unranked referrers` },
);
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/api/zod-schemas-v1.ts` around lines 67 - 86,
Update makeUnrankedReferrerMetricsSchema so the eight fields that must be zero
are validated at parse time: totalReferrals, totalIncrementalDuration,
totalRevenueContribution, score, finalScoreBoost, finalScore, awardPoolShare,
and awardPoolApproxValue. Replace their current permissive schemas (e.g.,
makeNonNegativeIntegerSchema, makeDurationSchema, makePriceEthSchema,
makeFiniteNonNegativeNumberSchema, makePriceUsdcSchema) with explicit zero
constraints—either z.literal(0) for exact-zero or apply .min(0).max(0, "<field>
must be 0") to the existing schema factories—to ensure these fields can only be
0 and include a clear error message referencing the field via the valueLabel.

Comment on lines +118 to +148
export const validateReferrerLeaderboardPageContext = (
context: ReferrerLeaderboardPageContext,
): void => {
validateReferrerLeaderboardPageParams(context);
if (!isNonNegativeInteger(context.totalRecords)) {
throw new Error(
`Invalid ReferrerLeaderboardPageContext: total must be a non-negative integer but is ${context.totalRecords}.`,
);
}
const startIndex = (context.page - 1) * context.recordsPerPage;
const endIndex = startIndex + context.recordsPerPage;

if (!context.hasNext && endIndex < context.totalRecords) {
throw new Error(
`Invalid ReferrerLeaderboardPageContext: if hasNext is false, endIndex (${endIndex}) must be greater than or equal to total (${context.totalRecords}).`,
);
} else if (context.hasNext && context.page * context.recordsPerPage >= context.totalRecords) {
throw new Error(
`Invalid ReferrerLeaderboardPageContext: if hasNext is true, endIndex (${endIndex}) must be less than total (${context.totalRecords}).`,
);
}
if (!context.hasPrev && context.page !== 1) {
throw new Error(
`Invalid ReferrerLeaderboardPageContext: if hasPrev is false, page must be the first page (1) but is ${context.page}.`,
);
} else if (context.hasPrev && context.page === 1) {
throw new Error(
`Invalid ReferrerLeaderboardPageContext: if hasPrev is true, page must not be the first page (1) but is ${context.page}.`,
);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Context validator misses totalPages/startIndex/endIndex invariants.

validateReferrerLeaderboardPageContext doesn’t verify totalPages, page <= totalPages, or that startIndex/endIndex match derived values. This lets inconsistent contexts pass validation.

✅ Suggested validation additions
 export const validateReferrerLeaderboardPageContext = (
   context: ReferrerLeaderboardPageContext,
 ): void => {
   validateReferrerLeaderboardPageParams(context);
   if (!isNonNegativeInteger(context.totalRecords)) {
     throw new Error(
       `Invalid ReferrerLeaderboardPageContext: total must be a non-negative integer but is ${context.totalRecords}.`,
     );
   }
+  if (!isPositiveInteger(context.totalPages)) {
+    throw new Error(
+      `Invalid ReferrerLeaderboardPageContext: totalPages must be a positive integer but is ${context.totalPages}.`,
+    );
+  }
+  const expectedTotalPages = Math.max(
+    1,
+    Math.ceil(context.totalRecords / context.recordsPerPage),
+  );
+  if (context.totalPages !== expectedTotalPages) {
+    throw new Error(
+      `Invalid ReferrerLeaderboardPageContext: totalPages (${context.totalPages}) must equal ${expectedTotalPages}.`,
+    );
+  }
+  if (context.page > context.totalPages) {
+    throw new Error(
+      `Invalid ReferrerLeaderboardPageContext: page (${context.page}) must be <= totalPages (${context.totalPages}).`,
+    );
+  }

   const startIndex = (context.page - 1) * context.recordsPerPage;
   const endIndex = startIndex + context.recordsPerPage;
+  const expectedStartIndex = context.totalRecords === 0 ? undefined : startIndex;
+  const expectedEndIndex =
+    context.totalRecords === 0
+      ? undefined
+      : Math.min(endIndex - 1, context.totalRecords - 1);
+  if (context.totalRecords === 0) {
+    if (context.startIndex !== undefined || context.endIndex !== undefined) {
+      throw new Error(
+        `Invalid ReferrerLeaderboardPageContext: startIndex/endIndex must be undefined when totalRecords is 0.`,
+      );
+    }
+  } else if (context.startIndex !== expectedStartIndex || context.endIndex !== expectedEndIndex) {
+    throw new Error(
+      `Invalid ReferrerLeaderboardPageContext: startIndex/endIndex must be ${expectedStartIndex}/${expectedEndIndex} but are ${context.startIndex}/${context.endIndex}.`,
+    );
+  }

   if (!context.hasNext && endIndex < context.totalRecords) {
     throw new Error(
       `Invalid ReferrerLeaderboardPageContext: if hasNext is false, endIndex (${endIndex}) must be greater than or equal to total (${context.totalRecords}).`,
     );
   } else if (context.hasNext && context.page * context.recordsPerPage >= context.totalRecords) {
     throw new Error(
       `Invalid ReferrerLeaderboardPageContext: if hasNext is true, endIndex (${endIndex}) must be less than total (${context.totalRecords}).`,
     );
   }
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/leaderboard-page-v1.ts` around lines 118 - 148,
The validator must also check totalPages and that the provided
startIndex/endIndex match derived values: inside
validateReferrerLeaderboardPageContext compute expectedTotalPages =
Math.ceil(context.totalRecords / context.recordsPerPage) (handle zero records),
compute expectedStartIndex = (context.page - 1) * context.recordsPerPage and
expectedEndIndex = Math.min(expectedStartIndex + context.recordsPerPage,
context.totalRecords), then validate that context.totalPages is a non-negative
integer and equals expectedTotalPages, that context.page <= context.totalPages
(and valid for zero/empty cases), and that context.startIndex ===
expectedStartIndex and context.endIndex === expectedEndIndex (also ensure
endIndex >= startIndex); throw the same style of Error with clear messages when
any of these invariants fail.

Comment on lines +56 to +61
const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer);
if (uniqueReferrers.length !== allReferrers.length) {
throw new Error(
"ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers",
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Duplicate referrer detection is ineffective.
uniqueReferrers is just a copy of the input, so the length comparison always passes and duplicates will silently overwrite when building the Map. Use a Set to detect duplicates reliably.

🐛 Proposed fix
-  const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer);
-  if (uniqueReferrers.length !== allReferrers.length) {
+  const uniqueReferrers = new Set(allReferrers.map((referrer) => referrer.referrer));
+  if (uniqueReferrers.size !== allReferrers.length) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer);
if (uniqueReferrers.length !== allReferrers.length) {
throw new Error(
"ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers",
);
}
const uniqueReferrers = new Set(allReferrers.map((referrer) => referrer.referrer));
if (uniqueReferrers.size !== allReferrers.length) {
throw new Error(
"ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers",
);
}
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/leaderboard-v1.ts` around lines 56 - 61, The
duplicate-referrer check is wrong because uniqueReferrers is just a mapping
copy; replace it by creating a Set of referrer strings from allReferrers (e.g.,
new Set(allReferrers.map(r => r.referrer))) and compare set.size to
allReferrers.length inside buildReferrerLeaderboard (or wherever uniqueReferrers
is defined); if sizes differ, throw the existing Error so duplicates are
detected before constructing the Map and silently overwriting entries.

Comment on lines +40 to +47
export function calcReferrerFinalScoreBoost(
rank: ReferrerRank,
rules: ReferralProgramRules,
): number {
if (!isReferrerQualified(rank, rules)) return 0;

return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Division-by-zero when maxQualifiedReferrers === 1.

calcReferrerFinalScoreBoost divides by (maxQualifiedReferrers - 1), which becomes zero when only one referrer can qualify. This yields Infinity/NaN for the top-ranked referrer.

🛠️ Guard against the edge case
 export function calcReferrerFinalScoreBoost(
   rank: ReferrerRank,
   rules: ReferralProgramRules,
 ): number {
   if (!isReferrerQualified(rank, rules)) return 0;
 
+  if (rules.maxQualifiedReferrers === 1) return 1;
   return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function calcReferrerFinalScoreBoost(
rank: ReferrerRank,
rules: ReferralProgramRules,
): number {
if (!isReferrerQualified(rank, rules)) return 0;
return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
}
export function calcReferrerFinalScoreBoost(
rank: ReferrerRank,
rules: ReferralProgramRules,
): number {
if (!isReferrerQualified(rank, rules)) return 0;
if (rules.maxQualifiedReferrers === 1) return 1;
return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
}
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/rank-v1.ts` around lines 40 - 47,
calcReferrerFinalScoreBoost currently divides by (rules.maxQualifiedReferrers -
1) causing divide-by-zero when maxQualifiedReferrers === 1; update
calcReferrerFinalScoreBoost to guard that case by returning the correct boost
(return 1 for the sole qualified referrer) when rules.maxQualifiedReferrers <= 1
(and keep the existing isReferrerQualified check), otherwise compute 1 - (rank -
1) / (rules.maxQualifiedReferrers - 1) as before.

Comment on lines +280 to +303
export const buildAwardedReferrerMetrics = (
referrer: RankedReferrerMetrics,
aggregatedMetrics: AggregatedReferrerMetrics,
rules: ReferralProgramRules,
): AwardedReferrerMetrics => {
const awardPoolShare = calcReferrerAwardPoolShare(referrer, aggregatedMetrics, rules);

// Calculate the approximate USDC value by multiplying the share by the total award pool value
// We need to convert the share (a number between 0 and 1) to a bigint amount
const awardPoolApproxAmount = BigInt(
Math.floor(awardPoolShare * Number(rules.totalAwardPoolValue.amount)),
);

const result = {
...referrer,
awardPoolShare,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: awardPoolApproxAmount,
},
};
validateAwardedReferrerMetrics(result, rules);
return result;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid bigint → number conversion to prevent precision loss.

Number(rules.totalAwardPoolValue.amount) can exceed Number.MAX_SAFE_INTEGER, leading to rounding errors in awardPoolApproxAmount. This defeats the purpose of using bigint for currency amounts. Use fixed‑point scaling to keep bigint arithmetic and avoid overflow.

✅ Suggested fix (fixed-point scaling)
   // Calculate the approximate USDC value by multiplying the share by the total award pool value
   // We need to convert the share (a number between 0 and 1) to a bigint amount
-  const awardPoolApproxAmount = BigInt(
-    Math.floor(awardPoolShare * Number(rules.totalAwardPoolValue.amount)),
-  );
+  const SCALE = 1_000_000n;
+  const shareScaled = BigInt(Math.floor(awardPoolShare * Number(SCALE)));
+  const awardPoolApproxAmount =
+    (rules.totalAwardPoolValue.amount * shareScaled) / SCALE;
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/referrer-metrics-v1.ts` around lines 280 - 303, In
buildAwardedReferrerMetrics the code converts rules.totalAwardPoolValue.amount
to Number which can overflow; keep all math in bigint by using fixed‑point
scaling or an integer fraction instead of Number(). Change
calcReferrerAwardPoolShare usage so you either obtain a rational
numerator/denominator (or use a fixed SCALE as a BigInt) and compute
awardPoolApproxAmount with bigint arithmetic: (BigInt(numerator) *
rules.totalAwardPoolValue.amount) / BigInt(denominator) (or
(BigInt(Math.round(awardPoolShare*SCALE)) * totalAmount) / SCALE as BigInt),
then set awardPoolApproxValue.amount to that bigint and run
validateAwardedReferrerMetrics as before.

priceUsdc,
type UnixTimestamp,
} from "@ensnode/ensnode-sdk";
import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

subregistryId is never validated.

validateReferralProgramRules checks price, timestamps, and counts, but skips the subregistryId field, allowing invalid account IDs to slip through. Consider validating it alongside the other invariants.

🛠️ Suggested validation
-import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal";
+import { makeAccountIdSchema, makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal";
@@
   const parseResult = priceUsdcSchema.safeParse(rules.totalAwardPoolValue);
   if (!parseResult.success) {
     throw new Error(
       `ReferralProgramRules: totalAwardPoolValue validation failed: ${parseResult.error.message}`,
     );
   }
+
+  const accountIdSchema = makeAccountIdSchema("ReferralProgramRules.subregistryId");
+  const accountIdResult = accountIdSchema.safeParse(rules.subregistryId);
+  if (!accountIdResult.success) {
+    throw new Error(
+      `ReferralProgramRules: subregistryId validation failed: ${accountIdResult.error.message}`,
+    );
+  }
 
   validateNonNegativeInteger(rules.maxQualifiedReferrers);

Also applies to: 67-85

🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/rules-v1.ts` at line 7,
validateReferralProgramRules currently omits validation for the subregistryId
field; update the function (and the same checks around lines 67-85) to validate
subregistryId alongside price/timestamp/count invariants: locate the
validateReferralProgramRules function and add a check that subregistryId is
present and matches your expected account-id format (e.g., non-empty string and
the same pattern used elsewhere for ENS account IDs or a shared validator helper
if one exists), and throw or return an error when it fails so invalid account
IDs cannot pass through.

Comment on lines +14 to +15
export * from "./api/types-v1";
export * from "./api/zod-schemas-v1";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the current state of v1.ts
cat -n packages/ens-referrals/src/v1.ts

Repository: namehash/ensnode

Length of output: 1170


🏁 Script executed:

# Check the header and content of zod-schemas-v1.ts
head -40 packages/ens-referrals/src/api/zod-schemas-v1.ts

Repository: namehash/ensnode

Length of output: 1481


🏁 Script executed:

# Check if internal.ts exists and its content
cat -n packages/ens-referrals/src/internal.ts 2>/dev/null || echo "internal.ts not found"

Repository: namehash/ensnode

Length of output: 723


🏁 Script executed:

# List all files in the src directory to understand the structure
find packages/ens-referrals/src -type f -name "*.ts" | head -20

Repository: namehash/ensnode

Length of output: 941


Remove Zod schemas from public v1 entry point.

The header in packages/ens-referrals/src/api/zod-schemas-v1.ts explicitly states that schemas must remain internal and only be re-exported from internal.ts. Re-exporting from the public v1 entry point violates this policy and locks an internal implementation detail into the public API surface. Remove this export from line 15.

🔧 Suggested change
 export * from "./api/types-v1";
-export * from "./api/zod-schemas-v1";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export * from "./api/types-v1";
export * from "./api/zod-schemas-v1";
export * from "./api/types-v1";
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1.ts` around lines 14 - 15, Remove the public
re-export of the internal Zod schemas by deleting the export of
"./api/zod-schemas-v1" from the v1 public entry (leave "export * from
\"./api/types-v1\""), so the internal module "zod-schemas-v1" is no longer
exposed; ensure any consumers that relied on that export are updated to import
from the internal re-export location (internal.ts) instead and run the
build/tests to confirm no public API leak remains.

): number {
if (!isReferrerQualified(rank, rules)) return 0;

return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calcReferrerFinalScoreBoost function will return NaN when maxQualifiedReferrers is 1, because it performs division by (maxQualifiedReferrers - 1) which equals 0.

View Details
📝 Patch Details
diff --git a/packages/ens-referrals/src/rank-v1.ts b/packages/ens-referrals/src/rank-v1.ts
index 7ddc54e2..7535c2f4 100644
--- a/packages/ens-referrals/src/rank-v1.ts
+++ b/packages/ens-referrals/src/rank-v1.ts
@@ -43,6 +43,9 @@ export function calcReferrerFinalScoreBoost(
 ): number {
   if (!isReferrerQualified(rank, rules)) return 0;
 
+  // Handle edge case: only 1 qualified referrer
+  if (rules.maxQualifiedReferrers === 1) return 1;
+
   return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
 }
 
diff --git a/packages/ens-referrals/src/rank.ts b/packages/ens-referrals/src/rank.ts
index 093fde9f..e37d6b5e 100644
--- a/packages/ens-referrals/src/rank.ts
+++ b/packages/ens-referrals/src/rank.ts
@@ -43,6 +43,9 @@ export function calcReferrerFinalScoreBoost(
 ): number {
   if (!isReferrerQualified(rank, rules)) return 0;
 
+  // Handle edge case: only 1 qualified referrer
+  if (rules.maxQualifiedReferrers === 1) return 1;
+
   return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
 }
 

Analysis

Division by zero in calcReferrerFinalScoreBoost causes NaN when maxQualifiedReferrers is 1

What fails: The calcReferrerFinalScoreBoost() function in packages/ens-referrals/src/rank-v1.ts (and rank.ts) returns NaN when maxQualifiedReferrers === 1 and rank === 1.

How to reproduce:

const rules = {
  totalAwardPoolValue: priceUsdc(10_000_000_000n),
  maxQualifiedReferrers: 1,  // Only 1 qualified referrer
  startTime: 1764547200,
  endTime: 1767225599,
  subregistryId: "0x123"
};

const result = calcReferrerFinalScoreBoost(1, rules);
// Returns: NaN

What happens vs expected:

  • Current behavior: The calculation 1 - (1 - 1) / (1 - 1) evaluates to 1 - 0/0 = 1 - NaN = NaN
  • Expected behavior: The function should return a valid number between 0 and 1 (specifically 1.0, since the only qualified referrer should receive the full boost)

Impact: The NaN value cascades through downstream functions:

  • calcReferrerFinalScoreMultiplier() receives NaN and returns 1 + NaN = NaN
  • calcReferrerFinalScore() receives NaN and returns score * NaN = NaN
  • buildRankedReferrerMetrics() creates invalid metrics with NaN values
  • Validation in validateRankedReferrerMetrics() fails when checking that finalScoreBoost must be between 0 and 1

Fix: Added a special case check for maxQualifiedReferrers === 1 to return 1 (full boost) since there is only one qualified referrer. This is mathematically consistent with the linear interpolation behavior of the original formula for larger values.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts`:
- Around line 44-88: The SUM(incrementalDuration) can be NULL when all rows are
NULL, so change the select for totalIncrementalDuration to mirror revenue and
wrap the sum in COALESCE to default to 0; specifically replace
sum(schema.registrarActions.incrementalDuration).as("total_incremental_duration")
with sql`COALESCE(SUM(${schema.registrarActions.incrementalDuration}),
0)`.as("total_incremental_duration") so
deserializeDuration(record.totalIncrementalDuration) and the ordering by
total_incremental_duration are safe and non-null.

In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts`:
- Around line 949-1540: The referrerLeaderboardPageResponseOk fixture duplicates
the populatedReferrerLeaderboard data; replace the repeated literal by
constructing referrerLeaderboardPageResponseOk from the existing
populatedReferrerLeaderboard fixture (e.g., import/populate
populatedReferrerLeaderboard and then build referrerLeaderboardPageResponseOk by
referencing its properties for data.rules, data.referrers,
data.aggregatedMetrics, data.pageContext and data.accurateAsOf) so changes to
populatedReferrerLeaderboard automatically flow to
referrerLeaderboardPageResponseOk and avoid drift; ensure the top-level
responseCode remains ReferrerLeaderboardPageResponseCodes.Ok and any
BigInt/const types are preserved when mapping.
- Around line 282-947: The populatedReferrerLeaderboard fixture has several
referrer entries (inside populatedReferrerLeaderboard.referrers Map) whose
totalRevenueContribution.amount are incorrectly set to 6_000_000_000_000_000n
but should match the distinct values in dbResultsReferrerLeaderboard (e.g.,
0.007, 0.0065, 0.0075, 0.008, 0.0085 ETH → 7_000_000_000_000_000n,
6_500_000_000_000_000n, 7_500_000_000_000_000n, 8_000_000_000_000_000n,
8_500_000_000_000_000n); locate the affected referrer keys in the Map (those
currently using 6_000_000_000_000_000n) and replace each amount to the exact
big-int values from dbResultsReferrerLeaderboard, then update any dependent
fields in populatedReferrerLeaderboard (scores, awardPoolShare,
awardPoolApproxValue, and aggregatedMetrics like grandTotalRevenueContribution)
so the fixture stays consistent with the source.

In `@packages/ens-referrals/src/aggregations-v1.ts`:
- Around line 69-72: The function buildAggregatedReferrerMetrics assumes
referrers is a complete, globally ranked list starting at rank 1 (the invariant
check expects ranks to be absolute), so add a clear JSDoc on the
buildAggregatedReferrerMetrics export stating that referrers must contain a full
ranked list with ranks starting at 1 (not a paginated/partial slice), and
document the expected shape and consequences (that maxQualifiedReferrers > 0
with no qualified referrers will throw) so callers know to pass the full ranking
or to pre-aggregate before calling.

In `@packages/ens-referrals/src/api/types-v1.ts`:
- Around line 46-50: The ReferrerLeaderboardPageResponseError type (and
ReferrerDetailResponseError) currently carries both error and errorMessage;
decide and implement a single consistent pattern: either consolidate into one
field (e.g., errorMessage: string) by removing the redundant property and
updating all usages of ReferrerLeaderboardPageResponseError and
ReferrerDetailResponseError, or explicitly document and rename to make the
distinction clear (e.g., errorCode: string for machine-readable values and
message: string for human-readable text) and update all references and
serializers/deserializers accordingly; update the type definitions
(ReferrerLeaderboardPageResponseError, ReferrerDetailResponseError) and any code
that constructs or reads these objects to match the chosen shape and ensure
tests/API clients reflect the change.

In `@packages/ens-referrals/src/api/zod-schemas-v1.ts`:
- Around line 110-125: The schema makeReferrerLeaderboardPageContextSchema
currently allows optional startIndex and endIndex but doesn't enforce endIndex
>= startIndex; update the returned z.object to add a refinement (using .refine
or .superRefine on the object) that, when both startIndex and endIndex are
defined, asserts endIndex >= startIndex and returns a clear error path (e.g.,
`${valueLabel}.endIndex` or `${valueLabel}.startIndex`) on failure; reference
the object produced by makeReferrerLeaderboardPageContextSchema and the
startIndex/endIndex fields when implementing the check so the validator triggers
only when both values are present.

In `@packages/ens-referrals/src/leaderboard-v1.ts`:
- Around line 56-61: The duplicate detection is wrong because uniqueReferrers is
created with map which preserves length; replace it with a Set-based check:
compute const uniqueReferrerSet = new Set(allReferrers.map(r => r.referrer)) and
then compare uniqueReferrerSet.size to allReferrers.length (or recreate
uniqueReferrers via Array.from(uniqueReferrerSet)) in the
buildReferrerLeaderboard logic to properly detect duplicates and throw the same
Error when sizes differ; update references to uniqueReferrers accordingly.

In `@packages/ens-referrals/src/referrer-metrics-v1.ts`:
- Around line 287-300: The current calculation for awardPoolApproxAmount loses
precision by converting rules.totalAwardPoolValue.amount (a bigint) to Number;
instead compute the amount using precise integer or big-decimal arithmetic:
either represent awardPoolShare as an integer fraction (numerator/denominator)
and compute awardPoolApproxAmount = (BigInt(rules.totalAwardPoolValue.amount) *
numerator) / denominator, or use a BigNumber/Decimal library to multiply a
Decimal(awardPoolShare) by the bigint total and round/floor to a bigint; update
the code around awardPoolApproxAmount, awardPoolShare and
result.awardPoolApproxValue to use the chosen precise method.

In `@packages/ens-referrals/src/rules-v1.ts`:
- Around line 67-86: The function validateReferralProgramRules currently omits
validation for the subregistryId field; add a call to the existing AccountId
validator (e.g., validateAccountId) to check rules.subregistryId inside
validateReferralProgramRules, placed alongside the other field checks (after
totalAwardPoolValue parsing and before/after maxQualifiedReferrers validation),
and throw a clear Error if validation fails so the code validates that
subregistryId is a valid AccountId.

In `@packages/ensnode-sdk/src/shared/serialize.ts`:
- Around line 23-36: Add a short maintenance note next to the duplicated
uint256ToHex32 definition explaining that this is an intentional inline copy of
the implementation in ../ens/subname-helpers.ts due to Vite SSR resolution, and
include a TODO/link or issue number to track changes so both implementations
stay synchronized; reference the function name uint256ToHex32 and the original
file subname-helpers.ts in the comment to make future updates clear.
♻️ Duplicate comments (9)
packages/ensnode-sdk/src/shared/zod-schemas.ts (1)

16-23: Unused import Price should be removed.

The Price import on line 19 is not used anywhere in this file. This was previously flagged and remains unresolved.

Suggested fix
 import {
   type CurrencyId,
   CurrencyIds,
-  Price,
   type PriceDai,
   type PriceEth,
   type PriceUsdc,
 } from "./currencies";
packages/ens-referrals/src/rank-v1.ts (1)

40-47: Division by zero when maxQualifiedReferrers is 1.

When rules.maxQualifiedReferrers === 1 and rank === 1, line 46 computes (rank - 1) / (rules.maxQualifiedReferrers - 1) which evaluates to 0 / 0 = NaN, causing the function to return NaN.

Proposed fix
 export function calcReferrerFinalScoreBoost(
   rank: ReferrerRank,
   rules: ReferralProgramRules,
 ): number {
   if (!isReferrerQualified(rank, rules)) return 0;
 
+  // When only one referrer can qualify, that referrer gets the maximum boost
+  if (rules.maxQualifiedReferrers === 1) return 1;
+
   return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1);
 }
packages/ens-referrals/src/api/types-v1.ts (1)

12-12: Consider using a type alias instead of an empty interface.

An interface that declares no additional members is equivalent to its supertype. Using a type alias is more explicit.

packages/ens-referrals/src/api/serialized-types-v1.ts (1)

8-15: Remove unused imports.

ReferrerDetailResponse (line 9) and ReferrerLeaderboardPageResponse (line 12) are imported but never used in this file. Only their constituent types (*Ok and *Error variants) are used.

packages/ens-referrals/src/api/zod-schemas-v1.ts (3)

25-25: Remove unused import ReferrerDetailRanked.

The type ReferrerDetailRanked is imported but only used in JSDoc comments, not in code. The ReferrerDetailTypeIds import is sufficient.


153-160: Remove unused _valueLabel parameter or use it.

The _valueLabel parameter is declared but never used in makeReferrerLeaderboardPageResponseErrorSchema. Either remove it or use it for field descriptions like the other schema factories.


213-220: Remove unused _valueLabel parameter or use it.

Same issue as above - _valueLabel is declared but unused in makeReferrerDetailResponseErrorSchema.

packages/ens-referrals/src/api/serialize-v1.ts (1)

164-187: Unreachable break statement after nested switch.

The break on line 182 follows a nested switch where both branches ("ranked" and "unranked") return early. If TypeScript's type narrowing ensures exhaustiveness, this break is dead code. If not, the function could implicitly return undefined.

Consider removing the break since each nested case already returns, or add an exhaustive check after the nested switch for defensive coding:

Suggested fix
         case "unranked":
           return {
             responseCode: response.responseCode,
             data: serializeReferrerDetailUnranked(response.data),
           };
       }
-      break;

     case ReferrerDetailResponseCodes.Error:
       return response;
   }
 }
packages/ens-referrals/src/api/deserialize-v1.ts (1)

190-229: Potential uninitialized variable if nested switch is non-exhaustive.

The let deserialized variable (line 194) may remain unassigned if maybeResponse.data.type doesn't match "ranked" or "unranked". The break on line 212 would exit the outer switch, leaving deserialized undefined when accessed on line 222.

If TypeScript's type system guarantees exhaustiveness of the nested switch, the break is dead code. Otherwise, this could cause a runtime error. Consider restructuring to eliminate the intermediate variable or adding an exhaustive guard.

Suggested fix
 export function deserializeReferrerDetailResponse(
   maybeResponse: SerializedReferrerDetailResponse,
   valueLabel?: string,
 ): ReferrerDetailResponse {
-  let deserialized: ReferrerDetailResponse;
   switch (maybeResponse.responseCode) {
     case "ok": {
+      let data: ReferrerDetailRanked | ReferrerDetailUnranked;
       switch (maybeResponse.data.type) {
         case "ranked":
-          deserialized = {
-            responseCode: maybeResponse.responseCode,
-            data: deserializeReferrerDetailRanked(maybeResponse.data),
-          } as ReferrerDetailResponse;
+          data = deserializeReferrerDetailRanked(maybeResponse.data);
           break;
-
         case "unranked":
-          deserialized = {
-            responseCode: maybeResponse.responseCode,
-            data: deserializeReferrerDetailUnranked(maybeResponse.data),
-          } as ReferrerDetailResponse;
+          data = deserializeReferrerDetailUnranked(maybeResponse.data);
           break;
       }
-      break;
+      const deserialized = {
+        responseCode: maybeResponse.responseCode,
+        data,
+      } as ReferrerDetailResponse;
+      const schema = makeReferrerDetailResponseSchema(valueLabel);
+      const parsed = schema.safeParse(deserialized);
+      if (parsed.error) {
+        throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
+      }
+      return parsed.data;
     }
-
-    case "error":
-      deserialized = maybeResponse;
-      break;
+    case "error": {
+      const schema = makeReferrerDetailResponseSchema(valueLabel);
+      const parsed = schema.safeParse(maybeResponse);
+      if (parsed.error) {
+        throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
+      }
+      return parsed.data;
+    }
   }
-
-  // Then validate the deserialized structure using zod schemas
-  const schema = makeReferrerDetailResponseSchema(valueLabel);
-  const parsed = schema.safeParse(deserialized);
-
-  if (parsed.error) {
-    throw new Error(`Cannot deserialize ReferrerDetailResponse:\n${prettifyError(parsed.error)}\n`);
-  }
-
-  return parsed.data;
 }

Comment on lines +44 to +88
totalIncrementalDuration: sum(schema.registrarActions.incrementalDuration).as(
"total_incremental_duration",
),
// Note: Using raw SQL for COALESCE because Drizzle doesn't natively support it yet.
// See: https://github.com/drizzle-team/drizzle-orm/issues/3708
totalRevenueContribution:
sql<string>`COALESCE(SUM(${schema.registrarActions.total}), 0)`.as(
"total_revenue_contribution",
),
})
.from(schema.registrarActions)
.where(
and(
// Filter by timestamp range
gte(schema.registrarActions.timestamp, BigInt(rules.startTime)),
lte(schema.registrarActions.timestamp, BigInt(rules.endTime)),
// Filter by decodedReferrer not null
isNotNull(schema.registrarActions.decodedReferrer),
// Filter by decodedReferrer not zero address
ne(schema.registrarActions.decodedReferrer, zeroAddress),
// Filter by subregistryId matching the provided subregistryId
eq(schema.registrarActions.subregistryId, formatAccountId(rules.subregistryId)),
),
)
.groupBy(schema.registrarActions.decodedReferrer)
.orderBy(desc(sql`total_incremental_duration`));

// Type assertion: The WHERE clause in the query above guarantees non-null values for:
// 1. `referrer` is guaranteed to be non-null due to isNotNull filter
// 2. `totalIncrementalDuration` is guaranteed to be non-null as it is the sum of non-null bigint values
// 3. `totalRevenueContribution` is guaranteed to be non-null due to COALESCE with 0
interface NonNullRecord {
referrer: Address;
totalReferrals: number;
totalIncrementalDuration: string;
totalRevenueContribution: string;
}

return (records as NonNullRecord[]).map((record) => {
return buildReferrerMetrics(
record.referrer,
record.totalReferrals,
deserializeDuration(record.totalIncrementalDuration),
priceEth(BigInt(record.totalRevenueContribution)),
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against NULL totalIncrementalDuration sums.
SUM(incrementalDuration) returns NULL if all rows are NULL for a referrer, but Line 73 assumes non-null and deserializeDuration will then throw. Mirror the COALESCE pattern used for revenue.

🐛 Suggested fix
-        totalIncrementalDuration: sum(schema.registrarActions.incrementalDuration).as(
-          "total_incremental_duration",
-        ),
+        totalIncrementalDuration:
+          sql<string>`COALESCE(SUM(${schema.registrarActions.incrementalDuration}), 0)`.as(
+            "total_incremental_duration",
+          ),
🤖 Prompt for AI Agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/database-v1.ts` around
lines 44 - 88, The SUM(incrementalDuration) can be NULL when all rows are NULL,
so change the select for totalIncrementalDuration to mirror revenue and wrap the
sum in COALESCE to default to 0; specifically replace
sum(schema.registrarActions.incrementalDuration).as("total_incremental_duration")
with sql`COALESCE(SUM(${schema.registrarActions.incrementalDuration}),
0)`.as("total_incremental_duration") so
deserializeDuration(record.totalIncrementalDuration) and the ordering by
total_incremental_duration are safe and non-null.

Comment on lines +282 to +947
export const populatedReferrerLeaderboard: ReferrerLeaderboard = {
rules: {
totalAwardPoolValue: {
currency: "USDC" as const,
amount: 10_000_000_000n,
},
maxQualifiedReferrers: 10,
startTime: 1735689600,
endTime: 1767225599,
subregistryId: {
chainId: 1,
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
},
aggregatedMetrics: {
grandTotalReferrals: 68,
grandTotalIncrementalDuration: 367027203,
grandTotalRevenueContribution: {
currency: "ETH" as const,
amount: 235_000_000_000_000_000n,
}, // 0.235 ETH
grandTotalQualifiedReferrersFinalScore: 16.55216891669386,
minFinalScoreToQualify: 0,
},
referrers: new Map([
[
"0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e",
{
referrer: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e",
totalReferrals: 3,
totalIncrementalDuration: 94694400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 15_000_000_000_000_000n,
}, // 0.015 ETH
score: 3.0007460796594043,
rank: 1,
isQualified: true,
finalScoreBoost: 1,
finalScore: 6.001492159318809,
awardPoolShare: 0.36258040801323277,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(3625.8040801323277 * 1000000)),
},
},
],
[
"0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca",
{
referrer: "0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca",
totalReferrals: 2,
totalIncrementalDuration: 63072000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 10_000_000_000_000_000n,
}, // 0.01 ETH
score: 1.9986721151016105,
rank: 2,
isQualified: true,
finalScoreBoost: 0.8888888888888888,
finalScore: 3.7752695507474865,
awardPoolShare: 0.22808307296452854,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(2280.8307296452854 * 1000000)),
},
},
],
[
"0x00000000000000000000000000000000000000f1",
{
referrer: "0x00000000000000000000000000000000000000f1",
totalReferrals: 3,
totalIncrementalDuration: 39657600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 12_000_000_000_000_000n,
}, // 0.012 ETH
score: 1.256699316207725,
rank: 3,
isQualified: true,
finalScoreBoost: 0.7777777777777778,
finalScore: 2.234132117702622,
awardPoolShare: 0.1349751883844881,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(1349.7518838448811 * 1000000)),
},
},
],
[
"0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
{
referrer: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
totalReferrals: 4,
totalIncrementalDuration: 34214400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 18_000_000_000_000_000n,
}, // 0.018 ETH
score: 1.084211174767449,
rank: 4,
isQualified: true,
finalScoreBoost: 0.6666666666666667,
finalScore: 1.8070186246124151,
awardPoolShare: 0.10917110825215952,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(1091.7110825215952 * 1000000)),
},
},
],
[
"0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a",
{
referrer: "0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a",
totalReferrals: 7,
totalIncrementalDuration: 15120000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 21_000_000_000_000_000n,
}, // 0.021 ETH
score: 0.47913372622298883,
rank: 5,
isQualified: true,
finalScoreBoost: 0.5555555555555556,
finalScore: 0.7453191296802049,
awardPoolShare: 0.04502848741040249,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(450.2848741040249 * 1000000)),
},
},
],
[
"0xffa596cdf9a69676e689b1a92e5e681711227d75",
{
referrer: "0xffa596cdf9a69676e689b1a92e5e681711227d75",
totalReferrals: 5,
totalIncrementalDuration: 12960000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 16_000_000_000_000_000n,
}, // 0.016 ETH
score: 0.41068605104827616,
rank: 6,
isQualified: true,
finalScoreBoost: 0.4444444444444444,
finalScore: 0.59321318484751,
awardPoolShare: 0.035839000183789736,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(358.3900018378974 * 1000000)),
},
},
],
[
"0x2a614b7984854177d22fa23a4034a13ea82e4f97",
{
referrer: "0x2a614b7984854177d22fa23a4034a13ea82e4f97",
totalReferrals: 5,
totalIncrementalDuration: 12096000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 14_000_000_000_000_000n,
}, // 0.014 ETH
score: 0.38330698097839105,
rank: 7,
isQualified: true,
finalScoreBoost: 0.33333333333333337,
finalScore: 0.5110759746378548,
awardPoolShare: 0.030876677081418856,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(308.76677081418853 * 1000000)),
},
},
],
[
"0x2382a5878a44a6de5c3d91537d4132dc29e93c60",
{
referrer: "0x2382a5878a44a6de5c3d91537d4132dc29e93c60",
totalReferrals: 4,
totalIncrementalDuration: 9676800,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 13_000_000_000_000_000n,
}, // 0.013 ETH
score: 0.30664558478271287,
rank: 8,
isQualified: true,
finalScoreBoost: 0.2222222222222222,
finalScore: 0.3747890480677602,
awardPoolShare: 0.022642896526373826,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(226.42896526373826 * 1000000)),
},
},
],
[
"0x0000ffa596cdf9a69676e689b1a92e5e68171122",
{
referrer: "0x0000ffa596cdf9a69676e689b1a92e5e68171122",
totalReferrals: 1,
totalIncrementalDuration: 7948800,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 5_000_000_000_000_000n,
}, // 0.005 ETH
score: 0.2518874446429427,
rank: 9,
isQualified: true,
finalScoreBoost: 0.11111111111111116,
finalScore: 0.2798749384921586,
awardPoolShare: 0.016908656496967468,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(169.08656496967467 * 1000000)),
},
},
],
[
"0xc7190732aa0c3d523d945530bec6caeb8489b4a5",
{
referrer: "0xc7190732aa0c3d523d945530bec6caeb8489b4a5",
totalReferrals: 3,
totalIncrementalDuration: 7257600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 9_000_000_000_000_000n,
}, // 0.009 ETH
score: 0.22998418858703465,
rank: 10,
isQualified: true,
finalScoreBoost: 0,
finalScore: 0.22998418858703465,
awardPoolShare: 0.013894504686638484,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(138.94504686638484 * 1000000)),
},
},
],
[
"0x98c54f630c38c434cff2a1e3be9e095977cdc6af",
{
referrer: "0x98c54f630c38c434cff2a1e3be9e095977cdc6af",
totalReferrals: 3,
totalIncrementalDuration: 7257600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 9_000_000_000_000_000n,
}, // 0.009 ETH
score: 0.22998418858703465,
rank: 11,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.22998418858703465,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0xadc318567a4a16db3839208b435184ae86ba3e43",
{
referrer: "0xadc318567a4a16db3839208b435184ae86ba3e43",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 12,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf",
{
referrer: "0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 13,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6",
{
referrer: "0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 14,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x58879236e40b73482f585a5f74766d6b99cb1057",
{
referrer: "0x58879236e40b73482f585a5f74766d6b99cb1057",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 15,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481",
{
referrer: "0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 16,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x2254f9bab9b3d56994504c46932289447a708529",
{
referrer: "0x2254f9bab9b3d56994504c46932289447a708529",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 17,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x54e7c79aceb6b736da4c29da088aae30991635bb",
{
referrer: "0x54e7c79aceb6b736da4c29da088aae30991635bb",
totalReferrals: 2,
totalIncrementalDuration: 4579200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 0n,
}, // 0 ETH
score: 0.14510907137039092,
rank: 18,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.14510907137039092,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c",
{
referrer: "0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c",
totalReferrals: 1,
totalIncrementalDuration: 3974400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 0n,
}, // 0 ETH
score: 0.12594372232147136,
rank: 19,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.12594372232147136,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0",
{
referrer: "0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0",
totalReferrals: 1,
totalIncrementalDuration: 3628800,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 4_000_000_000_000_000n,
}, // 0.004 ETH
score: 0.11499209429351732,
rank: 20,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.11499209429351732,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x8354d821a89cc3c37902b60e9f30a15a6f810096",
{
referrer: "0x8354d821a89cc3c37902b60e9f30a15a6f810096",
totalReferrals: 2,
totalIncrementalDuration: 2505600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 5_500_000_000_000_000n,
}, // 0.0055 ETH
score: 0.07939930320266672,
rank: 21,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07939930320266672,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f",
{
referrer: "0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f",
totalReferrals: 3,
totalIncrementalDuration: 2419203,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 11_000_000_000_000_000n,
}, // 0.011 ETH
score: 0.07666149126189374,
rank: 22,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666149126189374,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0xf5746ef53ed961afd3b2a6c6d13de65e1605d215",
{
referrer: "0xf5746ef53ed961afd3b2a6c6d13de65e1605d215",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 23,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0xf35d9e265d20096af90a891205020ffab9291c8b",
{
referrer: "0xf35d9e265d20096af90a891205020ffab9291c8b",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 24,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f",
{
referrer: "0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 25,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64",
{
referrer: "0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 26,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x531a360408b69dcf325115921064c6e784cdc297",
{
referrer: "0x531a360408b69dcf325115921064c6e784cdc297",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 27,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x3d93f8a930023263c17a639580525a561072458c",
{
referrer: "0x3d93f8a930023263c17a639580525a561072458c",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 28,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
[
"0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4",
{
referrer: "0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 29,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
]),
accurateAsOf: 1735689600,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix drift between dbResults and populated leaderboard metrics.
In Line 550-668, several totalRevenueContribution.amount values are set to 0.006 ETH, but the corresponding entries in dbResultsReferrerLeaderboard (Lines 118-161) have distinct amounts (e.g., 0.007, 0.0065, 0.0075, 0.008, 0.0085). This makes the populated fixture inconsistent with its source metrics and can mask aggregation bugs.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts` around
lines 282 - 947, The populatedReferrerLeaderboard fixture has several referrer
entries (inside populatedReferrerLeaderboard.referrers Map) whose
totalRevenueContribution.amount are incorrectly set to 6_000_000_000_000_000n
but should match the distinct values in dbResultsReferrerLeaderboard (e.g.,
0.007, 0.0065, 0.0075, 0.008, 0.0085 ETH → 7_000_000_000_000_000n,
6_500_000_000_000_000n, 7_500_000_000_000_000n, 8_000_000_000_000_000n,
8_500_000_000_000_000n); locate the affected referrer keys in the Map (those
currently using 6_000_000_000_000_000n) and replace each amount to the exact
big-int values from dbResultsReferrerLeaderboard, then update any dependent
fields in populatedReferrerLeaderboard (scores, awardPoolShare,
awardPoolApproxValue, and aggregatedMetrics like grandTotalRevenueContribution)
so the fixture stays consistent with the source.

Comment on lines +949 to +1540
export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseOk = {
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
data: {
rules: {
totalAwardPoolValue: {
currency: "USDC" as const,
amount: 10_000_000_000n,
},
maxQualifiedReferrers: 10,
startTime: 1735689600,
endTime: 1767225599,
subregistryId: {
chainId: 1,
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
},
referrers: [
{
referrer: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e",
totalReferrals: 3,
totalIncrementalDuration: 94694400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 15_000_000_000_000_000n,
}, // 0.015 ETH
score: 3.0007460796594043,
rank: 1,
isQualified: true,
finalScoreBoost: 1,
finalScore: 6.001492159318809,
awardPoolShare: 0.36258040801323277,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(3625.8040801323277 * 1000000)),
},
},
{
referrer: "0xcfa4f8192ad39d1ee09f473e88e79d267e09ddca",
totalReferrals: 2,
totalIncrementalDuration: 63072000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 10_000_000_000_000_000n,
}, // 0.01 ETH
score: 1.9986721151016105,
rank: 2,
isQualified: true,
finalScoreBoost: 0.8888888888888888,
finalScore: 3.7752695507474865,
awardPoolShare: 0.22808307296452854,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(2280.8307296452854 * 1000000)),
},
},
{
referrer: "0x00000000000000000000000000000000000000f1",
totalReferrals: 3,
totalIncrementalDuration: 39657600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 12_000_000_000_000_000n,
}, // 0.012 ETH
score: 1.256699316207725,
rank: 3,
isQualified: true,
finalScoreBoost: 0.7777777777777778,
finalScore: 2.234132117702622,
awardPoolShare: 0.1349751883844881,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(1349.7518838448811 * 1000000)),
},
},
{
referrer: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
totalReferrals: 4,
totalIncrementalDuration: 34214400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 18_000_000_000_000_000n,
}, // 0.018 ETH
score: 1.084211174767449,
rank: 4,
isQualified: true,
finalScoreBoost: 0.6666666666666667,
finalScore: 1.8070186246124151,
awardPoolShare: 0.10917110825215952,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(1091.7110825215952 * 1000000)),
},
},
{
referrer: "0xabe3fdb4d2cd5f2e7193a4ac380ecb68e899896a",
totalReferrals: 7,
totalIncrementalDuration: 15120000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 21_000_000_000_000_000n,
}, // 0.021 ETH
score: 0.47913372622298883,
rank: 5,
isQualified: true,
finalScoreBoost: 0.5555555555555556,
finalScore: 0.7453191296802049,
awardPoolShare: 0.04502848741040249,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(450.2848741040249 * 1000000)),
},
},
{
referrer: "0xffa596cdf9a69676e689b1a92e5e681711227d75",
totalReferrals: 5,
totalIncrementalDuration: 12960000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 16_000_000_000_000_000n,
}, // 0.016 ETH
score: 0.41068605104827616,
rank: 6,
isQualified: true,
finalScoreBoost: 0.4444444444444444,
finalScore: 0.59321318484751,
awardPoolShare: 0.035839000183789736,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(358.3900018378974 * 1000000)),
},
},
{
referrer: "0x2a614b7984854177d22fa23a4034a13ea82e4f97",
totalReferrals: 5,
totalIncrementalDuration: 12096000,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 14_000_000_000_000_000n,
}, // 0.014 ETH
score: 0.38330698097839105,
rank: 7,
isQualified: true,
finalScoreBoost: 0.33333333333333337,
finalScore: 0.5110759746378548,
awardPoolShare: 0.030876677081418856,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(308.76677081418853 * 1000000)),
},
},
{
referrer: "0x2382a5878a44a6de5c3d91537d4132dc29e93c60",
totalReferrals: 4,
totalIncrementalDuration: 9676800,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 13_000_000_000_000_000n,
}, // 0.013 ETH
score: 0.30664558478271287,
rank: 8,
isQualified: true,
finalScoreBoost: 0.2222222222222222,
finalScore: 0.3747890480677602,
awardPoolShare: 0.022642896526373826,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(226.42896526373826 * 1000000)),
},
},
{
referrer: "0x0000ffa596cdf9a69676e689b1a92e5e68171122",
totalReferrals: 1,
totalIncrementalDuration: 7948800,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 5_000_000_000_000_000n,
}, // 0.005 ETH
score: 0.2518874446429427,
rank: 9,
isQualified: true,
finalScoreBoost: 0.11111111111111116,
finalScore: 0.2798749384921586,
awardPoolShare: 0.016908656496967468,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(169.08656496967467 * 1000000)),
},
},
{
referrer: "0xc7190732aa0c3d523d945530bec6caeb8489b4a5",
totalReferrals: 3,
totalIncrementalDuration: 7257600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 9_000_000_000_000_000n,
}, // 0.009 ETH
score: 0.22998418858703465,
rank: 10,
isQualified: true,
finalScoreBoost: 0,
finalScore: 0.22998418858703465,
awardPoolShare: 0.013894504686638484,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(138.94504686638484 * 1000000)),
},
},
{
referrer: "0x98c54f630c38c434cff2a1e3be9e095977cdc6af",
totalReferrals: 3,
totalIncrementalDuration: 7257600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 9_000_000_000_000_000n,
}, // 0.009 ETH
score: 0.22998418858703465,
rank: 11,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.22998418858703465,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0xadc318567a4a16db3839208b435184ae86ba3e43",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 12,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x7e5d0cdd8144d0ec6ef7140e65714c011d462dbf",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 13,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x71afe4867bef795a686d13f4dc60bc8d3a4e70f6",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 14,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x58879236e40b73482f585a5f74766d6b99cb1057",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 15,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x32eccaf03d59d87c8a164cffea7cb0c4b3b9d481",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 16,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x2254f9bab9b3d56994504c46932289447a708529",
totalReferrals: 2,
totalIncrementalDuration: 4838400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 6_000_000_000_000_000n,
}, // 0.006 ETH
score: 0.15332279239135643,
rank: 17,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.15332279239135643,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x54e7c79aceb6b736da4c29da088aae30991635bb",
totalReferrals: 2,
totalIncrementalDuration: 4579200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 0n,
}, // 0 ETH
score: 0.14510907137039092,
rank: 18,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.14510907137039092,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c",
totalReferrals: 1,
totalIncrementalDuration: 3974400,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 0n,
}, // 0 ETH
score: 0.12594372232147136,
rank: 19,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.12594372232147136,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0xce5ecf6d9e2181ad77b53305e2b1b6eca54728f0",
totalReferrals: 1,
totalIncrementalDuration: 3628800,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 4_000_000_000_000_000n,
}, // 0.004 ETH
score: 0.11499209429351732,
rank: 20,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.11499209429351732,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x8354d821a89cc3c37902b60e9f30a15a6f810096",
totalReferrals: 2,
totalIncrementalDuration: 2505600,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 5_500_000_000_000_000n,
}, // 0.0055 ETH
score: 0.07939930320266672,
rank: 21,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07939930320266672,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x7bddd635be34bcf860d5f02ae53b16fcd17e8f6f",
totalReferrals: 3,
totalIncrementalDuration: 2419203,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 11_000_000_000_000_000n,
}, // 0.011 ETH
score: 0.07666149126189374,
rank: 22,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666149126189374,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0xf5746ef53ed961afd3b2a6c6d13de65e1605d215",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 23,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0xf35d9e265d20096af90a891205020ffab9291c8b",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 24,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0xe45fb62899ccc74449923c7b34a91d7b9ee27d9f",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 25,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x9b86be6324d8d56247c04b2ec7ea4d0149fb1f64",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 26,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x531a360408b69dcf325115921064c6e784cdc297",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 27,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x3d93f8a930023263c17a639580525a561072458c",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 28,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
{
referrer: "0x1779c4ad42cd07e437b6c6444b539ea1734fcaf4",
totalReferrals: 1,
totalIncrementalDuration: 2419200,
totalRevenueContribution: {
currency: "ETH" as const,
amount: 3_000_000_000_000_000n,
}, // 0.003 ETH
score: 0.07666139619567822,
rank: 29,
isQualified: false,
finalScoreBoost: 0,
finalScore: 0.07666139619567822,
awardPoolShare: 0,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: BigInt(Math.floor(0 * 1000000)),
},
},
],
aggregatedMetrics: {
grandTotalReferrals: 68,
grandTotalIncrementalDuration: 367027203,
grandTotalRevenueContribution: {
currency: "ETH" as const,
amount: 235_000_000_000_000_000n,
}, // 0.235 ETH
grandTotalQualifiedReferrersFinalScore: 16.55216891669386,
minFinalScoreToQualify: 0,
},
pageContext: {
page: 1,
recordsPerPage: 100,
totalRecords: 29,
totalPages: 1,
hasNext: false,
hasPrev: false,
startIndex: 0,
endIndex: 28,
},
accurateAsOf: 1735689600,
},
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Reduce duplication between populated leaderboard and page response fixtures.
The page response largely restates populatedReferrerLeaderboard. Consider deriving it from the populated fixture to avoid drift as mock data evolves.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts` around
lines 949 - 1540, The referrerLeaderboardPageResponseOk fixture duplicates the
populatedReferrerLeaderboard data; replace the repeated literal by constructing
referrerLeaderboardPageResponseOk from the existing populatedReferrerLeaderboard
fixture (e.g., import/populate populatedReferrerLeaderboard and then build
referrerLeaderboardPageResponseOk by referencing its properties for data.rules,
data.referrers, data.aggregatedMetrics, data.pageContext and data.accurateAsOf)
so changes to populatedReferrerLeaderboard automatically flow to
referrerLeaderboardPageResponseOk and avoid drift; ensure the top-level
responseCode remains ReferrerLeaderboardPageResponseCodes.Ok and any
BigInt/const types are preserved when mapping.

Comment on lines +69 to +72
export const buildAggregatedReferrerMetrics = (
referrers: RankedReferrerMetrics[],
rules: ReferralProgramRules,
): AggregatedReferrerMetrics => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Document the precondition for the referrers parameter.

The invariant check at lines 97-101 assumes that referrers contains a complete ranked list where ranks start from 1. If this function is called with a partial list (e.g., a page of results with ranks 4-6), the invariant check would incorrectly throw an error when maxQualifiedReferrers > 0 but no qualified referrers are in the slice.

Consider adding a JSDoc comment clarifying this precondition:

Suggested documentation
+/**
+ * Build aggregated metrics from a list of ranked referrer metrics.
+ *
+ * `@param` referrers - Complete list of RankedReferrerMetrics for the leaderboard.
+ *                    Ranks must start from 1 and be contiguous.
+ * `@param` rules - The referral program rules governing qualification.
+ * `@returns` Aggregated metrics across all referrers.
+ */
 export const buildAggregatedReferrerMetrics = (
   referrers: RankedReferrerMetrics[],
   rules: ReferralProgramRules,
 ): AggregatedReferrerMetrics => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const buildAggregatedReferrerMetrics = (
referrers: RankedReferrerMetrics[],
rules: ReferralProgramRules,
): AggregatedReferrerMetrics => {
/**
* Build aggregated metrics from a list of ranked referrer metrics.
*
* `@param` referrers - Complete list of RankedReferrerMetrics for the leaderboard.
* Ranks must start from 1 and be contiguous.
* `@param` rules - The referral program rules governing qualification.
* `@returns` Aggregated metrics across all referrers.
*/
export const buildAggregatedReferrerMetrics = (
referrers: RankedReferrerMetrics[],
rules: ReferralProgramRules,
): AggregatedReferrerMetrics => {
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/aggregations-v1.ts` around lines 69 - 72, The
function buildAggregatedReferrerMetrics assumes referrers is a complete,
globally ranked list starting at rank 1 (the invariant check expects ranks to be
absolute), so add a clear JSDoc on the buildAggregatedReferrerMetrics export
stating that referrers must contain a full ranked list with ranks starting at 1
(not a paginated/partial slice), and document the expected shape and
consequences (that maxQualifiedReferrers > 0 with no qualified referrers will
throw) so callers know to pass the full ranking or to pre-aggregate before
calling.

Comment on lines +46 to +50
export type ReferrerLeaderboardPageResponseError = {
responseCode: typeof ReferrerLeaderboardPageResponseCodes.Error;
error: string;
errorMessage: string;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider whether both error and errorMessage are necessary.

The error response type has both error and errorMessage fields. This pattern is repeated for ReferrerDetailResponseError as well. Consider documenting the distinction between these fields (e.g., error for machine-readable codes, errorMessage for human-readable text) or consolidating if they serve the same purpose.

🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/api/types-v1.ts` around lines 46 - 50, The
ReferrerLeaderboardPageResponseError type (and ReferrerDetailResponseError)
currently carries both error and errorMessage; decide and implement a single
consistent pattern: either consolidate into one field (e.g., errorMessage:
string) by removing the redundant property and updating all usages of
ReferrerLeaderboardPageResponseError and ReferrerDetailResponseError, or
explicitly document and rename to make the distinction clear (e.g., errorCode:
string for machine-readable values and message: string for human-readable text)
and update all references and serializers/deserializers accordingly; update the
type definitions (ReferrerLeaderboardPageResponseError,
ReferrerDetailResponseError) and any code that constructs or reads these objects
to match the chosen shape and ensure tests/API clients reflect the change.

Comment on lines +110 to +125
export const makeReferrerLeaderboardPageContextSchema = (
valueLabel: string = "ReferrerLeaderboardPageContext",
) =>
z.object({
page: makePositiveIntegerSchema(`${valueLabel}.page`),
recordsPerPage: makePositiveIntegerSchema(`${valueLabel}.recordsPerPage`).max(
REFERRERS_PER_LEADERBOARD_PAGE_MAX,
`${valueLabel}.recordsPerPage must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
),
totalRecords: makeNonNegativeIntegerSchema(`${valueLabel}.totalRecords`),
totalPages: makePositiveIntegerSchema(`${valueLabel}.totalPages`),
hasNext: z.boolean(),
hasPrev: z.boolean(),
startIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.startIndex`)),
endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider validating startIndex and endIndex relationship.

The schema validates startIndex and endIndex as optional non-negative integers, but doesn't validate that endIndex >= startIndex when both are present. This invariant may be enforced elsewhere, but adding it here would provide earlier validation.

♻️ Proposed refinement to validate index relationship
 export const makeReferrerLeaderboardPageContextSchema = (
   valueLabel: string = "ReferrerLeaderboardPageContext",
 ) =>
   z.object({
     page: makePositiveIntegerSchema(`${valueLabel}.page`),
     recordsPerPage: makePositiveIntegerSchema(`${valueLabel}.recordsPerPage`).max(
       REFERRERS_PER_LEADERBOARD_PAGE_MAX,
       `${valueLabel}.recordsPerPage must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
     ),
     totalRecords: makeNonNegativeIntegerSchema(`${valueLabel}.totalRecords`),
     totalPages: makePositiveIntegerSchema(`${valueLabel}.totalPages`),
     hasNext: z.boolean(),
     hasPrev: z.boolean(),
     startIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.startIndex`)),
     endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)),
-  });
+  }).refine(
+    (data) => data.startIndex === undefined || data.endIndex === undefined || data.endIndex >= data.startIndex,
+    { message: `${valueLabel}.endIndex must be >= startIndex` }
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const makeReferrerLeaderboardPageContextSchema = (
valueLabel: string = "ReferrerLeaderboardPageContext",
) =>
z.object({
page: makePositiveIntegerSchema(`${valueLabel}.page`),
recordsPerPage: makePositiveIntegerSchema(`${valueLabel}.recordsPerPage`).max(
REFERRERS_PER_LEADERBOARD_PAGE_MAX,
`${valueLabel}.recordsPerPage must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
),
totalRecords: makeNonNegativeIntegerSchema(`${valueLabel}.totalRecords`),
totalPages: makePositiveIntegerSchema(`${valueLabel}.totalPages`),
hasNext: z.boolean(),
hasPrev: z.boolean(),
startIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.startIndex`)),
endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)),
});
export const makeReferrerLeaderboardPageContextSchema = (
valueLabel: string = "ReferrerLeaderboardPageContext",
) =>
z.object({
page: makePositiveIntegerSchema(`${valueLabel}.page`),
recordsPerPage: makePositiveIntegerSchema(`${valueLabel}.recordsPerPage`).max(
REFERRERS_PER_LEADERBOARD_PAGE_MAX,
`${valueLabel}.recordsPerPage must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
),
totalRecords: makeNonNegativeIntegerSchema(`${valueLabel}.totalRecords`),
totalPages: makePositiveIntegerSchema(`${valueLabel}.totalPages`),
hasNext: z.boolean(),
hasPrev: z.boolean(),
startIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.startIndex`)),
endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)),
}).refine(
(data) => data.startIndex === undefined || data.endIndex === undefined || data.endIndex >= data.startIndex,
{ message: `${valueLabel}.endIndex must be >= startIndex` }
);
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/api/zod-schemas-v1.ts` around lines 110 - 125, The
schema makeReferrerLeaderboardPageContextSchema currently allows optional
startIndex and endIndex but doesn't enforce endIndex >= startIndex; update the
returned z.object to add a refinement (using .refine or .superRefine on the
object) that, when both startIndex and endIndex are defined, asserts endIndex >=
startIndex and returns a clear error path (e.g., `${valueLabel}.endIndex` or
`${valueLabel}.startIndex`) on failure; reference the object produced by
makeReferrerLeaderboardPageContextSchema and the startIndex/endIndex fields when
implementing the check so the validator triggers only when both values are
present.

Comment on lines +56 to +61
const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer);
if (uniqueReferrers.length !== allReferrers.length) {
throw new Error(
"ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers",
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: Duplicate referrer detection is ineffective.

The current check creates an array of addresses and compares its length to the input array length, but this doesn't actually detect duplicates. If there are duplicate referrer addresses, uniqueReferrers will still have the same length as allReferrers because map preserves array length.

🐛 Proposed fix using Set for proper duplicate detection
 export const buildReferrerLeaderboard = (
   allReferrers: ReferrerMetrics[],
   rules: ReferralProgramRules,
   accurateAsOf: UnixTimestamp,
 ): ReferrerLeaderboard => {
-  const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer);
-  if (uniqueReferrers.length !== allReferrers.length) {
+  const uniqueReferrers = new Set(allReferrers.map((referrer) => referrer.referrer));
+  if (uniqueReferrers.size !== allReferrers.length) {
     throw new Error(
       "ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers",
     );
   }
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/leaderboard-v1.ts` around lines 56 - 61, The
duplicate detection is wrong because uniqueReferrers is created with map which
preserves length; replace it with a Set-based check: compute const
uniqueReferrerSet = new Set(allReferrers.map(r => r.referrer)) and then compare
uniqueReferrerSet.size to allReferrers.length (or recreate uniqueReferrers via
Array.from(uniqueReferrerSet)) in the buildReferrerLeaderboard logic to properly
detect duplicates and throw the same Error when sizes differ; update references
to uniqueReferrers accordingly.

Comment on lines +287 to +300
// Calculate the approximate USDC value by multiplying the share by the total award pool value
// We need to convert the share (a number between 0 and 1) to a bigint amount
const awardPoolApproxAmount = BigInt(
Math.floor(awardPoolShare * Number(rules.totalAwardPoolValue.amount)),
);

const result = {
...referrer,
awardPoolShare,
awardPoolApproxValue: {
currency: "USDC" as const,
amount: awardPoolApproxAmount,
},
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential precision loss when calculating awardPoolApproxAmount.

The calculation Math.floor(awardPoolShare * Number(rules.totalAwardPoolValue.amount)) converts a bigint to Number, which can lose precision for values exceeding Number.MAX_SAFE_INTEGER (2^53 - 1). Since the award pool is 10 billion smallest USDC units (10^10), this is currently safe, but if the pool grows larger, precision could be lost.

Consider documenting this limitation or using a big decimal library for very large pools.

🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/referrer-metrics-v1.ts` around lines 287 - 300,
The current calculation for awardPoolApproxAmount loses precision by converting
rules.totalAwardPoolValue.amount (a bigint) to Number; instead compute the
amount using precise integer or big-decimal arithmetic: either represent
awardPoolShare as an integer fraction (numerator/denominator) and compute
awardPoolApproxAmount = (BigInt(rules.totalAwardPoolValue.amount) * numerator) /
denominator, or use a BigNumber/Decimal library to multiply a
Decimal(awardPoolShare) by the bigint total and round/floor to a bigint; update
the code around awardPoolApproxAmount, awardPoolShare and
result.awardPoolApproxValue to use the chosen precise method.

Comment on lines +67 to +86
export const validateReferralProgramRules = (rules: ReferralProgramRules): void => {
// Validate totalAwardPoolValue using Zod schema
const priceUsdcSchema = makePriceUsdcSchema("ReferralProgramRules.totalAwardPoolValue");
const parseResult = priceUsdcSchema.safeParse(rules.totalAwardPoolValue);
if (!parseResult.success) {
throw new Error(
`ReferralProgramRules: totalAwardPoolValue validation failed: ${parseResult.error.message}`,
);
}

validateNonNegativeInteger(rules.maxQualifiedReferrers);
validateUnixTimestamp(rules.startTime);
validateUnixTimestamp(rules.endTime);

if (rules.endTime < rules.startTime) {
throw new Error(
`ReferralProgramRules: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`,
);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Validation does not check subregistryId.

The validateReferralProgramRules function validates totalAwardPoolValue, maxQualifiedReferrers, startTime, and endTime, but does not validate the subregistryId field. Consider adding validation for subregistryId to ensure it's a valid AccountId.

♻️ Proposed fix to add subregistryId validation
 export const validateReferralProgramRules = (rules: ReferralProgramRules): void => {
   // Validate totalAwardPoolValue using Zod schema
   const priceUsdcSchema = makePriceUsdcSchema("ReferralProgramRules.totalAwardPoolValue");
   const parseResult = priceUsdcSchema.safeParse(rules.totalAwardPoolValue);
   if (!parseResult.success) {
     throw new Error(
       `ReferralProgramRules: totalAwardPoolValue validation failed: ${parseResult.error.message}`,
     );
   }

   validateNonNegativeInteger(rules.maxQualifiedReferrers);
   validateUnixTimestamp(rules.startTime);
   validateUnixTimestamp(rules.endTime);

   if (rules.endTime < rules.startTime) {
     throw new Error(
       `ReferralProgramRules: startTime: ${rules.startTime} is after endTime: ${rules.endTime}.`,
     );
   }
+
+  // Consider validating subregistryId format/structure
+  if (!rules.subregistryId || typeof rules.subregistryId !== "string") {
+    throw new Error(`ReferralProgramRules: subregistryId must be a non-empty string.`);
+  }
 };
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/rules-v1.ts` around lines 67 - 86, The function
validateReferralProgramRules currently omits validation for the subregistryId
field; add a call to the existing AccountId validator (e.g., validateAccountId)
to check rules.subregistryId inside validateReferralProgramRules, placed
alongside the other field checks (after totalAwardPoolValue parsing and
before/after maxQualifiedReferrers validation), and throw a clear Error if
validation fails so the code validates that subregistryId is a valid AccountId.

Comment on lines +23 to +36
/**
* Encodes a uint256 bigint as hex string sized to 32 bytes.
* Uses include, in the context of ENS, decoding the uint256-encoded tokenId of NFT-issuing contracts
* into Node or LabelHash, which is a common behavior in the ENS ecosystem.
* (see NameWrapper, ETHRegistrarController)
*
* @remarks
* This is an inline copy of the function from `../ens/subname-helpers.ts` to avoid
* cross-module imports that cause Vite SSR module resolution issues. When Vitest loads
* TypeScript sources in a pnpm workspace, importing from ../ens creates a complex module
* graph that Vite SSR cannot properly resolve, resulting in imported functions appearing
* as undefined at runtime.
*/
const uint256ToHex32 = (num: bigint): Hex => toHex(num, { size: 32 });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify that both uint256ToHex32 implementations are consistent

echo "=== Searching for uint256ToHex32 in subname-helpers.ts ==="
rg -n 'uint256ToHex32' --glob '**/subname-helpers.ts' -A 2

echo ""
echo "=== Searching for uint256ToHex32 in serialize.ts ==="
rg -n 'uint256ToHex32' --glob '**/serialize.ts' -A 2

echo ""
echo "=== Checking if subname-helpers.ts exists and checking its exports ==="
find . -name 'subname-helpers.ts' -type f 2>/dev/null

echo ""
echo "=== Checking relative path from serialize.ts ==="
find . -path '*/shared/serialize.ts' -type f 2>/dev/null

Repository: namehash/ensnode

Length of output: 950


Documentation is thorough; code duplication requires maintenance tracking.

The inline copy of uint256ToHex32 from ../ens/subname-helpers.ts is intentional and well-documented due to Vite SSR module resolution constraints. Both implementations are currently identical. However, this duplication creates a maintenance burden: if the original function in subname-helpers.ts is modified, this private copy must be updated in parallel to maintain consistency.

Consider adding a comment or tracking mechanism (e.g., a maintenance note or linked issue) to ensure the implementations remain synchronized if changes occur to either version.

🤖 Prompt for AI Agents
In `@packages/ensnode-sdk/src/shared/serialize.ts` around lines 23 - 36, Add a
short maintenance note next to the duplicated uint256ToHex32 definition
explaining that this is an intentional inline copy of the implementation in
../ens/subname-helpers.ts due to Vite SSR resolution, and include a TODO/link or
issue number to track changes so both implementations stay synchronized;
reference the function name uint256ToHex32 and the original file
subname-helpers.ts in the comment to make future updates clear.

Copilot AI review requested due to automatic review settings January 26, 2026 14:27
@vercel vercel bot temporarily deployed to Preview – ensnode.io January 26, 2026 14:27 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io January 26, 2026 14:27 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io January 26, 2026 14:27 Inactive
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +288 to +291
// We need to convert the share (a number between 0 and 1) to a bigint amount
const awardPoolApproxAmount = BigInt(
Math.floor(awardPoolShare * Number(rules.totalAwardPoolValue.amount)),
);
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calculation on lines 289-290 converts the totalAwardPoolValue.amount (a bigint) to a Number before multiplying by awardPoolShare. This conversion could cause precision loss if totalAwardPoolValue.amount exceeds Number.MAX_SAFE_INTEGER (2^53 - 1, approximately 9 quadrillion). Given that USDC has 6 decimals, this would happen with amounts above 9 million USDC, which is possible for large award pools. Consider using bigint arithmetic throughout: multiply the bigint by a scaled integer representation of the share, then divide by the scaling factor.

Suggested change
// We need to convert the share (a number between 0 and 1) to a bigint amount
const awardPoolApproxAmount = BigInt(
Math.floor(awardPoolShare * Number(rules.totalAwardPoolValue.amount)),
);
// Use bigint arithmetic for the large amount to avoid precision loss. We represent the
// share as a scaled integer, multiply in bigint space, then divide by the scale.
const SHARE_SCALE = 1_000_000_000n;
const scaledShare = BigInt(Math.floor(awardPoolShare * Number(SHARE_SCALE)));
const awardPoolApproxAmount =
(rules.totalAwardPoolValue.amount * scaledShare) / SHARE_SCALE;

Copilot uses AI. Check for mistakes.
"require": {
"types": "./dist/v1.d.cts",
"default": "./dist/v1.cjs"
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The publishConfig.exports section is missing the "./internal" export path that is defined in the development exports (line 23). This means that when the package is published, consumers won't be able to import from "@namehash/ens-referrals/internal". The publishConfig.exports should include the internal export path with proper dist mappings.

Suggested change
}
}
},
"./internal": {
"import": {
"types": "./dist/internal.d.ts",
"default": "./dist/internal.js"
},
"require": {
"types": "./dist/internal.d.cts",
"default": "./dist/internal.cjs"
}

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +218
export function deserializeReferrerDetailResponse(
maybeResponse: SerializedReferrerDetailResponse,
valueLabel?: string,
): ReferrerDetailResponse {
let deserialized: ReferrerDetailResponse;
switch (maybeResponse.responseCode) {
case "ok": {
switch (maybeResponse.data.type) {
case "ranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailRanked(maybeResponse.data),
} as ReferrerDetailResponse;
break;

case "unranked":
deserialized = {
responseCode: maybeResponse.responseCode,
data: deserializeReferrerDetailUnranked(maybeResponse.data),
} as ReferrerDetailResponse;
break;
}
break;
}

case "error":
deserialized = maybeResponse;
break;
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function deserializeReferrerDetailResponse has a bug where the variable deserialized may be used uninitialized. If the response.data.type doesn't match "ranked" or "unranked" (which shouldn't happen with proper typing but could with malformed input), the inner switch falls through without assigning to deserialized, and then the variable is used on line 222. This will cause a runtime error. Either add a default case to the inner switch that throws an error, or ensure TypeScript's exhaustiveness checking catches this by restructuring the code.

Copilot uses AI. Check for mistakes.
* Schema for {@link ReferrerLeaderboardPageResponseError}
*/
export const makeReferrerLeaderboardPageResponseErrorSchema = (
_valueLabel: string = "ReferrerLeaderboardPageResponseError",
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'_valueLabel' is assigned a value but never used.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants