Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions .changeset/graceful-unresolvable-symbols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@chainlink/mobula-state-adapter': patch
---

Fix: Gracefully handle unresolvable symbols and add case-insensitive request handling.

1. **Graceful Error Handling**: The adapter now returns `undefined` instead of throwing errors when symbols cannot be resolved to asset IDs in `getAssetId()` and `getQuoteId()`. The subscription message builder skips subscriptions for unresolvable symbols with a warning, preventing the EA from failing to establish WebSocket connections due to background processing errors.

2. **Case-Insensitive Requests**: Added `requestTransforms` to uppercase `base` and `quote` parameters and resolve them via `includes.json` before processing. This allows lowercase requests (e.g., `btc/usd`) to work seamlessly.
21 changes: 1 addition & 20 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
2 changes: 1 addition & 1 deletion packages/sources/mobula-state/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"typescript": "5.8.3"
},
"dependencies": {
"@chainlink/external-adapter-framework": "2.11.0",
"@chainlink/external-adapter-framework": "2.11.1",
"tslib": "2.4.1"
}
}
4 changes: 2 additions & 2 deletions packages/sources/mobula-state/src/config/includes.json
Original file line number Diff line number Diff line change
Expand Up @@ -1335,7 +1335,7 @@
"to": "USD",
"includes": [
{
"from": "102502051",
"from": "102479784",
"to": "USD",
"inverse": false
}
Expand Down Expand Up @@ -2028,7 +2028,7 @@
"to": "USD",
"includes": [
{
"from": "102483737",
"from": "102483988",
"to": "USD",
"inverse": false
}
Expand Down
9 changes: 9 additions & 0 deletions packages/sources/mobula-state/src/endpoint/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@ export const endpoint = new CryptoPriceEndpoint({
aliases: ['state', 'crypto'],
transport: wsTransport,
inputParameters,
requestTransforms: [
// Uppercase base/quote for case-insensitive lookups
// Framework will then apply includes.json transformations
(req) => {
req.requestContext.data.base = req.requestContext.data.base.toUpperCase()
req.requestContext.data.quote = req.requestContext.data.quote.toUpperCase()
return req
},
],
})
33 changes: 22 additions & 11 deletions packages/sources/mobula-state/src/transport/price.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { WebsocketReverseMappingTransport } from '@chainlink/external-adapter-framework/transports'
import { ProviderResult } from '@chainlink/external-adapter-framework/util'
import { ProviderResult, makeLogger } from '@chainlink/external-adapter-framework/util'
import { BaseEndpointTypes } from '../endpoint/price'

const logger = makeLogger('MobulaStateTransportPrice')

export interface WSResponse {
timestamp: number
price: number
Expand Down Expand Up @@ -41,21 +43,21 @@ const QUOTE_ASSET_IDS: Record<string, number> = {
}

// Get asset ID from the resolved symbol (after framework includes are applied)
const getAssetId = (symbol: string): number => {
// Returns undefined if the symbol cannot be resolved
const getAssetId = (symbol: string): number | undefined => {
const parsed = Number.parseInt(symbol, 10)

if (!Number.isNaN(parsed)) {
return parsed
}

throw new Error(
`Unable to resolve asset ID for symbol: ${symbol}. Please ensure includes.json is configured for this symbol.`,
)
// Return undefined instead of throwing - allows graceful handling
return undefined
}

// Map quote symbols to IDs
// Returns: number for crypto quotes, 'USD' string for USD, or throws for unmapped symbols
const getQuoteId = (quote: string): number | 'USD' => {
// Returns: number for crypto quotes, 'USD' string for USD, or undefined for unmapped symbols
const getQuoteId = (quote: string): number | 'USD' | undefined => {
const upperQuote = quote.toUpperCase()

// USD doesn't need a numeric quote_id in the API, return string for composite key
Expand All @@ -75,10 +77,8 @@ const getQuoteId = (quote: string): number | 'USD' => {
return parsed
}

// Quote not found - must be in includes.json or use hardcoded quote
throw new Error(
`Unable to resolve quote ID for symbol: ${quote}. Please add a base/quote pair to includes.json or use a supported quote currency (BTC, ETH, SOL, HYPE, S, BBSOL, USD).`,
)
// Quote not found - return undefined to allow graceful handling
return undefined
}

export const wsTransport: WebsocketReverseMappingTransport<WsTransportTypes, string> =
Expand Down Expand Up @@ -114,6 +114,17 @@ export const wsTransport: WebsocketReverseMappingTransport<WsTransportTypes, str
const assetId = getAssetId(params.base)
const quoteId = getQuoteId(params.quote)

// Check if we successfully resolved both IDs
if (assetId === undefined || quoteId === undefined) {
// Log warning but don't crash - this allows other subscriptions to continue
logger.warn(
`Skipping subscription for ${params.base}/${params.quote}: ` +
`Unable to resolve ${assetId === undefined ? 'base asset ID' : 'quote ID'}. ` +
`Ensure this pair is in includes.json or use direct asset IDs.`,
)
return undefined
}

// Store mapping using composite key: baseID-quoteID -> original user params
const compositeKey = `${assetId}-${quoteId}`
wsTransport.setReverseMapping(compositeKey, params)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ exports[`websocket funding rate endpoint have data should return success 1`] = `
"result": null,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 15158,
"providerDataStreamEstablishedUnixMs": 15150,
"providerDataReceivedUnixMs": 16168,
"providerDataStreamEstablishedUnixMs": 16160,
},
}
`;
Expand All @@ -26,8 +26,8 @@ exports[`websocket funding rate endpoint have partial data return success 1`] =
"result": null,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 16168,
"providerDataStreamEstablishedUnixMs": 15150,
"providerDataReceivedUnixMs": 17178,
"providerDataStreamEstablishedUnixMs": 16160,
},
}
`;
Expand All @@ -43,6 +43,47 @@ exports[`websocket funding rate endpoint no data should return failure 1`] = `
}
`;

exports[`websocket graceful error handling and case-insensitive requests should continue working after receiving invalid symbol requests 1`] = `
{
"data": {
"result": 2950.12,
},
"result": 2950.12,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 8088,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
}
`;

exports[`websocket graceful error handling and case-insensitive requests should handle lowercase symbols (case-insensitive) 1`] = `
{
"data": {
"result": 1.0012,
},
"result": 1.0012,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 7078,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
}
`;

exports[`websocket graceful error handling and case-insensitive requests should return 504 for unresolvable symbols without crashing 1`] = `
{
"error": {
"message": "The EA has not received any values from the Data Provider for the requested data yet. Retry after a short delay, and if the problem persists raise this issue in the relevant channels.",
"name": "AdapterError",
},
"status": "errored",
"statusCode": 504,
}
`;

exports[`websocket price endpoint CBETH/ETH should return success - tests includes.json + hardcoded ETH quote 1`] = `
{
"data": {
Expand All @@ -51,7 +92,7 @@ exports[`websocket price endpoint CBETH/ETH should return success - tests includ
"result": 1.0456,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 8088,
"providerDataReceivedUnixMs": 10108,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand All @@ -66,7 +107,7 @@ exports[`websocket price endpoint EZETH/ETH should return success - tests hardco
"result": 1.0612,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 7078,
"providerDataReceivedUnixMs": 9098,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand Down Expand Up @@ -96,7 +137,7 @@ exports[`websocket price endpoint GHO/USD should return success - tests includes
"result": 1.0012,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 10108,
"providerDataReceivedUnixMs": 7078,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand All @@ -111,7 +152,7 @@ exports[`websocket price endpoint LBTC/BTC should return success - tests include
"result": 0.9985,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 9098,
"providerDataReceivedUnixMs": 11118,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand Down Expand Up @@ -171,7 +212,7 @@ exports[`websocket price endpoint direct asset IDs with crypto quote should retu
"result": 1.0456,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 8088,
"providerDataReceivedUnixMs": 10108,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand All @@ -186,7 +227,7 @@ exports[`websocket price endpoint direct asset IDs with hardcoded SOL quote shou
"result": 3.456,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 9098,
"providerDataReceivedUnixMs": 11118,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand All @@ -201,7 +242,7 @@ exports[`websocket price endpoint direct base and quote asset IDs should return
"result": 0.0000102,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 10108,
"providerDataReceivedUnixMs": 7078,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand All @@ -216,7 +257,7 @@ exports[`websocket price endpoint direct base asset ID should return success 1`]
"result": 4233.15,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 7078,
"providerDataReceivedUnixMs": 9098,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand All @@ -231,7 +272,7 @@ exports[`websocket price endpoint quote override with asset ID should return suc
"result": 1.0612,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 7078,
"providerDataReceivedUnixMs": 9098,
"providerDataStreamEstablishedUnixMs": 1010,
"providerIndicatedTimeUnixMs": 1514764861500,
},
Expand Down
54 changes: 52 additions & 2 deletions packages/sources/mobula-state/test/integration/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,21 @@ describe('websocket', () => {
endpoint: 'price',
transport: 'ws',
})
await testAdapter.waitForCache(6) // Wait for all primed pairs to be cached
})
// Prime cache for graceful error handling tests
await testAdapter.request({
base: 'gho', // Lowercase test - should get uppercased to GHO
quote: 'usd',
endpoint: 'price',
transport: 'ws',
})
await testAdapter.request({
base: 'RSETH', // For graceful error handling continuation test
quote: 'USD',
endpoint: 'price',
transport: 'ws',
})
await testAdapter.waitForCache(8) // Wait for all primed pairs to be cached (7 new + 1 initial = 8 total, gho/usd doesn't create a new entry since it uppercases to GHO/USD)
}, 30000)

afterAll(async () => {
setEnvVariables(oldEnv)
Expand Down Expand Up @@ -287,4 +300,41 @@ describe('websocket', () => {
expect(response.json()).toMatchSnapshot()
})
})

describe('graceful error handling and case-insensitive requests', () => {
it('should handle lowercase symbols (case-insensitive)', async () => {
const response = await testAdapter.request({
base: 'gho',
quote: 'usd',
})
expect(response.statusCode).toBe(200)
expect(response.json()).toMatchSnapshot()
})

it('should return 504 for unresolvable symbols without crashing', async () => {
const response = await testAdapter.request({
base: 'FAKESYMBOL',
quote: 'USD',
})
expect(response.statusCode).toBe(504)
expect(response.json()).toMatchSnapshot()
})

it('should continue working after receiving invalid symbol requests', async () => {
// First, make an invalid request
const invalidResponse = await testAdapter.request({
base: 'INVALIDSYMBOL',
quote: 'USD',
})
expect(invalidResponse.statusCode).toBe(504)

// Then verify valid requests still work
const validResponse = await testAdapter.request({
base: 'RSETH',
quote: 'USD',
})
expect(validResponse.statusCode).toBe(200)
expect(validResponse.json()).toMatchSnapshot()
})
})
})
Loading
Loading