Skip to content

coins: runtime numeric guardrails + spec compliance test suite#11802

Open
waynebruce0x wants to merge 3 commits into
masterfrom
waynebruce0x/coins-numeric-guardrails
Open

coins: runtime numeric guardrails + spec compliance test suite#11802
waynebruce0x wants to merge 3 commits into
masterfrom
waynebruce0x/coins-numeric-guardrails

Conversation

@waynebruce0x

@waynebruce0x waynebruce0x commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #11797. That PR fixed the string-price bug at the DB-write helper; this PR hardens the surrounding paths and adds test coverage so the bug class cannot silently return.

  • addToDBWritesList numeric guardrail (warn-only) — new coerceNumericField helper runs on every call. Coerces price/decimals/confidence with Number(), then if the result isn't finite (or if the original input was a weird type/string/undefined) fires a deduped Discord warning via the existing STALE_COINS_ADAPTERS_WEBHOOK and continues the write. Zero runtime blast radius: the write still happens with whatever Number(...) produces, matching pre-PR behaviour for the existing Number(decimals) / Number(confidence) coercion. Dedup is per-{adapter, field, reason} tuple per process lifetime — a single misbehaving adapter produces one Discord message, not one per row.
  • Redis write path coercionservingLayer.ts (Redis rebuild) and bootstrapRedis.ts now Number() the price and skip rows where it isn't finite. Before this, legacy string-typed rows in ClickHouse would propagate to clients through the cache even after write-time fixes.
  • ClickHouse fallback read-path coercionservingLayer.ts coerces price on the CH fallback for parity with the Redis path (which already used parseFloat). If cached price isn't finite, that coin is omitted from the response instead of returning a bad value.
  • rwa/backed.ts cleanup — replaced writes.map that mutated writes during its own iteration with writes.slice() + nested loop over extra chains. Same behaviour (Array#map snapshots length), obviously correct at a glance.
  • New coins/src/adapters/utils/database.test.ts — 6 unit tests for the guardrail: valid inputs pass through untouched (no warn), string-numeric prices coerce silently, unparseable/NaN/undefined inputs write + warn once, dedup verified (5 bad calls → 1 Discord message).
  • New coins/src/api-spec.test.ts — 33-test live integration suite validating responses on all 7 spec'd coins endpoints against the OpenAPI spec. Configurable via COINS_API_BASE (default https://coins.llama.fi) and SKIP_LIVE=1. Covers happy paths, type assertions, unknown/malformed coins, mixed-case, timestamp boundaries, batchHistorical duplicate-casing regression, and explicit regression guards for the string-price bug.

addToDBWritesList has ~374 call sites across 162 adapter files, so early versions of this PR that throw'd on invalid numerics were a much higher blast-radius change. The committed version is log-only and preserves the exact write behaviour the codebase has today — an adapter that was silently writing NaN before still writes NaN, but now it sends one Discord message so we notice and fix the adapter. No runtime behaviour change for any currently-working call site.

Test plan

  • npx jest src/adapters/utils/database.test.ts → 6/6 pass (guardrail unit tests)
  • tsc --noEmit clean on all 6 touched files; pre-existing errors elsewhere untouched
  • Baseline comparison: same targeted tests that fail on this branch (missing AWS creds, pre-existing TS errors in other adapters) also fail identically on origin/master — not caused by this PR
  • cd coins && npx jest api-spec — 33/33 green once Number Type Coins  #11797 merges (2 intentional regression guards for that PR)
  • Verify Redis bootstrap still writes expected keys against staging data

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved numeric validation/coercion across price pipelines and storage; only finite numeric prices are written/stored.
    • Reduced noisy warnings with throttled/de-duplicated alerts for invalid numeric inputs.
  • Tests

    • Added integration tests for API endpoints and unit tests covering numeric coercion and webhook-warning behavior.
  • Refactor

    • Streamlined cross-chain write record generation.
  • Chores

    • Added new token-mapping entries for several b* assets.

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Normalizes and coerces numeric price fields across adapters, storage, and serving; adds warning/throttling for invalid numeric inputs with tests; expands token mappings; refactors RWA cross-chain write generation; and adds conditional live API integration tests.

Changes

Price Validation & Mappings

Layer / File(s) Summary
Token mapping additions
coins/src/adapters/tokenMapping.json
Adds repeated b* token mapping entries across multiple chain sections mapping addresses to asset#ethereum:<address> with decimals: "18" and prefixed symbols.
Numeric warning infrastructure
coins/src/adapters/utils/database.ts
Introduces startup-lifetime counters keyed by (adapter, field, reason), thresholded warning emission (console + optional Discord webhook), a test reset helper, and coerceNumericField.
DB write coercion & wiring
coins/src/adapters/utils/database.ts
Updates addToDBWritesList to coerce price, decimals, and confidence via coerceNumericField and store coerced values (number/NaN/undefined) into write records across branches.
Unit tests for warnings
coins/src/adapters/utils/database.test.ts
Adds Jest tests that mock webhook, reset warning state, assert numeric coercion, single-warning emission per condition, deduplication thresholds, and webhook-absent behavior.
RWA cross-chain write refactor
coins/src/adapters/rwa/backed.ts
Snapshots writes before generating derived cross-chain Write records and consolidates derived PKs to asset#${extraChain}:${addressPart} format.
Serving & bootstrap numeric normalization
coins/src/utils/servingLayer.ts, coins/src/scripts/bootstrapRedis.ts
Normalize candidate price to finite numeric priceNum in CH/Redis serving paths and skip non-finite entries; bootstrap Redis stores numeric priceNum and avoids writing non-finite prices.
Live API integration tests
coins/src/api-spec.test.ts
Adds a conditional live test suite exercising multiple endpoints and asserting that numeric fields (price, timestamp, optional decimals/confidence) are numbers and that error/malformed inputs yield non-5xx responses.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Numbers hopping clean and bright,
Strings turn to digits in the night,
Warnings whisper once, then cease,
Tests and mappings bring new peace,
Redis and API sing numeric light.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the two main components of this PR: runtime numeric guardrails (validation and coercion logic) and a spec compliance test suite (new integration tests).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch waynebruce0x/coins-numeric-guardrails

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Follow-up to #11797. That PR coerces price at the DB-write helper; the
gaps addressed here are (a) other write/read paths, (b) no runtime
guard against future regressions the TypeScript types cannot catch
(adapter input data is typed as `any`), and (c) no live test coverage
against the published OpenAPI spec.

- addToDBWritesList: new `coerceNumericField` helper runs on every
  call and throws loudly when price/decimals/confidence are not finite
  numbers. Single chokepoint so adapters cannot reintroduce the Ondo
  string-price bug by bypassing Number().
- servingLayer + bootstrapRedis: coerce price on the Redis-cache
  write path and skip rows whose price is not a finite number. Before
  this, stale string-typed rows in ClickHouse would propagate to
  clients through the cache even after write-time fixes.
- servingLayer: coerce price on the ClickHouse fallback read path for
  parity with the Redis read path (already used parseFloat).
- rwa/backed.ts: replace writes.map that mutated writes during its
  own iteration with an explicit `writes.slice()` + nested loop over
  extra chains. Same behaviour (Array#map snapshots length), obviously
  correct at a glance.
- coins/src/api-spec.test.ts: new 33-test live integration suite that
  validates responses on all seven spec'd coins endpoints against the
  OpenAPI spec. Configurable via COINS_API_BASE (default
  https://coins.llama.fi) and SKIP_LIVE=1. Covers happy paths, type
  assertions, unknown/malformed coins, mixed-case, timestamp boundaries,
  batchHistorical duplicate-casing regression, and explicit regression
  guards for the string-price bug in #11797.

Companion spec changes in DefiLlama/api-docs#15 (mark decimals optional,
document confidence) — required for this suite to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@waynebruce0x waynebruce0x force-pushed the waynebruce0x/coins-numeric-guardrails branch from 39146f1 to 14909c7 Compare April 22, 2026 13:17
@waynebruce0x waynebruce0x marked this pull request as ready for review April 23, 2026 11:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
coins/src/adapters/utils/database.test.ts (2)

11-18: Test isolation is implicitly coupled to the module-level dedup set.

warnedNumericKeys in database.ts is module-scoped and is not reset in beforeEach — every test here passes only because each uses a unique adapter name (ok-adapter, bad-price-adapter, nan-decimals-adapter, …). A future maintainer adding a test that reuses one of these adapter strings will see zero warnings and a false green test.

Two low-friction ways to harden this:

  • jest.isolateModules(...) around the import so each test gets a fresh module instance, or
  • Export a __resetNumericWarningsForTests() helper from database.ts and call it in beforeEach.
Option 2 sketch
// database.ts
+export function __resetNumericWarningsForTests() {
+  warnedNumericKeys.clear();
+}
// database.test.ts
 beforeEach(() => {
   mockSendMessage.mockClear();
+  __resetNumericWarningsForTests();
   process.env.STALE_COINS_ADAPTERS_WEBHOOK = "https://discord.test/webhook";
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coins/src/adapters/utils/database.test.ts` around lines 11 - 18, Tests share
module-scoped warnedNumericKeys in database.ts causing implicit coupling; fix by
either wrapping the import that uses database.ts in jest.isolateModules(...)
inside each test so each test gets a fresh module instance, or add and export a
test-only function named __resetNumericWarningsForTests (or similar) from
database.ts that clears warnedNumericKeys and call that function in beforeEach
of database.test.ts; reference the module symbol warnedNumericKeys and the new
helper __resetNumericWarningsForTests (or the jest.isolateModules wrapper) when
making the change so future tests are isolated.

93-110: Consider also asserting the "webhook unset" branch.

warnInvalidNumericField only calls sendMessage when process.env.STALE_COINS_ADAPTERS_WEBHOOK is truthy. None of the tests cover the env-unset path, so a future regression that unconditionally calls sendMessage (e.g. someone removes the env guard) wouldn't be caught. A one-line test that unsets the env and asserts mockSendMessage was not called would close that gap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coins/src/adapters/utils/database.test.ts` around lines 93 - 110, Add a test
that verifies the "webhook unset" branch by clearing
process.env.STALE_COINS_ADAPTERS_WEBHOOK, calling addToDBWritesList (the same
way as the existing "undefined confidence" test) and asserting mockSendMessage
was not called; ensure you use the same inputs as the existing test (e.g.,
adapter "undef-conf-adapter") so warnInvalidNumericField path is exercised, and
restore or isolate the env var after the test to avoid cross-test pollution.
coins/src/adapters/utils/database.ts (2)

179-181: Non-null assertion masks the fact that confidenceNum can be NaN.

coerceNumericField(confidence, "confidence", adapter, false)! — the ! eliminates undefined from the type, which is accurate (given allowUndefined=false), but confidenceNum can still be NaN. Since the type is now number, downstream consumers won't be warned by the type system that a finiteness check is still required. Consider either:

  • Returning a discriminated result ({ ok: true, value } | { ok: false }) and letting the caller decide, or
  • Replacing the ! with an explicit Number.isFinite guard and either return-ing or substituting a sentinel when invalid.

Lower priority since the PR is explicitly warn-only, but it would harden the function signature for future callers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coins/src/adapters/utils/database.ts` around lines 179 - 181, The current
non-null assertion on confidenceNum (const confidenceNum =
coerceNumericField(confidence, "confidence", adapter, false)!) hides that the
value can be NaN; update the callsite or the coerceNumericField API so callers
see when the numeric value is invalid: either change coerceNumericField to
return a discriminated result type (e.g., { ok: true, value: number } | { ok:
false, reason? }) and handle the failure before using confidenceNum, or remove
the `!` and add an explicit Number.isFinite(confidenceNum) guard after the call
(and return/substitute a sentinel or throw/log) so downstream code using
confidenceNum (priceNum/decimalsNum consumers) cannot assume a finite number.

134-150: Dedup is per-process-lifetime and uncapped — fine operationally, but silences long-running breakage.

warnedNumericKeys never resets, so once an adapter has warned for (field, reason) you get no further signal for the rest of the pod's uptime. In practice pods get rotated often enough that this is fine, but a broken adapter that first trips on startup warns once and then disappears from Discord for the pod's remaining N hours — ops will underestimate frequency.

Two low-cost improvements to consider:

  • Periodically reset the set (e.g. on a daily interval) so chronic failures keep surfacing.
  • Include a counter in the message (seen 147 times since startup) by tracking a Map<string, number> instead of a Set<string> and only re-emitting on threshold boundaries (1, 10, 100, 1000…).

Also note the set has no upper bound; size is bounded by adapters × fields × reasons which is small, so not a memory concern — mentioning only for future-proofing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coins/src/adapters/utils/database.ts` around lines 134 - 150,
warnedNumericKeys is an unbounded Set used by warnInvalidNumericField so each
unique adapter:field:reason only logs once per process lifetime; change it to a
Map<string, number> (keep the name) and increment the count on each occurrence,
include the count in the console and webhook message (e.g., "seen N times since
startup"), and only emit logs when the count hits threshold boundaries (1, 10,
100, 1000, …) to avoid noisy repeats; additionally add a periodic reset (e.g.,
setInterval clearing the Map once per day) so chronic failures continue to
resurface across long-lived processes while preserving the existing
sendMessage/webhook behavior and the function signature of
warnInvalidNumericField.
coins/src/utils/servingLayer.ts (1)

155-160: Good — symmetric with the write-path guard.

Coercing price.price and dropping non-finite entries on the CH fallback prevents legacy string rows from leaking through the read path. Consider applying the same guard to the Redis read path at Line 103 for consistency — it still uses parseFloat(price.price) without a Number.isFinite check, so any pre-existing cache entry with a bad value (e.g. a serialized null/"NaN" price, or a cache row written before this PR deployed) would still produce a NaN price field in the response.

Suggested defensive change at Line 103
-      response[coin] = {
-        decimals: mapping.decimals ?? undefined,
-        symbol: mapping.symbol ?? "",
-        price: parseFloat(price.price),
-        timestamp: typeof price.timestamp === "string" ? Math.floor(new Date(price.timestamp).getTime() / 1000) : price.timestamp,
-        confidence: price.confidence ?? undefined,
-      };
-      hits++;
+      const priceNum = typeof price.price === "number" ? price.price : Number(price.price);
+      if (!Number.isFinite(priceNum)) return;
+      response[coin] = {
+        decimals: mapping.decimals ?? undefined,
+        symbol: mapping.symbol ?? "",
+        price: priceNum,
+        timestamp: typeof price.timestamp === "string" ? Math.floor(new Date(price.timestamp).getTime() / 1000) : price.timestamp,
+        confidence: price.confidence ?? undefined,
+      };
+      hits++;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coins/src/utils/servingLayer.ts` around lines 155 - 160, The Redis read path
currently uses parseFloat(price.price) and can produce NaN for bad cached
values; mirror the CH fallback by coercing price.price to a Number (e.g., typeof
check or Number()) and then guard with Number.isFinite before assigning to
response[coin]; if the coerced value is not finite, skip the entry (same
behavior as the CH branch that sets decimals/symbol and price only when priceNum
is finite). Target the code that calls parseFloat(price.price) and the
response[coin] assignment to apply this defensive check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@coins/src/adapters/utils/database.ts`:
- Around line 158-161: The code path that handles value === undefined || value
=== null conflates both cases and then returns allowUndefined ? undefined :
Number(value), which makes null become 0 and undefined become NaN inconsistently
and also changes behavior when allowUndefined=true; change the branch to handle
null and undefined explicitly: call warnInvalidNumericField(value, field,
adapter, "missing") as currently done, but then if value === undefined return
allowUndefined ? undefined : Number(undefined) (preserving NaN), and if value
=== null return allowUndefined ? undefined : Number(null) (preserving 0) — i.e.,
split the combined condition into two branches using the existing symbols value,
allowUndefined, field, adapter and warnInvalidNumericField so behaviour is
deterministic and explicit.
- Around line 152-165: coerceNumericField currently allows NaN (and coerces null
to 0) for numeric fields like decimals and confidence, so update the downstream
write filter in batchWriteWithAlerts (and/or sanitize outputs from
coerceNumericField) to reject or strip non-finite decimals and confidence before
writing: ensure batchWriteWithAlerts requires Number.isFinite(i.price)
regardless of i.redirect and add checks like Number.isFinite(i.decimals ?? 0)
(or delete i.decimals when non-finite) and similarly handle i.confidence, or if
you intend to keep this PR warn-only, add a TODO referencing a follow-up to
enforce finite checks and strip non-finite numeric fields; reference functions
coerceNumericField, batchWriteWithAlerts, and filterWritesWithLowConfidence when
making the change.

In `@coins/src/api-spec.test.ts`:
- Around line 126-137: The test "SPYon (Ondo equity) — price must be a number,
not a string" silently passes because of the early return if (!coin) return; —
make the guard deterministic by ensuring the coin is queried over a wider window
or by asserting presence: modify the GET request parameters used by
get(`${P}/${SPYON}`) to include a larger searchWidth (e.g. "30d") or replace the
early return with an explicit expect(coin).toBeDefined() so the test fails when
SPYON is absent; update references in the test to P, SPYON, coin, and the get
call accordingly.
- Around line 213-224: The test "same coin in two casings — regression for
duplicate-entry bug (PR test C)" currently only checks each returned coin has ≤1
price entry but doesn't ensure the two casings collapsed; update the test to
also assert that the server returned a single canonical key (inspect
Object.keys(r.data.coins).length) or verify the union of timestamps across all
entries has no duplicates (collect timestamps from r.data.coins values and
assert uniqueness) — reference variables/checkpoints: checksum, lower (USDC),
and r.data.coins to locate where to add the extra assertion.
- Around line 243-257: The test "happy path — prices[] with numeric fields"
currently calls assertFiniteNumber on coin.decimals and coin.confidence
unconditionally; change it to only call assertFiniteNumber for each field when
that property exists on coin (use the "in" check like assertPriceEntry does),
i.e., guard assertFiniteNumber(coin.decimals, "coin.decimals") and
assertFiniteNumber(coin.confidence, "coin.confidence") with checks such as "if
('decimals' in coin) ..." and "if ('confidence' in coin) ...", leaving the rest
of the price array assertions (assertPriceEntry and checks of coin.prices)
intact.

---

Nitpick comments:
In `@coins/src/adapters/utils/database.test.ts`:
- Around line 11-18: Tests share module-scoped warnedNumericKeys in database.ts
causing implicit coupling; fix by either wrapping the import that uses
database.ts in jest.isolateModules(...) inside each test so each test gets a
fresh module instance, or add and export a test-only function named
__resetNumericWarningsForTests (or similar) from database.ts that clears
warnedNumericKeys and call that function in beforeEach of database.test.ts;
reference the module symbol warnedNumericKeys and the new helper
__resetNumericWarningsForTests (or the jest.isolateModules wrapper) when making
the change so future tests are isolated.
- Around line 93-110: Add a test that verifies the "webhook unset" branch by
clearing process.env.STALE_COINS_ADAPTERS_WEBHOOK, calling addToDBWritesList
(the same way as the existing "undefined confidence" test) and asserting
mockSendMessage was not called; ensure you use the same inputs as the existing
test (e.g., adapter "undef-conf-adapter") so warnInvalidNumericField path is
exercised, and restore or isolate the env var after the test to avoid cross-test
pollution.

In `@coins/src/adapters/utils/database.ts`:
- Around line 179-181: The current non-null assertion on confidenceNum (const
confidenceNum = coerceNumericField(confidence, "confidence", adapter, false)!)
hides that the value can be NaN; update the callsite or the coerceNumericField
API so callers see when the numeric value is invalid: either change
coerceNumericField to return a discriminated result type (e.g., { ok: true,
value: number } | { ok: false, reason? }) and handle the failure before using
confidenceNum, or remove the `!` and add an explicit
Number.isFinite(confidenceNum) guard after the call (and return/substitute a
sentinel or throw/log) so downstream code using confidenceNum
(priceNum/decimalsNum consumers) cannot assume a finite number.
- Around line 134-150: warnedNumericKeys is an unbounded Set used by
warnInvalidNumericField so each unique adapter:field:reason only logs once per
process lifetime; change it to a Map<string, number> (keep the name) and
increment the count on each occurrence, include the count in the console and
webhook message (e.g., "seen N times since startup"), and only emit logs when
the count hits threshold boundaries (1, 10, 100, 1000, …) to avoid noisy
repeats; additionally add a periodic reset (e.g., setInterval clearing the Map
once per day) so chronic failures continue to resurface across long-lived
processes while preserving the existing sendMessage/webhook behavior and the
function signature of warnInvalidNumericField.

In `@coins/src/utils/servingLayer.ts`:
- Around line 155-160: The Redis read path currently uses
parseFloat(price.price) and can produce NaN for bad cached values; mirror the CH
fallback by coercing price.price to a Number (e.g., typeof check or Number())
and then guard with Number.isFinite before assigning to response[coin]; if the
coerced value is not finite, skip the entry (same behavior as the CH branch that
sets decimals/symbol and price only when priceNum is finite). Target the code
that calls parseFloat(price.price) and the response[coin] assignment to apply
this defensive check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b37a90e-849b-402f-9025-b6d8e51048e7

📥 Commits

Reviewing files that changed from the base of the PR and between 08eda57 and 14909c7.

📒 Files selected for processing (6)
  • coins/src/adapters/rwa/backed.ts
  • coins/src/adapters/utils/database.test.ts
  • coins/src/adapters/utils/database.ts
  • coins/src/api-spec.test.ts
  • coins/src/scripts/bootstrapRedis.ts
  • coins/src/utils/servingLayer.ts

Comment on lines +152 to +165
function coerceNumericField(
value: unknown,
field: string,
adapter: string,
allowUndefined: boolean,
): number | undefined {
if (value === undefined || value === null) {
if (!allowUndefined) warnInvalidNumericField(value, field, adapter, "missing");
return allowUndefined ? undefined : (Number(value) as number);
}
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) warnInvalidNumericField(value, field, adapter, "non-finite");
return n;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

NaN still propagates to write records for decimals and confidence.

When value can't be coerced, coerceNumericField returns NaN (or Number(null) === 0). The PR is intentionally warn-only for price, and the downstream guard in batchWriteWithAlerts (Line 473: isFinite(i.price) || i.redirect) filters NaN-priced items without a redirect — but:

  • NaN decimals is not filtered anywhere I can see and will reach DynamoDB/Kafka/ClickHouse.
  • NaN confidence does get dropped indirectly by filterWritesWithLowConfidence (NaN > threshold is false), but only if that filter runs upstream of batchWriteWithAlerts for the given adapter — not all adapter paths are guaranteed to call it.
  • A write carrying a redirect with a NaN price will still slip through the isFinite || redirect filter.

Since the stated goal is "prevent string-price regressions", consider extending the batchWriteWithAlerts filter at Line 473 to also require Number.isFinite(i.decimals ?? 0) (or drop the attribute when non-finite before write) and to check price finiteness even when a redirect is present. If that's intentionally deferred to keep this PR warn-only, please add a TODO referencing the follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coins/src/adapters/utils/database.ts` around lines 152 - 165,
coerceNumericField currently allows NaN (and coerces null to 0) for numeric
fields like decimals and confidence, so update the downstream write filter in
batchWriteWithAlerts (and/or sanitize outputs from coerceNumericField) to reject
or strip non-finite decimals and confidence before writing: ensure
batchWriteWithAlerts requires Number.isFinite(i.price) regardless of i.redirect
and add checks like Number.isFinite(i.decimals ?? 0) (or delete i.decimals when
non-finite) and similarly handle i.confidence, or if you intend to keep this PR
warn-only, add a TODO referencing a follow-up to enforce finite checks and strip
non-finite numeric fields; reference functions coerceNumericField,
batchWriteWithAlerts, and filterWritesWithLowConfidence when making the change.

Comment thread coins/src/adapters/utils/database.ts
Comment thread coins/src/api-spec.test.ts
Comment thread coins/src/api-spec.test.ts
Comment thread coins/src/api-spec.test.ts
- Resolve conflict in coins/src/utils/servingLayer.ts (keep coerced priceNum;
  apply same Number.isFinite guard to the Redis read path for parity).
- Drop merge-introduced duplicate `priceNum` in addToDBWritesList.
- coerceNumericField: split null vs undefined branches so behaviour is
  explicit (undefined -> NaN, null -> NaN when allowUndefined=false; both
  -> undefined when allowUndefined=true). Add TS overloads so callers don't
  need a non-null assertion when allowUndefined=false.
- Replace per-process dedup Set with a Map<string, number> that emits on
  threshold boundaries (1, 10, 100, 1000, 10000) so a chronically broken
  adapter keeps surfacing instead of going silent for the rest of the pod's
  lifetime.
- Export __resetNumericWarningsForTests for test isolation.
- Document NaN-on-decimals/confidence as warn-only-by-design with a TODO
  pointing at the follow-up filter tightening in batchWriteWithAlerts.
- database.test.ts: reset warnings in beforeEach; assert second threshold
  fires; add webhook-unset path test.
- api-spec.test.ts: SPYon guard now uses searchWidth=30d + expects coin to
  be defined (was silently passing when the token aged out); duplicate-
  casing test now asserts canonical-key collapse and timestamp uniqueness;
  /chart happy-path gates decimals/confidence asserts on `in` checks since
  both are optional in the spec.
Comment thread coins/src/adapters/rwa/backed.ts Outdated
for (const w of originals) {
const addressPart = w.PK.substring(w.PK.indexOf(":") + 1);
for (const extraChain of extraChains) {
writes.push({ ...w, PK: `asset#${extraChain}:${addressPart}` });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why not map the token on the other chains instead of writing the price everytime?

Comment thread coins/src/scripts/bootstrapRedis.ts Outdated
if (p.price && p.price !== "0") {
pipeline.set(`price:${p.canonical_id}`, JSON.stringify({ price: p.price, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.latest_ts || null }), "EX", PRICE_TTL);
}
if (!p.price || p.price === "0") continue;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

sometimes we set the price as 0 for blacklisted tokens?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
coins/src/api-spec.test.ts (1)

187-196: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Same silent-pass pattern flagged on SPYon survives in three other happy-path tests.

The earlier review fixed if (!coin) return;expect(coin).toBeDefined() for the SPYon regression guard, but identical guards still exist in:

  • Line 188 — batchHistorical happy path (USDC at 1700000000)
  • Line 255 — /chart/{coins} happy path
  • Line 335 — /prices/first/{coins} happy path

These are billed as "happy path" assertions, so if the live API ever omits the canonical USDC entry (transient outage, key-casing drift, response-shape change) all the assertions below the early-return are skipped and the test reports green. For the canonical-USDC happy paths, it’s safe to assert presence and let the test fail loudly:

📝 Proposed fixes
@@ batchHistorical happy path (around line 187-188)
       const coin = r.data.coins[USDC];
-      if (!coin) return;
+      expect(coin).toBeDefined();
       expect(typeof coin.symbol).toBe("string");
@@ /chart happy path (around line 254-255)
       const coin = r.data.coins[USDC];
-      if (!coin) return;
+      expect(coin).toBeDefined();
       expect(typeof coin.symbol).toBe("string");
@@ /prices/first happy path (around line 334-335)
       const coin = r.data.coins[USDC];
-      if (!coin) return;
+      expect(coin).toBeDefined();
       assertFiniteNumber(coin.price, "coin.price");

The if (r.data.coins[USDC]) guard at line 271 in the span=1 test has the same shape — happy to leave that one if it’s deliberately tolerant of edge cases, but worth a sanity check.

Also applies to: 254-266, 334-339

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@coins/src/api-spec.test.ts` around lines 187 - 196, Replace the silent
early-return guards that skip assertions when the canonical USDC entry is
missing by asserting presence instead: for each test block that does "const coin
= r.data.coins[USDC]; if (!coin) return;" (e.g., the batchHistorical happy path,
the /chart/{coins} happy path, and the /prices/first/{coins} happy path), change
the guard to expect(coin).toBeDefined() and then continue with the existing
type/array/assertFiniteNumber checks so failures surface loudly; leave the one
at the span=1 test only if its tolerant behavior is intentional after a quick
sanity check.
🧹 Nitpick comments (1)
coins/src/adapters/utils/database.test.ts (1)

151-167: 💤 Low value

Test name promises a console.error assertion that isn’t made.

The test is titled webhook unset: console.error still fires but no Discord call, but only asserts that mockSendMessage was not called — there’s no spy on console.error, so the “still fires” half of the contract isn’t actually verified. Either drop the claim from the title or add a jest.spyOn(console, "error") and assert it was invoked.

♻️ Proposed assertion
   test("webhook unset: console.error still fires but no Discord call", () => {
+    const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
     delete process.env.STALE_COINS_ADAPTERS_WEBHOOK;
     const writes: Write[] = [];
     addToDBWritesList(
       writes,
       "ethereum",
       "0xF99",
       1.0,
       18,
       "X",
       TS,
       "no-webhook-adapter",
       undefined as any,
     );
     expect(writes).toHaveLength(1);
     expect(mockSendMessage).not.toHaveBeenCalled();
+    expect(errSpy).toHaveBeenCalled();
+    errSpy.mockRestore();
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@coins/src/adapters/utils/database.test.ts` around lines 151 - 167, The test
"webhook unset: console.error still fires but no Discord call" currently only
checks mockSendMessage and doesn't verify console.error; update the test that
calls addToDBWritesList by either renaming the test to remove "console.error
still fires" or (preferred) add a spy via jest.spyOn(console, "error") before
invoking addToDBWritesList and assert that console.error was called (and
optionally restore the spy after); keep the existing assertion that
mockSendMessage was not called to ensure no Discord call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@coins/src/scripts/bootstrapRedis.ts`:
- Around line 89-94: Decide whether price 0 is a valid market price or a
blacklist sentinel; if it is a sentinel, change the guard in the loop that
processes page (the p.price check before Number(p.price) and subsequent
pipeline.set with PRICE_TTL in bootstrapRedis.ts) to also skip when priceNum ===
0, and make the identical change in coins/src/utils/servingLayer.ts (the code
path around the price parsing/assignment currently at lines ~222–225) so both
places consistently drop zero prices; otherwise (if zero is valid) add a brief
inline comment near the p.price check in bootstrapRedis.ts and next to the
servingLayer price handling explaining that price=0 is intentionally accepted.

---

Duplicate comments:
In `@coins/src/api-spec.test.ts`:
- Around line 187-196: Replace the silent early-return guards that skip
assertions when the canonical USDC entry is missing by asserting presence
instead: for each test block that does "const coin = r.data.coins[USDC]; if
(!coin) return;" (e.g., the batchHistorical happy path, the /chart/{coins} happy
path, and the /prices/first/{coins} happy path), change the guard to
expect(coin).toBeDefined() and then continue with the existing
type/array/assertFiniteNumber checks so failures surface loudly; leave the one
at the span=1 test only if its tolerant behavior is intentional after a quick
sanity check.

---

Nitpick comments:
In `@coins/src/adapters/utils/database.test.ts`:
- Around line 151-167: The test "webhook unset: console.error still fires but no
Discord call" currently only checks mockSendMessage and doesn't verify
console.error; update the test that calls addToDBWritesList by either renaming
the test to remove "console.error still fires" or (preferred) add a spy via
jest.spyOn(console, "error") before invoking addToDBWritesList and assert that
console.error was called (and optionally restore the spy after); keep the
existing assertion that mockSendMessage was not called to ensure no Discord
call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a32abe9-e9ce-42ad-bcb8-46b97eb05f10

📥 Commits

Reviewing files that changed from the base of the PR and between 14909c7 and 0ed5550.

📒 Files selected for processing (7)
  • coins/src/adapters/rwa/backed.ts
  • coins/src/adapters/tokenMapping.json
  • coins/src/adapters/utils/database.test.ts
  • coins/src/adapters/utils/database.ts
  • coins/src/api-spec.test.ts
  • coins/src/scripts/bootstrapRedis.ts
  • coins/src/utils/servingLayer.ts
💤 Files with no reviewable changes (1)
  • coins/src/adapters/rwa/backed.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • coins/src/adapters/utils/database.ts
  • coins/src/utils/servingLayer.ts
👮 Files not reviewed due to content moderation or server errors (1)
  • coins/src/adapters/tokenMapping.json

Comment on lines 89 to 94
for (const p of page) {
if (p.price && p.price !== "0") {
pipeline.set(`price:${p.canonical_id}`, JSON.stringify({ price: p.price, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.latest_ts || null }), "EX", PRICE_TTL);
}
if (!p.price) continue;
const priceNum = Number(p.price);
if (!Number.isFinite(priceNum)) continue;
pipeline.set(`price:${p.canonical_id}`, JSON.stringify({ price: priceNum, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.latest_ts || null }), "EX", PRICE_TTL);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether '0' is used as a blacklist sentinel and how the serving layer / consumers treat price=0 vs missing key.
rg -nP -C3 "blacklist" coins/src
rg -nP -C3 'price\s*===?\s*0\b|price\s*===?\s*"0"' coins/src
rg -nP -C3 "price:\$\{" coins/src
# Find the matching write path in servingLayer.ts referenced by the PR summary.
rg -nP -C5 'priceNum|Number\(p\.price\)|Number\(.*price' coins/src/utils/servingLayer.ts

Repository: DefiLlama/defillama-server

Length of output: 9187


🏁 Script executed:

# Check if there are any explicit zero-price handling in consumers or downstream code
rg -nP "price.*===?\s*0|price.*===?\s*['\"]0['\"]" coins/src --type ts --type js -A 2 -B 2

# Look for how the price key from Redis is consumed downstream
rg -nP "price:\\\$|price:" coins/src --type ts --type js -C 2 | head -100

# Check if bootstrapRedis.ts actually has a different condition elsewhere (to confirm change)
cat coins/src/scripts/bootstrapRedis.ts | head -100

Repository: DefiLlama/defillama-server

Length of output: 12920


Inconsistency: price=0 behavior differs from historical guard but matches existing servingLayer pattern.

The code at lines 89–94 skips empty prices via if (!p.price) but accepts price="0" (string zero is truthy, so !p.price is false). After conversion, Number("0") is finite and gets written to Redis.

This is identical to the pattern already in coins/src/utils/servingLayer.ts:222–225, which also lacks an explicit zero-skip. If this is a change from an older p.price !== "0" guard (per the PR summary), the same change should apply to servingLayer for consistency—or document why the difference exists.

However, consumers in servingLayer don't reject price: 0; they simply skip missing keys. If price zero is intentional (legitimate markets with zero-USD assets exist), the current code is fine and should be documented. If zero remains a sentinel for blacklisted tokens, add || priceNum === 0 to the guard in both locations:

If 0 means "blacklisted", add guard here and in servingLayer.ts
       if (!p.price) continue;
       const priceNum = Number(p.price);
-      if (!Number.isFinite(priceNum)) continue;
+      if (!Number.isFinite(priceNum) || priceNum === 0) continue;
       pipeline.set(`price:${p.canonical_id}`, ...

Clarify: Is price zero a valid market price or a blacklist sentinel? If sentinel, it should skip here and in servingLayer.ts. Otherwise, document the intent to accept zero-USD prices.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@coins/src/scripts/bootstrapRedis.ts` around lines 89 - 94, Decide whether
price 0 is a valid market price or a blacklist sentinel; if it is a sentinel,
change the guard in the loop that processes page (the p.price check before
Number(p.price) and subsequent pipeline.set with PRICE_TTL in bootstrapRedis.ts)
to also skip when priceNum === 0, and make the identical change in
coins/src/utils/servingLayer.ts (the code path around the price
parsing/assignment currently at lines ~222–225) so both places consistently drop
zero prices; otherwise (if zero is valid) add a brief inline comment near the
p.price check in bootstrapRedis.ts and next to the servingLayer price handling
explaining that price=0 is intentionally accepted.

@waynebruce0x waynebruce0x marked this pull request as draft May 8, 2026 12:10
@noateden noateden self-requested a review May 12, 2026 06:39
@waynebruce0x waynebruce0x marked this pull request as ready for review May 12, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants