Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions packages/sources/blocksize-capital/CASE_NORMALIZATION_ROLLOUT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# 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`).

## 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

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: 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:

- allium-state
- twosigma
- expand-network
- tp
- icap

Deploy, soak 1-2 days.

### Step 5: Batch B (medium risk)

Bump framework version in:

- finage
- coinpaprika
- coinmetrics
- dxfeed
- gsr

Deploy, soak 1-2 days.

### Step 6: Batch C (higher risk)

Bump framework version in:

- tradermade
- finalto
- elwood
- mobula-state (funding-rate)

Deploy, soak 1-2 days.

## 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)

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).
Original file line number Diff line number Diff line change
@@ -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<never>,
})

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)
})
})
16 changes: 8 additions & 8 deletions packages/sources/tiingo-state/src/endpoint/utils.ts
Original file line number Diff line number Diff line change
@@ -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() {}
19 changes: 9 additions & 10 deletions packages/sources/tiingo/src/endpoint/utils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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() {}
Loading