-
Notifications
You must be signed in to change notification settings - Fork 15
Mature Price Models for "v1" ENSAnalytics #1562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
📝 WalkthroughWalkthroughThis PR implements a comprehensive v1 API for the ENS referrals system, introducing mature price models (PriceUsdc, PriceEth) throughout a new v1 module namespace. Updates include core leaderboard business logic, pagination, ranking, scoring, aggregation, API serialization layers, client implementation, and corresponding handlers, middleware, caching, and test fixtures, alongside import reorganization in ensnode-sdk to use granular module paths. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Handler
participant Middleware
participant Cache
participant Database
participant LeaderboardBuilder
participant Serializer
participant APIResponse
Client->>Handler: GET /ensanalytics/v1/referrers
activate Handler
Handler->>Middleware: Process request (referrerLeaderboardMiddlewareV1)
activate Middleware
Middleware->>Cache: read()
activate Cache
Cache->>Database: getReferrerMetrics(rules)
activate Database
Database-->>Cache: Return ReferrerMetrics[]
deactivate Database
Cache->>LeaderboardBuilder: buildReferrerLeaderboard(metrics, rules, accurateAsOf)
activate LeaderboardBuilder
LeaderboardBuilder-->>Cache: Return ReferrerLeaderboard
deactivate LeaderboardBuilder
Cache-->>Middleware: Return ReferrerLeaderboard | Error
deactivate Cache
Middleware-->>Handler: Set c.var.referrerLeaderboardV1
deactivate Middleware
Handler->>Handler: Access c.var.referrerLeaderboardV1
Handler->>Serializer: serializeReferrerLeaderboardPageResponse(leaderboard)
activate Serializer
Serializer->>Serializer: Convert prices to serialized format
Serializer-->>Handler: Return SerializedResponse
deactivate Serializer
Handler-->>APIResponse: JSON with responseCode & data
APIResponse-->>Client: HTTP 200 with leaderboard page
deactivate Handler
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this 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-sdkwith corresponding schemas, serialization, and deserialization functions - Created comprehensive v1 API modules for
@namehash/ens-referralsincluding leaderboard, metrics, rankings, and award calculations - Implemented v1-specific database queries, handlers, and tests in
ensapito 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; | ||
|
|
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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).
| // 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; |
| break; | ||
|
|
||
| case ReferrerDetailResponseCodes.Error: | ||
| return response; | ||
| } |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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:
- Remove the break statement on line 182 and let the nested switch return directly
- 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.
| break; | |
| case ReferrerDetailResponseCodes.Error: | |
| return response; | |
| } | |
| case ReferrerDetailResponseCodes.Error: | |
| return response; | |
| } | |
| throw new Error(`Unexpected ReferrerDetailResponseCode: ${String(response.responseCode)}`); |
| 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; | ||
| } |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| import type { AwardedReferrerMetrics, UnrankedReferrerMetrics } from "../referrer-metrics-v1"; | ||
| import type { ReferralProgramRules } from "../rules-v1"; | ||
| import type { | ||
| ReferrerDetailResponse, |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| ReferrerDetailResponse, |
| ReferrerDetailResponse, | ||
| ReferrerDetailResponseError, | ||
| ReferrerDetailResponseOk, | ||
| ReferrerLeaderboardPageResponse, |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| ReferrerLeaderboardPageResponse, |
| _valueLabel: string = "ReferrerLeaderboardPageResponseError", | ||
| ) => | ||
| z.object({ | ||
| responseCode: z.literal(ReferrerLeaderboardPageResponseCodes.Error), | ||
| error: z.string(), | ||
| errorMessage: z.string(), |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| _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`), |
| _valueLabel: string = "ReferrerDetailResponse", | ||
| ) => | ||
| z.object({ | ||
| responseCode: z.literal(ReferrerDetailResponseCodes.Error), | ||
| error: z.string(), | ||
| errorMessage: z.string(), | ||
| }); |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| _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); |
| @@ -13,7 +13,14 @@ import { z } from "zod/v4"; | |||
|
|
|||
| import { ENSNamespaceIds, type InterpretedName, Node } from "../ens"; | |||
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| import { ENSNamespaceIds, type InterpretedName, Node } from "../ens"; | |
| import { ENSNamespaceIds, type InterpretedName } from "../ens"; |
| import { | ||
| type CurrencyId, | ||
| CurrencyIds, | ||
| Price, |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| Price, |
| type PriceUsdc, | ||
| } from "./currencies"; | ||
| import { reinterpretName } from "./interpretation/reinterpretation"; | ||
| import type { AccountIdString } from "./serialized-types"; |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| import type { AccountIdString } from "./serialized-types"; |
There was a problem hiding this 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 centralizinguint256ToHex32to 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.
| // 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)), | ||
| ); | ||
| }); |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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`), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find packages/ens-referrals -name "*referrer-metrics*" -type fRepository: 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.tsRepository: namehash/ensnode
Length of output: 113
🏁 Script executed:
cat -n packages/ens-referrals/src/referrer-metrics-v1.tsRepository: namehash/ensnode
Length of output: 17799
🏁 Script executed:
wc -l packages/ens-referrals/src/api/zod-schemas-v1.tsRepository: namehash/ensnode
Length of output: 112
🏁 Script executed:
sed -n '67,86p' packages/ens-referrals/src/api/zod-schemas-v1.tsRepository: namehash/ensnode
Length of output: 1120
🏁 Script executed:
sed -n '60,100p' packages/ens-referrals/src/api/zod-schemas-v1.tsRepository: 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.
| 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.
| 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}.`, | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer); | ||
| if (uniqueReferrers.length !== allReferrers.length) { | ||
| throw new Error( | ||
| "ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers", | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| export function calcReferrerFinalScoreBoost( | ||
| rank: ReferrerRank, | ||
| rules: ReferralProgramRules, | ||
| ): number { | ||
| if (!isReferrerQualified(rank, rules)) return 0; | ||
|
|
||
| return 1 - (rank - 1) / (rules.maxQualifiedReferrers - 1); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| priceUsdc, | ||
| type UnixTimestamp, | ||
| } from "@ensnode/ensnode-sdk"; | ||
| import { makePriceUsdcSchema } from "@ensnode/ensnode-sdk/internal"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
packages/ens-referrals/src/v1.ts
Outdated
| export * from "./api/types-v1"; | ||
| export * from "./api/zod-schemas-v1"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, check the current state of v1.ts
cat -n packages/ens-referrals/src/v1.tsRepository: 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.tsRepository: 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 -20Repository: 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.
| 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); |
There was a problem hiding this comment.
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: NaNWhat happens vs expected:
- Current behavior: The calculation
1 - (1 - 1) / (1 - 1)evaluates to1 - 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 returns1 + NaN = NaNcalcReferrerFinalScore()receives NaN and returnsscore * NaN = NaNbuildRankedReferrerMetrics()creates invalid metrics with NaN values- Validation in
validateRankedReferrerMetrics()fails when checking thatfinalScoreBoostmust 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.
There was a problem hiding this 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 importPriceshould be removed.The
Priceimport 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 whenmaxQualifiedReferrersis 1.When
rules.maxQualifiedReferrers === 1andrank === 1, line 46 computes(rank - 1) / (rules.maxQualifiedReferrers - 1)which evaluates to0 / 0 = NaN, causing the function to returnNaN.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) andReferrerLeaderboardPageResponse(line 12) are imported but never used in this file. Only their constituent types (*Okand*Errorvariants) are used.packages/ens-referrals/src/api/zod-schemas-v1.ts (3)
25-25: Remove unused importReferrerDetailRanked.The type
ReferrerDetailRankedis imported but only used in JSDoc comments, not in code. TheReferrerDetailTypeIdsimport is sufficient.
153-160: Remove unused_valueLabelparameter or use it.The
_valueLabelparameter is declared but never used inmakeReferrerLeaderboardPageResponseErrorSchema. Either remove it or use it for field descriptions like the other schema factories.
213-220: Remove unused_valueLabelparameter or use it.Same issue as above -
_valueLabelis declared but unused inmakeReferrerDetailResponseErrorSchema.packages/ens-referrals/src/api/serialize-v1.ts (1)
164-187: Unreachablebreakstatement after nested switch.The
breakon line 182 follows a nested switch where both branches ("ranked"and"unranked") return early. If TypeScript's type narrowing ensures exhaustiveness, thisbreakis dead code. If not, the function could implicitly returnundefined.Consider removing the
breaksince 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 deserializedvariable (line 194) may remain unassigned ifmaybeResponse.data.typedoesn't match"ranked"or"unranked". Thebreakon line 212 would exit the outer switch, leavingdeserializedundefined when accessed on line 222.If TypeScript's type system guarantees exhaustiveness of the nested switch, the
breakis 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; }
| 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)), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
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.
| export const buildAggregatedReferrerMetrics = ( | ||
| referrers: RankedReferrerMetrics[], | ||
| rules: ReferralProgramRules, | ||
| ): AggregatedReferrerMetrics => { |
There was a problem hiding this comment.
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.
| 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.
| export type ReferrerLeaderboardPageResponseError = { | ||
| responseCode: typeof ReferrerLeaderboardPageResponseCodes.Error; | ||
| error: string; | ||
| errorMessage: string; | ||
| }; |
There was a problem hiding this comment.
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.
| 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`)), | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
| const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer); | ||
| if (uniqueReferrers.length !== allReferrers.length) { | ||
| throw new Error( | ||
| "ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers", | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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}.`, | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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.
| /** | ||
| * 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 }); |
There was a problem hiding this comment.
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/nullRepository: 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.
There was a problem hiding this 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.
| // 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)), | ||
| ); |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| // 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; |
| 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; | ||
| } |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
| * Schema for {@link ReferrerLeaderboardPageResponseError} | ||
| */ | ||
| export const makeReferrerLeaderboardPageResponseErrorSchema = ( | ||
| _valueLabel: string = "ReferrerLeaderboardPageResponseError", |
Copilot
AI
Jan 26, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this 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 40 out of 40 changed files in this pull request and generated 11 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } from "@ensnode/ensnode-sdk/internal"; | ||
|
|
||
| import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; | ||
| import { type ReferrerDetailRanked, ReferrerDetailTypeIds } from "../referrer-detail"; |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'ReferrerDetailRanked' is defined but never used.
| import { type ReferrerDetailRanked, ReferrerDetailTypeIds } from "../referrer-detail"; | |
| import { ReferrerDetailTypeIds } from "../referrer-detail"; |
| @@ -13,7 +13,14 @@ import { z } from "zod/v4"; | |||
|
|
|||
| import { ENSNamespaceIds, type InterpretedName, Node } from "../ens"; | |||
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
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.
| import { | ||
| type CurrencyId, | ||
| CurrencyIds, | ||
| Price, |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
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.
| async getReferrerLeaderboardPage( | ||
| request?: ReferrerLeaderboardPageRequest, | ||
| ): Promise<ReferrerLeaderboardPageResponse> { | ||
| const url = new URL(`/ensanalytics/referrers`, this.options.url); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The client is constructing incorrect API endpoint URLs. Both the leaderboard page and referrer detail endpoints are missing the /v1 path segment and one has an extra /api prefix that doesn't match the API structure.
View Details
📝 Patch Details
diff --git a/packages/ens-referrals/src/v1/client.ts b/packages/ens-referrals/src/v1/client.ts
index 7d2eb6ad..01dcd47f 100644
--- a/packages/ens-referrals/src/v1/client.ts
+++ b/packages/ens-referrals/src/v1/client.ts
@@ -124,7 +124,7 @@ export class ENSReferralsClient {
async getReferrerLeaderboardPage(
request?: ReferrerLeaderboardPageRequest,
): Promise<ReferrerLeaderboardPageResponse> {
- const url = new URL(`/ensanalytics/referrers`, this.options.url);
+ const url = new URL(`/ensanalytics/v1/referrers`, this.options.url);
if (request?.page) url.searchParams.set("page", request.page.toString());
if (request?.recordsPerPage)
@@ -229,7 +229,7 @@ export class ENSReferralsClient {
*/
async getReferrerDetail(request: ReferrerDetailRequest): Promise<ReferrerDetailResponse> {
const url = new URL(
- `/api/ensanalytics/referrers/${encodeURIComponent(request.referrer)}`,
+ `/ensanalytics/v1/referrers/${encodeURIComponent(request.referrer)}`,
this.options.url,
);
Analysis
Incorrect API endpoint URLs in ENSReferralsClient v1
What fails: The client constructs incorrect API endpoint URLs that will receive 404 errors from the ENSNode API.
How to reproduce:
- Create an ENSReferralsClient pointing to any ENSNode API instance
- Call
client.getReferrerLeaderboardPage()- requests/ensanalytics/referrersinstead of/ensanalytics/v1/referrers - Call
client.getReferrerDetail({referrer: "0x..."})- requests/api/ensanalytics/referrers/...instead of/ensanalytics/v1/referrers/... - Both requests fail with 404 because the endpoints don't exist at those paths
Expected behavior: Both methods should request URLs that match the server-side routing in apps/ensapi/src/index.ts (line 59-60) where the v1 ENSAnalytics API is mounted at /ensanalytics/v1. The handler in apps/ensapi/src/handlers/ensanalytics-api-v1.ts defines routes at /referrers and /referrers/:referrer, resulting in final endpoints:
/ensanalytics/v1/referrers/ensanalytics/v1/referrers/:referrer
Fixed:
- Line 127: Changed from
/ensanalytics/referrersto/ensanalytics/v1/referrers - Line 232: Changed from
/api/ensanalytics/referrers/...to/ensanalytics/v1/referrers/...
Verification: TypeScript type checking and all existing tests pass after the fix.
There was a problem hiding this 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
🤖 Fix all issues with AI agents
In `@apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts`:
- Around line 50-55: The code throws a new Error when indexingStatusCache.read()
returns an Error, losing the original error; before throwing, log the root error
(the indexingStatus value) so diagnostics are preserved — e.g., call
console.error or the module logger with a message like
"indexingStatusCache.read() failed" and include indexingStatus, then throw the
existing new Error as before; locate the check around indexingStatusCache.read()
/ indexingStatus in referrer-leaderboard.cache-v1.ts and add the log immediately
before the throw.
In `@packages/ens-referrals/src/v1/address.ts`:
- Around line 13-15: normalizeAddress currently lowercases any string cast to
Address without validating it; update normalizeAddress to validate the input
before returning a lowered Address (use an address validator like
ethers.utils.isAddress or a project isValidAddress helper) and throw a clear
error (or return a Result/nullable type) when validation fails, or alternatively
add a JSDoc comment on normalizeAddress stating callers must supply a valid
Address; reference the normalizeAddress function and the Address type so
reviewers can find where to add the validation or documentation.
In `@packages/ens-referrals/src/v1/api/serialize.ts`:
- Around line 146-187: The switch branches in
serializeReferrerLeaderboardPageResponse and serializeReferrerDetailResponse can
fall through and return undefined for unknown enums; add explicit
default/fallback branches that throw a descriptive Error (including the
unexpected responseCode or response.data.type) so the functions always return a
Serialized* type or throw; specifically, in
serializeReferrerLeaderboardPageResponse handle unknown
ReferrerLeaderboardPageResponseCodes by throwing, and in
serializeReferrerDetailResponse add a default for the outer
ReferrerDetailResponseCodes and a default for the inner switch on
response.data.type (alongside existing handling that returns via
serializeReferrerLeaderboardPage, serializeReferrerDetailRanked, and
serializeReferrerDetailUnranked) so no code path returns undefined.
In `@packages/ens-referrals/src/v1/client.ts`:
- Around line 124-132: The code in getReferrerLeaderboardPage drops explicit
pagination values like 0 due to truthy checks; change the conditions that set
URL search params from if (request?.page) and if (request?.recordsPerPage) to
explicit undefined checks (e.g., if (request?.page !== undefined) and if
(request?.recordsPerPage !== undefined)) so zero is preserved (keep using
toString() when adding the params); optionally add basic validation if you want
to reject negative values.
In `@packages/ens-referrals/src/v1/link.test.ts`:
- Around line 12-23: Add a test case to buildEnsReferralUrl that asserts it
throws for invalid input: call buildEnsReferralUrl with a clearly invalid
address string (e.g., "0xinvalid" cast to Address) and expect the call to throw
(use expect(() => buildEnsReferralUrl(...)).toThrow()). This verifies the
function's failure path (which relies on getAddress) and should be added
alongside the existing tests in the describe("buildEnsReferralUrl") block in the
v1/link.test.ts file.
- Around line 7-9: The variable vitalikEthAddressChecksummed is being set by
calling getAddress on a string that is already EIP-55 checksummed; simplify by
assigning the literal directly to vitalikEthAddressChecksummed (type Address)
instead of calling getAddress, i.e., replace
getAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") with the checksummed
string literal assigned to vitalikEthAddressChecksummed.
In `@packages/ens-referrals/src/v1/number.ts`:
- Around line 13-21: The module exposes validateNonNegativeInteger but lacks
matching validator functions for other numeric checks used elsewhere; add
validatePositiveInteger and validateFiniteNonNegativeNumber to mirror the
pattern of validateNonNegativeInteger and use existing helpers isPositiveInteger
and isFiniteNonNegativeNumber, throwing descriptive Errors (e.g., "Invalid
positive integer: ${value}.", "Invalid finite non-negative number: ${value}.")
so callers can consistently call validation functions like
validatePositiveInteger and validateFiniteNonNegativeNumber alongside
validateNonNegativeInteger.
- Around line 1-3: The exported isInteger wrapper simply forwards to
Number.isInteger and can be removed to avoid unnecessary indirection: delete the
isInteger function/export from the module and update all callers to call
Number.isInteger(...) directly (search for references to isInteger to update
imports/usages); alternatively, if you want to keep a consistent helper API,
replace the function with a direct alias export like export const isInteger =
Number.isInteger to avoid the extra function wrapper.
In `@packages/ens-referrals/src/v1/referrer-detail.ts`:
- Around line 132-156: The Map lookup in getReferrerDetail uses
leaderboard.referrers which stores keys in lowercase, so normalize the provided
referrer before using it: call normalizeAddress(referrer) and use that
normalized value for leaderboard.referrers.get(...) and when passing to
buildUnrankedReferrerMetrics; also add the import for normalizeAddress from
"./address". Ensure all occurrences in getReferrerDetail use the normalized
address variable.
In `@packages/ens-referrals/src/v1/score.ts`:
- Around line 1-35: The calcReferrerScore function doesn't validate its input
and can return negative/NaN scores; update
calcReferrerScore(totalIncrementalDuration) to validate the duration before
computing by calling validateReferrerScore or using isValidReferrerScore and
throwing a clear Error when invalid, then proceed to compute
totalIncrementalDuration / SECONDS_PER_YEAR; reference the existing functions
isValidReferrerScore and validateReferrerScore to implement the check and
preserve the public API invariant.
In `@packages/ens-referrals/src/v1/time.ts`:
- Around line 1-9: The current validateUnixTimestamp function only checks
isInteger(timestamp) and thus allows negative integers; update
validateUnixTimestamp to also check isNonNegativeInteger(timestamp) (or combine
with the existing isInteger check) and throw a descriptive Error when the
timestamp is negative so only non‑negative UnixTimestamp values are accepted;
reference the validateUnixTimestamp function and the helper isNonNegativeInteger
to locate where to add the additional validation and error message.
| const indexingStatus = await indexingStatusCache.read(); | ||
| if (indexingStatus instanceof Error) { | ||
| throw new Error( | ||
| "Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.", | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Log the root indexing-status error before throwing.
Right now the original error is lost, and this happens before the try/catch that logs failures. Logging it here will improve diagnosability.
📝 Suggested tweak
- if (indexingStatus instanceof Error) {
- throw new Error(
- "Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
- );
- }
+ if (indexingStatus instanceof Error) {
+ logger.error(
+ { error: indexingStatus },
+ "Unable to generate referrer leaderboard: indexingStatusCache failed to initialize",
+ );
+ throw new Error(
+ "Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
+ );
+ }🤖 Prompt for AI Agents
In `@apps/ensapi/src/cache/referrer-leaderboard.cache-v1.ts` around lines 50 - 55,
The code throws a new Error when indexingStatusCache.read() returns an Error,
losing the original error; before throwing, log the root error (the
indexingStatus value) so diagnostics are preserved — e.g., call console.error or
the module logger with a message like "indexingStatusCache.read() failed" and
include indexingStatus, then throw the existing new Error as before; locate the
check around indexingStatusCache.read() / indexingStatus in
referrer-leaderboard.cache-v1.ts and add the log immediately before the throw.
| export const normalizeAddress = (address: Address): Address => { | ||
| return address.toLowerCase() as Address; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
normalizeAddress does not validate its input.
This function will happily lowercase any string cast as Address, including invalid ones. If the intent is to always produce a valid lowercase address, consider adding validation or documenting that callers must ensure validity.
💡 Option: Add validation or document the assumption
+/**
+ * Normalize an address to lowercase format.
+ * `@param` address - A valid EVM address (not validated here)
+ * `@returns` The address in lowercase format
+ */
export const normalizeAddress = (address: Address): Address => {
return address.toLowerCase() as Address;
};Or with validation:
export const normalizeAddress = (address: Address): Address => {
+ if (!isAddress(address, { strict: false })) {
+ throw new Error(`Invalid address: ${address}`);
+ }
return address.toLowerCase() as Address;
};📝 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.
| export const normalizeAddress = (address: Address): Address => { | |
| return address.toLowerCase() as Address; | |
| }; | |
| /** | |
| * Normalize an address to lowercase format. | |
| * `@param` address - A valid EVM address (not validated here) | |
| * `@returns` The address in lowercase format | |
| */ | |
| export const normalizeAddress = (address: Address): Address => { | |
| return address.toLowerCase() as Address; | |
| }; |
| export const normalizeAddress = (address: Address): Address => { | |
| return address.toLowerCase() as Address; | |
| }; | |
| export const normalizeAddress = (address: Address): Address => { | |
| if (!isAddress(address, { strict: false })) { | |
| throw new Error(`Invalid address: ${address}`); | |
| } | |
| return address.toLowerCase() as Address; | |
| }; |
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/address.ts` around lines 13 - 15,
normalizeAddress currently lowercases any string cast to Address without
validating it; update normalizeAddress to validate the input before returning a
lowered Address (use an address validator like ethers.utils.isAddress or a
project isValidAddress helper) and throw a clear error (or return a
Result/nullable type) when validation fails, or alternatively add a JSDoc
comment on normalizeAddress stating callers must supply a valid Address;
reference the normalizeAddress function and the Address type so reviewers can
find where to add the validation or documentation.
| export function serializeReferrerLeaderboardPageResponse( | ||
| response: ReferrerLeaderboardPageResponse, | ||
| ): SerializedReferrerLeaderboardPageResponse { | ||
| switch (response.responseCode) { | ||
| case ReferrerLeaderboardPageResponseCodes.Ok: | ||
| return { | ||
| responseCode: response.responseCode, | ||
| data: serializeReferrerLeaderboardPage(response.data), | ||
| }; | ||
|
|
||
| case ReferrerLeaderboardPageResponseCodes.Error: | ||
| return response; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Serialize a {@link ReferrerDetailResponse} object. | ||
| */ | ||
| export function serializeReferrerDetailResponse( | ||
| response: ReferrerDetailResponse, | ||
| ): SerializedReferrerDetailResponse { | ||
| switch (response.responseCode) { | ||
| case ReferrerDetailResponseCodes.Ok: | ||
| switch (response.data.type) { | ||
| case "ranked": | ||
| return { | ||
| responseCode: response.responseCode, | ||
| data: serializeReferrerDetailRanked(response.data), | ||
| }; | ||
|
|
||
| case "unranked": | ||
| return { | ||
| responseCode: response.responseCode, | ||
| data: serializeReferrerDetailUnranked(response.data), | ||
| }; | ||
| } | ||
| break; | ||
|
|
||
| case ReferrerDetailResponseCodes.Error: | ||
| return response; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Add explicit fallbacks to avoid undefined returns.
If a new responseCode or type appears, these functions can fall through without a return. Consider default branches that throw.
♻️ Suggested hardening
export function serializeReferrerLeaderboardPageResponse(
response: ReferrerLeaderboardPageResponse,
): SerializedReferrerLeaderboardPageResponse {
switch (response.responseCode) {
case ReferrerLeaderboardPageResponseCodes.Ok:
return {
responseCode: response.responseCode,
data: serializeReferrerLeaderboardPage(response.data),
};
case ReferrerLeaderboardPageResponseCodes.Error:
return response;
+ default:
+ throw new Error(`Unhandled ReferrerLeaderboardPageResponse code: ${response.responseCode}`);
}
}
@@
export function serializeReferrerDetailResponse(
response: ReferrerDetailResponse,
): SerializedReferrerDetailResponse {
switch (response.responseCode) {
case ReferrerDetailResponseCodes.Ok:
switch (response.data.type) {
case "ranked":
return {
responseCode: response.responseCode,
data: serializeReferrerDetailRanked(response.data),
};
case "unranked":
return {
responseCode: response.responseCode,
data: serializeReferrerDetailUnranked(response.data),
};
+ default:
+ throw new Error(`Unhandled ReferrerDetail type: ${String(response.data.type)}`);
}
break;
case ReferrerDetailResponseCodes.Error:
return response;
+ default:
+ throw new Error(`Unhandled ReferrerDetailResponse code: ${response.responseCode}`);
}
}🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/api/serialize.ts` around lines 146 - 187, The
switch branches in serializeReferrerLeaderboardPageResponse and
serializeReferrerDetailResponse can fall through and return undefined for
unknown enums; add explicit default/fallback branches that throw a descriptive
Error (including the unexpected responseCode or response.data.type) so the
functions always return a Serialized* type or throw; specifically, in
serializeReferrerLeaderboardPageResponse handle unknown
ReferrerLeaderboardPageResponseCodes by throwing, and in
serializeReferrerDetailResponse add a default for the outer
ReferrerDetailResponseCodes and a default for the inner switch on
response.data.type (alongside existing handling that returns via
serializeReferrerLeaderboardPage, serializeReferrerDetailRanked, and
serializeReferrerDetailUnranked) so no code path returns undefined.
| async getReferrerLeaderboardPage( | ||
| request?: ReferrerLeaderboardPageRequest, | ||
| ): Promise<ReferrerLeaderboardPageResponse> { | ||
| const url = new URL(`/ensanalytics/referrers`, this.options.url); | ||
|
|
||
| if (request?.page) url.searchParams.set("page", request.page.toString()); | ||
| if (request?.recordsPerPage) | ||
| url.searchParams.set("recordsPerPage", request.recordsPerPage.toString()); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t drop explicit pagination values.
The truthy checks skip 0, so invalid inputs get silently ignored instead of being surfaced. Prefer !== undefined (or explicit validation).
🛠 Suggested fix
- if (request?.page) url.searchParams.set("page", request.page.toString());
- if (request?.recordsPerPage)
- url.searchParams.set("recordsPerPage", request.recordsPerPage.toString());
+ if (request?.page !== undefined) url.searchParams.set("page", request.page.toString());
+ if (request?.recordsPerPage !== undefined)
+ url.searchParams.set("recordsPerPage", request.recordsPerPage.toString());📝 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.
| async getReferrerLeaderboardPage( | |
| request?: ReferrerLeaderboardPageRequest, | |
| ): Promise<ReferrerLeaderboardPageResponse> { | |
| const url = new URL(`/ensanalytics/referrers`, this.options.url); | |
| if (request?.page) url.searchParams.set("page", request.page.toString()); | |
| if (request?.recordsPerPage) | |
| url.searchParams.set("recordsPerPage", request.recordsPerPage.toString()); | |
| async getReferrerLeaderboardPage( | |
| request?: ReferrerLeaderboardPageRequest, | |
| ): Promise<ReferrerLeaderboardPageResponse> { | |
| const url = new URL(`/ensanalytics/referrers`, this.options.url); | |
| if (request?.page !== undefined) url.searchParams.set("page", request.page.toString()); | |
| if (request?.recordsPerPage !== undefined) | |
| url.searchParams.set("recordsPerPage", request.recordsPerPage.toString()); |
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/client.ts` around lines 124 - 132, The code in
getReferrerLeaderboardPage drops explicit pagination values like 0 due to truthy
checks; change the conditions that set URL search params from if (request?.page)
and if (request?.recordsPerPage) to explicit undefined checks (e.g., if
(request?.page !== undefined) and if (request?.recordsPerPage !== undefined)) so
zero is preserved (keep using toString() when adding the params); optionally add
basic validation if you want to reject negative values.
| const vitalikEthAddressChecksummed: Address = getAddress( | ||
| "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Redundant getAddress call on already checksummed literal.
The string "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" is already checksummed. You could simplify this to a direct assignment, though the current approach is harmless.
♻️ Suggested simplification
-const vitalikEthAddressChecksummed: Address = getAddress(
- "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
-);
+const vitalikEthAddressChecksummed: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";📝 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.
| const vitalikEthAddressChecksummed: Address = getAddress( | |
| "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", | |
| ); | |
| const vitalikEthAddressChecksummed: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; |
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/link.test.ts` around lines 7 - 9, The variable
vitalikEthAddressChecksummed is being set by calling getAddress on a string that
is already EIP-55 checksummed; simplify by assigning the literal directly to
vitalikEthAddressChecksummed (type Address) instead of calling getAddress, i.e.,
replace getAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") with the
checksummed string literal assigned to vitalikEthAddressChecksummed.
| export const validateNonNegativeInteger = (value: number): void => { | ||
| if (!isNonNegativeInteger(value)) { | ||
| throw new Error(`Invalid non-negative integer: ${value}.`); | ||
| } | ||
| }; | ||
|
|
||
| export const isFiniteNonNegativeNumber = (value: number): boolean => { | ||
| return value >= 0 && Number.isFinite(value); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
API consistency: only one validator function provided.
The module provides validateNonNegativeInteger but not corresponding validators for isPositiveInteger or isFiniteNonNegativeNumber. If these are needed elsewhere in the v1 API, consider adding them for consistency.
💡 Optional: Add missing validators if needed
export const validatePositiveInteger = (value: number): void => {
if (!isPositiveInteger(value)) {
throw new Error(`Invalid positive integer: ${value}.`);
}
};
export const validateFiniteNonNegativeNumber = (value: number): void => {
if (!isFiniteNonNegativeNumber(value)) {
throw new Error(`Invalid finite non-negative number: ${value}.`);
}
};🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/number.ts` around lines 13 - 21, The module
exposes validateNonNegativeInteger but lacks matching validator functions for
other numeric checks used elsewhere; add validatePositiveInteger and
validateFiniteNonNegativeNumber to mirror the pattern of
validateNonNegativeInteger and use existing helpers isPositiveInteger and
isFiniteNonNegativeNumber, throwing descriptive Errors (e.g., "Invalid positive
integer: ${value}.", "Invalid finite non-negative number: ${value}.") so callers
can consistently call validation functions like validatePositiveInteger and
validateFiniteNonNegativeNumber alongside validateNonNegativeInteger.
| export const getReferrerDetail = ( | ||
| referrer: Address, | ||
| leaderboard: ReferrerLeaderboard, | ||
| ): ReferrerDetail => { | ||
| const awardedReferrerMetrics = leaderboard.referrers.get(referrer); | ||
|
|
||
| // If referrer is on the leaderboard, return their ranked metrics | ||
| if (awardedReferrerMetrics) { | ||
| return { | ||
| type: ReferrerDetailTypeIds.Ranked, | ||
| rules: leaderboard.rules, | ||
| referrer: awardedReferrerMetrics, | ||
| aggregatedMetrics: leaderboard.aggregatedMetrics, | ||
| accurateAsOf: leaderboard.accurateAsOf, | ||
| }; | ||
| } | ||
|
|
||
| // If referrer not found, return an unranked referrer record | ||
| return { | ||
| type: ReferrerDetailTypeIds.Unranked, | ||
| rules: leaderboard.rules, | ||
| referrer: buildUnrankedReferrerMetrics(referrer), | ||
| aggregatedMetrics: leaderboard.aggregatedMetrics, | ||
| accurateAsOf: leaderboard.accurateAsOf, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# First, verify the file exists and get context around the function
echo "=== Checking referrer-detail.ts file ==="
wc -l packages/ens-referrals/src/v1/referrer-detail.ts
# Check imports and type definitions in referrer-detail.ts
echo -e "\n=== Imports and top of file ==="
head -30 packages/ens-referrals/src/v1/referrer-detail.ts
# Search for address normalization patterns in the v1 directory
echo -e "\n=== Searching for address normalization patterns ==="
rg -n "toLowerCase|getAddress|normalize|checksum" packages/ens-referrals/src/v1 -g '*.ts' -C 2
# Check how leaderboard.referrers is populated
echo -e "\n=== Searching for leaderboard.referrers population ==="
rg -n "referrers\.set|referrers\[|new Map" packages/ens-referrals/src/v1 -g '*.ts' -C 3
# Check buildUnrankedReferrerMetrics function
echo -e "\n=== Checking buildUnrankedReferrerMetrics ==="
rg -n "buildUnrankedReferrerMetrics" packages/ens-referrals/src/v1 -g '*.ts' -C 5Repository: namehash/ensnode
Length of output: 9885
🏁 Script executed:
#!/bin/bash
# Look at the ReferrerLeaderboard type definition
echo "=== Searching for ReferrerLeaderboard type ==="
rg -n "interface ReferrerLeaderboard|type ReferrerLeaderboard" packages/ens-referrals/src/v1 -g '*.ts' -A 10
# Check if there's documentation about lowercase keys
echo -e "\n=== Searching for documentation about address format ==="
rg -n "lowercase|Lowercase|LOWERCASE" packages/ens-referrals/src/v1 -g '*.ts' -C 2
# Look at Address type usage
echo -e "\n=== Checking Address type definition ==="
rg -n "type Address|interface Address" packages/ens-referrals/src -g '*.ts' -C 3Repository: namehash/ensnode
Length of output: 18364
🏁 Script executed:
#!/bin/bash
# Check parent directory for address normalization patterns
echo "=== Searching in parent directories for address handling ==="
rg -n "toLowerCase\(\)" packages/ens-referrals/src -g '*.ts' -B 2 -A 2
# Look for any existing normalization utilities
echo -e "\n=== Checking for utilities ==="
fd -t f "util" packages/ens-referrals/src/v1 -o -name "*util*" -o -name "*helper*"
# Get full context of getReferrerDetail function
echo -e "\n=== Full getReferrerDetail function ==="
sed -n '132,156p' packages/ens-referrals/src/v1/referrer-detail.tsRepository: namehash/ensnode
Length of output: 2545
🏁 Script executed:
#!/bin/bash
# Check current imports in referrer-detail.ts
echo "=== Current imports in referrer-detail.ts ==="
head -15 packages/ens-referrals/src/v1/referrer-detail.ts
# Check buildReferrerMetrics to see normalization
echo -e "\n=== buildReferrerMetrics function ==="
sed -n '54,75p' packages/ens-referrals/src/v1/referrer-metrics.ts
# Check test file to see if there are tests for this scenario
echo -e "\n=== Check for referrer-detail tests ==="
fd -t f "referrer-detail" packages/ens-referrals/src/v1 -name "*.test.ts"Repository: namehash/ensnode
Length of output: 1460
🏁 Script executed:
#!/bin/bash
# Check if there are any tests that might test checksummed addresses
if [ -f "packages/ens-referrals/src/v1/referrer-detail.test.ts" ]; then
echo "=== referrer-detail tests ==="
cat packages/ens-referrals/src/v1/referrer-detail.test.ts
fi
# Also check the address.ts file for the full utilities available
echo -e "\n=== Full address.ts file ==="
cat packages/ens-referrals/src/v1/address.tsRepository: namehash/ensnode
Length of output: 603
Normalize address casing before Map lookup.
Leaderboard keys are fully lowercase (per documentation in leaderboard.ts line 37). A checksummed Address will miss the Map and incorrectly return Unranked even when ranked. Normalize before the get() call and before building unranked metrics.
Suggested fix
export const getReferrerDetail = (
referrer: Address,
leaderboard: ReferrerLeaderboard,
): ReferrerDetail => {
- const awardedReferrerMetrics = leaderboard.referrers.get(referrer);
+ const normalizedReferrer = normalizeAddress(referrer);
+ const awardedReferrerMetrics = leaderboard.referrers.get(normalizedReferrer);
// If referrer is on the leaderboard, return their ranked metrics
if (awardedReferrerMetrics) {
return {
type: ReferrerDetailTypeIds.Ranked,
rules: leaderboard.rules,
referrer: awardedReferrerMetrics,
aggregatedMetrics: leaderboard.aggregatedMetrics,
accurateAsOf: leaderboard.accurateAsOf,
};
}
// If referrer not found, return an unranked referrer record
return {
type: ReferrerDetailTypeIds.Unranked,
rules: leaderboard.rules,
- referrer: buildUnrankedReferrerMetrics(referrer),
+ referrer: buildUnrankedReferrerMetrics(normalizedReferrer),
aggregatedMetrics: leaderboard.aggregatedMetrics,
accurateAsOf: leaderboard.accurateAsOf,
};
};Add import: import { normalizeAddress } from "./address";
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/referrer-detail.ts` around lines 132 - 156, The
Map lookup in getReferrerDetail uses leaderboard.referrers which stores keys in
lowercase, so normalize the provided referrer before using it: call
normalizeAddress(referrer) and use that normalized value for
leaderboard.referrers.get(...) and when passing to buildUnrankedReferrerMetrics;
also add the import for normalizeAddress from "./address". Ensure all
occurrences in getReferrerDetail use the normalized address variable.
| import type { Duration } from "@ensnode/ensnode-sdk"; | ||
|
|
||
| import { SECONDS_PER_YEAR } from "./time"; | ||
|
|
||
| /** | ||
| * The score of a referrer. | ||
| * | ||
| * @invariant Guaranteed to be a finite non-negative number (>= 0) | ||
| */ | ||
| export type ReferrerScore = number; | ||
|
|
||
| export const isValidReferrerScore = (score: ReferrerScore): boolean => { | ||
| return score >= 0 && Number.isFinite(score); | ||
| }; | ||
|
|
||
| export const validateReferrerScore = (score: ReferrerScore): void => { | ||
| if (!isValidReferrerScore(score)) { | ||
| throw new Error( | ||
| `Invalid referrer score: ${score}. Referrer score must be a finite non-negative number.`, | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Calculate the score of a referrer based on the total incremental duration | ||
| * (in seconds) of registrations and renewals for direct subnames of .eth | ||
| * referrered by the referrer within the ENS Holiday Awards period. | ||
| * | ||
| * @param totalIncrementalDuration - The total incremental duration (in seconds) | ||
| * of referrals made by a referrer within the {@link ReferralProgramRules}. | ||
| * @returns The score of the referrer. | ||
| */ | ||
| export const calcReferrerScore = (totalIncrementalDuration: Duration): ReferrerScore => { | ||
| return totalIncrementalDuration / SECONDS_PER_YEAR; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Validate duration before scoring.
This keeps the public API invariant intact and prevents negative/NaN scores from leaking downstream.
♻️ Suggested refactor
-import { SECONDS_PER_YEAR } from "./time";
+import { SECONDS_PER_YEAR, validateDuration } from "./time";
@@
export const calcReferrerScore = (totalIncrementalDuration: Duration): ReferrerScore => {
+ validateDuration(totalIncrementalDuration);
return totalIncrementalDuration / SECONDS_PER_YEAR;
};🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/score.ts` around lines 1 - 35, The
calcReferrerScore function doesn't validate its input and can return
negative/NaN scores; update calcReferrerScore(totalIncrementalDuration) to
validate the duration before computing by calling validateReferrerScore or using
isValidReferrerScore and throwing a clear Error when invalid, then proceed to
compute totalIncrementalDuration / SECONDS_PER_YEAR; reference the existing
functions isValidReferrerScore and validateReferrerScore to implement the check
and preserve the public API invariant.
| import type { Duration, UnixTimestamp } from "@ensnode/ensnode-sdk"; | ||
|
|
||
| import { isInteger, isNonNegativeInteger } from "./number"; | ||
|
|
||
| export const validateUnixTimestamp = (timestamp: UnixTimestamp): void => { | ||
| if (!isInteger(timestamp)) { | ||
| throw new Error(`Invalid Unix timestamp: ${timestamp}. Unix timestamp must be an integer.`); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reject negative Unix timestamps.
validateUnixTimestamp currently accepts negative integers, which can let invalid program windows through. Prefer a non‑negative check.
🛠️ Suggested fix
-import { isInteger, isNonNegativeInteger } from "./number";
+import { isNonNegativeInteger } from "./number";
@@
export const validateUnixTimestamp = (timestamp: UnixTimestamp): void => {
- if (!isInteger(timestamp)) {
- throw new Error(`Invalid Unix timestamp: ${timestamp}. Unix timestamp must be an integer.`);
- }
+ if (!isNonNegativeInteger(timestamp)) {
+ throw new Error(
+ `Invalid Unix timestamp: ${timestamp}. Unix timestamp must be a non-negative integer.`,
+ );
+ }
};📝 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.
| import type { Duration, UnixTimestamp } from "@ensnode/ensnode-sdk"; | |
| import { isInteger, isNonNegativeInteger } from "./number"; | |
| export const validateUnixTimestamp = (timestamp: UnixTimestamp): void => { | |
| if (!isInteger(timestamp)) { | |
| throw new Error(`Invalid Unix timestamp: ${timestamp}. Unix timestamp must be an integer.`); | |
| } | |
| }; | |
| import type { Duration, UnixTimestamp } from "@ensnode/ensnode-sdk"; | |
| import { isNonNegativeInteger } from "./number"; | |
| export const validateUnixTimestamp = (timestamp: UnixTimestamp): void => { | |
| if (!isNonNegativeInteger(timestamp)) { | |
| throw new Error( | |
| `Invalid Unix timestamp: ${timestamp}. Unix timestamp must be a non-negative integer.`, | |
| ); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/time.ts` around lines 1 - 9, The current
validateUnixTimestamp function only checks isInteger(timestamp) and thus allows
negative integers; update validateUnixTimestamp to also check
isNonNegativeInteger(timestamp) (or combine with the existing isInteger check)
and throw a descriptive Error when the timestamp is negative so only
non‑negative UnixTimestamp values are accepted; reference the
validateUnixTimestamp function and the helper isNonNegativeInteger to locate
where to add the additional validation and error message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@packages/ens-referrals/src/v1/client.ts`:
- Line 133: Two fetch calls in packages/ens-referrals/src/v1/client.ts (the one
assigning to response at the shown diff and the other fetch later) lack
timeout/abort handling; update both fetch calls to include an AbortSignal-based
timeout (e.g., AbortSignal.timeout(30000)) or wrap them with an AbortController
+ setTimeout and clearTimeout to abort slow requests, propagate the
controller.signal to fetch, and handle/translate abort errors appropriately so
callers receive a clear timeout error instead of hanging indefinitely.
- Line 127: The getReferrerDetail method is building the wrong path
(`/api/ensanalytics/...`) causing 404s; update the URL construction in
getReferrerDetail to use the v1 route consistent with getReferrerLeaderboardPage
by using `/ensanalytics/v1/referrers/${encodeURIComponent(request.referrer)}`
when creating the URL (keep using new URL(..., this.options.url)); locate the
getReferrerDetail function and replace the incorrect `/api/ensanalytics/...`
segment with
`/ensanalytics/v1/referrers/${encodeURIComponent(request.referrer)}`.
In `@packages/ens-referrals/src/v1/referrer-metrics.ts`:
- Around line 242-283: The validateAwardedReferrerMetrics function currently
only checks awardPoolApproxValue.amount range and can miss malformed PriceUsdc
objects; update validateAwardedReferrerMetrics to first validate
referrer.awardPoolApproxValue against the PriceUsdc schema (use
makePriceUsdcSchema().validate or equivalent used by
validateUnrankedReferrerMetrics) and throw on schema validation errors, then
retain the existing bigint range check against rules.totalAwardPoolValue.amount;
ensure any necessary import of makePriceUsdcSchema is added.
| async getReferrerLeaderboardPage( | ||
| request?: ReferrerLeaderboardPageRequest, | ||
| ): Promise<ReferrerLeaderboardPageResponse> { | ||
| const url = new URL(`/ensanalytics/v1/referrers`, this.options.url); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, read the client.ts file to see both endpoints
echo "=== Reading packages/ens-referrals/src/v1/client.ts ==="
wc -l packages/ens-referrals/src/v1/client.tsRepository: namehash/ensnode
Length of output: 159
🏁 Script executed:
#!/bin/bash
# Read specific lines around both endpoints mentioned
echo "=== Lines around 127 (getReferrerLeaderboardPage) ==="
sed -n '120,135n' packages/ens-referrals/src/v1/client.ts
echo ""
echo "=== Lines around 231-234 (getReferrerDetail) ==="
sed -n '225,240n' packages/ens-referrals/src/v1/client.tsRepository: namehash/ensnode
Length of output: 163
🏁 Script executed:
#!/bin/bash
# Search for all ensanalytics endpoint patterns
echo "=== All ensanalytics URL patterns in client.ts ==="
rg -n "ensanalytics" packages/ens-referrals/src/v1/client.ts -C 1Repository: namehash/ensnode
Length of output: 385
🏁 Script executed:
#!/bin/bash
# Search for backend route definitions that would show correct endpoint paths
echo "=== Searching for backend API routes ==="
find . -type f \( -name "*route*" -o -name "*router*" -o -name "*handler*" -o -name "*api*" \) ! -path "*/node_modules/*" ! -path "*/.git/*" | head -20Repository: namehash/ensnode
Length of output: 1103
🏁 Script executed:
#!/bin/bash
# Check the v1 handler for ensanalytics routes
echo "=== ensanalytics-api-v1.ts (looking for referrers routes) ==="
rg -n "referrers" apps/ensapi/src/handlers/ensanalytics-api-v1.ts -C 2
echo ""
echo "=== ensanalytics-api.ts (looking for referrers routes) ==="
rg -n "referrers" apps/ensapi/src/handlers/ensanalytics-api.ts -C 2Repository: namehash/ensnode
Length of output: 1811
🏁 Script executed:
#!/bin/bash
# Look at how the handler files are registered/mounted in the API
echo "=== Searching for how handlers are mounted/registered ==="
find ./apps/ensapi -name "*.ts" ! -path "*/node_modules/*" ! -path "*/.test.*" ! -path "*/.spec.*" | xargs grep -l "ensanalytics-api" | head -5Repository: namehash/ensnode
Length of output: 348
🏁 Script executed:
#!/bin/bash
# Check the main index.ts to see how handlers are mounted
echo "=== apps/ensapi/src/index.ts (looking for handler mounting) ==="
cat apps/ensapi/src/index.ts | head -100Repository: namehash/ensnode
Length of output: 3319
🏁 Script executed:
#!/bin/bash
# Search for where the ensanalytics handlers are imported/mounted
echo "=== Searching for ensanalytics handler imports and routing ==="
rg -n "ensanalytics-api" apps/ensapi/src/index.ts -C 3Repository: namehash/ensnode
Length of output: 512
🏁 Script executed:
#!/bin/bash
# Check if there's a routing prefix being applied to /api/ensanalytics
echo "=== Checking for /api/ prefix routing ==="
rg -n "/api" apps/ensapi/src/index.ts -C 2Repository: namehash/ensnode
Length of output: 501
🏁 Script executed:
#!/bin/bash
# Verify the exact function names and context around line 232
echo "=== Reading getReferrerDetail function (lines 225-245) ==="
sed -n '225,245n' packages/ens-referrals/src/v1/client.tsRepository: namehash/ensnode
Length of output: 118
🏁 Script executed:
#!/bin/bash
# Try reading the entire file since it's 254 lines
echo "=== Reading entire packages/ens-referrals/src/v1/client.ts ==="
cat -n packages/ens-referrals/src/v1/client.ts | tail -100Repository: namehash/ensnode
Length of output: 4958
🏁 Script executed:
#!/bin/bash
# Let's get the middle section
echo "=== Middle section of client.ts (lines 200-254) ==="
cat -n packages/ens-referrals/src/v1/client.ts | sed -n '200,254p'Repository: namehash/ensnode
Length of output: 2649
🏁 Script executed:
#!/bin/bash
# Check the beginning of the file to understand which API version this client targets
echo "=== First 50 lines of client.ts ==="
head -50 packages/ens-referrals/src/v1/client.tsRepository: namehash/ensnode
Length of output: 1286
Fix incorrect API path for getReferrerDetail endpoint.
The getReferrerDetail method (line 232) uses /api/ensanalytics/referrers/... which does not exist in the backend. The backend routes ensanalytics handlers at /ensanalytics and /ensanalytics/v1, not /api/ensanalytics. This will cause 404 errors when calling this endpoint.
For a v1 client, change line 232 to either:
/ensanalytics/v1/referrers/${encodeURIComponent(request.referrer)}(consistent with getReferrerLeaderboardPage on line 127)/ensanalytics/referrers/${encodeURIComponent(request.referrer)}(if the endpoint only exists in v0)
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/client.ts` at line 127, The getReferrerDetail
method is building the wrong path (`/api/ensanalytics/...`) causing 404s; update
the URL construction in getReferrerDetail to use the v1 route consistent with
getReferrerLeaderboardPage by using
`/ensanalytics/v1/referrers/${encodeURIComponent(request.referrer)}` when
creating the URL (keep using new URL(..., this.options.url)); locate the
getReferrerDetail function and replace the incorrect `/api/ensanalytics/...`
segment with
`/ensanalytics/v1/referrers/${encodeURIComponent(request.referrer)}`.
| if (request?.recordsPerPage) | ||
| url.searchParams.set("recordsPerPage", request.recordsPerPage.toString()); | ||
|
|
||
| const response = await fetch(url); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider adding request timeout handling.
Both fetch calls lack timeout/abort handling. On slow or unresponsive servers, these could hang indefinitely.
♻️ Suggested approach using AbortSignal.timeout()
// Modern approach (Node 18+, modern browsers)
const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
// Or with AbortController for more control
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(url, { signal: controller.signal });
// ...
} finally {
clearTimeout(timeoutId);
}Also applies to: 236-236
🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/client.ts` at line 133, Two fetch calls in
packages/ens-referrals/src/v1/client.ts (the one assigning to response at the
shown diff and the other fetch later) lack timeout/abort handling; update both
fetch calls to include an AbortSignal-based timeout (e.g.,
AbortSignal.timeout(30000)) or wrap them with an AbortController + setTimeout
and clearTimeout to abort slow requests, propagate the controller.signal to
fetch, and handle/translate abort errors appropriately so callers receive a
clear timeout error instead of hanging indefinitely.
| /** | ||
| * Extends {@link RankedReferrerMetrics} to include additional metrics | ||
| * relative to {@link AggregatedRankedReferrerMetrics}. | ||
| */ | ||
| export interface AwardedReferrerMetrics extends RankedReferrerMetrics { | ||
| /** | ||
| * The referrer's share of the award pool. | ||
| * | ||
| * @invariant Guaranteed to be a number between 0 and 1 (inclusive) | ||
| * @invariant Calculated as: `finalScore / {@link AggregatedRankedReferrerMetrics.grandTotalQualifiedReferrersFinalScore}` if `isQualified` is `true`, else `0` | ||
| */ | ||
| awardPoolShare: number; | ||
|
|
||
| /** | ||
| * The approximate USDC value of the referrer's share of the {@link ReferralProgramRules.totalAwardPoolValue}. | ||
| * | ||
| * @invariant Guaranteed to be a valid PriceUsdc with amount between 0 and {@link ReferralProgramRules.totalAwardPoolValue.amount} (inclusive) | ||
| * @invariant Calculated as: `awardPoolShare` * {@link ReferralProgramRules.totalAwardPoolValue.amount} | ||
| */ | ||
| awardPoolApproxValue: PriceUsdc; | ||
| } | ||
|
|
||
| export const validateAwardedReferrerMetrics = ( | ||
| referrer: AwardedReferrerMetrics, | ||
| rules: ReferralProgramRules, | ||
| ): void => { | ||
| validateRankedReferrerMetrics(referrer, rules); | ||
| if (referrer.awardPoolShare < 0 || referrer.awardPoolShare > 1) { | ||
| throw new Error( | ||
| `Invalid AwardedReferrerMetrics: ${referrer.awardPoolShare}. awardPoolShare must be between 0 and 1 (inclusive).`, | ||
| ); | ||
| } | ||
|
|
||
| if ( | ||
| referrer.awardPoolApproxValue.amount < 0n || | ||
| referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount | ||
| ) { | ||
| throw new Error( | ||
| `Invalid AwardedReferrerMetrics: ${referrer.awardPoolApproxValue.amount.toString()}. awardPoolApproxValue must be between 0 and ${rules.totalAwardPoolValue.amount.toString()} (inclusive).`, | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's explore the structure of the referrer-metrics.ts file
cd packages/ens-referrals/src/v1 && wc -l referrer-metrics.tsRepository: namehash/ensnode
Length of output: 83
🏁 Script executed:
# Search for validateUnrankedReferrerMetrics to see how it validates PriceUsdc
rg "validateUnrankedReferrerMetrics" packages/ens-referrals/src/v1/Repository: namehash/ensnode
Length of output: 295
🏁 Script executed:
# Search for makePriceUsdcSchema in the codebase
rg "makePriceUsdcSchema" packages/ens-referrals/Repository: namehash/ensnode
Length of output: 1053
🏁 Script executed:
# Search for PriceUsdc type definition
rg "type PriceUsdc\|interface PriceUsdc" packages/ens-referrals/Repository: namehash/ensnode
Length of output: 42
🏁 Script executed:
# Let's look at the validateUnrankedReferrerMetrics implementation
rg -A 30 "export const validateUnrankedReferrerMetrics" packages/ens-referrals/src/v1/referrer-metrics.tsRepository: namehash/ensnode
Length of output: 1281
🏁 Script executed:
# Let's also check the imports at the top of referrer-metrics.ts
head -50 packages/ens-referrals/src/v1/referrer-metrics.tsRepository: namehash/ensnode
Length of output: 1611
🏁 Script executed:
# Search for PriceUsdc definition more broadly
rg "PriceUsdc" packages/ens-referrals/src/v1/ -B 2 | head -100Repository: namehash/ensnode
Length of output: 7431
🏁 Script executed:
# Let's get the full validateUnrankedReferrerMetrics function
rg -A 50 "export const validateUnrankedReferrerMetrics" packages/ens-referrals/src/v1/referrer-metrics.tsRepository: namehash/ensnode
Length of output: 1880
🏁 Script executed:
# Let's also check the validateAwardedReferrerMetrics implementation to see current state
rg -B 5 -A 25 "export const validateAwardedReferrerMetrics" packages/ens-referrals/src/v1/referrer-metrics.tsRepository: namehash/ensnode
Length of output: 1169
Validate awardPoolApproxValue with the PriceUsdc schema.
The validateAwardedReferrerMetrics function only checks the amount range, whereas validateUnrankedReferrerMetrics validates the full PriceUsdc object using makePriceUsdcSchema. An invalid currency or malformed object would bypass the current checks. The interface itself declares this field must be "a valid PriceUsdc", so add schema validation to match the pattern used for similar fields throughout the codebase.
Suggested fix
export const validateAwardedReferrerMetrics = (
referrer: AwardedReferrerMetrics,
rules: ReferralProgramRules,
): void => {
validateRankedReferrerMetrics(referrer, rules);
if (referrer.awardPoolShare < 0 || referrer.awardPoolShare > 1) {
throw new Error(
`Invalid AwardedReferrerMetrics: ${referrer.awardPoolShare}. awardPoolShare must be between 0 and 1 (inclusive).`,
);
}
+ const priceUsdcSchema = makePriceUsdcSchema("AwardedReferrerMetrics.awardPoolApproxValue");
+ const usdcParseResult = priceUsdcSchema.safeParse(referrer.awardPoolApproxValue);
+ if (!usdcParseResult.success) {
+ throw new Error(
+ `Invalid AwardedReferrerMetrics: awardPoolApproxValue validation failed: ${usdcParseResult.error.message}`,
+ );
+ }
+
if (
referrer.awardPoolApproxValue.amount < 0n ||
referrer.awardPoolApproxValue.amount > rules.totalAwardPoolValue.amount
) {
throw new Error(
`Invalid AwardedReferrerMetrics: ${referrer.awardPoolApproxValue.amount.toString()}. awardPoolApproxValue must be between 0 and ${rules.totalAwardPoolValue.amount.toString()} (inclusive).`,
);
}
};🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/referrer-metrics.ts` around lines 242 - 283,
The validateAwardedReferrerMetrics function currently only checks
awardPoolApproxValue.amount range and can miss malformed PriceUsdc objects;
update validateAwardedReferrerMetrics to first validate
referrer.awardPoolApproxValue against the PriceUsdc schema (use
makePriceUsdcSchema().validate or equivalent used by
validateUnrankedReferrerMetrics) and throw on schema validation errors, then
retain the existing bigint range check against rules.totalAwardPoolValue.amount;
ensure any necessary import of makePriceUsdcSchema is added.
| * described by `pageContext` within the related {@link ReferrerLeaderboard}. | ||
| * | ||
| * @invariant Array will be empty if `pageContext.totalRecords` is 0. | ||
| * @invariant Array entries are ordered by `rank` (descending). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| * @invariant Array entries are ordered by `rank` (descending). | |
| * @invariant Array entries are ordered by `rank` (ascending). |
The invariant documentation comment incorrectly states that array entries are ordered by rank in "descending" order, when they are actually in "ascending" order (rank 1, 2, 3, etc.). This was already corrected in the main leaderboard-page.ts file but the v1 version still has the outdated comment.
View Details
Analysis
Incorrect invariant documentation in v1 leaderboard-page.ts
What fails: The invariant comment for the referrers array in ReferrerLeaderboardPage interface incorrectly states entries are ordered by rank (descending) when they are actually ordered by rank (ascending).
How to reproduce: Review the invariant documentation at line 212 in packages/ens-referrals/src/v1/leaderboard-page.ts and compare it with the main version in packages/ens-referrals/src/leaderboard-page.ts at line 211.
Result: v1 version states "(descending)" while main version correctly states "(ascending)"
Expected behavior: The invariant should state "(ascending)" to match:
- The actual implementation which retrieves values from an ordered Map in insertion order
- The leaderboard invariant in both v1 and main
leaderboard.tsfiles which explicitly state "Map entries are ordered byrank(ascending)" - The correction already made in the main version of
leaderboard-page.ts
The referrers array contains rank 1, 2, 3... in that order (ascending), where rank 1 is the best-performing referrer.
There was a problem hiding this 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 43 out of 43 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const uniqueReferrers = allReferrers.map((referrer) => referrer.referrer); | ||
| if (uniqueReferrers.length !== allReferrers.length) { | ||
| throw new Error( | ||
| "ReferrerLeaderboard: Cannot buildReferrerLeaderboard containing duplicate referrers", | ||
| ); | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The uniqueness check is incorrect. Line 56 maps all referrer addresses into an array, and line 57 checks if the length equals the input length. However, this doesn't verify uniqueness - it just checks if all items have a referrer property. To properly check for duplicates, you should use a Set: if (new Set(uniqueReferrers).size !== allReferrers.length). The current code would never throw the error even if there are duplicate referrers.
| * described by `pageContext` within the related {@link ReferrerLeaderboard}. | ||
| * | ||
| * @invariant Array will be empty if `pageContext.totalRecords` is 0. | ||
| * @invariant Array entries are ordered by `rank` (descending). |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment incorrectly states that array entries are ordered by rank in descending order. Based on the implementation in leaderboard.ts (line 73-74), ranks are assigned as index + 1, meaning rank 1 comes first, followed by rank 2, etc. This is ascending order, not descending. The comment should say "ascending" to match the actual ordering and be consistent with the comment in leaderboard.ts line 34.
| * @invariant Array entries are ordered by `rank` (descending). | |
| * @invariant Array entries are ordered by `rank` (ascending). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@packages/ens-referrals/src/v1/api/deserialize.ts`:
- Around line 63-82: Both deserializeUnrankedReferrerMetrics and
deserializeAwardedReferrerMetrics duplicate the same logic; extract a shared
helper (e.g., deserializeReferrerMetrics or a private function) that accepts a
SerializedUnrankedReferrerMetrics/SerializedAwardedReferrerMetrics input and
returns the common UnrankedReferrerMetrics/AwardedReferrerMetrics shape,
delegating price conversions to deserializePriceEth and deserializePriceUsdc,
then replace both deserializeUnrankedReferrerMetrics and
deserializeAwardedReferrerMetrics to call that helper to remove duplication
while keeping existing public function names.
| /** | ||
| * Deserializes an {@link SerializedUnrankedReferrerMetrics} object. | ||
| */ | ||
| function deserializeUnrankedReferrerMetrics( | ||
| metrics: SerializedUnrankedReferrerMetrics, | ||
| ): UnrankedReferrerMetrics { | ||
| return { | ||
| referrer: metrics.referrer, | ||
| totalReferrals: metrics.totalReferrals, | ||
| totalIncrementalDuration: metrics.totalIncrementalDuration, | ||
| totalRevenueContribution: deserializePriceEth(metrics.totalRevenueContribution), | ||
| score: metrics.score, | ||
| rank: metrics.rank, | ||
| isQualified: metrics.isQualified, | ||
| finalScoreBoost: metrics.finalScoreBoost, | ||
| finalScore: metrics.finalScore, | ||
| awardPoolShare: metrics.awardPoolShare, | ||
| awardPoolApproxValue: deserializePriceUsdc(metrics.awardPoolApproxValue), | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider extracting shared deserialization logic.
deserializeUnrankedReferrerMetrics and deserializeAwardedReferrerMetrics have identical implementations. If both types share the same structure, a generic helper could reduce duplication.
♻️ Optional refactor to reduce duplication
+function deserializeBaseReferrerMetrics<T extends SerializedAwardedReferrerMetrics | SerializedUnrankedReferrerMetrics>(
+ metrics: T,
+): Omit<T, 'totalRevenueContribution' | 'awardPoolApproxValue'> & {
+ totalRevenueContribution: PriceEth;
+ awardPoolApproxValue: PriceUsdc;
+} {
+ return {
+ referrer: metrics.referrer,
+ totalReferrals: metrics.totalReferrals,
+ totalIncrementalDuration: metrics.totalIncrementalDuration,
+ totalRevenueContribution: deserializePriceEth(metrics.totalRevenueContribution),
+ score: metrics.score,
+ rank: metrics.rank,
+ isQualified: metrics.isQualified,
+ finalScoreBoost: metrics.finalScoreBoost,
+ finalScore: metrics.finalScore,
+ awardPoolShare: metrics.awardPoolShare,
+ awardPoolApproxValue: deserializePriceUsdc(metrics.awardPoolApproxValue),
+ };
+}🤖 Prompt for AI Agents
In `@packages/ens-referrals/src/v1/api/deserialize.ts` around lines 63 - 82, Both
deserializeUnrankedReferrerMetrics and deserializeAwardedReferrerMetrics
duplicate the same logic; extract a shared helper (e.g.,
deserializeReferrerMetrics or a private function) that accepts a
SerializedUnrankedReferrerMetrics/SerializedAwardedReferrerMetrics input and
returns the common UnrankedReferrerMetrics/AwardedReferrerMetrics shape,
delegating price conversions to deserializePriceEth and deserializePriceUsdc,
then replace both deserializeUnrankedReferrerMetrics and
deserializeAwardedReferrerMetrics to call that helper to remove duplication
while keeping existing public function names.
ENS Referrals API v1: Price Types Migration
closes: #1521
Reviewer Focus (Read This First)
What reviewers should focus on
packages/ens-referrals/src/v1/*/v1subpath exportawardPoolApproxAmountError I got during testing:
Location:
apps/ensapi/src/handlers/ensanalytics-api-v1.test.tsError: Tests fail with:
{ "responseCode": "error", "error": "Internal server error", "errorMessage": "(0 , __vite_ssr_import_0__.serializePriceUsdc) is not a function" }In my various experiments, I found it to be also failing with
serializePriceEth, which was implemented before this PR. Tried untangling imports inensnode-sdk/ensandensnode-sdk/sharedto make this dependence a single-direction one, but the problem persisted. Importing this directly from ENSApi seemed to work, and importing it from thedistversion worked as well. The current workaround is to export it frominternaland use it for importing inens-referrals, which is not what I intended, but from the fixes I found seemed to be the most benign.Fixed through: #1572
Problem & Motivation
Why this exists
ens-referralspackage was first implemented, we took a few temporary shortcuts to ensure ENS Holiday Awards could be shipped before Dec 1. These temporary shortcuts included quick fixes for theUSDQuantityandRevenueContributionprice data models currently defined inens-referrals.PriceUsdcandPriceEthare defined inensnode-sdk, however we were previously blocked on using these more mature data models because of issue 1519 and our need to ensure ENS Holiday Awards was shipped on schedule.What Changed (Concrete)
What actually changed
ens-referralstov1directoryv1andv1/internalUSDQuantityandRevenueContributiontypes to use mature price types fromensnode-sdkV1middleware to ENSApi to properly handle new types that appear in Referrer Leaderboardensnode-sdk/sharedto be explicit to avoid Vitest issues/ensanalytics/v1to/v1/ensanalyticsDesign & Planning
How this approach was chosen
v1/versionSelf-Review
What you caught yourself
Cross-Codebase Alignment
Related code you checked
PriceEthand its serialization implementation for consistencyPriceUsdc,PriceEth,awardPoolApproxValue,totalAwardPoolValue,RevenueContribution,USDQuantityUSDQuantityandRevenueContribution)maxQualifiedReferrersis 1, using set foruniqueReferrers)Downstream & Consumer Impact
Who this affects and how
v1path for importsTesting Evidence
How this was validated
v1for importing, Vite issues, proper currency calculationsScope Reductions
What you intentionally didn't do
No changes to v0 implementation.
Risk Analysis
How this could go wrong
Low risk - v0 API completely unchanged, v1 is additive only.
Pre-Review Checklist (Blocking)