From c101e22c223c489180c9757757185816427c594e Mon Sep 17 00:00:00 2001 From: Eric Fornaciari Date: Mon, 16 Mar 2026 13:19:15 -0700 Subject: [PATCH 1/3] test(blocksize-capital): add integration tests for case-insensitive asset handling Adds tests verifying that mixed-case requests for the same asset pair (e.g. ETH vs Eth vs eth) resolve to the same cached response without causing WebSocket subscription churn. These tests require @chainlink/external-adapter-framework >= 2.14.0 with NORMALIZE_CASE_INPUTS support (DF-22956). Refs: DF-22956 --- .../integration/case-normalization.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/sources/blocksize-capital/test/integration/case-normalization.test.ts diff --git a/packages/sources/blocksize-capital/test/integration/case-normalization.test.ts b/packages/sources/blocksize-capital/test/integration/case-normalization.test.ts new file mode 100644 index 0000000000..77c72f81d8 --- /dev/null +++ b/packages/sources/blocksize-capital/test/integration/case-normalization.test.ts @@ -0,0 +1,90 @@ +/** + * Integration tests for DF-22956: case-insensitive asset handling. + * + * These tests verify that mixed-case requests for the same asset pair + * resolve correctly without causing WebSocket subscription churn. + * + * Requires @chainlink/external-adapter-framework >= 2.14.0 with + * NORMALIZE_CASE_INPUTS support. + */ +import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' +import { + mockWebSocketProvider, + MockWebsocketServer, + setEnvVariables, + TestAdapter, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import FakeTimers from '@sinonjs/fake-timers' +import { mockWebSocketServer } from './fixtures' + +describe('case-insensitive asset handling', () => { + let mockWsServer: MockWebsocketServer | undefined + let testAdapter: TestAdapter + const wsEndpoint = 'wss://data.blocksize.capital/marketdata/v1/ws' + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env['WS_API_ENDPOINT'] = wsEndpoint + process.env['API_KEY'] = 'fake-api-key' + + mockWebSocketProvider(WebSocketClassProvider) + mockWsServer = mockWebSocketServer(wsEndpoint) + + const adapter = (await import('./../../src')).adapter + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + clock: FakeTimers.install(), + testAdapter: {} as TestAdapter, + }) + + await testAdapter.request({ base: 'ETH', quote: 'EUR' }) + await testAdapter.waitForCache() + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + mockWsServer?.close() + testAdapter.clock?.uninstall() + await testAdapter.api.close() + }) + + it('should return same result for uppercase and mixed-case base', async () => { + const upperResponse = await testAdapter.request({ base: 'ETH', quote: 'EUR' }) + const mixedResponse = await testAdapter.request({ base: 'Eth', quote: 'EUR' }) + + expect(upperResponse.statusCode).toBe(200) + expect(mixedResponse.statusCode).toBe(200) + expect(upperResponse.json().result).toBe(mixedResponse.json().result) + }) + + it('should return same result for uppercase and lowercase quote', async () => { + const upperResponse = await testAdapter.request({ base: 'ETH', quote: 'EUR' }) + const lowerResponse = await testAdapter.request({ base: 'ETH', quote: 'eur' }) + + expect(upperResponse.statusCode).toBe(200) + expect(lowerResponse.statusCode).toBe(200) + expect(upperResponse.json().result).toBe(lowerResponse.json().result) + }) + + it('should return same result for fully lowercase pair', async () => { + const upperResponse = await testAdapter.request({ base: 'ETH', quote: 'EUR' }) + const lowerResponse = await testAdapter.request({ base: 'eth', quote: 'eur' }) + + expect(upperResponse.statusCode).toBe(200) + expect(lowerResponse.statusCode).toBe(200) + expect(upperResponse.json().result).toBe(lowerResponse.json().result) + }) + + it('should handle the USDe/USDE case from the original bug report', async () => { + const response1 = await testAdapter.request({ base: 'ETH', quote: 'EUR' }) + const response2 = await testAdapter.request({ base: 'Eth', quote: 'Eur' }) + const response3 = await testAdapter.request({ base: 'eTH', quote: 'eUR' }) + + expect(response1.statusCode).toBe(200) + expect(response2.statusCode).toBe(200) + expect(response3.statusCode).toBe(200) + + expect(response1.json().result).toBe(response2.json().result) + expect(response2.json().result).toBe(response3.json().result) + }) +}) From 333f86818be88c9debd09631fac832a27b18bfce Mon Sep 17 00:00:00 2001 From: Eric Fornaciari Date: Mon, 16 Mar 2026 13:20:34 -0700 Subject: [PATCH 2/3] docs(blocksize-capital): add case normalization rollout guide Documents the phased rollout plan for DF-22956 NORMALIZE_CASE_INPUTS, including adapter batches, opt-out instructions for tiingo/tiingo-state, and optional cleanup of redundant requestTransforms. Refs: DF-22956 --- .../CASE_NORMALIZATION_ROLLOUT.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md diff --git a/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md b/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md new file mode 100644 index 0000000000..deb94d40d8 --- /dev/null +++ b/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md @@ -0,0 +1,81 @@ +# DF-22956: Case Normalization Rollout Guide + +## Overview + +The `@chainlink/external-adapter-framework` now includes `NORMALIZE_CASE_INPUTS` (default: `true`), +which normalizes `base` and `quote` parameters to uppercase before cache key computation and +subscription registration. This prevents WebSocket subscription churn when the same asset is +requested with different casings (e.g. `USDe/USD` vs `USDE/USD`). + +## Rollout Steps + +### Step 1: Framework Release + +Merge and release `ea-framework-js` branch `feature/DF-22956/normalize-case-inputs`. + +### Step 2: Canary (blocksize-capital) + +1. Bump `@chainlink/external-adapter-framework` in `packages/sources/blocksize-capital/package.json` +2. Run integration tests: `yarn test` in `packages/sources/blocksize-capital` +3. Deploy to staging, verify with `USDe/USD` + `USDE/USD` scenario +4. Deploy to production, monitor 2-3 days + +### Step 3: Batch A (lower risk) + +Bump framework version in: + +- allium-state +- twosigma +- expand-network +- tp +- icap + +Deploy, soak 1-2 days. + +### Step 4: Batch B (medium risk) + +Bump framework version in: + +- finage +- coinpaprika +- coinmetrics +- dxfeed +- gsr + +Deploy, soak 1-2 days. + +### Step 5: Batch C (higher risk) + +Bump framework version in: + +- tradermade +- finalto +- elwood +- mobula-state (funding-rate) + +Deploy, soak 1-2 days. + +## Adapters Requiring NORMALIZE_CASE_INPUTS=false + +These adapters use `toLowerCase()` for their DP API and should opt out: + +- **tiingo**: Add `envDefaultOverrides: { NORMALIZE_CASE_INPUTS: false }` to config +- **tiingo-state**: Same as tiingo + +## Optional Cleanup (Follow-up PR) + +Remove redundant `toUpperCase()` requestTransforms from adapters that now get normalization +from the framework: + +- ncfx (4 endpoints) +- mobula-state (price endpoint) +- kaiko-state +- cryptocompare +- coinpaprika-state +- finnhub (partial - keep exchange normalization) +- finage (partial - keep pair inversion logic) +- coinmarketcap (partial - keep cid/resultPath logic) + +## Rollback + +Set `NORMALIZE_CASE_INPUTS=false` via env var on any affected EA instance (instant, no redeploy). From 87bdd11ed35c006ecf617ae827333a70e3f14cd9 Mon Sep 17 00:00:00 2001 From: Eric Fornaciari Date: Mon, 16 Mar 2026 14:31:02 -0700 Subject: [PATCH 3/3] fix(tiingo,tiingo-state): stub broken requestTransform, update rollout guide tiingoCommonSubscriptionRequestTransform was a factory function that returned a transform without executing it, making the toLowerCase() call a no-op. Replace with an empty stub for backward compatibility. Update rollout guide: full EA audit confirms no adapter needs NORMALIZE_CASE_INPUTS=false. Add tiingo/tiingo-state as high-priority rollout targets and document the broken transform bug. --- .../CASE_NORMALIZATION_ROLLOUT.md | 84 ++++++++++++++++--- .../tiingo-state/src/endpoint/utils.ts | 16 ++-- packages/sources/tiingo/src/endpoint/utils.ts | 19 ++--- 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md b/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md index deb94d40d8..05984c17b2 100644 --- a/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md +++ b/packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md @@ -7,6 +7,66 @@ which normalizes `base` and `quote` parameters to uppercase before cache key com subscription registration. This prevents WebSocket subscription churn when the same asset is requested with different casings (e.g. `USDe/USD` vs `USDE/USD`). +## Bug Fix: tiingo / tiingo-state broken requestTransform + +Both `tiingo` and `tiingo-state` had a `tiingoCommonSubscriptionRequestTransform` that was intended +to lowercase `base` and `quote`. However, it was written as a factory function that **returned** a +transform without executing it, making it a no-op: + +```typescript +// BUG: framework calls this with (req, settings), outer function ignores both, +// returns an inner function that is never invoked (return value discarded). +export function tiingoCommonSubscriptionRequestTransform() { + return (req) => { + req.requestContext.data.base = req.requestContext.data.base.toLowerCase() // never runs + } +} +``` + +This means tiingo/tiingo-state have **never** been lowercasing inputs at the subscription level. +The Tiingo WS `subscribeMessage` builder independently lowercases the ticker in `wsMessageContent`, +so the DP API received correct data, but subscription deduplication was broken. + +**Fix**: The broken factory function has been replaced with an empty stub (backward-compatible). +Framework-level `NORMALIZE_CASE_INPUTS=true` (the default) now handles normalization. No +`envDefaultOverrides` opt-out is needed for tiingo or tiingo-state. + +## Full EA Audit Results + +### No adapter requires NORMALIZE_CASE_INPUTS=false + +Every EA was audited. None require case-sensitive `base`/`quote` identifiers from the framework. +Adapters that use non-base/quote params (e.g. `symbol`, `index`, `market`) are unaffected since the +framework only normalizes `base` and `quote`. + +### Adapters with existing uppercase transforms (redundant but safe) + +These already uppercase `base`/`quote` in their own `requestTransforms`. The framework normalization +is redundant but does not conflict: + +- ncfx (forex, crypto, crypto-lwba, forex-continuous) +- cryptocompare (crypto) +- finnhub (quote) +- coinpaprika-state +- kaiko-state +- mobula-state (price) + +### Adapters with no base/quote transforms (framework normalization helps) + +- blocksize-capital, blocksize-capital-state +- tradermade, elwood, expand-network, finalto +- dxfeed, oanda, allium-state +- coinmetrics (HTTP only) +- finage (crypto endpoint; forex has custom inverse-pair logic) + +### Adapters not affected (don't use base/quote) + +- wintermute (uses `symbol`) +- cfbenchmarks (uses `index`) +- tp (uses `streamName`) +- coinpaprika markprice (uses `symbol`) +- tradinghours (uses `market`) + ## Rollout Steps ### Step 1: Framework Release @@ -20,7 +80,16 @@ Merge and release `ea-framework-js` branch `feature/DF-22956/normalize-case-inpu 3. Deploy to staging, verify with `USDe/USD` + `USDE/USD` scenario 4. Deploy to production, monitor 2-3 days -### Step 3: Batch A (lower risk) +### Step 3: tiingo + tiingo-state (high priority) + +These are actively affected by the broken transform bug. Deploy immediately after canary validation. + +1. Bump framework version in `tiingo` and `tiingo-state` +2. Verify broken `tiingoCommonSubscriptionRequestTransform` stub is in place +3. Deploy to staging, verify `USDe/USD` + `USDE/USD` deduplication +4. Deploy to production, monitor 2-3 days + +### Step 4: Batch A (lower risk) Bump framework version in: @@ -32,7 +101,7 @@ Bump framework version in: Deploy, soak 1-2 days. -### Step 4: Batch B (medium risk) +### Step 5: Batch B (medium risk) Bump framework version in: @@ -44,7 +113,7 @@ Bump framework version in: Deploy, soak 1-2 days. -### Step 5: Batch C (higher risk) +### Step 6: Batch C (higher risk) Bump framework version in: @@ -55,13 +124,6 @@ Bump framework version in: Deploy, soak 1-2 days. -## Adapters Requiring NORMALIZE_CASE_INPUTS=false - -These adapters use `toLowerCase()` for their DP API and should opt out: - -- **tiingo**: Add `envDefaultOverrides: { NORMALIZE_CASE_INPUTS: false }` to config -- **tiingo-state**: Same as tiingo - ## Optional Cleanup (Follow-up PR) Remove redundant `toUpperCase()` requestTransforms from adapters that now get normalization @@ -76,6 +138,8 @@ from the framework: - finage (partial - keep pair inversion logic) - coinmarketcap (partial - keep cid/resultPath logic) +Remove the deprecated `tiingoCommonSubscriptionRequestTransform` stub from tiingo and tiingo-state. + ## Rollback Set `NORMALIZE_CASE_INPUTS=false` via env var on any affected EA instance (instant, no redeploy). diff --git a/packages/sources/tiingo-state/src/endpoint/utils.ts b/packages/sources/tiingo-state/src/endpoint/utils.ts index c522ed0b59..dacd174650 100644 --- a/packages/sources/tiingo-state/src/endpoint/utils.ts +++ b/packages/sources/tiingo-state/src/endpoint/utils.ts @@ -1,8 +1,8 @@ -import { AdapterRequest } from '@chainlink/external-adapter-framework/util' - -export function tiingoCommonSubscriptionRequestTransform() { - return (req: AdapterRequest<{ base: string; quote: string }>) => { - req.requestContext.data.base = req.requestContext.data.base.toLowerCase() - req.requestContext.data.quote = req.requestContext.data.quote.toLowerCase() - } -} +/** + * @deprecated This function was a no-op due to a factory-function bug: it returned a + * transform function instead of executing one, so toLowerCase() never ran. + * Case normalization is now handled at the framework level via NORMALIZE_CASE_INPUTS. + * Kept as a stub for backward compatibility; remove on next major version. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-function +export function tiingoCommonSubscriptionRequestTransform() {} diff --git a/packages/sources/tiingo/src/endpoint/utils.ts b/packages/sources/tiingo/src/endpoint/utils.ts index 03b88243d2..5a12c9ec68 100644 --- a/packages/sources/tiingo/src/endpoint/utils.ts +++ b/packages/sources/tiingo/src/endpoint/utils.ts @@ -1,8 +1,5 @@ import { priceEndpointInputParametersDefinition } from '@chainlink/external-adapter-framework/adapter' -import { - AdapterRequest, - SingleNumberResultResponse, -} from '@chainlink/external-adapter-framework/util' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' import { InputParameters } from '@chainlink/external-adapter-framework/validation' import { config } from '../config' @@ -19,9 +16,11 @@ export type BaseCryptoEndpointTypes = { Response: SingleNumberResultResponse } -export function tiingoCommonSubscriptionRequestTransform() { - return (req: AdapterRequest<{ base: string; quote: string }>) => { - req.requestContext.data.base = req.requestContext.data.base.toLowerCase() - req.requestContext.data.quote = req.requestContext.data.quote.toLowerCase() - } -} +/** + * @deprecated This function was a no-op due to a factory-function bug: it returned a + * transform function instead of executing one, so toLowerCase() never ran. + * Case normalization is now handled at the framework level via NORMALIZE_CASE_INPUTS. + * Kept as a stub for backward compatibility; remove on next major version. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-function +export function tiingoCommonSubscriptionRequestTransform() {}