-
Notifications
You must be signed in to change notification settings - Fork 16
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
Conversation
🦋 Changeset detectedLatest commit: 6b507fd The changes in this PR will be included in the next version bump. This PR includes changesets to release 18 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
📝 WalkthroughWalkthroughAdds a v1 ENS referrals API: new v1 types, serialization/deserialization, client, leaderboard construction/aggregation/pagination, DB metric query, SWR cache + middleware, route wiring, many v1 test fixtures, and switches v1 price types to PriceEth/PriceUsdc and related shared-import refactors. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Handler as "V1 Handler"
participant Middleware as "referrerLeaderboardMiddlewareV1"
participant Cache as "referrerLeaderboardCacheV1"
participant Indexer as "Indexing Status"
participant DB as "Database"
participant Builder as "Leaderboard Builder"
Client->>Handler: GET /v1/ensanalytics/referrers?page=1
Handler->>Middleware: apply
Middleware->>Cache: read()
alt cache miss / revalidate
Cache->>Indexer: getIndexingStatus()
Indexer-->>Cache: status (Following|Completed)
Cache->>Indexer: getLatestIndexedBlockRef()
Indexer-->>Cache: blockRef (timestamp)
Cache->>DB: getReferrerMetrics(rules)
DB-->>Cache: ReferrerMetrics[]
Cache->>Builder: buildReferrerLeaderboard(metrics, rules, timestamp)
Builder-->>Cache: ReferrerLeaderboard
else cache hit
Cache-->>Middleware: ReferrerLeaderboard (cached)
end
Middleware->>Handler: set c.var.referrerLeaderboardV1
Handler->>Handler: build page / detail
Handler-->>Client: Serialized ReferrerLeaderboardPageResponse
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ 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; |
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.
@Goader This looks like a good suggestion.
| @@ -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, |
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.
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.
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; }
| /** | ||
| * 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; |
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.
11 files reviewed, no comments
lightwalker-eth
left a 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.
@Goader Hey thanks for this! Reviewed and shared some suggestions. Please take the lead to merge when ready 👍
| } from "@ensnode/ensnode-sdk/internal"; | ||
|
|
||
| import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; | ||
| import { type ReferrerDetailRanked, ReferrerDetailTypeIds } from "../referrer-detail"; |
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.
@Goader Could you check this?
| export const makeReferrerLeaderboardPageResponseSchema = ( | ||
| valueLabel: string = "ReferrerLeaderboardPageResponse", | ||
| ) => | ||
| z.union([ |
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.
I believe here we could make use of Zod's support for discriminated unions.
discriminatedUnion instead of union.
Related docs: https://zod.dev/api?id=discriminated-unions
| const result = { | ||
| ...referrer, | ||
| awardPoolShare, | ||
| 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.
Please see related comments above in this file. awardPoolApproxAmount will already be a PriceUsdc if we follow those suggestions and won't need to be manually converted back into a PriceUsdc here.
| * @returns An {@link UnrankedReferrerMetrics} with zero values for all metrics and null rank | ||
| */ | ||
| export const buildUnrankedReferrerMetrics = (referrer: Address): UnrankedReferrerMetrics => { | ||
| const baseMetrics = buildReferrerMetrics(referrer, 0, 0, { |
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.
Let's not create Price objects inline like this. Suggest instead to define utility functions such as buildPriceEth that take in an amount (in wei) and then return back a PriceEth object.
| finalScoreBoost: 0, | ||
| finalScore: 0, | ||
| awardPoolShare: 0, | ||
| 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.
Please see related comment above. This could be a buildPriceUsdc utility function.
| finalScoreBoost: 1, | ||
| finalScore: 6.001492159318809, | ||
| awardPoolShare: 0.36258040801323277, | ||
| 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.
For each of our supported currency types, suggest to define a utility function such as:
parseUsdc which should take as input a string such as "123.45678" (representing $123.45678 in USDC) and then return back a PriceUsdc where the amount is converted into the appropriate representation. This will need to use getCurrencyInfo and CurrencyInfo.decimals.
Each of these helper functions should have good unit tests defined.
Here's some related utility functions that operate on a slightly different data model but are fundamentally solving the same problem:
parseUnitsin viem: https://github.com/wevm/viem/blob/f11456b6907e024eb0b34e333a2d39c28a6d417e/src/utils/unit/parseUnits.ts- ... related unit tests: https://github.com/wevm/viem/blob/f11456b6907e024eb0b34e333a2d39c28a6d417e/src/utils/unit/parseUnits.test.ts
parseEtherin viem, which would be something like aparseEthin our data model and terminology: https://github.com/wevm/viem/blob/f11456b6907e024eb0b34e333a2d39c28a6d417e/src/utils/unit/parseEther.ts- ... related unit tests: https://github.com/wevm/viem/blob/f11456b6907e024eb0b34e333a2d39c28a6d417e/src/utils/unit/parseEther.test.ts
| referrer: "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e", | ||
| totalReferrals: 3, | ||
| totalIncrementalDuration: 94694400, | ||
| 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.
This can be implemented as parseEth. Please see my related comment on parseUsdc.
…e into feat/mature-price-models
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 85 out of 85 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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: 5
🤖 Fix all issues with AI agents
In `@apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts`:
- Around line 197-214: The populatedReferrerLeaderboard fixture contains two
referrers (0x54e7c79aceb6b736da4c29da088aae30991635bb and
0xe3cc38fb4da8a96a6ab245022e6778a1ed32619c) that are missing from
dbResultsReferrerLeaderboard, causing
aggregatedMetrics.grandTotalRevenueContribution to be incorrect; fix by either
(A) adding those two referrers with correct metric fields to
dbResultsReferrerLeaderboard so the populatedReferrerLeaderboard aggregation
matches the DB source, or (B) removing those two entries from
populatedReferrerLeaderboard and updating aggregatedMetrics
(grandTotalRevenueContribution, grandTotalReferrals,
grandTotalIncrementalDuration, grandTotalQualifiedReferrersFinalScore, etc.) to
reflect the true sums from dbResultsReferrerLeaderboard; update whichever
function/fixture references populatedReferrerLeaderboard and
dbResultsReferrerLeaderboard to keep them consistent.
In `@packages/ens-referrals/src/v1/api/zod-schemas.ts`:
- Around line 31-38: Add an object-level validation to
makeReferralProgramRulesSchema to enforce the documented invariant that endTime
must be >= startTime: after building the z.object with totalAwardPoolValue,
maxQualifiedReferrers, startTime, endTime, subregistryId, call .refine(...) on
the object returned by makeReferralProgramRulesSchema to compare the parsed
startTime and endTime values (they are Unix timestamps), returning a clear error
message and setting the error path to the endTime field if endTime < startTime
so Zod rejects invalid API responses at schema-parse time.
In `@packages/ens-referrals/src/v1/referrer-metrics.ts`:
- Around line 297-304: Replace the type assertion on the constructed object with
a compile-time check using TypeScript's `satisfies` operator: change the
`result` declaration that currently uses `as AwardedReferrerMetrics` to use
`satisfies AwardedReferrerMetrics` (e.g., the `result` const created before
calling `validateAwardedReferrerMetrics`). Keep the same object shape (spreading
`referrer` and adding `awardPoolShare` and `awardPoolApproxValue`) and retain
the `validateAwardedReferrerMetrics(result, rules)` call; this enforces
structural type correctness at compile time rather than bypassing checks with
`as`.
In `@packages/ensnode-sdk/src/shared/currencies.ts`:
- Around line 217-305: The parseEth, parseUsdc, and parseDai functions currently
allow empty and negative inputs because they rely on viem.parseUnits; add input
validation in each function (parseEth, parseUsdc, parseDai) to trim the incoming
string, throw an Error if the trimmed value is empty, and throw an Error if the
trimmed value represents a negative number (e.g., startsWith('-') or parses
negative), before calling getCurrencyInfo/parseUnits; ensure the thrown error
message is clear (e.g., "amount must be a non-negative decimal string") and keep
using priceEth/priceUsdc/priceDai to return the typed result, and update the
JSDoc examples to remove or change the cases showing "" and "-1" as valid
results.
In `@packages/ensnode-sdk/src/shared/numbers.ts`:
- Around line 54-85: The function scaleBigintByNumber can call
scaleFactor.toFixed(20) which will produce exponential notation for magnitudes
>= 1e21 and later cause BigInt parsing to throw; add an explicit guard before
calling toFixed inside scaleBigintByNumber that rejects scaleFactor values >=
1e21 (and likewise any scaleFactor >= 1e21 or NaN already guarded) by throwing a
clear Error (e.g., "scaleBigintByNumber: scaleFactor must be less than 1e21,
got: <value>") so the function fails fast and avoids producing exponential
strings for fixedStr.
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: 2
🤖 Fix all issues with AI agents
In `@packages/ens-referrals/src/v1/api/zod-schemas.ts`:
- Line 25: Remove the unused type import ReferrerDetailRanked from the import
statement in zod-schemas (leave ReferrerDetailTypeIds intact); since
ReferrerDetailRanked is only referenced in a JSDoc `@link` comment it does not
need to be imported, so update the import that currently mentions
ReferrerDetailRanked to only import ReferrerDetailTypeIds.
In `@packages/ensnode-sdk/src/shared/currencies.ts`:
- Around line 217-312: validateAmountToParse currently trims for validation but
discards the trimmed value, causing parseUnits (used in parseEth, parseUsdc,
parseDai) to receive untrimmed input; change validateAmountToParse to return the
trimmed string (or alternatively have parseEth/parseUsdc/parseDai call .trim()
themselves) and then pass that trimmed value into parseUnits so parsing and
validation are consistent (update signatures/usages of validateAmountToParse and
ensure parseEth, parseUsdc, parseDai use the returned trimmed value when calling
parseUnits).
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 85 out of 85 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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
bigintapi. It was a bug introduced previously inensnode-sdkand migrated toens-referrals. Fixed as of nowCross-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)